Skip to main content

mur_common/deps/
mod.rs

1//! Portable program dependencies: declaring, detecting, and (curated)
2//! installing the external programs a shared MUR artifact needs.
3//! See docs/superpowers/specs/2026-07-11-portable-program-dependencies-design.md
4
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8pub mod detect;
9pub mod registry;
10
11/// One external-program requirement declared by a skill / MCP entry / agent
12/// profile / fleet. Data only — no I/O.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
14pub struct ProgramDep {
15    /// Stable lowercase identifier (also the registry key when `registry` is None).
16    pub name: String,
17    /// How to check whether the program is present.
18    pub detect: DetectMethod,
19    /// Human-readable "why this is needed", shown in the doctor report.
20    pub reason: String,
21    /// Manual-install guidance (URL/command). Display only — never executed.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub hint: Option<String>,
24    /// Optional key into MUR's curated registry (enables auto-install).
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub registry: Option<String>,
27    /// Author-declared install recipe (Phase 2). Present only in signed
28    /// bundles; auto-installed at import ONLY from a trusted publisher.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub recipe: Option<ProgramRecipe>,
31}
32
33/// Exactly one detection method (serde picks the arm by which field is present).
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
35#[serde(untagged)]
36pub enum DetectMethod {
37    /// A file exists at this (tilde/`$MUR_HOME`-expanded) path.
38    File { file: String },
39    /// A command resolves on `PATH`.
40    Command { command: String },
41    /// A command's reported version is `>= min`.
42    Version { version: VersionCheck },
43}
44
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
46pub struct VersionCheck {
47    /// Full command line to run, e.g. "node --version".
48    pub command: String,
49    /// Minimum acceptable semver, e.g. "18.0.0".
50    pub min: String,
51}
52
53/// Result of detecting a `ProgramDep`.
54#[derive(Debug, Clone, PartialEq)]
55pub enum DepStatus {
56    Present,
57    Missing,
58    PresentWrongVersion { found: String },
59}
60
61/// Current platform key, `<arch>-<os>` (e.g. "aarch64-macos"), matching the
62/// curated registry's per-platform keys. Uses the compiled target via
63/// `std::env::consts` (no subprocess).
64pub fn current_platform() -> String {
65    format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS)
66}
67
68/// Author-declared, per-platform install recipe carried on a `ProgramDep`
69/// inside a SIGNED bundle. Its integrity flows from the bundle signature; its
70/// authorization from the publisher's trust classification at import.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
72pub struct ProgramRecipe {
73    /// `<arch>-<os>` → recipe. Only the current platform's entry is installed.
74    pub platforms: BTreeMap<String, PlatformRecipe>,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
78pub struct PlatformRecipe {
79    pub url: String,
80    pub sha256: String,
81    /// Bare-binary install target (relative to mur_home); `None` when `archive`.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub install_to: Option<String>,
84    #[serde(default)]
85    pub executable: bool,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub archive: Option<registry::ArchiveSpec>,
88}
89
90impl ProgramRecipe {
91    /// Convert this recipe's entry for `platform` into the Phase 1
92    /// `CuratedRecipe` the installer consumes. `None` if no entry for the
93    /// platform. `description` is a fixed author-provenance label.
94    pub fn for_platform(&self, platform: &str) -> Option<registry::CuratedRecipe> {
95        let p = self.platforms.get(platform)?;
96        Some(registry::CuratedRecipe {
97            description: "author-declared recipe".to_string(),
98            url: p.url.clone(),
99            sha256: p.sha256.clone(),
100            install_to: p.install_to.clone(),
101            executable: p.executable,
102            archive: p.archive.clone(),
103        })
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn program_dep_parses_all_detect_methods() {
113        let y = r#"
114- name: lightpanda
115  detect: { file: "~/.mur/aura/lightpanda" }
116  reason: "render tier"
117  hint: "https://lightpanda.io/download"
118  registry: lightpanda
119- name: gh
120  detect: { command: "gh" }
121  reason: "github ops"
122- name: node
123  detect: { version: { command: "node --version", min: "18.0.0" } }
124  reason: "js runtime"
125"#;
126        let deps: Vec<ProgramDep> = serde_yaml::from_str(y).unwrap();
127        assert_eq!(deps.len(), 3);
128        assert_eq!(deps[0].name, "lightpanda");
129        assert!(
130            matches!(&deps[0].detect, DetectMethod::File { file } if file == "~/.mur/aura/lightpanda")
131        );
132        assert_eq!(deps[0].registry.as_deref(), Some("lightpanda"));
133        assert!(matches!(&deps[1].detect, DetectMethod::Command { command } if command == "gh"));
134        assert!(deps[1].hint.is_none());
135        assert!(
136            matches!(&deps[2].detect, DetectMethod::Version { version } if version.min == "18.0.0")
137        );
138    }
139
140    #[test]
141    fn current_platform_is_arch_dash_os() {
142        let p = current_platform();
143        assert!(p.contains('-'));
144        // arch and os are non-empty
145        let (arch, os) = p.split_once('-').unwrap();
146        assert!(!arch.is_empty() && !os.is_empty());
147    }
148
149    #[test]
150    fn program_dep_parses_optional_recipe_and_defaults_none() {
151        let with = r#"
152name: some-tool
153detect: { command: some-tool }
154reason: "x"
155recipe:
156  platforms:
157    aarch64-macos: { url: "u", sha256: "s", install_to: "aura/some-tool", executable: true }
158"#;
159        let d: ProgramDep = serde_yaml::from_str(with).unwrap();
160        assert!(d.recipe.is_some());
161        assert!(d.recipe.unwrap().for_platform("aarch64-macos").is_some());
162
163        // Phase 1 dep without recipe → None (back-compat).
164        let without = "name: x\ndetect: { command: x }\nreason: r\n";
165        let d2: ProgramDep = serde_yaml::from_str(without).unwrap();
166        assert!(d2.recipe.is_none());
167    }
168}
169
170#[cfg(test)]
171mod recipe_tests {
172    use super::*;
173
174    #[test]
175    fn program_recipe_for_platform_converts_to_curated() {
176        let y = r#"
177platforms:
178  aarch64-macos: { url: "https://x/tool", sha256: "abc123", install_to: "aura/tool", executable: true }
179"#;
180        let r: ProgramRecipe = serde_yaml::from_str(y).unwrap();
181        let cur = r.for_platform("aarch64-macos").expect("platform present");
182        assert_eq!(cur.url, "https://x/tool");
183        assert_eq!(cur.sha256, "abc123");
184        assert_eq!(cur.install_to.as_deref(), Some("aura/tool"));
185        assert!(cur.executable);
186        assert!(cur.archive.is_none());
187        assert!(r.for_platform("sparc-solaris").is_none());
188    }
189}