1use std::fs;
14use std::io::{self, Cursor, ErrorKind};
15use std::path::{Component, Path, PathBuf};
16
17use bytes::Bytes;
18use murmur_artifact::payload_shape::SKILL_MD_ENTRY;
19use murmur_artifact::{
20 check_skill_md, classify_skill_folder_root_file, skill_folder_root_refusal, skill_folder_skips,
21 zip_guard, SkillFolderError, SkillFolderRootFile, SkillVersionRule,
22};
23use serde::Serialize;
24use zip::ZipArchive;
25
26use crate::artifact::SkillFiles;
27use crate::errors::RuntimeError;
28
29pub const BASE_MANIFEST_FOLDER: &str = "manifest-folder";
31pub const BASE_INSTALLED_PACKAGE: &str = "installed-package";
33pub const BASE_HOST_PATH: &str = "host-path";
35
36const NOT_IN_PACKAGE: &str = "not in the installed package; list it under requires_files: in the \
38 capsule's murmur.yaml and rebuild the package";
39const OUTSIDE_PACKAGE: &str = "names a path outside the installed package";
41
42#[derive(Clone)]
44pub struct InstalledCapsule {
45 pub artifact: String,
47 pub version: String,
48 pub package: Bytes,
50}
51
52impl InstalledCapsule {
53 #[must_use]
55 pub fn label(&self) -> String {
56 format!("{}@{}", self.artifact, self.version)
57 }
58}
59
60impl std::fmt::Debug for InstalledCapsule {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.debug_struct("InstalledCapsule")
63 .field("artifact", &self.artifact)
64 .field("version", &self.version)
65 .field("package_bytes", &self.package.len())
66 .finish()
67 }
68}
69
70#[derive(Debug, Clone)]
72pub enum ManifestFiles {
73 Directory(PathBuf),
75 Package(InstalledCapsule),
77}
78
79enum Location {
81 Host(PathBuf),
83 Entry(String),
85 Outside,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct ManifestFileReport {
93 pub key: String,
96 pub declared: String,
98 pub resolved: String,
101 pub base: &'static str,
103}
104
105impl ManifestFiles {
106 fn locate(&self, declared: &str) -> (Location, String, &'static str) {
109 let raw = Path::new(declared);
110 if raw.is_absolute() {
111 return (
112 Location::Host(raw.to_path_buf()),
113 raw.display().to_string(),
114 BASE_HOST_PATH,
115 );
116 }
117 match self {
118 Self::Directory(dir) => {
121 let path = dir.join(raw);
122 let shown = path.display().to_string();
123 (Location::Host(path), shown, BASE_MANIFEST_FOLDER)
124 }
125 Self::Package(capsule) => match normalise_declared(declared) {
126 Some(entry) => {
127 let shown = format!("{}!/{entry}", capsule.label());
128 (Location::Entry(entry), shown, BASE_INSTALLED_PACKAGE)
129 }
130 None => (
131 Location::Outside,
132 format!("{}!/{declared}", capsule.label()),
133 BASE_INSTALLED_PACKAGE,
134 ),
135 },
136 }
137 }
138
139 #[must_use]
141 pub fn resolved(&self, declared: &str) -> String {
142 self.locate(declared).1
143 }
144
145 pub fn read_to_string(&self, declared: &str) -> Result<String, (String, io::Error)> {
149 let (location, shown, _) = self.locate(declared);
150 let result = match (&location, self) {
151 (Location::Host(path), _) => fs::read_to_string(path),
152 (Location::Entry(entry), Self::Package(capsule)) => {
153 read_package_entry(&capsule.package, entry).and_then(|bytes| {
154 String::from_utf8(bytes)
155 .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))
156 })
157 }
158 (Location::Outside, _) | (Location::Entry(_), Self::Directory(_)) => {
159 Err(io::Error::new(ErrorKind::InvalidInput, OUTSIDE_PACKAGE))
160 }
161 };
162 result.map_err(|source| (shown, source))
163 }
164
165 pub fn read_skill_source(&self, source: &str) -> Result<LocalSkill, RuntimeError> {
174 let (location, shown, _) = self.locate(source);
175 match (location, self) {
176 (Location::Host(path), _) => read_host_skill_source(&path),
177 (Location::Entry(prefix), Self::Package(capsule)) => {
178 read_package_skill_source(&capsule.package, &prefix, &shown)
179 }
180 (Location::Outside, _) | (Location::Entry(_), Self::Directory(_)) => {
181 Err(RuntimeError::SkillSourceRead {
182 path: shown,
183 source: io::Error::new(ErrorKind::InvalidInput, OUTSIDE_PACKAGE),
184 })
185 }
186 }
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct LocalSkill {
193 pub files: SkillFiles,
196 pub description: String,
198}
199
200fn checked_local_skill(
202 path: &Path,
203 folder: Option<&str>,
204 files: SkillFiles,
205) -> Result<LocalSkill, RuntimeError> {
206 let front =
207 check_skill_md(path, &files[0].1, folder, SkillVersionRule::NotRead).map_err(refused)?;
208 Ok(LocalSkill {
209 files,
210 description: front.description,
211 })
212}
213
214fn last_two_components(path: &str) -> (&str, Option<&str>) {
216 let mut parts = path.rsplit('/');
217 let last = parts.next().unwrap_or_default();
218 (last, parts.next().filter(|parent| !parent.is_empty()))
219}
220
221fn normalise_declared(declared: &str) -> Option<String> {
224 let mut parts = Vec::new();
225 for component in Path::new(declared).components() {
226 match component {
227 Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()),
228 Component::CurDir => {}
229 Component::ParentDir => return None,
230 Component::RootDir | Component::Prefix(_) => {}
231 }
232 }
233 Some(parts.join("/"))
234}
235
236struct PackageEntry {
239 raw: String,
240 name: String,
241 symlink: bool,
242}
243
244fn open_package(package: &[u8]) -> io::Result<ZipArchive<Cursor<&[u8]>>> {
245 ZipArchive::new(Cursor::new(package)).map_err(|error| {
246 io::Error::new(
247 ErrorKind::InvalidData,
248 format!("cannot open the installed package: {error}"),
249 )
250 })
251}
252
253fn package_entries(archive: &mut ZipArchive<Cursor<&[u8]>>) -> io::Result<Vec<PackageEntry>> {
255 let mut entries = Vec::new();
256 for index in 0..archive.len() {
257 let entry = archive
258 .by_index_raw(index)
259 .map_err(|error| io::Error::new(ErrorKind::InvalidData, error.to_string()))?;
260 if entry.is_dir() {
261 continue;
262 }
263 let raw = entry
264 .name()
265 .map_err(|error| io::Error::new(ErrorKind::InvalidData, error.to_string()))?
266 .into_owned();
267 let Ok(name) = zip_guard::normalized_entry_name(&raw) else {
270 continue;
271 };
272 if name.is_empty() {
273 continue;
274 }
275 let symlink = entry.is_symlink();
276 entries.push(PackageEntry { raw, name, symlink });
277 }
278 Ok(entries)
279}
280
281fn read_capped(
282 archive: &mut ZipArchive<Cursor<&[u8]>>,
283 raw_name: &str,
284 max_bytes: u64,
285) -> io::Result<Vec<u8>> {
286 zip_guard::read_zip_entry_capped(archive, raw_name, max_bytes)
287 .map_err(|error| io::Error::new(ErrorKind::InvalidData, error.to_string()))
288}
289
290fn read_package_entry(package: &[u8], entry: &str) -> io::Result<Vec<u8>> {
293 let not_found = || io::Error::new(ErrorKind::NotFound, NOT_IN_PACKAGE);
294 if entry.is_empty() {
295 return Err(not_found());
296 }
297 let mut archive = open_package(package)?;
298 let raw = package_entries(&mut archive)?
299 .into_iter()
300 .find(|candidate| candidate.name == entry)
301 .ok_or_else(not_found)?
302 .raw;
303 read_capped(
304 &mut archive,
305 &raw,
306 zip_guard::max_artifact_decompressed_bytes(),
307 )
308}
309
310fn read_package_skill_source(
313 package: &[u8],
314 prefix: &str,
315 shown: &str,
316) -> Result<LocalSkill, RuntimeError> {
317 let unreadable = |source| RuntimeError::SkillSourceRead {
318 path: shown.to_string(),
319 source,
320 };
321 let not_found = || RuntimeError::SkillSourceNotFound {
322 path: shown.to_string(),
323 };
324 if prefix.is_empty() {
325 return Err(not_found());
326 }
327 let mut archive = open_package(package).map_err(unreadable)?;
328 let entries = package_entries(&mut archive).map_err(unreadable)?;
329 let max_bytes = zip_guard::max_artifact_decompressed_bytes();
330
331 if let Some(file) = entries.iter().find(|entry| entry.name == prefix) {
332 let (file_name, folder) = last_two_components(prefix);
333 if file_name != SKILL_MD_ENTRY {
334 return Err(refused(SkillFolderError::MisspelledSkillMd {
335 path: PathBuf::from(shown),
336 }));
337 }
338 let bytes = read_capped(&mut archive, &file.raw, max_bytes).map_err(unreadable)?;
339 return checked_local_skill(
340 Path::new(shown),
341 folder,
342 vec![(SKILL_MD_ENTRY.to_string(), bytes)],
343 );
344 }
345
346 let folder = PathBuf::from(shown);
347 let under = format!("{prefix}/");
348 let mut found_any = false;
349 let mut skill_md: Option<&PackageEntry> = None;
350 let mut supporting: Vec<(String, &PackageEntry)> = Vec::new();
351 for entry in &entries {
352 let Some(relative) = entry.name.strip_prefix(&under) else {
353 continue;
354 };
355 found_any = true;
356 if relative.split('/').any(skill_folder_skips) {
357 continue;
358 }
359 if entry.symlink {
360 return Err(refused(SkillFolderError::Symlink {
361 path: folder.join(relative),
362 }));
363 }
364 if relative.contains('/') {
365 supporting.push((relative.to_string(), entry));
366 continue;
367 }
368 let class = classify_skill_folder_root_file(relative);
369 if let Some(refusal) = skill_folder_root_refusal(class, folder.join(relative)) {
370 return Err(refused(refusal));
371 }
372 match class {
373 SkillFolderRootFile::SkillMd => skill_md = Some(entry),
374 _ => supporting.push((relative.to_string(), entry)),
375 }
376 }
377 if !found_any {
378 return Err(not_found());
379 }
380 let Some(skill_md) = skill_md else {
381 return Err(RuntimeError::SkillSourceMissingSkillMd {
382 path: shown.to_string(),
383 });
384 };
385 supporting.sort_by(|a, b| a.0.cmp(&b.0));
386
387 let mut total = 0u64;
388 let mut files = Vec::with_capacity(supporting.len() + 1);
389 let skill_md_raw = skill_md.raw.clone();
390 for (relative, raw) in std::iter::once((SKILL_MD_ENTRY.to_string(), skill_md_raw)).chain(
391 supporting
392 .into_iter()
393 .map(|(relative, entry)| (relative, entry.raw.clone())),
394 ) {
395 let bytes =
396 read_capped(&mut archive, &raw, max_bytes.saturating_sub(total)).map_err(|source| {
397 RuntimeError::SkillSourceRead {
398 path: format!("{shown}/{relative}"),
399 source,
400 }
401 })?;
402 total += bytes.len() as u64;
403 files.push((relative, bytes));
404 }
405 let (folder_name, _) = last_two_components(prefix);
406 checked_local_skill(&folder.join(SKILL_MD_ENTRY), Some(folder_name), files)
407}
408
409fn refused(error: SkillFolderError) -> RuntimeError {
413 match error {
414 SkillFolderError::MissingSkillMd { dir } => RuntimeError::SkillSourceMissingSkillMd {
415 path: dir.display().to_string(),
416 },
417 SkillFolderError::Io { path, source } => RuntimeError::SkillSourceRead {
418 path: path.display().to_string(),
419 source,
420 },
421 error => {
422 let message = error.to_string();
423 let hint = error.hint().unwrap_or_default();
424 if error.breaks_standard() {
425 RuntimeError::SkillSourceNotStandard { message, hint }
426 } else {
427 RuntimeError::SkillSourceRefused { message, hint }
428 }
429 }
430 }
431}
432
433fn read_host_skill_source(path: &Path) -> Result<LocalSkill, RuntimeError> {
437 if !path.exists() {
438 return Err(RuntimeError::SkillSourceNotFound {
439 path: path.display().to_string(),
440 });
441 }
442
443 let read = |file: &Path| {
444 fs::read(file).map_err(|source| RuntimeError::SkillSourceRead {
445 path: file.display().to_string(),
446 source,
447 })
448 };
449 if !path.is_dir() {
450 if path.file_name().is_none_or(|name| name != SKILL_MD_ENTRY) {
451 return Err(refused(SkillFolderError::MisspelledSkillMd {
452 path: path.to_path_buf(),
453 }));
454 }
455 let parent = path
456 .parent()
457 .filter(|parent| !parent.as_os_str().is_empty())
458 .unwrap_or(Path::new("."));
459 let canonical =
460 fs::canonicalize(parent).map_err(|source| RuntimeError::SkillSourceRead {
461 path: parent.display().to_string(),
462 source,
463 })?;
464 let folder = canonical
465 .file_name()
466 .map(|name| name.to_string_lossy().into_owned());
467 let files = vec![(SKILL_MD_ENTRY.to_string(), read(path)?)];
468 return checked_local_skill(path, folder.as_deref(), files);
469 }
470
471 let folder = murmur_artifact::collect_skill_folder(path).map_err(refused)?;
472 let mut files = vec![(SKILL_MD_ENTRY.to_string(), read(&folder.skill_md)?)];
473 for (relative, file) in &folder.supporting_files {
474 files.push((relative.clone(), read(file)?));
475 }
476 checked_local_skill(&folder.skill_md, folder.folder_name.as_deref(), files)
477}
478
479#[must_use]
486pub fn manifest_file_reports<'a>(
487 files: &ManifestFiles,
488 inference: Option<&murmur_artifact::InferenceConfig>,
489 sources: impl IntoIterator<Item = (&'a str, &'a str)>,
490) -> Vec<ManifestFileReport> {
491 let row = |key: String, declared: &str| {
492 let (_, resolved, base) = files.locate(declared);
493 ManifestFileReport {
494 key,
495 declared: declared.to_string(),
496 resolved,
497 base,
498 }
499 };
500 let mut rows = Vec::new();
501 if let Some(inference) = inference {
502 if let Some(path) = &inference.system_prompt_file {
503 rows.push(row("inference.system_prompt_file".to_string(), path));
504 }
505 if let Some(path) = inference
506 .compaction
507 .as_ref()
508 .and_then(|compaction| compaction.system_prompt_file.as_ref())
509 {
510 rows.push(row(
511 "inference.compaction.system_prompt_file".to_string(),
512 path,
513 ));
514 }
515 }
516 for (name, source) in sources {
517 rows.push(row(format!("artifacts[{name}].source"), source));
518 }
519 rows
520}
521
522#[must_use]
525pub fn render_manifest_files(rows: &[ManifestFileReport]) -> String {
526 if rows.is_empty() {
527 return String::new();
528 }
529 let mut out = String::from("\nManifest files\n");
530 for row in rows {
531 out.push_str(&format!(
532 " {}: {} -> {}\n",
533 row.key, row.declared, row.resolved
534 ));
535 }
536 out
537}
538
539#[cfg(test)]
540mod tests {
541 use std::io::Write;
542
543 use zip::write::SimpleFileOptions;
544 use zip::ZipWriter;
545
546 use super::*;
547
548 fn package(entries: &[(&str, &[u8])]) -> InstalledCapsule {
549 let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
550 for (name, body) in entries {
551 writer
552 .start_file(*name, SimpleFileOptions::default())
553 .unwrap();
554 writer.write_all(body).unwrap();
555 }
556 InstalledCapsule {
557 artifact: "local/house".to_string(),
558 version: "0.1.0".to_string(),
559 package: Bytes::from(writer.finish().unwrap().into_inner()),
560 }
561 }
562
563 const HOUSE_SKILL_MD: &[u8] =
564 b"---\nname: house\ndescription: How the house works.\n---\n# House skill\n";
565
566 fn house() -> ManifestFiles {
567 ManifestFiles::Package(package(&[
568 ("murmur.yaml", b"name: house\n"),
569 ("compaction.md", b"Summarise tersely."),
570 ("./instructions.md", b"Package instructions."),
571 ("SKILL.md", HOUSE_SKILL_MD),
572 ("skills/house/SKILL.md", HOUSE_SKILL_MD),
573 ("skills/house/references/a.md", b"reference a"),
574 ("skills/house/references/murmur.yaml", b"nested is a file"),
575 ("skills/house/.hidden", b"hidden"),
576 ("skills/manifest/SKILL.md", HOUSE_SKILL_MD),
577 ("skills/manifest/murmur.yaml", b"name: inner\n"),
578 ("skills/twice/SKILL.md", b"one"),
579 ("skills/twice/skill.md", b"two"),
580 ("skills/lower/skill.md", HOUSE_SKILL_MD),
581 ("skills/other/SKILL.md", HOUSE_SKILL_MD),
582 ("skills/bare/notes.md", b"no skill"),
583 ("skills/built/SKILL.md", b"built"),
584 ("skills/built/murmur-build.json", b"{}"),
585 ]))
586 }
587
588 fn not_standard(result: Result<LocalSkill, RuntimeError>) -> (String, String) {
590 match result {
591 Err(RuntimeError::SkillSourceNotStandard { message, hint }) => (message, hint),
592 other => panic!("expected SkillSourceNotStandard, got {other:?}"),
593 }
594 }
595
596 #[test]
597 fn every_spelling_of_a_relative_path_reads_the_normalised_entry() {
598 let files = house();
599 for declared in [
600 "instructions.md",
601 "./instructions.md",
602 "././instructions.md",
603 ] {
604 assert_eq!(
605 files.read_to_string(declared).unwrap(),
606 "Package instructions.",
607 "{declared}"
608 );
609 assert_eq!(
610 files.resolved(declared),
611 "local/house@0.1.0!/instructions.md"
612 );
613 }
614 }
615
616 #[test]
617 fn an_entry_missing_from_the_package_is_not_found_and_names_requires_files() {
618 let (shown, error) = house().read_to_string("./missing.md").unwrap_err();
619 assert_eq!(shown, "local/house@0.1.0!/missing.md");
620 assert_eq!(error.kind(), ErrorKind::NotFound);
621 assert!(error.to_string().contains("requires_files"), "{error}");
622 }
623
624 #[test]
625 fn a_path_naming_only_the_package_root_is_not_found() {
626 let (_, error) = house().read_to_string("./").unwrap_err();
627 assert_eq!(error.kind(), ErrorKind::NotFound);
628 }
629
630 #[test]
631 fn a_package_path_that_climbs_out_is_invalid_input() {
632 let (shown, error) = house().read_to_string("../x").unwrap_err();
633 assert_eq!(shown, "local/house@0.1.0!/../x");
634 assert_eq!(error.kind(), ErrorKind::InvalidInput);
635 assert!(error.to_string().contains("outside the installed package"));
636 let (_, error) = house().read_to_string("a/../../x").unwrap_err();
637 assert_eq!(error.kind(), ErrorKind::InvalidInput);
638 }
639
640 #[test]
641 fn an_absolute_path_reads_the_host_file_under_either_base() {
642 let dir = tempfile::tempdir().unwrap();
643 let host = dir.path().join("host.md");
644 fs::write(&host, "Host text.").unwrap();
645 let declared = host.to_str().unwrap();
646 assert_eq!(house().read_to_string(declared).unwrap(), "Host text.");
647 let other = ManifestFiles::Directory(PathBuf::from("/nonexistent"));
648 assert_eq!(other.read_to_string(declared).unwrap(), "Host text.");
649 assert_eq!(house().resolved(declared), declared);
650 }
651
652 #[test]
653 fn a_directory_joins_the_declared_path_without_normalising() {
654 let dir = tempfile::tempdir().unwrap();
655 fs::write(dir.path().join("instructions.md"), "Folder text.").unwrap();
656 let files = ManifestFiles::Directory(dir.path().to_path_buf());
657 assert_eq!(
658 files.read_to_string("./instructions.md").unwrap(),
659 "Folder text."
660 );
661 let (shown, error) = files.read_to_string("./missing.md").unwrap_err();
662 assert_eq!(shown, dir.path().join("./missing.md").display().to_string());
663 assert_eq!(error.kind(), ErrorKind::NotFound);
664 }
665
666 #[test]
667 fn a_package_file_source_installs_that_skill_md_alone() {
668 let skill = house()
669 .read_skill_source("./skills/house/SKILL.md")
670 .unwrap();
671 assert_eq!(
672 skill,
673 LocalSkill {
674 files: vec![("SKILL.md".to_string(), HOUSE_SKILL_MD.to_vec())],
675 description: "How the house works.".to_string(),
676 }
677 );
678 }
679
680 #[test]
681 fn a_package_file_source_not_named_skill_md_is_refused() {
682 for source in ["./compaction.md", "skills/lower/skill.md"] {
683 let (message, hint) = not_standard(house().read_skill_source(source));
684 assert!(
685 message.ends_with("a skill's file is named SKILL.md, exactly"),
686 "{message}"
687 );
688 assert!(hint.starts_with("rename "), "{hint}");
689 }
690 }
691
692 #[test]
693 fn a_skill_md_at_the_package_root_has_no_folder_and_is_refused() {
694 let (message, hint) = not_standard(house().read_skill_source("SKILL.md"));
695 assert_eq!(
696 message,
697 "local/house@0.1.0!/SKILL.md: is not inside a folder, so front block name 'house' \
698 has no folder name to match"
699 );
700 assert_eq!(hint, "move SKILL.md into a folder named house");
701 }
702
703 #[test]
704 fn a_package_folder_source_installs_skill_md_and_supporting_files() {
705 let skill = house().read_skill_source("skills/house/").unwrap();
706 let names: Vec<&str> = skill.files.iter().map(|(name, _)| name.as_str()).collect();
707 assert_eq!(
708 names,
709 ["SKILL.md", "references/a.md", "references/murmur.yaml"]
710 );
711 assert_eq!(skill.files[0].1, HOUSE_SKILL_MD);
712 assert_eq!(skill.description, "How the house works.");
713 }
714
715 #[test]
716 fn a_package_folder_breaking_the_standard_is_refused_by_rule() {
717 let (message, hint) = not_standard(house().read_skill_source("skills/manifest"));
718 assert_eq!(
719 message,
720 "local/house@0.1.0!/skills/manifest/murmur.yaml: a skill folder holds no murmur.yaml \
721 beside SKILL.md"
722 );
723 assert!(hint.ends_with("delete murmur.yaml"), "{hint}");
724
725 let (message, hint) = not_standard(house().read_skill_source("skills/twice"));
726 assert_eq!(
727 message,
728 "local/house@0.1.0!/skills/twice/skill.md: a skill's file is named SKILL.md, exactly"
729 );
730 assert_eq!(hint, "rename skill.md to SKILL.md");
731
732 let (message, _) = not_standard(house().read_skill_source("skills/other"));
733 assert_eq!(
734 message,
735 "local/house@0.1.0!/skills/other/SKILL.md: front block name 'house' differs from \
736 the folder name 'other'"
737 );
738 }
739
740 #[test]
741 fn a_package_folder_with_build_info_is_refused() {
742 match house().read_skill_source("skills/built") {
743 Err(RuntimeError::SkillSourceRefused { message, hint }) => {
744 assert!(message.contains("reserved for mur build"), "{message}");
745 assert!(
746 hint.contains("mur build writes murmur-build.json"),
747 "{hint}"
748 );
749 }
750 other => panic!("expected SkillSourceRefused, got {other:?}"),
751 }
752 }
753
754 fn host_skill(base: &Path, folder: &str, skill_md: &str) -> PathBuf {
755 let dir = base.join(folder);
756 fs::create_dir_all(dir.join("references")).unwrap();
757 fs::write(dir.join("SKILL.md"), skill_md).unwrap();
758 fs::write(dir.join("references/forms.md"), "# Forms\n").unwrap();
759 dir
760 }
761
762 const PDF_HELPER: &str = "---\nname: pdf-helper\ndescription: Fill PDF forms; use when a form must be filled.\n---\n# PDF helper\n";
763
764 #[test]
765 fn a_host_folder_source_needs_no_version_and_carries_its_description() {
766 let base = tempfile::tempdir().unwrap();
767 let dir = host_skill(base.path(), "pdf-helper", PDF_HELPER);
768 let files = ManifestFiles::Directory(base.path().to_path_buf());
769 let skill = files.read_skill_source("./pdf-helper").unwrap();
770 let names: Vec<&str> = skill.files.iter().map(|(name, _)| name.as_str()).collect();
771 assert_eq!(names, ["SKILL.md", "references/forms.md"]);
772 assert_eq!(
773 skill.description,
774 "Fill PDF forms; use when a form must be filled."
775 );
776
777 let file = files.read_skill_source("./pdf-helper/SKILL.md").unwrap();
778 assert_eq!(
779 file.files,
780 vec![("SKILL.md".to_string(), PDF_HELPER.as_bytes().to_vec())]
781 );
782 let absolute = dir.join("SKILL.md");
783 let file = files.read_skill_source(absolute.to_str().unwrap()).unwrap();
784 assert_eq!(file.files.len(), 1);
785 }
786
787 #[test]
788 fn a_host_source_breaking_the_standard_is_refused_by_rule() {
789 let base = tempfile::tempdir().unwrap();
790 let files = ManifestFiles::Directory(base.path().to_path_buf());
791
792 let dir = host_skill(
793 base.path(),
794 "lower",
795 &PDF_HELPER.replace("pdf-helper", "lower"),
796 );
797 fs::rename(dir.join("SKILL.md"), dir.join("skill.md")).unwrap();
798 let (message, hint) = not_standard(files.read_skill_source("./lower"));
799 assert!(
800 message.ends_with("lower/skill.md: a skill's file is named SKILL.md, exactly"),
801 "{message}"
802 );
803 assert_eq!(hint, "rename skill.md to SKILL.md");
804 let (message, _) = not_standard(files.read_skill_source("./lower/skill.md"));
805 assert!(
806 message.ends_with("a skill's file is named SKILL.md, exactly"),
807 "{message}"
808 );
809
810 let dir = host_skill(
811 base.path(),
812 "manifest",
813 &PDF_HELPER.replace("pdf-helper", "manifest"),
814 );
815 fs::write(dir.join("murmur.yaml"), "name: manifest\n").unwrap();
816 let (message, _) = not_standard(files.read_skill_source("./manifest"));
817 assert!(
818 message.ends_with("a skill folder holds no murmur.yaml beside SKILL.md"),
819 "{message}"
820 );
821
822 host_skill(
823 base.path(),
824 "pdf-helper",
825 &PDF_HELPER.replace("name: pdf-helper", "name: other-name"),
826 );
827 let (message, hint) = not_standard(files.read_skill_source("./pdf-helper"));
828 assert!(
829 message.ends_with(
830 "SKILL.md: front block name 'other-name' differs from the folder name 'pdf-helper'"
831 ),
832 "{message}"
833 );
834 assert_eq!(
835 hint,
836 "set name: pdf-helper, or rename the folder to other-name"
837 );
838 let (message, _) = not_standard(files.read_skill_source("./pdf-helper/SKILL.md"));
839 assert!(
840 message.contains("differs from the folder name 'pdf-helper'"),
841 "{message}"
842 );
843 }
844
845 #[test]
846 fn a_package_folder_without_skill_md_names_the_folder() {
847 match house().read_skill_source("skills/bare") {
848 Err(RuntimeError::SkillSourceMissingSkillMd { path }) => {
849 assert_eq!(path, "local/house@0.1.0!/skills/bare");
850 }
851 other => panic!("expected SkillSourceMissingSkillMd, got {other:?}"),
852 }
853 }
854
855 #[test]
856 fn a_missing_package_prefix_is_not_found() {
857 match house().read_skill_source("skills/absent") {
858 Err(RuntimeError::SkillSourceNotFound { path }) => {
859 assert_eq!(path, "local/house@0.1.0!/skills/absent");
860 }
861 other => panic!("expected SkillSourceNotFound, got {other:?}"),
862 }
863 }
864
865 #[test]
866 fn a_climbing_package_skill_source_is_refused() {
867 match house().read_skill_source("../skills/house") {
868 Err(RuntimeError::SkillSourceRead { path, source }) => {
869 assert_eq!(path, "local/house@0.1.0!/../skills/house");
870 assert_eq!(source.kind(), ErrorKind::InvalidInput);
871 }
872 other => panic!("expected SkillSourceRead, got {other:?}"),
873 }
874 }
875
876 #[test]
877 fn reports_list_declared_paths_in_manifest_order() {
878 let inference = murmur_artifact::InferenceConfig {
879 transport: "http".to_string(),
880 model: "test-model".to_string(),
881 driver: None,
882 command: None,
883 compaction: Some(murmur_artifact::CompactionConfig {
884 threshold: None,
885 model: None,
886 system_prompt: None,
887 system_prompt_file: Some("compaction.md".to_string()),
888 dump_summaries: None,
889 max_tokens: None,
890 driver_config: None,
891 }),
892 system_prompt: None,
893 system_prompt_file: Some("./instructions.md".to_string()),
894 system_prompt_artifact: None,
895 instruction_files: None,
896 max_turns: None,
897 max_tokens: None,
898 max_session_tokens: None,
899 tool_refresh: murmur_artifact::ToolRefresh::Compaction,
900 alternates: Vec::new(),
901 retry: murmur_artifact::InferenceRetry::default(),
902 };
903 let rows = manifest_file_reports(
904 &house(),
905 Some(&inference),
906 [
907 ("house", "skills/house"),
908 ("abs", "/opt/pdf-helper/SKILL.md"),
909 ],
910 );
911 assert_eq!(
912 rows,
913 vec![
914 ManifestFileReport {
915 key: "inference.system_prompt_file".to_string(),
916 declared: "./instructions.md".to_string(),
917 resolved: "local/house@0.1.0!/instructions.md".to_string(),
918 base: BASE_INSTALLED_PACKAGE,
919 },
920 ManifestFileReport {
921 key: "inference.compaction.system_prompt_file".to_string(),
922 declared: "compaction.md".to_string(),
923 resolved: "local/house@0.1.0!/compaction.md".to_string(),
924 base: BASE_INSTALLED_PACKAGE,
925 },
926 ManifestFileReport {
927 key: "artifacts[house].source".to_string(),
928 declared: "skills/house".to_string(),
929 resolved: "local/house@0.1.0!/skills/house".to_string(),
930 base: BASE_INSTALLED_PACKAGE,
931 },
932 ManifestFileReport {
933 key: "artifacts[abs].source".to_string(),
934 declared: "/opt/pdf-helper/SKILL.md".to_string(),
935 resolved: "/opt/pdf-helper/SKILL.md".to_string(),
936 base: BASE_HOST_PATH,
937 },
938 ]
939 );
940 let folder = ManifestFiles::Directory(PathBuf::from("/src/house"));
941 let rows = manifest_file_reports(&folder, Some(&inference), []);
942 assert_eq!(rows[0].resolved, "/src/house/./instructions.md");
943 assert_eq!(rows[0].base, BASE_MANIFEST_FOLDER);
944 assert!(manifest_file_reports(&folder, None, []).is_empty());
945 }
946
947 #[test]
948 fn the_block_renders_only_when_rows_exist() {
949 assert_eq!(render_manifest_files(&[]), "");
950 let rows = vec![ManifestFileReport {
951 key: "inference.system_prompt_file".to_string(),
952 declared: "./instructions.md".to_string(),
953 resolved: "local/house@0.1.0!/instructions.md".to_string(),
954 base: BASE_INSTALLED_PACKAGE,
955 }];
956 assert_eq!(
957 render_manifest_files(&rows),
958 "\nManifest files\n inference.system_prompt_file: ./instructions.md -> \
959 local/house@0.1.0!/instructions.md\n"
960 );
961 }
962}