Replace color-eyre with miette for error reporting
miette provides richer diagnostics — source spans, labels, help text,
and severity levels — which will be useful as frork's error messages
mature. FrorkError now derives miette::Diagnostic alongside thiserror.

The io::Error uses in the satisfy prompt needed explicit .map_err
wrapping since miette::Report does not impl From<io::Error> the way
eyre::Report does.

Assisted-by: Claude Opus 4.6 via pi
change qpoxvunslsrpzknxvnwrwkzrvvxwknmp
commit cc7ce2309840fc502da8f31b9beb587f4da39097
author Alpha Chen <alpha@kejadlen.dev>
date
parent qqyulppv
diff --git a/AGENTS.md b/AGENTS.md
index aa117f5..8af6bf3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,7 +50,7 @@ just
 ## Conventions
 
 - **Rust edition 2024**, resolver v3 workspace.
-- Error handling: `color-eyre` in the binary, `thiserror` in library code.
+- Error handling: `miette` in the binary, `thiserror` in library code. Library errors derive `miette::Diagnostic`.
 - Logging uses `tracing` with `tracing-subscriber`. Use `tracing::info`, `tracing::debug`, etc. — not `println!` for diagnostic output.
 - Lua integration via `mlua` with vendored Lua 5.4. Fennel compiler is vendored as a Lua source file.
 - All CI checks must pass: `cargo fmt --all --check`, `cargo clippy`, `cargo test`.
diff --git a/Cargo.lock b/Cargo.lock
index 8532086..7e0b94c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -112,6 +112,15 @@ dependencies = [
  "windows-link",
 ]
 
+[[package]]
+name = "backtrace-ext"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
+dependencies = [
+ "backtrace",
+]
+
 [[package]]
 name = "bitflags"
 version = "2.10.0"
@@ -212,33 +221,6 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
 
-[[package]]
-name = "color-eyre"
-version = "0.6.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
-dependencies = [
- "backtrace",
- "color-spantrace",
- "eyre",
- "indenter",
- "once_cell",
- "owo-colors",
- "tracing-error",
-]
-
-[[package]]
-name = "color-spantrace"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
-dependencies = [
- "once_cell",
- "owo-colors",
- "tracing-core",
- "tracing-error",
-]
-
 [[package]]
 name = "colorchoice"
 version = "1.0.4"
@@ -299,16 +281,6 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
-[[package]]
-name = "eyre"
-version = "0.6.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
-dependencies = [
- "indenter",
- "once_cell",
-]
-
 [[package]]
 name = "fastrand"
 version = "2.3.0"
@@ -343,10 +315,10 @@ version = "0.1.0"
 dependencies = [
  "assert_cmd",
  "clap",
- "color-eyre",
  "frork-lib",
  "fs-err",
  "hegeltest",
+ "miette",
  "mlua",
  "predicates",
  "regex",
@@ -431,10 +403,10 @@ dependencies = [
 ]
 
 [[package]]
-name = "indenter"
-version = "0.3.4"
+name = "is_ci"
+version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
 
 [[package]]
 name = "is_terminal_polyfill"
@@ -509,6 +481,36 @@ version = "2.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
 
+[[package]]
+name = "miette"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7"
+dependencies = [
+ "backtrace",
+ "backtrace-ext",
+ "cfg-if",
+ "miette-derive",
+ "owo-colors",
+ "supports-color",
+ "supports-hyperlinks",
+ "supports-unicode",
+ "terminal_size",
+ "textwrap",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "miette-derive"
+version = "7.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.9"
@@ -858,6 +860,27 @@ version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
 
+[[package]]
+name = "supports-color"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
+dependencies = [
+ "is_ci",
+]
+
+[[package]]
+name = "supports-hyperlinks"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91"
+
+[[package]]
+name = "supports-unicode"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
+
 [[package]]
 name = "syn"
 version = "2.0.108"
@@ -882,12 +905,32 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "termtree"
 version = "0.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
 
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "unicode-linebreak",
+ "unicode-width 0.2.2",
+]
+
 [[package]]
 name = "thiserror"
 version = "2.0.17"
@@ -949,16 +992,6 @@ dependencies = [
  "valuable",
 ]
 
-[[package]]
-name = "tracing-error"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
-dependencies = [
- "tracing",
- "tracing-subscriber",
-]
-
 [[package]]
 name = "tracing-log"
 version = "0.2.0"
@@ -1000,6 +1033,24 @@ version = "1.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06"
 
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.2"
diff --git a/frork-cli/Cargo.toml b/frork-cli/Cargo.toml
index 6d2e416..0f47c93 100644
--- a/frork-cli/Cargo.toml
+++ b/frork-cli/Cargo.toml
@@ -9,7 +9,7 @@ path = "src/main.rs"
 
 [dependencies]
 frork-lib = { path = "../frork-lib" }
-color-eyre = "*"
+miette = { version = "*", features = ["fancy"] }
 fs-err = "*"
 clap = { version = "*", features = ["derive", "env"] }
 mlua = { version = "*", features = ["lua54", "serde", "vendored"] }
diff --git a/frork-cli/src/assertions.rs b/frork-cli/src/assertions.rs
index ea908bf..5dfaa0c 100644
--- a/frork-cli/src/assertions.rs
+++ b/frork-cli/src/assertions.rs
@@ -1,5 +1,5 @@
-use color_eyre::{Result, eyre::eyre};
 use fs_err as fs;
+use miette::{Result, miette};
 use mlua::prelude::*;
 use serde::Deserialize;
 use std::path::Path;
@@ -33,7 +33,7 @@ where
     fn create(&self, lua: &Lua, args: LuaMultiValue) -> Result<Box<dyn AssertionType>> {
         T::from_lua_multi(args, lua)
             .map(|t| Box::new(t) as Box<dyn AssertionType>)
-            .map_err(|e| eyre!("Failed to create assertion type: {}", e))
+            .map_err(|e| miette!("Failed to create assertion type: {}", e))
     }
 }
 
@@ -178,7 +178,7 @@ impl AssertionType for Symlink {
     fn install(&self) -> Result<()> {
         use std::os::unix::fs;
         fs::symlink(&self.source, &self.target)
-            .map_err(|e| eyre!("Failed to create symlink: {}", e))?;
+            .map_err(|e| miette!("Failed to create symlink: {}", e))?;
         debug!("created: {}", self);
         Ok(())
     }
@@ -225,7 +225,7 @@ impl AssertionType for Directory {
     }
 
     fn install(&self) -> Result<()> {
-        fs::create_dir_all(&self.path).map_err(|e| eyre!("Failed to create directory: {}", e))?;
+        fs::create_dir_all(&self.path).map_err(|e| miette!("Failed to create directory: {}", e))?;
         debug!("created: {}", self);
         Ok(())
     }
@@ -267,7 +267,7 @@ impl AssertionType for Debug {
         if let Some(ref status_fn) = self.status_fn {
             let result = status_fn
                 .call::<Status>(LuaMultiValue::new())
-                .map_err(|e| eyre!("Debug status function failed: {}", e))?;
+                .map_err(|e| miette!("Debug status function failed: {}", e))?;
             Ok(result)
         } else {
             Ok(Status::Ok)
@@ -279,10 +279,10 @@ impl AssertionType for Debug {
         let install_fn = self
             .install_fn
             .as_ref()
-            .ok_or_else(|| eyre!("Install not implemented for debug assertion"))?;
+            .ok_or_else(|| miette!("Install not implemented for debug assertion"))?;
         install_fn
             .call::<()>(LuaMultiValue::new())
-            .map_err(|e| eyre!("Debug install function failed: {}", e))?;
+            .map_err(|e| miette!("Debug install function failed: {}", e))?;
         Ok(())
     }
 
@@ -335,7 +335,7 @@ impl AssertionType for Git {
 
         // Check if directory is empty (only . and ..)
         let mut entries =
-            fs::read_dir(dir_path).map_err(|e| eyre!("Failed to read directory: {}", e))?;
+            fs::read_dir(dir_path).map_err(|e| miette!("Failed to read directory: {}", e))?;
         if entries.next().is_none() {
             return Ok(Status::Missing);
         }
@@ -371,7 +371,7 @@ impl AssertionType for Git {
         let (_output, exit_code) = Utils::sh("git", &["clone", &self.remote_url, &self.dir])?;
 
         if exit_code != 0 {
-            return Err(eyre!("Failed to clone git repository"));
+            return Err(miette!("Failed to clone git repository"));
         }
 
         debug!("created: {}", self);
@@ -497,7 +497,7 @@ impl AssertionType for Brew {
         )?;
 
         if exit_code != 0 {
-            return Err(eyre!("Failed to install Homebrew: {}", output));
+            return Err(miette!("Failed to install Homebrew: {}", output));
         }
 
         debug!("installed: {}", self);
@@ -533,7 +533,7 @@ impl std::fmt::Display for BrewBundle {
 impl AssertionType for BrewBundle {
     #[cfg(not(target_os = "macos"))]
     fn status(&self) -> Result<Status> {
-        Err(eyre!("brew-bundle only supported on Darwin/macOS"))
+        Err(miette!("brew-bundle only supported on Darwin/macOS"))
     }
 
     #[cfg(target_os = "macos")]
@@ -572,7 +572,7 @@ impl AssertionType for BrewBundle {
 
     #[cfg(not(target_os = "macos"))]
     fn install(&self) -> Result<()> {
-        Err(eyre!("brew-bundle only supported on Darwin/macOS"))
+        Err(miette!("brew-bundle only supported on Darwin/macOS"))
     }
 
     #[cfg(target_os = "macos")]
@@ -583,7 +583,7 @@ impl AssertionType for BrewBundle {
         let (_output, exit_code) = Utils::sh("brew", &["bundle", "install", &file_arg])?;
 
         if exit_code != 0 {
-            return Err(eyre!("Failed to install brew bundle"));
+            return Err(miette!("Failed to install brew bundle"));
         }
 
         debug!("installed: {}", self);
diff --git a/frork-cli/src/errors.rs b/frork-cli/src/errors.rs
index 4043b67..ee1fbeb 100644
--- a/frork-cli/src/errors.rs
+++ b/frork-cli/src/errors.rs
@@ -1,7 +1,8 @@
+use miette::Diagnostic;
 use mlua::prelude::*;
 use thiserror::Error;
 
-#[derive(Error, Debug, Clone, PartialEq)]
+#[derive(Error, Diagnostic, Debug, Clone, PartialEq)]
 pub enum FrorkError {
     #[error("No operation specified")]
     NoOperation,
diff --git a/frork-cli/src/main.rs b/frork-cli/src/main.rs
index 02fe4da..6a22411 100644
--- a/frork-cli/src/main.rs
+++ b/frork-cli/src/main.rs
@@ -20,9 +20,9 @@ use assertions::Symlink;
 use assertions::TypedFactory;
 use clap::Parser;
 use clap::Subcommand;
-use color_eyre::Result;
-use color_eyre::eyre::eyre;
 use errors::FrorkError;
+use miette::Result;
+use miette::miette;
 use mlua::prelude::*;
 use tracing::info;
 use utils::Utils;
@@ -188,19 +188,19 @@ fn setup_lua(
     let fennel_module: LuaTable = lua
         .load(fennel_code)
         .eval()
-        .map_err(|e| eyre!("Failed to load Fennel: {}", e))?;
+        .map_err(|e| miette!("Failed to load Fennel: {}", e))?;
     lua.register_module("fennel", &fennel_module)
-        .map_err(|e| eyre!("Failed to register fennel module: {}", e))?;
+        .map_err(|e| miette!("Failed to register fennel module: {}", e))?;
 
     let frork_table = match Frork::new(handle_status, lua.clone())
         .into_lua(&lua)
-        .map_err(|e| eyre!("Failed to create frork table: {}", e))?
+        .map_err(|e| miette!("Failed to create frork table: {}", e))?
     {
         LuaValue::Table(table) => table,
         _ => unreachable!(),
     };
     lua.register_module("frork", &frork_table)
-        .map_err(|e| eyre!("Failed to register frork module: {}", e))?;
+        .map_err(|e| miette!("Failed to register frork module: {}", e))?;
 
     Ok((lua, frork_table, fennel_module))
 }
@@ -213,18 +213,18 @@ fn run_code(
 
     let ok_fn: LuaFunction = frork_module
         .get("ok")
-        .map_err(|e| eyre!("Failed to get frork.ok: {}", e))?;
+        .map_err(|e| miette!("Failed to get frork.ok: {}", e))?;
     lua.globals()
         .set("ok", ok_fn)
-        .map_err(|e| eyre!("Failed to set ok global: {}", e))?;
+        .map_err(|e| miette!("Failed to set ok global: {}", e))?;
 
     let eval_fn: LuaFunction = fennel_module
         .get("eval")
-        .map_err(|e| eyre!("Failed to get fennel.eval: {}", e))?;
+        .map_err(|e| miette!("Failed to get fennel.eval: {}", e))?;
 
     eval_fn
         .call::<()>(code)
-        .map_err(|e| eyre!("Failed to execute fennel code: {}", e))?;
+        .map_err(|e| miette!("Failed to execute fennel code: {}", e))?;
 
     Ok(())
 }
@@ -243,19 +243,19 @@ fn run_script(
         let package_table: LuaTable = lua
             .globals()
             .get("package")
-            .map_err(|e| eyre!("Failed to get package table: {}", e))?;
+            .map_err(|e| miette!("Failed to get package table: {}", e))?;
 
         // Add to Lua search path
         let current_path: String = package_table
             .get("path")
-            .map_err(|e| eyre!("Failed to get current Lua path: {}", e))?;
+            .map_err(|e| miette!("Failed to get current Lua path: {}", e))?;
         let new_path = format!(
             "{};{}/?.lua;{}/?/init.lua",
             current_path, script_dir_str, script_dir_str
         );
         package_table
             .set("path", new_path)
-            .map_err(|e| eyre!("Failed to set Lua path: {}", e))?;
+            .map_err(|e| miette!("Failed to set Lua path: {}", e))?;
 
         // Add to Fennel search path
         let current_fennel_path: String = fennel_module
@@ -267,7 +267,7 @@ fn run_script(
         );
         fennel_module
             .set("path", new_fennel_path)
-            .map_err(|e| eyre!("Failed to set Fennel path: {}", e))?;
+            .map_err(|e| miette!("Failed to set Fennel path: {}", e))?;
     }
 
     lua.load(format!(
@@ -275,7 +275,7 @@ fn run_script(
         script
     ))
     .exec()
-    .map_err(|e| eyre!("Failed to execute script: {}", e))?;
+    .map_err(|e| miette!("Failed to execute script: {}", e))?;
 
     Ok(())
 }
@@ -307,12 +307,16 @@ fn satisfy(status: &Status, assertion: &dyn AssertionType) -> Result<()> {
             println!("  expected: {}", conflict.expected);
             println!("    actual: {}", conflict.actual);
 
-            use std::io::{self, Write};
+            use std::io::Write as _;
             print!("Upgrade? [y/N]: ");
-            io::stdout().flush()?;
+            std::io::stdout()
+                .flush()
+                .map_err(|e| miette!("Failed to flush stdout: {}", e))?;
 
             let mut input = String::new();
-            io::stdin().read_line(&mut input)?;
+            std::io::stdin()
+                .read_line(&mut input)
+                .map_err(|e| miette!("Failed to read input: {}", e))?;
 
             match input.trim().to_lowercase().as_str() {
                 "y" | "yes" => {
@@ -338,7 +342,6 @@ fn run(command: &Commands) -> Result<()> {
 }
 
 fn main() -> Result<()> {
-    color_eyre::install()?;
     tracing_subscriber::fmt::init();
 
     let cli = Cli::parse();
diff --git a/frork-cli/src/utils.rs b/frork-cli/src/utils.rs
index c08ae68..282aefe 100644
--- a/frork-cli/src/utils.rs
+++ b/frork-cli/src/utils.rs
@@ -1,4 +1,4 @@
-use color_eyre::{Result, eyre::eyre};
+use miette::{Result, miette};
 use mlua::prelude::*;
 use regex::Regex;
 use std::env;
@@ -35,7 +35,7 @@ impl From<ExpandedPath> for String {
 }
 
 impl TryFrom<String> for ExpandedPath {
-    type Error = color_eyre::eyre::Error;
+    type Error = miette::Report;
 
     fn try_from(path: String) -> Result<Self> {
         Self::new(&path)
@@ -43,7 +43,7 @@ impl TryFrom<String> for ExpandedPath {
 }
 
 impl TryFrom<&str> for ExpandedPath {
-    type Error = color_eyre::eyre::Error;
+    type Error = miette::Report;
 
     fn try_from(path: &str) -> Result<Self> {
         Self::new(path)
@@ -110,7 +110,8 @@ impl Utils {
 
         // Expand tilde
         if expanded.starts_with('~') {
-            let home = env::var("HOME").map_err(|_| eyre!("HOME environment variable not set"))?;
+            let home =
+                env::var("HOME").map_err(|_| miette!("HOME environment variable not set"))?;
             expanded = expanded.replacen('~', &home, 1);
         }
 
@@ -130,7 +131,7 @@ impl Utils {
             .to_string();
 
         if !missing.is_empty() {
-            return Err(eyre!(
+            return Err(miette!(
                 "Environment variables not found: {}",
                 missing.join(", ")
             ));
@@ -167,13 +168,13 @@ impl Utils {
 
         let output = command
             .output()
-            .map_err(|e| eyre!("Failed to execute command '{}': {}", cmd, e))?;
+            .map_err(|e| miette!("Failed to execute command '{}': {}", cmd, e))?;
 
         let stdout = String::from_utf8_lossy(&output.stdout).to_string();
         let status = output
             .status
             .code()
-            .ok_or_else(|| eyre!("Command '{}' terminated by signal", cmd))?;
+            .ok_or_else(|| miette!("Command '{}' terminated by signal", cmd))?;
         debug!(
             "Command '{}' completed with status {}, stdout: {}",
             cmd,
@@ -191,7 +192,7 @@ impl Utils {
             debug!("Binary '{}' found in PATH", bin_name);
             Ok(())
         } else {
-            Err(eyre!("Required binary '{}' not found in PATH", bin_name))
+            Err(miette!("Required binary '{}' not found in PATH", bin_name))
         }
     }
 }