1#[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#[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 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#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ContextFile {
47 pub path: PathBuf,
48}
49
50#[derive(Clone, Debug)]
52pub struct Installer {
53 root: PathBuf,
54 context_path: PathBuf,
55 packages: Vec<ContextPackage>,
56}
57
58impl Installer {
59 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 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 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}