1use super::package::Package;
5use super::skill::SkillDefinition;
6#[cfg(test)]
7use super::test_filesystem as fs;
8use bake::{Error, Result};
9use serde::Deserialize;
10use std::collections::{HashMap, HashSet};
11use std::env;
12use std::ffi::{OsStr, OsString};
13#[cfg(not(test))]
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18#[derive(Clone, Debug)]
20pub struct Specification {
21 pub name: String,
22 pub version: String,
23 pub description: Option<String>,
24 pub context_path: PathBuf,
25 selector: String,
26}
27
28impl Specification {
29 pub fn selector(&self) -> &str {
31 &self.selector
32 }
33
34 #[cfg(test)]
35 pub(super) fn for_test(name: &str, version: &str, context_path: PathBuf) -> Self {
36 Self {
37 name: name.to_owned(),
38 version: version.to_owned(),
39 description: Some(format!("{name} documentation")),
40 context_path,
41 selector: format!("{name}@{version}"),
42 }
43 }
44}
45
46#[derive(Clone, Debug)]
48pub struct FileDefinition {
49 pub path: PathBuf,
51 pub source_path: PathBuf,
53 pub(crate) document: Option<super::document::Document>,
54}
55
56#[derive(Clone, Debug)]
58pub enum Definition {
59 File(FileDefinition),
60 Skill(SkillDefinition),
61}
62
63impl Definition {
64 pub fn display_name(&self) -> String {
66 match self {
67 Self::File(file) => file
68 .path
69 .to_string_lossy()
70 .replace(std::path::MAIN_SEPARATOR, "/"),
71 Self::Skill(skill) => format!(
72 "{} (skill)",
73 skill.path.file_stem().unwrap().to_string_lossy()
74 ),
75 }
76 }
77
78 pub fn path(&self) -> &Path {
80 match self {
81 Self::File(file) => &file.path,
82 Self::Skill(skill) => &skill.path,
83 }
84 }
85
86 pub fn content(&self) -> Result<String> {
88 match self {
89 Self::File(file) => file.content(),
90 Self::Skill(skill) => Ok(skill.content().to_owned()),
91 }
92 }
93
94 pub fn is_skill(&self) -> bool {
96 matches!(self, Self::Skill(_))
97 }
98}
99
100impl FileDefinition {
101 pub fn content(&self) -> Result<String> {
103 if let Some(document) = &self.document {
104 return Ok(document.content().to_owned());
105 }
106
107 read_context_file(&self.source_path)
108 }
109}
110
111#[derive(Debug)]
113pub struct Installation {
114 pub context: Vec<String>,
115 pub skills: Vec<String>,
116}
117
118#[derive(Clone, Debug)]
120pub struct Installer {
121 root: PathBuf,
122 context_path: PathBuf,
123 specifications: Vec<Specification>,
124}
125
126impl Installer {
127 pub fn discover(root: impl Into<PathBuf>) -> Result<Self> {
129 let root = root.into();
130 let cargo = cargo_executable(env::var_os("CARGO"));
131 Self::discover_with_cargo(root, cargo)
132 }
133
134 fn discover_with_cargo(root: PathBuf, cargo: OsString) -> Result<Self> {
135 let manifest = root.join("Cargo.toml");
136 let output = run_cargo_metadata(&root, &cargo)?;
137 if !output.status.success() {
138 return Err(cargo_metadata_failure(
139 &manifest,
140 output.status,
141 &output.stderr,
142 ));
143 }
144
145 let metadata = parse_metadata(&output.stdout)?;
146 Self::discover_from_metadata(root, metadata)
147 }
148
149 #[cfg(test)]
150 pub(super) fn for_test(root: &Path, specifications: Vec<Specification>) -> Self {
151 Self {
152 root: root.to_path_buf(),
153 context_path: root.join(".agents/context"),
154 specifications,
155 }
156 }
157
158 fn discover_from_metadata(root: PathBuf, metadata: CargoMetadata) -> Result<Self> {
159 let workspace_members: HashSet<_> = metadata.workspace_members.into_iter().collect();
160 let resolved_packages: HashSet<_> = metadata
161 .resolve
162 .map(|resolve| {
163 resolve
164 .nodes
165 .into_iter()
166 .map(|node| node.package_id)
167 .collect()
168 })
169 .unwrap_or_default();
170
171 let mut candidates = Vec::new();
172 for package in metadata.packages {
173 if workspace_members.contains(&package.package_id)
174 || (!resolved_packages.is_empty()
175 && !resolved_packages.contains(&package.package_id))
176 {
177 continue;
178 }
179
180 let Some(package_root) = package.manifest_path.parent() else {
181 continue;
182 };
183 let context_path = package_root.join("context");
184 match fs::symlink_metadata(&context_path) {
185 Ok(metadata) if metadata.file_type().is_dir() => {}
186 Ok(_) => continue,
187 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
188 Err(error) => {
189 return Err(Error::new(format!(
190 "cannot inspect {}: {error}",
191 context_path.display()
192 )));
193 }
194 };
195 candidates.push(Specification {
196 name: package.name,
197 version: package.version,
198 description: package.description,
199 context_path,
200 selector: String::new(),
201 });
202 }
203
204 let mut name_counts = HashMap::new();
205 for specification in &candidates {
206 *name_counts
207 .entry(specification.name.clone())
208 .or_insert(0usize) += 1;
209 }
210 for specification in &mut candidates {
211 specification.selector = if name_counts[&specification.name] == 1 {
212 specification.name.clone()
213 } else {
214 format!("{}@{}", specification.name, specification.version)
215 };
216 }
217 candidates.sort_by(|left, right| {
218 left.name
219 .cmp(&right.name)
220 .then_with(|| left.version.cmp(&right.version))
221 });
222
223 Ok(Self {
224 context_path: root.join(".agents/context"),
225 root,
226 specifications: candidates,
227 })
228 }
229
230 pub fn root(&self) -> &Path {
231 &self.root
232 }
233
234 pub fn context_path(&self) -> &Path {
235 &self.context_path
236 }
237
238 pub fn specifications(&self) -> &[Specification] {
239 &self.specifications
240 }
241
242 pub fn find_specification(&self, selector: &str) -> Result<Option<Specification>> {
243 let matches: Vec<_> = self
244 .specifications
245 .iter()
246 .filter(|specification| {
247 specification.selector == selector || specification.name == selector
248 })
249 .collect();
250
251 match matches.as_slice() {
252 [] => Ok(None),
253 [specification] => Ok(Some((*specification).clone())),
254 _ => {
255 let selectors = matches
256 .iter()
257 .map(|specification| specification.selector.as_str())
258 .collect::<Vec<_>>()
259 .join(", ");
260 Err(Error::new(format!(
261 "multiple versions of crate {selector:?} provide context; choose one of: {selectors}"
262 )))
263 }
264 }
265 }
266
267 pub fn providers(&self) -> Result<Vec<Package>> {
269 self.specifications
270 .iter()
271 .cloned()
272 .map(Package::load)
273 .collect()
274 }
275
276 pub fn find_provider(&self, selector: &str) -> Result<Option<Package>> {
278 self.find_specification(selector)?
279 .map(Package::load)
280 .transpose()
281 }
282
283 pub fn show_context_file(&self, selector: &str, file: &str) -> Result<Option<String>> {
285 let Some(package) = self.find_provider(selector)? else {
286 return Ok(None);
287 };
288 package
289 .find_definition(file)?
290 .map(Definition::content)
291 .transpose()
292 }
293
294 pub fn install(&self, selector: Option<&str>) -> Result<Installation> {
296 let loaded =
298 if let Some(selector) = selector {
299 vec![self.find_provider(selector)?.ok_or_else(|| {
300 Error::new(format!("no context found for crate {selector:?}"))
301 })?]
302 } else {
303 self.providers()?
304 };
305 let definitions: Vec<_> = loaded
306 .iter()
307 .flat_map(|package| package.definitions.iter())
308 .filter_map(|definition| match definition {
309 Definition::Skill(skill) => Some(skill.clone()),
310 Definition::File(_) => None,
311 })
312 .collect();
313 let package = selector
314 .and_then(|_| loaded.first())
315 .map(|package| package.specification.name.as_str());
316
317 let skills = super::skill::install_definitions(&self.root, &definitions, package, true)?;
319 let mut context_names = Vec::new();
320 for package in loaded {
321 if package.install_files(&self.context_path.join(package.specification.selector()))? {
322 context_names.push(package.specification.selector().to_owned());
323 }
324 }
325 Ok(Installation {
326 context: context_names,
327 skills,
328 })
329 }
330}
331
332fn run_cargo_metadata(root: &Path, cargo: &OsStr) -> Result<std::process::Output> {
333 let manifest = root.join("Cargo.toml");
334 let mut command = Command::new(cargo);
335 command
336 .args([
337 "metadata",
338 "--format-version",
339 "1",
340 "--locked",
341 "--manifest-path",
342 ])
343 .arg(&manifest)
344 .current_dir(root);
345
346 command.output().map_err(|error| {
347 Error::new(format!(
348 "cannot run cargo metadata for {}: {error}",
349 manifest.display()
350 ))
351 })
352}
353
354fn cargo_executable(cargo: Option<OsString>) -> OsString {
355 cargo.unwrap_or_else(|| "cargo".into())
356}
357
358fn cargo_metadata_failure(manifest: &Path, status: impl std::fmt::Display, stderr: &[u8]) -> Error {
359 let details = String::from_utf8_lossy(stderr).trim().to_owned();
360 Error::new(format!(
361 "cargo metadata failed for {} ({}): {}",
362 manifest.display(),
363 status,
364 if details.is_empty() {
365 "run cargo check to resolve and lock the project's dependencies".to_owned()
366 } else {
367 details
368 }
369 ))
370}
371
372fn parse_metadata(stdout: &[u8]) -> Result<CargoMetadata> {
373 serde_json::from_slice(stdout)
374 .map_err(|error| Error::new(format!("cannot parse cargo metadata: {error}")))
375}
376
377#[derive(Deserialize)]
378struct CargoMetadata {
379 workspace_members: Vec<String>,
380 packages: Vec<CargoPackage>,
381 resolve: Option<Resolve>,
382}
383
384#[derive(Deserialize)]
385struct Resolve {
386 nodes: Vec<ResolveNode>,
387}
388
389#[derive(Deserialize)]
390struct ResolveNode {
391 #[serde(rename = "id")]
392 package_id: String,
393}
394
395#[derive(Deserialize)]
396struct CargoPackage {
397 #[serde(rename = "id")]
398 package_id: String,
399 name: String,
400 version: String,
401 description: Option<String>,
402 manifest_path: PathBuf,
403}
404
405fn read_context_file(path: &Path) -> Result<String> {
406 fs::read_to_string(path)
407 .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))
408}
409
410pub(crate) fn remove_existing(path: &Path) -> Result<()> {
411 let metadata = match fs::symlink_metadata(path) {
412 Ok(metadata) => metadata,
413 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
414 Err(error) => {
415 return Err(Error::new(format!(
416 "cannot inspect {}: {error}",
417 path.display()
418 )));
419 }
420 };
421 let result = if metadata.file_type().is_dir() {
422 fs::remove_dir_all(path)
423 } else {
424 fs::remove_file(path)
425 };
426 result.map_err(|error| Error::new(format!("cannot remove {}: {error}", path.display())))
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use tempfile::tempdir;
433
434 fn cargo_package(id: &str, name: &str, version: &str, manifest_path: PathBuf) -> CargoPackage {
435 CargoPackage {
436 package_id: id.to_owned(),
437 name: name.to_owned(),
438 version: version.to_owned(),
439 description: Some(format!("{name} description")),
440 manifest_path,
441 }
442 }
443
444 #[test]
445 fn reports_cargo_execution_status_and_json_errors() {
446 let directory = tempdir().unwrap();
447 let error = Installer::discover(directory.path()).err().unwrap();
448 assert!(error.to_string().contains("cargo metadata failed"));
449
450 let error = Installer::discover_with_cargo(
451 directory.path().to_path_buf(),
452 directory.path().join("missing-cargo").into_os_string(),
453 )
454 .unwrap_err();
455 assert!(error.to_string().contains("cannot run cargo metadata"));
456
457 assert_eq!(cargo_executable(None), OsString::from("cargo"));
458 assert_eq!(
459 cargo_executable(Some(OsString::from("custom-cargo"))),
460 OsString::from("custom-cargo")
461 );
462
463 let error = cargo_metadata_failure(
464 &directory.path().join("Cargo.toml"),
465 "exit status: 1",
466 b" cargo stderr \n",
467 );
468 assert!(error.to_string().ends_with("cargo stderr"));
469 let error = cargo_metadata_failure(&PathBuf::from("Cargo.toml"), "exit status: 1", b"\n");
470 assert!(error.to_string().contains("run cargo check to resolve"));
471
472 let error = parse_metadata(b"not JSON").err().unwrap();
473 assert!(error.to_string().contains("cannot parse cargo metadata"));
474 }
475
476 #[cfg(unix)]
477 #[test]
478 fn reports_metadata_parse_errors_from_cargo() {
479 let directory = tempdir().unwrap();
480 let error =
481 Installer::discover_with_cargo(directory.path().to_path_buf(), "/usr/bin/true".into())
482 .unwrap_err();
483 assert!(error.to_string().contains("cannot parse cargo metadata"));
484 }
485
486 #[test]
487 fn discovers_only_resolved_non_workspace_context_packages_and_disambiguates_versions() {
488 let directory = tempdir().unwrap();
489 let root = directory.path();
490 for path in [
491 "workspace",
492 "duplicate-v1",
493 "duplicate-v2",
494 "missing",
495 "file",
496 ] {
497 fs::create_dir_all(root.join(path)).unwrap();
498 }
499 fs::create_dir(root.join("workspace/context")).unwrap();
500 fs::create_dir(root.join("duplicate-v1/context")).unwrap();
501 fs::create_dir(root.join("duplicate-v2/context")).unwrap();
502 fs::write(root.join("file/context"), "not a directory").unwrap();
503
504 let packages = vec![
505 cargo_package(
506 "root 0.1.0",
507 "workspace",
508 "0.1.0",
509 root.join("workspace/Cargo.toml"),
510 ),
511 cargo_package(
512 "dup 1.0.0",
513 "duplicate",
514 "1.0.0",
515 root.join("duplicate-v1/Cargo.toml"),
516 ),
517 cargo_package(
518 "dup 2.0.0",
519 "duplicate",
520 "2.0.0",
521 root.join("duplicate-v2/Cargo.toml"),
522 ),
523 cargo_package(
524 "unresolved 1.0.0",
525 "unresolved",
526 "1.0.0",
527 root.join("missing/Cargo.toml"),
528 ),
529 cargo_package("file 1.0.0", "file", "1.0.0", root.join("file/Cargo.toml")),
530 ];
531 let metadata = CargoMetadata {
532 workspace_members: vec!["root 0.1.0".to_owned()],
533 packages,
534 resolve: Some(Resolve {
535 nodes: vec![
536 ResolveNode {
537 package_id: "root 0.1.0".to_owned(),
538 },
539 ResolveNode {
540 package_id: "dup 1.0.0".to_owned(),
541 },
542 ResolveNode {
543 package_id: "dup 2.0.0".to_owned(),
544 },
545 ResolveNode {
546 package_id: "file 1.0.0".to_owned(),
547 },
548 ],
549 }),
550 };
551 let installer = Installer::discover_from_metadata(root.to_path_buf(), metadata).unwrap();
552
553 assert_eq!(installer.root(), root);
554 assert_eq!(installer.context_path(), root.join(".agents/context"));
555 assert_eq!(
556 installer
557 .specifications()
558 .iter()
559 .map(Specification::selector)
560 .collect::<Vec<_>>(),
561 ["duplicate@1.0.0", "duplicate@2.0.0"]
562 );
563 assert!(installer.find_specification("missing").unwrap().is_none());
564 assert!(
565 installer
566 .find_specification("duplicate")
567 .unwrap_err()
568 .to_string()
569 .contains("duplicate@1.0.0, duplicate@2.0.0")
570 );
571 assert_eq!(
572 installer
573 .find_specification("duplicate@2.0.0")
574 .unwrap()
575 .unwrap()
576 .version,
577 "2.0.0"
578 );
579 }
580
581 #[test]
582 fn supports_metadata_without_a_resolve_graph_and_skips_missing_or_malformed_paths() {
583 let directory = tempdir().unwrap();
584 let root = directory.path();
585 fs::create_dir_all(root.join("valid/context")).unwrap();
586 fs::create_dir(root.join("path-is-file")).unwrap();
587 fs::write(root.join("path-is-file/context"), "not a directory").unwrap();
588
589 let metadata = CargoMetadata {
590 workspace_members: Vec::new(),
591 packages: vec![
592 cargo_package("valid", "valid", "1.0.0", root.join("valid/Cargo.toml")),
593 cargo_package(
594 "missing",
595 "missing",
596 "1.0.0",
597 root.join("absent/Cargo.toml"),
598 ),
599 cargo_package(
600 "file",
601 "file",
602 "1.0.0",
603 root.join("path-is-file/Cargo.toml"),
604 ),
605 cargo_package("empty", "empty", "1.0.0", PathBuf::new()),
606 ],
607 resolve: None,
608 };
609 let installer = Installer::discover_from_metadata(root.to_path_buf(), metadata).unwrap();
610 assert_eq!(installer.specifications().len(), 1);
611 assert_eq!(installer.specifications()[0].selector(), "valid");
612 }
613
614 #[cfg(unix)]
615 #[test]
616 fn reports_context_provider_path_inspection_errors() {
617 let directory = tempdir().unwrap();
618 let root = directory.path();
619 let blocker = root.join("blocker");
620 fs::write(&blocker, "file").unwrap();
621 let metadata = CargoMetadata {
622 workspace_members: Vec::new(),
623 packages: vec![cargo_package(
624 "broken",
625 "broken",
626 "1.0.0",
627 blocker.join("Cargo.toml"),
628 )],
629 resolve: None,
630 };
631 assert!(Installer::discover_from_metadata(root.to_path_buf(), metadata).is_err());
632 }
633
634 #[test]
635 fn finds_loaded_source_paths_before_skill_names() {
636 let directory = tempdir().unwrap();
637 let context = directory.path().join("context");
638 fs::create_dir_all(context.join("nested")).unwrap();
639 fs::write(context.join("guide.md"), "guide").unwrap();
640 let source =
641 "---\nname: guide\nmetadata: {type: skill}\ndescription: Workflow.\n---\n# Workflow\n";
642 fs::write(context.join("workflow.md"), source).unwrap();
643 fs::write(context.join("nested/readme.MD"), "nested").unwrap();
644 let specification = Specification::for_test("provider", "1.0.0", context.clone());
645 let installer = Installer::for_test(directory.path(), vec![specification]);
646 let package = installer.find_provider("provider").unwrap().unwrap();
647 assert_eq!(
648 package
649 .find_definition("guide")
650 .unwrap()
651 .unwrap()
652 .content()
653 .unwrap(),
654 "guide"
655 );
656 assert_eq!(
657 package
658 .find_definition("guide.md")
659 .unwrap()
660 .unwrap()
661 .content()
662 .unwrap(),
663 "guide"
664 );
665 assert_eq!(
666 package
667 .find_definition("workflow")
668 .unwrap()
669 .unwrap()
670 .content()
671 .unwrap(),
672 source
673 );
674 assert_eq!(
675 package
676 .find_definition("nested/readme.MD")
677 .unwrap()
678 .unwrap()
679 .content()
680 .unwrap(),
681 "nested"
682 );
683 assert!(package.find_definition("not-found").unwrap().is_none());
684 for path in ["", "/etc/passwd", "./guide.md", "nested/../guide.md"] {
685 assert!(package.find_definition(path).is_err());
686 }
687
688 fs::remove_file(context.join("guide.md")).unwrap();
690 assert_eq!(
691 package
692 .find_definition("guide")
693 .unwrap()
694 .unwrap()
695 .content()
696 .unwrap(),
697 "guide"
698 );
699 assert_eq!(
700 installer
701 .show_context_file("provider", "guide")
702 .unwrap()
703 .as_deref(),
704 Some(source)
705 );
706 fs::write(context.join("guide"), "exact source").unwrap();
707 fs::write(context.join("guide.md"), "markdown source").unwrap();
708 assert_eq!(
709 installer
710 .show_context_file("provider", "guide")
711 .unwrap()
712 .as_deref(),
713 Some("exact source")
714 );
715 assert!(installer.find_provider("missing").unwrap().is_none());
716 assert!(read_context_file(&context).is_err());
717 }
718
719 #[cfg(unix)]
720 #[test]
721 fn reports_context_removal_errors() {
722 use std::os::unix::fs::PermissionsExt;
723
724 let directory = tempdir().unwrap();
725 let parent = directory.path().join("context");
726 fs::create_dir(&parent).unwrap();
727 let destination = parent.join("installed");
728 fs::write(&destination, "previous context").unwrap();
729 fs::set_permissions(&parent, fs::Permissions::from_mode(0o555)).unwrap();
730
731 let result = remove_existing(&destination);
732 fs::set_permissions(&parent, fs::Permissions::from_mode(0o755)).unwrap();
733
734 assert!(result.unwrap_err().to_string().contains("cannot remove"));
735 }
736
737 #[cfg(unix)]
738 #[test]
739 fn rejects_context_file_symlinks_that_escape_the_provider() {
740 use std::os::unix::fs::symlink;
741
742 let directory = tempdir().unwrap();
743 let context = directory.path().join("context");
744 fs::create_dir_all(&context).unwrap();
745 let outside = directory.path().join("private.md");
746 fs::write(&outside, "secret").unwrap();
747 symlink(&outside, context.join("external.md")).unwrap();
748
749 let package = Package::load(Specification::for_test("provider", "1.0.0", context)).unwrap();
750 assert!(package.find_definition("external.md").unwrap().is_none());
751 }
752
753 #[cfg(unix)]
754 #[test]
755 fn reports_removal_errors_for_paths_beneath_files() {
756 let directory = tempdir().unwrap();
757 let blocker = directory.path().join("blocker");
758 fs::write(&blocker, "file").unwrap();
759 assert!(remove_existing(&blocker.join("child")).is_err());
760 }
761 #[test]
762 fn coordinates_ordinary_files_skills_and_empty_providers() {
763 let directory = tempdir().unwrap();
764 let root = directory.path();
765 let context = root.join("provider/context");
766 fs::create_dir_all(&context).unwrap();
767 fs::write(context.join("guide.md"), "# Guide\n").unwrap();
768 fs::write(context.join("data.txt"), "Opaque resource").unwrap();
769 fs::write(
770 context.join("workflow.md"),
771 "---\nmetadata: {type: skill}\ndescription: Workflow.\n---\n",
772 )
773 .unwrap();
774 let package = Specification::for_test("provider", "1.0.0", context.clone());
775 let installer = Installer::for_test(root, vec![package.clone()]);
776 assert_eq!(
777 installer
778 .show_context_file("provider", "data.txt")
779 .unwrap()
780 .as_deref(),
781 Some("Opaque resource")
782 );
783 assert!(
784 installer
785 .show_context_file("provider", "provider-workflow")
786 .unwrap()
787 .unwrap()
788 .contains("Workflow.")
789 );
790 assert!(
791 installer
792 .install(Some("missing"))
793 .unwrap_err()
794 .to_string()
795 .contains("no context found")
796 );
797 let ambiguous = Installer::for_test(root, vec![package.clone(), package]);
798 assert!(ambiguous.install(Some("provider")).is_err());
799 let loaded = installer.install(None).unwrap();
800 assert_eq!(loaded.context, ["provider@1.0.0"]);
801 assert_eq!(loaded.skills, ["provider-workflow"]);
802 fs::remove_file(context.join("guide.md")).unwrap();
803 fs::remove_file(context.join("data.txt")).unwrap();
804 assert!(installer.install(None).unwrap().context.is_empty());
805 assert!(installer.install(None).unwrap().context.is_empty());
806 fs::write(context.join("broken.md"), "---\n[invalid\n---\n").unwrap();
807 assert!(
808 installer
809 .show_context_file("provider", "provider-workflow")
810 .is_err()
811 );
812 assert!(installer.install(Some("provider")).is_err());
813 }
814
815 #[test]
816 fn reports_ordinary_context_write_failures_from_the_main_installation() {
817 let directory = tempdir().unwrap();
818 let root = directory.path();
819 let context = root.join("provider/context");
820 fs::create_dir_all(&context).unwrap();
821 fs::write(context.join("guide.md"), "# Guide\n").unwrap();
822 let package = Specification::for_test("provider", "1.0.0", context);
823 let installer = Installer::for_test(root, vec![package]);
824 let destination = root.join(".agents/context/provider@1.0.0/guide.md");
825 let _failure = fs::fail_once(fs::Operation::Write, move |path| path == destination);
826 assert!(
827 installer
828 .install(None)
829 .unwrap_err()
830 .to_string()
831 .contains("cannot write")
832 );
833 }
834}