Use fs_err and forbid raw std::fs methods via clippy
fs_err includes file paths in error messages automatically, so manual
with_context wrappers restating the path are removed.

Assisted-by: Claude Opus 4.6 via pi
change xvtqoxyuzryonrkuuppkmtukumtvyotq
commit 3a50bbda54d21e260b6f4c3b7aa5ef9f5a2430b2
author Alpha Chen <alpha@kejadlen.dev>
date
parent vznypkxl
diff --git a/Cargo.lock b/Cargo.lock
index 650c6d5..dafba70 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -73,6 +73,12 @@ version = "1.0.101"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
 
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
 [[package]]
 name = "backtrace"
 version = "0.3.76"
@@ -183,6 +189,15 @@ dependencies = [
  "once_cell",
 ]
 
+[[package]]
+name = "fs-err"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "gimli"
 version = "0.32.3"
@@ -320,6 +335,7 @@ version = "0.1.0"
 dependencies = [
  "clap",
  "color-eyre",
+ "fs-err",
  "serde",
  "serde_json",
  "serde_yml",
diff --git a/Cargo.toml b/Cargo.toml
index 7b94347..2378f7f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ edition = "2024"
 [dependencies]
 clap = { version = "*", features = ["derive"] }
 color-eyre = "*"
+fs-err = "*"
 tracing = "*"
 tracing-subscriber = "*"
 serde = { version = "*", features = ["derive"] }
diff --git a/clippy.toml b/clippy.toml
new file mode 100644
index 0000000..e8f917e
--- /dev/null
+++ b/clippy.toml
@@ -0,0 +1,10 @@
+disallowed-methods = [
+    { path = "std::fs::read_to_string", reason = "use fs_err::read_to_string" },
+    { path = "std::fs::write", reason = "use fs_err::write" },
+    { path = "std::fs::read_dir", reason = "use fs_err::read_dir" },
+    { path = "std::fs::remove_file", reason = "use fs_err::remove_file" },
+    { path = "std::fs::remove_dir_all", reason = "use fs_err::remove_dir_all" },
+    { path = "std::fs::create_dir_all", reason = "use fs_err::create_dir_all" },
+    { path = "std::fs::read_link", reason = "use fs_err::read_link" },
+    { path = "std::fs::symlink_metadata", reason = "use fs_err::symlink_metadata" },
+]
diff --git a/src/config.rs b/src/config.rs
index 34f09bc..4a2d891 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -24,8 +24,7 @@ pub fn load() -> Result<Vec<Plugin>> {
         );
     }
 
-    let mut entries: Vec<_> = std::fs::read_dir(&config_dir)
-        .with_context(|| format!("failed to read config dir: {}", config_dir.display()))?
+    let mut entries: Vec<_> = fs_err::read_dir(&config_dir)?
         .filter_map(|e| e.ok())
         .filter(|e| e.path().extension().is_some_and(|ext| ext == "yml"))
         .collect();
@@ -41,8 +40,7 @@ pub fn load() -> Result<Vec<Plugin>> {
 
     for entry in &entries {
         let path = entry.path();
-        let contents = std::fs::read_to_string(&path)
-            .with_context(|| format!("failed to read {}", path.display()))?;
+        let contents = fs_err::read_to_string(&path)?;
         let file: ConfigFile = serde_yml::from_str(&contents)
             .with_context(|| format!("failed to parse {}", path.display()))?;
 
diff --git a/src/git.rs b/src/git.rs
index 44e80b6..5daf332 100644
--- a/src/git.rs
+++ b/src/git.rs
@@ -32,8 +32,7 @@ pub fn clone_or_fetch(repo_path: &Path, src: &str) -> Result<()> {
             bail!("git fetch failed for {}", src);
         }
     } else {
-        std::fs::create_dir_all(repo_path)
-            .with_context(|| format!("failed to create {}", repo_path.display()))?;
+        fs_err::create_dir_all(repo_path)?;
         let status = Command::new("git")
             .args(["clone", "--quiet", "--bare", src, "."])
             .current_dir(repo_path)
@@ -91,7 +90,7 @@ pub fn ensure_worktree(repo_path: &Path, rev: &str) -> Result<PathBuf> {
             bail!("git checkout failed for {} in {}", rev, wt_path.display());
         }
     } else {
-        std::fs::create_dir_all(wt_path.parent().unwrap())?;
+        fs_err::create_dir_all(wt_path.parent().unwrap())?;
         let status = Command::new("git")
             .args([
                 "worktree",
@@ -139,7 +138,7 @@ fn visit_worktree_dirs(
     dir: &Path,
     used: &std::collections::HashMap<PathBuf, std::collections::HashSet<String>>,
 ) -> Result<()> {
-    let entries = match std::fs::read_dir(dir) {
+    let entries = match fs_err::read_dir(dir) {
         Ok(e) => e,
         Err(_) => return Ok(()),
     };
@@ -157,7 +156,7 @@ fn visit_worktree_dirs(
             let repo_path = path.parent().unwrap();
             let used_revs = used.get(repo_path);
 
-            for wt_entry in std::fs::read_dir(&path)? {
+            for wt_entry in fs_err::read_dir(&path)? {
                 let wt_entry = wt_entry?;
                 let wt_name = wt_entry.file_name();
                 let wt_name_str = wt_name.to_string_lossy();
@@ -173,7 +172,7 @@ fn visit_worktree_dirs(
                         .status();
                     // If that fails, just remove the directory
                     if wt_path.exists() {
-                        std::fs::remove_dir_all(&wt_path)?;
+                        fs_err::remove_dir_all(&wt_path)?;
                     }
                     info!("pruned worktree: {}", wt_path.display());
                 }
diff --git a/src/lockfile.rs b/src/lockfile.rs
index cbe306f..dd47291 100644
--- a/src/lockfile.rs
+++ b/src/lockfile.rs
@@ -18,8 +18,7 @@ pub struct LockedPlugin {
 
 pub fn load() -> Result<Lockfile> {
     let path = crate::paths::lockfile_path()?;
-    let contents = std::fs::read_to_string(&path)
-        .with_context(|| format!("failed to read lockfile: {}", path.display()))?;
+    let contents = fs_err::read_to_string(&path)?;
     let lockfile: Lockfile = serde_yml::from_str(&contents)
         .with_context(|| format!("failed to parse lockfile: {}", path.display()))?;
     Ok(lockfile)
@@ -28,7 +27,6 @@ pub fn load() -> Result<Lockfile> {
 pub fn save(lockfile: &Lockfile) -> Result<()> {
     let path = crate::paths::lockfile_path()?;
     let contents = serde_yml::to_string(lockfile).context("failed to serialize lockfile")?;
-    std::fs::write(&path, contents)
-        .with_context(|| format!("failed to write lockfile: {}", path.display()))?;
+    fs_err::write(&path, contents)?;
     Ok(())
 }
diff --git a/src/main.rs b/src/main.rs
index f347ff1..7190421 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,5 @@
+#![deny(clippy::disallowed_methods)]
+
 mod config;
 mod git;
 mod lockfile;
@@ -193,7 +195,7 @@ fn do_install() -> Result<()> {
             );
         }
 
-        for entry in std::fs::read_dir(&skills_source)? {
+        for entry in fs_err::read_dir(&skills_source)? {
             let entry = entry?;
             if !entry.path().is_dir() {
                 continue;
@@ -204,12 +206,7 @@ fn do_install() -> Result<()> {
 
             // Remove existing symlink if present
             if symlink_path.symlink_metadata().is_ok() {
-                std::fs::remove_file(&symlink_path).with_context(|| {
-                    format!(
-                        "failed to remove existing symlink: {}",
-                        symlink_path.display()
-                    )
-                })?;
+                fs_err::remove_file(&symlink_path)?;
             }
 
             std::os::unix::fs::symlink(entry.path(), &symlink_path).with_context(|| {
@@ -229,7 +226,7 @@ fn do_install() -> Result<()> {
         }
     }
 
-    for entry in std::fs::read_dir(&skills_dir)? {
+    for entry in fs_err::read_dir(&skills_dir)? {
         let entry = entry?;
         let path = entry.path();
 
@@ -242,11 +239,11 @@ fn do_install() -> Result<()> {
         let name_str = name.to_string_lossy();
 
         // Only touch symlinks pointing into our cache
-        if let Ok(target) = std::fs::read_link(&path)
+        if let Ok(target) = fs_err::read_link(&path)
             && target.starts_with(&cache_dir)
             && !installed_skills.contains(&name)
         {
-            std::fs::remove_file(&path)?;
+            fs_err::remove_file(&path)?;
             info!("removed stale symlink: {}", name_str);
         }
     }
diff --git a/src/marketplace.rs b/src/marketplace.rs
index f4034f8..4047a59 100644
--- a/src/marketplace.rs
+++ b/src/marketplace.rs
@@ -24,8 +24,7 @@ pub fn find_plugin_source(
 ) -> Result<String> {
     let manifest_path = repo_path.join(marketplace_override.unwrap_or(DEFAULT_PATH));
 
-    let contents = std::fs::read_to_string(&manifest_path)
-        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
+    let contents = fs_err::read_to_string(&manifest_path)?;
 
     let marketplace: Marketplace = serde_json::from_str(&contents)
         .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
diff --git a/src/paths.rs b/src/paths.rs
index de884f0..be8d845 100644
--- a/src/paths.rs
+++ b/src/paths.rs
@@ -28,7 +28,7 @@ pub fn lockfile_path() -> Result<PathBuf> {
 
 pub fn skills_dir() -> Result<PathBuf> {
     let path = home_dir().join(".claude").join("skills");
-    std::fs::create_dir_all(&path)
+    fs_err::create_dir_all(&path)
         .with_context(|| format!("failed to create skills dir: {}", path.display()))?;
     Ok(path)
 }