1use mlua_swarm_schema::{default_global_agent_kind, AgentKind, Blueprint};
42use serde_json::Value;
43use std::path::{Path, PathBuf};
44use thiserror::Error;
45
46#[derive(Debug, Clone, Default)]
65pub struct ResolveConfig {
66 pub base: PathBuf,
68 pub in_bp_includes: Vec<PathBuf>,
70 pub env_includes: Vec<PathBuf>,
72 pub cli_includes: Vec<PathBuf>,
74 pub config_includes: Vec<PathBuf>,
76 pub bundled_default: Option<PathBuf>,
80}
81
82impl ResolveConfig {
83 pub fn new(base: impl Into<PathBuf>) -> Self {
86 Self {
87 base: base.into(),
88 ..Default::default()
89 }
90 }
91
92 pub fn with_in_bp_includes(mut self, v: Vec<PathBuf>) -> Self {
95 self.in_bp_includes = v;
96 self
97 }
98
99 pub fn with_env_includes(mut self, v: Vec<PathBuf>) -> Self {
101 self.env_includes = v;
102 self
103 }
104
105 pub fn with_cli_includes(mut self, v: Vec<PathBuf>) -> Self {
107 self.cli_includes = v;
108 self
109 }
110
111 pub fn with_config_includes(mut self, v: Vec<PathBuf>) -> Self {
113 self.config_includes = v;
114 self
115 }
116
117 pub fn with_bundled_default(mut self, p: Option<PathBuf>) -> Self {
120 self.bundled_default = p;
121 self
122 }
123
124 pub fn search_paths(&self) -> impl Iterator<Item = PathBuf> + '_ {
129 let base = self.base.clone();
130 std::iter::once(self.base.clone())
131 .chain(self.in_bp_includes.iter().map(move |p| base.join(p)))
132 .chain(self.env_includes.iter().cloned())
133 .chain(self.cli_includes.iter().cloned())
134 .chain(self.config_includes.iter().cloned())
135 .chain(self.bundled_default.iter().cloned())
136 }
137}
138
139pub fn env_blueprint_includes() -> Vec<PathBuf> {
143 std::env::var_os("MSE_BLUEPRINT_INCLUDES")
144 .map(|s| std::env::split_paths(&s).collect())
145 .unwrap_or_default()
146}
147
148pub fn pre_read_in_bp_includes(val: &Value) -> Vec<PathBuf> {
152 val.get("blueprint_ref_includes")
153 .and_then(|v| v.as_array())
154 .map(|arr| {
155 arr.iter()
156 .filter_map(|v| v.as_str())
157 .map(PathBuf::from)
158 .collect()
159 })
160 .unwrap_or_default()
161}
162
163#[derive(Debug, Error)]
166pub enum LoadError {
167 #[error("io: {0}")]
170 Io(#[from] std::io::Error),
171 #[error("json parse: {0}")]
173 Json(#[from] serde_json::Error),
174 #[error("yaml parse: {0}")]
176 Yaml(#[from] serde_yaml::Error),
177 #[error("unsupported extension: {0:?} (expected .json / .yaml / .yml)")]
179 UnknownFormat(Option<String>),
180 #[error("$file ref expansion at {path:?}: {msg}")]
183 FileRef {
184 path: PathBuf,
186 msg: String,
188 },
189 #[error("blueprint shape invalid: {0}")]
191 Shape(String),
192}
193
194pub fn load_blueprint_from_path<P: AsRef<Path>>(path: P) -> Result<Blueprint, LoadError> {
198 let path = path.as_ref();
199 let raw = std::fs::read_to_string(path)?;
200 let ext = path
201 .extension()
202 .and_then(|e| e.to_str())
203 .map(|s| s.to_lowercase());
204 let value: Value = match ext.as_deref() {
205 Some("json") => serde_json::from_str(&raw)?,
206 Some("yaml") | Some("yml") => {
207 let yv: serde_yaml::Value = serde_yaml::from_str(&raw)?;
208 serde_json::to_value(yv)
209 .map_err(|e| LoadError::Shape(format!("yaml→json convert: {e}")))?
210 }
211 other => return Err(LoadError::UnknownFormat(other.map(|s| s.to_string()))),
212 };
213 let base = path
214 .parent()
215 .unwrap_or_else(|| Path::new("."))
216 .to_path_buf();
217 let default_kind = pre_read_default_agent_kind(&value);
226 let resolved = expand_file_refs(value, &base, default_kind)?;
227 let bp: Blueprint = serde_json::from_value(resolved)
228 .map_err(|e| LoadError::Shape(format!("typed parse: {e}")))?;
229 Ok(bp)
230}
231
232pub fn pre_read_default_agent_kind(val: &Value) -> AgentKind {
238 val.get("default_agent_kind")
239 .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
240 .unwrap_or_else(default_global_agent_kind)
241}
242
243fn resolve_ref_path(rel: &str, cfg: &ResolveConfig) -> Result<PathBuf, LoadError> {
259 let rel_path = Path::new(rel);
260 if rel_path.is_absolute() {
261 return Err(LoadError::FileRef {
262 path: rel_path.to_path_buf(),
263 msg: "absolute path not allowed (must be relative to Blueprint dir)".into(),
264 });
265 }
266 if rel_path
267 .components()
268 .any(|c| matches!(c, std::path::Component::ParentDir))
269 {
270 return Err(LoadError::FileRef {
271 path: rel_path.to_path_buf(),
272 msg: "'..' parent-dir escape not allowed".into(),
273 });
274 }
275 let mut searched: Vec<PathBuf> = Vec::new();
276 for dir in cfg.search_paths() {
277 let candidate = dir.join(rel_path);
278 if candidate.exists() {
279 return Ok(candidate);
280 }
281 searched.push(dir);
282 }
283 let searched_str = searched
284 .iter()
285 .map(|p| p.display().to_string())
286 .collect::<Vec<_>>()
287 .join(", ");
288 Err(LoadError::FileRef {
289 path: rel_path.to_path_buf(),
290 msg: format!("not found in include cascade (searched: {searched_str})"),
291 })
292}
293
294pub fn expand_file_refs_with_config(
305 val: Value,
306 cfg: &ResolveConfig,
307 default_kind: AgentKind,
308) -> Result<Value, LoadError> {
309 match val {
310 Value::Object(map) => {
311 if map.len() == 1 {
313 if let Some(Value::String(rel)) = map.get("$file") {
314 let full = resolve_ref_path(rel, cfg)?;
315 let content =
316 std::fs::read_to_string(&full).map_err(|e| LoadError::FileRef {
317 path: full.clone(),
318 msg: e.to_string(),
319 })?;
320 return Ok(Value::String(content));
321 }
322 }
323 if let Some(Value::String(rel)) = map.get("$agent_md") {
335 let full = resolve_ref_path(rel, cfg)?;
336 let resolved_kind = map
339 .get("kind")
340 .and_then(|v| serde_json::from_value::<AgentKind>(v.clone()).ok())
341 .unwrap_or_else(|| default_kind.clone());
342 let def = crate::agent_md::load_file(&full, resolved_kind).map_err(|e| {
343 LoadError::FileRef {
344 path: full.clone(),
345 msg: format!("agent_md parse: {e}"),
346 }
347 })?;
348 let mut def_v = serde_json::to_value(&def).map_err(|e| LoadError::FileRef {
349 path: full.clone(),
350 msg: format!("agent_md serialize: {e}"),
351 })?;
352 if let Value::Object(def_map) = &mut def_v {
353 for (k, v) in map {
354 if k == "$agent_md" {
355 continue;
356 }
357 let expanded = expand_file_refs_with_config(v, cfg, default_kind.clone())?;
360 def_map.insert(k, expanded);
361 }
362 }
363 return Ok(def_v);
364 }
365 let mut new_map = serde_json::Map::with_capacity(map.len());
366 for (k, v) in map {
367 new_map.insert(
368 k,
369 expand_file_refs_with_config(v, cfg, default_kind.clone())?,
370 );
371 }
372 Ok(Value::Object(new_map))
373 }
374 Value::Array(arr) => {
375 let mut new_arr = Vec::with_capacity(arr.len());
376 for v in arr {
377 new_arr.push(expand_file_refs_with_config(v, cfg, default_kind.clone())?);
378 }
379 Ok(Value::Array(new_arr))
380 }
381 other => Ok(other),
382 }
383}
384
385pub fn expand_file_refs(
389 val: Value,
390 base: &Path,
391 default_kind: AgentKind,
392) -> Result<Value, LoadError> {
393 let cfg = ResolveConfig::new(base.to_path_buf());
394 expand_file_refs_with_config(val, &cfg, default_kind)
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use serde_json::json;
401 use std::fs;
402 use tempfile::TempDir;
403
404 fn write_md(dir: &Path, rel: &str, content: &str) -> PathBuf {
405 let p = dir.join(rel);
406 if let Some(parent) = p.parent() {
407 fs::create_dir_all(parent).unwrap();
408 }
409 fs::write(&p, content).unwrap();
410 p
411 }
412
413 const AGENT_MD: &str = "---\n\
414name: researcher\n\
415description: focus on XX/YY sites\n\
416model: sonnet\n\
417---\n\
418You are a researcher. Focus on XX/YY sites.\n";
419
420 #[test]
421 fn agent_md_ref_expands_to_typed_agent_def_object() {
422 let dir = TempDir::new().unwrap();
423 write_md(dir.path(), "agents/r.md", AGENT_MD);
424
425 let bp = json!({
426 "agents": [ { "$agent_md": "agents/r.md" } ]
427 });
428 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
429
430 let agent = &resolved["agents"][0];
431 assert!(agent.is_object(), "expanded value is JSON object");
432 assert_eq!(agent["name"], "researcher");
433 assert_eq!(agent["kind"], "operator", "default kind from loader");
434 assert!(
435 agent["profile"]["system_prompt"]
436 .as_str()
437 .unwrap()
438 .contains("You are a researcher"),
439 "profile.system_prompt baked from body, got: {:?}",
440 agent["profile"]
441 );
442 }
443
444 #[test]
445 fn agent_md_ref_rejects_absolute_path() {
446 let dir = TempDir::new().unwrap();
447 let bp = json!({ "$agent_md": "/etc/passwd" });
448 let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err("abs rejected");
449 assert!(format!("{err}").contains("absolute path"), "got: {err}");
450 }
451
452 #[test]
453 fn agent_md_ref_rejects_parent_dir_escape() {
454 let dir = TempDir::new().unwrap();
455 let bp = json!({ "$agent_md": "../escape.md" });
456 let err = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect_err(".. rejected");
457 assert!(format!("{err}").contains("parent-dir escape"), "got: {err}");
458 }
459
460 #[test]
461 fn agent_md_ref_merges_sibling_keys_as_shallow_override() {
462 let dir = TempDir::new().unwrap();
463 write_md(dir.path(), "agents/r.md", AGENT_MD);
464 let bp = json!({
465 "$agent_md": "agents/r.md",
466 "spec": { "operator_ref": "ws-sid-42" },
467 });
468 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
469 assert_eq!(resolved["name"], "researcher", "name from md preserved");
470 assert_eq!(
471 resolved["spec"]["operator_ref"], "ws-sid-42",
472 "sibling spec overrides md default (= Null)"
473 );
474 assert!(
475 resolved["profile"]["system_prompt"]
476 .as_str()
477 .unwrap()
478 .contains("You are a researcher"),
479 "profile from md preserved"
480 );
481 }
482
483 #[test]
484 fn file_ref_still_returns_raw_string_unchanged() {
485 let dir = TempDir::new().unwrap();
486 write_md(dir.path(), "prompts/raw.md", "raw body content");
487 let bp = json!({ "$file": "prompts/raw.md" });
488 let resolved = expand_file_refs(bp, dir.path(), AgentKind::Operator).expect("expand ok");
489 assert_eq!(resolved, json!("raw body content"));
490 }
491
492 #[test]
497 fn cascade_falls_through_tiers() {
498 let base_dir = TempDir::new().unwrap();
501 let cli_dir = TempDir::new().unwrap();
502 let bundled_dir = TempDir::new().unwrap();
503
504 write_md(base_dir.path(), "prompts/x.md", "from-base");
506 write_md(cli_dir.path(), "prompts/x.md", "from-cli");
508 write_md(bundled_dir.path(), "prompts/x.md", "from-bundled");
510
511 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
512 .with_cli_includes(vec![cli_dir.path().to_path_buf()])
513 .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
514 let bp = json!({ "$file": "prompts/x.md" });
515 let resolved =
516 expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
517 assert_eq!(resolved, json!("from-base"));
518 }
519
520 #[test]
521 fn cascade_reports_all_searched_paths_on_miss() {
522 let base_dir = TempDir::new().unwrap();
525 let cli_a = TempDir::new().unwrap();
526 let cli_b = TempDir::new().unwrap();
527
528 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
529 .with_cli_includes(vec![cli_a.path().to_path_buf(), cli_b.path().to_path_buf()]);
530 let bp = json!({ "$file": "prompts/missing.md" });
531 let err = expand_file_refs_with_config(bp, &cfg, AgentKind::Operator)
532 .expect_err("miss reports cascade");
533 let msg = format!("{err}");
534 assert!(
535 msg.contains(base_dir.path().to_str().unwrap()),
536 "base dir named: {msg}"
537 );
538 assert!(
539 msg.contains(cli_a.path().to_str().unwrap()),
540 "cli_a dir named: {msg}"
541 );
542 assert!(
543 msg.contains(cli_b.path().to_str().unwrap()),
544 "cli_b dir named: {msg}"
545 );
546 assert!(msg.contains("cascade"), "message flags cascade: {msg}");
547 }
548
549 #[test]
550 fn env_includes_split_multi_paths() {
551 let old = std::env::var_os("MSE_BLUEPRINT_INCLUDES");
555 let sep = if cfg!(windows) { ';' } else { ':' };
556 std::env::set_var(
557 "MSE_BLUEPRINT_INCLUDES",
558 format!("/tmp/aaa{sep}/tmp/bbb{sep}/tmp/ccc"),
559 );
560 let got = env_blueprint_includes();
561 match old {
564 Some(v) => std::env::set_var("MSE_BLUEPRINT_INCLUDES", v),
565 None => std::env::remove_var("MSE_BLUEPRINT_INCLUDES"),
566 }
567 assert_eq!(
568 got,
569 vec![
570 PathBuf::from("/tmp/aaa"),
571 PathBuf::from("/tmp/bbb"),
572 PathBuf::from("/tmp/ccc"),
573 ]
574 );
575 }
576
577 #[test]
578 fn in_bp_includes_reader_returns_empty_when_absent() {
579 let bp = json!({ "id": "no-includes" });
580 assert!(pre_read_in_bp_includes(&bp).is_empty());
581
582 let bp2 = json!({
583 "id": "with-includes",
584 "blueprint_ref_includes": ["ext/agents", "vendor/samples"],
585 });
586 assert_eq!(
587 pre_read_in_bp_includes(&bp2),
588 vec![PathBuf::from("ext/agents"), PathBuf::from("vendor/samples")]
589 );
590 }
591
592 #[test]
593 fn absolute_and_parent_escape_still_rejected_across_cascade() {
594 let base = TempDir::new().unwrap();
598 let extra = TempDir::new().unwrap();
599 let cfg = ResolveConfig::new(base.path().to_path_buf())
600 .with_cli_includes(vec![extra.path().to_path_buf()]);
601
602 let err_abs = expand_file_refs_with_config(
603 json!({ "$file": "/etc/passwd" }),
604 &cfg,
605 AgentKind::Operator,
606 )
607 .expect_err("absolute rejected");
608 assert!(
609 format!("{err_abs}").contains("absolute path"),
610 "got: {err_abs}"
611 );
612
613 let err_parent = expand_file_refs_with_config(
614 json!({ "$file": "../escape.md" }),
615 &cfg,
616 AgentKind::Operator,
617 )
618 .expect_err(".. rejected");
619 assert!(
620 format!("{err_parent}").contains("parent-dir escape"),
621 "got: {err_parent}"
622 );
623 }
624
625 #[test]
626 fn bundled_default_used_only_when_no_other_match() {
627 let base_dir = TempDir::new().unwrap();
630 let bundled_dir = TempDir::new().unwrap();
631 write_md(bundled_dir.path(), "prompts/y.md", "from-bundled");
632
633 let cfg = ResolveConfig::new(base_dir.path().to_path_buf())
634 .with_bundled_default(Some(bundled_dir.path().to_path_buf()));
635 let bp = json!({ "$file": "prompts/y.md" });
636 let resolved =
637 expand_file_refs_with_config(bp, &cfg, AgentKind::Operator).expect("expand ok");
638 assert_eq!(resolved, json!("from-bundled"));
639 }
640}