1#![allow(dead_code)]
25
26use std::collections::BTreeMap;
27use std::fs;
28use std::path::{Path, PathBuf};
29
30use serde::Deserialize;
31use thiserror::Error;
32
33const ALLOWED_TOP_KEYS: &[&str] = &[
34 "name",
35 "instructions",
36 "overview_prefix",
37 "source_root",
38 "source_roots",
39 "trust",
40 "tools",
41 "embedder",
42 "builtins",
43 "env_file",
44 "workspace",
45 "extensions",
46 "skills",
47];
48const ALLOWED_WORKSPACE_KEYS: &[&str] = &[
49 "kind",
50 "root",
51 "watch",
52 "applies_to",
53 "sandbox_root",
54 "adopt_client_roots",
55];
56const VALID_WORKSPACE_KIND: &[&str] = &["github", "local"];
57const ALLOWED_TRUST_KEYS: &[&str] = &["allow_python_tools", "allow_embedder"];
58const ALLOWED_TOOL_KEYS: &[&str] = &[
59 "name",
60 "description",
61 "parameters",
62 "cypher",
63 "python",
64 "function",
65 "bundled",
66 "hidden",
67 "rename",
71];
72const ALLOWED_EMBEDDER_KEYS: &[&str] = &["module", "class", "kwargs"];
73const ALLOWED_BUILTIN_KEYS: &[&str] = &["save_graph", "temp_cleanup", "screen_stargazers"];
74const VALID_TEMP_CLEANUP: &[&str] = &["never", "on_overview"];
75
76#[derive(Debug, Error)]
77#[error("{path}: {message}")]
78pub struct ManifestError {
79 pub path: String,
80 pub message: String,
81}
82
83impl ManifestError {
84 pub fn at(path: &Path, message: impl Into<String>) -> Self {
85 Self {
86 path: path.display().to_string(),
87 message: message.into(),
88 }
89 }
90
91 pub fn bare(message: impl Into<String>) -> Self {
92 Self {
93 path: "<manifest>".to_string(),
94 message: message.into(),
95 }
96 }
97}
98
99#[derive(Debug, Default, Clone)]
100pub struct TrustConfig {
101 pub allow_python_tools: bool,
102 pub allow_embedder: bool,
103}
104
105#[derive(Debug, Clone)]
106pub enum ToolSpec {
107 Cypher(CypherTool),
108 Python(PythonTool),
109 Bundled(BundledOverride),
125}
126
127impl ToolSpec {
128 pub fn name(&self) -> &str {
129 match self {
130 ToolSpec::Cypher(t) => &t.name,
131 ToolSpec::Python(t) => &t.name,
132 ToolSpec::Bundled(t) => &t.name,
133 }
134 }
135}
136
137#[derive(Debug, Clone)]
138pub struct CypherTool {
139 pub name: String,
140 pub cypher: String,
141 pub description: Option<String>,
142 pub parameters: Option<serde_json::Value>,
143}
144
145#[derive(Debug, Clone)]
146pub struct PythonTool {
147 pub name: String,
148 pub python: String,
149 pub function: String,
150 pub description: Option<String>,
151 pub parameters: Option<serde_json::Value>,
152}
153
154#[derive(Debug, Clone)]
155pub struct BundledOverride {
156 pub name: String,
161 pub description: Option<String>,
165 pub hidden: bool,
169 pub rename: Option<String>,
182}
183
184#[derive(Debug, Clone)]
185pub struct EmbedderConfig {
186 pub module: String,
187 pub class: String,
188 pub kwargs: serde_json::Map<String, serde_json::Value>,
189}
190
191#[derive(Debug, Clone)]
192pub struct BuiltinsConfig {
193 pub save_graph: bool,
194 pub temp_cleanup: TempCleanup,
195 pub screen_stargazers: bool,
199}
200
201impl Default for BuiltinsConfig {
202 fn default() -> Self {
203 Self {
204 save_graph: false,
205 temp_cleanup: TempCleanup::default(),
206 screen_stargazers: true,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
212pub enum TempCleanup {
213 #[default]
214 Never,
215 OnOverview,
216}
217
218impl TempCleanup {
219 pub fn as_str(&self) -> &'static str {
220 match self {
221 TempCleanup::Never => "never",
222 TempCleanup::OnOverview => "on_overview",
223 }
224 }
225}
226
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
228pub enum WorkspaceKind {
229 #[default]
232 Github,
233 Local,
236}
237
238impl WorkspaceKind {
239 pub fn as_str(&self) -> &'static str {
240 match self {
241 WorkspaceKind::Github => "github",
242 WorkspaceKind::Local => "local",
243 }
244 }
245}
246
247#[derive(Debug, Clone, Default)]
248pub struct WorkspaceConfig {
249 pub kind: WorkspaceKind,
250 pub root: Option<String>,
253 pub watch: bool,
256 pub sandbox_root: Option<String>,
266 pub adopt_client_roots: bool,
287 pub applies_to: Option<AppliesTo>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum AppliesTo {
323 Pattern(String),
328 Patterns(Vec<String>),
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
341pub enum SkillSource {
342 Bundled,
346 Path(String),
351}
352
353#[derive(Debug, Clone, Default, PartialEq, Eq)]
365pub enum SkillsSource {
366 #[default]
368 Disabled,
369 Sources(Vec<SkillSource>),
374}
375
376#[derive(Debug, Clone)]
377pub struct Manifest {
378 pub yaml_path: PathBuf,
379 pub name: Option<String>,
380 pub instructions: Option<String>,
381 pub overview_prefix: Option<String>,
382 pub source_roots: Vec<String>,
383 pub trust: TrustConfig,
384 pub tools: Vec<ToolSpec>,
385 pub embedder: Option<EmbedderConfig>,
386 pub builtins: BuiltinsConfig,
387 pub env_file: Option<String>,
391 pub workspace: Option<WorkspaceConfig>,
395 pub extensions: serde_json::Map<String, serde_json::Value>,
405 pub skills: SkillsSource,
417}
418
419impl Manifest {
420 pub fn to_json(&self) -> serde_json::Value {
430 serde_json::json!({
431 "yaml_path": self.yaml_path.display().to_string(),
432 "name": self.name,
433 "instructions": self.instructions,
434 "overview_prefix": self.overview_prefix,
435 "source_roots": self.source_roots,
436 "trust": {
437 "allow_python_tools": self.trust.allow_python_tools,
438 "allow_embedder": self.trust.allow_embedder,
439 },
440 "tools": self.tools.iter().map(|t| match t {
441 ToolSpec::Cypher(c) => serde_json::json!({
442 "kind": "cypher",
443 "name": c.name,
444 "cypher": c.cypher,
445 "description": c.description,
446 "parameters": c.parameters,
447 }),
448 ToolSpec::Python(p) => serde_json::json!({
449 "kind": "python",
450 "name": p.name,
451 "python": p.python,
452 "function": p.function,
453 "description": p.description,
454 "parameters": p.parameters,
455 }),
456 ToolSpec::Bundled(b) => serde_json::json!({
457 "kind": "bundled",
458 "name": b.name,
459 "description": b.description,
460 "hidden": b.hidden,
461 "rename": b.rename,
462 }),
463 }).collect::<Vec<_>>(),
464 "embedder": self.embedder.as_ref().map(|e| serde_json::json!({
465 "module": e.module,
466 "class": e.class,
467 "kwargs": e.kwargs,
468 })),
469 "builtins": {
470 "save_graph": self.builtins.save_graph,
471 "temp_cleanup": self.builtins.temp_cleanup.as_str(),
472 "screen_stargazers": self.builtins.screen_stargazers,
473 },
474 "env_file": self.env_file,
475 "workspace": self.workspace.as_ref().map(|w| serde_json::json!({
476 "kind": w.kind.as_str(),
477 "root": w.root,
478 "watch": w.watch,
479 "applies_to": w.applies_to.as_ref().map(|a| match a {
480 AppliesTo::Pattern(p) => serde_json::Value::String(p.clone()),
481 AppliesTo::Patterns(ps) => serde_json::Value::Array(
482 ps.iter().map(|p| serde_json::Value::String(p.clone())).collect()
483 ),
484 }),
485 })),
486 "extensions": self.extensions,
487 "skills": self.skills_to_json(),
488 })
489 }
490
491 fn skills_to_json(&self) -> serde_json::Value {
504 match &self.skills {
505 SkillsSource::Disabled => serde_json::Value::Bool(false),
506 SkillsSource::Sources(sources) => {
507 let arr: Vec<serde_json::Value> = sources
508 .iter()
509 .map(|s| match s {
510 SkillSource::Bundled => serde_json::Value::Bool(true),
511 SkillSource::Path(p) => serde_json::Value::String(p.clone()),
512 })
513 .collect();
514 serde_json::Value::Array(arr)
515 }
516 }
517 }
518}
519
520pub fn find_sibling_manifest(graph_path: &Path) -> Option<PathBuf> {
522 let stem = graph_path.file_stem()?;
523 let parent = graph_path.parent()?;
524 let candidate = parent.join(format!("{}_mcp.yaml", stem.to_string_lossy()));
525 if candidate.is_file() {
526 Some(candidate)
527 } else {
528 None
529 }
530}
531
532pub fn find_workspace_manifest(workspace_dir: &Path) -> Option<PathBuf> {
581 let primary = workspace_dir.join("workspace_mcp.yaml");
582 if primary.is_file() {
583 return Some(primary);
584 }
585 let parent = workspace_dir.parent()?;
588 let workspace_resolved = workspace_dir.canonicalize().ok()?;
589 let parent_resolved = parent.canonicalize().ok()?;
590 if parent_resolved == workspace_resolved {
591 return None;
593 }
594 let fallback = parent.join("workspace_mcp.yaml");
595 if !fallback.is_file() {
596 return None;
597 }
598
599 let manifest = match load(&fallback) {
603 Ok(m) => m,
604 Err(e) => {
605 tracing::warn!(
606 manifest = %fallback.display(),
607 error = %e,
608 "parent-walk manifest exists but failed to parse; ignoring"
609 );
610 return None;
611 }
612 };
613 let declared = manifest
614 .workspace
615 .as_ref()
616 .and_then(|w| w.applies_to.as_ref());
617 let Some(declared_applies_to) = declared else {
618 tracing::info!(
619 manifest = %fallback.display(),
620 "parent-walk manifest does not declare workspace.applies_to; \
621 ignoring (set workspace.applies_to: <pattern> to opt in)"
622 );
623 return None;
624 };
625 let Some(basename) = workspace_resolved.file_name().and_then(|n| n.to_str()) else {
629 return None; };
631 let patterns: Vec<&str> = match declared_applies_to {
632 AppliesTo::Pattern(p) => vec![p.as_str()],
633 AppliesTo::Patterns(ps) => ps.iter().map(String::as_str).collect(),
634 };
635 let matched = patterns.iter().any(|pat| {
636 match globset::Glob::new(pat) {
637 Ok(g) => g.compile_matcher().is_match(basename),
638 Err(_) => {
639 false
642 }
643 }
644 });
645 if matched {
646 tracing::info!(
647 workspace_dir = %workspace_dir.display(),
648 manifest = %fallback.display(),
649 "manifest discovered via parent-walk fallback (workspace.applies_to matched)"
650 );
651 Some(fallback)
652 } else {
653 tracing::info!(
654 workspace_dir = %workspace_resolved.display(),
655 manifest = %fallback.display(),
656 basename = %basename,
657 patterns = ?patterns,
658 "parent-walk manifest's workspace.applies_to does not match \
659 this workspace_dir's basename; ignoring"
660 );
661 None
662 }
663}
664
665pub fn load(yaml_path: &Path) -> Result<Manifest, ManifestError> {
667 let text = fs::read_to_string(yaml_path)
668 .map_err(|e| ManifestError::at(yaml_path, format!("read error: {e}")))?;
669 let raw: serde_yaml::Value = serde_yaml::from_str(&text)
670 .map_err(|e| ManifestError::at(yaml_path, format!("YAML parse error: {e}")))?;
671 let raw = match raw {
672 serde_yaml::Value::Null => serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
673 v => v,
674 };
675 let map = raw
676 .as_mapping()
677 .ok_or_else(|| ManifestError::at(yaml_path, "top-level must be a mapping"))?;
678 build(map, yaml_path)
679}
680
681fn build(raw: &serde_yaml::Mapping, yaml_path: &Path) -> Result<Manifest, ManifestError> {
682 check_keys(raw, ALLOWED_TOP_KEYS, "top-level keys", yaml_path)?;
683
684 if raw.contains_key("source_root") && raw.contains_key("source_roots") {
685 return Err(ManifestError::at(
686 yaml_path,
687 "specify either source_root (str) or source_roots (list), not both",
688 ));
689 }
690
691 let mut source_roots: Vec<String> = Vec::new();
692 if let Some(v) = raw.get("source_root") {
693 let s = v.as_str().filter(|s| !s.is_empty()).ok_or_else(|| {
694 ManifestError::at(yaml_path, "source_root must be a non-empty string")
695 })?;
696 source_roots.push(s.to_string());
697 } else if let Some(v) = raw.get("source_roots") {
698 let seq = v.as_sequence().ok_or_else(|| {
699 ManifestError::at(
700 yaml_path,
701 "source_roots must be a list of non-empty strings",
702 )
703 })?;
704 if seq.is_empty() {
705 return Err(ManifestError::at(
706 yaml_path,
707 "source_roots must be non-empty when set",
708 ));
709 }
710 for item in seq {
711 let s = item.as_str().filter(|s| !s.is_empty()).ok_or_else(|| {
712 ManifestError::at(
713 yaml_path,
714 "source_roots must be a list of non-empty strings",
715 )
716 })?;
717 source_roots.push(s.to_string());
718 }
719 }
720
721 let trust = build_trust(raw.get("trust"), yaml_path)?;
722 let tools = build_tools(raw.get("tools"), yaml_path)?;
723 let embedder = build_embedder(raw.get("embedder"), yaml_path)?;
724 let builtins = build_builtins(raw.get("builtins"), yaml_path)?;
725 let workspace = build_workspace(raw.get("workspace"), yaml_path)?;
726 let extensions = build_extensions(raw.get("extensions"), yaml_path)?;
727 let skills = build_skills(raw.get("skills"), yaml_path)?;
728
729 Ok(Manifest {
730 yaml_path: yaml_path.to_path_buf(),
731 name: optional_str(raw, "name", yaml_path)?,
732 instructions: optional_str(raw, "instructions", yaml_path)?,
733 overview_prefix: optional_str(raw, "overview_prefix", yaml_path)?,
734 source_roots,
735 trust,
736 tools,
737 embedder,
738 builtins,
739 env_file: optional_str(raw, "env_file", yaml_path)?,
740 workspace,
741 extensions,
742 skills,
743 })
744}
745
746fn build_skills(
768 raw: Option<&serde_yaml::Value>,
769 yaml_path: &Path,
770) -> Result<SkillsSource, ManifestError> {
771 use serde_yaml::Value;
772
773 match raw {
774 None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(SkillsSource::Disabled),
775 Some(Value::Bool(true)) => Ok(SkillsSource::Sources(vec![SkillSource::Bundled])),
776 Some(Value::String(s)) => {
777 if s.is_empty() {
778 return Err(ManifestError::at(
779 yaml_path,
780 "skills: path must be a non-empty string",
781 ));
782 }
783 Ok(SkillsSource::Sources(vec![SkillSource::Path(s.clone())]))
784 }
785 Some(Value::Sequence(seq)) => {
786 let mut sources = Vec::with_capacity(seq.len());
787 for (idx, item) in seq.iter().enumerate() {
788 match item {
789 Value::Bool(true) => sources.push(SkillSource::Bundled),
790 Value::Bool(false) => {
791 return Err(ManifestError::at(
792 yaml_path,
793 format!(
794 "skills[{idx}]: `false` is not a valid entry in a `skills:` \
795 list (only `true` for bundled, or a path string)"
796 ),
797 ));
798 }
799 Value::String(s) => {
800 if s.is_empty() {
801 return Err(ManifestError::at(
802 yaml_path,
803 format!("skills[{idx}]: path must be a non-empty string"),
804 ));
805 }
806 sources.push(SkillSource::Path(s.clone()));
807 }
808 _ => {
809 return Err(ManifestError::at(
810 yaml_path,
811 format!(
812 "skills[{idx}]: each entry must be `true` (for bundled) or a \
813 path string"
814 ),
815 ));
816 }
817 }
818 }
819 Ok(SkillsSource::Sources(sources))
820 }
821 Some(_) => Err(ManifestError::at(
822 yaml_path,
823 "skills must be `false`, `true`, a path string, or a list of \
824 (true | path string) entries",
825 )),
826 }
827}
828
829fn build_extensions(
830 raw: Option<&serde_yaml::Value>,
831 yaml_path: &Path,
832) -> Result<serde_json::Map<String, serde_json::Value>, ManifestError> {
833 let Some(raw) = raw else {
834 return Ok(serde_json::Map::new());
835 };
836 if matches!(raw, serde_yaml::Value::Null) {
837 return Ok(serde_json::Map::new());
838 }
839 if !raw.is_mapping() {
840 return Err(ManifestError::at(
841 yaml_path,
842 "extensions must be a mapping (downstream-binary-specific keys)",
843 ));
844 }
845 match yaml_to_json(raw.clone())? {
846 serde_json::Value::Object(o) => Ok(o),
847 _ => Err(ManifestError::at(yaml_path, "extensions must be a mapping")),
848 }
849}
850
851fn build_workspace(
852 raw: Option<&serde_yaml::Value>,
853 yaml_path: &Path,
854) -> Result<Option<WorkspaceConfig>, ManifestError> {
855 let Some(raw) = raw else { return Ok(None) };
856 if matches!(raw, serde_yaml::Value::Null) {
857 return Ok(None);
858 }
859 let map = raw
860 .as_mapping()
861 .ok_or_else(|| ManifestError::at(yaml_path, "workspace must be a mapping"))?;
862 check_keys(map, ALLOWED_WORKSPACE_KEYS, "workspace keys", yaml_path)?;
863 let kind = match map.get("kind") {
864 None | Some(serde_yaml::Value::Null) => WorkspaceKind::default(),
865 Some(serde_yaml::Value::String(s)) => match s.as_str() {
866 "github" => WorkspaceKind::Github,
867 "local" => WorkspaceKind::Local,
868 other => {
869 return Err(ManifestError::at(
870 yaml_path,
871 format!(
872 "workspace.kind must be one of {VALID_WORKSPACE_KIND:?}, got {other:?}"
873 ),
874 ));
875 }
876 },
877 Some(_) => {
878 return Err(ManifestError::at(
879 yaml_path,
880 format!("workspace.kind must be one of {VALID_WORKSPACE_KIND:?}"),
881 ))
882 }
883 };
884 let root = match map.get("root") {
885 None | Some(serde_yaml::Value::Null) => None,
886 Some(serde_yaml::Value::String(s)) if !s.is_empty() => Some(s.clone()),
887 _ => {
888 return Err(ManifestError::at(
889 yaml_path,
890 "workspace.root must be a non-empty string",
891 ))
892 }
893 };
894 let sandbox_root = match map.get("sandbox_root") {
895 None | Some(serde_yaml::Value::Null) => None,
896 Some(serde_yaml::Value::String(s)) if !s.is_empty() => Some(s.clone()),
897 _ => {
898 return Err(ManifestError::at(
899 yaml_path,
900 "workspace.sandbox_root must be a non-empty string",
901 ))
902 }
903 };
904 let watch = match map.get("watch") {
905 None | Some(serde_yaml::Value::Null) => false,
906 Some(serde_yaml::Value::Bool(b)) => *b,
907 Some(_) => {
908 return Err(ManifestError::at(
909 yaml_path,
910 "workspace.watch must be a bool",
911 ))
912 }
913 };
914 let adopt_client_roots = match map.get("adopt_client_roots") {
915 None | Some(serde_yaml::Value::Null) => false,
916 Some(serde_yaml::Value::Bool(b)) => *b,
917 Some(_) => {
918 return Err(ManifestError::at(
919 yaml_path,
920 "workspace.adopt_client_roots must be a bool",
921 ))
922 }
923 };
924 let applies_to =
925 match map.get("applies_to") {
926 None | Some(serde_yaml::Value::Null) => None,
927 Some(serde_yaml::Value::String(s)) => {
928 Some(AppliesTo::Pattern(parse_applies_to_pattern(s, yaml_path)?))
929 }
930 Some(serde_yaml::Value::Sequence(seq)) => {
931 if seq.is_empty() {
932 return Err(ManifestError::at(
933 yaml_path,
934 "workspace.applies_to: list must contain at least one pattern",
935 ));
936 }
937 let mut patterns = Vec::with_capacity(seq.len());
938 for (i, item) in seq.iter().enumerate() {
939 let s = item.as_str().ok_or_else(|| {
940 ManifestError::at(
941 yaml_path,
942 format!("workspace.applies_to[{i}] must be a string"),
943 )
944 })?;
945 let cleaned = parse_applies_to_pattern(s, yaml_path).map_err(|e| {
946 ManifestError::at(
947 yaml_path,
948 format!("workspace.applies_to[{i}]: {}", e.message),
949 )
950 })?;
951 patterns.push(cleaned);
952 }
953 Some(AppliesTo::Patterns(patterns))
954 }
955 _ => return Err(ManifestError::at(
956 yaml_path,
957 "workspace.applies_to must be a non-empty string (a pattern) or a list of patterns",
958 )),
959 };
960 if kind == WorkspaceKind::Local && root.is_none() && !adopt_client_roots {
965 return Err(ManifestError::at(
966 yaml_path,
967 "workspace.kind: local requires workspace.root to be set",
968 ));
969 }
970 if kind == WorkspaceKind::Local && watch && root.is_none() {
978 return Err(ManifestError::at(
979 yaml_path,
980 "workspace.watch requires workspace.root — an adoption-only \
981 workspace has nothing to watch at boot",
982 ));
983 }
984 if kind == WorkspaceKind::Github && watch {
985 return Err(ManifestError::at(
986 yaml_path,
987 "workspace.watch is only valid with workspace.kind: local",
988 ));
989 }
990 if kind == WorkspaceKind::Github && sandbox_root.is_some() {
991 return Err(ManifestError::at(
992 yaml_path,
993 "workspace.sandbox_root is only valid with workspace.kind: local",
994 ));
995 }
996 if kind == WorkspaceKind::Github && adopt_client_roots {
997 return Err(ManifestError::at(
998 yaml_path,
999 "workspace.adopt_client_roots is only valid with workspace.kind: local",
1000 ));
1001 }
1002 Ok(Some(WorkspaceConfig {
1003 kind,
1004 root,
1005 watch,
1006 applies_to,
1007 sandbox_root,
1008 adopt_client_roots,
1009 }))
1010}
1011
1012fn parse_applies_to_pattern(raw: &str, yaml_path: &Path) -> Result<String, ManifestError> {
1021 let trimmed = raw.trim();
1022 if trimmed.is_empty() {
1023 return Err(ManifestError::at(
1024 yaml_path,
1025 "workspace.applies_to: pattern must not be empty",
1026 ));
1027 }
1028 let stripped = trimmed.strip_prefix("./").unwrap_or(trimmed);
1032 if stripped.is_empty() {
1033 return Err(ManifestError::at(
1034 yaml_path,
1035 "workspace.applies_to: pattern must not be empty after stripping `./` prefix",
1036 ));
1037 }
1038 if stripped.contains('/') {
1039 return Err(ManifestError::at(
1040 yaml_path,
1041 format!(
1042 "workspace.applies_to: pattern {raw:?} must be a single path segment \
1043 (no embedded `/`) — parent-walk discovery is bounded to one level"
1044 ),
1045 ));
1046 }
1047 if stripped == ".." || stripped.starts_with("../") {
1048 return Err(ManifestError::at(
1049 yaml_path,
1050 format!("workspace.applies_to: pattern {raw:?} must not contain `..`"),
1051 ));
1052 }
1053 if Path::new(stripped).is_absolute() {
1054 return Err(ManifestError::at(
1055 yaml_path,
1056 format!("workspace.applies_to: pattern {raw:?} must be relative, not absolute"),
1057 ));
1058 }
1059 globset::Glob::new(stripped).map_err(|e| {
1063 ManifestError::at(
1064 yaml_path,
1065 format!("workspace.applies_to: invalid glob pattern {raw:?}: {e}"),
1066 )
1067 })?;
1068 Ok(stripped.to_string())
1069}
1070
1071fn check_keys(
1072 map: &serde_yaml::Mapping,
1073 allowed: &[&str],
1074 label: &str,
1075 yaml_path: &Path,
1076) -> Result<(), ManifestError> {
1077 let mut unknown: Vec<String> = Vec::new();
1078 for (k, _) in map {
1079 let key = k.as_str().unwrap_or("<non-string-key>");
1080 if !allowed.contains(&key) {
1081 unknown.push(key.to_string());
1082 }
1083 }
1084 if !unknown.is_empty() {
1085 unknown.sort();
1086 return Err(ManifestError::at(
1087 yaml_path,
1088 format!("unknown {label}: {unknown:?}. Allowed: {allowed:?}"),
1089 ));
1090 }
1091 Ok(())
1092}
1093
1094fn optional_str(
1095 raw: &serde_yaml::Mapping,
1096 key: &str,
1097 yaml_path: &Path,
1098) -> Result<Option<String>, ManifestError> {
1099 match raw.get(key) {
1100 None | Some(serde_yaml::Value::Null) => Ok(None),
1101 Some(serde_yaml::Value::String(s)) => Ok(Some(s.clone())),
1102 Some(_) => Err(ManifestError::at(
1103 yaml_path,
1104 format!("{key} must be a string"),
1105 )),
1106 }
1107}
1108
1109fn build_trust(
1110 raw: Option<&serde_yaml::Value>,
1111 yaml_path: &Path,
1112) -> Result<TrustConfig, ManifestError> {
1113 let Some(raw) = raw else {
1114 return Ok(TrustConfig::default());
1115 };
1116 let map = raw
1117 .as_mapping()
1118 .ok_or_else(|| ManifestError::at(yaml_path, "trust must be a mapping"))?;
1119 check_keys(map, ALLOWED_TRUST_KEYS, "trust keys", yaml_path)?;
1120 let mut cfg = TrustConfig::default();
1121 if let Some(v) = map.get("allow_python_tools") {
1122 cfg.allow_python_tools = v.as_bool().ok_or_else(|| {
1123 ManifestError::at(yaml_path, "trust.allow_python_tools must be a bool")
1124 })?;
1125 }
1126 if let Some(v) = map.get("allow_embedder") {
1127 cfg.allow_embedder = v
1128 .as_bool()
1129 .ok_or_else(|| ManifestError::at(yaml_path, "trust.allow_embedder must be a bool"))?;
1130 }
1131 Ok(cfg)
1132}
1133
1134fn build_tools(
1135 raw: Option<&serde_yaml::Value>,
1136 yaml_path: &Path,
1137) -> Result<Vec<ToolSpec>, ManifestError> {
1138 let Some(raw) = raw else {
1139 return Ok(Vec::new());
1140 };
1141 let seq = raw
1142 .as_sequence()
1143 .ok_or_else(|| ManifestError::at(yaml_path, "tools must be a list"))?;
1144 let mut tools: Vec<ToolSpec> = Vec::new();
1145 let mut seen: BTreeMap<String, ()> = BTreeMap::new();
1146 for (i, entry) in seq.iter().enumerate() {
1147 let tool = build_tool(entry, i, yaml_path)?;
1148 let name = tool.name().to_string();
1149 if seen.insert(name.clone(), ()).is_some() {
1150 return Err(ManifestError::at(
1151 yaml_path,
1152 format!("duplicate tool name: {name:?}"),
1153 ));
1154 }
1155 tools.push(tool);
1156 }
1157 Ok(tools)
1158}
1159
1160fn build_tool(
1161 entry: &serde_yaml::Value,
1162 idx: usize,
1163 yaml_path: &Path,
1164) -> Result<ToolSpec, ManifestError> {
1165 let map = entry
1166 .as_mapping()
1167 .ok_or_else(|| ManifestError::at(yaml_path, format!("tools[{idx}] must be a mapping")))?;
1168 check_keys(map, ALLOWED_TOOL_KEYS, "tool keys", yaml_path)?;
1169
1170 let has_cypher = map.contains_key("cypher");
1175 let has_python = map.contains_key("python");
1176 let has_bundled = map.contains_key("bundled");
1177 let kinds_present: Vec<&str> = [
1178 ("cypher", has_cypher),
1179 ("python", has_python),
1180 ("bundled", has_bundled),
1181 ]
1182 .into_iter()
1183 .filter(|(_, p)| *p)
1184 .map(|(k, _)| k)
1185 .collect();
1186 if kinds_present.is_empty() {
1187 return Err(ManifestError::at(
1188 yaml_path,
1189 format!("tools[{idx}] needs exactly one of: [\"cypher\", \"python\", \"bundled\"]"),
1190 ));
1191 }
1192 if kinds_present.len() > 1 {
1193 return Err(ManifestError::at(
1194 yaml_path,
1195 format!("tools[{idx}] has multiple kinds set ({kinds_present:?}); pick exactly one"),
1196 ));
1197 }
1198
1199 if has_bundled {
1204 return build_bundled_override(map, idx, yaml_path);
1205 }
1206
1207 let name = map
1208 .get("name")
1209 .and_then(|v| v.as_str())
1210 .filter(|s| valid_identifier(s))
1211 .ok_or_else(|| {
1212 ManifestError::at(
1213 yaml_path,
1214 format!("tools[{idx}] needs a string `name:` matching ^[a-zA-Z_][a-zA-Z0-9_]*$"),
1215 )
1216 })?
1217 .to_string();
1218
1219 if map.contains_key("hidden") {
1223 return Err(ManifestError::at(
1224 yaml_path,
1225 format!(
1226 "tools[{idx}] ({name:?}) `hidden:` is only valid on `bundled:` override entries"
1227 ),
1228 ));
1229 }
1230
1231 let description = match map.get("description") {
1232 None | Some(serde_yaml::Value::Null) => None,
1233 Some(serde_yaml::Value::String(s)) => Some(s.clone()),
1234 Some(_) => {
1235 return Err(ManifestError::at(
1236 yaml_path,
1237 format!("tools[{idx}] ({name:?}).description must be a string"),
1238 ))
1239 }
1240 };
1241
1242 let parameters = match map.get("parameters") {
1243 None | Some(serde_yaml::Value::Null) => None,
1244 Some(v) if v.is_mapping() => Some(yaml_to_json(v.clone())?),
1245 Some(_) => {
1246 return Err(ManifestError::at(
1247 yaml_path,
1248 format!("tools[{idx}] ({name:?}).parameters must be a mapping"),
1249 ))
1250 }
1251 };
1252
1253 if has_cypher {
1254 let cypher = map
1255 .get("cypher")
1256 .and_then(|v| v.as_str())
1257 .filter(|s| !s.trim().is_empty())
1258 .ok_or_else(|| {
1259 ManifestError::at(
1260 yaml_path,
1261 format!("tools[{idx}] ({name:?}).cypher must be a non-empty string"),
1262 )
1263 })?
1264 .to_string();
1265 return Ok(ToolSpec::Cypher(CypherTool {
1266 name,
1267 cypher,
1268 description,
1269 parameters,
1270 }));
1271 }
1272
1273 let python = map
1275 .get("python")
1276 .and_then(|v| v.as_str())
1277 .filter(|s| !s.is_empty())
1278 .ok_or_else(|| {
1279 ManifestError::at(
1280 yaml_path,
1281 format!("tools[{idx}] ({name:?}).python must be a non-empty path string"),
1282 )
1283 })?
1284 .to_string();
1285 let function = map
1286 .get("function")
1287 .and_then(|v| v.as_str())
1288 .filter(|s| valid_identifier(s))
1289 .ok_or_else(|| {
1290 ManifestError::at(
1291 yaml_path,
1292 format!(
1293 "tools[{idx}] ({name:?}) python tools need `function:` set to a valid Python identifier"
1294 ),
1295 )
1296 })?
1297 .to_string();
1298 Ok(ToolSpec::Python(PythonTool {
1299 name,
1300 python,
1301 function,
1302 description,
1303 parameters,
1304 }))
1305}
1306
1307fn build_bundled_override(
1311 map: &serde_yaml::Mapping,
1312 idx: usize,
1313 yaml_path: &Path,
1314) -> Result<ToolSpec, ManifestError> {
1315 let name = map
1316 .get("bundled")
1317 .and_then(|v| v.as_str())
1318 .filter(|s| valid_identifier(s))
1319 .ok_or_else(|| {
1320 ManifestError::at(
1321 yaml_path,
1322 format!(
1323 "tools[{idx}] `bundled:` must be a string naming a bundled tool \
1324 (must match ^[a-zA-Z_][a-zA-Z0-9_]*$)"
1325 ),
1326 )
1327 })?
1328 .to_string();
1329
1330 for forbidden in ["name", "parameters", "function"] {
1335 if map.contains_key(forbidden) {
1336 return Err(ManifestError::at(
1337 yaml_path,
1338 format!(
1339 "tools[{idx}] bundled override {name:?} cannot set `{forbidden}:` \
1340 (only `description:`, `hidden:`, and `rename:` are permitted on overrides)"
1341 ),
1342 ));
1343 }
1344 }
1345
1346 let description = match map.get("description") {
1347 None | Some(serde_yaml::Value::Null) => None,
1348 Some(serde_yaml::Value::String(s)) => Some(s.clone()),
1349 Some(_) => {
1350 return Err(ManifestError::at(
1351 yaml_path,
1352 format!("tools[{idx}] bundled override {name:?}.description must be a string"),
1353 ))
1354 }
1355 };
1356
1357 let hidden = match map.get("hidden") {
1358 None | Some(serde_yaml::Value::Null) => false,
1359 Some(serde_yaml::Value::Bool(b)) => *b,
1360 Some(_) => {
1361 return Err(ManifestError::at(
1362 yaml_path,
1363 format!("tools[{idx}] bundled override {name:?}.hidden must be a bool"),
1364 ))
1365 }
1366 };
1367
1368 let rename = match map.get("rename") {
1373 None | Some(serde_yaml::Value::Null) => None,
1374 Some(serde_yaml::Value::String(s)) => {
1375 if !valid_identifier(s) {
1376 return Err(ManifestError::at(
1377 yaml_path,
1378 format!(
1379 "tools[{idx}] bundled override {name:?}.rename must be a valid identifier \
1380 (^[a-zA-Z_][a-zA-Z0-9_]*$), got {s:?}"
1381 ),
1382 ));
1383 }
1384 Some(s.clone())
1385 }
1386 Some(_) => {
1387 return Err(ManifestError::at(
1388 yaml_path,
1389 format!("tools[{idx}] bundled override {name:?}.rename must be a string"),
1390 ))
1391 }
1392 };
1393
1394 Ok(ToolSpec::Bundled(BundledOverride {
1395 name,
1396 description,
1397 hidden,
1398 rename,
1399 }))
1400}
1401
1402fn build_embedder(
1403 raw: Option<&serde_yaml::Value>,
1404 yaml_path: &Path,
1405) -> Result<Option<EmbedderConfig>, ManifestError> {
1406 let Some(raw) = raw else { return Ok(None) };
1407 if matches!(raw, serde_yaml::Value::Null) {
1408 return Ok(None);
1409 }
1410 let map = raw
1411 .as_mapping()
1412 .ok_or_else(|| ManifestError::at(yaml_path, "embedder must be a mapping"))?;
1413 check_keys(map, ALLOWED_EMBEDDER_KEYS, "embedder keys", yaml_path)?;
1414 let module = map
1415 .get("module")
1416 .and_then(|v| v.as_str())
1417 .filter(|s| !s.is_empty())
1418 .ok_or_else(|| {
1419 ManifestError::at(
1420 yaml_path,
1421 "embedder.module must be a non-empty string (path or dotted name)",
1422 )
1423 })?
1424 .to_string();
1425 let class = map
1426 .get("class")
1427 .and_then(|v| v.as_str())
1428 .filter(|s| valid_identifier(s))
1429 .ok_or_else(|| {
1430 ManifestError::at(
1431 yaml_path,
1432 "embedder.class must be a valid identifier matching ^[a-zA-Z_][a-zA-Z0-9_]*$",
1433 )
1434 })?
1435 .to_string();
1436 let kwargs = match map.get("kwargs") {
1437 None | Some(serde_yaml::Value::Null) => serde_json::Map::new(),
1438 Some(v) if v.is_mapping() => match yaml_to_json(v.clone())? {
1439 serde_json::Value::Object(o) => o,
1440 _ => {
1441 return Err(ManifestError::at(
1442 yaml_path,
1443 "embedder.kwargs must be a mapping",
1444 ))
1445 }
1446 },
1447 Some(_) => {
1448 return Err(ManifestError::at(
1449 yaml_path,
1450 "embedder.kwargs must be a mapping",
1451 ))
1452 }
1453 };
1454 Ok(Some(EmbedderConfig {
1455 module,
1456 class,
1457 kwargs,
1458 }))
1459}
1460
1461fn build_builtins(
1462 raw: Option<&serde_yaml::Value>,
1463 yaml_path: &Path,
1464) -> Result<BuiltinsConfig, ManifestError> {
1465 let Some(raw) = raw else {
1466 return Ok(BuiltinsConfig::default());
1467 };
1468 if matches!(raw, serde_yaml::Value::Null) {
1469 return Ok(BuiltinsConfig::default());
1470 }
1471 let map = raw
1472 .as_mapping()
1473 .ok_or_else(|| ManifestError::at(yaml_path, "builtins must be a mapping"))?;
1474 check_keys(map, ALLOWED_BUILTIN_KEYS, "builtins keys", yaml_path)?;
1475 let mut cfg = BuiltinsConfig::default();
1476 if let Some(v) = map.get("save_graph") {
1477 cfg.save_graph = v
1478 .as_bool()
1479 .ok_or_else(|| ManifestError::at(yaml_path, "builtins.save_graph must be a bool"))?;
1480 }
1481 if let Some(v) = map.get("screen_stargazers") {
1482 cfg.screen_stargazers = v.as_bool().ok_or_else(|| {
1483 ManifestError::at(yaml_path, "builtins.screen_stargazers must be a bool")
1484 })?;
1485 }
1486 if let Some(v) = map.get("temp_cleanup") {
1487 let s = v.as_str().ok_or_else(|| {
1488 ManifestError::at(
1489 yaml_path,
1490 format!("builtins.temp_cleanup must be one of {VALID_TEMP_CLEANUP:?}"),
1491 )
1492 })?;
1493 cfg.temp_cleanup = match s {
1494 "never" => TempCleanup::Never,
1495 "on_overview" => TempCleanup::OnOverview,
1496 other => {
1497 return Err(ManifestError::at(
1498 yaml_path,
1499 format!(
1500 "builtins.temp_cleanup must be one of {VALID_TEMP_CLEANUP:?}, got {other:?}"
1501 ),
1502 ))
1503 }
1504 };
1505 }
1506 Ok(cfg)
1507}
1508
1509fn valid_identifier(s: &str) -> bool {
1510 let mut chars = s.chars();
1511 match chars.next() {
1512 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1513 _ => return false,
1514 }
1515 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1516}
1517
1518fn yaml_to_json(v: serde_yaml::Value) -> Result<serde_json::Value, ManifestError> {
1519 serde_json::to_value(&v)
1520 .map_err(|e| ManifestError::bare(format!("yaml→json conversion failed: {e}")))
1521}
1522
1523#[derive(Debug, Deserialize)]
1524struct _Reserved;
1525
1526#[cfg(test)]
1527mod tests {
1528 use super::*;
1529
1530 fn write_tmp(text: &str) -> tempfile::NamedTempFile {
1531 let mut f = tempfile::NamedTempFile::new().unwrap();
1532 std::io::Write::write_all(&mut f, text.as_bytes()).unwrap();
1533 f
1534 }
1535
1536 #[test]
1537 fn loads_minimal_empty_manifest() {
1538 let f = write_tmp("");
1539 let m = load(f.path()).unwrap();
1540 assert_eq!(m.tools.len(), 0);
1541 assert_eq!(m.source_roots.len(), 0);
1542 assert!(!m.trust.allow_python_tools);
1543 assert!(!m.trust.allow_embedder);
1544 assert_eq!(m.builtins.temp_cleanup, TempCleanup::Never);
1545 }
1546
1547 #[test]
1548 fn loads_name_and_instructions() {
1549 let f = write_tmp("name: Demo\ninstructions: |\n multi-line\n block\n");
1550 let m = load(f.path()).unwrap();
1551 assert_eq!(m.name.as_deref(), Some("Demo"));
1552 assert!(m.instructions.unwrap().contains("multi-line"));
1553 }
1554
1555 #[test]
1556 fn rejects_unknown_top_key() {
1557 let f = write_tmp("bogus: 1\n");
1558 let err = load(f.path()).unwrap_err();
1559 assert!(err.message.contains("unknown top-level"));
1560 }
1561
1562 #[test]
1563 fn source_root_string_normalises_to_list() {
1564 let f = write_tmp("source_root: ./data\n");
1565 let m = load(f.path()).unwrap();
1566 assert_eq!(m.source_roots, vec!["./data".to_string()]);
1567 }
1568
1569 #[test]
1570 fn source_roots_list_preserved() {
1571 let f = write_tmp("source_roots:\n - ./a\n - ./b\n");
1572 let m = load(f.path()).unwrap();
1573 assert_eq!(m.source_roots, vec!["./a".to_string(), "./b".to_string()]);
1574 }
1575
1576 #[test]
1577 fn rejects_both_source_root_and_source_roots() {
1578 let f = write_tmp("source_root: ./a\nsource_roots: [./b]\n");
1579 assert!(load(f.path()).unwrap_err().message.contains("not both"));
1580 }
1581
1582 #[test]
1583 fn cypher_tool_parses() {
1584 let f = write_tmp("tools:\n - name: lookup\n cypher: MATCH (n) RETURN n\n");
1585 let m = load(f.path()).unwrap();
1586 assert_eq!(m.tools.len(), 1);
1587 match &m.tools[0] {
1588 ToolSpec::Cypher(t) => {
1589 assert_eq!(t.name, "lookup");
1590 assert!(t.cypher.contains("MATCH"));
1591 }
1592 _ => panic!("expected cypher tool"),
1593 }
1594 }
1595
1596 #[test]
1597 fn python_tool_parses() {
1598 let f =
1599 write_tmp("tools:\n - name: detail\n python: ./tools.py\n function: detail\n");
1600 let m = load(f.path()).unwrap();
1601 match &m.tools[0] {
1602 ToolSpec::Python(t) => {
1603 assert_eq!(t.python, "./tools.py");
1604 assert_eq!(t.function, "detail");
1605 }
1606 _ => panic!("expected python tool"),
1607 }
1608 }
1609
1610 #[test]
1611 fn rejects_tool_with_both_kinds() {
1612 let f = write_tmp(
1613 "tools:\n - name: x\n cypher: 'MATCH (n) RETURN n'\n python: ./t.py\n function: x\n",
1614 );
1615 assert!(load(f.path())
1616 .unwrap_err()
1617 .message
1618 .contains("multiple kinds"));
1619 }
1620
1621 #[test]
1622 fn rejects_tool_with_no_kind() {
1623 let f = write_tmp("tools:\n - name: x\n");
1624 assert!(load(f.path())
1625 .unwrap_err()
1626 .message
1627 .contains("needs exactly one"));
1628 }
1629
1630 #[test]
1631 fn rejects_duplicate_tool_names() {
1632 let f = write_tmp(
1633 "tools:\n - name: same\n cypher: 'MATCH (n) RETURN n'\n - name: same\n cypher: 'MATCH (m) RETURN m'\n",
1634 );
1635 assert!(load(f.path()).unwrap_err().message.contains("duplicate"));
1636 }
1637
1638 #[test]
1641 fn bundled_override_with_description_parses() {
1642 let f =
1643 write_tmp("tools:\n - bundled: repo_management\n description: \"FIRST STEP\"\n");
1644 let m = load(f.path()).unwrap();
1645 assert_eq!(m.tools.len(), 1);
1646 match &m.tools[0] {
1647 ToolSpec::Bundled(b) => {
1648 assert_eq!(b.name, "repo_management");
1649 assert_eq!(b.description.as_deref(), Some("FIRST STEP"));
1650 assert!(!b.hidden);
1651 }
1652 _ => panic!("expected bundled override"),
1653 }
1654 }
1655
1656 #[test]
1657 fn bundled_override_with_hidden_parses() {
1658 let f = write_tmp("tools:\n - bundled: ping\n hidden: true\n");
1659 let m = load(f.path()).unwrap();
1660 match &m.tools[0] {
1661 ToolSpec::Bundled(b) => {
1662 assert_eq!(b.name, "ping");
1663 assert!(b.hidden);
1664 assert!(b.description.is_none());
1665 }
1666 _ => panic!("expected bundled override"),
1667 }
1668 }
1669
1670 #[test]
1671 fn bundled_override_alongside_cypher_tools_parses() {
1672 let f = write_tmp(
1673 "tools:\n\
1674 \x20\x20- bundled: cypher_query\n\
1675 \x20\x20\x20\x20description: \"Custom server description\"\n\
1676 \x20\x20- name: lookup\n\
1677 \x20\x20\x20\x20cypher: \"MATCH (n) RETURN n\"\n",
1678 );
1679 let m = load(f.path()).unwrap();
1680 assert_eq!(m.tools.len(), 2);
1681 assert!(matches!(m.tools[0], ToolSpec::Bundled(_)));
1682 assert!(matches!(m.tools[1], ToolSpec::Cypher(_)));
1683 }
1684
1685 #[test]
1686 fn rejects_bundled_with_cypher_kind() {
1687 let f =
1688 write_tmp("tools:\n - bundled: cypher_query\n cypher: \"MATCH (n) RETURN n\"\n");
1689 let err = load(f.path()).unwrap_err();
1690 assert!(
1691 err.message.contains("multiple kinds"),
1692 "got: {}",
1693 err.message
1694 );
1695 }
1696
1697 #[test]
1698 fn rejects_bundled_with_name_field() {
1699 let f = write_tmp("tools:\n - bundled: ping\n name: ping\n");
1700 let err = load(f.path()).unwrap_err();
1701 assert!(
1702 err.message.contains("cannot set `name:`"),
1703 "got: {}",
1704 err.message
1705 );
1706 }
1707
1708 #[test]
1709 fn rejects_bundled_with_parameters_field() {
1710 let f =
1711 write_tmp("tools:\n - bundled: cypher_query\n parameters:\n type: object\n");
1712 let err = load(f.path()).unwrap_err();
1713 assert!(
1714 err.message.contains("cannot set `parameters:`"),
1715 "got: {}",
1716 err.message
1717 );
1718 }
1719
1720 #[test]
1721 fn rejects_bundled_with_non_bool_hidden() {
1722 let f = write_tmp("tools:\n - bundled: ping\n hidden: yes-please\n");
1723 let err = load(f.path()).unwrap_err();
1724 assert!(
1725 err.message.contains("hidden must be a bool"),
1726 "got: {}",
1727 err.message
1728 );
1729 }
1730
1731 #[test]
1732 fn rejects_hidden_on_cypher_tool() {
1733 let f = write_tmp(
1734 "tools:\n - name: lookup\n cypher: \"MATCH (n) RETURN n\"\n hidden: true\n",
1735 );
1736 let err = load(f.path()).unwrap_err();
1737 assert!(
1738 err.message
1739 .contains("`hidden:` is only valid on `bundled:` override entries"),
1740 "got: {}",
1741 err.message
1742 );
1743 }
1744
1745 #[test]
1746 fn rejects_duplicate_bundled_overrides() {
1747 let f = write_tmp(
1751 "tools:\n - bundled: ping\n hidden: true\n - bundled: ping\n description: \"x\"\n",
1752 );
1753 assert!(load(f.path()).unwrap_err().message.contains("duplicate"));
1754 }
1755
1756 #[test]
1757 fn rejects_bundled_with_invalid_identifier() {
1758 let f = write_tmp("tools:\n - bundled: \"123-bad\"\n hidden: true\n");
1759 let err = load(f.path()).unwrap_err();
1760 assert!(
1761 err.message.contains("must be a string"),
1762 "got: {}",
1763 err.message
1764 );
1765 }
1766
1767 #[test]
1769 fn bundled_rename_parses_when_valid_identifier() {
1770 let f = write_tmp("tools:\n - bundled: cypher_query\n rename: legal_cypher_query\n");
1771 let m = load(f.path()).unwrap();
1772 match &m.tools[0] {
1773 ToolSpec::Bundled(b) => {
1774 assert_eq!(b.name, "cypher_query");
1775 assert_eq!(b.rename.as_deref(), Some("legal_cypher_query"));
1776 assert!(!b.hidden);
1777 assert!(b.description.is_none());
1778 }
1779 _ => panic!("expected bundled override"),
1780 }
1781 }
1782
1783 #[test]
1784 fn bundled_rename_alongside_description_parses() {
1785 let f = write_tmp(
1786 "tools:\n - bundled: cypher_query\n rename: legal_cypher_query\n description: \"Legal-corpus cypher\"\n",
1787 );
1788 let m = load(f.path()).unwrap();
1789 match &m.tools[0] {
1790 ToolSpec::Bundled(b) => {
1791 assert_eq!(b.rename.as_deref(), Some("legal_cypher_query"));
1792 assert_eq!(b.description.as_deref(), Some("Legal-corpus cypher"));
1793 }
1794 _ => panic!("expected bundled override"),
1795 }
1796 }
1797
1798 #[test]
1799 fn bundled_rename_defaults_to_none() {
1800 let f = write_tmp("tools:\n - bundled: cypher_query\n description: \"x\"\n");
1801 let m = load(f.path()).unwrap();
1802 match &m.tools[0] {
1803 ToolSpec::Bundled(b) => assert!(b.rename.is_none()),
1804 _ => panic!("expected bundled override"),
1805 }
1806 }
1807
1808 #[test]
1809 fn rejects_bundled_rename_with_invalid_identifier() {
1810 let f = write_tmp("tools:\n - bundled: cypher_query\n rename: \"123-bad\"\n");
1811 let err = load(f.path()).unwrap_err();
1812 assert!(
1813 err.message.contains("rename must be a valid identifier"),
1814 "got: {}",
1815 err.message
1816 );
1817 }
1818
1819 #[test]
1820 fn rejects_bundled_rename_with_non_string_value() {
1821 let f = write_tmp("tools:\n - bundled: cypher_query\n rename: 42\n");
1822 let err = load(f.path()).unwrap_err();
1823 assert!(
1824 err.message.contains("rename must be a string"),
1825 "got: {}",
1826 err.message
1827 );
1828 }
1829
1830 #[test]
1831 fn bundled_rename_serialises_to_json() {
1832 let f = write_tmp("tools:\n - bundled: cypher_query\n rename: legal_cypher_query\n");
1833 let m = load(f.path()).unwrap();
1834 let json = m.to_json();
1835 let tools = json.get("tools").and_then(|t| t.as_array()).unwrap();
1836 let entry = &tools[0];
1837 assert_eq!(entry.get("kind").and_then(|v| v.as_str()), Some("bundled"));
1838 assert_eq!(
1839 entry.get("name").and_then(|v| v.as_str()),
1840 Some("cypher_query")
1841 );
1842 assert_eq!(
1843 entry.get("rename").and_then(|v| v.as_str()),
1844 Some("legal_cypher_query")
1845 );
1846 }
1847
1848 #[test]
1849 fn bundled_override_to_json_shape() {
1850 let f = write_tmp(
1851 "tools:\n - bundled: repo_management\n description: \"FIRST STEP\"\n hidden: false\n",
1852 );
1853 let m = load(f.path()).unwrap();
1854 let v = m.to_json();
1855 assert_eq!(v["tools"][0]["kind"], "bundled");
1856 assert_eq!(v["tools"][0]["name"], "repo_management");
1857 assert_eq!(v["tools"][0]["description"], "FIRST STEP");
1858 assert_eq!(v["tools"][0]["hidden"], false);
1859 }
1860
1861 #[test]
1862 fn embedder_parses() {
1863 let f = write_tmp(
1864 "embedder:\n module: ./e.py\n class: GraphEmbedder\n kwargs:\n cooldown: 900\n",
1865 );
1866 let m = load(f.path()).unwrap();
1867 let e = m.embedder.unwrap();
1868 assert_eq!(e.module, "./e.py");
1869 assert_eq!(e.class, "GraphEmbedder");
1870 assert_eq!(e.kwargs.get("cooldown").unwrap().as_i64(), Some(900));
1871 }
1872
1873 #[test]
1874 fn builtins_parses_temp_cleanup() {
1875 let f = write_tmp("builtins:\n save_graph: true\n temp_cleanup: on_overview\n");
1876 let m = load(f.path()).unwrap();
1877 assert!(m.builtins.save_graph);
1878 assert_eq!(m.builtins.temp_cleanup, TempCleanup::OnOverview);
1879 }
1880
1881 #[test]
1882 fn rejects_invalid_temp_cleanup() {
1883 let f = write_tmp("builtins:\n temp_cleanup: nuke\n");
1884 assert!(load(f.path()).unwrap_err().message.contains("temp_cleanup"));
1885 }
1886
1887 #[test]
1888 fn allow_embedder_trust_parses() {
1889 let f = write_tmp("trust:\n allow_embedder: true\n");
1890 let m = load(f.path()).unwrap();
1891 assert!(m.trust.allow_embedder);
1892 }
1893
1894 #[test]
1895 fn retired_allow_query_preprocessor_is_rejected_as_unknown() {
1896 let f = write_tmp("trust:\n allow_query_preprocessor: true\n");
1900 let err = load(f.path()).unwrap_err();
1901 assert!(err.message.contains("trust keys"));
1902 assert!(err.message.contains("allow_query_preprocessor"));
1903 }
1904
1905 #[test]
1906 fn find_sibling_works() {
1907 let dir = tempfile::tempdir().unwrap();
1908 let graph = dir.path().join("demo.kgl");
1909 std::fs::write(&graph, b"\x00").unwrap();
1910 let sibling = dir.path().join("demo_mcp.yaml");
1911 std::fs::write(&sibling, "name: x\n").unwrap();
1912 assert_eq!(find_sibling_manifest(&graph), Some(sibling));
1913 }
1914
1915 #[test]
1916 fn workspace_local_parses() {
1917 let f = write_tmp("workspace:\n kind: local\n root: ./src\n watch: true\n");
1918 let m = load(f.path()).unwrap();
1919 let w = m.workspace.unwrap();
1920 assert_eq!(w.kind, WorkspaceKind::Local);
1921 assert_eq!(w.root.as_deref(), Some("./src"));
1922 assert!(w.watch);
1923 }
1924
1925 #[test]
1926 fn workspace_github_default_kind() {
1927 let f = write_tmp("workspace: {}\n");
1928 let m = load(f.path()).unwrap();
1929 let w = m.workspace.unwrap();
1930 assert_eq!(w.kind, WorkspaceKind::Github);
1931 assert!(w.root.is_none());
1932 assert!(!w.watch);
1933 }
1934
1935 #[test]
1936 fn workspace_local_without_root_errors() {
1937 let f = write_tmp("workspace:\n kind: local\n");
1938 let err = load(f.path()).unwrap_err();
1939 assert!(err.message.contains("requires workspace.root"));
1940 }
1941
1942 #[test]
1943 fn workspace_unknown_key_rejected() {
1944 let f = write_tmp("workspace:\n kind: local\n root: ./x\n bogus: 1\n");
1945 let err = load(f.path()).unwrap_err();
1946 assert!(err.message.contains("unknown workspace keys"));
1947 }
1948
1949 #[test]
1950 fn workspace_invalid_kind_rejected() {
1951 let f = write_tmp("workspace:\n kind: docker\n root: ./x\n");
1952 let err = load(f.path()).unwrap_err();
1953 assert!(err.message.contains("workspace.kind"));
1954 }
1955
1956 #[test]
1957 fn workspace_watch_invalid_for_github() {
1958 let f = write_tmp("workspace:\n kind: github\n watch: true\n");
1959 let err = load(f.path()).unwrap_err();
1960 assert!(err.message.contains("watch is only valid"));
1961 }
1962
1963 #[test]
1964 fn workspace_sandbox_root_parses_for_local() {
1965 let f = write_tmp("workspace:\n kind: local\n root: ./src\n sandbox_root: ./\n");
1966 let m = load(f.path()).unwrap();
1967 let w = m.workspace.unwrap();
1968 assert_eq!(w.sandbox_root.as_deref(), Some("./"));
1969 }
1970
1971 #[test]
1972 fn workspace_sandbox_root_absent_by_default() {
1973 let f = write_tmp("workspace:\n kind: local\n root: ./src\n");
1974 let m = load(f.path()).unwrap();
1975 assert!(m.workspace.unwrap().sandbox_root.is_none());
1976 }
1977
1978 #[test]
1979 fn workspace_sandbox_root_invalid_for_github() {
1980 let f = write_tmp("workspace:\n kind: github\n sandbox_root: ./repos\n");
1981 let err = load(f.path()).unwrap_err();
1982 assert!(
1983 err.message.contains("sandbox_root is only valid"),
1984 "unexpected error: {}",
1985 err.message
1986 );
1987 }
1988
1989 #[test]
1990 fn workspace_sandbox_root_must_be_a_non_empty_string() {
1991 let f = write_tmp("workspace:\n kind: local\n root: ./src\n sandbox_root: 7\n");
1992 let err = load(f.path()).unwrap_err();
1993 assert!(
1994 err.message
1995 .contains("sandbox_root must be a non-empty string"),
1996 "unexpected error: {}",
1997 err.message
1998 );
1999 let f = write_tmp("workspace:\n kind: local\n root: ./src\n sandbox_root: ''\n");
2000 let err = load(f.path()).unwrap_err();
2001 assert!(
2002 err.message
2003 .contains("sandbox_root must be a non-empty string"),
2004 "unexpected error: {}",
2005 err.message
2006 );
2007 }
2008
2009 #[test]
2010 fn workspace_adopt_client_roots_absent_by_default() {
2011 let f = write_tmp("workspace:\n kind: local\n root: ./src\n");
2012 let m = load(f.path()).unwrap();
2013 assert!(!m.workspace.unwrap().adopt_client_roots);
2014 }
2015
2016 #[test]
2017 fn workspace_adopt_client_roots_permits_omitting_root() {
2018 let f = write_tmp("workspace:\n kind: local\n adopt_client_roots: true\n");
2019 let m = load(f.path()).unwrap();
2020 let w = m.workspace.unwrap();
2021 assert!(w.adopt_client_roots);
2022 assert!(
2023 w.root.is_none(),
2024 "the root is expected to arrive from the client"
2025 );
2026 }
2027
2028 #[test]
2029 fn workspace_adopt_client_roots_false_still_requires_root() {
2030 let f = write_tmp("workspace:\n kind: local\n adopt_client_roots: false\n");
2033 let err = load(f.path()).unwrap_err();
2034 assert!(err.message.contains("requires workspace.root"));
2035 }
2036
2037 #[test]
2038 fn workspace_adopt_client_roots_coexists_with_an_explicit_root() {
2039 let f = write_tmp(
2040 "workspace:\n kind: local\n root: ./src\n sandbox_root: ./\n adopt_client_roots: true\n",
2041 );
2042 let w = load(f.path()).unwrap().workspace.unwrap();
2043 assert!(w.adopt_client_roots);
2044 assert_eq!(w.root.as_deref(), Some("./src"));
2045 }
2046
2047 #[test]
2052 fn workspace_watch_requires_a_root_even_with_adoption_enabled() {
2053 let f = write_tmp("workspace:\n kind: local\n watch: true\n adopt_client_roots: true\n");
2054 let err = load(f.path()).unwrap_err();
2055 assert!(
2056 err.message
2057 .contains("workspace.watch requires workspace.root"),
2058 "unexpected error: {}",
2059 err.message
2060 );
2061 }
2062
2063 #[test]
2064 fn workspace_watch_with_a_root_is_fine() {
2065 let f = write_tmp("workspace:\n kind: local\n root: ./src\n watch: true\n");
2066 let w = load(f.path()).unwrap().workspace.unwrap();
2067 assert!(w.watch && w.root.is_some());
2068 }
2069
2070 #[test]
2071 fn workspace_adopt_client_roots_invalid_for_github() {
2072 let f = write_tmp("workspace:\n kind: github\n adopt_client_roots: true\n");
2073 let err = load(f.path()).unwrap_err();
2074 assert!(
2075 err.message.contains("adopt_client_roots is only valid"),
2076 "unexpected error: {}",
2077 err.message
2078 );
2079 }
2080
2081 #[test]
2082 fn workspace_adopt_client_roots_must_be_a_bool() {
2083 let f = write_tmp(
2084 "workspace:\n kind: local\n root: ./src\n adopt_client_roots: yes-please\n",
2085 );
2086 let err = load(f.path()).unwrap_err();
2087 assert!(
2088 err.message.contains("adopt_client_roots must be a bool"),
2089 "unexpected error: {}",
2090 err.message
2091 );
2092 }
2093
2094 #[test]
2095 fn extensions_passthrough_parses() {
2096 let f = write_tmp(
2097 "extensions:\n csv_http_server: true\n csv_http_server_dir: temp/\n arbitrary:\n nested: 1\n",
2098 );
2099 let m = load(f.path()).unwrap();
2100 assert_eq!(
2101 m.extensions
2102 .get("csv_http_server")
2103 .and_then(|v| v.as_bool()),
2104 Some(true)
2105 );
2106 assert_eq!(
2107 m.extensions
2108 .get("csv_http_server_dir")
2109 .and_then(|v| v.as_str()),
2110 Some("temp/")
2111 );
2112 assert_eq!(
2114 m.extensions
2115 .get("arbitrary")
2116 .and_then(|v| v.get("nested"))
2117 .and_then(|v| v.as_i64()),
2118 Some(1)
2119 );
2120 }
2121
2122 #[test]
2123 fn extensions_absent_defaults_to_empty() {
2124 let f = write_tmp("name: x\n");
2125 let m = load(f.path()).unwrap();
2126 assert!(m.extensions.is_empty());
2127 }
2128
2129 #[test]
2130 fn extensions_inner_keys_unvalidated() {
2131 let f = write_tmp(
2135 "extensions:\n whatever_kglite_wants: foo\n some_other_consumer: { a: 1, b: 2 }\n",
2136 );
2137 load(f.path()).unwrap();
2138 }
2139
2140 #[test]
2141 fn extensions_must_be_a_mapping() {
2142 let f = write_tmp("extensions: not-a-mapping\n");
2143 let err = load(f.path()).unwrap_err();
2144 assert!(err.message.contains("extensions must be a mapping"));
2145 }
2146
2147 #[test]
2148 fn env_file_key_parses() {
2149 let f = write_tmp("env_file: ../.env\n");
2150 let m = load(f.path()).unwrap();
2151 assert_eq!(m.env_file.as_deref(), Some("../.env"));
2152 }
2153
2154 #[test]
2155 fn env_file_unset_is_none() {
2156 let f = write_tmp("name: Demo\n");
2157 let m = load(f.path()).unwrap();
2158 assert!(m.env_file.is_none());
2159 }
2160
2161 #[test]
2162 fn find_workspace_works() {
2163 let dir = tempfile::tempdir().unwrap();
2164 let manifest = dir.path().join("workspace_mcp.yaml");
2165 std::fs::write(&manifest, "name: ws\n").unwrap();
2166 assert_eq!(find_workspace_manifest(dir.path()), Some(manifest));
2167 }
2168
2169 #[test]
2170 fn find_workspace_walks_one_level_up_with_applies_to() {
2171 let dir = tempfile::tempdir().unwrap();
2176 let parent = dir.path().join("parent");
2177 std::fs::create_dir(&parent).unwrap();
2178 let manifest = parent.join("workspace_mcp.yaml");
2179 std::fs::write(
2180 &manifest,
2181 "workspace:\n kind: github\n applies_to: ./repos\n",
2182 )
2183 .unwrap();
2184 let repos = parent.join("repos");
2185 std::fs::create_dir(&repos).unwrap();
2186
2187 assert_eq!(find_workspace_manifest(&parent), Some(manifest.clone()));
2189
2190 let found = find_workspace_manifest(&repos).expect("parent fallback should fire");
2193 assert_eq!(
2194 found.canonicalize().unwrap(),
2195 manifest.canonicalize().unwrap()
2196 );
2197 }
2198
2199 #[test]
2200 fn find_workspace_ignores_parent_without_applies_to() {
2201 let dir = tempfile::tempdir().unwrap();
2207 let parent = dir.path().join("parent");
2208 std::fs::create_dir(&parent).unwrap();
2209 let manifest = parent.join("workspace_mcp.yaml");
2210 std::fs::write(&manifest, "name: not for repos\n").unwrap();
2211 let repos = parent.join("repos");
2212 std::fs::create_dir(&repos).unwrap();
2213
2214 assert_eq!(
2215 find_workspace_manifest(&repos),
2216 None,
2217 "parent manifest without workspace.applies_to must NOT auto-attach"
2218 );
2219 }
2220
2221 #[test]
2222 fn find_workspace_ignores_parent_with_mismatched_applies_to() {
2223 let dir = tempfile::tempdir().unwrap();
2227 let parent = dir.path().join("parent");
2228 std::fs::create_dir(&parent).unwrap();
2229 let manifest = parent.join("workspace_mcp.yaml");
2230 std::fs::write(
2231 &manifest,
2232 "workspace:\n kind: github\n applies_to: ./repos\n",
2233 )
2234 .unwrap();
2235 let other = parent.join("other_dir");
2236 std::fs::create_dir(&other).unwrap();
2237
2238 assert_eq!(
2239 find_workspace_manifest(&other),
2240 None,
2241 "applies_to: ./repos must NOT match --workspace ./other_dir"
2242 );
2243 }
2244
2245 #[test]
2246 fn find_workspace_applies_to_wildcard_matches_any_child() {
2247 let dir = tempfile::tempdir().unwrap();
2251 let parent = dir.path().join("parent");
2252 std::fs::create_dir(&parent).unwrap();
2253 let manifest = parent.join("workspace_mcp.yaml");
2254 std::fs::write(&manifest, "workspace:\n kind: github\n applies_to: '*'\n").unwrap();
2255 for child_name in ["repos", "clones", "totally-different-name"] {
2256 let child = parent.join(child_name);
2257 std::fs::create_dir(&child).unwrap();
2258 let found =
2259 find_workspace_manifest(&child).expect("wildcard should match any direct child");
2260 assert_eq!(
2261 found.canonicalize().unwrap(),
2262 manifest.canonicalize().unwrap(),
2263 "wildcard should match child {child_name:?}"
2264 );
2265 }
2266 }
2267
2268 #[test]
2269 fn find_workspace_applies_to_glob_matches_prefix() {
2270 let dir = tempfile::tempdir().unwrap();
2273 let parent = dir.path().join("parent");
2274 std::fs::create_dir(&parent).unwrap();
2275 let manifest = parent.join("workspace_mcp.yaml");
2276 std::fs::write(
2277 &manifest,
2278 "workspace:\n kind: github\n applies_to: ./prod-*\n",
2279 )
2280 .unwrap();
2281 for child_name in ["prod-api", "prod-web", "prod-"] {
2283 let child = parent.join(child_name);
2284 std::fs::create_dir(&child).unwrap();
2285 assert!(
2286 find_workspace_manifest(&child).is_some(),
2287 "prod-* should match {child_name:?}"
2288 );
2289 }
2290 for child_name in ["test-api", "stage-web", "random"] {
2292 let child = parent.join(child_name);
2293 std::fs::create_dir(&child).unwrap();
2294 assert_eq!(
2295 find_workspace_manifest(&child),
2296 None,
2297 "prod-* should NOT match {child_name:?}"
2298 );
2299 }
2300 }
2301
2302 #[test]
2303 fn find_workspace_applies_to_list_matches_any_entry() {
2304 let dir = tempfile::tempdir().unwrap();
2307 let parent = dir.path().join("parent");
2308 std::fs::create_dir(&parent).unwrap();
2309 let manifest = parent.join("workspace_mcp.yaml");
2310 std::fs::write(
2311 &manifest,
2312 "workspace:\n kind: github\n applies_to:\n - ./repos\n - ./clones\n",
2313 )
2314 .unwrap();
2315 for matching in ["repos", "clones"] {
2316 let child = parent.join(matching);
2317 std::fs::create_dir(&child).unwrap();
2318 assert!(
2319 find_workspace_manifest(&child).is_some(),
2320 "list should match {matching:?}"
2321 );
2322 }
2323 let other = parent.join("scratch");
2324 std::fs::create_dir(&other).unwrap();
2325 assert_eq!(
2326 find_workspace_manifest(&other),
2327 None,
2328 "list with [repos, clones] must NOT match scratch"
2329 );
2330 }
2331
2332 #[test]
2333 fn applies_to_rejects_deep_path_at_parse_time() {
2334 let f = write_tmp("workspace:\n kind: github\n applies_to: ./too/deep/path\n");
2335 let err = load(f.path()).unwrap_err();
2336 assert!(
2337 err.message.contains("must be a single path segment"),
2338 "got: {}",
2339 err.message
2340 );
2341 }
2342
2343 #[test]
2344 fn applies_to_rejects_invalid_glob_at_parse_time() {
2345 let f = write_tmp("workspace:\n kind: github\n applies_to: './[unterminated'\n");
2347 let err = load(f.path()).unwrap_err();
2348 assert!(
2349 err.message.contains("invalid glob pattern"),
2350 "got: {}",
2351 err.message
2352 );
2353 }
2354
2355 #[test]
2356 fn applies_to_rejects_parent_relative() {
2357 let f = write_tmp("workspace:\n kind: github\n applies_to: '..'\n");
2361 let err = load(f.path()).unwrap_err();
2362 assert!(err.message.contains("must not contain `..`"));
2363
2364 let f2 = write_tmp("workspace:\n kind: github\n applies_to: '../up'\n");
2365 let err2 = load(f2.path()).unwrap_err();
2366 assert!(err2.message.contains("must be a single path segment"));
2367 }
2368
2369 #[test]
2370 fn find_workspace_returns_none_when_missing_everywhere() {
2371 let dir = tempfile::tempdir().unwrap();
2372 let child = dir.path().join("child");
2373 std::fs::create_dir(&child).unwrap();
2374 assert_eq!(find_workspace_manifest(&child), None);
2376 }
2377
2378 #[test]
2379 fn find_workspace_primary_wins_over_parent_fallback() {
2380 let dir = tempfile::tempdir().unwrap();
2387 let parent_manifest = dir.path().join("workspace_mcp.yaml");
2388 std::fs::write(
2389 &parent_manifest,
2390 "workspace:\n kind: github\n applies_to: ./repos\n",
2391 )
2392 .unwrap();
2393 let child = dir.path().join("repos");
2394 std::fs::create_dir(&child).unwrap();
2395 let child_manifest = child.join("workspace_mcp.yaml");
2396 std::fs::write(&child_manifest, "name: child\n").unwrap();
2397
2398 let found = find_workspace_manifest(&child).expect("primary should resolve");
2402 assert_eq!(
2403 found.canonicalize().unwrap(),
2404 child_manifest.canonicalize().unwrap(),
2405 "primary location must win when both primary and parent fallback exist"
2406 );
2407 }
2408
2409 #[test]
2410 fn to_json_shape_is_stable() {
2411 let f = write_tmp(
2412 r#"
2413name: KGLite Codebase
2414source_roots: [src, lib]
2415trust:
2416 allow_embedder: true
2417embedder:
2418 module: kglite.embed
2419 class: SentenceTransformerEmbedder
2420builtins:
2421 save_graph: true
2422 temp_cleanup: on_overview
2423"#,
2424 );
2425 let m = load(f.path()).unwrap();
2426 let actual = m.to_json();
2427 let expected = serde_json::json!({
2428 "yaml_path": f.path().display().to_string(),
2429 "name": "KGLite Codebase",
2430 "instructions": null,
2431 "overview_prefix": null,
2432 "source_roots": ["src", "lib"],
2433 "trust": {
2434 "allow_python_tools": false,
2435 "allow_embedder": true,
2436 },
2437 "tools": [],
2438 "embedder": {
2439 "module": "kglite.embed",
2440 "class": "SentenceTransformerEmbedder",
2441 "kwargs": {},
2442 },
2443 "builtins": { "save_graph": true, "temp_cleanup": "on_overview", "screen_stargazers": true },
2444 "env_file": null,
2445 "workspace": null,
2446 "extensions": {},
2447 "skills": false,
2448 });
2449 assert_eq!(actual, expected);
2450 }
2451
2452 #[test]
2453 fn to_json_round_trips_tools_and_workspace() {
2454 let f = write_tmp(
2455 r#"
2456name: Full Surface
2457source_root: ./src
2458trust:
2459 allow_python_tools: true
2460tools:
2461 - name: nodes_for
2462 cypher: "MATCH (n {name: $name}) RETURN n"
2463 description: "fetch nodes by name"
2464 - name: run_query
2465 python: tools.py
2466 function: run
2467workspace:
2468 kind: local
2469 root: /tmp/ws
2470 watch: true
2471builtins:
2472 save_graph: false
2473env_file: .env.local
2474extensions:
2475 kglite:
2476 flavour: standard
2477"#,
2478 );
2479 let m = load(f.path()).unwrap();
2480 let v = m.to_json();
2481 assert_eq!(v["name"], "Full Surface");
2482 assert_eq!(v["trust"]["allow_python_tools"], true);
2483 assert_eq!(v["workspace"]["kind"], "local");
2484 assert_eq!(v["workspace"]["root"], "/tmp/ws");
2485 assert_eq!(v["workspace"]["watch"], true);
2486 assert_eq!(v["env_file"], ".env.local");
2487 assert_eq!(v["tools"][0]["kind"], "cypher");
2488 assert_eq!(v["tools"][0]["name"], "nodes_for");
2489 assert_eq!(v["tools"][1]["kind"], "python");
2490 assert_eq!(v["tools"][1]["name"], "run_query");
2491 assert_eq!(v["tools"][1]["python"], "tools.py");
2492 assert_eq!(v["tools"][1]["function"], "run");
2493 assert_eq!(v["extensions"]["kglite"]["flavour"], "standard");
2494 }
2495
2496 #[test]
2499 fn skills_disabled_by_default() {
2500 let f = write_tmp("name: x\n");
2501 let m = load(f.path()).unwrap();
2502 assert_eq!(m.skills, SkillsSource::Disabled);
2503 assert_eq!(m.to_json()["skills"], serde_json::Value::Bool(false));
2504 }
2505
2506 #[test]
2507 fn skills_explicit_false_disabled() {
2508 let f = write_tmp("name: x\nskills: false\n");
2509 let m = load(f.path()).unwrap();
2510 assert_eq!(m.skills, SkillsSource::Disabled);
2511 }
2512
2513 #[test]
2514 fn skills_bool_true_parses_to_single_bundled() {
2515 let f = write_tmp("name: x\nskills: true\n");
2516 let m = load(f.path()).unwrap();
2517 assert_eq!(m.skills, SkillsSource::Sources(vec![SkillSource::Bundled]));
2518 let v = m.to_json();
2520 assert_eq!(v["skills"], serde_json::json!([true]));
2521 }
2522
2523 #[test]
2524 fn skills_path_string_parses_to_single_path() {
2525 let f = write_tmp("name: x\nskills: ./local-skills/\n");
2526 let m = load(f.path()).unwrap();
2527 assert_eq!(
2528 m.skills,
2529 SkillsSource::Sources(vec![SkillSource::Path("./local-skills/".into())])
2530 );
2531 let v = m.to_json();
2533 assert_eq!(v["skills"], serde_json::json!(["./local-skills/"]));
2534 }
2535
2536 #[test]
2537 fn skills_list_polymorphic_parses() {
2538 let f =
2539 write_tmp("name: x\nskills:\n - true\n - ./local-overrides/\n - ~/shared-skills/\n");
2540 let m = load(f.path()).unwrap();
2541 assert_eq!(
2542 m.skills,
2543 SkillsSource::Sources(vec![
2544 SkillSource::Bundled,
2545 SkillSource::Path("./local-overrides/".into()),
2546 SkillSource::Path("~/shared-skills/".into()),
2547 ])
2548 );
2549 let v = m.to_json();
2551 assert_eq!(
2552 v["skills"],
2553 serde_json::json!([true, "./local-overrides/", "~/shared-skills/"])
2554 );
2555 }
2556
2557 #[test]
2558 fn skills_empty_list_parses_as_opt_in_with_no_root_sources() {
2559 let f = write_tmp("name: x\nskills: []\n");
2564 let m = load(f.path()).unwrap();
2565 assert_eq!(m.skills, SkillsSource::Sources(vec![]));
2566 }
2567
2568 #[test]
2569 fn skills_false_in_list_rejected() {
2570 let f = write_tmp("name: x\nskills:\n - false\n");
2571 let err = load(f.path()).unwrap_err();
2572 assert!(
2573 err.message.contains("skills[0]")
2574 && err.message.contains("`false` is not a valid entry"),
2575 "unexpected: {}",
2576 err.message
2577 );
2578 }
2579
2580 #[test]
2581 fn skills_invalid_type_rejected() {
2582 let f = write_tmp("name: x\nskills: 42\n");
2583 let err = load(f.path()).unwrap_err();
2584 assert!(
2585 err.message.contains("skills must be"),
2586 "unexpected: {}",
2587 err.message
2588 );
2589 }
2590
2591 #[test]
2592 fn skills_empty_path_string_rejected() {
2593 let f = write_tmp("name: x\nskills: \"\"\n");
2594 let err = load(f.path()).unwrap_err();
2595 assert!(
2596 err.message.contains("non-empty string"),
2597 "unexpected: {}",
2598 err.message
2599 );
2600 }
2601
2602 #[test]
2603 fn skills_field_is_purely_additive_on_existing_manifests() {
2604 let f = write_tmp(
2609 r#"
2610name: legacy
2611source_roots: [src]
2612trust:
2613 allow_python_tools: true
2614workspace:
2615 kind: github
2616"#,
2617 );
2618 let m = load(f.path()).unwrap();
2619 assert_eq!(m.skills, SkillsSource::Disabled);
2620 assert_eq!(m.to_json()["skills"], serde_json::Value::Bool(false));
2621 }
2622}