Skip to main content

bake_agent_context/agent/context/
installer.rs

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