diff --git a/Cargo.lock b/Cargo.lock index 93ad2d1f..52acc031 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,6 +238,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cargo-bp-script" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "270ddcb68d819bdb04ec316aaa5ff82b95b798323fb8598c8d9a6142350ae944" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -2429,6 +2440,7 @@ dependencies = [ "anyhow", "assert_matches", "bytes", + "cargo-bp-script", "cargo_metadata", "chrono", "clap", @@ -2498,6 +2510,7 @@ version = "0.1.0" dependencies = [ "acpr", "anyhow", + "cargo-bp-script", "clap", "sacp", "sacp-tokio", diff --git a/Cargo.toml b/Cargo.toml index 16679fba..85485188 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ dialoguer = "0.12.0" toml_edit = "0.25.11" url = "2.5.8" symposium-install = { version = "0.1.0", path = "symposium-install", features = ["clap"] } +cargo-bp-script = "0.1.0" [dev-dependencies] diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 5bbd37f5..5cd3f301 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -73,6 +73,10 @@ Manages `state.toml` in the config directory. Tracks the semver of the binary th Implements `cargo agents self-update`. Queries the registry for the latest published version via `cargo search`, then installs it via `cargo install symposium --force`. Also provides `re_exec()` which replaces the current process with the newly installed binary (Unix `exec`, spawn-and-exit on Windows) — used by the `auto-update = "on"` startup path. Contains `maybe_warn_for_update()` (sync, for the `warn` library path) and `maybe_check_for_update()` (async, for the binary `on` + re-exec path). +### `crate_sources/` — workspace dependency discovery and fetching + +Houses `WorkspaceCrate` (the representation of a crate in the workspace graph) and the `RustCrateFetch` builder for downloading crate sources. `workspace_crates()` runs `cargo metadata` to collect direct dependencies. `discover_battery_packs()` is the async companion: it acquires the `cargo-bp` binary into symposium's binary cache (via `acquire_source`), then runs `cargo bp status --json` (using the `cargo-bp-script` crate) to find installed battery packs. Battery pack crates (names ending in `-battery-pack`) aren't normal Cargo dependencies — they're managed by the `cargo bp` tool — but plugin predicates reference them by name, so `sync` merges them into the workspace crate list for matching purposes. If acquisition or execution fails, battery pack discovery is silently skipped. + ### `crate_command.rs` — crate source lookup Contains `dispatch_crate()`, which resolves a crate's version and fetches its source code. Called by the CLI's `crate-info` command. Path dependencies are resolved to their local source directory via `WorkspaceCrate.path`. diff --git a/src/config.rs b/src/config.rs index a5ef7bff..5794dad9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -202,6 +202,7 @@ pub struct Symposium { cache_dir: PathBuf, home_dir: PathBuf, cargo_override: Option, + cargo_bp_override: Option, } impl Symposium { @@ -225,6 +226,7 @@ impl Symposium { // init_logging() is called after construction. let cargo_override = env::var("SYMPOSIUM_CARGO").ok().map(PathBuf::from); + let cargo_bp_override = env::var("SYMPOSIUM_CARGO_BP").ok().map(PathBuf::from); Self { config, @@ -232,6 +234,7 @@ impl Symposium { cache_dir, home_dir, cargo_override, + cargo_bp_override, } } @@ -257,6 +260,7 @@ impl Symposium { cache_dir, home_dir, cargo_override: None, + cargo_bp_override: None, } } @@ -285,6 +289,17 @@ impl Symposium { self.cargo_override = Some(path); } + /// Return the `cargo-bp` binary override path, if configured. + pub fn cargo_bp_override(&self) -> Option<&Path> { + self.cargo_bp_override.as_deref() + } + + /// Override the `cargo-bp` binary path (test-only). + #[doc(hidden)] + pub fn set_cargo_bp_override(&mut self, path: PathBuf) { + self.cargo_bp_override = Some(path); + } + /// Initialize logging. Call once at startup. pub fn init_logging(&self) { use std::fs::OpenOptions; diff --git a/src/crate_sources/battery_pack.rs b/src/crate_sources/battery_pack.rs new file mode 100644 index 00000000..9475fd09 --- /dev/null +++ b/src/crate_sources/battery_pack.rs @@ -0,0 +1,156 @@ +//! Battery pack discovery via `cargo bp status --json`. +//! +//! Battery packs are crate bundles managed by the `cargo bp` tool rather than +//! declared as normal Cargo dependencies. To let plugin predicates reference +//! them by name (e.g. `crates: cli-battery-pack`), we run `cargo bp status` +//! and inject the results into the workspace crate list. + +use std::path::Path; + +use crate::config::Symposium; + +use super::list::WorkspaceCrate; + +/// Acquire the `cargo-bp` binary (installing if needed) and run +/// `cargo bp status --json` to discover installed battery packs. +/// +/// Returns one `WorkspaceCrate` per installed battery pack. These are +/// virtual entries — battery packs aren't normal Cargo dependencies, but +/// plugin predicates reference them by crate name so they must appear in +/// the workspace crate list for matching to work. +/// +/// If acquisition or execution fails, returns an empty list (logged at +/// debug level) so the rest of sync proceeds unaffected. +pub async fn discover_battery_packs(sym: &Symposium, cwd: &Path) -> Vec { + let binary_path = if let Some(override_path) = sym.cargo_bp_override() { + override_path.to_path_buf() + } else { + match acquire_cargo_bp(sym).await { + Ok(path) => path, + Err(e) => { + tracing::debug!(error = %e, "failed to acquire cargo-bp, skipping battery pack discovery"); + return Vec::new(); + } + } + }; + + let cwd = cwd.to_path_buf(); + let report = match tokio::task::spawn_blocking(move || { + cargo_bp_script::StatusCommand::new() + .program(&binary_path) + .cwd(&cwd) + .run() + }) + .await + { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + tracing::debug!(error = %e, "cargo bp status failed, skipping battery pack discovery"); + return Vec::new(); + } + Err(e) => { + tracing::debug!(error = %e, "cargo bp status task panicked, skipping battery pack discovery"); + return Vec::new(); + } + }; + + battery_packs_from_report(report) +} + +/// Ensure `cargo-bp` is installed in symposium's binary cache and return the +/// path to the binary. +async fn acquire_cargo_bp(sym: &Symposium) -> anyhow::Result { + let source = symposium_install::CargoSource::new("cargo-bp"); + + let acquired = symposium_install::acquire_source( + &sym.install_context(), + &symposium_install::Source::Cargo(source), + Some("cargo-bp"), + ) + .await?; + + let binary_name = acquired + .resolved_executable + .unwrap_or_else(|| "cargo-bp".to_string()); + let exe_name = if cfg!(windows) { + format!("{binary_name}.exe") + } else { + binary_name + }; + Ok(acquired.base.join("bin").join(exe_name)) +} + +fn battery_packs_from_report(report: cargo_bp_script::StatusReport) -> Vec { + report + .packs + .into_iter() + .filter_map(|pack| { + semver::Version::parse(&pack.version) + .ok() + .map(|version| WorkspaceCrate { + name: pack.name, + version, + path: None, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn battery_packs_from_report_converts_installed_packs() { + use cargo_bp_script::{InstalledPackStatus, ProjectInfo, StatusReport}; + + let report = StatusReport::new(ProjectInfo::new("/tmp/Cargo.toml")) + .with_pack(InstalledPackStatus::new("cli", "cli-battery-pack", "0.3.0")) + .with_pack(InstalledPackStatus::new( + "error", + "error-battery-pack", + "0.2.0", + )); + + let crates = battery_packs_from_report(report); + assert_eq!(crates.len(), 2); + + assert_eq!(crates[0].name, "cli-battery-pack"); + assert_eq!(crates[0].version, semver::Version::new(0, 3, 0)); + assert!(crates[0].path.is_none()); + + assert_eq!(crates[1].name, "error-battery-pack"); + assert_eq!(crates[1].version, semver::Version::new(0, 2, 0)); + assert!(crates[1].path.is_none()); + } + + #[test] + fn battery_packs_from_report_skips_unparseable_versions() { + use cargo_bp_script::{InstalledPackStatus, ProjectInfo, StatusReport}; + + let report = StatusReport::new(ProjectInfo::new("/tmp/Cargo.toml")) + .with_pack(InstalledPackStatus::new( + "bad", + "bad-battery-pack", + "not-a-version", + )) + .with_pack(InstalledPackStatus::new( + "good", + "good-battery-pack", + "1.0.0", + )); + + let crates = battery_packs_from_report(report); + assert_eq!(crates.len(), 1); + assert_eq!(crates[0].name, "good-battery-pack"); + } + + #[test] + fn battery_packs_from_report_empty_report() { + use cargo_bp_script::{ProjectInfo, StatusReport}; + + let report = StatusReport::new(ProjectInfo::new("/tmp/Cargo.toml")); + let crates = battery_packs_from_report(report); + assert!(crates.is_empty()); + } +} diff --git a/src/crate_sources/mod.rs b/src/crate_sources/mod.rs index 48dc5981..1714ec65 100644 --- a/src/crate_sources/mod.rs +++ b/src/crate_sources/mod.rs @@ -9,9 +9,11 @@ use std::path::PathBuf; use anyhow::Result; +mod battery_pack; mod list; mod probe; +pub use battery_pack::discover_battery_packs; pub use list::{WorkspaceCrate, workspace_crates}; /// Normalize a crate name for hyphen/underscore-insensitive comparison. diff --git a/src/sync.rs b/src/sync.rs index 3984e428..1802d28d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -174,7 +174,20 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { // Load plugin registry and workspace deps let registry = plugins::load_registry(sym); - let workspace = crate::crate_sources::workspace_crates(&project_root); + let mut workspace = crate::crate_sources::workspace_crates(&project_root); + + // Augment with installed battery packs. + let battery_packs = crate::crate_sources::discover_battery_packs(sym, &project_root).await; + for bp in battery_packs { + let normalized = crate::crate_sources::normalize_crate_name(&bp.name); + if !workspace + .iter() + .any(|c| crate::crate_sources::normalize_crate_name(&c.name) == normalized) + { + workspace.push(bp); + } + } + workspace.sort_by(|a, b| a.name.cmp(&b.name)); for warning in ®istry.warnings { out.warn(format!( diff --git a/symposium-testlib/Cargo.toml b/symposium-testlib/Cargo.toml index e4527655..8731d6bd 100644 --- a/symposium-testlib/Cargo.toml +++ b/symposium-testlib/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/symposium-dev/symposium" publish = false [dependencies] +cargo-bp-script = "0.1.0" clap = { version = "4", features = ["derive"] } serde = "1" serde_json = "1" diff --git a/symposium-testlib/src/lib.rs b/symposium-testlib/src/lib.rs index 05e1a583..f12743ab 100644 --- a/symposium-testlib/src/lib.rs +++ b/symposium-testlib/src/lib.rs @@ -452,6 +452,32 @@ impl TestContext { self.sym.set_cargo_override(script_path); } + /// Point `cargo-bp` invocations at a mock script for the duration of this test. + /// + /// Writes the given shell script into the tempdir and configures `Symposium` + /// to use it instead of acquiring the real `cargo-bp`. + pub fn set_mock_cargo_bp(&mut self, script: &str) { + let script_path = self.tempdir.join("mock-cargo-bp"); + std::fs::write(&script_path, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + self.sym.set_cargo_bp_override(script_path); + } + + /// Configure a typed mock for `cargo bp status --json`. + /// + /// Serializes the given `StatusReport` to JSON and writes a shell script + /// that outputs it. This ensures the mock produces output that matches + /// the `cargo-bp-script` serialization format exactly. + pub fn set_mock_cargo_bp_status(&mut self, report: &cargo_bp_script::StatusReport) { + let json = serde_json::to_string(report).expect("StatusReport serializes"); + let script = format!("#!/bin/sh\nprintf '%s' '{}'\n", json.replace('\'', "'\\''")); + self.set_mock_cargo_bp(&script); + } + /// Replace variable content with stable placeholders for snapshot tests. pub fn normalize_paths(&self, output: &str) -> String { let config_dir = self.sym.config_dir().to_string_lossy().to_string(); diff --git a/tests/fixtures/battery-pack0/Cargo.toml b/tests/fixtures/battery-pack0/Cargo.toml new file mode 100644 index 00000000..889f5ba4 --- /dev/null +++ b/tests/fixtures/battery-pack0/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "bp-test-project" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/battery-pack0/dot-symposium/config.toml b/tests/fixtures/battery-pack0/dot-symposium/config.toml new file mode 100644 index 00000000..777c5d87 --- /dev/null +++ b/tests/fixtures/battery-pack0/dot-symposium/config.toml @@ -0,0 +1,5 @@ +hook-scope = "project" + +[defaults] +symposium-recommendations = false +user-plugins = true diff --git a/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/SYMPOSIUM.toml b/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/SYMPOSIUM.toml new file mode 100644 index 00000000..032eb372 --- /dev/null +++ b/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/SYMPOSIUM.toml @@ -0,0 +1,5 @@ +name = "bp-plugin" +crates = ["cli-battery-pack"] + +[[skills]] +source.path = "." diff --git a/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/cli-bp-guidance/SKILL.md b/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/cli-bp-guidance/SKILL.md new file mode 100644 index 00000000..69cf09f5 --- /dev/null +++ b/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/cli-bp-guidance/SKILL.md @@ -0,0 +1,6 @@ +--- +name: cli-bp-guidance +description: Guidance for cli-battery-pack users +--- + +Use the CLI battery pack for building command-line tools. diff --git a/tests/fixtures/battery-pack0/src/lib.rs b/tests/fixtures/battery-pack0/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/tests/init_sync.rs b/tests/init_sync.rs index c02e56e4..476389b0 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2095,3 +2095,94 @@ async fn sync_crate_metadata_missing_path_dir() { .await .unwrap(); } + +/// `sync` installs skills for battery packs discovered via `cargo bp status`. +/// +/// The battery pack crate is not a normal Cargo dependency — it's reported +/// by a mock `cargo-bp` binary, and the plugin's `crates` predicate matches +/// against that. +#[tokio::test] +async fn sync_installs_skill_for_battery_pack() { + use cargo_bp_script::{InstalledPackStatus, ProjectInfo, StatusReport}; + + with_fixture( + TestMode::SimulationOnly, + &["battery-pack0"], + async |mut ctx| { + let report = StatusReport::new(ProjectInfo::new("Cargo.toml")) + .with_pack(InstalledPackStatus::new("cli", "cli-battery-pack", "0.3.0")); + ctx.set_mock_cargo_bp_status(&report); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let skill_dir = + find_installed_skill(&workspace_root.join(".claude/skills"), "cli-bp-guidance"); + let content = std::fs::read_to_string(skill_dir.join("SKILL.md"))?; + assert!(content.contains("CLI battery pack")); + assert!(skill_dir.join(".symposium").exists()); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// `sync` does NOT install battery-pack skills when `cargo bp` reports +/// the pack is not installed. +#[tokio::test] +async fn sync_skips_skill_when_battery_pack_not_installed() { + use cargo_bp_script::{ProjectInfo, StatusReport}; + + with_fixture( + TestMode::SimulationOnly, + &["battery-pack0"], + async |mut ctx| { + let report = StatusReport::new(ProjectInfo::new("Cargo.toml")); + ctx.set_mock_cargo_bp_status(&report); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let skills = + find_installed_skills(&workspace_root.join(".claude/skills"), "cli-bp-guidance"); + assert!( + skills.is_empty(), + "skill should NOT be installed when battery pack is not present" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// `sync` proceeds gracefully when `cargo-bp` is unavailable (e.g. network +/// down, binary not found). Battery-pack skills are simply skipped. +#[tokio::test] +async fn sync_graceful_when_cargo_bp_unavailable() { + with_fixture( + TestMode::SimulationOnly, + &["battery-pack0"], + async |mut ctx| { + // Point at a nonexistent binary to simulate acquisition failure. + ctx.set_mock_cargo_bp("#!/bin/sh\nexit 1\n"); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let skills = + find_installed_skills(&workspace_root.join(".claude/skills"), "cli-bp-guidance"); + assert!( + skills.is_empty(), + "skill should NOT be installed when cargo-bp fails" + ); + Ok(()) + }, + ) + .await + .unwrap(); +}