1use std::collections::HashMap;
63use std::fs;
64use std::path::{Path, PathBuf};
65use std::sync::Arc;
66
67use serde::Deserialize;
68
69use super::manifest::{SkillSource, SkillsSource};
70
71#[derive(Debug, Clone)]
81pub struct BundledSkill {
82 pub name: &'static str,
85 pub body: &'static str,
89}
90
91#[derive(Debug, Clone, Default, Deserialize)]
100pub struct SkillFrontmatter {
101 #[serde(default)]
106 pub name: String,
107 #[serde(default)]
110 pub description: String,
111
112 #[serde(default)]
115 pub applies_to: Option<HashMap<String, String>>,
116
117 #[serde(default)]
121 pub references_tools: Vec<String>,
122
123 #[serde(default)]
127 pub references_arguments: Vec<String>,
128
129 #[serde(default)]
134 pub references_properties: Vec<String>,
135
136 #[serde(default = "default_auto_inject_hint")]
141 pub auto_inject_hint: bool,
142
143 #[serde(default)]
155 pub applies_when: Option<AppliesWhen>,
156}
157
158fn default_auto_inject_hint() -> bool {
159 true
160}
161
162#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
171pub struct AppliesWhen {
172 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub graph_has_node_type: Option<Vec<String>>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub graph_has_property: Option<GraphPropertyCheck>,
183
184 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub tool_registered: Option<String>,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub extension_enabled: Option<String>,
195}
196
197#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
199pub struct GraphPropertyCheck {
200 pub node_type: String,
201 pub prop_name: String,
202}
203
204#[derive(Debug)]
208pub enum PredicateClause<'a> {
209 GraphHasNodeType(&'a [String]),
211 GraphHasProperty {
213 node_type: &'a str,
214 prop_name: &'a str,
215 },
216 ToolRegistered(&'a str),
218 ExtensionEnabled(&'a str),
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum PredicateOutcome {
227 Satisfied,
229 Unsatisfied,
231 Unknown,
235}
236
237#[derive(Debug, Clone, Default)]
241pub struct SkillActivation {
242 pub active: bool,
245 pub clauses: Vec<(String, PredicateOutcome)>,
248}
249
250pub trait SkillPredicateEvaluator: Send + Sync {
283 fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool>;
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum SkillProvenance {
291 Project,
294 DomainPack(PathBuf),
297 Bundled,
300}
301
302#[derive(Debug, Clone)]
305pub struct Skill {
306 pub frontmatter: SkillFrontmatter,
307 pub body: String,
308 pub provenance: SkillProvenance,
309}
310
311impl Skill {
312 pub fn name(&self) -> &str {
315 &self.frontmatter.name
316 }
317
318 pub fn description(&self) -> &str {
320 &self.frontmatter.description
321 }
322}
323
324#[derive(Debug)]
330pub enum SkillError {
331 Io {
333 path: PathBuf,
334 source: std::io::Error,
335 },
336 MissingFrontmatter { path: PathBuf },
338 InvalidFrontmatter { path: PathBuf, message: String },
340 MissingRequiredField { path: PathBuf, field: &'static str },
342 SkillTooLarge {
344 path: PathBuf,
345 bytes: usize,
346 limit: usize,
347 },
348 PathNotFound { raw: String, resolved: PathBuf },
351 BundledSkillInvalid { name: &'static str, message: String },
356}
357
358impl std::fmt::Display for SkillError {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 match self {
361 SkillError::Io { path, source } => {
362 write!(f, "skill I/O error at {}: {source}", path.display())
363 }
364 SkillError::MissingFrontmatter { path } => write!(
365 f,
366 "skill at {} is missing the `---` YAML frontmatter delimiter at the start of the file",
367 path.display()
368 ),
369 SkillError::InvalidFrontmatter { path, message } => {
370 write!(
371 f,
372 "skill frontmatter at {} is not valid YAML: {message}",
373 path.display()
374 )
375 }
376 SkillError::MissingRequiredField { path, field } => write!(
377 f,
378 "skill at {} is missing required frontmatter field `{field}`",
379 path.display()
380 ),
381 SkillError::SkillTooLarge {
382 path,
383 bytes,
384 limit,
385 } => write!(
386 f,
387 "skill at {} is {bytes} bytes; exceeds the {limit} byte hard limit",
388 path.display()
389 ),
390 SkillError::PathNotFound { raw, resolved } => write!(
391 f,
392 "skill path {raw:?} (resolved to {}) does not exist or is not a directory",
393 resolved.display()
394 ),
395 SkillError::BundledSkillInvalid { name, message } => write!(
396 f,
397 "bundled skill `{name}` is malformed: {message}"
398 ),
399 }
400 }
401}
402
403impl std::error::Error for SkillError {
404 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
405 match self {
406 SkillError::Io { source, .. } => Some(source),
407 _ => None,
408 }
409 }
410}
411
412pub const SOFT_SIZE_LIMIT_BYTES: usize = 4 * 1024;
417pub const HARD_SIZE_LIMIT_BYTES: usize = 16 * 1024;
421pub const SESSION_TOTAL_LIMIT_BYTES: usize = 64 * 1024;
426
427fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
438 let trimmed = content.strip_prefix("---\n").or_else(|| {
439 content.strip_prefix("---\r\n")
441 })?;
442 let mut search_start = 0;
444 while let Some(idx) = trimmed[search_start..].find("---") {
445 let abs = search_start + idx;
446 let at_line_start = abs == 0 || trimmed.as_bytes().get(abs - 1) == Some(&b'\n');
448 let after = &trimmed[abs + 3..];
450 let line_end_ok = after.is_empty() || after.starts_with('\n') || after.starts_with("\r\n");
451 if at_line_start && line_end_ok {
452 let frontmatter = &trimmed[..abs];
453 let body_start = if after.starts_with("\r\n") {
454 abs + 3 + 2
455 } else if after.starts_with('\n') {
456 abs + 3 + 1
457 } else {
458 abs + 3
459 };
460 let body = &trimmed[body_start..];
461 return Some((frontmatter, body));
462 }
463 search_start = abs + 3;
464 }
465 None
466}
467
468pub fn parse_skill(content: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
471 let (frontmatter_str, body) =
472 split_frontmatter(content).ok_or_else(|| SkillError::MissingFrontmatter {
473 path: path.to_path_buf(),
474 })?;
475
476 let frontmatter: SkillFrontmatter =
477 serde_yaml::from_str(frontmatter_str).map_err(|e| SkillError::InvalidFrontmatter {
478 path: path.to_path_buf(),
479 message: e.to_string(),
480 })?;
481
482 if frontmatter.name.is_empty() {
483 return Err(SkillError::MissingRequiredField {
484 path: path.to_path_buf(),
485 field: "name",
486 });
487 }
488 if frontmatter.description.is_empty() {
489 return Err(SkillError::MissingRequiredField {
490 path: path.to_path_buf(),
491 field: "description",
492 });
493 }
494
495 Ok((frontmatter, body.to_string()))
496}
497
498pub fn load_skill_from_file(path: &Path, provenance: SkillProvenance) -> Result<Skill, SkillError> {
502 let content = fs::read_to_string(path).map_err(|e| SkillError::Io {
503 path: path.to_path_buf(),
504 source: e,
505 })?;
506
507 if content.len() > HARD_SIZE_LIMIT_BYTES {
508 return Err(SkillError::SkillTooLarge {
509 path: path.to_path_buf(),
510 bytes: content.len(),
511 limit: HARD_SIZE_LIMIT_BYTES,
512 });
513 }
514 if content.len() > SOFT_SIZE_LIMIT_BYTES {
515 tracing::warn!(
516 path = %path.display(),
517 bytes = content.len(),
518 soft_limit = SOFT_SIZE_LIMIT_BYTES,
519 "skill exceeds the soft size limit; consider splitting"
520 );
521 }
522
523 let (frontmatter, body) = parse_skill(&content, path)?;
524 Ok(Skill {
525 frontmatter,
526 body,
527 provenance,
528 })
529}
530
531pub fn load_skills_from_dir(
537 dir: &Path,
538 provenance: SkillProvenance,
539) -> Result<Vec<Skill>, SkillError> {
540 if !dir.is_dir() {
541 return Ok(Vec::new());
542 }
543
544 let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
545 path: dir.to_path_buf(),
546 source: e,
547 })?;
548
549 let mut skills = Vec::new();
550 for entry in entries {
551 let entry = match entry {
552 Ok(e) => e,
553 Err(e) => {
554 tracing::warn!(
555 dir = %dir.display(),
556 error = %e,
557 "failed to read directory entry; skipping"
558 );
559 continue;
560 }
561 };
562 let path = entry.path();
563 if path.extension().map(|e| e == "md").unwrap_or(false) {
566 match load_skill_from_file(&path, provenance.clone()) {
567 Ok(skill) => skills.push(skill),
568 Err(e) => {
569 tracing::warn!(
570 path = %path.display(),
571 error = %e,
572 "failed to load skill; skipping"
573 );
574 }
575 }
576 }
577 }
578 Ok(skills)
579}
580
581pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
594 let p = Path::new(raw);
595 if p.is_absolute() {
596 return p.to_path_buf();
597 }
598 if let Some(rest) = raw.strip_prefix("~/") {
599 if let Some(home) = std::env::var_os("HOME") {
600 return PathBuf::from(home).join(rest);
601 }
602 }
604 manifest_dir.join(raw)
605}
606
607pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
613 let stem = yaml_path
614 .file_stem()
615 .map(|s| s.to_string_lossy().into_owned())
616 .unwrap_or_else(|| "manifest".to_string());
617 let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
618 parent.join(format!("{stem}.skills"))
619}
620
621pub fn library_bundled_skills() -> Vec<BundledSkill> {
631 crate::server::bundled_skills_index::library_bundled_skills()
632}
633
634pub fn render_skill_template(name: &str, description: &str) -> String {
649 format!(
650 "---\n\
651 name: {name}\n\
652 description: {description}\n\
653 # Optional mcp-methods extension fields (uncomment as needed):\n\
654 # applies_to:\n\
655 # mcp_methods: \">=0.3.35\"\n\
656 # references_tools:\n\
657 # - {name}\n\
658 # references_arguments:\n\
659 # - {name}.<arg_name>\n\
660 # auto_inject_hint: true\n\
661 ---\n\
662 \n\
663 # `{name}` methodology\n\
664 \n\
665 ## Overview\n\
666 \n\
667 <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
668 what comes before and after it in the typical workflow.>\n\
669 \n\
670 ## Quick Reference\n\
671 \n\
672 | Task | Approach |\n\
673 |---|---|\n\
674 | <TODO: common task A> | <TODO: one-line pattern> |\n\
675 | <TODO: common task B> | <TODO: one-line pattern> |\n\
676 \n\
677 ## <TODO: Major topic>\n\
678 \n\
679 <TODO: concrete prose, code blocks, examples.>\n\
680 \n\
681 ## Common Pitfalls\n\
682 \n\
683 ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
684 \n\
685 ✅ <TODO: positive guidance, often a heuristic>\n\
686 \n\
687 ## When `{name}` is the wrong tool\n\
688 \n\
689 - **<TODO: scenario>** — use <other tool> because <reason>.\n"
690 )
691}
692
693pub fn write_skill_template(
708 dest: &Path,
709 name: &str,
710 description: &str,
711) -> Result<PathBuf, SkillError> {
712 let path = resolve_template_dest(dest, name);
713
714 if path.exists() {
715 return Err(SkillError::Io {
716 path: path.clone(),
717 source: std::io::Error::new(
718 std::io::ErrorKind::AlreadyExists,
719 "destination already exists; delete it before re-running",
720 ),
721 });
722 }
723
724 if let Some(parent) = path.parent() {
725 if !parent.as_os_str().is_empty() && !parent.exists() {
726 fs::create_dir_all(parent).map_err(|e| SkillError::Io {
727 path: parent.to_path_buf(),
728 source: e,
729 })?;
730 }
731 }
732
733 let body = render_skill_template(name, description);
734 fs::write(&path, body).map_err(|e| SkillError::Io {
735 path: path.clone(),
736 source: e,
737 })?;
738 Ok(path)
739}
740
741fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
742 if dest.is_dir() {
743 return dest.join(format!("{name}.md"));
744 }
745 if dest
746 .extension()
747 .map(|e| e.eq_ignore_ascii_case("md"))
748 .unwrap_or(false)
749 {
750 return dest.to_path_buf();
751 }
752 dest.join(format!("{name}.md"))
753}
754
755#[derive(Default)]
765pub struct Registry {
766 bundled: Vec<BundledSkill>,
767 root_dirs: Vec<(PathBuf, String)>, root_includes_bundled: bool,
772 project_dir: Option<PathBuf>,
775 evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
781}
782
783impl std::fmt::Debug for Registry {
784 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785 f.debug_struct("Registry")
786 .field("bundled", &self.bundled)
787 .field("root_dirs", &self.root_dirs)
788 .field("root_includes_bundled", &self.root_includes_bundled)
789 .field("project_dir", &self.project_dir)
790 .field(
791 "evaluator",
792 &self
793 .evaluator
794 .as_ref()
795 .map(|_| "<dyn SkillPredicateEvaluator>"),
796 )
797 .finish()
798 }
799}
800
801impl Registry {
802 pub fn new() -> Self {
807 Self::default()
808 }
809
810 pub fn with_predicate_evaluator(
823 mut self,
824 evaluator: impl SkillPredicateEvaluator + 'static,
825 ) -> Self {
826 self.evaluator = Some(Arc::new(evaluator));
827 self
828 }
829
830 pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
848 self.bundled.push(skill);
849 self
850 }
851
852 pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
854 self.bundled.extend(skills);
855 self
856 }
857
858 pub fn merge_framework_defaults(self) -> Self {
863 let defaults = library_bundled_skills();
864 self.add_bundled_many(defaults)
865 }
866
867 pub fn layer_dirs(
879 mut self,
880 source: &SkillsSource,
881 yaml_path: &Path,
882 ) -> Result<Self, SkillError> {
883 let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
884
885 match source {
886 SkillsSource::Disabled => {
887 self.root_includes_bundled = false;
892 }
893 SkillsSource::Sources(sources) => {
894 for src in sources {
895 match src {
896 SkillSource::Bundled => {
897 self.root_includes_bundled = true;
898 }
899 SkillSource::Path(raw) => {
900 let resolved = resolve_skill_path(raw, manifest_dir);
901 if !resolved.is_dir() {
902 return Err(SkillError::PathNotFound {
903 raw: raw.clone(),
904 resolved,
905 });
906 }
907 self.root_dirs.push((resolved, raw.clone()));
908 }
909 }
910 }
911 }
912 }
913
914 Ok(self)
915 }
916
917 pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
922 let candidate = project_skills_dir(yaml_path);
923 if candidate.is_dir() {
924 self.project_dir = Some(candidate);
925 }
926 self
927 }
928
929 pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
944 let Self {
945 bundled,
946 root_dirs,
947 root_includes_bundled,
948 project_dir,
949 evaluator,
950 } = self;
951
952 let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
955 if root_includes_bundled {
956 for b in &bundled {
957 let path = PathBuf::from(format!("<bundled:{}>", b.name));
958 let (frontmatter, body) =
959 parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
960 name: b.name,
961 message: e.to_string(),
962 })?;
963 if frontmatter.name != b.name {
964 return Err(SkillError::BundledSkillInvalid {
965 name: b.name,
966 message: format!(
967 "frontmatter name {:?} does not match the bundled key {:?}",
968 frontmatter.name, b.name
969 ),
970 });
971 }
972 bundled_skills.push(Skill {
973 frontmatter,
974 body,
975 provenance: SkillProvenance::Bundled,
976 });
977 }
978 }
979
980 let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
982 for (resolved, _raw) in &root_dirs {
983 let provenance = SkillProvenance::DomainPack(resolved.clone());
984 let skills = load_skills_from_dir(resolved, provenance)?;
985 root_skills_per_dir.push(skills);
986 }
987
988 let project_skills: Vec<Skill> = match &project_dir {
990 Some(dir) => load_skills_from_dir(dir, SkillProvenance::Project)?,
991 None => Vec::new(),
992 };
993
994 let mut resolved: HashMap<String, Skill> = HashMap::new();
1004 let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
1005
1006 for skill in &bundled_skills {
1010 let name = skill.name().to_string();
1011 collisions
1012 .entry(name.clone())
1013 .or_default()
1014 .push(skill.provenance.clone());
1015 resolved.insert(name, skill.clone());
1016 }
1017 for skills in root_skills_per_dir.iter().rev() {
1018 for skill in skills {
1019 let name = skill.name().to_string();
1020 collisions
1021 .entry(name.clone())
1022 .or_default()
1023 .push(skill.provenance.clone());
1024 resolved.insert(name, skill.clone());
1025 }
1026 }
1027 for skill in &project_skills {
1028 let name = skill.name().to_string();
1029 collisions
1030 .entry(name.clone())
1031 .or_default()
1032 .push(skill.provenance.clone());
1033 resolved.insert(name, skill.clone());
1034 }
1035
1036 for (name, candidates) in &collisions {
1039 if candidates.len() > 1 {
1040 let winner = resolved
1041 .get(name)
1042 .map(|s| format_provenance(&s.provenance))
1043 .unwrap_or_else(|| "<none>".to_string());
1044 let all_candidates: Vec<String> =
1045 candidates.iter().map(format_provenance).collect();
1046 tracing::info!(
1047 skill = %name,
1048 candidates = ?all_candidates,
1049 winner = %winner,
1050 "skill resolved across multiple layers"
1051 );
1052 }
1053 }
1054
1055 let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
1057 if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
1058 tracing::warn!(
1059 total_bytes,
1060 limit = SESSION_TOTAL_LIMIT_BYTES,
1061 skill_count = resolved.len(),
1062 "total resolved skill body size exceeds session limit; \
1063 consider trimming or splitting skills"
1064 );
1065 }
1066
1067 Ok(ResolvedRegistry {
1068 skills: resolved,
1069 evaluator,
1070 })
1071 }
1072}
1073
1074fn format_provenance(p: &SkillProvenance) -> String {
1075 match p {
1076 SkillProvenance::Project => "project".to_string(),
1077 SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
1078 SkillProvenance::Bundled => "bundled".to_string(),
1079 }
1080}
1081
1082#[derive(Default)]
1088pub struct ResolvedRegistry {
1089 skills: HashMap<String, Skill>,
1090 pub(crate) evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
1096}
1097
1098impl std::fmt::Debug for ResolvedRegistry {
1099 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1100 f.debug_struct("ResolvedRegistry")
1101 .field("skills", &self.skills)
1102 .field(
1103 "evaluator",
1104 &self
1105 .evaluator
1106 .as_ref()
1107 .map(|_| "<dyn SkillPredicateEvaluator>"),
1108 )
1109 .finish()
1110 }
1111}
1112
1113impl ResolvedRegistry {
1114 pub fn skill_names(&self) -> Vec<String> {
1117 let mut names: Vec<String> = self.skills.keys().cloned().collect();
1118 names.sort();
1119 names
1120 }
1121
1122 pub fn get(&self, name: &str) -> Option<&Skill> {
1125 self.skills.get(name)
1126 }
1127
1128 pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
1131 self.skills.iter()
1132 }
1133
1134 pub fn len(&self) -> usize {
1136 self.skills.len()
1137 }
1138
1139 pub fn is_empty(&self) -> bool {
1141 self.skills.is_empty()
1142 }
1143
1144 pub fn activation_for(
1157 &self,
1158 skill: &Skill,
1159 registered_tools: &std::collections::HashSet<String>,
1160 extensions: &serde_json::Map<String, serde_json::Value>,
1161 ) -> SkillActivation {
1162 let Some(applies_when) = skill.frontmatter.applies_when.as_ref() else {
1163 return SkillActivation {
1164 active: true,
1165 clauses: Vec::new(),
1166 };
1167 };
1168 let mut clauses = Vec::new();
1169 let mut all_satisfied = true;
1170
1171 if let Some(types) = applies_when.graph_has_node_type.as_ref() {
1172 let clause = PredicateClause::GraphHasNodeType(types);
1173 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1174 if outcome != PredicateOutcome::Satisfied {
1175 all_satisfied = false;
1176 }
1177 clauses.push((format!("graph_has_node_type: {types:?}"), outcome));
1178 }
1179 if let Some(prop) = applies_when.graph_has_property.as_ref() {
1180 let clause = PredicateClause::GraphHasProperty {
1181 node_type: &prop.node_type,
1182 prop_name: &prop.prop_name,
1183 };
1184 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1185 if outcome != PredicateOutcome::Satisfied {
1186 all_satisfied = false;
1187 }
1188 clauses.push((
1189 format!("graph_has_property: {}.{}", prop.node_type, prop.prop_name),
1190 outcome,
1191 ));
1192 }
1193 if let Some(tool) = applies_when.tool_registered.as_ref() {
1194 let clause = PredicateClause::ToolRegistered(tool);
1195 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1196 if outcome != PredicateOutcome::Satisfied {
1197 all_satisfied = false;
1198 }
1199 clauses.push((format!("tool_registered: {tool}"), outcome));
1200 }
1201 if let Some(key) = applies_when.extension_enabled.as_ref() {
1202 let clause = PredicateClause::ExtensionEnabled(key);
1203 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1204 if outcome != PredicateOutcome::Satisfied {
1205 all_satisfied = false;
1206 }
1207 clauses.push((format!("extension_enabled: {key}"), outcome));
1208 }
1209
1210 SkillActivation {
1211 active: all_satisfied,
1212 clauses,
1213 }
1214 }
1215
1216 fn dispatch_clause(
1217 &self,
1218 clause: &PredicateClause<'_>,
1219 registered_tools: &std::collections::HashSet<String>,
1220 extensions: &serde_json::Map<String, serde_json::Value>,
1221 ) -> PredicateOutcome {
1222 match clause {
1227 PredicateClause::ToolRegistered(name) => {
1228 return if registered_tools.contains(*name) {
1229 PredicateOutcome::Satisfied
1230 } else {
1231 PredicateOutcome::Unsatisfied
1232 };
1233 }
1234 PredicateClause::ExtensionEnabled(key) => {
1235 let truthy = extensions
1236 .get(*key)
1237 .map(|v| !v.is_null() && v != &serde_json::Value::Bool(false))
1238 .unwrap_or(false);
1239 return if truthy {
1240 PredicateOutcome::Satisfied
1241 } else {
1242 PredicateOutcome::Unsatisfied
1243 };
1244 }
1245 _ => {}
1246 }
1247
1248 match self.evaluator.as_ref().and_then(|e| e.evaluate(clause)) {
1251 Some(true) => PredicateOutcome::Satisfied,
1252 Some(false) => PredicateOutcome::Unsatisfied,
1253 None => PredicateOutcome::Unknown,
1254 }
1255 }
1256}
1257
1258#[cfg(test)]
1261mod tests {
1262 use super::*;
1263 use std::io::Write;
1264
1265 fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
1266 let path = dir.join(format!("{name}.md"));
1267 let mut f = fs::File::create(&path).unwrap();
1268 f.write_all(content.as_bytes()).unwrap();
1269 path
1270 }
1271
1272 fn minimal_skill(name: &str) -> String {
1273 format!(
1274 "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
1275 )
1276 }
1277
1278 #[test]
1281 fn parse_frontmatter_basic() {
1282 let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
1283 let path = PathBuf::from("test.md");
1284 let (fm, body) = parse_skill(content, &path).unwrap();
1285 assert_eq!(fm.name, "foo");
1286 assert_eq!(fm.description, "A foo skill.");
1287 assert_eq!(body, "\nBody here.\n");
1288 assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
1289 }
1290
1291 #[test]
1292 fn parse_frontmatter_missing_delimiters_rejected() {
1293 let content = "name: foo\ndescription: bar\n";
1294 let path = PathBuf::from("test.md");
1295 let err = parse_skill(content, &path).unwrap_err();
1296 assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
1297 }
1298
1299 #[test]
1300 fn parse_frontmatter_invalid_yaml_rejected() {
1301 let content = "---\nname: foo\n bad: yaml: nesting\n---\nbody\n";
1302 let path = PathBuf::from("test.md");
1303 let err = parse_skill(content, &path).unwrap_err();
1304 assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
1305 }
1306
1307 #[test]
1308 fn parse_frontmatter_missing_name_rejected() {
1309 let content = "---\ndescription: bar\n---\nbody\n";
1310 let path = PathBuf::from("test.md");
1311 let err = parse_skill(content, &path).unwrap_err();
1312 assert!(matches!(
1313 err,
1314 SkillError::MissingRequiredField { field: "name", .. }
1315 ));
1316 }
1317
1318 #[test]
1319 fn parse_frontmatter_missing_description_rejected() {
1320 let content = "---\nname: foo\n---\nbody\n";
1321 let path = PathBuf::from("test.md");
1322 let err = parse_skill(content, &path).unwrap_err();
1323 assert!(matches!(
1324 err,
1325 SkillError::MissingRequiredField {
1326 field: "description",
1327 ..
1328 }
1329 ));
1330 }
1331
1332 #[test]
1333 fn parse_frontmatter_all_optional_fields() {
1334 let content = "---\n\
1335name: foo\n\
1336description: Full surface.\n\
1337references_tools: [grep, list_source]\n\
1338references_arguments: [grep.pattern]\n\
1339references_properties: [Function.module]\n\
1340auto_inject_hint: false\n\
1341applies_to:\n mcp_methods: \">=0.3.35\"\n\
1342---\n\
1343Body.\n";
1344 let path = PathBuf::from("test.md");
1345 let (fm, _) = parse_skill(content, &path).unwrap();
1346 assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1347 assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1348 assert_eq!(fm.references_properties, vec!["Function.module"]);
1349 assert!(!fm.auto_inject_hint);
1350 assert_eq!(
1351 fm.applies_to.unwrap().get("mcp_methods"),
1352 Some(&">=0.3.35".to_string())
1353 );
1354 }
1355
1356 #[test]
1359 fn load_skill_from_file_basic() {
1360 let dir = tempfile::tempdir().unwrap();
1361 let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1362 let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1363 assert_eq!(skill.name(), "foo");
1364 assert_eq!(skill.provenance, SkillProvenance::Project);
1365 }
1366
1367 #[test]
1368 fn load_skill_too_large_rejected() {
1369 let dir = tempfile::tempdir().unwrap();
1370 let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1372 let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1373 let path = write_skill(dir.path(), "big", &content);
1374 let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1375 assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1376 }
1377
1378 #[test]
1379 fn load_skills_from_dir_walks_markdown_only() {
1380 let dir = tempfile::tempdir().unwrap();
1381 write_skill(dir.path(), "a", &minimal_skill("a"));
1382 write_skill(dir.path(), "b", &minimal_skill("b"));
1383 fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1385 let sub = dir.path().join("sub");
1387 fs::create_dir(&sub).unwrap();
1388 write_skill(&sub, "c", &minimal_skill("c"));
1389
1390 let skills = load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1391 assert_eq!(skills.len(), 2);
1392 let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1393 names.sort();
1394 assert_eq!(names, vec!["a", "b"]);
1395 }
1396
1397 #[test]
1398 fn load_skills_from_dir_missing_returns_empty() {
1399 let dir = tempfile::tempdir().unwrap();
1400 let nonexistent = dir.path().join("does-not-exist");
1401 let skills = load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1402 assert!(skills.is_empty());
1403 }
1404
1405 #[test]
1408 fn resolve_skill_path_relative() {
1409 let manifest_dir = Path::new("/a/b");
1410 assert_eq!(
1411 resolve_skill_path("./skills", manifest_dir),
1412 PathBuf::from("/a/b/./skills")
1413 );
1414 assert_eq!(
1415 resolve_skill_path("skills", manifest_dir),
1416 PathBuf::from("/a/b/skills")
1417 );
1418 }
1419
1420 #[test]
1421 fn resolve_skill_path_absolute() {
1422 let manifest_dir = Path::new("/a/b");
1423 assert_eq!(
1424 resolve_skill_path("/abs/skills", manifest_dir),
1425 PathBuf::from("/abs/skills")
1426 );
1427 }
1428
1429 #[test]
1430 fn resolve_skill_path_home_relative() {
1431 let manifest_dir = Path::new("/a/b");
1432 unsafe {
1436 std::env::set_var("HOME", "/home/test");
1437 }
1438 assert_eq!(
1439 resolve_skill_path("~/skills", manifest_dir),
1440 PathBuf::from("/home/test/skills")
1441 );
1442 }
1443
1444 #[test]
1445 fn project_skills_dir_naming() {
1446 assert_eq!(
1447 project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1448 PathBuf::from("/a/b/legal_mcp.skills")
1449 );
1450 assert_eq!(
1451 project_skills_dir(Path::new("workspace_mcp.yaml")),
1452 PathBuf::from("workspace_mcp.skills")
1453 );
1454 }
1455
1456 #[test]
1459 fn registry_disabled_resolves_empty() {
1460 let dir = tempfile::tempdir().unwrap();
1461 let yaml = dir.path().join("test_mcp.yaml");
1462 fs::write(&yaml, "name: x\n").unwrap();
1463
1464 let registry = Registry::new()
1465 .layer_dirs(&SkillsSource::Disabled, &yaml)
1466 .unwrap()
1467 .auto_detect_project_layer(&yaml)
1468 .finalise()
1469 .unwrap();
1470 assert!(registry.is_empty());
1471 }
1472
1473 #[test]
1474 fn registry_add_bundled_only_visible_when_opted_in() {
1475 let dir = tempfile::tempdir().unwrap();
1476 let yaml = dir.path().join("test_mcp.yaml");
1477 fs::write(&yaml, "name: x\n").unwrap();
1478
1479 let bundled = BundledSkill {
1480 name: "foo",
1481 body: Box::leak(minimal_skill("foo").into_boxed_str()),
1485 };
1486
1487 let registry = Registry::new()
1489 .add_bundled(bundled.clone())
1490 .layer_dirs(&SkillsSource::Disabled, &yaml)
1491 .unwrap()
1492 .finalise()
1493 .unwrap();
1494 assert!(registry.is_empty(), "disabled must short-circuit bundled");
1495
1496 let registry = Registry::new()
1498 .add_bundled(bundled)
1499 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1500 .unwrap()
1501 .finalise()
1502 .unwrap();
1503 assert_eq!(registry.len(), 1);
1504 assert!(registry.get("foo").is_some());
1505 assert_eq!(
1506 registry.get("foo").unwrap().provenance,
1507 SkillProvenance::Bundled
1508 );
1509 }
1510
1511 #[test]
1512 fn registry_three_layer_resolution_project_wins_over_bundled() {
1513 let dir = tempfile::tempdir().unwrap();
1514 let yaml = dir.path().join("test_mcp.yaml");
1515 fs::write(&yaml, "name: x\n").unwrap();
1516
1517 let bundled = BundledSkill {
1519 name: "foo",
1520 body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1521 };
1522
1523 let project_dir = dir.path().join("test_mcp.skills");
1525 fs::create_dir(&project_dir).unwrap();
1526 fs::write(
1527 project_dir.join("foo.md"),
1528 "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1529 )
1530 .unwrap();
1531
1532 let registry = Registry::new()
1533 .add_bundled(bundled)
1534 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1535 .unwrap()
1536 .auto_detect_project_layer(&yaml)
1537 .finalise()
1538 .unwrap();
1539
1540 assert_eq!(registry.len(), 1);
1541 let skill = registry.get("foo").unwrap();
1542 assert_eq!(skill.description(), "from project.");
1543 assert_eq!(skill.provenance, SkillProvenance::Project);
1544 }
1545
1546 #[test]
1547 fn registry_root_layer_first_declaration_wins() {
1548 let dir = tempfile::tempdir().unwrap();
1549 let yaml = dir.path().join("test_mcp.yaml");
1550 fs::write(&yaml, "name: x\n").unwrap();
1551
1552 let primary = dir.path().join("primary");
1554 fs::create_dir(&primary).unwrap();
1555 fs::write(
1556 primary.join("foo.md"),
1557 "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1558 )
1559 .unwrap();
1560
1561 let secondary = dir.path().join("secondary");
1563 fs::create_dir(&secondary).unwrap();
1564 fs::write(
1565 secondary.join("foo.md"),
1566 "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1567 )
1568 .unwrap();
1569
1570 let registry = Registry::new()
1571 .layer_dirs(
1572 &SkillsSource::Sources(vec![
1573 SkillSource::Path("./primary".into()),
1574 SkillSource::Path("./secondary".into()),
1575 ]),
1576 &yaml,
1577 )
1578 .unwrap()
1579 .finalise()
1580 .unwrap();
1581
1582 assert_eq!(registry.len(), 1);
1583 assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1584 }
1585
1586 #[test]
1587 fn registry_root_layer_nonexistent_path_rejected() {
1588 let dir = tempfile::tempdir().unwrap();
1589 let yaml = dir.path().join("test_mcp.yaml");
1590 fs::write(&yaml, "name: x\n").unwrap();
1591
1592 let err = Registry::new()
1593 .layer_dirs(
1594 &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1595 &yaml,
1596 )
1597 .unwrap_err();
1598 assert!(matches!(err, SkillError::PathNotFound { .. }));
1599 }
1600
1601 #[test]
1602 fn registry_empty_list_opts_in_without_root_sources() {
1603 let dir = tempfile::tempdir().unwrap();
1604 let yaml = dir.path().join("test_mcp.yaml");
1605 fs::write(&yaml, "name: x\n").unwrap();
1606
1607 let project_dir = dir.path().join("test_mcp.skills");
1609 fs::create_dir(&project_dir).unwrap();
1610 fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1611
1612 let registry = Registry::new()
1613 .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1614 .unwrap()
1615 .auto_detect_project_layer(&yaml)
1616 .finalise()
1617 .unwrap();
1618
1619 assert_eq!(registry.len(), 1);
1620 assert_eq!(
1621 registry.get("only").unwrap().provenance,
1622 SkillProvenance::Project
1623 );
1624 }
1625
1626 #[test]
1627 fn registry_bundled_name_mismatch_rejected_at_finalise() {
1628 let dir = tempfile::tempdir().unwrap();
1629 let yaml = dir.path().join("test_mcp.yaml");
1630 fs::write(&yaml, "name: x\n").unwrap();
1631
1632 let bundled = BundledSkill {
1634 name: "foo",
1635 body: Box::leak(
1636 "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1637 .to_string()
1638 .into_boxed_str(),
1639 ),
1640 };
1641
1642 let err = Registry::new()
1643 .add_bundled(bundled)
1644 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1645 .unwrap()
1646 .finalise()
1647 .unwrap_err();
1648 assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1649 }
1650
1651 #[test]
1652 fn registry_library_bundled_skills_returns_vec() {
1653 let skills = library_bundled_skills();
1658 assert!(
1659 !skills.is_empty(),
1660 "library_bundled_skills should return framework defaults from Phase 1d onward"
1661 );
1662 }
1663
1664 #[test]
1665 fn registry_skill_names_sorted() {
1666 let dir = tempfile::tempdir().unwrap();
1667 let yaml = dir.path().join("test_mcp.yaml");
1668 fs::write(&yaml, "name: x\n").unwrap();
1669
1670 let pack = dir.path().join("pack");
1671 fs::create_dir(&pack).unwrap();
1672 fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1673 fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1674 fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1675
1676 let registry = Registry::new()
1677 .layer_dirs(
1678 &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1679 &yaml,
1680 )
1681 .unwrap()
1682 .finalise()
1683 .unwrap();
1684
1685 assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1686 }
1687
1688 #[test]
1691 fn render_skill_template_is_parse_valid() {
1692 let body = render_skill_template("custom_method", "A test description for the skill.");
1696 let (fm, _body) =
1697 parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1698 assert_eq!(fm.name, "custom_method");
1699 assert_eq!(fm.description, "A test description for the skill.");
1700 }
1701
1702 #[test]
1703 fn render_skill_template_substitutes_name_into_body_headings() {
1704 let body = render_skill_template("my_skill", "desc");
1705 assert!(body.contains("# `my_skill` methodology"));
1706 assert!(body.contains("## When `my_skill` is the wrong tool"));
1707 }
1708
1709 #[test]
1710 fn write_skill_template_writes_into_directory() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1713 assert_eq!(dest, dir.path().join("alpha.md"));
1714 let content = fs::read_to_string(&dest).unwrap();
1715 assert!(content.contains("name: alpha"));
1716 }
1717
1718 #[test]
1719 fn write_skill_template_writes_to_explicit_md_path() {
1720 let dir = tempfile::tempdir().unwrap();
1721 let explicit = dir.path().join("renamed.md");
1722 let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1723 assert_eq!(dest, explicit);
1724 assert!(explicit.is_file());
1725 }
1726
1727 #[test]
1728 fn write_skill_template_creates_missing_parents() {
1729 let dir = tempfile::tempdir().unwrap();
1730 let nested = dir.path().join("a/b/c");
1731 let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1732 assert_eq!(dest, nested.join("alpha.md"));
1733 assert!(dest.is_file());
1734 }
1735
1736 #[test]
1737 fn write_skill_template_refuses_to_overwrite() {
1738 let dir = tempfile::tempdir().unwrap();
1739 let path = dir.path().join("alpha.md");
1740 fs::write(&path, "existing").unwrap();
1741 let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1742 assert!(matches!(err, SkillError::Io { .. }));
1743 assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1745 }
1746
1747 #[test]
1748 fn write_skill_template_round_trips_through_registry() {
1749 let dir = tempfile::tempdir().unwrap();
1752 let yaml = dir.path().join("test_mcp.yaml");
1753 fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1754 let skills_dir = dir.path().join("test_mcp.skills");
1755 write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1756
1757 let registry = Registry::new()
1758 .auto_detect_project_layer(&yaml)
1759 .finalise()
1760 .unwrap();
1761 let skill = registry
1762 .get("custom_method")
1763 .expect("template should resolve");
1764 assert_eq!(skill.description(), "Project-layer skill body.");
1765 }
1766
1767 fn skill_with_applies_when(applies_when_yaml: &str) -> Skill {
1770 let body = format!(
1771 "---\nname: gated\ndescription: A gated skill.\n\
1772 applies_when:\n{applies_when_yaml}\n---\n\nBody.\n"
1773 );
1774 let (frontmatter, body) = parse_skill(&body, &PathBuf::from("gated.md")).unwrap();
1775 Skill {
1776 frontmatter,
1777 body,
1778 provenance: SkillProvenance::Bundled,
1779 }
1780 }
1781
1782 #[test]
1783 fn applies_when_parses_map_shape() {
1784 let skill = skill_with_applies_when(
1785 " graph_has_node_type: [Function, Class]\n\
1786 \x20 tool_registered: cypher_query\n\
1787 \x20 extension_enabled: csv_http_server\n\
1788 \x20 graph_has_property:\n\
1789 \x20 node_type: Function\n\
1790 \x20 prop_name: module",
1791 );
1792 let applies = skill.frontmatter.applies_when.unwrap();
1793 assert_eq!(
1794 applies.graph_has_node_type.as_deref(),
1795 Some(["Function".to_string(), "Class".to_string()].as_slice())
1796 );
1797 assert_eq!(applies.tool_registered.as_deref(), Some("cypher_query"));
1798 assert_eq!(
1799 applies.extension_enabled.as_deref(),
1800 Some("csv_http_server")
1801 );
1802 assert_eq!(
1803 applies.graph_has_property,
1804 Some(GraphPropertyCheck {
1805 node_type: "Function".to_string(),
1806 prop_name: "module".to_string(),
1807 })
1808 );
1809 }
1810
1811 #[test]
1812 fn applies_when_absent_means_always_active() {
1813 let body = "---\nname: ungated\ndescription: An ungated skill.\n---\n\nBody.\n";
1814 let (frontmatter, body) = parse_skill(body, &PathBuf::from("ungated.md")).unwrap();
1815 let skill = Skill {
1816 frontmatter,
1817 body,
1818 provenance: SkillProvenance::Bundled,
1819 };
1820 let registry = ResolvedRegistry::default();
1821 let activation = registry.activation_for(
1822 &skill,
1823 &std::collections::HashSet::new(),
1824 &serde_json::Map::new(),
1825 );
1826 assert!(activation.active);
1827 assert!(activation.clauses.is_empty());
1828 }
1829
1830 #[test]
1831 fn tool_registered_predicate_dispatches_in_framework() {
1832 let skill = skill_with_applies_when(" tool_registered: cypher_query");
1833 let registry = ResolvedRegistry::default();
1834 let mut tools = std::collections::HashSet::new();
1835
1836 let inactive = registry.activation_for(&skill, &tools, &serde_json::Map::new());
1838 assert!(!inactive.active);
1839 assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
1840
1841 tools.insert("cypher_query".to_string());
1843 let active = registry.activation_for(&skill, &tools, &serde_json::Map::new());
1844 assert!(active.active);
1845 assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
1846 }
1847
1848 #[test]
1849 fn extension_enabled_predicate_dispatches_in_framework() {
1850 let skill = skill_with_applies_when(" extension_enabled: csv_http_server");
1851 let registry = ResolvedRegistry::default();
1852 let tools = std::collections::HashSet::new();
1853 let mut extensions = serde_json::Map::new();
1854
1855 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1857
1858 extensions.insert("csv_http_server".to_string(), serde_json::json!(false));
1860 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1861
1862 extensions.insert("csv_http_server".to_string(), serde_json::Value::Null);
1864 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1865
1866 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1868 assert!(registry.activation_for(&skill, &tools, &extensions).active);
1869
1870 extensions.insert(
1872 "csv_http_server".to_string(),
1873 serde_json::json!({"enabled": true}),
1874 );
1875 assert!(registry.activation_for(&skill, &tools, &extensions).active);
1876 }
1877
1878 struct StubEvaluator {
1879 has_function: bool,
1880 }
1881 impl SkillPredicateEvaluator for StubEvaluator {
1882 fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
1883 match clause {
1884 PredicateClause::GraphHasNodeType(types) => {
1885 Some(types.iter().any(|t| t == "Function") && self.has_function)
1886 }
1887 _ => None,
1888 }
1889 }
1890 }
1891
1892 #[test]
1893 fn graph_predicate_dispatches_via_evaluator() {
1894 let skill = skill_with_applies_when(" graph_has_node_type: [Function, Class]");
1895
1896 let registry = Registry::new()
1898 .with_predicate_evaluator(StubEvaluator { has_function: true })
1899 .finalise()
1900 .unwrap();
1901 let active = registry.activation_for(
1902 &skill,
1903 &std::collections::HashSet::new(),
1904 &serde_json::Map::new(),
1905 );
1906 assert!(active.active);
1907 assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
1908
1909 let registry = Registry::new()
1911 .with_predicate_evaluator(StubEvaluator {
1912 has_function: false,
1913 })
1914 .finalise()
1915 .unwrap();
1916 let inactive = registry.activation_for(
1917 &skill,
1918 &std::collections::HashSet::new(),
1919 &serde_json::Map::new(),
1920 );
1921 assert!(!inactive.active);
1922 assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
1923 }
1924
1925 #[test]
1926 fn graph_predicate_unknown_without_evaluator_means_inactive() {
1927 let skill = skill_with_applies_when(" graph_has_node_type: [Function]");
1928 let registry = ResolvedRegistry::default();
1929 let activation = registry.activation_for(
1930 &skill,
1931 &std::collections::HashSet::new(),
1932 &serde_json::Map::new(),
1933 );
1934 assert!(!activation.active);
1935 assert_eq!(activation.clauses[0].1, PredicateOutcome::Unknown);
1936 }
1937
1938 #[test]
1939 fn multiple_predicates_all_must_be_satisfied() {
1940 let skill = skill_with_applies_when(
1941 " graph_has_node_type: [Function]\n\
1942 \x20 tool_registered: cypher_query",
1943 );
1944 let registry = Registry::new()
1945 .with_predicate_evaluator(StubEvaluator { has_function: true })
1946 .finalise()
1947 .unwrap();
1948 let mut tools = std::collections::HashSet::new();
1949 let extensions = serde_json::Map::new();
1950
1951 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1953
1954 tools.insert("cypher_query".to_string());
1956 assert!(registry.activation_for(&skill, &tools, &extensions).active);
1957 }
1958}