Skip to main content

bake_agent_context/
installer.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4#[cfg(test)]
5use super::test_filesystem as fs;
6use bake::{Error, Result};
7use serde::Deserialize;
8use std::collections::{HashMap, HashSet};
9use std::env;
10use std::ffi::{OsStr, OsString};
11#[cfg(not(test))]
12use std::fs;
13use std::path::{Component, Path, PathBuf};
14use std::process::Command;
15
16/// A resolved Cargo package that provides a top-level `context/` directory.
17#[derive(Clone, Debug)]
18pub struct ContextPackage {
19    pub name: String,
20    pub version: String,
21    pub description: Option<String>,
22    pub context_path: PathBuf,
23    selector: String,
24}
25
26impl ContextPackage {
27    /// The name accepted by the task's `--package` option.
28    pub fn selector(&self) -> &str {
29        &self.selector
30    }
31
32    #[cfg(test)]
33    pub(super) fn for_test(name: &str, version: &str, context_path: PathBuf) -> Self {
34        Self {
35            name: name.to_owned(),
36            version: version.to_owned(),
37            description: Some(format!("{name} documentation")),
38            context_path,
39            selector: format!("{name}@{version}"),
40        }
41    }
42}
43
44/// A context file relative to its provider's `context/` directory.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ContextFile {
47    pub path: PathBuf,
48}
49
50/// Discovers, lists, reads, and installs context from resolved Cargo dependencies.
51#[derive(Clone, Debug)]
52pub struct Installer {
53    root: PathBuf,
54    context_path: PathBuf,
55    packages: Vec<ContextPackage>,
56}
57
58impl Installer {
59    /// Resolve the project's Cargo packages and find dependencies with `context/` directories.
60    pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
61        let root = root.into();
62        let cargo = cargo_executable(env::var_os("CARGO"));
63        Self::new_with_cargo(root, cargo)
64    }
65
66    fn new_with_cargo(root: PathBuf, cargo: OsString) -> Result<Self> {
67        let manifest = root.join("Cargo.toml");
68        let output = run_cargo_metadata(&root, &cargo)?;
69        if !output.status.success() {
70            return Err(cargo_metadata_failure(
71                &manifest,
72                output.status,
73                &output.stderr,
74            ));
75        }
76
77        let metadata = parse_metadata(&output.stdout)?;
78        Self::from_metadata(root, metadata)
79    }
80
81    #[cfg(test)]
82    pub(super) fn for_test(root: &Path, packages: Vec<ContextPackage>) -> Self {
83        Self {
84            root: root.to_path_buf(),
85            context_path: root.join(".agents/context"),
86            packages,
87        }
88    }
89
90    fn from_metadata(root: PathBuf, metadata: CargoMetadata) -> Result<Self> {
91        let workspace_members: HashSet<_> = metadata.workspace_members.into_iter().collect();
92        let resolved_packages: HashSet<_> = metadata
93            .resolve
94            .map(|resolve| {
95                resolve
96                    .nodes
97                    .into_iter()
98                    .map(|node| node.package_id)
99                    .collect()
100            })
101            .unwrap_or_default();
102
103        let mut candidates = Vec::new();
104        for package in metadata.packages {
105            if workspace_members.contains(&package.package_id)
106                || (!resolved_packages.is_empty()
107                    && !resolved_packages.contains(&package.package_id))
108            {
109                continue;
110            }
111
112            let Some(package_root) = package.manifest_path.parent() else {
113                continue;
114            };
115            let context_path = package_root.join("context");
116            match fs::symlink_metadata(&context_path) {
117                Ok(metadata) if metadata.file_type().is_dir() => {}
118                Ok(_) => continue,
119                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
120                Err(error) => {
121                    return Err(Error::new(format!(
122                        "cannot inspect {}: {error}",
123                        context_path.display()
124                    )));
125                }
126            };
127            candidates.push(ContextPackage {
128                name: package.name,
129                version: package.version,
130                description: package.description,
131                context_path,
132                selector: String::new(),
133            });
134        }
135
136        let mut name_counts = HashMap::new();
137        for package in &candidates {
138            *name_counts.entry(package.name.clone()).or_insert(0usize) += 1;
139        }
140        for package in &mut candidates {
141            package.selector = if name_counts[&package.name] == 1 {
142                package.name.clone()
143            } else {
144                format!("{}@{}", package.name, package.version)
145            };
146        }
147        candidates.sort_by(|left, right| {
148            left.name
149                .cmp(&right.name)
150                .then_with(|| left.version.cmp(&right.version))
151        });
152
153        Ok(Self {
154            context_path: root.join(".agents/context"),
155            root,
156            packages: candidates,
157        })
158    }
159
160    pub fn root(&self) -> &Path {
161        &self.root
162    }
163
164    pub fn context_path(&self) -> &Path {
165        &self.context_path
166    }
167
168    pub fn packages(&self) -> &[ContextPackage] {
169        &self.packages
170    }
171
172    pub fn find_package(&self, selector: &str) -> Result<Option<ContextPackage>> {
173        let matches: Vec<_> = self
174            .packages
175            .iter()
176            .filter(|package| package.selector == selector || package.name == selector)
177            .collect();
178
179        match matches.as_slice() {
180            [] => Ok(None),
181            [package] => Ok(Some((*package).clone())),
182            _ => {
183                let selectors = matches
184                    .iter()
185                    .map(|package| package.selector.as_str())
186                    .collect::<Vec<_>>()
187                    .join(", ");
188                Err(Error::new(format!(
189                    "multiple versions of crate {selector:?} provide context; choose one of: {selectors}"
190                )))
191            }
192        }
193    }
194
195    pub fn list_context_files(&self, package: &ContextPackage) -> Result<Vec<ContextFile>> {
196        let mut files = Vec::new();
197        collect_files(&package.context_path, &mut files)?;
198        files.sort();
199
200        let skill_names: HashSet<_> = super::skill::list_package_skills(package)?
201            .into_iter()
202            .map(|skill| skill.source_name)
203            .collect();
204        Ok(files
205            .into_iter()
206            .filter_map(|file| {
207                file.strip_prefix(&package.context_path)
208                    .ok()
209                    .map(|path| ContextFile {
210                        path: path.to_path_buf(),
211                    })
212            })
213            .filter(|file| !is_skill_context_path(&file.path, &skill_names))
214            .collect())
215    }
216
217    pub fn show_context_file(&self, selector: &str, file: &str) -> Result<Option<String>> {
218        let Some(package) = self.find_package(selector)? else {
219            return Ok(None);
220        };
221        let Some((context_root, path)) = find_context_file_with_root(&package.context_path, file)?
222        else {
223            return Ok(None);
224        };
225
226        let skill_names: HashSet<_> = super::skill::list_package_skills(&package)?
227            .into_iter()
228            .map(|skill| skill.source_name)
229            .collect();
230        let relative_path = path
231            .strip_prefix(&context_root)
232            .expect("find_context_file only returns paths within context_root");
233        if is_skill_context_path(relative_path, &skill_names) {
234            return Ok(None);
235        }
236
237        read_context_file(&path)
238    }
239
240    /// Install one package's context. Returns `false` when it does not provide context.
241    pub fn install_package(&self, selector: &str) -> Result<bool> {
242        let Some(package) = self.find_package(selector)? else {
243            return Ok(false);
244        };
245        let skills = super::skill::list_package_skills(&package)?;
246        let skill_names: HashSet<_> = skills.into_iter().map(|skill| skill.source_name).collect();
247
248        fs::create_dir_all(&self.context_path).map_err(|error| {
249            Error::new(format!(
250                "cannot create {}: {error}",
251                self.context_path.display()
252            ))
253        })?;
254        let destination = self.context_path.join(&package.selector);
255        remove_existing(&destination)?;
256        let copied = copy_context_tree(&package.context_path, &destination, &skill_names, true)?;
257        if !copied {
258            remove_existing(&destination)?;
259        }
260        Ok(copied)
261    }
262
263    /// Install all resolved dependency packages that provide context.
264    pub fn install_all(&self) -> Result<Vec<String>> {
265        let mut installed = Vec::new();
266        for package in &self.packages {
267            if self.install_package(&package.selector)? {
268                installed.push(package.selector.clone());
269            }
270        }
271        Ok(installed)
272    }
273}
274
275fn run_cargo_metadata(root: &Path, cargo: &OsStr) -> Result<std::process::Output> {
276    let manifest = root.join("Cargo.toml");
277    let mut command = Command::new(cargo);
278    command
279        .args([
280            "metadata",
281            "--format-version",
282            "1",
283            "--locked",
284            "--manifest-path",
285        ])
286        .arg(&manifest)
287        .current_dir(root);
288
289    command.output().map_err(|error| {
290        Error::new(format!(
291            "cannot run cargo metadata for {}: {error}",
292            manifest.display()
293        ))
294    })
295}
296
297fn cargo_executable(cargo: Option<OsString>) -> OsString {
298    cargo.unwrap_or_else(|| "cargo".into())
299}
300
301fn cargo_metadata_failure(manifest: &Path, status: impl std::fmt::Display, stderr: &[u8]) -> Error {
302    let details = String::from_utf8_lossy(stderr).trim().to_owned();
303    Error::new(format!(
304        "cargo metadata failed for {} ({}): {}",
305        manifest.display(),
306        status,
307        if details.is_empty() {
308            "run cargo check to resolve and lock the project's dependencies".to_owned()
309        } else {
310            details
311        }
312    ))
313}
314
315fn parse_metadata(stdout: &[u8]) -> Result<CargoMetadata> {
316    serde_json::from_slice(stdout)
317        .map_err(|error| Error::new(format!("cannot parse cargo metadata: {error}")))
318}
319
320#[derive(Deserialize)]
321struct CargoMetadata {
322    workspace_members: Vec<String>,
323    packages: Vec<CargoPackage>,
324    resolve: Option<Resolve>,
325}
326
327#[derive(Deserialize)]
328struct Resolve {
329    nodes: Vec<ResolveNode>,
330}
331
332#[derive(Deserialize)]
333struct ResolveNode {
334    #[serde(rename = "id")]
335    package_id: String,
336}
337
338#[derive(Deserialize)]
339struct CargoPackage {
340    #[serde(rename = "id")]
341    package_id: String,
342    name: String,
343    version: String,
344    description: Option<String>,
345    manifest_path: PathBuf,
346}
347
348#[cfg(test)]
349fn find_context_file(context_path: &Path, file: &str) -> Result<Option<PathBuf>> {
350    Ok(find_context_file_with_root(context_path, file)?.map(|(_, path)| path))
351}
352
353fn find_context_file_with_root(
354    context_path: &Path,
355    file: &str,
356) -> Result<Option<(PathBuf, PathBuf)>> {
357    let requested = Path::new(file);
358    if requested.is_absolute()
359        || requested
360            .components()
361            .any(|component| !matches!(component, Component::Normal(_)))
362    {
363        return Err(Error::new(
364            "context file must be a relative path inside context/",
365        ));
366    }
367
368    let canonical_root = canonical_context_root(context_path)?;
369    let mut candidates = vec![context_path.join(requested)];
370    if requested.extension().is_none() {
371        candidates.push(context_path.join(requested).with_extension("md"));
372    }
373
374    for candidate in candidates {
375        let Ok(canonical_candidate) = candidate.canonicalize() else {
376            continue;
377        };
378        if !canonical_candidate.starts_with(&canonical_root) || !canonical_candidate.is_file() {
379            continue;
380        }
381        return Ok(Some((canonical_root, canonical_candidate)));
382    }
383
384    Ok(None)
385}
386
387fn canonical_context_root(context_path: &Path) -> Result<PathBuf> {
388    context_path.canonicalize().map_err(|error| {
389        Error::new(format!(
390            "cannot resolve {}: {error}",
391            context_path.display()
392        ))
393    })
394}
395
396fn read_context_file(path: &Path) -> Result<Option<String>> {
397    fs::read_to_string(path)
398        .map(Some)
399        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))
400}
401
402pub(crate) fn markdown_files(root: &Path) -> Result<Vec<PathBuf>> {
403    let mut files = Vec::new();
404    collect_markdown_files(root, &mut files)?;
405    files.sort();
406    Ok(files)
407}
408
409fn collect_markdown_files(directory: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
410    let entries = fs::read_dir(directory)
411        .map_err(|error| Error::new(format!("cannot read {}: {error}", directory.display())))?;
412    for entry in entries {
413        let entry = entry?;
414        let file_type = entry.file_type()?;
415        let path = entry.path();
416        if file_type.is_dir() {
417            collect_markdown_files(&path, files)?;
418        } else if file_type.is_file()
419            && path
420                .extension()
421                .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
422        {
423            files.push(path);
424        }
425    }
426    Ok(())
427}
428
429fn collect_files(directory: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
430    let entries = fs::read_dir(directory)
431        .map_err(|error| Error::new(format!("cannot read {}: {error}", directory.display())))?;
432    for entry in entries {
433        let entry = entry?;
434        let file_type = entry.file_type()?;
435        let path = entry.path();
436        if file_type.is_dir() {
437            collect_files(&path, files)?;
438        } else if file_type.is_file() {
439            files.push(path);
440        }
441    }
442    Ok(())
443}
444
445fn copy_context_tree(
446    source: &Path,
447    destination: &Path,
448    skill_names: &HashSet<String>,
449    root: bool,
450) -> Result<bool> {
451    let source_type = fs::symlink_metadata(source)
452        .map_err(|error| Error::new(format!("cannot inspect {}: {error}", source.display())))?
453        .file_type();
454    if !source_type.is_dir() {
455        return Err(Error::new(format!(
456            "context provider {} is not a regular directory",
457            source.display()
458        )));
459    }
460
461    fs::create_dir_all(destination)
462        .map_err(|error| Error::new(format!("cannot create {}: {error}", destination.display())))?;
463    let entries = fs::read_dir(source)
464        .map_err(|error| Error::new(format!("cannot read {}: {error}", source.display())))?;
465    let mut copied = false;
466    for entry in entries {
467        let entry = entry?;
468        let file_type = entry.file_type()?;
469        let source_path = entry.path();
470        let destination_path = destination.join(entry.file_name());
471        if file_type.is_dir() {
472            if root
473                && entry
474                    .file_name()
475                    .to_str()
476                    .is_some_and(|name| skill_names.contains(name))
477            {
478                continue;
479            }
480
481            if copy_context_tree(&source_path, &destination_path, skill_names, false)? {
482                copied = true;
483            } else {
484                remove_empty_context_directory(&destination_path)?;
485            }
486        } else if file_type.is_file() {
487            if root && is_skill_markdown(&source_path, skill_names) {
488                continue;
489            }
490            fs::copy(&source_path, &destination_path).map_err(|error| {
491                Error::new(format!(
492                    "cannot copy {} to {}: {error}",
493                    source_path.display(),
494                    destination_path.display()
495                ))
496            })?;
497            copied = true;
498        }
499    }
500    Ok(copied)
501}
502
503fn remove_empty_context_directory(path: &Path) -> Result<()> {
504    fs::remove_dir(path).map_err(|error| {
505        Error::new(format!(
506            "cannot remove empty context directory {}: {error}",
507            path.display()
508        ))
509    })
510}
511
512fn is_skill_context_path(path: &Path, skill_names: &HashSet<String>) -> bool {
513    let mut components = path.components();
514    let Some(first) = components
515        .next()
516        .and_then(|component| component.as_os_str().to_str())
517    else {
518        return false;
519    };
520    if skill_names.contains(first) {
521        return true;
522    }
523
524    components.next().is_none() && is_skill_markdown(path, skill_names)
525}
526
527fn is_skill_markdown(path: &Path, skill_names: &HashSet<String>) -> bool {
528    path.extension()
529        .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
530        && path
531            .file_stem()
532            .and_then(|stem| stem.to_str())
533            .is_some_and(|stem| skill_names.contains(stem))
534}
535
536fn remove_existing(path: &Path) -> Result<()> {
537    let metadata = match fs::symlink_metadata(path) {
538        Ok(metadata) => metadata,
539        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
540        Err(error) => {
541            return Err(Error::new(format!(
542                "cannot inspect {}: {error}",
543                path.display()
544            )));
545        }
546    };
547    let result = if metadata.file_type().is_dir() {
548        fs::remove_dir_all(path)
549    } else {
550        fs::remove_file(path)
551    };
552    result.map_err(|error| Error::new(format!("cannot remove {}: {error}", path.display())))
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use tempfile::tempdir;
559
560    fn package(name: &str, version: &str, context_path: PathBuf) -> ContextPackage {
561        ContextPackage {
562            name: name.to_owned(),
563            version: version.to_owned(),
564            description: None,
565            context_path,
566            selector: format!("{name}@{version}"),
567        }
568    }
569
570    fn installer(root: &Path, packages: Vec<ContextPackage>) -> Installer {
571        Installer {
572            root: root.to_path_buf(),
573            context_path: root.join(".agents/context"),
574            packages,
575        }
576    }
577
578    fn write(root: &Path, relative: &str, contents: &str) {
579        let path = root.join(relative);
580        fs::create_dir_all(path.parent().unwrap()).unwrap();
581        fs::write(path, contents).unwrap();
582    }
583
584    fn cargo_package(id: &str, name: &str, version: &str, manifest_path: PathBuf) -> CargoPackage {
585        CargoPackage {
586            package_id: id.to_owned(),
587            name: name.to_owned(),
588            version: version.to_owned(),
589            description: Some(format!("{name} description")),
590            manifest_path,
591        }
592    }
593
594    #[test]
595    fn reports_cargo_execution_status_and_json_errors() {
596        let directory = tempdir().unwrap();
597        let error = Installer::new(directory.path()).err().unwrap();
598        assert!(error.to_string().contains("cargo metadata failed"));
599
600        let error = Installer::new_with_cargo(
601            directory.path().to_path_buf(),
602            directory.path().join("missing-cargo").into_os_string(),
603        )
604        .unwrap_err();
605        assert!(error.to_string().contains("cannot run cargo metadata"));
606
607        assert_eq!(cargo_executable(None), OsString::from("cargo"));
608        assert_eq!(
609            cargo_executable(Some(OsString::from("custom-cargo"))),
610            OsString::from("custom-cargo")
611        );
612
613        let error = cargo_metadata_failure(
614            &directory.path().join("Cargo.toml"),
615            "exit status: 1",
616            b"  cargo stderr \n",
617        );
618        assert!(error.to_string().ends_with("cargo stderr"));
619        let error = cargo_metadata_failure(&PathBuf::from("Cargo.toml"), "exit status: 1", b"\n");
620        assert!(error.to_string().contains("run cargo check to resolve"));
621
622        let error = parse_metadata(b"not JSON").err().unwrap();
623        assert!(error.to_string().contains("cannot parse cargo metadata"));
624    }
625
626    #[cfg(unix)]
627    #[test]
628    fn reports_metadata_parse_errors_from_cargo() {
629        let directory = tempdir().unwrap();
630        let error =
631            Installer::new_with_cargo(directory.path().to_path_buf(), "/usr/bin/true".into())
632                .unwrap_err();
633        assert!(error.to_string().contains("cannot parse cargo metadata"));
634    }
635
636    #[test]
637    fn discovers_only_resolved_non_workspace_context_packages_and_disambiguates_versions() {
638        let directory = tempdir().unwrap();
639        let root = directory.path();
640        for path in [
641            "workspace",
642            "duplicate-v1",
643            "duplicate-v2",
644            "missing",
645            "file",
646        ] {
647            fs::create_dir_all(root.join(path)).unwrap();
648        }
649        fs::create_dir(root.join("workspace/context")).unwrap();
650        fs::create_dir(root.join("duplicate-v1/context")).unwrap();
651        fs::create_dir(root.join("duplicate-v2/context")).unwrap();
652        fs::write(root.join("file/context"), "not a directory").unwrap();
653
654        let packages = vec![
655            cargo_package(
656                "root 0.1.0",
657                "workspace",
658                "0.1.0",
659                root.join("workspace/Cargo.toml"),
660            ),
661            cargo_package(
662                "dup 1.0.0",
663                "duplicate",
664                "1.0.0",
665                root.join("duplicate-v1/Cargo.toml"),
666            ),
667            cargo_package(
668                "dup 2.0.0",
669                "duplicate",
670                "2.0.0",
671                root.join("duplicate-v2/Cargo.toml"),
672            ),
673            cargo_package(
674                "unresolved 1.0.0",
675                "unresolved",
676                "1.0.0",
677                root.join("missing/Cargo.toml"),
678            ),
679            cargo_package("file 1.0.0", "file", "1.0.0", root.join("file/Cargo.toml")),
680        ];
681        let metadata = CargoMetadata {
682            workspace_members: vec!["root 0.1.0".to_owned()],
683            packages,
684            resolve: Some(Resolve {
685                nodes: vec![
686                    ResolveNode {
687                        package_id: "root 0.1.0".to_owned(),
688                    },
689                    ResolveNode {
690                        package_id: "dup 1.0.0".to_owned(),
691                    },
692                    ResolveNode {
693                        package_id: "dup 2.0.0".to_owned(),
694                    },
695                    ResolveNode {
696                        package_id: "file 1.0.0".to_owned(),
697                    },
698                ],
699            }),
700        };
701        let installer = Installer::from_metadata(root.to_path_buf(), metadata).unwrap();
702
703        assert_eq!(installer.root(), root);
704        assert_eq!(installer.context_path(), root.join(".agents/context"));
705        assert_eq!(
706            installer
707                .packages()
708                .iter()
709                .map(ContextPackage::selector)
710                .collect::<Vec<_>>(),
711            ["duplicate@1.0.0", "duplicate@2.0.0"]
712        );
713        assert!(installer.find_package("missing").unwrap().is_none());
714        assert!(
715            installer
716                .find_package("duplicate")
717                .unwrap_err()
718                .to_string()
719                .contains("duplicate@1.0.0, duplicate@2.0.0")
720        );
721        assert_eq!(
722            installer
723                .find_package("duplicate@2.0.0")
724                .unwrap()
725                .unwrap()
726                .version,
727            "2.0.0"
728        );
729    }
730
731    #[test]
732    fn supports_metadata_without_a_resolve_graph_and_skips_missing_or_malformed_paths() {
733        let directory = tempdir().unwrap();
734        let root = directory.path();
735        fs::create_dir_all(root.join("valid/context")).unwrap();
736        fs::create_dir(root.join("path-is-file")).unwrap();
737        fs::write(root.join("path-is-file/context"), "not a directory").unwrap();
738
739        let metadata = CargoMetadata {
740            workspace_members: Vec::new(),
741            packages: vec![
742                cargo_package("valid", "valid", "1.0.0", root.join("valid/Cargo.toml")),
743                cargo_package(
744                    "missing",
745                    "missing",
746                    "1.0.0",
747                    root.join("absent/Cargo.toml"),
748                ),
749                cargo_package(
750                    "file",
751                    "file",
752                    "1.0.0",
753                    root.join("path-is-file/Cargo.toml"),
754                ),
755                cargo_package("empty", "empty", "1.0.0", PathBuf::new()),
756            ],
757            resolve: None,
758        };
759        let installer = Installer::from_metadata(root.to_path_buf(), metadata).unwrap();
760        assert_eq!(installer.packages().len(), 1);
761        assert_eq!(installer.packages()[0].selector(), "valid");
762    }
763
764    #[cfg(unix)]
765    #[test]
766    fn reports_context_provider_path_inspection_errors() {
767        let directory = tempdir().unwrap();
768        let root = directory.path();
769        let blocker = root.join("blocker");
770        fs::write(&blocker, "file").unwrap();
771        let metadata = CargoMetadata {
772            workspace_members: Vec::new(),
773            packages: vec![cargo_package(
774                "broken",
775                "broken",
776                "1.0.0",
777                blocker.join("Cargo.toml"),
778            )],
779            resolve: None,
780        };
781        assert!(Installer::from_metadata(root.to_path_buf(), metadata).is_err());
782    }
783
784    #[test]
785    fn locates_context_files_safely_and_finds_markdown_extensions() {
786        let directory = tempdir().unwrap();
787        let context = directory.path().join("context");
788        fs::create_dir_all(context.join("nested")).unwrap();
789        fs::write(context.join("guide.md"), "guide").unwrap();
790        fs::write(context.join("nested/readme.MD"), "nested").unwrap();
791        fs::create_dir(context.join("directory")).unwrap();
792        fs::write(context.join("plain.txt"), "plain").unwrap();
793
794        assert_eq!(
795            find_context_file(&context, "guide").unwrap(),
796            Some(context.join("guide.md").canonicalize().unwrap())
797        );
798        assert!(
799            find_context_file(&context, "nested/readme.MD")
800                .unwrap()
801                .is_some()
802        );
803        assert_eq!(find_context_file(&context, "not-found").unwrap(), None);
804        assert_eq!(find_context_file(&context, "directory").unwrap(), None);
805        assert!(find_context_file(&context, "/etc/passwd").is_err());
806        assert!(find_context_file(&context, "./guide.md").is_err());
807        assert!(find_context_file(&context, "nested/../guide.md").is_err());
808        assert!(find_context_file(&context.join("missing"), "guide.md").is_err());
809        assert!(canonical_context_root(&context.join("does-not-exist")).is_err());
810        assert!(read_context_file(&context).is_err());
811
812        let markdown = markdown_files(&context).unwrap();
813        assert_eq!(markdown.len(), 2);
814        assert!(markdown[0] < markdown[1]);
815
816        let files_path = context.join("not-a-directory");
817        fs::write(&files_path, "file").unwrap();
818        assert!(markdown_files(&files_path).is_err());
819        assert!(collect_files(&files_path, &mut Vec::new()).is_err());
820    }
821
822    #[test]
823    fn hides_skill_files_assets_and_preserves_regular_context_files() {
824        let directory = tempdir().unwrap();
825        let root = directory.path();
826        let source = root.join("provider/context");
827        fs::create_dir_all(source.join("nested")).unwrap();
828        fs::create_dir_all(source.join("empty")).unwrap();
829        write(&source, "guide.md", "# Guide\n");
830        write(
831            &source,
832            "my-skill.md",
833            "---\ntype: skill\ndescription: A skill.\n---\n\n# Skill\n",
834        );
835        write(&source, "my-skill/reference.md", "asset\n");
836        write(&source, "nested/guide.md", "nested\n");
837        write(&source, "nested/my-skill.md", "ordinary nested context\n");
838        write(&source, "README.MD", "upper-case extension\n");
839        fs::write(source.join("plain.txt"), "text").unwrap();
840        #[cfg(unix)]
841        {
842            use std::os::unix::fs::symlink;
843            symlink(source.join("guide.md"), source.join("linked.md")).unwrap();
844        }
845
846        let package = package("provider", "1.0.0", source.clone());
847        let installer = installer(root, vec![package.clone()]);
848        let listed = installer.list_context_files(&package).unwrap();
849        let listed: Vec<_> = listed
850            .iter()
851            .map(|file| file.path.to_string_lossy().replace('\\', "/"))
852            .collect();
853        assert_eq!(
854            listed,
855            [
856                "README.MD",
857                "guide.md",
858                "nested/guide.md",
859                "nested/my-skill.md",
860                "plain.txt"
861            ]
862        );
863
864        assert!(!installer.install_package("missing").unwrap());
865        assert!(installer.install_package("provider@1.0.0").unwrap());
866        let installed = root.join(".agents/context/provider@1.0.0");
867        assert!(installed.join("guide.md").is_file());
868        assert!(installed.join("nested/my-skill.md").is_file());
869        assert!(!installed.join("my-skill.md").exists());
870        assert!(!installed.join("my-skill").exists());
871        assert!(!installed.join("empty").exists());
872    }
873
874    #[test]
875    fn removes_empty_installations_and_surfaces_destination_errors() {
876        let directory = tempdir().unwrap();
877        let root = directory.path();
878        let source = root.join("provider/context");
879        fs::create_dir_all(&source).unwrap();
880        write(
881            &source,
882            "skill.md",
883            "---\ntype: skill\ndescription: Skill.\n---\n\n# Skill\n",
884        );
885        fs::create_dir_all(source.join("skill/assets")).unwrap();
886        fs::write(source.join("skill/assets/image.png"), "asset").unwrap();
887        let installer = installer(root, vec![package("provider", "1.0.0", source)]);
888
889        assert!(!installer.install_package("provider@1.0.0").unwrap());
890        assert!(!root.join(".agents/context/provider@1.0.0").exists());
891
892        let blocker = root.join(".agents");
893        fs::remove_dir_all(&blocker).unwrap();
894        fs::write(&blocker, "not a directory").unwrap();
895        assert!(installer.install_package("provider@1.0.0").is_err());
896    }
897
898    #[test]
899    fn copies_context_tree_while_skipping_symlinks_and_reports_copy_errors() {
900        #[cfg(unix)]
901        use std::os::unix::fs::symlink;
902
903        let directory = tempdir().unwrap();
904        let root = directory.path();
905        let source = root.join("source");
906        fs::create_dir_all(source.join("empty")).unwrap();
907        fs::create_dir_all(source.join("nested")).unwrap();
908        fs::write(source.join("guide.md"), "guide").unwrap();
909        fs::write(source.join("skill.md"), "skill").unwrap();
910        fs::write(source.join("nested/guide.md"), "nested").unwrap();
911        #[cfg(unix)]
912        symlink(root.join("source/guide.md"), source.join("linked.md")).unwrap();
913        let destination = root.join("destination");
914        assert!(
915            copy_context_tree(
916                &source,
917                &destination,
918                &HashSet::from(["skill".to_owned()]),
919                true
920            )
921            .unwrap()
922        );
923        assert!(destination.join("guide.md").is_file());
924        assert!(destination.join("nested/guide.md").is_file());
925        assert!(!destination.join("skill.md").exists());
926        assert!(!destination.join("empty").exists());
927        assert!(!destination.join("linked.md").exists());
928
929        let non_directory = root.join("not-a-directory");
930        fs::write(&non_directory, "file").unwrap();
931        assert!(
932            copy_context_tree(&non_directory, &root.join("out"), &HashSet::new(), true).is_err()
933        );
934        let missing = root.join("missing");
935        assert!(copy_context_tree(&missing, &root.join("out"), &HashSet::new(), true).is_err());
936
937        let blocker = root.join("blocker");
938        fs::write(&blocker, "file").unwrap();
939        assert!(copy_context_tree(&source, &blocker.join("out"), &HashSet::new(), true).is_err());
940
941        let bad_destination = root.join("bad-copy");
942        fs::create_dir_all(bad_destination.join("guide.md")).unwrap();
943        assert!(copy_context_tree(&source, &bad_destination, &HashSet::new(), true).is_err());
944
945        let nonempty = root.join("nonempty");
946        fs::create_dir_all(&nonempty).unwrap();
947        fs::write(nonempty.join("child"), "file").unwrap();
948        assert!(remove_empty_context_directory(&nonempty).is_err());
949    }
950
951    #[cfg(unix)]
952    #[test]
953    fn reports_context_tree_directory_read_errors() {
954        use std::os::unix::fs::PermissionsExt;
955
956        let directory = tempdir().unwrap();
957        let source = directory.path().join("source");
958        fs::create_dir(&source).unwrap();
959        fs::set_permissions(&source, fs::Permissions::from_mode(0o000)).unwrap();
960
961        let result = copy_context_tree(
962            &source,
963            &directory.path().join("destination"),
964            &HashSet::new(),
965            true,
966        );
967        fs::set_permissions(&source, fs::Permissions::from_mode(0o755)).unwrap();
968
969        assert!(result.unwrap_err().to_string().contains("cannot read"));
970    }
971
972    #[test]
973    fn reports_directory_entry_type_and_recursive_read_errors() {
974        let directory = tempdir().unwrap();
975        let root = directory.path().join("context");
976        fs::create_dir_all(root.join("nested")).unwrap();
977        fs::write(root.join("guide.md"), "guide").unwrap();
978
979        let failure_path = root.clone();
980        let _failure = fs::fail_once(fs::Operation::ReadDirectoryEntry, move |path| {
981            path == failure_path
982        });
983        assert!(
984            markdown_files(&root)
985                .unwrap_err()
986                .to_string()
987                .contains("injected")
988        );
989        drop(_failure);
990
991        let file = root.join("guide.md");
992        let failure_path = file.clone();
993        let _failure = fs::fail_once(fs::Operation::FileType, move |path| path == failure_path);
994        assert!(
995            markdown_files(&root)
996                .unwrap_err()
997                .to_string()
998                .contains("injected")
999        );
1000        drop(_failure);
1001
1002        let failure_path = root.join("nested");
1003        let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| {
1004            path == failure_path
1005        });
1006        assert!(
1007            markdown_files(&root)
1008                .unwrap_err()
1009                .to_string()
1010                .contains("cannot read")
1011        );
1012        drop(_failure);
1013
1014        let failure_path = root.clone();
1015        let _failure = fs::fail_once(fs::Operation::ReadDirectoryEntry, move |path| {
1016            path == failure_path
1017        });
1018        assert!(collect_files(&root, &mut Vec::new()).is_err());
1019        drop(_failure);
1020
1021        let failure_path = file;
1022        let _failure = fs::fail_once(fs::Operation::FileType, move |path| path == failure_path);
1023        assert!(collect_files(&root, &mut Vec::new()).is_err());
1024        drop(_failure);
1025
1026        let failure_path = root.join("nested");
1027        let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| {
1028            path == failure_path
1029        });
1030        assert!(collect_files(&root, &mut Vec::new()).is_err());
1031    }
1032
1033    #[test]
1034    fn reports_copy_entry_type_recursive_and_empty_directory_errors() {
1035        let directory = tempdir().unwrap();
1036        let source = directory.path().join("source");
1037        fs::create_dir_all(source.join("nested")).unwrap();
1038        fs::create_dir(source.join("empty")).unwrap();
1039        fs::write(source.join("guide.md"), "guide").unwrap();
1040
1041        let failure_path = source.clone();
1042        let _failure = fs::fail_once(fs::Operation::ReadDirectoryEntry, move |path| {
1043            path == failure_path
1044        });
1045        assert!(
1046            copy_context_tree(
1047                &source,
1048                &directory.path().join("entry"),
1049                &HashSet::new(),
1050                true
1051            )
1052            .unwrap_err()
1053            .to_string()
1054            .contains("injected")
1055        );
1056        drop(_failure);
1057
1058        let failure_path = source.join("guide.md");
1059        let _failure = fs::fail_once(fs::Operation::FileType, move |path| path == failure_path);
1060        assert!(
1061            copy_context_tree(
1062                &source,
1063                &directory.path().join("type"),
1064                &HashSet::new(),
1065                true
1066            )
1067            .unwrap_err()
1068            .to_string()
1069            .contains("injected")
1070        );
1071        drop(_failure);
1072
1073        let failure_path = source.join("nested");
1074        let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| {
1075            path == failure_path
1076        });
1077        assert!(
1078            copy_context_tree(
1079                &source,
1080                &directory.path().join("recursive"),
1081                &HashSet::new(),
1082                true
1083            )
1084            .unwrap_err()
1085            .to_string()
1086            .contains("cannot read")
1087        );
1088        drop(_failure);
1089
1090        let empty_destination = directory.path().join("empty-destination/empty");
1091        let failure_path = empty_destination.clone();
1092        let _failure = fs::fail_once(fs::Operation::RemoveDirectory, move |path| {
1093            path == failure_path
1094        });
1095        assert!(
1096            copy_context_tree(
1097                &source,
1098                &directory.path().join("empty-destination"),
1099                &HashSet::new(),
1100                true
1101            )
1102            .unwrap_err()
1103            .to_string()
1104            .contains("cannot remove empty context directory")
1105        );
1106    }
1107
1108    #[test]
1109    fn propagates_package_operation_errors() {
1110        let directory = tempdir().unwrap();
1111        let root = directory.path();
1112        let source = root.join("provider/context");
1113        fs::create_dir_all(&source).unwrap();
1114        fs::write(source.join("guide.md"), "# Guide\n").unwrap();
1115        fs::write(source.join("binary.txt"), [0xff]).unwrap();
1116        let provider_package = package("provider", "1.0.0", source.clone());
1117        let provider_installer = installer(root, vec![provider_package.clone()]);
1118
1119        assert_eq!(
1120            provider_installer
1121                .show_context_file("provider", "guide.md")
1122                .unwrap(),
1123            Some("# Guide\n".to_owned())
1124        );
1125        assert!(
1126            provider_installer
1127                .show_context_file("provider", "binary.txt")
1128                .unwrap_err()
1129                .to_string()
1130                .contains("cannot read")
1131        );
1132
1133        let mut ambiguous = provider_package.clone();
1134        ambiguous.version = "2.0.0".to_owned();
1135        ambiguous.selector = "provider@2.0.0".to_owned();
1136        let ambiguous_installer = installer(root, vec![provider_package.clone(), ambiguous]);
1137        assert!(
1138            ambiguous_installer
1139                .show_context_file("provider", "guide.md")
1140                .unwrap_err()
1141                .to_string()
1142                .contains("multiple versions")
1143        );
1144
1145        let destination = root.join(".agents/context/provider@1.0.0");
1146        let failure_path = destination.clone();
1147        let _failure = fs::fail_once(fs::Operation::Inspect, move |path| path == failure_path);
1148        assert!(provider_installer.install_package("provider").is_err());
1149        drop(_failure);
1150
1151        let failure_path = source.join("guide.md");
1152        let _failure = fs::fail_once(fs::Operation::Copy, move |path| path == failure_path);
1153        assert!(provider_installer.install_package("provider").is_err());
1154        drop(_failure);
1155
1156        let skill_only = root.join("skills/context");
1157        fs::create_dir_all(skill_only.join("skill/assets")).unwrap();
1158        fs::write(
1159            skill_only.join("skill.md"),
1160            "---\ntype: skill\ndescription: Skill.\n---\n\n# Skill\n",
1161        )
1162        .unwrap();
1163        fs::write(skill_only.join("skill/assets/image.png"), "asset").unwrap();
1164        let skill_installer = installer(root, vec![package("skills", "1.0.0", skill_only)]);
1165        let empty_destination = root.join(".agents/context/skills@1.0.0");
1166        let failure_path = empty_destination.clone();
1167        let _failure = fs::fail_once(fs::Operation::RemoveDirectoryTree, move |path| {
1168            path == failure_path
1169        });
1170        assert!(skill_installer.install_package("skills").is_err());
1171        drop(_failure);
1172        assert!(skill_installer.install_all().unwrap().is_empty());
1173
1174        let bad = root.join("broken/context");
1175        fs::create_dir_all(&bad).unwrap();
1176        fs::write(bad.join("broken.md"), "---\ntype: guide\n---\n# Bad\n").unwrap();
1177        let broken_installer = installer(root, vec![package("broken", "1.0.0", bad)]);
1178        assert!(
1179            broken_installer
1180                .list_context_files(&broken_installer.packages[0])
1181                .is_err()
1182        );
1183        assert!(
1184            broken_installer
1185                .show_context_file("broken", "broken.md")
1186                .is_err()
1187        );
1188        assert!(broken_installer.install_package("broken").is_err());
1189        assert!(broken_installer.install_all().is_err());
1190
1191        let files_installer = installer(root, vec![provider_package]);
1192        let failure_path = source.clone();
1193        let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| {
1194            path == failure_path
1195        });
1196        assert!(
1197            files_installer
1198                .list_context_files(&files_installer.packages[0])
1199                .is_err()
1200        );
1201    }
1202
1203    #[cfg(unix)]
1204    #[test]
1205    fn reports_context_removal_errors() {
1206        use std::os::unix::fs::PermissionsExt;
1207
1208        let directory = tempdir().unwrap();
1209        let parent = directory.path().join("context");
1210        fs::create_dir(&parent).unwrap();
1211        let destination = parent.join("installed");
1212        fs::write(&destination, "previous context").unwrap();
1213        fs::set_permissions(&parent, fs::Permissions::from_mode(0o555)).unwrap();
1214
1215        let result = remove_existing(&destination);
1216        fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
1217
1218        assert!(result.unwrap_err().to_string().contains("cannot remove"));
1219    }
1220
1221    #[test]
1222    fn shows_only_regular_context_and_handles_missing_packages_and_files() {
1223        let directory = tempdir().unwrap();
1224        let root = directory.path();
1225        let source = root.join("provider/context");
1226        fs::create_dir_all(&source).unwrap();
1227        write(&source, "guide.md", "# Guide\n");
1228        write(
1229            &source,
1230            "skill.md",
1231            "---\ntype: skill\ndescription: A skill.\n---\n\n# Skill\n",
1232        );
1233        let package = package("provider", "1.0.0", source.clone());
1234        let installer = installer(root, vec![package]);
1235
1236        assert!(
1237            installer
1238                .show_context_file("missing", "guide.md")
1239                .unwrap()
1240                .is_none()
1241        );
1242        assert!(
1243            installer
1244                .show_context_file("provider@1.0.0", "missing.md")
1245                .unwrap()
1246                .is_none()
1247        );
1248        assert_eq!(
1249            installer
1250                .show_context_file("provider@1.0.0", "guide")
1251                .unwrap(),
1252            Some("# Guide\n".to_owned())
1253        );
1254        assert!(
1255            installer
1256                .show_context_file("provider@1.0.0", "skill")
1257                .unwrap()
1258                .is_none()
1259        );
1260    }
1261
1262    #[test]
1263    fn installs_all_context_packages_in_selector_order() {
1264        let directory = tempdir().unwrap();
1265        let root = directory.path();
1266        let zeta = root.join("zeta/context");
1267        let alpha = root.join("alpha/context");
1268        fs::create_dir_all(&zeta).unwrap();
1269        fs::create_dir_all(&alpha).unwrap();
1270        fs::write(zeta.join("guide.md"), "Zeta guide.\n").unwrap();
1271        fs::write(alpha.join("guide.md"), "Alpha guide.\n").unwrap();
1272
1273        let populated = installer(
1274            root,
1275            vec![
1276                package("alpha", "1.0.0", alpha),
1277                package("zeta", "1.0.0", zeta),
1278            ],
1279        );
1280        assert_eq!(
1281            populated.install_all().unwrap(),
1282            ["alpha@1.0.0", "zeta@1.0.0"]
1283        );
1284        assert!(root.join(".agents/context/alpha@1.0.0/guide.md").is_file());
1285        assert!(root.join(".agents/context/zeta@1.0.0/guide.md").is_file());
1286
1287        assert!(
1288            installer(root, Vec::new())
1289                .install_all()
1290                .unwrap()
1291                .is_empty()
1292        );
1293    }
1294
1295    #[cfg(unix)]
1296    #[test]
1297    fn rejects_context_file_symlinks_that_escape_the_provider() {
1298        use std::os::unix::fs::symlink;
1299
1300        let directory = tempdir().unwrap();
1301        let context = directory.path().join("context");
1302        fs::create_dir_all(&context).unwrap();
1303        let outside = directory.path().join("private.md");
1304        fs::write(&outside, "secret").unwrap();
1305        symlink(&outside, context.join("external.md")).unwrap();
1306
1307        assert_eq!(find_context_file(&context, "external.md").unwrap(), None);
1308    }
1309
1310    #[test]
1311    fn path_classification_and_removal_cover_files_directories_and_errors() {
1312        let names = HashSet::from(["skill".to_owned()]);
1313        assert!(is_skill_context_path(
1314            Path::new("skill/references/a.md"),
1315            &names
1316        ));
1317        assert!(is_skill_context_path(Path::new("skill.MD"), &names));
1318        assert!(!is_skill_context_path(Path::new("nested/skill.md"), &names));
1319        assert!(!is_skill_context_path(Path::new(""), &names));
1320        assert!(is_skill_markdown(Path::new("skill.MD"), &names));
1321        assert!(!is_skill_markdown(Path::new("skill.txt"), &names));
1322
1323        let directory = tempdir().unwrap();
1324        let root = directory.path();
1325        let absent = root.join("absent");
1326        remove_existing(&absent).unwrap();
1327        let file = root.join("file");
1328        fs::write(&file, "file").unwrap();
1329        remove_existing(&file).unwrap();
1330        let folder = root.join("folder");
1331        fs::create_dir(&folder).unwrap();
1332        remove_existing(&folder).unwrap();
1333        assert!(!folder.exists());
1334    }
1335
1336    #[cfg(unix)]
1337    #[test]
1338    fn reports_removal_errors_for_paths_beneath_files() {
1339        let directory = tempdir().unwrap();
1340        let blocker = directory.path().join("blocker");
1341        fs::write(&blocker, "file").unwrap();
1342        assert!(remove_existing(&blocker.join("child")).is_err());
1343    }
1344}