lean_ctx/core/addons/
pack_env.rs1use 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
30pub 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
59pub 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 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 #[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}