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
531#[derive(Debug, Clone)]
547pub struct ParseWarning {
548 pub path: PathBuf,
550 pub error: String,
552}
553
554pub fn load_skills_from_dir(
563 dir: &Path,
564 provenance: SkillProvenance,
565) -> Result<(Vec<Skill>, Vec<ParseWarning>), SkillError> {
566 if !dir.is_dir() {
567 return Ok((Vec::new(), Vec::new()));
568 }
569
570 let entries = fs::read_dir(dir).map_err(|e| SkillError::Io {
571 path: dir.to_path_buf(),
572 source: e,
573 })?;
574
575 let mut skills = Vec::new();
576 let mut warnings = Vec::new();
577 for entry in entries {
578 let entry = match entry {
579 Ok(e) => e,
580 Err(e) => {
581 tracing::warn!(
582 dir = %dir.display(),
583 error = %e,
584 "failed to read directory entry; skipping"
585 );
586 warnings.push(ParseWarning {
587 path: dir.to_path_buf(),
588 error: format!("failed to read directory entry: {e}"),
589 });
590 continue;
591 }
592 };
593 let path = entry.path();
594 if path.extension().map(|e| e == "md").unwrap_or(false) {
597 match load_skill_from_file(&path, provenance.clone()) {
598 Ok(skill) => skills.push(skill),
599 Err(e) => {
600 tracing::warn!(
601 path = %path.display(),
602 error = %e,
603 "failed to load skill; skipping"
604 );
605 warnings.push(ParseWarning {
606 path: path.clone(),
607 error: e.to_string(),
608 });
609 }
610 }
611 }
612 }
613 Ok((skills, warnings))
614}
615
616pub fn resolve_skill_path(raw: &str, manifest_dir: &Path) -> PathBuf {
629 let p = Path::new(raw);
630 if p.is_absolute() {
631 return p.to_path_buf();
632 }
633 if let Some(rest) = raw.strip_prefix("~/") {
634 if let Some(home) = std::env::var_os("HOME") {
635 return PathBuf::from(home).join(rest);
636 }
637 }
639 manifest_dir.join(raw)
640}
641
642pub fn project_skills_dir(yaml_path: &Path) -> PathBuf {
648 let stem = yaml_path
649 .file_stem()
650 .map(|s| s.to_string_lossy().into_owned())
651 .unwrap_or_else(|| "manifest".to_string());
652 let parent = yaml_path.parent().unwrap_or_else(|| Path::new("."));
653 parent.join(format!("{stem}.skills"))
654}
655
656pub fn library_bundled_skills() -> Vec<BundledSkill> {
666 crate::server::bundled_skills_index::library_bundled_skills()
667}
668
669pub fn render_skill_template(name: &str, description: &str) -> String {
684 format!(
685 "---\n\
686 name: {name}\n\
687 description: {description}\n\
688 # Optional mcp-methods extension fields (uncomment as needed):\n\
689 # applies_to:\n\
690 # mcp_methods: \">=0.3.35\"\n\
691 # references_tools:\n\
692 # - {name}\n\
693 # references_arguments:\n\
694 # - {name}.<arg_name>\n\
695 # auto_inject_hint: true\n\
696 ---\n\
697 \n\
698 # `{name}` methodology\n\
699 \n\
700 ## Overview\n\
701 \n\
702 <TODO: 2–3 sentences. What this skill enables, when to reach for it,\n\
703 what comes before and after it in the typical workflow.>\n\
704 \n\
705 ## Quick Reference\n\
706 \n\
707 | Task | Approach |\n\
708 |---|---|\n\
709 | <TODO: common task A> | <TODO: one-line pattern> |\n\
710 | <TODO: common task B> | <TODO: one-line pattern> |\n\
711 \n\
712 ## <TODO: Major topic>\n\
713 \n\
714 <TODO: concrete prose, code blocks, examples.>\n\
715 \n\
716 ## Common Pitfalls\n\
717 \n\
718 ❌ <TODO: specific anti-pattern, framed as a behaviour to avoid>\n\
719 \n\
720 ✅ <TODO: positive guidance, often a heuristic>\n\
721 \n\
722 ## When `{name}` is the wrong tool\n\
723 \n\
724 - **<TODO: scenario>** — use <other tool> because <reason>.\n"
725 )
726}
727
728pub fn write_skill_template(
743 dest: &Path,
744 name: &str,
745 description: &str,
746) -> Result<PathBuf, SkillError> {
747 let path = resolve_template_dest(dest, name);
748
749 if path.exists() {
750 return Err(SkillError::Io {
751 path: path.clone(),
752 source: std::io::Error::new(
753 std::io::ErrorKind::AlreadyExists,
754 "destination already exists; delete it before re-running",
755 ),
756 });
757 }
758
759 if let Some(parent) = path.parent() {
760 if !parent.as_os_str().is_empty() && !parent.exists() {
761 fs::create_dir_all(parent).map_err(|e| SkillError::Io {
762 path: parent.to_path_buf(),
763 source: e,
764 })?;
765 }
766 }
767
768 let body = render_skill_template(name, description);
769 fs::write(&path, body).map_err(|e| SkillError::Io {
770 path: path.clone(),
771 source: e,
772 })?;
773 Ok(path)
774}
775
776fn resolve_template_dest(dest: &Path, name: &str) -> PathBuf {
777 if dest.is_dir() {
778 return dest.join(format!("{name}.md"));
779 }
780 if dest
781 .extension()
782 .map(|e| e.eq_ignore_ascii_case("md"))
783 .unwrap_or(false)
784 {
785 return dest.to_path_buf();
786 }
787 dest.join(format!("{name}.md"))
788}
789
790#[derive(Default)]
800pub struct Registry {
801 bundled: Vec<BundledSkill>,
802 root_dirs: Vec<(PathBuf, String)>, root_includes_bundled: bool,
807 project_dir: Option<PathBuf>,
810 evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
816}
817
818impl std::fmt::Debug for Registry {
819 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820 f.debug_struct("Registry")
821 .field("bundled", &self.bundled)
822 .field("root_dirs", &self.root_dirs)
823 .field("root_includes_bundled", &self.root_includes_bundled)
824 .field("project_dir", &self.project_dir)
825 .field(
826 "evaluator",
827 &self
828 .evaluator
829 .as_ref()
830 .map(|_| "<dyn SkillPredicateEvaluator>"),
831 )
832 .finish()
833 }
834}
835
836impl Registry {
837 pub fn new() -> Self {
842 Self::default()
843 }
844
845 pub fn with_predicate_evaluator(
858 mut self,
859 evaluator: impl SkillPredicateEvaluator + 'static,
860 ) -> Self {
861 self.evaluator = Some(Arc::new(evaluator));
862 self
863 }
864
865 pub fn add_bundled(mut self, skill: BundledSkill) -> Self {
883 self.bundled.push(skill);
884 self
885 }
886
887 pub fn add_bundled_many(mut self, skills: impl IntoIterator<Item = BundledSkill>) -> Self {
889 self.bundled.extend(skills);
890 self
891 }
892
893 pub fn merge_framework_defaults(self) -> Self {
898 let defaults = library_bundled_skills();
899 self.add_bundled_many(defaults)
900 }
901
902 pub fn layer_dirs(
914 mut self,
915 source: &SkillsSource,
916 yaml_path: &Path,
917 ) -> Result<Self, SkillError> {
918 let manifest_dir = yaml_path.parent().unwrap_or_else(|| Path::new("."));
919
920 match source {
921 SkillsSource::Disabled => {
922 self.root_includes_bundled = false;
927 }
928 SkillsSource::Sources(sources) => {
929 for src in sources {
930 match src {
931 SkillSource::Bundled => {
932 self.root_includes_bundled = true;
933 }
934 SkillSource::Path(raw) => {
935 let resolved = resolve_skill_path(raw, manifest_dir);
936 if !resolved.is_dir() {
937 return Err(SkillError::PathNotFound {
938 raw: raw.clone(),
939 resolved,
940 });
941 }
942 self.root_dirs.push((resolved, raw.clone()));
943 }
944 }
945 }
946 }
947 }
948
949 Ok(self)
950 }
951
952 pub fn auto_detect_project_layer(mut self, yaml_path: &Path) -> Self {
957 let candidate = project_skills_dir(yaml_path);
958 if candidate.is_dir() {
959 self.project_dir = Some(candidate);
960 }
961 self
962 }
963
964 pub fn finalise(self) -> Result<ResolvedRegistry, SkillError> {
979 let Self {
980 bundled,
981 root_dirs,
982 root_includes_bundled,
983 project_dir,
984 evaluator,
985 } = self;
986
987 let mut bundled_skills: Vec<Skill> = Vec::with_capacity(bundled.len());
990 if root_includes_bundled {
991 for b in &bundled {
992 let path = PathBuf::from(format!("<bundled:{}>", b.name));
993 let (frontmatter, body) =
994 parse_skill(b.body, &path).map_err(|e| SkillError::BundledSkillInvalid {
995 name: b.name,
996 message: e.to_string(),
997 })?;
998 if frontmatter.name != b.name {
999 return Err(SkillError::BundledSkillInvalid {
1000 name: b.name,
1001 message: format!(
1002 "frontmatter name {:?} does not match the bundled key {:?}",
1003 frontmatter.name, b.name
1004 ),
1005 });
1006 }
1007 bundled_skills.push(Skill {
1008 frontmatter,
1009 body,
1010 provenance: SkillProvenance::Bundled,
1011 });
1012 }
1013 }
1014
1015 let mut parse_warnings: Vec<ParseWarning> = Vec::new();
1019 let mut root_skills_per_dir: Vec<Vec<Skill>> = Vec::with_capacity(root_dirs.len());
1020 for (resolved, _raw) in &root_dirs {
1021 let provenance = SkillProvenance::DomainPack(resolved.clone());
1022 let (skills, warnings) = load_skills_from_dir(resolved, provenance)?;
1023 parse_warnings.extend(warnings);
1024 root_skills_per_dir.push(skills);
1025 }
1026
1027 let project_skills: Vec<Skill> = match &project_dir {
1029 Some(dir) => {
1030 let (skills, warnings) = load_skills_from_dir(dir, SkillProvenance::Project)?;
1031 parse_warnings.extend(warnings);
1032 skills
1033 }
1034 None => Vec::new(),
1035 };
1036
1037 let mut resolved: HashMap<String, Skill> = HashMap::new();
1047 let mut collisions: HashMap<String, Vec<SkillProvenance>> = HashMap::new();
1048
1049 for skill in &bundled_skills {
1053 let name = skill.name().to_string();
1054 collisions
1055 .entry(name.clone())
1056 .or_default()
1057 .push(skill.provenance.clone());
1058 resolved.insert(name, skill.clone());
1059 }
1060 for skills in root_skills_per_dir.iter().rev() {
1061 for skill in skills {
1062 let name = skill.name().to_string();
1063 collisions
1064 .entry(name.clone())
1065 .or_default()
1066 .push(skill.provenance.clone());
1067 resolved.insert(name, skill.clone());
1068 }
1069 }
1070 for skill in &project_skills {
1071 let name = skill.name().to_string();
1072 collisions
1073 .entry(name.clone())
1074 .or_default()
1075 .push(skill.provenance.clone());
1076 resolved.insert(name, skill.clone());
1077 }
1078
1079 for (name, candidates) in &collisions {
1082 if candidates.len() > 1 {
1083 let winner = resolved
1084 .get(name)
1085 .map(|s| format_provenance(&s.provenance))
1086 .unwrap_or_else(|| "<none>".to_string());
1087 let all_candidates: Vec<String> =
1088 candidates.iter().map(format_provenance).collect();
1089 tracing::info!(
1090 skill = %name,
1091 candidates = ?all_candidates,
1092 winner = %winner,
1093 "skill resolved across multiple layers"
1094 );
1095 }
1096 }
1097
1098 let total_bytes: usize = resolved.values().map(|s| s.body.len()).sum();
1100 if total_bytes > SESSION_TOTAL_LIMIT_BYTES {
1101 tracing::warn!(
1102 total_bytes,
1103 limit = SESSION_TOTAL_LIMIT_BYTES,
1104 skill_count = resolved.len(),
1105 "total resolved skill body size exceeds session limit; \
1106 consider trimming or splitting skills"
1107 );
1108 }
1109
1110 Ok(ResolvedRegistry {
1111 skills: resolved,
1112 evaluator,
1113 parse_warnings,
1114 })
1115 }
1116}
1117
1118fn format_provenance(p: &SkillProvenance) -> String {
1119 match p {
1120 SkillProvenance::Project => "project".to_string(),
1121 SkillProvenance::DomainPack(path) => format!("pack:{}", path.display()),
1122 SkillProvenance::Bundled => "bundled".to_string(),
1123 }
1124}
1125
1126#[derive(Default)]
1132pub struct ResolvedRegistry {
1133 skills: HashMap<String, Skill>,
1134 pub(crate) evaluator: Option<Arc<dyn SkillPredicateEvaluator>>,
1140 parse_warnings: Vec<ParseWarning>,
1146}
1147
1148impl std::fmt::Debug for ResolvedRegistry {
1149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1150 f.debug_struct("ResolvedRegistry")
1151 .field("skills", &self.skills)
1152 .field(
1153 "evaluator",
1154 &self
1155 .evaluator
1156 .as_ref()
1157 .map(|_| "<dyn SkillPredicateEvaluator>"),
1158 )
1159 .finish()
1160 }
1161}
1162
1163impl ResolvedRegistry {
1164 pub fn skill_names(&self) -> Vec<String> {
1167 let mut names: Vec<String> = self.skills.keys().cloned().collect();
1168 names.sort();
1169 names
1170 }
1171
1172 pub fn get(&self, name: &str) -> Option<&Skill> {
1175 self.skills.get(name)
1176 }
1177
1178 pub fn iter(&self) -> impl Iterator<Item = (&String, &Skill)> {
1181 self.skills.iter()
1182 }
1183
1184 pub fn len(&self) -> usize {
1186 self.skills.len()
1187 }
1188
1189 pub fn is_empty(&self) -> bool {
1191 self.skills.is_empty()
1192 }
1193
1194 pub fn parse_warnings(&self) -> &[ParseWarning] {
1201 &self.parse_warnings
1202 }
1203
1204 pub fn activation_for(
1217 &self,
1218 skill: &Skill,
1219 registered_tools: &std::collections::HashSet<String>,
1220 extensions: &serde_json::Map<String, serde_json::Value>,
1221 ) -> SkillActivation {
1222 let Some(applies_when) = skill.frontmatter.applies_when.as_ref() else {
1223 return SkillActivation {
1224 active: true,
1225 clauses: Vec::new(),
1226 };
1227 };
1228 let mut clauses = Vec::new();
1229 let mut all_satisfied = true;
1230
1231 if let Some(types) = applies_when.graph_has_node_type.as_ref() {
1232 let clause = PredicateClause::GraphHasNodeType(types);
1233 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1234 if outcome != PredicateOutcome::Satisfied {
1235 all_satisfied = false;
1236 }
1237 clauses.push((format!("graph_has_node_type: {types:?}"), outcome));
1238 }
1239 if let Some(prop) = applies_when.graph_has_property.as_ref() {
1240 let clause = PredicateClause::GraphHasProperty {
1241 node_type: &prop.node_type,
1242 prop_name: &prop.prop_name,
1243 };
1244 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1245 if outcome != PredicateOutcome::Satisfied {
1246 all_satisfied = false;
1247 }
1248 clauses.push((
1249 format!("graph_has_property: {}.{}", prop.node_type, prop.prop_name),
1250 outcome,
1251 ));
1252 }
1253 if let Some(tool) = applies_when.tool_registered.as_ref() {
1254 let clause = PredicateClause::ToolRegistered(tool);
1255 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1256 if outcome != PredicateOutcome::Satisfied {
1257 all_satisfied = false;
1258 }
1259 clauses.push((format!("tool_registered: {tool}"), outcome));
1260 }
1261 if let Some(key) = applies_when.extension_enabled.as_ref() {
1262 let clause = PredicateClause::ExtensionEnabled(key);
1263 let outcome = self.dispatch_clause(&clause, registered_tools, extensions);
1264 if outcome != PredicateOutcome::Satisfied {
1265 all_satisfied = false;
1266 }
1267 clauses.push((format!("extension_enabled: {key}"), outcome));
1268 }
1269
1270 SkillActivation {
1271 active: all_satisfied,
1272 clauses,
1273 }
1274 }
1275
1276 fn dispatch_clause(
1277 &self,
1278 clause: &PredicateClause<'_>,
1279 registered_tools: &std::collections::HashSet<String>,
1280 extensions: &serde_json::Map<String, serde_json::Value>,
1281 ) -> PredicateOutcome {
1282 match clause {
1287 PredicateClause::ToolRegistered(name) => {
1288 return if registered_tools.contains(*name) {
1289 PredicateOutcome::Satisfied
1290 } else {
1291 PredicateOutcome::Unsatisfied
1292 };
1293 }
1294 PredicateClause::ExtensionEnabled(key) => {
1295 let truthy = extensions
1296 .get(*key)
1297 .map(|v| !v.is_null() && v != &serde_json::Value::Bool(false))
1298 .unwrap_or(false);
1299 return if truthy {
1300 PredicateOutcome::Satisfied
1301 } else {
1302 PredicateOutcome::Unsatisfied
1303 };
1304 }
1305 _ => {}
1306 }
1307
1308 match self.evaluator.as_ref().and_then(|e| e.evaluate(clause)) {
1311 Some(true) => PredicateOutcome::Satisfied,
1312 Some(false) => PredicateOutcome::Unsatisfied,
1313 None => PredicateOutcome::Unknown,
1314 }
1315 }
1316}
1317
1318#[cfg(test)]
1321mod tests {
1322 use super::*;
1323 use std::io::Write;
1324
1325 fn write_skill(dir: &Path, name: &str, content: &str) -> PathBuf {
1326 let path = dir.join(format!("{name}.md"));
1327 let mut f = fs::File::create(&path).unwrap();
1328 f.write_all(content.as_bytes()).unwrap();
1329 path
1330 }
1331
1332 fn minimal_skill(name: &str) -> String {
1333 format!(
1334 "---\nname: {name}\ndescription: A test skill named {name}.\n---\n\n# {name}\n\nBody.\n"
1335 )
1336 }
1337
1338 #[test]
1341 fn parse_frontmatter_basic() {
1342 let content = "---\nname: foo\ndescription: A foo skill.\n---\n\nBody here.\n";
1343 let path = PathBuf::from("test.md");
1344 let (fm, body) = parse_skill(content, &path).unwrap();
1345 assert_eq!(fm.name, "foo");
1346 assert_eq!(fm.description, "A foo skill.");
1347 assert_eq!(body, "\nBody here.\n");
1348 assert!(fm.auto_inject_hint, "auto_inject_hint defaults to true");
1349 }
1350
1351 #[test]
1352 fn parse_frontmatter_missing_delimiters_rejected() {
1353 let content = "name: foo\ndescription: bar\n";
1354 let path = PathBuf::from("test.md");
1355 let err = parse_skill(content, &path).unwrap_err();
1356 assert!(matches!(err, SkillError::MissingFrontmatter { .. }));
1357 }
1358
1359 #[test]
1360 fn parse_frontmatter_invalid_yaml_rejected() {
1361 let content = "---\nname: foo\n bad: yaml: nesting\n---\nbody\n";
1362 let path = PathBuf::from("test.md");
1363 let err = parse_skill(content, &path).unwrap_err();
1364 assert!(matches!(err, SkillError::InvalidFrontmatter { .. }));
1365 }
1366
1367 #[test]
1368 fn parse_frontmatter_missing_name_rejected() {
1369 let content = "---\ndescription: bar\n---\nbody\n";
1370 let path = PathBuf::from("test.md");
1371 let err = parse_skill(content, &path).unwrap_err();
1372 assert!(matches!(
1373 err,
1374 SkillError::MissingRequiredField { field: "name", .. }
1375 ));
1376 }
1377
1378 #[test]
1379 fn parse_frontmatter_missing_description_rejected() {
1380 let content = "---\nname: foo\n---\nbody\n";
1381 let path = PathBuf::from("test.md");
1382 let err = parse_skill(content, &path).unwrap_err();
1383 assert!(matches!(
1384 err,
1385 SkillError::MissingRequiredField {
1386 field: "description",
1387 ..
1388 }
1389 ));
1390 }
1391
1392 #[test]
1393 fn parse_frontmatter_all_optional_fields() {
1394 let content = "---\n\
1395name: foo\n\
1396description: Full surface.\n\
1397references_tools: [grep, list_source]\n\
1398references_arguments: [grep.pattern]\n\
1399references_properties: [Function.module]\n\
1400auto_inject_hint: false\n\
1401applies_to:\n mcp_methods: \">=0.3.35\"\n\
1402---\n\
1403Body.\n";
1404 let path = PathBuf::from("test.md");
1405 let (fm, _) = parse_skill(content, &path).unwrap();
1406 assert_eq!(fm.references_tools, vec!["grep", "list_source"]);
1407 assert_eq!(fm.references_arguments, vec!["grep.pattern"]);
1408 assert_eq!(fm.references_properties, vec!["Function.module"]);
1409 assert!(!fm.auto_inject_hint);
1410 assert_eq!(
1411 fm.applies_to.unwrap().get("mcp_methods"),
1412 Some(&">=0.3.35".to_string())
1413 );
1414 }
1415
1416 #[test]
1419 fn load_skill_from_file_basic() {
1420 let dir = tempfile::tempdir().unwrap();
1421 let path = write_skill(dir.path(), "foo", &minimal_skill("foo"));
1422 let skill = load_skill_from_file(&path, SkillProvenance::Project).unwrap();
1423 assert_eq!(skill.name(), "foo");
1424 assert_eq!(skill.provenance, SkillProvenance::Project);
1425 }
1426
1427 #[test]
1428 fn load_skill_too_large_rejected() {
1429 let dir = tempfile::tempdir().unwrap();
1430 let big_body = "x".repeat(HARD_SIZE_LIMIT_BYTES + 100);
1432 let content = format!("---\nname: big\ndescription: too big.\n---\n{big_body}");
1433 let path = write_skill(dir.path(), "big", &content);
1434 let err = load_skill_from_file(&path, SkillProvenance::Project).unwrap_err();
1435 assert!(matches!(err, SkillError::SkillTooLarge { .. }));
1436 }
1437
1438 #[test]
1439 fn load_skills_from_dir_walks_markdown_only() {
1440 let dir = tempfile::tempdir().unwrap();
1441 write_skill(dir.path(), "a", &minimal_skill("a"));
1442 write_skill(dir.path(), "b", &minimal_skill("b"));
1443 fs::write(dir.path().join("readme.txt"), "not a skill").unwrap();
1445 let sub = dir.path().join("sub");
1447 fs::create_dir(&sub).unwrap();
1448 write_skill(&sub, "c", &minimal_skill("c"));
1449
1450 let (skills, warnings) =
1451 load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1452 assert_eq!(skills.len(), 2);
1453 assert!(warnings.is_empty());
1454 let mut names: Vec<&str> = skills.iter().map(|s| s.name()).collect();
1455 names.sort();
1456 assert_eq!(names, vec!["a", "b"]);
1457 }
1458
1459 #[test]
1460 fn load_skills_from_dir_missing_returns_empty() {
1461 let dir = tempfile::tempdir().unwrap();
1462 let nonexistent = dir.path().join("does-not-exist");
1463 let (skills, warnings) =
1464 load_skills_from_dir(&nonexistent, SkillProvenance::Project).unwrap();
1465 assert!(skills.is_empty());
1466 assert!(warnings.is_empty());
1467 }
1468
1469 #[test]
1470 fn load_skills_from_dir_surfaces_yaml_parse_failure_as_warning() {
1471 let dir = tempfile::tempdir().unwrap();
1479 write_skill(dir.path(), "good", &minimal_skill("good"));
1481 write_skill(
1483 dir.path(),
1484 "broken",
1485 "---\nname: broken\ndescription: First clause: second clause\n---\n# body\n",
1486 );
1487
1488 let (skills, warnings) =
1489 load_skills_from_dir(dir.path(), SkillProvenance::Project).unwrap();
1490 assert_eq!(skills.len(), 1, "the good skill should still load");
1491 assert_eq!(skills[0].name(), "good");
1492 assert_eq!(
1493 warnings.len(),
1494 1,
1495 "the broken file should surface as a warning"
1496 );
1497 assert!(warnings[0].path.ends_with("broken.md"));
1498 assert!(!warnings[0].error.is_empty());
1499 }
1500
1501 #[test]
1502 fn resolved_registry_parse_warnings_propagated_from_project_layer() {
1503 let dir = tempfile::tempdir().unwrap();
1506 let yaml = dir.path().join("test_mcp.yaml");
1507 fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1508 let skills_dir = dir.path().join("test_mcp.skills");
1509 fs::create_dir(&skills_dir).unwrap();
1510 write_skill(&skills_dir, "good", &minimal_skill("good"));
1512 write_skill(
1514 &skills_dir,
1515 "broken",
1516 "---\nname: broken\ndescription: bad\nstill in frontmatter\n",
1517 );
1518
1519 let registry = Registry::new()
1520 .auto_detect_project_layer(&yaml)
1521 .finalise()
1522 .unwrap();
1523
1524 assert_eq!(registry.len(), 1, "good skill resolved");
1525 assert!(registry.get("good").is_some());
1526 let warnings = registry.parse_warnings();
1527 assert_eq!(warnings.len(), 1);
1528 assert!(warnings[0].path.ends_with("broken.md"));
1529 }
1530
1531 #[test]
1534 fn resolve_skill_path_relative() {
1535 let manifest_dir = Path::new("/a/b");
1536 assert_eq!(
1537 resolve_skill_path("./skills", manifest_dir),
1538 PathBuf::from("/a/b/./skills")
1539 );
1540 assert_eq!(
1541 resolve_skill_path("skills", manifest_dir),
1542 PathBuf::from("/a/b/skills")
1543 );
1544 }
1545
1546 #[test]
1547 fn resolve_skill_path_absolute() {
1548 let manifest_dir = Path::new("/a/b");
1549 assert_eq!(
1550 resolve_skill_path("/abs/skills", manifest_dir),
1551 PathBuf::from("/abs/skills")
1552 );
1553 }
1554
1555 #[test]
1556 fn resolve_skill_path_home_relative() {
1557 let manifest_dir = Path::new("/a/b");
1558 unsafe {
1562 std::env::set_var("HOME", "/home/test");
1563 }
1564 assert_eq!(
1565 resolve_skill_path("~/skills", manifest_dir),
1566 PathBuf::from("/home/test/skills")
1567 );
1568 }
1569
1570 #[test]
1571 fn project_skills_dir_naming() {
1572 assert_eq!(
1573 project_skills_dir(Path::new("/a/b/legal_mcp.yaml")),
1574 PathBuf::from("/a/b/legal_mcp.skills")
1575 );
1576 assert_eq!(
1577 project_skills_dir(Path::new("workspace_mcp.yaml")),
1578 PathBuf::from("workspace_mcp.skills")
1579 );
1580 }
1581
1582 #[test]
1585 fn registry_disabled_resolves_empty() {
1586 let dir = tempfile::tempdir().unwrap();
1587 let yaml = dir.path().join("test_mcp.yaml");
1588 fs::write(&yaml, "name: x\n").unwrap();
1589
1590 let registry = Registry::new()
1591 .layer_dirs(&SkillsSource::Disabled, &yaml)
1592 .unwrap()
1593 .auto_detect_project_layer(&yaml)
1594 .finalise()
1595 .unwrap();
1596 assert!(registry.is_empty());
1597 }
1598
1599 #[test]
1600 fn registry_add_bundled_only_visible_when_opted_in() {
1601 let dir = tempfile::tempdir().unwrap();
1602 let yaml = dir.path().join("test_mcp.yaml");
1603 fs::write(&yaml, "name: x\n").unwrap();
1604
1605 let bundled = BundledSkill {
1606 name: "foo",
1607 body: Box::leak(minimal_skill("foo").into_boxed_str()),
1611 };
1612
1613 let registry = Registry::new()
1615 .add_bundled(bundled.clone())
1616 .layer_dirs(&SkillsSource::Disabled, &yaml)
1617 .unwrap()
1618 .finalise()
1619 .unwrap();
1620 assert!(registry.is_empty(), "disabled must short-circuit bundled");
1621
1622 let registry = Registry::new()
1624 .add_bundled(bundled)
1625 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1626 .unwrap()
1627 .finalise()
1628 .unwrap();
1629 assert_eq!(registry.len(), 1);
1630 assert!(registry.get("foo").is_some());
1631 assert_eq!(
1632 registry.get("foo").unwrap().provenance,
1633 SkillProvenance::Bundled
1634 );
1635 }
1636
1637 #[test]
1638 fn registry_three_layer_resolution_project_wins_over_bundled() {
1639 let dir = tempfile::tempdir().unwrap();
1640 let yaml = dir.path().join("test_mcp.yaml");
1641 fs::write(&yaml, "name: x\n").unwrap();
1642
1643 let bundled = BundledSkill {
1645 name: "foo",
1646 body: "---\nname: foo\ndescription: from bundled.\n---\nbundled body\n",
1647 };
1648
1649 let project_dir = dir.path().join("test_mcp.skills");
1651 fs::create_dir(&project_dir).unwrap();
1652 fs::write(
1653 project_dir.join("foo.md"),
1654 "---\nname: foo\ndescription: from project.\n---\nproject body\n",
1655 )
1656 .unwrap();
1657
1658 let registry = Registry::new()
1659 .add_bundled(bundled)
1660 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1661 .unwrap()
1662 .auto_detect_project_layer(&yaml)
1663 .finalise()
1664 .unwrap();
1665
1666 assert_eq!(registry.len(), 1);
1667 let skill = registry.get("foo").unwrap();
1668 assert_eq!(skill.description(), "from project.");
1669 assert_eq!(skill.provenance, SkillProvenance::Project);
1670 }
1671
1672 #[test]
1673 fn registry_root_layer_first_declaration_wins() {
1674 let dir = tempfile::tempdir().unwrap();
1675 let yaml = dir.path().join("test_mcp.yaml");
1676 fs::write(&yaml, "name: x\n").unwrap();
1677
1678 let primary = dir.path().join("primary");
1680 fs::create_dir(&primary).unwrap();
1681 fs::write(
1682 primary.join("foo.md"),
1683 "---\nname: foo\ndescription: from primary.\n---\nprimary body\n",
1684 )
1685 .unwrap();
1686
1687 let secondary = dir.path().join("secondary");
1689 fs::create_dir(&secondary).unwrap();
1690 fs::write(
1691 secondary.join("foo.md"),
1692 "---\nname: foo\ndescription: from secondary.\n---\nsecondary body\n",
1693 )
1694 .unwrap();
1695
1696 let registry = Registry::new()
1697 .layer_dirs(
1698 &SkillsSource::Sources(vec![
1699 SkillSource::Path("./primary".into()),
1700 SkillSource::Path("./secondary".into()),
1701 ]),
1702 &yaml,
1703 )
1704 .unwrap()
1705 .finalise()
1706 .unwrap();
1707
1708 assert_eq!(registry.len(), 1);
1709 assert_eq!(registry.get("foo").unwrap().description(), "from primary.");
1710 }
1711
1712 #[test]
1713 fn registry_root_layer_nonexistent_path_rejected() {
1714 let dir = tempfile::tempdir().unwrap();
1715 let yaml = dir.path().join("test_mcp.yaml");
1716 fs::write(&yaml, "name: x\n").unwrap();
1717
1718 let err = Registry::new()
1719 .layer_dirs(
1720 &SkillsSource::Sources(vec![SkillSource::Path("./does-not-exist".into())]),
1721 &yaml,
1722 )
1723 .unwrap_err();
1724 assert!(matches!(err, SkillError::PathNotFound { .. }));
1725 }
1726
1727 #[test]
1728 fn registry_empty_list_opts_in_without_root_sources() {
1729 let dir = tempfile::tempdir().unwrap();
1730 let yaml = dir.path().join("test_mcp.yaml");
1731 fs::write(&yaml, "name: x\n").unwrap();
1732
1733 let project_dir = dir.path().join("test_mcp.skills");
1735 fs::create_dir(&project_dir).unwrap();
1736 fs::write(project_dir.join("only.md"), minimal_skill("only")).unwrap();
1737
1738 let registry = Registry::new()
1739 .layer_dirs(&SkillsSource::Sources(vec![]), &yaml)
1740 .unwrap()
1741 .auto_detect_project_layer(&yaml)
1742 .finalise()
1743 .unwrap();
1744
1745 assert_eq!(registry.len(), 1);
1746 assert_eq!(
1747 registry.get("only").unwrap().provenance,
1748 SkillProvenance::Project
1749 );
1750 }
1751
1752 #[test]
1753 fn registry_bundled_name_mismatch_rejected_at_finalise() {
1754 let dir = tempfile::tempdir().unwrap();
1755 let yaml = dir.path().join("test_mcp.yaml");
1756 fs::write(&yaml, "name: x\n").unwrap();
1757
1758 let bundled = BundledSkill {
1760 name: "foo",
1761 body: Box::leak(
1762 "---\nname: bar\ndescription: mismatch.\n---\nbody\n"
1763 .to_string()
1764 .into_boxed_str(),
1765 ),
1766 };
1767
1768 let err = Registry::new()
1769 .add_bundled(bundled)
1770 .layer_dirs(&SkillsSource::Sources(vec![SkillSource::Bundled]), &yaml)
1771 .unwrap()
1772 .finalise()
1773 .unwrap_err();
1774 assert!(matches!(err, SkillError::BundledSkillInvalid { .. }));
1775 }
1776
1777 #[test]
1778 fn registry_library_bundled_skills_returns_vec() {
1779 let skills = library_bundled_skills();
1784 assert!(
1785 !skills.is_empty(),
1786 "library_bundled_skills should return framework defaults from Phase 1d onward"
1787 );
1788 }
1789
1790 #[test]
1791 fn registry_skill_names_sorted() {
1792 let dir = tempfile::tempdir().unwrap();
1793 let yaml = dir.path().join("test_mcp.yaml");
1794 fs::write(&yaml, "name: x\n").unwrap();
1795
1796 let pack = dir.path().join("pack");
1797 fs::create_dir(&pack).unwrap();
1798 fs::write(pack.join("zeta.md"), minimal_skill("zeta")).unwrap();
1799 fs::write(pack.join("alpha.md"), minimal_skill("alpha")).unwrap();
1800 fs::write(pack.join("mu.md"), minimal_skill("mu")).unwrap();
1801
1802 let registry = Registry::new()
1803 .layer_dirs(
1804 &SkillsSource::Sources(vec![SkillSource::Path("./pack".into())]),
1805 &yaml,
1806 )
1807 .unwrap()
1808 .finalise()
1809 .unwrap();
1810
1811 assert_eq!(registry.skill_names(), vec!["alpha", "mu", "zeta"]);
1812 }
1813
1814 #[test]
1817 fn render_skill_template_is_parse_valid() {
1818 let body = render_skill_template("custom_method", "A test description for the skill.");
1822 let (fm, _body) =
1823 parse_skill(&body, &PathBuf::from("test.md")).expect("rendered template must parse");
1824 assert_eq!(fm.name, "custom_method");
1825 assert_eq!(fm.description, "A test description for the skill.");
1826 }
1827
1828 #[test]
1829 fn render_skill_template_substitutes_name_into_body_headings() {
1830 let body = render_skill_template("my_skill", "desc");
1831 assert!(body.contains("# `my_skill` methodology"));
1832 assert!(body.contains("## When `my_skill` is the wrong tool"));
1833 }
1834
1835 #[test]
1836 fn write_skill_template_writes_into_directory() {
1837 let dir = tempfile::tempdir().unwrap();
1838 let dest = write_skill_template(dir.path(), "alpha", "First skill.").unwrap();
1839 assert_eq!(dest, dir.path().join("alpha.md"));
1840 let content = fs::read_to_string(&dest).unwrap();
1841 assert!(content.contains("name: alpha"));
1842 }
1843
1844 #[test]
1845 fn write_skill_template_writes_to_explicit_md_path() {
1846 let dir = tempfile::tempdir().unwrap();
1847 let explicit = dir.path().join("renamed.md");
1848 let dest = write_skill_template(&explicit, "alpha", "First skill.").unwrap();
1849 assert_eq!(dest, explicit);
1850 assert!(explicit.is_file());
1851 }
1852
1853 #[test]
1854 fn write_skill_template_creates_missing_parents() {
1855 let dir = tempfile::tempdir().unwrap();
1856 let nested = dir.path().join("a/b/c");
1857 let dest = write_skill_template(&nested, "alpha", "First skill.").unwrap();
1858 assert_eq!(dest, nested.join("alpha.md"));
1859 assert!(dest.is_file());
1860 }
1861
1862 #[test]
1863 fn write_skill_template_refuses_to_overwrite() {
1864 let dir = tempfile::tempdir().unwrap();
1865 let path = dir.path().join("alpha.md");
1866 fs::write(&path, "existing").unwrap();
1867 let err = write_skill_template(dir.path(), "alpha", "Replace me?").unwrap_err();
1868 assert!(matches!(err, SkillError::Io { .. }));
1869 assert_eq!(fs::read_to_string(&path).unwrap(), "existing");
1871 }
1872
1873 #[test]
1874 fn write_skill_template_round_trips_through_registry() {
1875 let dir = tempfile::tempdir().unwrap();
1878 let yaml = dir.path().join("test_mcp.yaml");
1879 fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1880 let skills_dir = dir.path().join("test_mcp.skills");
1881 write_skill_template(&skills_dir, "custom_method", "Project-layer skill body.").unwrap();
1882
1883 let registry = Registry::new()
1884 .auto_detect_project_layer(&yaml)
1885 .finalise()
1886 .unwrap();
1887 let skill = registry
1888 .get("custom_method")
1889 .expect("template should resolve");
1890 assert_eq!(skill.description(), "Project-layer skill body.");
1891 }
1892
1893 fn skill_with_applies_when(applies_when_yaml: &str) -> Skill {
1896 let body = format!(
1897 "---\nname: gated\ndescription: A gated skill.\n\
1898 applies_when:\n{applies_when_yaml}\n---\n\nBody.\n"
1899 );
1900 let (frontmatter, body) = parse_skill(&body, &PathBuf::from("gated.md")).unwrap();
1901 Skill {
1902 frontmatter,
1903 body,
1904 provenance: SkillProvenance::Bundled,
1905 }
1906 }
1907
1908 #[test]
1909 fn applies_when_parses_map_shape() {
1910 let skill = skill_with_applies_when(
1911 " graph_has_node_type: [Function, Class]\n\
1912 \x20 tool_registered: cypher_query\n\
1913 \x20 extension_enabled: csv_http_server\n\
1914 \x20 graph_has_property:\n\
1915 \x20 node_type: Function\n\
1916 \x20 prop_name: module",
1917 );
1918 let applies = skill.frontmatter.applies_when.unwrap();
1919 assert_eq!(
1920 applies.graph_has_node_type.as_deref(),
1921 Some(["Function".to_string(), "Class".to_string()].as_slice())
1922 );
1923 assert_eq!(applies.tool_registered.as_deref(), Some("cypher_query"));
1924 assert_eq!(
1925 applies.extension_enabled.as_deref(),
1926 Some("csv_http_server")
1927 );
1928 assert_eq!(
1929 applies.graph_has_property,
1930 Some(GraphPropertyCheck {
1931 node_type: "Function".to_string(),
1932 prop_name: "module".to_string(),
1933 })
1934 );
1935 }
1936
1937 #[test]
1938 fn applies_when_absent_means_always_active() {
1939 let body = "---\nname: ungated\ndescription: An ungated skill.\n---\n\nBody.\n";
1940 let (frontmatter, body) = parse_skill(body, &PathBuf::from("ungated.md")).unwrap();
1941 let skill = Skill {
1942 frontmatter,
1943 body,
1944 provenance: SkillProvenance::Bundled,
1945 };
1946 let registry = ResolvedRegistry::default();
1947 let activation = registry.activation_for(
1948 &skill,
1949 &std::collections::HashSet::new(),
1950 &serde_json::Map::new(),
1951 );
1952 assert!(activation.active);
1953 assert!(activation.clauses.is_empty());
1954 }
1955
1956 #[test]
1957 fn tool_registered_predicate_dispatches_in_framework() {
1958 let skill = skill_with_applies_when(" tool_registered: cypher_query");
1959 let registry = ResolvedRegistry::default();
1960 let mut tools = std::collections::HashSet::new();
1961
1962 let inactive = registry.activation_for(&skill, &tools, &serde_json::Map::new());
1964 assert!(!inactive.active);
1965 assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
1966
1967 tools.insert("cypher_query".to_string());
1969 let active = registry.activation_for(&skill, &tools, &serde_json::Map::new());
1970 assert!(active.active);
1971 assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
1972 }
1973
1974 #[test]
1975 fn extension_enabled_predicate_dispatches_in_framework() {
1976 let skill = skill_with_applies_when(" extension_enabled: csv_http_server");
1977 let registry = ResolvedRegistry::default();
1978 let tools = std::collections::HashSet::new();
1979 let mut extensions = serde_json::Map::new();
1980
1981 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1983
1984 extensions.insert("csv_http_server".to_string(), serde_json::json!(false));
1986 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1987
1988 extensions.insert("csv_http_server".to_string(), serde_json::Value::Null);
1990 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
1991
1992 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1994 assert!(registry.activation_for(&skill, &tools, &extensions).active);
1995
1996 extensions.insert(
1998 "csv_http_server".to_string(),
1999 serde_json::json!({"enabled": true}),
2000 );
2001 assert!(registry.activation_for(&skill, &tools, &extensions).active);
2002 }
2003
2004 struct StubEvaluator {
2005 has_function: bool,
2006 }
2007 impl SkillPredicateEvaluator for StubEvaluator {
2008 fn evaluate(&self, clause: &PredicateClause<'_>) -> Option<bool> {
2009 match clause {
2010 PredicateClause::GraphHasNodeType(types) => {
2011 Some(types.iter().any(|t| t == "Function") && self.has_function)
2012 }
2013 _ => None,
2014 }
2015 }
2016 }
2017
2018 #[test]
2019 fn graph_predicate_dispatches_via_evaluator() {
2020 let skill = skill_with_applies_when(" graph_has_node_type: [Function, Class]");
2021
2022 let registry = Registry::new()
2024 .with_predicate_evaluator(StubEvaluator { has_function: true })
2025 .finalise()
2026 .unwrap();
2027 let active = registry.activation_for(
2028 &skill,
2029 &std::collections::HashSet::new(),
2030 &serde_json::Map::new(),
2031 );
2032 assert!(active.active);
2033 assert_eq!(active.clauses[0].1, PredicateOutcome::Satisfied);
2034
2035 let registry = Registry::new()
2037 .with_predicate_evaluator(StubEvaluator {
2038 has_function: false,
2039 })
2040 .finalise()
2041 .unwrap();
2042 let inactive = registry.activation_for(
2043 &skill,
2044 &std::collections::HashSet::new(),
2045 &serde_json::Map::new(),
2046 );
2047 assert!(!inactive.active);
2048 assert_eq!(inactive.clauses[0].1, PredicateOutcome::Unsatisfied);
2049 }
2050
2051 #[test]
2052 fn graph_predicate_unknown_without_evaluator_means_inactive() {
2053 let skill = skill_with_applies_when(" graph_has_node_type: [Function]");
2054 let registry = ResolvedRegistry::default();
2055 let activation = registry.activation_for(
2056 &skill,
2057 &std::collections::HashSet::new(),
2058 &serde_json::Map::new(),
2059 );
2060 assert!(!activation.active);
2061 assert_eq!(activation.clauses[0].1, PredicateOutcome::Unknown);
2062 }
2063
2064 #[test]
2065 fn multiple_predicates_all_must_be_satisfied() {
2066 let skill = skill_with_applies_when(
2067 " graph_has_node_type: [Function]\n\
2068 \x20 tool_registered: cypher_query",
2069 );
2070 let registry = Registry::new()
2071 .with_predicate_evaluator(StubEvaluator { has_function: true })
2072 .finalise()
2073 .unwrap();
2074 let mut tools = std::collections::HashSet::new();
2075 let extensions = serde_json::Map::new();
2076
2077 assert!(!registry.activation_for(&skill, &tools, &extensions).active);
2079
2080 tools.insert("cypher_query".to_string());
2082 assert!(registry.activation_for(&skill, &tools, &extensions).active);
2083 }
2084}