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