Skip to main content

capsule_runtime/
manifest_files.rs

1//! Where the files a capsule manifest names by path are read from: `inference.system_prompt_file`,
2//! `inference.compaction.system_prompt_file` and a local-source `artifacts[].source`.
3//!
4//! A relative path resolves against the manifest. For a capsule launched from a project
5//! (`mur run --manifest`) that is the folder holding `murmur.yaml`; for an installed capsule
6//! (`mur run --capsule`) it is the installed package, whose entries are read from the bytes the
7//! registry holds. The workdir, the current directory and the store root are never consulted for
8//! an installed capsule. An absolute path is read from the host in both cases.
9//!
10//! A package path is displayed as `<scope>/<name>@<version>!/<entry>`, the form error messages
11//! and [`ManifestFileReport::resolved`] carry.
12
13use 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
29/// [`ManifestFileReport::base`] for a relative path under `mur run --manifest`.
30pub const BASE_MANIFEST_FOLDER: &str = "manifest-folder";
31/// [`ManifestFileReport::base`] for a relative path under `mur run --capsule`.
32pub const BASE_INSTALLED_PACKAGE: &str = "installed-package";
33/// [`ManifestFileReport::base`] for an absolute path, under either launch.
34pub const BASE_HOST_PATH: &str = "host-path";
35
36/// The reason a relative path missing from an installed package fails with.
37const 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";
39/// The reason a relative path with a `..` component fails with under `mur run --capsule`.
40const OUTSIDE_PACKAGE: &str = "names a path outside the installed package";
41
42/// A capsule launched by name: the artifact it resolved to and that artifact's package bytes.
43#[derive(Clone)]
44pub struct InstalledCapsule {
45    /// `<scope>/<name>`, as the capsule was resolved by.
46    pub artifact: String,
47    pub version: String,
48    /// The `.mur.zip` the registry holds for `artifact@version`.
49    pub package: Bytes,
50}
51
52impl InstalledCapsule {
53    /// `<artifact>@<version>`, the prefix of every package path this capsule displays.
54    #[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/// What a relative path in a capsule's manifest resolves against.
71#[derive(Debug, Clone)]
72pub enum ManifestFiles {
73    /// The folder holding the manifest, for a capsule launched from a project.
74    Directory(PathBuf),
75    /// The installed package, for a capsule launched by name.
76    Package(InstalledCapsule),
77}
78
79/// Where one declared path is read from.
80enum Location {
81    /// A path on the host.
82    Host(PathBuf),
83    /// The package entry whose normalised name is this.
84    Entry(String),
85    /// A package path with a `..` component, which is refused.
86    Outside,
87}
88
89/// One declared path and where it resolves, as `--explain-scope` and
90/// `session_start.effective_grants.manifest_files` record it.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct ManifestFileReport {
93    /// The manifest key: `inference.system_prompt_file`, `inference.compaction.system_prompt_file`
94    /// or `artifacts[<name>].source`.
95    pub key: String,
96    /// The path as the manifest spells it.
97    pub declared: String,
98    /// Where it is read from: a host path, or `<scope>/<name>@<version>!/<entry>` for an entry of
99    /// an installed package.
100    pub resolved: String,
101    /// [`BASE_MANIFEST_FOLDER`], [`BASE_INSTALLED_PACKAGE`] or [`BASE_HOST_PATH`].
102    pub base: &'static str,
103}
104
105impl ManifestFiles {
106    /// Where `declared` is read from, the string that names it, and its
107    /// [`ManifestFileReport::base`]. Reads nothing.
108    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            // Joined without normalising: this string is what `E-RUN-009` and the skill-source
119            // errors print for a project launch.
120            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    /// Where `declared` resolves, as [`ManifestFileReport::resolved`] shows it.
140    #[must_use]
141    pub fn resolved(&self, declared: &str) -> String {
142        self.locate(declared).1
143    }
144
145    /// The text of the file `declared` names. The error carries the string naming where it was
146    /// looked for, and the I/O error: `NotFound` for an entry the package lacks, `InvalidInput`
147    /// for a package path with a `..` component.
148    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    /// A local-source skill: its files and its front block's description.
166    ///
167    /// `source` may name a file, which must be named `SKILL.md` and installs alone, or a folder,
168    /// whose `SKILL.md` and supporting files install by the rules
169    /// [`murmur_artifact::collect_skill_folder`] applies to a folder on disk. A package folder is
170    /// every entry under `<source>/`. `SKILL.md` must pass [`check_skill_md`] without
171    /// `metadata.version`, its `name` equal to the name of the folder holding it. Every refusal
172    /// happens here, before anything is staged.
173    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/// A local-source skill as [`ManifestFiles::read_skill_source`] read it.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct LocalSkill {
193    /// Each file as its `/`-separated path under `tools/<name>/` and its bytes, `SKILL.md`
194    /// first.
195    pub files: SkillFiles,
196    /// The `SKILL.md` front block's `description`.
197    pub description: String,
198}
199
200/// Check `skill_md` (`files[0]`), shown as `path`, and pair the files with its description.
201fn 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
214/// The last `/`-separated component of `path`, and the one before it.
215fn 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
221/// A declared relative path as a package entry name: its normal components joined by `/`. `None`
222/// when a component is `..`.
223fn 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
236/// One file entry of a package: its raw archive name, its normalised name, and whether it is a
237/// symlink.
238struct 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
253/// Every file entry of `archive`, directories left out.
254fn 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        // Normalised as `normalise_declared` normalises a declared path; a name the guard
268        // refuses is never a match.
269        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
290/// The bytes of the entry of `package` whose normalised name is `entry`, under the artifact
291/// decompression ceiling.
292fn 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
310/// A skill source in a package: the entry `prefix` names, or the folder of entries under
311/// `<prefix>/`. `shown` is the package path errors name.
312fn 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
409/// The launch error for a skill source the walk or the front-block check refuses:
410/// `E-BLD-009` for a break with the Agent Skills standard, `E-BLD-002` for what no artifact
411/// packs.
412fn 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
433/// A skill source on the host: a `SKILL.md` file, installed alone, or a folder walked by
434/// [`murmur_artifact::collect_skill_folder`]. The folder the front block's `name` must equal is
435/// the last component of the folder's canonical path, or of the file's parent's.
436fn 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/// One row per path the manifest names, in the order `inference.system_prompt_file`,
480/// `inference.compaction.system_prompt_file`, then `artifacts[<name>].source` for each
481/// local-source entry of `sources` (`(name, source)`, in manifest order). Reads nothing.
482///
483/// `inference` is the configuration the launch resolves prompts from, so a `--system-prompt`
484/// override that clears `system_prompt_file` leaves its row out.
485#[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/// The `Manifest files` block of `mur run --explain-scope`, empty when `rows` is. Starts with a
523/// blank line and ends with a newline.
524#[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    /// The `(message, hint)` of a `SkillSourceNotStandard` refusal.
589    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}