Skip to main content

mlua_swarm_compile/
linker.rs

1//! Blueprint loader (Phase B). Loads a Blueprint from a JSON / YAML file
2//! and recursively expands the internal `{"$file": "..."}` refs.
3//!
4//! ## File-ref expansion
5//!
6//! Anywhere inside the JSON value, this form is replaced by the referenced
7//! file's contents **as a raw string**. Paths are resolved **relative to
8//! the Blueprint file's directory**:
9//!
10//! ```jsonc
11//! { "$file": "prompts/system-writer.md" }
12//! ```
13//!
14//! Typical uses:
15//!
16//! - Externalising a large prompt out of a flow `Step.in`:
17//!   `{"op":"lit","value":{"$file":"prompts/x.md"}}`.
18//! - Externalising any field inside `AgentDef.spec` (system_prompt, args,
19//!   etc.).
20//! - Externalising per-agent or global `hints`.
21//!
22//! ## Agent-md ref expansion (structured ref)
23//!
24//! Specialised ref that expands an `agent.md` (frontmatter + body) into
25//! an **`AgentDef` object**:
26//!
27//! ```jsonc
28//! {
29//!   "agents": [
30//!     { "$agent_md": "agents/researcher.md" }
31//!   ]
32//! }
33//! ```
34//!
35//! Where `$file` returns a raw string, `$agent_md` runs the file through
36//! `agent_md_loader::parse` and returns a fully-populated `AgentDef` JSON
37//! object with `profile.system_prompt`, `meta`, `spec`, and so on already
38//! filled in. Path hygiene matches `$file`: absolute paths and `..` are
39//! rejected.
40//!
41//! Sibling keys next to `$agent_md` are shallow-merged onto that object,
42//! so the call site overrides what the agent.md declared. Shallow means
43//! per top-level key: a sibling `lints` replaces the frontmatter's whole
44//! `lints:` map (and `{}` clears it) rather than merging entry by entry —
45//! same for `spec` / `meta` / `profile`.
46
47use mlua_swarm_schema::{default_global_agent_kind, AgentKind, Blueprint};
48use serde_json::Value;
49use std::path::{Path, PathBuf};
50use thiserror::Error;
51
52/// Resolution config for `$agent_md` / `$file` refs. Ordered list of
53/// directories the linker walks first-hit-wins across 6 tiers.
54///
55/// Tier order (highest priority first):
56///
57/// 1. `base` — bp.lua parent directory (always tier 1).
58/// 2. `in_bp_includes` — in-bp declared `blueprint_ref_includes`
59///    (relative to `base`).
60/// 3. `env_includes` — env `MSE_BLUEPRINT_INCLUDES` (`:`- or `;`-
61///    separated absolute paths).
62/// 4. `cli_includes` — CLI `--include <path>` repeatable.
63/// 5. `config_includes` — server / config-file `blueprint_ref_includes`.
64/// 6. `bundled_default` — bundled fallback (typically
65///    `crates/mlua-swarm-cli/src/mcp/resources/samples/agents/`).
66///
67/// Callers construct the config via [`ResolveConfig::new`] and layer
68/// additional tiers through the builder methods, then pass the config
69/// to [`expand_file_refs_with_config`].
70#[derive(Debug, Clone, Default)]
71pub struct ResolveConfig {
72    /// bp.lua parent directory (always tier 1, always first).
73    pub base: PathBuf,
74    /// In-bp declared includes (tier 2, relative to `base`).
75    pub in_bp_includes: Vec<PathBuf>,
76    /// Env `MSE_BLUEPRINT_INCLUDES` (tier 3).
77    pub env_includes: Vec<PathBuf>,
78    /// CLI `--include <path>` repeatable (tier 4).
79    pub cli_includes: Vec<PathBuf>,
80    /// Server / config-file `blueprint_ref_includes` (tier 5).
81    pub config_includes: Vec<PathBuf>,
82    /// Bundled default (tier 6). `None` = no bundled fallback (typical
83    /// for server-side use, where the server never ships authoring
84    /// files).
85    pub bundled_default: Option<PathBuf>,
86}
87
88impl ResolveConfig {
89    /// Build a config with only the base tier set. Callers layer the
90    /// remaining tiers via builder methods.
91    pub fn new(base: impl Into<PathBuf>) -> Self {
92        Self {
93            base: base.into(),
94            ..Default::default()
95        }
96    }
97
98    /// Set the in-bp include list (tier 2). Paths are resolved relative
99    /// to `base` at search time.
100    pub fn with_in_bp_includes(mut self, v: Vec<PathBuf>) -> Self {
101        self.in_bp_includes = v;
102        self
103    }
104
105    /// Set the env include list (tier 3).
106    pub fn with_env_includes(mut self, v: Vec<PathBuf>) -> Self {
107        self.env_includes = v;
108        self
109    }
110
111    /// Set the CLI include list (tier 4).
112    pub fn with_cli_includes(mut self, v: Vec<PathBuf>) -> Self {
113        self.cli_includes = v;
114        self
115    }
116
117    /// Set the config-file include list (tier 5).
118    pub fn with_config_includes(mut self, v: Vec<PathBuf>) -> Self {
119        self.config_includes = v;
120        self
121    }
122
123    /// Set the bundled-default fallback (tier 6). Pass `None` to
124    /// disable the fallback (server-side default).
125    pub fn with_bundled_default(mut self, p: Option<PathBuf>) -> Self {
126        self.bundled_default = p;
127        self
128    }
129
130    /// Iterate every configured directory in cascade order (tier 1 → 6).
131    /// `in_bp_includes` entries are joined onto `base` at iteration
132    /// time so callers can pass in relative paths as declared in the
133    /// bp.lua source.
134    pub fn search_paths(&self) -> impl Iterator<Item = PathBuf> + '_ {
135        let base = self.base.clone();
136        std::iter::once(self.base.clone())
137            .chain(self.in_bp_includes.iter().map(move |p| base.join(p)))
138            .chain(self.env_includes.iter().cloned())
139            .chain(self.cli_includes.iter().cloned())
140            .chain(self.config_includes.iter().cloned())
141            .chain(self.bundled_default.iter().cloned())
142    }
143}
144
145/// Read `MSE_BLUEPRINT_INCLUDES` and split it into a directory list
146/// using the platform-native separator (`:` on Unix, `;` on Windows).
147/// Returns an empty vec when the variable is unset.
148pub fn env_blueprint_includes() -> Vec<PathBuf> {
149    std::env::var_os("MSE_BLUEPRINT_INCLUDES")
150        .map(|s| std::env::split_paths(&s).collect())
151        .unwrap_or_default()
152}
153
154/// Pull the top-level `blueprint_ref_includes` list out of the raw BP
155/// JSON. Returns an empty vec when the field is absent or malformed.
156/// Consumed by the CLI / server as tier 2 of the cascade.
157pub fn pre_read_in_bp_includes(val: &Value) -> Vec<PathBuf> {
158    val.get("blueprint_ref_includes")
159        .and_then(|v| v.as_array())
160        .map(|arr| {
161            arr.iter()
162                .filter_map(|v| v.as_str())
163                .map(PathBuf::from)
164                .collect()
165        })
166        .unwrap_or_default()
167}
168
169/// Everything that can go wrong while loading and `$file`/`$agent_md`
170/// expanding a Blueprint from disk.
171#[derive(Debug, Error)]
172pub enum LoadError {
173    /// Reading the Blueprint file (or a referenced `$file`/`$agent_md`)
174    /// failed.
175    #[error("io: {0}")]
176    Io(#[from] std::io::Error),
177    /// The `.json` file did not parse as JSON.
178    #[error("json parse: {0}")]
179    Json(#[from] serde_json::Error),
180    /// The `.yaml`/`.yml` file did not parse as YAML.
181    #[error("yaml parse: {0}")]
182    Yaml(#[from] serde_yaml::Error),
183    /// The file extension is not one of `.json` / `.yaml` / `.yml`.
184    #[error("unsupported extension: {0:?} (expected .json / .yaml / .yml)")]
185    UnknownFormat(Option<String>),
186    /// A `$file`/`$agent_md` ref failed path hygiene checks or the
187    /// referenced file could not be read/parsed.
188    #[error("$file ref expansion at {path:?}: {msg}")]
189    FileRef {
190        /// The resolved (or rejected) path of the ref.
191        path: PathBuf,
192        /// Human-readable description of what went wrong.
193        msg: String,
194    },
195    /// The expanded JSON value did not deserialize into a `Blueprint`.
196    #[error("blueprint shape invalid: {0}")]
197    Shape(String),
198}
199
200/// Load a Blueprint from a file path. Detects JSON vs. YAML by
201/// extension, recursively expands `$file` refs, and parses the result
202/// into a typed `Blueprint`.
203pub fn load_blueprint_from_path<P: AsRef<Path>>(path: P) -> Result<Blueprint, LoadError> {
204    let path = path.as_ref();
205    let raw = std::fs::read_to_string(path)?;
206    let ext = path
207        .extension()
208        .and_then(|e| e.to_str())
209        .map(|s| s.to_lowercase());
210    let value: Value = match ext.as_deref() {
211        Some("json") => serde_json::from_str(&raw)?,
212        Some("yaml") | Some("yml") => {
213            let yv: serde_yaml::Value = serde_yaml::from_str(&raw)?;
214            serde_json::to_value(yv)
215                .map_err(|e| LoadError::Shape(format!("yaml→json convert: {e}")))?
216        }
217        other => return Err(LoadError::UnknownFormat(other.map(|s| s.to_string()))),
218    };
219    let base = path
220        .parent()
221        .unwrap_or_else(|| Path::new("."))
222        .to_path_buf();
223    // Steps (1) and (3) of the four-layer cascade: pre-read the BP JSON's
224    // top-level `default_agent_kind`. If it is absent, fall back to the
225    // schema's `Default` impl (`Operator`). The value is passed into
226    // `expand_file_refs` and used as the loader-side kind default when a
227    // `$agent_md` has no sibling override. Step (2), the caller-side
228    // (CLI) override, is out of this function's scope — an upper layer
229    // (the server seed handler) is responsible for overwriting the
230    // pre-read value with the CLI value.
231    let default_kind = pre_read_default_agent_kind(&value);
232    let resolved = expand_file_refs(value, &base, default_kind)?;
233    let bp: Blueprint = serde_json::from_value(resolved)
234        .map_err(|e| LoadError::Shape(format!("typed parse: {e}")))?;
235    Ok(bp)
236}
237
238/// Pull `default_agent_kind` out of the raw BP JSON top level. Falls
239/// back to the schema's `Default` impl (`Operator`) if the key is
240/// missing or its type does not match. This is the first stage of
241/// resolving the default kind used inside `expand_file_refs` when a
242/// `$agent_md` has no sibling `kind` override.
243pub fn pre_read_default_agent_kind(val: &Value) -> AgentKind {
244    val.get("default_agent_kind")
245        .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
246        .unwrap_or_else(default_global_agent_kind)
247}
248
249/// Takes a JSON value: an object whose only key is `"$file": "path"` is
250/// replaced with the referenced file's contents; other objects / arrays
251/// recurse; scalars pass through unchanged.
252///
253/// Path hygiene: absolute paths and `..` parent-directory escapes are
254/// **rejected**, sandboxing all refs to the Blueprint's base-directory
255/// subtree. That structurally prevents accidentally pulling in
256/// `/etc/passwd` or `~/.ssh/id_rsa`. The trust boundary is spelled out
257/// explicitly.
258///
259/// Shared path hygiene for `$file` and `$agent_md`: absolute paths and
260/// `..` parent escapes are rejected; refs are searched across the
261/// 6-tier cascade in `cfg` (first-hit-wins). On miss, the error names
262/// every tier dir searched so authors can diagnose which include layer
263/// to add.
264fn resolve_ref_path(rel: &str, cfg: &ResolveConfig) -> Result<PathBuf, LoadError> {
265    let rel_path = Path::new(rel);
266    if rel_path.is_absolute() {
267        return Err(LoadError::FileRef {
268            path: rel_path.to_path_buf(),
269            msg: "absolute path not allowed (must be relative to Blueprint dir)".into(),
270        });
271    }
272    if rel_path
273        .components()
274        .any(|c| matches!(c, std::path::Component::ParentDir))
275    {
276        return Err(LoadError::FileRef {
277            path: rel_path.to_path_buf(),
278            msg: "'..' parent-dir escape not allowed".into(),
279        });
280    }
281    let mut searched: Vec<PathBuf> = Vec::new();
282    for dir in cfg.search_paths() {
283        let candidate = dir.join(rel_path);
284        if candidate.exists() {
285            return Ok(candidate);
286        }
287        searched.push(dir);
288    }
289    let searched_str = searched
290        .iter()
291        .map(|p| p.display().to_string())
292        .collect::<Vec<_>>()
293        .join(", ");
294    Err(LoadError::FileRef {
295        path: rel_path.to_path_buf(),
296        msg: format!("not found in include cascade (searched: {searched_str})"),
297    })
298}
299
300/// Primary entry — recursively expands `$file` / `$agent_md` refs
301/// across the 6-tier include cascade defined in `cfg`. Prefer this
302/// entry over [`expand_file_refs`] for any new caller that wants
303/// cascade-aware resolution.
304///
305/// `default_kind` is the fallback used when a `$agent_md` has no
306/// sibling `kind` — it should already be resolved by upper layers of
307/// the four-layer kind cascade. Callers resolve the BP top-level
308/// `default_agent_kind` and any CLI override before calling this
309/// function and pass in the literal kind.
310pub fn expand_file_refs_with_config(
311    val: Value,
312    cfg: &ResolveConfig,
313    default_kind: AgentKind,
314) -> Result<Value, LoadError> {
315    match val {
316        Value::Object(map) => {
317            // `$file`: a single-key raw-string substitution.
318            if map.len() == 1 {
319                if let Some(Value::String(rel)) = map.get("$file") {
320                    let full = resolve_ref_path(rel, cfg)?;
321                    let content =
322                        std::fs::read_to_string(&full).map_err(|e| LoadError::FileRef {
323                            path: full.clone(),
324                            msg: e.to_string(),
325                        })?;
326                    return Ok(Value::String(content));
327                }
328            }
329            // `$agent_md` accepts either a single-key object or an object
330            // with sibling keys. Sibling keys are shallow-merged onto the
331            // expanded AgentDef object, so the caller's values override
332            // whatever the AgentDef itself carried. Typical use: keep the
333            // name and profile from the agent.md but override only
334            // `spec.operator_ref` or `meta` at the call site.
335            //
336            // The merge is shallow at the AgentDef's top level, so a
337            // map-valued key replaces the md's map wholesale rather than
338            // merging into it: a sibling `lints` overrides the whole
339            // frontmatter `lints:` map (`{}` disables every level the
340            // agent.md declared), it does not add entries to it.
341            //
342            // Kind resolution cascade: (a) if a sibling `"kind"` literal
343            // is present, use it as-is; (b) otherwise, fall back to the
344            // `default_kind` argument, which the caller already resolved
345            // upstream from BP `default_agent_kind` or the CLI default.
346            if let Some(Value::String(rel)) = map.get("$agent_md") {
347                let full = resolve_ref_path(rel, cfg)?;
348                // Peek at the sibling "kind"; fall back to `default_kind`
349                // if absent.
350                let resolved_kind = map
351                    .get("kind")
352                    .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
353                    .unwrap_or_else(|| default_kind.clone());
354                let def = crate::agent_md::load_file(&full, resolved_kind).map_err(|e| {
355                    LoadError::FileRef {
356                        path: full.clone(),
357                        msg: format!("agent_md parse: {e}"),
358                    }
359                })?;
360                let mut def_v = serde_json::to_value(&def).map_err(|e| LoadError::FileRef {
361                    path: full.clone(),
362                    msg: format!("agent_md serialize: {e}"),
363                })?;
364                if let Value::Object(def_map) = &mut def_v {
365                    for (k, v) in map {
366                        if k == "$agent_md" {
367                            continue;
368                        }
369                        // Recursively expand the sibling before applying
370                        // it as a shallow override.
371                        let expanded = expand_file_refs_with_config(v, cfg, default_kind.clone())?;
372                        def_map.insert(k, expanded);
373                    }
374                }
375                return Ok(def_v);
376            }
377            let mut new_map = serde_json::Map::with_capacity(map.len());
378            for (k, v) in map {
379                new_map.insert(
380                    k,
381                    expand_file_refs_with_config(v, cfg, default_kind.clone())?,
382                );
383            }
384            Ok(Value::Object(new_map))
385        }
386        Value::Array(arr) => {
387            let mut new_arr = Vec::with_capacity(arr.len());
388            for v in arr {
389                new_arr.push(expand_file_refs_with_config(v, cfg, default_kind.clone())?);
390            }
391            Ok(Value::Array(new_arr))
392        }
393        other => Ok(other),
394    }
395}
396
397/// Backward-compat adapter — resolves refs against a single-tier
398/// cascade (only `base`). New callers should use
399/// [`expand_file_refs_with_config`] to opt into the full cascade.
400pub fn expand_file_refs(
401    val: Value,
402    base: &Path,
403    default_kind: AgentKind,
404) -> Result<Value, LoadError> {
405    let cfg = ResolveConfig::new(base.to_path_buf());
406    expand_file_refs_with_config(val, &cfg, default_kind)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use serde_json::json;
413    use std::fs;
414    use tempfile::TempDir;
415
416    fn write_md(dir: &Path, rel: &str, content: &str) -> PathBuf {
417        let p = dir.join(rel);
418        if let Some(parent) = p.parent() {
419            fs::create_dir_all(parent).unwrap();
420        }
421        fs::write(&p, content).unwrap();
422        p
423    }
424
425    const AGENT_MD: &str = "---\n\
426name: researcher\n\
427description: focus on XX/YY sites\n\
428model: sonnet\n\
429---\n\
430You are a researcher. Focus on XX/YY sites.\n";
431
432    #[test]
433    fn agent_md_ref_expands_to_typed_agent_def_object() {
434        let dir = TempDir::new().unwrap();
435        write_md(dir.path(), "agents/r.md", AGENT_MD);
436
437        let bp = json!({
438            "agents": [ { "$agent_md": "agents/r.md" } ]
439        });
440        let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
441
442        let agent = &resolved["agents"][0];
443        assert!(agent.is_object(), "expanded value is JSON object");
444        assert_eq!(agent["name"], "researcher");
445        assert_eq!(agent["kind"], "operator", "default kind from loader");
446        assert!(
447            agent["profile"]["system_prompt"]
448                .as_str()
449                .unwrap()
450                .contains("You are a researcher"),
451            "profile.system_prompt baked from body, got: {:?}",
452            agent["profile"]
453        );
454    }
455
456    #[test]
457    fn agent_md_ref_rejects_absolute_path() {
458        let dir = TempDir::new().unwrap();
459        let bp = json!({ "$agent_md": "/etc/passwd" });
460        let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err("abs rejected");
461        assert!(format!("{err}").contains("absolute path"), "got: {err}");
462    }
463
464    #[test]
465    fn agent_md_ref_rejects_parent_dir_escape() {
466        let dir = TempDir::new().unwrap();
467        let bp = json!({ "$agent_md": "../escape.md" });
468        let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err(".. rejected");
469        assert!(format!("{err}").contains("parent-dir escape"), "got: {err}");
470    }
471
472    #[test]
473    fn agent_md_ref_merges_sibling_keys_as_shallow_override() {
474        let dir = TempDir::new().unwrap();
475        write_md(dir.path(), "agents/r.md", AGENT_MD);
476        let bp = json!({
477            "$agent_md": "agents/r.md",
478            "spec": { "operator_ref": "ws-sid-42" },
479        });
480        let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
481        assert_eq!(resolved["name"], "researcher", "name from md preserved");
482        assert_eq!(
483            resolved["spec"]["operator_ref"], "ws-sid-42",
484            "sibling spec overrides md default (= Null)"
485        );
486        assert!(
487            resolved["profile"]["system_prompt"]
488                .as_str()
489                .unwrap()
490                .contains("You are a researcher"),
491            "profile from md preserved"
492        );
493    }
494
495    /// The frontmatter `lints:` map lands on the expanded AgentDef, and
496    /// a sibling `lints` key replaces it wholesale (shallow override —
497    /// map values are not merged key by key).
498    #[test]
499    fn agent_md_ref_sibling_lints_override_the_frontmatter_map() {
500        let dir = TempDir::new().unwrap();
501        write_md(
502            dir.path(),
503            "agents/r.md",
504            "---\nname: researcher\nlints:\n  agent-md-size: allow\n  \"category:style\": warn\n---\nYou are a researcher.\n",
505        );
506
507        // No sibling: the frontmatter map is what the AgentDef carries.
508        let from_md = expand_file_refs(
509            json!({ "$agent_md": "agents/r.md" }),
510            dir.path(),
511            AgentKind::Operator,
512        )
513        .expect("expand ok");
514        assert_eq!(
515            from_md["lints"],
516            json!({"agent-md-size": "allow", "category:style": "warn"})
517        );
518
519        // Sibling present: the whole map is replaced, not merged.
520        let overridden = expand_file_refs(
521            json!({
522                "$agent_md": "agents/r.md",
523                "lints": { "verdict-value-unhandled": "deny" },
524            }),
525            dir.path(),
526            AgentKind::Operator,
527        )
528        .expect("expand ok");
529        assert_eq!(
530            overridden["lints"],
531            json!({"verdict-value-unhandled": "deny"}),
532            "sibling lints replaces the frontmatter map wholesale"
533        );
534        assert_eq!(overridden["name"], "researcher", "name from md preserved");
535    }
536
537    #[test]
538    fn file_ref_still_returns_raw_string_unchanged() {
539        let dir = TempDir::new().unwrap();
540        write_md(dir.path(), "prompts/raw.md", "raw body content");
541        let bp = json!({ "$file": "prompts/raw.md" });
542        let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
543        assert_eq!(resolved, json!("raw body content"));
544    }
545
546    // ────────────────────────────────────────────────────────────────
547    // Include-cascade tests (Phase 3 — GH issue 4c4e3eb8)
548    // ────────────────────────────────────────────────────────────────
549
550    #[test]
551    fn cascade_falls_through_tiers() {
552        // Same filename in three distinct dirs, each with a different body;
553        // verify only the highest-priority tier hit is used.
554        let base_dir = TempDir::new().unwrap();
555        let cli_dir = TempDir::new().unwrap();
556        let bundled_dir = TempDir::new().unwrap();
557
558        // Tier 1 (base): the "correct" one.
559        write_md(base_dir.path(), "prompts/x.md", "from-base");
560        // Tier 4 (cli): shadowed by base.
561        write_md(cli_dir.path(), "prompts/x.md", "from-cli");
562        // Tier 6 (bundled): shadowed by base and cli.
563        write_md(bundled_dir.path(), "prompts/x.md", "from-bundled");
564
565        let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
566            .with_cli_includes(vec![cli_dir.path().to_path_buf()])
567            .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
568        let bp = json!({ "$file": "prompts/x.md" });
569        let resolved =
570            expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
571        assert_eq!(resolved, json!("from-base"));
572    }
573
574    #[test]
575    fn cascade_reports_all_searched_paths_on_miss() {
576        // No file exists in any tier — error message must name every
577        // searched dir so the author can diagnose the miss.
578        let base_dir = TempDir::new().unwrap();
579        let cli_a = TempDir::new().unwrap();
580        let cli_b = TempDir::new().unwrap();
581
582        let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
583            .with_cli_includes(vec![cli_a.path().to_path_buf(), cli_b.path().to_path_buf()]);
584        let bp = json!({ "$file": "prompts/missing.md" });
585        let err = expand_file_refs_with_config(bp, &cfg, AgentKind::Operator)
586            .expect_err("miss reports cascade");
587        let msg = format!("{err}");
588        assert!(
589            msg.contains(base_dir.path().to_str().unwrap()),
590            "base dir named: {msg}"
591        );
592        assert!(
593            msg.contains(cli_a.path().to_str().unwrap()),
594            "cli_a dir named: {msg}"
595        );
596        assert!(
597            msg.contains(cli_b.path().to_str().unwrap()),
598            "cli_b dir named: {msg}"
599        );
600        assert!(msg.contains("cascade"), "message flags cascade: {msg}");
601    }
602
603    #[test]
604    fn env_includes_split_multi_paths() {
605        // `env_blueprint_includes` splits `MSE_BLUEPRINT_INCLUDES` on the
606        // platform separator. Set → read → unset to avoid poisoning
607        // sibling tests.
608        let old = std::env::var_os("MSE_BLUEPRINT_INCLUDES");
609        let sep = if cfg!(windows) { ';' } else { ':' };
610        std::env::set_var(
611            "MSE_BLUEPRINT_INCLUDES",
612            format!("/tmp/aaa{sep}/tmp/bbb{sep}/tmp/ccc"),
613        );
614        let got = env_blueprint_includes();
615        // Restore before asserting so a failed assert still leaves the
616        // env clean.
617        match old {
618            Some(v) => std::env::set_var("MSE_BLUEPRINT_INCLUDES", v),
619            None => std::env::remove_var("MSE_BLUEPRINT_INCLUDES"),
620        }
621        assert_eq!(
622            got,
623            vec![
624                PathBuf::from("/tmp/aaa"),
625                PathBuf::from("/tmp/bbb"),
626                PathBuf::from("/tmp/ccc"),
627            ]
628        );
629    }
630
631    #[test]
632    fn in_bp_includes_reader_returns_empty_when_absent() {
633        let bp = json!({ "id": "no-includes" });
634        assert!(pre_read_in_bp_includes(&bp).is_empty());
635
636        let bp2 = json!({
637            "id": "with-includes",
638            "blueprint_ref_includes": ["ext/agents", "vendor/samples"],
639        });
640        assert_eq!(
641            pre_read_in_bp_includes(&bp2),
642            vec![PathBuf::from("ext/agents"), PathBuf::from("vendor/samples")]
643        );
644    }
645
646    #[test]
647    fn absolute_and_parent_escape_still_rejected_across_cascade() {
648        // Hygiene stays regardless of which cascade tiers are configured
649        // — absolute paths and `..` are rejected before any tier is
650        // walked.
651        let base = TempDir::new().unwrap();
652        let extra = TempDir::new().unwrap();
653        let cfg = ResolveConfig::new(base.path().to_path_buf())
654            .with_cli_includes(vec![extra.path().to_path_buf()]);
655
656        let err_abs = expand_file_refs_with_config(
657            json!({ "$file": "/etc/passwd" }),
658            &cfg,
659            AgentKind::Operator,
660        )
661        .expect_err("absolute rejected");
662        assert!(
663            format!("{err_abs}").contains("absolute path"),
664            "got: {err_abs}"
665        );
666
667        let err_parent = expand_file_refs_with_config(
668            json!({ "$file": "../escape.md" }),
669            &cfg,
670            AgentKind::Operator,
671        )
672        .expect_err(".. rejected");
673        assert!(
674            format!("{err_parent}").contains("parent-dir escape"),
675            "got: {err_parent}"
676        );
677    }
678
679    #[test]
680    fn bundled_default_used_only_when_no_other_match() {
681        // Bundled default (tier 6) is the last resort — used only when
682        // none of tiers 1-5 match.
683        let base_dir = TempDir::new().unwrap();
684        let bundled_dir = TempDir::new().unwrap();
685        write_md(bundled_dir.path(), "prompts/y.md", "from-bundled");
686
687        let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
688            .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
689        let bp = json!({ "$file": "prompts/y.md" });
690        let resolved =
691            expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
692        assert_eq!(resolved, json!("from-bundled"));
693    }
694}