diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6eba49a..1beaf9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,5 +48,5 @@ jobs: run: cargo install cargo-llvm-cov --locked - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets --locked -- -D warnings - - name: Run tests and require complete line coverage + - name: Run tests and require complete source-region coverage run: cargo bake --locked test:coverage --all-targets true diff --git a/Cargo.lock b/Cargo.lock index 936cd51..6a96ce8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,13 +31,13 @@ dependencies = [ [[package]] name = "bake-agent-context" -version = "0.3.0" +version = "0.3.1" dependencies = [ "bake", "serde", "serde_json", "serde_yaml_ng", - "socketry-markdown", + "socketry-markdown 0.2.0", "tempfile", ] @@ -65,7 +65,7 @@ dependencies = [ "bake-releases", "serde", "serde_json", - "socketry-markdown", + "socketry-markdown 0.1.1", "tempfile", "toml_edit", "ureq", @@ -78,7 +78,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4877b435fe237311d88e546e8fb2b4a9021aff3550b625b8286e27da0ec693fd" dependencies = [ "bake", - "socketry-markdown", + "socketry-markdown 0.1.1", "tempfile", ] @@ -101,7 +101,7 @@ checksum = "24fe3770977781484405d216b11a614a3e26404bb932bdddde718fad206f35c0" dependencies = [ "bake", "serde_json", - "socketry-markdown", + "socketry-markdown 0.1.1", "tempfile", ] @@ -112,15 +112,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8439a25fe3a3192a666570d3d907cfb5bff8fdb5df17c92e495bcd4132ce0283" dependencies = [ "bake", - "socketry-markdown", + "socketry-markdown 0.1.1", "tempfile", ] [[package]] name = "bake-test-rust" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d29e665d8e2373f5931adebe8ee29d36fd2a59c36c3d755a7d408d7eaed20dc" +checksum = "e9d5ceec9cc08a3bb421dd3755ad9e073831934793aa88d8f55095ad98c1dc45" dependencies = [ "bake", "serde_json", @@ -649,9 +649,19 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socketry-markdown" -version = "0.1.0" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5713ab1be01960a2cdbb85d6b696063b406300bc5a876a013e23d6e5bf94b19" +dependencies = [ + "regex", + "unicode-id", +] + +[[package]] +name = "socketry-markdown" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6b36f84563d391bc2e67e1c8aef731a773acafd4bf4a9c97886f3771133610" +checksum = "37d3a646262edeca60e86e4c4dd493167e74beb03ea75a60b2d5c40e6d6d8c5b" dependencies = [ "regex", "unicode-id", @@ -659,9 +669,9 @@ dependencies = [ [[package]] name = "socketry-project" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5c5a54ff8afc1eb90dfff374dad7550205d28956993b32c1bb7ae49776e1d3c" +checksum = "d5f3a4391b29d2fcfe7522e3fdf651aaff9fd3de6c530a90412060b6bd1ad4e4" dependencies = [ "bake", "bake-agent-context", diff --git a/Cargo.toml b/Cargo.toml index 0d12336..c37750c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bake-agent-context" -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT" repository = "https://github.com/socketry/bake-agent-context-rust" @@ -26,7 +26,7 @@ bake = "0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml_ng = "0.10" -socketry-markdown = "0.1.0" +socketry-markdown = "0.2.0" [patch.crates-io] bake-agent-context = { path = "." } diff --git a/bake/Cargo.toml b/bake/Cargo.toml index 4e885d8..18137fc 100644 --- a/bake/Cargo.toml +++ b/bake/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] socketry-project = ">=0.3.3" bake = "0" -bake-agent-context = { version = "0.3.0", path = ".." } +bake-agent-context = { version = "0.3.1", path = ".." } bake-cargo = { version = "0.4.0" } bake-license = { version = "0.2.0" } bake-releases = { version = "0.3.0" } diff --git a/bake/src/bake_generated_tasks/mod.rs b/bake/src/bake_generated_tasks/mod.rs index c3fbd4d..9cefc20 100644 --- a/bake/src/bake_generated_tasks/mod.rs +++ b/bake/src/bake_generated_tasks/mod.rs @@ -1,5 +1,7 @@ -// Generated by `cargo bake --regenerate`; do not edit. +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. +// Generated by `cargo bake --regenerate`; do not edit. use bake_agent_context as _; use bake_cargo as _; use bake_license as _; diff --git a/readme.md b/readme.md index 11bff2a..4667f9e 100644 --- a/readme.md +++ b/readme.md @@ -81,6 +81,11 @@ for the standard process. See [releases.md](releases.md) for the full release history. +### v0.3.1 + +- Require `socketry-markdown` 0.2.0 or newer for safe inline Markdown + serialization. + ### v0.3.0 - Expose the context API and Bake task adapters from the crate root, removing @@ -91,11 +96,6 @@ See [releases.md](releases.md) for the full release history. - Declare compatibility with the Bake 0.x API so task libraries can share one task registry when upgrading to crate-derived task namespaces. - -### v0.2.1 - -- Use the shared `socketry-project` Releasing skill for the standard release - process and remove references to the duplicate Bake Cargo publishing context. ## Contributing diff --git a/releases.md b/releases.md index a71798e..dac1768 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,10 @@ # Releases +## v0.3.1 + +- Require `socketry-markdown` 0.2.0 or newer for safe inline Markdown + serialization. + ## v0.3.0 - Expose the context API and Bake task adapters from the crate root, removing diff --git a/src/exclude.rs b/src/exclude.rs index d8a4490..dcbec9e 100644 --- a/src/exclude.rs +++ b/src/exclude.rs @@ -1,9 +1,12 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. +#[cfg(test)] +use super::test_filesystem as fs; use bake::{Error, Result}; use std::collections::BTreeSet; use std::ffi::OsStr; +#[cfg(not(test))] use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -42,7 +45,33 @@ pub(crate) fn prepare( root: &Path, skill_names: impl IntoIterator, ) -> Result> { - let Some(path) = local_exclude_path(root)? else { + prepare_with_locator(root, skill_names, local_exclude_path) +} + +#[cfg(all(test, unix))] +fn prepare_with_executable( + root: &Path, + skill_names: impl IntoIterator, + executable: &OsStr, +) -> Result> { + prepare_with_locator(root, skill_names, |root| { + local_exclude_path_with(root, executable) + }) +} + +fn prepare_with_locator( + root: &Path, + skill_names: impl IntoIterator, + locate: impl FnOnce(&Path) -> Result>, +) -> Result> { + prepare_from_path(locate(root)?, skill_names) +} + +fn prepare_from_path( + path: Option, + skill_names: impl IntoIterator, +) -> Result> { + let Some(path) = path else { return Ok(None); }; @@ -315,6 +344,41 @@ mod tests { assert!(error.to_string().contains("cannot read")); } + #[test] + fn propagates_git_lookup_and_managed_block_errors_from_prepare() { + let directory = tempdir().unwrap(); + #[cfg(unix)] + { + let executable = directory.path().join("not-executable"); + fs::write(&executable, "not a command").unwrap(); + let error = prepare_with_executable( + directory.path(), + Vec::::new(), + executable.as_os_str(), + ) + .err() + .unwrap(); + assert!(error.to_string().contains("cannot locate Git exclude file")); + } + + let error = prepare_with_locator(directory.path(), Vec::::new(), |_| { + Err(Error::new("injected Git lookup failure")) + }) + .err() + .unwrap(); + assert!(error.to_string().contains("injected Git lookup failure")); + + let repository = tempdir().unwrap(); + let output = Command::new("git") + .args(["init", "--quiet"]) + .current_dir(repository.path()) + .output() + .unwrap(); + assert!(output.status.success()); + fs::write(repository.path().join(".git/info/exclude"), BEGIN_MARKER).unwrap(); + assert!(prepare(repository.path(), Vec::::new()).is_err()); + } + #[cfg(unix)] #[test] fn handles_git_command_start_errors() { diff --git a/src/index.rs b/src/index.rs index 5263062..7f4f790 100644 --- a/src/index.rs +++ b/src/index.rs @@ -3,6 +3,8 @@ use super::installer::{ContextPackage, markdown_files}; use super::skill::frontmatter_description; +#[cfg(test)] +use super::test_filesystem as fs; use bake::{Error, Result}; use socketry_markdown::{ ParseOptions, @@ -10,6 +12,7 @@ use socketry_markdown::{ to_mdast, }; use std::collections::HashMap; +#[cfg(not(test))] use std::fs; use std::path::{Path, PathBuf}; @@ -143,7 +146,9 @@ impl ContextIndex { let mut files = Vec::new(); for path in markdown_files(&package_path)? { let (title, description) = extract_content(&path)?; - let relative_path = relative_context_path(&package_path, &path)?; + let relative_path = path + .strip_prefix(&package_path) + .expect("markdown_files only returns paths within its requested directory"); files.push(ContextDocument { path: relative_path.to_path_buf(), title, @@ -165,12 +170,6 @@ fn create_context_directory(path: &Path) -> Result<()> { .map_err(|error| Error::new(format!("cannot create {}: {error}", path.display()))) } -fn relative_context_path(package_path: &Path, path: &Path) -> Result { - path.strip_prefix(package_path) - .map(Path::to_path_buf) - .map_err(|error| Error::new(format!("cannot make context path relative: {error}"))) -} - fn append_document( sections: &mut Vec, link_root: &Path, @@ -448,6 +447,100 @@ mod tests { assert!(ContextIndex::new(directory.path()).update_index().is_err()); } + #[test] + fn propagates_index_and_exclude_write_errors() { + let directory = tempdir().unwrap(); + let context_path = directory.path().join(".agents/context"); + let failure_path = context_path.clone(); + let _failure = fs::fail_once(fs::Operation::CreateDirectoryTree, move |path| { + path == failure_path + }); + assert!(ContextIndex::new(directory.path()).update_index().is_err()); + drop(_failure); + + let directory = tempdir().unwrap(); + let index_path = directory.path().join(".agents/context/index.md"); + let failure_path = index_path.clone(); + let _failure = fs::fail_once(fs::Operation::Write, move |path| path == failure_path); + assert!(ContextIndex::new(directory.path()).update_index().is_err()); + drop(_failure); + + let directory = tempdir().unwrap(); + let output = std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(directory.path()) + .output() + .unwrap(); + assert!(output.status.success()); + fs::write( + directory.path().join(".git/info/exclude"), + "# BEGIN bake-agent-context", + ) + .unwrap(); + assert!(ContextIndex::new(directory.path()).update_index().is_err()); + + let directory = tempdir().unwrap(); + let output = std::process::Command::new("git") + .args(["init", "--quiet"]) + .current_dir(directory.path()) + .output() + .unwrap(); + assert!(output.status.success()); + let exclude_path = directory.path().join(".git/info/exclude"); + let failure_path = exclude_path.clone(); + let _failure = fs::fail_once(fs::Operation::Write, move |path| path == failure_path); + assert!(ContextIndex::new(directory.path()).update_index().is_err()); + } + + #[test] + fn propagates_context_entry_read_and_markdown_errors() { + let directory = tempdir().unwrap(); + let context_path = directory.path().join(".agents/context"); + fs::create_dir_all(&context_path).unwrap(); + let failure_path = context_path.clone(); + let _failure = fs::fail_once(fs::Operation::ReadDirectoryEntry, move |path| { + path == failure_path + }); + assert!( + ContextIndex::new(directory.path()) + .generate_index() + .is_err() + ); + drop(_failure); + + let package_path = context_path.join("provider"); + fs::create_dir_all(&package_path).unwrap(); + let failure_path = package_path.clone(); + let _failure = fs::fail_once(fs::Operation::FileType, move |path| path == failure_path); + assert!( + ContextIndex::new(directory.path()) + .generate_index() + .is_err() + ); + drop(_failure); + + let guide = package_path.join("guide.md"); + fs::write(&guide, "# Guide\n").unwrap(); + let failure_path = package_path.clone(); + let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| { + path == failure_path + }); + assert!( + ContextIndex::new(directory.path()) + .generate_index() + .is_err() + ); + drop(_failure); + + let failure_path = guide.clone(); + let _failure = fs::fail_once(fs::Operation::Read, move |path| path == failure_path); + assert!( + ContextIndex::new(directory.path()) + .generate_index() + .is_err() + ); + } + #[test] fn updates_local_git_excludes_with_the_generated_index() { let directory = tempdir().unwrap(); @@ -520,15 +613,4 @@ mod tests { assert_eq!(heading_text(&heading), expected); } } - - #[test] - fn reports_relative_path_invariant_violations() { - let directory = tempdir().unwrap(); - let package = directory.path().join("provider"); - assert!(relative_context_path(&package, &directory.path().join("outside.md")).is_err()); - assert_eq!( - relative_context_path(&package, &package.join("guide.md")).unwrap(), - PathBuf::from("guide.md") - ); - } } diff --git a/src/installer.rs b/src/installer.rs index 20f7a8c..9376d75 100644 --- a/src/installer.rs +++ b/src/installer.rs @@ -1,11 +1,14 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. +#[cfg(test)] +use super::test_filesystem as fs; use bake::{Error, Result}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; use std::env; use std::ffi::{OsStr, OsString}; +#[cfg(not(test))] use std::fs; use std::path::{Component, Path, PathBuf}; use std::process::Command; @@ -190,13 +193,14 @@ impl Installer { } pub fn list_context_files(&self, package: &ContextPackage) -> Result> { + let mut files = Vec::new(); + collect_files(&package.context_path, &mut files)?; + files.sort(); + let skill_names: HashSet<_> = super::skill::list_package_skills(package)? .into_iter() .map(|skill| skill.source_name) .collect(); - let mut files = Vec::new(); - collect_files(&package.context_path, &mut files)?; - files.sort(); Ok(files .into_iter() .filter_map(|file| { @@ -214,7 +218,8 @@ impl Installer { let Some(package) = self.find_package(selector)? else { return Ok(None); }; - let Some(path) = find_context_file(&package.context_path, file)? else { + let Some((context_root, path)) = find_context_file_with_root(&package.context_path, file)? + else { return Ok(None); }; @@ -222,8 +227,9 @@ impl Installer { .into_iter() .map(|skill| skill.source_name) .collect(); - let context_root = canonical_context_root(&package.context_path)?; - let relative_path = relative_context_path(&path, &context_root)?; + let relative_path = path + .strip_prefix(&context_root) + .expect("find_context_file only returns paths within context_root"); if is_skill_context_path(relative_path, &skill_names) { return Ok(None); } @@ -339,7 +345,15 @@ struct CargoPackage { manifest_path: PathBuf, } +#[cfg(test)] fn find_context_file(context_path: &Path, file: &str) -> Result> { + Ok(find_context_file_with_root(context_path, file)?.map(|(_, path)| path)) +} + +fn find_context_file_with_root( + context_path: &Path, + file: &str, +) -> Result> { let requested = Path::new(file); if requested.is_absolute() || requested @@ -351,6 +365,7 @@ fn find_context_file(context_path: &Path, file: &str) -> Result> )); } + let canonical_root = canonical_context_root(context_path)?; let mut candidates = vec![context_path.join(requested)]; if requested.extension().is_none() { candidates.push(context_path.join(requested).with_extension("md")); @@ -360,11 +375,10 @@ fn find_context_file(context_path: &Path, file: &str) -> Result> let Ok(canonical_candidate) = candidate.canonicalize() else { continue; }; - let canonical_root = canonical_context_root(context_path)?; if !canonical_candidate.starts_with(&canonical_root) || !canonical_candidate.is_file() { continue; } - return Ok(Some(canonical_candidate)); + return Ok(Some((canonical_root, canonical_candidate))); } Ok(None) @@ -379,11 +393,6 @@ fn canonical_context_root(context_path: &Path) -> Result { }) } -fn relative_context_path<'a>(path: &'a Path, root: &Path) -> Result<&'a Path> { - path.strip_prefix(root) - .map_err(|error| Error::new(format!("cannot make context path relative: {error}"))) -} - fn read_context_file(path: &Path) -> Result> { fs::read_to_string(path) .map(Some) @@ -614,6 +623,16 @@ mod tests { assert!(error.to_string().contains("cannot parse cargo metadata")); } + #[cfg(unix)] + #[test] + fn reports_metadata_parse_errors_from_cargo() { + let directory = tempdir().unwrap(); + let error = + Installer::new_with_cargo(directory.path().to_path_buf(), "/usr/bin/true".into()) + .unwrap_err(); + assert!(error.to_string().contains("cannot parse cargo metadata")); + } + #[test] fn discovers_only_resolved_non_workspace_context_packages_and_disambiguates_versions() { let directory = tempdir().unwrap(); @@ -786,8 +805,8 @@ mod tests { assert!(find_context_file(&context, "/etc/passwd").is_err()); assert!(find_context_file(&context, "./guide.md").is_err()); assert!(find_context_file(&context, "nested/../guide.md").is_err()); + assert!(find_context_file(&context.join("missing"), "guide.md").is_err()); assert!(canonical_context_root(&context.join("does-not-exist")).is_err()); - assert!(relative_context_path(&directory.path().join("outside"), &context).is_err()); assert!(read_context_file(&context).is_err()); let markdown = markdown_files(&context).unwrap(); @@ -818,6 +837,11 @@ mod tests { write(&source, "nested/my-skill.md", "ordinary nested context\n"); write(&source, "README.MD", "upper-case extension\n"); fs::write(source.join("plain.txt"), "text").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + symlink(source.join("guide.md"), source.join("linked.md")).unwrap(); + } let package = package("provider", "1.0.0", source.clone()); let installer = installer(root, vec![package.clone()]); @@ -945,6 +969,237 @@ mod tests { assert!(result.unwrap_err().to_string().contains("cannot read")); } + #[test] + fn reports_directory_entry_type_and_recursive_read_errors() { + let directory = tempdir().unwrap(); + let root = directory.path().join("context"); + fs::create_dir_all(root.join("nested")).unwrap(); + fs::write(root.join("guide.md"), "guide").unwrap(); + + let failure_path = root.clone(); + let _failure = fs::fail_once(fs::Operation::ReadDirectoryEntry, move |path| { + path == failure_path + }); + assert!( + markdown_files(&root) + .unwrap_err() + .to_string() + .contains("injected") + ); + drop(_failure); + + let file = root.join("guide.md"); + let failure_path = file.clone(); + let _failure = fs::fail_once(fs::Operation::FileType, move |path| path == failure_path); + assert!( + markdown_files(&root) + .unwrap_err() + .to_string() + .contains("injected") + ); + drop(_failure); + + let failure_path = root.join("nested"); + let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| { + path == failure_path + }); + assert!( + markdown_files(&root) + .unwrap_err() + .to_string() + .contains("cannot read") + ); + drop(_failure); + + let failure_path = root.clone(); + let _failure = fs::fail_once(fs::Operation::ReadDirectoryEntry, move |path| { + path == failure_path + }); + assert!(collect_files(&root, &mut Vec::new()).is_err()); + drop(_failure); + + let failure_path = file; + let _failure = fs::fail_once(fs::Operation::FileType, move |path| path == failure_path); + assert!(collect_files(&root, &mut Vec::new()).is_err()); + drop(_failure); + + let failure_path = root.join("nested"); + let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| { + path == failure_path + }); + assert!(collect_files(&root, &mut Vec::new()).is_err()); + } + + #[test] + fn reports_copy_entry_type_recursive_and_empty_directory_errors() { + let directory = tempdir().unwrap(); + let source = directory.path().join("source"); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::create_dir(source.join("empty")).unwrap(); + fs::write(source.join("guide.md"), "guide").unwrap(); + + let failure_path = source.clone(); + let _failure = fs::fail_once(fs::Operation::ReadDirectoryEntry, move |path| { + path == failure_path + }); + assert!( + copy_context_tree( + &source, + &directory.path().join("entry"), + &HashSet::new(), + true + ) + .unwrap_err() + .to_string() + .contains("injected") + ); + drop(_failure); + + let failure_path = source.join("guide.md"); + let _failure = fs::fail_once(fs::Operation::FileType, move |path| path == failure_path); + assert!( + copy_context_tree( + &source, + &directory.path().join("type"), + &HashSet::new(), + true + ) + .unwrap_err() + .to_string() + .contains("injected") + ); + drop(_failure); + + let failure_path = source.join("nested"); + let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| { + path == failure_path + }); + assert!( + copy_context_tree( + &source, + &directory.path().join("recursive"), + &HashSet::new(), + true + ) + .unwrap_err() + .to_string() + .contains("cannot read") + ); + drop(_failure); + + let empty_destination = directory.path().join("empty-destination/empty"); + let failure_path = empty_destination.clone(); + let _failure = fs::fail_once(fs::Operation::RemoveDirectory, move |path| { + path == failure_path + }); + assert!( + copy_context_tree( + &source, + &directory.path().join("empty-destination"), + &HashSet::new(), + true + ) + .unwrap_err() + .to_string() + .contains("cannot remove empty context directory") + ); + } + + #[test] + fn propagates_package_operation_errors() { + let directory = tempdir().unwrap(); + let root = directory.path(); + let source = root.join("provider/context"); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("guide.md"), "# Guide\n").unwrap(); + fs::write(source.join("binary.txt"), [0xff]).unwrap(); + let provider_package = package("provider", "1.0.0", source.clone()); + let provider_installer = installer(root, vec![provider_package.clone()]); + + assert_eq!( + provider_installer + .show_context_file("provider", "guide.md") + .unwrap(), + Some("# Guide\n".to_owned()) + ); + assert!( + provider_installer + .show_context_file("provider", "binary.txt") + .unwrap_err() + .to_string() + .contains("cannot read") + ); + + let mut ambiguous = provider_package.clone(); + ambiguous.version = "2.0.0".to_owned(); + ambiguous.selector = "provider@2.0.0".to_owned(); + let ambiguous_installer = installer(root, vec![provider_package.clone(), ambiguous]); + assert!( + ambiguous_installer + .show_context_file("provider", "guide.md") + .unwrap_err() + .to_string() + .contains("multiple versions") + ); + + let destination = root.join(".agents/context/provider@1.0.0"); + let failure_path = destination.clone(); + let _failure = fs::fail_once(fs::Operation::Inspect, move |path| path == failure_path); + assert!(provider_installer.install_package("provider").is_err()); + drop(_failure); + + let failure_path = source.join("guide.md"); + let _failure = fs::fail_once(fs::Operation::Copy, move |path| path == failure_path); + assert!(provider_installer.install_package("provider").is_err()); + drop(_failure); + + let skill_only = root.join("skills/context"); + fs::create_dir_all(skill_only.join("skill/assets")).unwrap(); + fs::write( + skill_only.join("skill.md"), + "---\ntype: skill\ndescription: Skill.\n---\n\n# Skill\n", + ) + .unwrap(); + fs::write(skill_only.join("skill/assets/image.png"), "asset").unwrap(); + let skill_installer = installer(root, vec![package("skills", "1.0.0", skill_only)]); + let empty_destination = root.join(".agents/context/skills@1.0.0"); + let failure_path = empty_destination.clone(); + let _failure = fs::fail_once(fs::Operation::RemoveDirectoryTree, move |path| { + path == failure_path + }); + assert!(skill_installer.install_package("skills").is_err()); + drop(_failure); + assert!(skill_installer.install_all().unwrap().is_empty()); + + let bad = root.join("broken/context"); + fs::create_dir_all(&bad).unwrap(); + fs::write(bad.join("broken.md"), "---\ntype: guide\n---\n# Bad\n").unwrap(); + let broken_installer = installer(root, vec![package("broken", "1.0.0", bad)]); + assert!( + broken_installer + .list_context_files(&broken_installer.packages[0]) + .is_err() + ); + assert!( + broken_installer + .show_context_file("broken", "broken.md") + .is_err() + ); + assert!(broken_installer.install_package("broken").is_err()); + assert!(broken_installer.install_all().is_err()); + + let files_installer = installer(root, vec![provider_package]); + let failure_path = source.clone(); + let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| { + path == failure_path + }); + assert!( + files_installer + .list_context_files(&files_installer.packages[0]) + .is_err() + ); + } + #[cfg(unix)] #[test] fn reports_context_removal_errors() { diff --git a/src/lib.rs b/src/lib.rs index db844cc..569d2fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,8 @@ mod exclude; mod index; mod installer; mod skill; +#[cfg(test)] +mod test_filesystem; pub use index::ContextIndex; pub use installer::{ContextFile, ContextPackage, Installer}; @@ -163,3 +165,148 @@ pub fn index(context: &mut Context) -> Result { .update_index()?; Ok("Updated .agents/context/index.md".to_owned()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::Path; + use tempfile::tempdir; + + fn make_context(root: &Path, packages: Vec) -> Context { + let mut context = bake::Registry::new().context(root); + context.insert(Installer::for_test(root, packages)); + context + } + + fn package(root: &Path, name: &str, version: &str) -> ContextPackage { + ContextPackage::for_test(name, version, root.join(name).join("context")) + } + + fn write(path: &Path, contents: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + #[test] + fn task_wrappers_return_outputs_and_propagate_errors() { + let directory = tempdir().unwrap(); + let root = directory.path(); + let guide = package(root, "provider", "1.0.0"); + write(&guide.context_path.join("guide.md"), "# Guide\n"); + write( + &guide.context_path.join("broken.md"), + "---\ntype: guide\n---\n# Unsupported\n", + ); + let mut context = make_context(root, vec![guide.clone()]); + + assert!( + list(&mut context, Some("unknown".to_owned())) + .unwrap() + .contains("No context found") + ); + assert!(list(&mut context, None).is_err()); + assert!(list(&mut context, Some("provider".to_owned())).is_err()); + assert!(install(&mut context, None).is_err()); + assert!(show(&mut context, "provider".to_owned(), "missing.md".to_owned()).is_err()); + assert!(install(&mut context, Some("provider".to_owned())).is_err()); + assert!(skill_list(&mut context, Some("provider".to_owned())).is_err()); + assert!(skill_install(&mut context, Some("provider".to_owned()), None).is_err()); + + let mut duplicates = make_context( + root, + vec![ + package(root, "duplicate", "1.0.0"), + package(root, "duplicate", "2.0.0"), + ], + ); + assert!(list(&mut duplicates, Some("duplicate".to_owned())).is_err()); + assert!( + show( + &mut duplicates, + "duplicate".to_owned(), + "guide.md".to_owned() + ) + .is_err() + ); + assert!( + duplicates + .get::() + .unwrap() + .install_package("duplicate") + .is_err() + ); + + let listing_package = package(root, "listing", "1.0.0"); + write( + &listing_package.context_path.join("guide.md"), + "# Listing\n", + ); + let mut listing_context = make_context(root, vec![listing_package]); + assert!( + list(&mut listing_context, None) + .unwrap() + .contains("listing@1.0.0 (1.0.0)") + ); + + let empty_package = package(root, "empty", "1.0.0"); + fs::create_dir_all(&empty_package.context_path).unwrap(); + let mut empty_context = make_context(root, vec![empty_package]); + assert!( + list(&mut empty_context, None) + .unwrap() + .contains("No dependency context files") + ); + } + + #[test] + fn task_wrappers_propagate_install_and_index_failures() { + let directory = tempdir().unwrap(); + let root = directory.path(); + let provider = package(root, "provider", "1.0.0"); + write(&provider.context_path.join("guide.md"), "# Guide\n"); + + let registry = root.join(".agents/skills/.agent-context-skills.json"); + write(®istry, r#"{"version":1,"skills":{}}"#); + let failure_path = registry.clone(); + let _failure = test_filesystem::fail_once(test_filesystem::Operation::Read, move |path| { + path == failure_path + }); + let mut context = make_context(root, vec![provider.clone()]); + assert!(install(&mut context, Some("provider".to_owned())).is_err()); + drop(_failure); + + let index_file = root.join(".agents/context/index.md"); + fs::create_dir_all(&index_file).unwrap(); + let mut context = make_context(root, Vec::new()); + assert!(install(&mut context, None).is_err()); + assert!(index(&mut context).is_err()); + + let mut empty = make_context(root, Vec::new()); + assert!( + list(&mut empty, None) + .unwrap() + .contains("No dependency context files") + ); + assert_eq!( + skill_list(&mut empty, None).unwrap(), + "No dependency skills found" + ); + assert_eq!( + skill_install(&mut empty, None, None).unwrap(), + "No dependency skills were installed" + ); + } + + #[test] + fn installer_creation_errors_are_returned_by_tasks() { + let directory = tempdir().unwrap(); + let mut context = bake::Registry::new().context(directory.path()); + assert!(list(&mut context, None).is_err()); + assert!(show(&mut context, "provider".to_owned(), "guide.md".to_owned()).is_err()); + assert!(install(&mut context, None).is_err()); + assert!(skill_list(&mut context, None).is_err()); + assert!(skill_install(&mut context, None, None).is_err()); + assert!(index(&mut context).is_err()); + } +} diff --git a/src/skill.rs b/src/skill.rs index d2f2f8b..378bb24 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -1,9 +1,9 @@ // Released under the MIT License. // Copyright, 2026, by Samuel Williams. -#[cfg(test)] -use self::test_filesystem as filesystem; use super::installer::{ContextPackage, Installer, markdown_files}; +#[cfg(test)] +use super::test_filesystem as filesystem; use bake::{Error, Result}; use serde::{Deserialize, Serialize}; use socketry_markdown::{ParseOptions, mdast::Node, to_mdast}; @@ -18,146 +18,6 @@ const REGISTRY_VERSION: u32 = 1; const REGISTRY_FILE: &str = ".agent-context-skills.json"; static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0); -#[cfg(test)] -mod test_filesystem { - use std::io; - use std::path::Path; - use std::sync::Mutex; - use std::thread::ThreadId; - - pub(super) use std::fs::{Metadata, copy, read_to_string, remove_file}; - - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - pub(super) enum Operation { - CreateDirectory, - CreateDirectoryTree, - Inspect, - Read, - ReadDirectoryEntry, - RenameSource, - RenameDestination, - RemoveDirectoryTree, - Write, - } - - struct Failure { - operation: Operation, - thread: ThreadId, - matches: Box bool + Send + Sync>, - } - - static FAILURES: Mutex> = Mutex::new(Vec::new()); - - pub(super) struct FailureGuard { - thread: ThreadId, - } - - impl Drop for FailureGuard { - fn drop(&mut self) { - FAILURES - .lock() - .unwrap() - .retain(|failure| failure.thread != self.thread); - } - } - - pub(super) fn fail_once( - operation: Operation, - matches: impl Fn(&Path) -> bool + Send + Sync + 'static, - ) -> FailureGuard { - let thread = std::thread::current().id(); - FAILURES.lock().unwrap().push(Failure { - operation, - thread, - matches: Box::new(matches), - }); - FailureGuard { thread } - } - - fn check_failure(operation: Operation, path: &Path) -> io::Result<()> { - if take_failure(operation, path) { - Err(io::Error::other("injected filesystem failure")) - } else { - Ok(()) - } - } - - fn take_failure(operation: Operation, path: &Path) -> bool { - let thread = std::thread::current().id(); - let mut failures = FAILURES.lock().unwrap(); - let failure = failures.iter().position(|failure| { - failure.thread == thread && failure.operation == operation && (failure.matches)(path) - }); - failure.is_some_and(|index| { - failures.remove(index); - true - }) - } - - pub(super) fn create_dir(path: impl AsRef) -> io::Result<()> { - check_failure(Operation::CreateDirectory, path.as_ref())?; - std::fs::create_dir(path) - } - - pub(super) fn create_dir_all(path: impl AsRef) -> io::Result<()> { - check_failure(Operation::CreateDirectoryTree, path.as_ref())?; - std::fs::create_dir_all(path) - } - - pub(super) fn symlink_metadata(path: impl AsRef) -> io::Result { - check_failure(Operation::Inspect, path.as_ref())?; - std::fs::symlink_metadata(path) - } - - pub(super) struct ReadDir { - inner: std::fs::ReadDir, - fail_next_entry: bool, - } - - impl Iterator for ReadDir { - type Item = io::Result; - - fn next(&mut self) -> Option { - if self.fail_next_entry { - self.fail_next_entry = false; - return Some(Err(io::Error::other("injected filesystem failure"))); - } - - self.inner.next() - } - } - - pub(super) fn read_dir(path: impl AsRef) -> io::Result { - let path = path.as_ref(); - let fail_next_entry = take_failure(Operation::ReadDirectoryEntry, path); - std::fs::read_dir(path).map(|inner| ReadDir { - inner, - fail_next_entry, - }) - } - - pub(super) fn remove_dir_all(path: impl AsRef) -> io::Result<()> { - check_failure(Operation::RemoveDirectoryTree, path.as_ref())?; - std::fs::remove_dir_all(path) - } - - pub(super) fn read(path: impl AsRef) -> io::Result> { - check_failure(Operation::Read, path.as_ref())?; - std::fs::read(path) - } - - pub(super) fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { - check_failure(Operation::RenameSource, from.as_ref())?; - check_failure(Operation::RenameDestination, to.as_ref())?; - std::fs::rename(from, to) - } - - pub(super) fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> io::Result<()> { - check_failure(Operation::Write, path.as_ref())?; - std::fs::write(path, contents) - } -} - /// A skill declared by a Markdown file in a dependency's `context/` directory. #[derive(Clone, Debug)] pub struct Skill { @@ -1162,7 +1022,7 @@ mod tests { let blocker = directory.path().join("asset-parent-is-file"); fs::write(&blocker, "file").unwrap(); let failure_path = blocker.join("skill"); - let _failure = filesystem::fail_once(test_filesystem::Operation::Inspect, move |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| { path == failure_path }); assert!( @@ -1342,10 +1202,10 @@ mod tests { fs::create_dir(&destination).unwrap(); let failure_path = assets.clone(); - let _failure = filesystem::fail_once( - test_filesystem::Operation::ReadDirectoryEntry, - move |path| path == failure_path, - ); + let _failure = + filesystem::fail_once(filesystem::Operation::ReadDirectoryEntry, move |path| { + path == failure_path + }); let error = copy_skill_assets(&assets, &destination, true).unwrap_err(); @@ -1513,7 +1373,7 @@ mod tests { fs::write(&blocker, "file").unwrap(); let blocked_registry_path = blocker.join("registry.json"); let failure_path = blocked_registry_path.clone(); - let _failure = filesystem::fail_once(test_filesystem::Operation::Inspect, move |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| { path == failure_path }); assert!( @@ -1530,12 +1390,12 @@ mod tests { fs::write(&blocker, "file").unwrap(); let child = blocker.join("child"); let failure_path = child.clone(); - let _failure = filesystem::fail_once(test_filesystem::Operation::Inspect, move |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| { path == failure_path }); assert!(path_exists(&child).is_err()); let failure_path = child.clone(); - let _failure = filesystem::fail_once(test_filesystem::Operation::Inspect, move |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| { path == failure_path }); assert!(remove_existing(&child).is_err()); @@ -1871,7 +1731,7 @@ mod tests { transaction_fixture(); let failure_path = registry_path.clone(); let _failure = - filesystem::fail_once(test_filesystem::Operation::RenameDestination, move |path| { + filesystem::fail_once(filesystem::Operation::RenameDestination, move |path| { path == failure_path }); let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err(); @@ -1891,7 +1751,7 @@ mod tests { transaction_fixture(); let failure_path = previous_skill.clone(); let _failure = - filesystem::fail_once(test_filesystem::Operation::RenameDestination, move |path| { + filesystem::fail_once(filesystem::Operation::RenameDestination, move |path| { path == failure_path }); let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err(); @@ -1910,7 +1770,7 @@ mod tests { let (_directory, installer, skill_path, registry_path) = fresh_install_fixture(); let failure_path = skill_path.clone(); let _failure = - filesystem::fail_once(test_filesystem::Operation::RenameDestination, move |path| { + filesystem::fail_once(filesystem::Operation::RenameDestination, move |path| { path == failure_path }); @@ -1926,7 +1786,7 @@ mod tests { let (_directory, installer, skill_path, registry_path) = fresh_install_fixture(); let failure_path = registry_path.clone(); let _failure = - filesystem::fail_once(test_filesystem::Operation::RenameDestination, move |path| { + filesystem::fail_once(filesystem::Operation::RenameDestination, move |path| { path == failure_path }); @@ -1942,10 +1802,9 @@ mod tests { let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) = transaction_fixture(); let failure_path = previous_skill.clone(); - let _failure = - filesystem::fail_once(test_filesystem::Operation::RenameSource, move |path| { - path == failure_path - }); + let _failure = filesystem::fail_once(filesystem::Operation::RenameSource, move |path| { + path == failure_path + }); let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err(); assert!(error.to_string().contains("cannot move existing skill")); @@ -1962,10 +1821,9 @@ mod tests { let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) = transaction_fixture(); let failure_path = stale_skill.clone(); - let _failure = - filesystem::fail_once(test_filesystem::Operation::RenameSource, move |path| { - path == failure_path - }); + let _failure = filesystem::fail_once(filesystem::Operation::RenameSource, move |path| { + path == failure_path + }); let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err(); assert!( @@ -1985,7 +1843,7 @@ mod tests { fn rolls_back_installed_skills_when_staged_registry_write_fails() { let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) = transaction_fixture(); - let _failure = filesystem::fail_once(test_filesystem::Operation::Write, |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Write, |path| { path.file_name() == Some(std::ffi::OsStr::new("registry.json")) }); let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err(); @@ -2008,10 +1866,9 @@ mod tests { let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) = transaction_fixture(); let failure_path = registry_path.clone(); - let _failure = - filesystem::fail_once(test_filesystem::Operation::RenameSource, move |path| { - path == failure_path - }); + let _failure = filesystem::fail_once(filesystem::Operation::RenameSource, move |path| { + path == failure_path + }); let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err(); assert!( @@ -2031,7 +1888,7 @@ mod tests { fn cleans_staging_directory_when_preparing_staging_files_fails() { let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) = transaction_fixture(); - let _failure = filesystem::fail_once(test_filesystem::Operation::CreateDirectory, |path| { + let _failure = filesystem::fail_once(filesystem::Operation::CreateDirectory, |path| { path.file_name() == Some(std::ffi::OsStr::new("backups")) }); let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err(); @@ -2057,7 +1914,7 @@ mod tests { #[test] fn reports_staging_directory_creation_failure() { let (_directory, installer, _, _, _, _) = transaction_fixture(); - let _failure = filesystem::fail_once(test_filesystem::Operation::CreateDirectory, |path| { + let _failure = filesystem::fail_once(filesystem::Operation::CreateDirectory, |path| { path.file_name() .and_then(std::ffi::OsStr::to_str) .is_some_and(|name| name.starts_with(".agent-context-staging-")) @@ -2074,7 +1931,7 @@ mod tests { fs::create_dir_all(skills_root).unwrap(); let unrelated_file = skills_root.join("unrelated-file"); fs::write(&unrelated_file, "keep this file\n").unwrap(); - let _failure = filesystem::fail_once(test_filesystem::Operation::Write, |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Write, |path| { path.file_name() == Some(std::ffi::OsStr::new("SKILL.md")) }); @@ -2098,10 +1955,10 @@ mod tests { let directory = tempdir().unwrap(); let destination = directory.path().join("created/nested"); let failure_path = destination.clone(); - let _failure = filesystem::fail_once( - test_filesystem::Operation::CreateDirectoryTree, - move |path| path == failure_path, - ); + let _failure = + filesystem::fail_once(filesystem::Operation::CreateDirectoryTree, move |path| { + path == failure_path + }); let error = ensure_directory(&destination).unwrap_err(); @@ -2115,7 +1972,7 @@ mod tests { let registry_path = directory.path().join("registry.json"); fs::write(®istry_path, r#"{"version":1,"skills":{}}"#).unwrap(); let failure_path = registry_path.clone(); - let _failure = filesystem::fail_once(test_filesystem::Operation::Read, move |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Read, move |path| { path == failure_path }); @@ -2129,7 +1986,7 @@ mod tests { let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) = transaction_fixture(); let failure_path = previous_skill.clone(); - let _failure = filesystem::fail_once(test_filesystem::Operation::Inspect, move |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| { path == failure_path }); @@ -2149,7 +2006,7 @@ mod tests { let (_directory, installer, previous_skill, stale_skill, registry_path, previous_registry) = transaction_fixture(); let failure_path = stale_skill.clone(); - let _failure = filesystem::fail_once(test_filesystem::Operation::Inspect, move |path| { + let _failure = filesystem::fail_once(filesystem::Operation::Inspect, move |path| { path == failure_path }); @@ -2168,12 +2025,11 @@ mod tests { fn reports_staging_cleanup_errors_after_committing_the_install() { let (_directory, installer, previous_skill, stale_skill, registry_path, _) = transaction_fixture(); - let _failure = - filesystem::fail_once(test_filesystem::Operation::RemoveDirectoryTree, |path| { - path.file_name() - .and_then(std::ffi::OsStr::to_str) - .is_some_and(|name| name.starts_with(".agent-context-staging-")) - }); + let _failure = filesystem::fail_once(filesystem::Operation::RemoveDirectoryTree, |path| { + path.file_name() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|name| name.starts_with(".agent-context-staging-")) + }); let error = install_skills(&installer, Some("provider@1.0.0"), None).unwrap_err(); diff --git a/src/test_filesystem.rs b/src/test_filesystem.rs new file mode 100644 index 0000000..3841a01 --- /dev/null +++ b/src/test_filesystem.rs @@ -0,0 +1,178 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +//! Deterministic filesystem failures for exercising error handling in tests. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::thread::ThreadId; + +pub(crate) use std::fs::{Metadata, Permissions, remove_file, set_permissions}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Operation { + Copy, + CreateDirectory, + CreateDirectoryTree, + FileType, + Inspect, + Read, + ReadDirectory, + ReadDirectoryEntry, + RemoveDirectory, + RemoveDirectoryTree, + RenameSource, + RenameDestination, + Write, +} + +struct Failure { + operation: Operation, + thread: ThreadId, + matches: Box bool + Send + Sync>, +} + +static FAILURES: Mutex> = Mutex::new(Vec::new()); + +pub(crate) struct FailureGuard { + thread: ThreadId, +} + +impl Drop for FailureGuard { + fn drop(&mut self) { + FAILURES + .lock() + .unwrap() + .retain(|failure| failure.thread != self.thread); + } +} + +pub(crate) fn fail_once( + operation: Operation, + matches: impl Fn(&Path) -> bool + Send + Sync + 'static, +) -> FailureGuard { + let thread = std::thread::current().id(); + FAILURES.lock().unwrap().push(Failure { + operation, + thread, + matches: Box::new(matches), + }); + FailureGuard { thread } +} + +fn check_failure(operation: Operation, path: &Path) -> io::Result<()> { + if take_failure(operation, path) { + Err(io::Error::other("injected filesystem failure")) + } else { + Ok(()) + } +} + +fn take_failure(operation: Operation, path: &Path) -> bool { + let thread = std::thread::current().id(); + let mut failures = FAILURES.lock().unwrap(); + let failure = failures.iter().position(|failure| { + failure.thread == thread && failure.operation == operation && (failure.matches)(path) + }); + failure.is_some_and(|index| { + failures.remove(index); + true + }) +} + +pub(crate) fn create_dir(path: impl AsRef) -> io::Result<()> { + check_failure(Operation::CreateDirectory, path.as_ref())?; + std::fs::create_dir(path) +} + +pub(crate) fn create_dir_all(path: impl AsRef) -> io::Result<()> { + check_failure(Operation::CreateDirectoryTree, path.as_ref())?; + std::fs::create_dir_all(path) +} + +pub(crate) fn symlink_metadata(path: impl AsRef) -> io::Result { + check_failure(Operation::Inspect, path.as_ref())?; + std::fs::symlink_metadata(path) +} + +pub(crate) fn read_to_string(path: impl AsRef) -> io::Result { + check_failure(Operation::Read, path.as_ref())?; + std::fs::read_to_string(path) +} + +pub(crate) struct DirectoryEntry(std::fs::DirEntry); + +impl DirectoryEntry { + pub(crate) fn file_type(&self) -> io::Result { + check_failure(Operation::FileType, &self.0.path())?; + self.0.file_type() + } + + pub(crate) fn path(&self) -> PathBuf { + self.0.path() + } + + pub(crate) fn file_name(&self) -> std::ffi::OsString { + self.0.file_name() + } +} + +pub(crate) struct ReadDir { + inner: std::fs::ReadDir, + fail_next_entry: bool, +} + +impl Iterator for ReadDir { + type Item = io::Result; + + fn next(&mut self) -> Option { + if self.fail_next_entry { + self.fail_next_entry = false; + return Some(Err(io::Error::other("injected filesystem failure"))); + } + + self.inner.next().map(|result| result.map(DirectoryEntry)) + } +} + +pub(crate) fn read_dir(path: impl AsRef) -> io::Result { + let path = path.as_ref(); + check_failure(Operation::ReadDirectory, path)?; + let fail_next_entry = take_failure(Operation::ReadDirectoryEntry, path); + std::fs::read_dir(path).map(|inner| ReadDir { + inner, + fail_next_entry, + }) +} + +pub(crate) fn remove_dir(path: impl AsRef) -> io::Result<()> { + check_failure(Operation::RemoveDirectory, path.as_ref())?; + std::fs::remove_dir(path) +} + +pub(crate) fn remove_dir_all(path: impl AsRef) -> io::Result<()> { + check_failure(Operation::RemoveDirectoryTree, path.as_ref())?; + std::fs::remove_dir_all(path) +} + +pub(crate) fn read(path: impl AsRef) -> io::Result> { + check_failure(Operation::Read, path.as_ref())?; + std::fs::read(path) +} + +pub(crate) fn rename(from: impl AsRef, to: impl AsRef) -> io::Result<()> { + check_failure(Operation::RenameSource, from.as_ref())?; + check_failure(Operation::RenameDestination, to.as_ref())?; + std::fs::rename(from, to) +} + +pub(crate) fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> io::Result<()> { + check_failure(Operation::Write, path.as_ref())?; + std::fs::write(path, contents) +} + +pub(crate) fn copy(from: impl AsRef, to: impl AsRef) -> io::Result { + check_failure(Operation::Copy, from.as_ref())?; + std::fs::copy(from, to) +}