Skip to main content

lean_ctx/core/addons/
pack_env.rs

1//! `{pack_dir:@ns/name}` expansion for an addon's `[mcp.env]` (GH #727).
2//!
3//! An addon that ships its content in a `kind=skills` pack must learn where
4//! that pack was materialized. The author names the variable and states which
5//! pack it refers to; lean-ctx expands the placeholder at wiring time against
6//! the resolved dependency version:
7//!
8//! ```toml
9//! [mcp.env]
10//! LEAN_MD_SKILLS_DIR = "{pack_dir:@dasTholo/lean-md-skills}"
11//! ```
12//!
13//! Pure by construction: the store root is a parameter, never read from the
14//! environment here, so the expansion is a deterministic function of
15//! (declared env, resolved deps, store root).
16//!
17//! No env-scrub change is required. [`super::env_scrub::apply_env`] applies the
18//! declared env *after* `env_clear()`, so an expanded value reaches the child
19//! without an allowlist entry — lean-ctx computed this value, it is not a host
20//! variable smuggled through.
21
22use std::collections::BTreeMap;
23use std::path::Path;
24
25use crate::core::context_package::deps::ResolvedDep;
26use crate::core::context_package::skills::skills_dir;
27
28const SCHEME: &str = "pack_dir:";
29
30/// Every pack name referenced by a `{pack_dir:…}` placeholder in `value`.
31///
32/// A `{` always opens a placeholder — there is no literal-brace escape. An
33/// unterminated brace, or a `{…}` whose body is not `pack_dir:<name>`, is a
34/// hard error: a typo must never survive as an env value with braces in it.
35pub fn referenced_packs(value: &str) -> Result<Vec<String>, String> {
36    let mut names = Vec::new();
37    let mut rest = value;
38    while let Some(open) = rest.find('{') {
39        let after = &rest[open + 1..];
40        let close = after
41            .find('}')
42            .ok_or_else(|| format!("unterminated `{{` in `{value}`"))?;
43        let body = &after[..close];
44        let name = body.strip_prefix(SCHEME).ok_or_else(|| {
45            format!(
46                "unknown placeholder `{{{body}}}` in `{value}` — only \
47                 `{{pack_dir:@ns/name}}` is supported"
48            )
49        })?;
50        if name.trim().is_empty() {
51            return Err(format!("empty pack name in `{value}`"));
52        }
53        names.push(name.to_string());
54        rest = &after[close + 1..];
55    }
56    Ok(names)
57}
58
59/// Expand every `{pack_dir:@ns/name}` in `declared_env` against `resolved`.
60///
61/// A placeholder naming a pack that is not a resolved dependency is a hard
62/// error; a value without a placeholder passes through unchanged.
63pub fn expand_pack_env(
64    declared_env: &BTreeMap<String, String>,
65    resolved: &[ResolvedDep],
66    store_root: &Path,
67) -> Result<BTreeMap<String, String>, String> {
68    let mut out = BTreeMap::new();
69    for (key, value) in declared_env {
70        let names = referenced_packs(value).map_err(|e| format!("[mcp.env] `{key}`: {e}"))?;
71        if names.is_empty() {
72            out.insert(key.clone(), value.clone());
73            continue;
74        }
75        let mut expanded = value.clone();
76        for name in names {
77            let dep = resolved.iter().find(|d| d.name == name).ok_or_else(|| {
78                format!(
79                    "[mcp.env] `{key}`: `{{pack_dir:{name}}}` names a pack that is not a \
80                     declared dependency"
81                )
82            })?;
83            let dir = skills_dir(store_root, &dep.name, &dep.version);
84            expanded = expanded.replace(&format!("{{{SCHEME}{name}}}"), &dir.display().to_string());
85        }
86        out.insert(key.clone(), expanded);
87    }
88    Ok(out)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn dep(name: &str, version: &str) -> ResolvedDep {
96        let bare = name.trim_start_matches('@');
97        let (ns, slug) = bare.split_once('/').expect("scoped name");
98        ResolvedDep {
99            name: name.to_string(),
100            namespace: ns.to_string(),
101            slug: slug.to_string(),
102            version: version.to_string(),
103            artifact_sha256: "a".repeat(64),
104        }
105    }
106
107    fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
108        pairs
109            .iter()
110            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
111            .collect()
112    }
113
114    #[test]
115    fn one_placeholder_expands_to_the_skills_dir() {
116        let root = Path::new("/store");
117        let deps = [dep("@dasTholo/lean-md-skills", "0.2.0")];
118        let out = expand_pack_env(
119            &env(&[("LEAN_MD_SKILLS_DIR", "{pack_dir:@dasTholo/lean-md-skills}")]),
120            &deps,
121            root,
122        )
123        .expect("expands");
124        // Segment names are explicit here (only the separator comes from the
125        // platform): `Path::join` is production's own separator, so this
126        // asserts the real invariant instead of duplicating the code under test.
127        let expected = Path::new("/store")
128            .join("skills")
129            .join("@dasTholo__lean-md-skills")
130            .join("0.2.0")
131            .display()
132            .to_string();
133        assert_eq!(out["LEAN_MD_SKILLS_DIR"], expected);
134    }
135
136    #[test]
137    fn two_dependencies_two_variables_both_expand() {
138        let root = Path::new("/store");
139        let deps = [dep("@ns/one", "1.0.0"), dep("@ns/two", "2.3.4")];
140        let out = expand_pack_env(
141            &env(&[
142                ("ONE_DIR", "{pack_dir:@ns/one}"),
143                ("TWO_DIR", "{pack_dir:@ns/two}"),
144            ]),
145            &deps,
146            root,
147        )
148        .expect("expands");
149        let expected_one = Path::new("/store")
150            .join("skills")
151            .join("@ns__one")
152            .join("1.0.0")
153            .display()
154            .to_string();
155        let expected_two = Path::new("/store")
156            .join("skills")
157            .join("@ns__two")
158            .join("2.3.4")
159            .display()
160            .to_string();
161        assert_eq!(out["ONE_DIR"], expected_one);
162        assert_eq!(out["TWO_DIR"], expected_two);
163    }
164
165    #[test]
166    fn placeholder_naming_an_unknown_pack_is_an_error() {
167        let deps = [dep("@ns/one", "1.0.0")];
168        let err = expand_pack_env(
169            &env(&[("D", "{pack_dir:@ns/other}")]),
170            &deps,
171            Path::new("/store"),
172        )
173        .expect_err("unknown pack");
174        assert!(err.contains("not a declared dependency"), "{err}");
175    }
176
177    #[test]
178    fn unknown_placeholder_scheme_is_an_error() {
179        let err = expand_pack_env(
180            &env(&[("D", "{bin_dir:@ns/one}")]),
181            &[],
182            Path::new("/store"),
183        )
184        .expect_err("unknown scheme");
185        assert!(err.contains("unknown placeholder"), "{err}");
186    }
187
188    #[test]
189    fn value_without_a_placeholder_passes_through_unchanged() {
190        let out = expand_pack_env(
191            &env(&[("PLAIN", "/etc/passwd"), ("EMPTY", "")]),
192            &[],
193            Path::new("/store"),
194        )
195        .expect("passes through");
196        assert_eq!(out["PLAIN"], "/etc/passwd");
197        assert_eq!(out["EMPTY"], "");
198    }
199
200    /// Incidental braces are an ERROR, not a pass-through: a `{` opens a
201    /// placeholder unconditionally, so a typo fails loudly at manifest parse
202    /// instead of reaching the child as a literal `{…}` env value.
203    #[test]
204    fn incidental_braces_are_an_error() {
205        let unterminated =
206            expand_pack_env(&env(&[("D", "a{b")]), &[], Path::new("/store")).expect_err("open");
207        assert!(unterminated.contains("unterminated"), "{unterminated}");
208
209        let bare =
210            expand_pack_env(&env(&[("D", "{HOME}")]), &[], Path::new("/store")).expect_err("bare");
211        assert!(bare.contains("unknown placeholder"), "{bare}");
212
213        let empty = expand_pack_env(&env(&[("D", "{pack_dir:}")]), &[], Path::new("/store"))
214            .expect_err("empty");
215        assert!(empty.contains("empty pack name"), "{empty}");
216    }
217}