Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions md/design/module-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
15 changes: 15 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ pub struct Symposium {
cache_dir: PathBuf,
home_dir: PathBuf,
cargo_override: Option<PathBuf>,
cargo_bp_override: Option<PathBuf>,
}

impl Symposium {
Expand All @@ -225,13 +226,15 @@ 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,
config_dir,
cache_dir,
home_dir,
cargo_override,
cargo_bp_override,
}
}

Expand All @@ -257,6 +260,7 @@ impl Symposium {
cache_dir,
home_dir,
cargo_override: None,
cargo_bp_override: None,
}
}

Expand Down Expand Up @@ -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;
Expand Down
156 changes: 156 additions & 0 deletions src/crate_sources/battery_pack.rs
Original file line number Diff line number Diff line change
@@ -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<WorkspaceCrate> {
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<std::path::PathBuf> {
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<WorkspaceCrate> {
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());
}
}
2 changes: 2 additions & 0 deletions src/crate_sources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 &registry.warnings {
out.warn(format!(
Expand Down
1 change: 1 addition & 0 deletions symposium-testlib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
26 changes: 26 additions & 0 deletions symposium-testlib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/battery-pack0/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[package]
name = "bp-test-project"
version = "0.1.0"
edition = "2021"
5 changes: 5 additions & 0 deletions tests/fixtures/battery-pack0/dot-symposium/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
hook-scope = "project"

[defaults]
symposium-recommendations = false
user-plugins = true
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name = "bp-plugin"
crates = ["cli-battery-pack"]

[[skills]]
source.path = "."
Original file line number Diff line number Diff line change
@@ -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.
Empty file.
Loading
Loading