1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, anyhow, bail};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug)]
12pub struct Skill {
13 pub path: PathBuf,
14 pub name: String,
15 pub description: String,
16}
17
18#[derive(Debug, Deserialize)]
32#[allow(dead_code)]
33struct SkillFrontmatter {
34 name: String,
35 description: String,
36 #[serde(default)]
37 license: Option<String>,
38 #[serde(default)]
39 compatibility: Option<String>,
40 #[serde(default)]
41 metadata: Option<serde_yaml::Mapping>,
42 #[serde(default, rename = "allowed-tools")]
43 allowed_tools: Option<String>,
44}
45
46pub fn read_skill(path: &Path) -> Result<Skill> {
47 if !path.is_dir() {
48 bail!("skill path is not a directory: {}", path.display());
49 }
50
51 let skill_file = path.join("SKILL.md");
52 let contents = fs::read_to_string(&skill_file)
53 .with_context(|| format!("failed to read {}", skill_file.display()))?;
54 let frontmatter = parse_frontmatter(&contents)
55 .with_context(|| format!("failed to parse {}", skill_file.display()))?;
56
57 Ok(Skill {
58 path: path.to_path_buf(),
59 name: frontmatter.name,
60 description: frontmatter.description,
61 })
62}
63
64fn parse_frontmatter(contents: &str) -> Result<SkillFrontmatter> {
65 let mut lines = contents.lines();
66 if lines.next() != Some("---") {
67 bail!("SKILL.md must start with YAML frontmatter delimited by ---");
68 }
69
70 let mut yaml = String::new();
71 for line in lines {
72 if line == "---" {
73 let frontmatter = serde_yaml::from_str(&yaml)?;
74 return Ok(frontmatter);
75 }
76 yaml.push_str(line);
77 yaml.push('\n');
78 }
79
80 bail!("SKILL.md frontmatter is missing closing ---");
81}
82
83pub fn validate_skill_metadata(skill: &Skill) -> Result<()> {
94 validate_skill_name(&skill.name)?;
95
96 if skill.description.trim().is_empty() {
97 bail!("description must not be empty");
98 }
99
100 Ok(())
108}
109
110pub fn validate_skill(skill: &Skill) -> Result<()> {
117 validate_skill_metadata(skill)?;
118
119 let dirname = skill
120 .path
121 .file_name()
122 .and_then(|name| name.to_str())
123 .ok_or_else(|| anyhow!("skill path has no valid directory name"))?;
124 if dirname != skill.name {
125 bail!(
126 "skill name must match directory name: frontmatter has {:?}, directory is {:?}",
127 skill.name,
128 dirname
129 );
130 }
131
132 Ok(())
133}
134
135pub fn validate_skill_name(name: &str) -> Result<()> {
136 let len = name.chars().count();
137 if len == 0 || len > 64 {
138 bail!("skill name must be 1-64 characters");
139 }
140
141 if name.starts_with('-') || name.ends_with('-') {
142 bail!("skill name must not start or end with a hyphen");
143 }
144
145 if name.contains("--") {
146 bail!("skill name must not contain consecutive hyphens");
147 }
148
149 if !name
150 .chars()
151 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
152 {
153 bail!("skill name may only contain lowercase letters, numbers, and hyphens");
154 }
155
156 Ok(())
157}
158
159pub fn checksum_dir(path: &Path) -> Result<String> {
160 let mut hasher = Sha256::new();
161 for file in collect_files(path)? {
162 let relative_path = file.strip_prefix(path).with_context(|| {
163 format!(
164 "failed to make {} relative to {}",
165 file.display(),
166 path.display()
167 )
168 })?;
169 hasher.update(relative_path.to_string_lossy().as_bytes());
170 hasher.update([0]);
171 hasher
172 .update(fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?);
173 hasher.update([0]);
174 }
175 Ok(format!("sha256:{:x}", hasher.finalize()))
176}
177
178pub fn collect_files(path: &Path) -> Result<Vec<PathBuf>> {
179 let mut files = Vec::new();
180 collect_files_inner(path, &mut files)?;
181 files.sort();
182 Ok(files)
183}
184
185fn collect_files_inner(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
186 for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
187 let entry = entry?;
188 let path = entry.path();
189 let file_type = entry.file_type()?;
190
191 if file_type.is_dir() {
192 collect_files_inner(&path, files)?;
193 } else if file_type.is_file() {
194 files.push(path);
195 }
196 }
197
198 Ok(())
199}
200
201#[derive(Debug, Clone, Deserialize, Serialize)]
202pub struct Manifest {
203 pub install: InstallConfig,
204 #[serde(default)]
205 pub skills: BTreeMap<String, String>,
206 #[serde(default)]
207 pub registries: BTreeMap<String, RegistryConfig>,
208}
209
210impl Manifest {
211 pub fn new(target: PathBuf) -> Self {
212 Self {
213 install: InstallConfig {
214 target,
215 default_registry: None,
216 },
217 skills: BTreeMap::new(),
218 registries: BTreeMap::new(),
219 }
220 }
221}
222
223#[derive(Debug, Clone, Deserialize, Serialize)]
224pub struct InstallConfig {
225 pub target: PathBuf,
226
227 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub default_registry: Option<String>,
243}
244
245pub const LOCKFILE_VERSION: u32 = 2;
264
265fn default_lockfile_version() -> u32 {
266 1
271}
272
273#[derive(Debug, Deserialize, Serialize)]
274pub struct Lockfile {
275 #[serde(default = "default_lockfile_version")]
280 pub version: u32,
281 #[serde(default)]
282 pub skill: Vec<LockedSkill>,
283}
284
285impl Default for Lockfile {
286 fn default() -> Self {
287 Self {
288 version: LOCKFILE_VERSION,
289 skill: Vec::new(),
290 }
291 }
292}
293
294impl Lockfile {
295 pub fn ensure_supported_version(&self) -> Result<(), String> {
300 if self.version > LOCKFILE_VERSION {
301 return Err(format!(
302 "lockfile version {} is newer than this knack supports (max {LOCKFILE_VERSION}); upgrade knack",
303 self.version
304 ));
305 }
306 Ok(())
307 }
308}
309
310#[derive(Debug, Clone, Deserialize, Serialize)]
311pub struct LockedSkill {
312 pub name: String,
313
314 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub namespace: Option<String>,
321
322 pub source: String,
323 pub resolved: String,
324 pub checksum: String,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
328pub struct RegistryConfig {
329 pub kind: RegistryKind,
330 pub url: String,
331 pub default_ref: String,
332}
333
334#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
335#[serde(rename_all = "kebab-case")]
336pub enum RegistryKind {
337 GitHost,
338 Http,
339}
340
341#[derive(Debug, Default, Clone, Deserialize, Serialize)]
342pub struct RegistryIndex {
343 #[serde(default)]
344 pub skill: Vec<IndexedSkill>,
345 #[serde(default)]
346 pub source: Vec<IndexSource>,
347}
348
349#[derive(Debug, Clone, Deserialize, Serialize)]
350pub struct IndexedSkill {
351 pub name: String,
352
353 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub namespace: Option<String>,
363
364 pub description: String,
365
366 pub source: String,
371
372 #[serde(default)]
373 pub tags: Vec<String>,
374
375 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub score: Option<f64>,
384}
385
386#[derive(Debug, Clone, Deserialize, Serialize)]
387pub struct IndexSource {
388 pub source: String,
389
390 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub namespace: Option<String>,
400
401 #[serde(default)]
402 pub tags: Vec<String>,
403}
404
405impl RegistryIndex {
406 pub fn validate(&self) -> Result<()> {
407 for skill in &self.skill {
408 skill.validate()?;
409 }
410 for source in &self.source {
411 source.validate()?;
412 }
413 Ok(())
414 }
415
416 pub fn search(&self, query: &str) -> Vec<(&IndexedSkill, f64)> {
432 let terms: Vec<String> = query
433 .split_whitespace()
434 .map(|term| term.to_ascii_lowercase())
435 .collect();
436 if terms.is_empty() {
437 return Vec::new();
438 }
439
440 let total_skills = self.skill.len();
441 let idf_weights: Vec<f64> = terms
448 .iter()
449 .map(|term| {
450 let doc_freq = self
451 .skill
452 .iter()
453 .filter(|skill| skill.matches_term(term))
454 .count();
455 inverse_document_frequency(total_skills, doc_freq)
456 })
457 .collect();
458
459 let mut scored: Vec<(&IndexedSkill, f64)> = self
460 .skill
461 .iter()
462 .filter_map(|skill| {
463 skill
464 .match_score(&terms, &idf_weights)
465 .map(|score| (skill, score))
466 })
467 .collect();
468
469 scored.sort_by(|(a, a_score), (b, b_score)| {
470 b_score
471 .partial_cmp(a_score)
472 .unwrap_or(std::cmp::Ordering::Equal)
473 .then_with(|| a.qualified_name().cmp(&b.qualified_name()))
474 });
475 scored
476 }
477}
478
479fn inverse_document_frequency(total_skills: usize, doc_freq: usize) -> f64 {
491 if total_skills == 0 || doc_freq == 0 {
492 return 1.0;
493 }
494 let n = total_skills as f64;
495 let df = doc_freq as f64;
496 (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
497}
498
499impl IndexSource {
500 pub fn validate(&self) -> Result<()> {
501 if self.source.trim().is_empty() {
502 bail!("indexed source must not be empty");
503 }
504 Ok(())
505 }
506}
507
508impl IndexedSkill {
509 pub fn validate(&self) -> Result<()> {
510 validate_skill_name(&self.name)?;
511 if let Some(ns) = &self.namespace {
512 validate_skill_name(ns).map_err(|err| anyhow!("invalid namespace: {err}"))?;
516 }
517 if self.description.trim().is_empty() {
518 bail!("indexed skill description must not be empty: {}", self.name);
519 }
520 if self.source.trim().is_empty() {
521 bail!("indexed skill source must not be empty: {}", self.name);
522 }
523 Ok(())
524 }
525
526 pub fn qualified_name(&self) -> String {
531 match &self.namespace {
532 Some(ns) => format!("{ns}/{}", self.name),
533 None => self.name.clone(),
534 }
535 }
536
537 fn field_weight(term: &str, field: &str, is_name_or_tag: bool) -> f64 {
545 if field.is_empty() {
546 return 0.0;
547 }
548 if is_name_or_tag {
549 if field == term {
550 return 4.0;
551 }
552 if field.starts_with(term) {
553 return 3.0;
554 }
555 if word_boundary_match(field, term) {
556 return 2.0;
557 }
558 }
559 if field.contains(term) {
560 if is_name_or_tag { 1.0 } else { 0.5 }
561 } else {
562 0.0
563 }
564 }
565
566 fn best_field_weight(&self, term: &str) -> f64 {
572 let name = self.name.to_ascii_lowercase();
573 let namespace = self
574 .namespace
575 .as_deref()
576 .map(|ns| ns.to_ascii_lowercase())
577 .unwrap_or_default();
578 let description = self.description.to_ascii_lowercase();
579
580 let mut best = Self::field_weight(term, &name, true);
581 best = best.max(Self::field_weight(term, &namespace, true));
582 for tag in &self.tags {
583 best = best.max(Self::field_weight(term, &tag.to_ascii_lowercase(), true));
584 }
585 best.max(Self::field_weight(term, &description, false))
586 }
587
588 fn matches_term(&self, term: &str) -> bool {
593 self.best_field_weight(term) > 0.0
594 }
595
596 fn match_score(&self, terms: &[String], idf_weights: &[f64]) -> Option<f64> {
604 let mut total = 0.0;
605 for (term, idf) in terms.iter().zip(idf_weights) {
606 let best = self.best_field_weight(term);
607 if best <= 0.0 {
608 return None;
609 }
610 total += best * idf;
611 }
612 Some(total)
613 }
614}
615
616fn word_boundary_match(field: &str, term: &str) -> bool {
621 let mut start = 0;
622 while let Some(idx) = field[start..].find(term) {
623 let match_start = start + idx;
624 let match_end = match_start + term.len();
625 let before_ok = match_start == 0
626 || !field[..match_start]
627 .chars()
628 .next_back()
629 .is_some_and(|c| c.is_alphanumeric());
630 let after_ok = match_end == field.len()
631 || !field[match_end..]
632 .chars()
633 .next()
634 .is_some_and(|c| c.is_alphanumeric());
635 if before_ok && after_ok {
636 return true;
637 }
638 start = match_start + 1;
639 if start >= field.len() {
640 break;
641 }
642 }
643 false
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn validates_skill_names() {
652 assert!(validate_skill_name("rust-code-review").is_ok());
653 assert!(validate_skill_name("Rust-Code-Review").is_err());
654 assert!(validate_skill_name("-rust").is_err());
655 assert!(validate_skill_name("rust-").is_err());
656 assert!(validate_skill_name("rust--review").is_err());
657 }
658
659 #[test]
660 fn parses_frontmatter() {
661 let frontmatter =
662 parse_frontmatter("---\nname: demo-skill\ndescription: Use for demos.\n---\n\nBody\n")
663 .expect("frontmatter should parse");
664
665 assert_eq!(frontmatter.name, "demo-skill");
666 assert_eq!(frontmatter.description, "Use for demos.");
667 }
668
669 #[test]
670 fn rejects_missing_frontmatter() {
671 assert!(parse_frontmatter("# demo\n").is_err());
672 }
673
674 #[test]
675 fn tolerates_unknown_frontmatter_fields() {
676 let frontmatter = parse_frontmatter(
680 "---\n\
681 name: agent-browser\n\
682 description: Browser automation.\n\
683 allowed-tools: Bash(agent-browser:*)\n\
684 hidden: true\n\
685 custom-field: arbitrary\n\
686 ---\n",
687 )
688 .expect("foreign fields must be ignored, not rejected");
689 assert_eq!(frontmatter.name, "agent-browser");
690 assert_eq!(frontmatter.description, "Browser automation.");
691 assert_eq!(
692 frontmatter.allowed_tools.as_deref(),
693 Some("Bash(agent-browser:*)")
694 );
695 }
696
697 #[test]
698 fn still_requires_name_and_description() {
699 assert!(parse_frontmatter("---\nname: x\ndesciption: oops\n---\n").is_err());
702 }
703
704 #[test]
705 fn validate_skill_metadata_ignores_directory_mismatch() {
706 let skill = Skill {
713 path: PathBuf::from("/tmp/composition-patterns"),
714 name: "vercel-composition-patterns".to_string(),
715 description: "React composition patterns.".to_string(),
716 };
717 assert!(validate_skill_metadata(&skill).is_ok());
718 assert!(validate_skill(&skill).is_err());
719 }
720
721 #[test]
722 fn accepts_long_descriptions() {
723 let long = "Use when the user is doing things. ".repeat(200);
728 assert!(long.len() > 1024);
729 let skill = Skill {
730 path: PathBuf::from("/tmp/example"),
731 name: "example".to_string(),
732 description: long,
733 };
734 assert!(validate_skill(&skill).is_ok());
735
736 let blank = Skill {
737 path: PathBuf::from("/tmp/example"),
738 name: "example".to_string(),
739 description: " ".to_string(),
740 };
741 assert!(validate_skill(&blank).is_err());
742 }
743
744 #[test]
745 fn searches_registry_index() {
746 let index = RegistryIndex {
747 skill: vec![
748 IndexedSkill {
749 name: "pdf".to_string(),
750 namespace: Some("anthropics".to_string()),
751 description: "Work with PDF documents".to_string(),
752 source: "anthropics/pdf".to_string(),
753 tags: vec!["documents".to_string(), "ocr".to_string()],
754 score: None,
755 },
756 IndexedSkill {
757 name: "rust-code-review".to_string(),
758 namespace: None,
759 description: "Review Rust code".to_string(),
760 source: "rust-code-review".to_string(),
761 tags: vec!["rust".to_string()],
762 score: None,
763 },
764 ],
765 source: Vec::new(),
766 };
767
768 assert_eq!(index.search("pdf").len(), 1);
769 assert_eq!(index.search("documents ocr").len(), 1);
770 assert_eq!(index.search("python").len(), 0);
771 assert_eq!(index.search("anthropics").len(), 1);
774 }
775
776 #[test]
777 fn ranks_name_matches_above_description_only_matches() {
778 let index = RegistryIndex {
784 skill: vec![
785 IndexedSkill {
786 name: "changelog-writer".to_string(),
787 namespace: None,
788 description: "Summarize commits, including ones touching Rust code."
789 .to_string(),
790 source: "changelog-writer".to_string(),
791 tags: vec![],
792 score: None,
793 },
794 IndexedSkill {
795 name: "rust-code-review".to_string(),
796 namespace: None,
797 description: "Review code for correctness".to_string(),
798 source: "rust-code-review".to_string(),
799 tags: vec!["rust".to_string()],
800 score: None,
801 },
802 ],
803 source: Vec::new(),
804 };
805
806 let results = index.search("rust");
807 assert_eq!(results.len(), 2);
808 assert_eq!(results[0].0.name, "rust-code-review");
809 assert!(results[0].1 > results[1].1);
810 }
811
812 #[test]
813 fn discounts_common_terms_against_rare_terms_via_idf() {
814 let mut skill = vec![
826 IndexedSkill {
827 name: "ci-tools".to_string(),
828 namespace: None,
829 description: "Helps deploy your pipeline safely.".to_string(),
830 source: "ci-tools".to_string(),
831 tags: vec!["ci".to_string()],
832 score: None,
833 },
834 IndexedSkill {
835 name: "deploy".to_string(),
836 namespace: None,
837 description: "Also handles some ci related tasks.".to_string(),
838 source: "deploy".to_string(),
839 tags: vec!["deploy".to_string()],
840 score: None,
841 },
842 ];
843 for i in 0..15 {
847 skill.push(IndexedSkill {
848 name: format!("filler-{i}"),
849 namespace: None,
850 description: "Handles deployment automation for unrelated workflows.".to_string(),
851 source: format!("filler-{i}"),
852 tags: vec![],
853 score: None,
854 });
855 }
856 let index = RegistryIndex {
857 skill,
858 source: Vec::new(),
859 };
860
861 let results = index.search("ci deploy");
862 assert_eq!(results.len(), 2);
863 assert_eq!(
864 results[0].0.name, "ci-tools",
865 "strong match on the rarer, more discriminating term should outrank \
866 a strong match on the term that's common across the index"
867 );
868 assert!(results[0].1 > results[1].1);
869 }
870
871 #[test]
872 fn requires_every_term_to_match_somewhere() {
873 let index = RegistryIndex {
876 skill: vec![IndexedSkill {
877 name: "pdf".to_string(),
878 namespace: Some("anthropics".to_string()),
879 description: "Work with PDF documents".to_string(),
880 source: "anthropics/pdf".to_string(),
881 tags: vec!["documents".to_string()],
882 score: None,
883 }],
884 source: Vec::new(),
885 };
886
887 assert_eq!(index.search("pdf python").len(), 0);
888 assert_eq!(index.search("pdf documents").len(), 1);
889 }
890
891 #[test]
892 fn ties_break_alphabetically_by_qualified_name() {
893 let index = RegistryIndex {
894 skill: vec![
895 IndexedSkill {
896 name: "zeta".to_string(),
897 namespace: None,
898 description: "docs helper".to_string(),
899 source: "zeta".to_string(),
900 tags: vec![],
901 score: None,
902 },
903 IndexedSkill {
904 name: "alpha".to_string(),
905 namespace: None,
906 description: "docs helper".to_string(),
907 source: "alpha".to_string(),
908 tags: vec![],
909 score: None,
910 },
911 ],
912 source: Vec::new(),
913 };
914
915 let results = index.search("docs");
916 assert_eq!(results[0].0.name, "alpha");
917 assert_eq!(results[1].0.name, "zeta");
918 }
919
920 #[test]
921 fn qualified_name_round_trips() {
922 let scoped = IndexedSkill {
923 name: "pdf".to_string(),
924 namespace: Some("anthropics".to_string()),
925 description: "x".to_string(),
926 source: "anthropics/pdf".to_string(),
927 tags: vec![],
928 score: None,
929 };
930 assert_eq!(scoped.qualified_name(), "anthropics/pdf");
931
932 let unscoped = IndexedSkill {
933 name: "legacy".to_string(),
934 namespace: None,
935 description: "x".to_string(),
936 source: "legacy".to_string(),
937 tags: vec![],
938 score: None,
939 };
940 assert_eq!(unscoped.qualified_name(), "legacy");
941 }
942
943 #[test]
944 fn validates_namespace_charset() {
945 let mut skill = IndexedSkill {
947 name: "ok".to_string(),
948 namespace: Some("good-ns".to_string()),
949 description: "x".to_string(),
950 source: "good-ns/ok".to_string(),
951 tags: vec![],
952 score: None,
953 };
954 assert!(skill.validate().is_ok());
955
956 skill.namespace = Some("Bad_Namespace".to_string());
957 let err = skill.validate().unwrap_err().to_string();
958 assert!(err.contains("invalid namespace"), "got: {err}");
959 }
960
961 #[test]
962 fn parses_v1_lockfile_without_version_or_namespace() {
963 let toml_v1 = r#"
967[[skill]]
968name = "pdf"
969source = "public:pdf"
970resolved = "http+knack:https://example.com/skills/pdf/archive#sha=abc123"
971checksum = "sha256:deadbeef"
972"#;
973 let lockfile: Lockfile = toml::from_str(toml_v1).expect("v1 lockfile must parse");
974 assert_eq!(lockfile.version, 1);
975 assert_eq!(lockfile.skill.len(), 1);
976 assert_eq!(lockfile.skill[0].namespace, None);
977 assert!(lockfile.ensure_supported_version().is_ok());
978 }
979
980 #[test]
981 fn parses_v2_lockfile_with_namespace() {
982 let toml_v2 = r#"
983version = 2
984
985[[skill]]
986name = "pdf"
987namespace = "anthropics"
988source = "public:anthropics/pdf"
989resolved = "http+knack:https://example.com/skills/anthropics/pdf/archive#sha=abc"
990checksum = "sha256:deadbeef"
991"#;
992 let lockfile: Lockfile = toml::from_str(toml_v2).expect("v2 lockfile must parse");
993 assert_eq!(lockfile.version, 2);
994 assert_eq!(lockfile.skill[0].namespace.as_deref(), Some("anthropics"));
995 }
996
997 #[test]
998 fn rejects_lockfile_from_newer_knack() {
999 let future = r#"
1000version = 999
1001[[skill]]
1002name = "pdf"
1003source = "public:pdf"
1004resolved = "x"
1005checksum = "x"
1006"#;
1007 let lockfile: Lockfile = toml::from_str(future).unwrap();
1008 let err = lockfile
1009 .ensure_supported_version()
1010 .expect_err("future lockfile must be rejected");
1011 assert!(err.contains("newer than this knack supports"), "got: {err}");
1012 }
1013
1014 #[test]
1015 fn locked_skill_omits_namespace_when_absent() {
1016 let skill = LockedSkill {
1017 name: "pdf".to_string(),
1018 namespace: None,
1019 source: "public:pdf".to_string(),
1020 resolved: "x".to_string(),
1021 checksum: "y".to_string(),
1022 };
1023 let serialized = toml::to_string(&skill).unwrap();
1024 assert!(
1025 !serialized.contains("namespace"),
1026 "namespace should be omitted from legacy entries, got: {serialized}"
1027 );
1028 }
1029
1030 #[test]
1031 fn parses_legacy_unnamespaced_index_json() {
1032 let json = r#"{
1036 "name": "pdf",
1037 "description": "PDF docs",
1038 "source": "public:pdf",
1039 "tags": ["documents"]
1040 }"#;
1041 let parsed: IndexedSkill =
1042 serde_json::from_str(json).expect("legacy index.json must parse");
1043 assert_eq!(parsed.name, "pdf");
1044 assert_eq!(parsed.namespace, None);
1045 assert_eq!(parsed.qualified_name(), "pdf");
1046 }
1047
1048 #[test]
1049 fn omits_namespace_field_when_absent_on_serialize() {
1050 let skill = IndexedSkill {
1055 name: "legacy".to_string(),
1056 namespace: None,
1057 description: "x".to_string(),
1058 source: "legacy".to_string(),
1059 tags: vec![],
1060 score: None,
1061 };
1062 let json = serde_json::to_string(&skill).unwrap();
1063 assert!(
1064 !json.contains("namespace"),
1065 "namespace should be omitted, got: {json}"
1066 );
1067 }
1068}