diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 594122d3..96bbbb1f 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -6,8 +6,8 @@ This section describes the logic of each `cargo agents` command. When a skill group uses `source = "crate"`, the sync flow takes an additional path: -1. `predicate::union_matched_packages()` resolves plugin-level and group-level predicates against the workspace to produce a set of concrete crate name/version pairs. -2. For each crate in the set, `RustCrateFetch` fetches the source — checking path overrides (for local path deps), then the cargo registry cache, then crates.io. +1. `predicate::union_matched_packages()` resolves plugin-level and group-level predicates against the workspace to produce a set of concrete package ids (`depends-on` witnesses). +2. For each crate in the set, the [cargo package manager](./module-structure.md#pm--package-managers) fetches the source: `CargoPm::fetch` delegates to `RustCrateFetch`, which checks path overrides (for local path deps), then the cargo registry cache, then crates.io. The fetched id carries the exact resolved version. 3. `crate_metadata::parse_crate_metadata()` reads `[package.metadata.symposium]` from the crate's `Cargo.toml`: - **No metadata** — fall back to the default `skills/` subdirectory. - **`skills = []`** — no skills from this crate. @@ -15,7 +15,7 @@ When a skill group uses `source = "crate"`, the sync flow takes an additional pa - **`crate = { name, version? }` entries** — redirect: fetch the target crate and follow its metadata recursively (with cycle detection and a depth limit of 10). 4. `discover_skills()` scans each resolved directory for `SKILL.md` files. -The key code paths are in `skills.rs` (`load_crate_skills`, `fetch_and_resolve_skills`), `crate_metadata.rs` (`parse_crate_metadata`), `predicate.rs` (`witness`, `union_matched_packages`), and `crate_sources/mod.rs` (`RustCrateFetch`, `WorkspaceCrate`). +The key code paths are in `skills.rs` (`load_crate_skills`, `fetch_and_resolve_skills`), `crate_metadata.rs` (`parse_crate_metadata`), `predicate.rs` (`witness`, `union_matched_packages`), `pm/cargo.rs` (`CargoPm`), and `crate_sources/mod.rs` (`RustCrateFetch`, `WorkspaceCrate`). ## Help rendering diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 7093e71d..de2d3cb6 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -47,22 +47,26 @@ Defines `Source` (the `source = "..."`-tagged enum: `cargo`, `github`) and `acqu Validates skill group source constraints during manifest validation: mutual exclusivity of `source.path`/`source.git`/`source = "crate"`, and the requirement that `source = "crate"` has at least one non-wildcard predicate. +### `pm/` — package managers + +The in-process seam from the [registry-centric plugin distribution RFD](../rfds/registry-centric-plugins/README.md). A `PackageId` is the canonical `(pm, name, version)` tuple; `version` may still be a requirement (a semver range, or `*` for "no requirement"), and `fetch` canonicalizes it — a `FetchedPackage` carries the exact resolved id plus the content directory. The `PackageManager` trait has two operations today — `fetch` and `list_deps` — and one implementation: `CargoPm` (`pm/cargo.rs`), whose `fetch` delegates to `crate_sources::RustCrateFetch` (path-dependency override, workspace pin, registry) and whose `list_deps` renders the workspace crates as cargo ids, the form [predicate evaluation](#predicaters--unified-activation-predicates) consumes. Consumers: crate-source skill resolution in `skills.rs` and `crate_command.rs` build ids with `CargoPm::id_for` and fetch through the trait; every dependency-list site (hook dispatch, sync, help rendering, subcommand dispatch, skill matching) builds its `PredicateContext` from `list_deps`. The RFD's other operations (`resolve`/`list_plugins`, `search`) are not routed through the seam yet. + ### `crate_metadata.rs` — parse Cargo.toml metadata Parses `[package.metadata.symposium]` from crate `Cargo.toml` files. Crate authors embed skill layout metadata so Symposium knows where to find skills (or which other crate to redirect to). Returns `SkillSource::Path(subdir)` or `SkillSource::Crate { name, version }` for redirects. ### `predicate.rs` — unified activation predicates -Defines one `Predicate` enum covering both dependency-graph matching and runtime/environment gating, plus `PredicateSet` (a list ANDed together) and `PredicateContext` (the workspace dependency list it evaluates against). Two surface syntaxes lower to the same tree: +Defines one `Predicate` enum covering both dependency-graph matching and runtime/environment gating, plus `PredicateSet` (a list ANDed together) and `PredicateContext` (the workspace dependency list it evaluates against — `PackageId`s from the [package-manager layer](#pm--package-managers)'s `list_deps`). Two surface syntaxes lower to the same tree: - The **`depends-on`** field uses dependency-atom syntax (`serde`, `serde>=1.0`, `*`) and lowers, via `DependsOnList`, to `depends-on(...)` / `depends-on(*)` predicates OR-combined into a single `any(...)` that is appended to the same list. So `depends-on` is sugar — there is no separate dependency-predicate type. - The **`predicates`** field uses function-call syntax: `depends-on()`, `shell()` (verbatim arg, `sh -c`, exit 0 holds), `path_exists()` (disk, then `$PATH` for bare names), `env([=])`, `workspace-member()` (the plugin is defined by a member of the active workspace — provenance stamped per plugin into `PredicateContext` via `ParsedPlugin::applies`; registry loading stamps false, workspace-plugin loading stamps true), and the combinators `not(

)`, `any(

, …)`, `all(

, …)`. The retired `crate(...)` spelling is rejected with a migration hint, as are the old `crates` fields. -Each gated struct (plugin, skill group, skill, hook, MCP server, subcommand) stores a single merged `predicates: PredicateSet`. Evaluation is `PredicateSet::evaluate(ctx) -> bool`. For `source = "crate"`, `witness` / `union_matched_packages` return the concrete crates that participate in a *satisfying* evaluation (the fetch set): `depends-on(c)` contributes `c` when present, `any` unions its true children, `all` unions all children when all hold, and `not` contributes nothing. `collect_dep_names` (crates.io validation) walks all positions regardless. Plugin/group/skill/MCP predicates are evaluated at sync time; hook dispatch evaluates the plugin-level set (so a plugin's `depends-on` now gates its hooks) plus the hook-level set. Hook dispatch threads in the workspace crate list, but resolves it (running cargo) only when some plugin- or hook-level predicate references a *concrete* `depends-on(...)` — wildcard and env/shell/path predicates dispatch without a cargo query. See the [predicates reference](../reference/predicates.md). +Each gated struct (plugin, skill group, skill, hook, MCP server, subcommand) stores a single merged `predicates: PredicateSet`. Evaluation is `PredicateSet::evaluate(ctx) -> bool`. A `depends-on` atom matches a dependency by exact name; a version requirement is checked when the dependency id's version component parses as semver. For `source = "crate"`, `witness` / `union_matched_packages` return the concrete `PackageId`s that participate in a *satisfying* evaluation (the fetch set): `depends-on(c)` contributes `c`'s ids when present, `any` unions its true children, `all` unions all children when all hold, and `not` contributes nothing. `collect_dep_names` (crates.io validation) walks all positions regardless. Plugin/group/skill/MCP predicates are evaluated at sync time; hook dispatch evaluates the plugin-level set (so a plugin's `depends-on` now gates its hooks) plus the hook-level set. Hook dispatch threads in the workspace crate list, but resolves it (running cargo) only when some plugin- or hook-level predicate references a *concrete* `depends-on(...)` — wildcard and env/shell/path predicates dispatch without a cargo query. See the [predicates reference](../reference/predicates.md). ### `skills.rs` — skill resolution and matching -Given a `PluginRegistry` and workspace dependencies, this module resolves skill group sources (fetching from git if needed), discovers `SKILL.md` files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. For `source = "crate"` groups, resolves predicates to a matched crate set, fetches each crate's source via `RustCrateFetch`, reads `[package.metadata.symposium]` to determine skill paths, and follows redirects recursively with cycle detection and a depth limit of 10. +Given a `PluginRegistry` and workspace dependencies, this module resolves skill group sources (fetching from git if needed), discovers `SKILL.md` files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. For `source = "crate"` groups, resolves predicates to a matched crate set, fetches each crate's source through the [package-manager layer](#pm--package-managers) (`CargoPm::fetch`), reads `[package.metadata.symposium]` to determine skill paths, and follows redirects recursively with cycle detection and a depth limit of 10. Each applicable skill carries a `SkillOrigin` describing *where its bytes live*, used at sync time for dedup and install-path disambiguation. What matters for identity is the on-disk location of the skill, not which plugin manifest pointed at it — two plugins in the same source pointing at the same skill bundle dedupe. - `Crate { name, version }` — from a crate-source resolution (`source = "crate"`). Two `Crate` origins with the same `(name, version)` are the same logical skill, regardless of which plugin pointed at them. diff --git a/src/crate_command.rs b/src/crate_command.rs index 492bc834..6d704375 100644 --- a/src/crate_command.rs +++ b/src/crate_command.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::config::Symposium; -use crate::crate_sources; +use crate::pm::{CargoPm, PackageManager as _}; /// Result of dispatching a command. pub enum DispatchResult { @@ -22,18 +22,14 @@ pub async fn dispatch_crate( tracing::debug!(%name, ?version, "crate-info dispatched"); let mut deps = sym.workspace_deps(cwd); let workspace = deps.crates(); - let mut fetch = crate_sources::RustCrateFetch::new(name, workspace); - if let Some(v) = version { - fetch = fetch.version(v); - } - - match fetch.fetch().await { + let id = CargoPm::id_for(name, version); + match CargoPm.fetch(&id, workspace).await { Ok(result) => { let output = format!( "Crate: {}\nVersion: {}\nSource: {}\n", - result.name, - result.version, - result.path.display() + result.id.name, + result.id.version, + result.root.display() ); tracing::trace!(%output, "crate-info output"); DispatchResult::Ok(output) diff --git a/src/crate_sources/list.rs b/src/crate_sources/list.rs deleted file mode 100644 index e404abe3..00000000 --- a/src/crate_sources/list.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! List workspace crates with available guidance - -use symposium_sdk::workspace::WorkspaceCrate; - -/// The workspace crates as `(name, version)` pairs — the form predicate -/// evaluation consumes (see [`crate::predicate::PredicateContext`]). -pub fn crate_pairs(crates: &[WorkspaceCrate]) -> Vec<(String, semver::Version)> { - crates - .iter() - .map(|c| (c.name.clone(), c.version.clone())) - .collect() -} diff --git a/src/crate_sources/mod.rs b/src/crate_sources/mod.rs index 7ee21eef..10d2a1ee 100644 --- a/src/crate_sources/mod.rs +++ b/src/crate_sources/mod.rs @@ -10,11 +10,8 @@ use std::path::PathBuf; use anyhow::Result; use symposium_sdk::workspace::WorkspaceCrate; -mod list; mod probe; -pub use list::crate_pairs; - /// Normalize a crate name for hyphen/underscore-insensitive comparison. /// /// Cargo treats `foo-bar` and `foo_bar` as the same crate name (published diff --git a/src/help_render.rs b/src/help_render.rs index fbe1a5e6..6177c8c4 100644 --- a/src/help_render.rs +++ b/src/help_render.rs @@ -9,7 +9,6 @@ use std::{fmt::Write as _, path::Path}; use clap::{Command, CommandFactory}; -use semver::Version; use symposium_sdk::workspace::WorkspaceCrate; @@ -17,6 +16,7 @@ use crate::{ cli::{Cli, Commands, builtin_audience}, config::Symposium, plugins::{Audience, PluginRegistry, load_registry_with_workspace}, + pm::{PackageId, PackageManager as _}, subcommand_dispatch::applicable_subcommands, }; @@ -108,7 +108,7 @@ fn render(registry: &PluginRegistry, workspace: &[WorkspaceCrate]) -> String { let header = &full[..commands_idx]; let options = &full[options_idx..]; - let deps = crate::crate_sources::crate_pairs(workspace); + let deps = crate::pm::CargoPm.list_deps(workspace); let humans = collect_section(&cmd, registry, &deps, Audience::Humans); let agents = collect_section(&cmd, registry, &deps, Audience::Agents); @@ -146,7 +146,7 @@ fn render(registry: &PluginRegistry, workspace: &[WorkspaceCrate]) -> String { fn collect_section( cmd: &Command, registry: &PluginRegistry, - deps: &[(String, Version)], + deps: &[PackageId], target: Audience, ) -> Vec<(String, String)> { let mut builtins = cmd diff --git a/src/hook.rs b/src/hook.rs index 0c1b48ae..acb6b15a 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -11,6 +11,7 @@ use crate::installation::{ resolve_runnable, }; use crate::plugins::{HookFormat, Installation}; +use crate::pm::PackageManager as _; use crate::{ config::Symposium, hook_schema::{AgentHookInput, symposium}, @@ -412,12 +413,12 @@ async fn prewarm_hook_sources(sym: &Symposium, deps: &mut WorkspaceDeps) { // Resolving the workspace runs cargo, so only do it when some hook's // gating references a concrete crate (mirrors dispatch). - let pairs = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) { - crate::crate_sources::crate_pairs(deps.crates()) + let dep_ids = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) { + crate::pm::CargoPm.list_deps(deps.crates()) } else { Vec::new() }; - let mut ctx = crate::predicate::PredicateContext::new(&pairs); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); for parsed in &plugins { if !parsed.applies(&mut ctx) { @@ -496,9 +497,9 @@ fn handle_session_start( fn discovery_hint(sym: &Symposium, deps: &mut WorkspaceDeps) -> Option { let workspace = deps.load().cloned(); let registry = crate::plugins::load_registry_with_workspace(sym, workspace.as_deref()); - let pairs = crate::crate_sources::crate_pairs(deps.crates()); + let dep_ids = crate::pm::CargoPm.list_deps(deps.crates()); - let any_subcommand = !applicable_subcommands(®istry, &pairs).is_empty(); + let any_subcommand = !applicable_subcommands(®istry, &dep_ids).is_empty(); any_subcommand.then(|| { format!( @@ -576,12 +577,12 @@ pub async fn dispatch_plugin_hooks( // Resolving the workspace means running cargo, so only do it when some // plugin's hook gating actually references a concrete crate (a `depends-on(*)` // wildcard or env/shell/path predicate never needs the crate graph). - let pairs = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) { - crate::crate_sources::crate_pairs(deps.crates()) + let dep_ids = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) { + crate::pm::CargoPm.list_deps(deps.crates()) } else { Vec::new() }; - let mut ctx = crate::predicate::PredicateContext::new(&pairs); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); let hooks = dispatched_hooks_for_payload(&plugins, sym_input, host_agent, &mut ctx); let mut output = prior_output; @@ -1131,7 +1132,11 @@ mod tests { ); // serde present → the hook fires. - let deps = vec![("serde".to_string(), semver::Version::new(1, 0, 0))]; + let deps = vec![crate::pm::PackageId::new( + crate::pm::CARGO_PM, + "serde", + "1.0.0", + )]; let matched = dispatched_hooks_for_payload( &[plugin], &pre_tool_use_input(), diff --git a/src/lib.rs b/src/lib.rs index 7270264a..6b1bf965 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod hook_schema; pub(crate) mod installation; pub mod output; pub mod plugins; +pub mod pm; pub mod report; pub mod self_update; pub mod state; diff --git a/src/plugins.rs b/src/plugins.rs index 7790b62f..4a362ada 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -2052,8 +2052,8 @@ mod tests { PredicateSet::from_depends_on(s).unwrap() } - fn ctx(crates: &[(String, semver::Version)]) -> crate::predicate::PredicateContext<'_> { - crate::predicate::PredicateContext::new(crates) + fn ctx(deps: &[crate::pm::PackageId]) -> crate::predicate::PredicateContext<'_> { + crate::predicate::PredicateContext::new(deps) } fn from_str(s: &str) -> Result { @@ -2643,8 +2643,8 @@ mod tests { #[test] fn plugin_crate_filtering() { let workspace_crates = vec![ - ("serde".to_string(), semver::Version::new(1, 0, 0)), - ("tokio".to_string(), semver::Version::new(1, 0, 0)), + crate::pm::PackageId::new(crate::pm::CARGO_PM, "serde", "1.0.0"), + crate::pm::PackageId::new(crate::pm::CARGO_PM, "tokio", "1.0.0"), ]; // Plugin with wildcard - should apply to all @@ -2852,7 +2852,7 @@ mod tests { source_dir: PathBuf::from("/test"), workspace_member: false, }; - let deps: Vec<(String, semver::Version)> = Vec::new(); + let deps: Vec = Vec::new(); let mut c = ctx(&deps); assert!(!parsed.applies(&mut c)); parsed.workspace_member = true; diff --git a/src/pm/cargo.rs b/src/pm/cargo.rs new file mode 100644 index 00000000..b1db8e2a --- /dev/null +++ b/src/pm/cargo.rs @@ -0,0 +1,41 @@ +//! The cargo package manager: crates from the active workspace's dependency +//! graph, resolved by [`RustCrateFetch`] (path-dependency override, then the +//! cargo registry cache, then crates.io). + +use anyhow::Result; +use symposium_sdk::workspace::WorkspaceCrate; + +use crate::crate_sources::RustCrateFetch; + +use super::{ANY_VERSION, CARGO_PM, FetchedPackage, PackageId, PackageManager}; + +pub struct CargoPm; + +impl CargoPm { + /// Cargo id for a crate name and optional version requirement. + pub fn id_for(name: &str, version: Option<&str>) -> PackageId { + PackageId::new(CARGO_PM, name, version.unwrap_or(ANY_VERSION)) + } +} + +impl PackageManager for CargoPm { + async fn fetch(&self, id: &PackageId, workspace: &[WorkspaceCrate]) -> Result { + debug_assert_eq!(id.pm, CARGO_PM); + let mut fetch = RustCrateFetch::new(&id.name, workspace); + if id.version != ANY_VERSION { + fetch = fetch.version(&id.version); + } + let result = fetch.fetch().await?; + Ok(FetchedPackage { + id: PackageId::new(CARGO_PM, result.name, result.version), + root: result.path, + }) + } + + fn list_deps(&self, workspace: &[WorkspaceCrate]) -> Vec { + workspace + .iter() + .map(|c| PackageId::new(CARGO_PM, c.name.clone(), c.version.to_string())) + .collect() + } +} diff --git a/src/pm/mod.rs b/src/pm/mod.rs new file mode 100644 index 00000000..b8ce79aa --- /dev/null +++ b/src/pm/mod.rs @@ -0,0 +1,78 @@ +//! Package managers: the in-process seam from the [registry-centric plugin +//! distribution RFD](../../md/rfds/registry-centric-plugins/README.md). +//! +//! A [`PackageId`] names a package as a `(pm, name, version)` tuple, and a +//! [`PackageManager`] resolves ids of its ecosystem to content on disk. +//! Cargo is the only package manager today, and both of its existing +//! operations route through the seam: `fetch` (callers that used to +//! construct a [`RustCrateFetch`](crate::crate_sources::RustCrateFetch) +//! directly go through [`CargoPm`] instead) and `list_deps` (the workspace +//! dependency list that predicate evaluation consumes). + +use std::path::PathBuf; + +use anyhow::Result; +use symposium_sdk::workspace::WorkspaceCrate; + +mod cargo; +pub use cargo::CargoPm; + +/// The `pm` component of cargo package ids. +pub const CARGO_PM: &str = "cargo"; + +/// Version placeholder for "no requirement": the package manager resolves it +/// (for cargo: a workspace pin, or the newest published version). +pub const ANY_VERSION: &str = "*"; + +/// Canonical package coordinates: which package manager, which package, +/// which version. +/// +/// `version` may still be a *requirement* (a semver range, or +/// [`ANY_VERSION`]); [`PackageManager::fetch`] canonicalizes it — the id on +/// a [`FetchedPackage`] always names the exact resolved version. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PackageId { + pub pm: String, + pub name: String, + pub version: String, +} + +impl PackageId { + pub fn new(pm: impl Into, name: impl Into, version: impl Into) -> Self { + Self { + pm: pm.into(), + name: name.into(), + version: version.into(), + } + } +} + +impl std::fmt::Display for PackageId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}:{}", self.pm, self.name, self.version) + } +} + +/// A fetched package: the exact id it resolved to, plus the directory +/// holding its content. +#[derive(Debug, Clone)] +pub struct FetchedPackage { + pub id: PackageId, + pub root: PathBuf, +} + +/// The package-manager interface. +// Auto trait bounds can't be named on an `async fn` trait method; fine here +// because nothing holds the future across threads, and a `Send` bound would +// be premature with a single in-process implementation. +#[allow(async_fn_in_trait)] +pub trait PackageManager { + /// Resolve `id` and return its content directory. + /// + /// `workspace` supplies the active workspace's dependency resolution: + /// path-dependency overrides and version pins. + async fn fetch(&self, id: &PackageId, workspace: &[WorkspaceCrate]) -> Result; + + /// The workspace's dependencies, as ids of this PM's ecosystem. + fn list_deps(&self, workspace: &[WorkspaceCrate]) -> Vec; +} diff --git a/src/predicate.rs b/src/predicate.rs index cd044a3d..288a0e59 100644 --- a/src/predicate.rs +++ b/src/predicate.rs @@ -31,6 +31,8 @@ use std::process::Command; use anyhow::{Context, Result, bail}; +use crate::pm::{CARGO_PM, PackageId}; + /// Names reserved for builtin predicates. Custom predicates must not use /// these. `crate` is retired syntax but stays reserved so a custom predicate /// can never squat on it. @@ -54,7 +56,7 @@ pub const BUILTIN_PREDICATE_NAMES: &[&str] = &[ /// for the lifetime of the context. #[derive(Debug)] pub struct PredicateContext<'a> { - pub deps: &'a [(String, semver::Version)], + pub deps: &'a [PackageId], /// Whether the plugin currently being evaluated is defined by a member /// of the active workspace. This is *provenance*, not a workspace fact: /// the loader stamps it per plugin (via `ParsedPlugin::applies`) before @@ -65,7 +67,7 @@ pub struct PredicateContext<'a> { } impl<'a> PredicateContext<'a> { - pub fn new(deps: &'a [(String, semver::Version)]) -> Self { + pub fn new(deps: &'a [PackageId]) -> Self { Self { deps, workspace_member: false, @@ -75,7 +77,7 @@ impl<'a> PredicateContext<'a> { } pub fn with_custom_predicates( - deps: &'a [(String, semver::Version)], + deps: &'a [PackageId], entries: std::collections::HashMap, ) -> Self { Self { @@ -163,11 +165,10 @@ impl Predicate { /// needed. pub fn evaluate(&self, ctx: &mut PredicateContext) -> bool { match self { - Predicate::DependsOn(name, version_req) => { - ctx.deps.iter().any(|(dep_name, dep_ver)| { - dep_name == name && version_req.as_ref().is_none_or(|req| req.matches(dep_ver)) - }) - } + Predicate::DependsOn(name, version_req) => ctx + .deps + .iter() + .any(|dep| dep_matches(dep, name, version_req.as_ref())), Predicate::DependsOnWildcard => true, Predicate::Shell(cmd) => run_shell(cmd), Predicate::PathExists(arg) => path_exists(arg), @@ -188,16 +189,13 @@ impl Predicate { /// children's witnesses (when all hold), and `not` contributes nothing /// (negation is about absence). Non-dependency leaves contribute an empty /// witness. - pub fn witness(&self, ctx: &mut PredicateContext) -> Option> { + pub fn witness(&self, ctx: &mut PredicateContext) -> Option> { match self { Predicate::DependsOn(name, version_req) => { let hits: Vec<_> = ctx .deps .iter() - .filter(|(dep_name, dep_ver)| { - dep_name == name - && version_req.as_ref().is_none_or(|req| req.matches(dep_ver)) - }) + .filter(|dep| dep_matches(dep, name, version_req.as_ref())) .cloned() .collect(); if hits.is_empty() { None } else { Some(hits) } @@ -231,11 +229,13 @@ impl Predicate { } Predicate::Custom { name, arg } => { let witness = ctx.custom_witness(name, arg)?; - let pairs = witness + let ids = witness .iter() - .map(|wc| (wc.crate_name.clone(), wc.version.clone())) + .map(|wc| { + PackageId::new(CARGO_PM, wc.crate_name.clone(), wc.version.to_string()) + }) .collect(); - Some(pairs) + Some(ids) } } } @@ -360,7 +360,7 @@ impl PredicateSet { /// Witness for the whole set (treated as one big `all(...)`): `None` if any /// predicate is false, otherwise the deduplicated union of witnesses. - pub fn witness(&self, ctx: &mut PredicateContext) -> Option> { + pub fn witness(&self, ctx: &mut PredicateContext) -> Option> { let mut packages = Vec::new(); for p in &self.predicates { packages.extend(p.witness(ctx)?); @@ -409,14 +409,14 @@ impl PredicateSet { pub fn union_matched_packages( sets: &[&PredicateSet], ctx: &mut PredicateContext, -) -> Vec<(String, semver::Version)> { +) -> Vec { let mut seen = std::collections::HashSet::new(); let mut result = Vec::new(); for set in sets { if let Some(matched) = set.witness(ctx) { - for pair in matched { - if seen.insert(pair.0.clone()) { - result.push(pair); + for id in matched { + if seen.insert(id.name.clone()) { + result.push(id); } } } @@ -424,14 +424,22 @@ pub fn union_matched_packages( result } -fn dedup_packages(packages: Vec<(String, semver::Version)>) -> Vec<(String, semver::Version)> { +fn dedup_packages(packages: Vec) -> Vec { let mut seen = std::collections::HashSet::new(); packages .into_iter() - .filter(|(name, _)| seen.insert(name.clone())) + .filter(|id| seen.insert(id.name.clone())) .collect() } +/// A dependency id satisfies a `depends-on` atom when the name matches +/// exactly and, when the atom carries a version requirement, the id's +/// version component parses as semver and satisfies it. +fn dep_matches(dep: &PackageId, name: &str, req: Option<&semver::VersionReq>) -> bool { + dep.name == name + && req.is_none_or(|req| semver::Version::parse(&dep.version).is_ok_and(|v| req.matches(&v))) +} + // --- the `depends-on` field: a list of dependency atoms, OR-combined --- /// The parsed `depends-on = [...]` field — a list of crate atoms. Lowers to a @@ -1006,18 +1014,14 @@ fn parse_witness_stdout(predicate_name: &str, stdout: &[u8]) -> Option semver::Version { - semver::Version::parse(s).unwrap() - } - - fn ctx<'a>(crates: &'a [(String, semver::Version)]) -> PredicateContext<'a> { - PredicateContext::new(crates) + fn ctx<'a>(deps: &'a [PackageId]) -> PredicateContext<'a> { + PredicateContext::new(deps) } - fn ws(pairs: &[(&str, &str)]) -> Vec<(String, semver::Version)> { + fn ws(pairs: &[(&str, &str)]) -> Vec { pairs .iter() - .map(|(n, ver)| (n.to_string(), v(ver))) + .map(|(n, ver)| PackageId::new(CARGO_PM, *n, *ver)) .collect() } @@ -1224,7 +1228,7 @@ mod tests { .witness(&mut ctx(&w)) .unwrap() .into_iter() - .map(|(n, _)| n) + .map(|id| id.name) .collect(); assert_eq!(names, vec!["c1"]); } @@ -1241,7 +1245,7 @@ mod tests { .witness(&mut ctx(&w)) .unwrap() .into_iter() - .map(|(n, _)| n) + .map(|id| id.name) .collect(); assert_eq!(names, vec!["c1"]); } @@ -1256,7 +1260,7 @@ mod tests { .witness(&mut ctx(&w)) .unwrap() .into_iter() - .map(|(n, _)| n) + .map(|id| id.name) .collect(); names.sort(); assert_eq!(names, vec!["c1", "c2"]); @@ -1274,7 +1278,7 @@ mod tests { let group = PredicateSet::from_depends_on("serde, tokio").unwrap(); let w = ws(&[("serde", "1.0.0"), ("tokio", "1.0.0")]); let result = union_matched_packages(&[&plugin, &group], &mut ctx(&w)); - let mut names: Vec<_> = result.into_iter().map(|(n, _)| n).collect(); + let mut names: Vec<_> = result.into_iter().map(|id| id.name).collect(); names.sort(); assert_eq!(names, vec!["serde", "tokio"]); } @@ -1754,8 +1758,8 @@ mod tests { }; let witness = pred.witness(&mut ctx).unwrap(); assert_eq!(witness.len(), 1); - assert_eq!(witness[0].0, "cli-battery-pack"); - assert_eq!(witness[0].1, semver::Version::parse("0.3.1").unwrap()); + assert_eq!(witness[0].name, "cli-battery-pack"); + assert_eq!(witness[0].version, "0.3.1"); } #[test] @@ -1818,9 +1822,9 @@ mod tests { }; let witness = pred.witness(&mut ctx).unwrap(); assert_eq!(witness.len(), 3); - assert_eq!(witness[0].0, "a"); - assert_eq!(witness[1].0, "b"); - assert_eq!(witness[2].0, "c"); + assert_eq!(witness[0].name, "a"); + assert_eq!(witness[1].name, "b"); + assert_eq!(witness[2].name, "c"); } #[test] @@ -1836,7 +1840,7 @@ mod tests { }; let witness = pred.witness(&mut ctx).unwrap(); assert_eq!(witness.len(), 1); - assert_eq!(witness[0].0, "serde"); + assert_eq!(witness[0].name, "serde"); } #[test] @@ -1863,7 +1867,7 @@ mod tests { }; let witness = pred.witness(&mut ctx).unwrap(); assert_eq!(witness.len(), 1); - assert_eq!(witness[0].0, "tokio"); + assert_eq!(witness[0].name, "tokio"); } #[test] diff --git a/src/skills.rs b/src/skills.rs index 779efe7b..247a0c1e 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -11,6 +11,7 @@ use symposium_install::UpdateLevel; use crate::config::Symposium; use crate::plugins::{ParsedPlugin, PluginRegistry, PluginSource, SkillGroup}; +use crate::pm::PackageManager as _; use crate::predicate::{self, PredicateContext, PredicateSet}; fn source_display(source: &PluginSource) -> String { @@ -156,7 +157,7 @@ pub async fn skills_applicable_to( ) -> Vec { let mut results = Vec::new(); - let for_crates = crate::crate_sources::crate_pairs(workspace_crates); + let for_crates = crate::pm::CargoPm.list_deps(workspace_crates); let mut ctx = PredicateContext::with_custom_predicates(&for_crates, custom_predicate_entries); // Skills from plugin manifests. We iterate these separately @@ -318,10 +319,10 @@ async fn load_crate_skills( ) -> Vec<(Skill, SkillOrigin)> { let matched = predicate::union_matched_packages(&[&plugin.predicates, &group.predicates], ctx); let mut skills = Vec::new(); - for (name, _version) in &matched { + for id in &matched { let mut visited = std::collections::HashSet::new(); fetch_and_resolve_skills( - name, + &id.name, None, workspace_crates, group, @@ -364,12 +365,8 @@ async fn fetch_and_resolve_skills( return; } - let mut fetcher = crate::crate_sources::RustCrateFetch::new(crate_name, workspace_crates); - if let Some(vs) = version_spec { - fetcher = fetcher.version(vs); - } - - let result = match fetcher.fetch().await { + let id = crate::pm::CargoPm::id_for(crate_name, version_spec); + let result = match crate::pm::CargoPm.fetch(&id, workspace_crates).await { Ok(r) => r, Err(e) => { tracing::warn!( @@ -381,12 +378,12 @@ async fn fetch_and_resolve_skills( } }; - let version = match semver::Version::parse(&result.version) { + let version = match semver::Version::parse(&result.id.version) { Ok(v) => v, Err(e) => { tracing::warn!( crate_name = %crate_name, - version = %result.version, + version = %result.id.version, error = %e, "skipping crate-source skills: unparseable version" ); @@ -395,11 +392,11 @@ async fn fetch_and_resolve_skills( }; let origin = SkillOrigin::Crate { - name: result.name.clone(), + name: result.id.name.clone(), version, }; - let cargo_toml_path = result.path.join("Cargo.toml"); + let cargo_toml_path = result.root.join("Cargo.toml"); let metadata = match crate::crate_metadata::parse_crate_metadata(&cargo_toml_path) { Ok(m) => m, Err(e) => { @@ -414,7 +411,7 @@ async fn fetch_and_resolve_skills( match metadata { None => { - let dir = result.path.join(crate::plugins::CRATE_DEFAULT_SKILLS_PATH); + let dir = result.root.join(crate::plugins::CRATE_DEFAULT_SKILLS_PATH); let discovered = discover_skills(&dir, group); tracing::debug!( report = %crate::report::ReportEvent::SkillSourceSearched { @@ -439,7 +436,7 @@ async fn fetch_and_resolve_skills( for source in &meta.skills { match source { crate::crate_metadata::SkillSource::Path(p) => { - let dir = result.path.join(p); + let dir = result.root.join(p); let discovered = discover_skills(&dir, group); tracing::debug!( report = %crate::report::ReportEvent::SkillSourceSearched { @@ -935,8 +932,15 @@ mod tests { PredicateSet::from_depends_on(s).unwrap() } - fn ctx(crates: &[(String, semver::Version)]) -> PredicateContext<'_> { - PredicateContext::new(crates) + fn ctx(deps: &[crate::pm::PackageId]) -> PredicateContext<'_> { + PredicateContext::new(deps) + } + + fn ws(pairs: &[(&str, &str)]) -> Vec { + pairs + .iter() + .map(|(n, ver)| crate::pm::PackageId::new(crate::pm::CARGO_PM, *n, *ver)) + .collect() } // --- SkillOrigin identity --- @@ -2037,39 +2041,35 @@ mod tests { // --- AND composition across levels (plugin ∧ group ∧ skill) --- - fn v(s: &str) -> semver::Version { - semver::Version::parse(s).unwrap() - } - /// Each level's `depends-on` lowers to one predicate set; the skill applies when /// every level's set holds (AND across levels). - fn applies(levels: &[&str], ws: &[(String, semver::Version)]) -> bool { + fn applies(levels: &[&str], deps: &[crate::pm::PackageId]) -> bool { levels .iter() - .all(|spec| pred_set(spec).evaluate(&mut ctx(ws))) + .all(|spec| pred_set(spec).evaluate(&mut ctx(deps))) } #[test] fn and_across_levels_all_satisfied() { - let ws = vec![("serde".into(), v("1.0.0")), ("tokio".into(), v("1.0.0"))]; - assert!(applies(&["serde", "tokio"], &ws)); + let w = ws(&[("serde", "1.0.0"), ("tokio", "1.0.0")]); + assert!(applies(&["serde", "tokio"], &w)); } #[test] fn and_across_levels_one_missing() { - let ws = vec![("serde".into(), v("1.0.0"))]; - assert!(!applies(&["serde", "tokio"], &ws)); + let w = ws(&[("serde", "1.0.0")]); + assert!(!applies(&["serde", "tokio"], &w)); } #[test] fn and_across_levels_empty_is_vacuously_true() { - let ws = vec![("serde".into(), v("1.0.0"))]; - assert!(applies(&[], &ws)); + let w = ws(&[("serde", "1.0.0")]); + assert!(applies(&[], &w)); } #[test] fn wildcard_level_matches_any() { - let ws = vec![("serde".into(), v("1.0.0"))]; - assert!(applies(&["*", "serde"], &ws)); + let w = ws(&[("serde", "1.0.0")]); + assert!(applies(&["*", "serde"], &w)); } } diff --git a/src/subcommand_dispatch.rs b/src/subcommand_dispatch.rs index 79f051c6..9582d8a4 100644 --- a/src/subcommand_dispatch.rs +++ b/src/subcommand_dispatch.rs @@ -14,9 +14,9 @@ use crate::{ config::Symposium, installation::{acquire_installation, resolve_runnable}, plugins::{self, Plugin, PluginRegistry, Subcommand}, + pm::{CargoPm, PackageId, PackageManager as _}, }; use anyhow::{Context, Result, bail}; -use semver::Version; use symposium_install::{Runnable, UpdateLevel}; use tokio::process::Command; @@ -24,7 +24,7 @@ use tokio::process::Command; /// apply to `deps`. Shared between dispatch (name lookup) and help rendering (audience grouping). pub fn applicable_subcommands<'a>( registry: &'a PluginRegistry, - deps: &[(String, Version)], + deps: &[PackageId], ) -> Vec<(&'a Plugin, &'a str, &'a Subcommand)> { let mut ctx = crate::predicate::PredicateContext::new(deps); let mut results = Vec::new(); @@ -53,7 +53,7 @@ pub fn find_subcommand<'a>( name: &str, workspace: &[WorkspaceCrate], ) -> Result> { - let deps = crate::crate_sources::crate_pairs(workspace); + let deps = CargoPm.list_deps(workspace); let matches: Vec<_> = applicable_subcommands(registry, &deps) .into_iter() diff --git a/src/sync.rs b/src/sync.rs index c7e7df0b..68fd4897 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -16,6 +16,7 @@ use crate::agents::Agent; use crate::config::Symposium; use crate::output::{Output, display_path}; use crate::plugins; +use crate::pm::PackageManager as _; use crate::skills; use symposium_sdk::workspace::WorkspaceDeps; @@ -326,8 +327,8 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel } // Collect MCP servers from applicable plugins, filtered by workspace deps - let semver_pairs = crate::crate_sources::crate_pairs(&workspace); - let mut ctx = crate::predicate::PredicateContext::new(&semver_pairs); + let dep_ids = crate::pm::CargoPm.list_deps(&workspace); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); let mut mcp_servers: Vec = Vec::new(); for p in ®istry.plugins { if p.applies(&mut ctx) {