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