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