From 68da2e2374c6831068dbe6460bf25656dff7ab62 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Tue, 17 Mar 2026 16:04:46 -0400 Subject: [PATCH 1/3] chore(deps): owo_colors as a workspace dep --- Cargo.lock | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +++ 2 files changed, 57 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 6985c2f..74c5609 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "id-arena" version = "2.3.0" @@ -196,6 +202,23 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -250,6 +273,16 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -360,6 +393,25 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[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 = "syn" version = "2.0.117" @@ -401,6 +453,7 @@ name = "unrot" version = "0.1.1" dependencies = [ "clap", + "owo-colors", "unrot_core", ] @@ -408,6 +461,7 @@ dependencies = [ name = "unrot_core" version = "0.1.1" dependencies = [ + "owo-colors", "tempfile", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index 2b90878..25ef38b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,6 @@ overflow-checks = false [profile.bench] debug = true strip = "none" + +[workspace.dependencies] +owo-colors = { version = "4.3.0", features = ["supports-colors"] } From e420b205c4aaf10968cb08abf11408fd97d65b76 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Tue, 17 Mar 2026 16:05:35 -0400 Subject: [PATCH 2/3] feat(cli): `--no-color` configuration for commands/sub commands --- cli/Cargo.toml | 1 + cli/src/main.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index cbf1c06..ef22220 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -18,4 +18,5 @@ path = "src/main.rs" [dependencies] clap = { version = "4.6.0", features = ["derive"] } +owo-colors.workspace = true unrot_core = { version = "0.1.1", path = "../core" } diff --git a/cli/src/main.rs b/cli/src/main.rs index fff4633..61bc8a2 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,5 +1,6 @@ use clap::{Parser, Subcommand}; -use std::path::PathBuf; +use owo_colors::{OwoColorize, Stream}; +use std::{io::IsTerminal, path::PathBuf}; use unrot_core::{ BrokenSymlink, DEFAULT_IGNORE, RepairCase, TerminalIO, find_broken_symlinks, find_candidates, run, @@ -8,6 +9,20 @@ use unrot_core::{ fn main() { let cli = Cli::parse(); + let no_color = match &cli.subcommand { + Some(Sub::Scan(s)) => s.no_color, + Some(Sub::Fix(f)) => f.no_color, + Some(Sub::List(l)) => l.no_color, + None => cli.no_color, + }; + + // Configure colors: --no-color, NO_COLOR env, or piped stdout => plain + if no_color || std::env::var("NO_COLOR").is_ok() || !std::io::stdout().is_terminal() { + owo_colors::set_override(false); + } else { + owo_colors::unset_override(); + } + let (mode, path, extra_ignore, fix_opts) = match &cli.subcommand { Some(Sub::Scan(s)) => (Mode::Scan, resolve_path(&s.path), s.ignore.clone(), None), Some(Sub::Fix(f)) => ( @@ -46,15 +61,27 @@ fn main() { match mode { Mode::List => { for link in &broken { - println!("{}", link.link.display()); + println!( + "{}", + link.link + .display() + .if_supports_color(Stream::Stdout, |v| v.red()) + ); } return; } Mode::Scan => { if broken.is_empty() { - println!("no broken symlinks found"); + println!( + "{}", + "no broken symlinks found".if_supports_color(Stream::Stdout, |v| v.green()) + ); } else { - println!("found {} broken symlink(s):\n", broken.len()); + println!( + "{}", + format!("found {} broken symlink(s):\n", broken.len()) + .if_supports_color(Stream::Stdout, |v| v.yellow()) + ); for b in &broken { println!(" {b}"); } @@ -89,7 +116,10 @@ fn main() { } } Err(e) => { - eprintln!("error: {e}"); + eprintln!( + "{}", + format!("error: {e}").if_supports_color(Stream::Stderr, |v| v.red()) + ); std::process::exit(1); } } @@ -140,6 +170,10 @@ struct Cli { /// Collect all decisions, show summary, then confirm before applying #[arg(long)] batch_confirm: bool, + + /// Disable colored output + #[arg(long)] + no_color: bool, } #[derive(Subcommand)] @@ -162,6 +196,10 @@ struct ScanArgs { /// Additional directory names to ignore during walks #[arg(short = 'I', long)] ignore: Vec, + + /// Disable colored output + #[arg(long)] + no_color: bool, } #[derive(clap::Args)] @@ -184,6 +222,10 @@ struct FixArgs { /// Collect all decisions, show summary, then confirm before applying #[arg(long)] batch_confirm: bool, + + /// Disable colored output + #[arg(long)] + no_color: bool, } #[derive(clap::Args)] @@ -194,6 +236,10 @@ struct ListArgs { /// Additional directory names to ignore during walks #[arg(short = 'I', long)] ignore: Vec, + + /// Disable colored output + #[arg(long)] + no_color: bool, } #[cfg(test)] From 2382df43dcc1e2bf2e2e07ed03d7599eb9aff780 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Tue, 17 Mar 2026 16:06:35 -0400 Subject: [PATCH 3/3] feat(core): wire color formatting through core functionalities --- CHANGELOG.md | 38 +++++++++++++++++++++++ core/Cargo.toml | 1 + core/src/resolver/display.rs | 59 ++++++++++++++++++++++++++++++------ core/src/resolver/session.rs | 32 ++++++++++++++----- core/src/scanner.rs | 13 +++++++- 5 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..823db37 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Fixed +- `--no-color` and `NO_COLOR` now correctly suppress all ANSI codes. Plain + `.red()` / `.green()` etc. emit codes unconditionally; switched everything to + `if_supports_color()` which respects the owo-colors override system. + +## [0.1.1] - 2026-03-15 + +### Added +- Interactive fix mode: resolver control loop with IO abstraction and mock-driven tests +- Filesystem operation layer with `--dry-run` support +- `--batch-confirm` flag to collect all decisions then confirm before applying +- Safety confirmation layer for destructive actions (remove) +- Fuzzy candidate search with Levenshtein distance, path similarity, and depth penalty scoring +- Configurable ignore patterns (`-I`) propagated to scanner and fuzzy walker +- Cross-filesystem and loop-detection warnings +- CI lint workflow +- Release profile with LTO, single codegen unit, and stripped binaries + +### Changed +- Replaced subprocess-based resolution with native Rust implementation + +## [0.1.0] - 2026-03-13 + +### Added +- Initial release +- Recursive broken symlink scanner +- Reports dead target path for each broken link +- Basic `--list` and `--path` flags +- Fix for correctly identifying broken symlinks (not all symlinks) diff --git a/core/Cargo.toml b/core/Cargo.toml index 242a81e..9a74ed4 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,6 +14,7 @@ categories = ["command-line-utilities", "filesystem"] [dependencies] walkdir = "2.5.0" +owo-colors.workspace = true [dev-dependencies] tempfile = "3" diff --git a/core/src/resolver/display.rs b/core/src/resolver/display.rs index 7a5503e..d21edfb 100644 --- a/core/src/resolver/display.rs +++ b/core/src/resolver/display.rs @@ -1,5 +1,7 @@ use std::fmt; +use owo_colors::{OwoColorize, Stream}; + use super::{ model::RepairCase, safety::{format_warnings, relink_warnings}, @@ -17,7 +19,15 @@ pub fn format_header(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Result ref original_target, .. } = *case; - writeln!(w, "{} -> {}", link.display(), original_target.display()) + writeln!( + w, + "{} -> {}", + link.display() + .if_supports_color(Stream::Stdout, |v| v.red()), + original_target + .display() + .if_supports_color(Stream::Stdout, |v| v.red()) + ) } pub fn format_candidates(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Result { @@ -27,7 +37,11 @@ pub fn format_candidates(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Res .. } = *case; if candidates.is_empty() { - writeln!(w, " no candidates found") + writeln!( + w, + " {}", + "no candidates found".if_supports_color(Stream::Stdout, |v| v.yellow()) + ) } else { let target_basename = original_target .file_name() @@ -45,15 +59,24 @@ pub fn format_candidates(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Res writeln!( w, " [{}] {} (score: {:.2}, {} shared dirs, {})", - i + 1, - candidate.path.display(), + (i + 1) + .to_string() + .if_supports_color(Stream::Stdout, |v| v.cyan()), + candidate + .path + .display() + .if_supports_color(Stream::Stdout, |v| v.green()), candidate.score, candidate.shared_dirs, basename_note )?; let warnings = relink_warnings(&case.link, original_target, &candidate.path); if !warnings.is_empty() { - write!(w, "{}", format_warnings(&warnings))?; + write!( + w, + "{}", + format_warnings(&warnings).if_supports_color(Stream::Stdout, |v| v.yellow()) + )?; } } Ok(()) @@ -62,16 +85,21 @@ pub fn format_candidates(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Res pub fn format_actions(w: &mut impl fmt::Write, case: &RepairCase) -> fmt::Result { let RepairCase { ref candidates, .. } = *case; - if candidates.is_empty() { - writeln!(w, " [c] custom path [s] skip [r] remove") + let actions = if candidates.is_empty() { + "[c] custom path [s] skip [r] remove".to_string() } else { let n = candidates.len(); if n == 1 { - writeln!(w, " [1] select [c] custom path [s] skip [r] remove") + "[1] select [c] custom path [s] skip [r] remove".to_string() } else { - writeln!(w, " [1-{n}] select [c] custom path [s] skip [r] remove") + format!("[1-{n}] select [c] custom path [s] skip [r] remove") } - } + }; + writeln!( + w, + " {}", + actions.if_supports_color(Stream::Stdout, |v| v.cyan()) + ) } #[cfg(test)] @@ -79,6 +107,10 @@ mod tests { use super::*; use crate::fuzzy::ScoredCandidate; + fn disable_colors() { + owo_colors::set_override(false); + } + fn case_with_candidates() -> RepairCase { RepairCase::new( "/home/user/link".into(), @@ -119,6 +151,7 @@ mod tests { #[test] fn header_shows_link_and_target() { + disable_colors(); let mut out = String::new(); format_header(&mut out, &case_with_candidates()).unwrap(); assert_eq!(out, "/home/user/link -> /old/target.txt\n"); @@ -126,6 +159,7 @@ mod tests { #[test] fn candidates_listed_with_scores() { + disable_colors(); let mut out = String::new(); format_candidates(&mut out, &case_with_candidates()).unwrap(); assert!(out.contains("[1] /home/user/target.txt (score: 3.20")); @@ -136,6 +170,7 @@ mod tests { #[test] fn no_candidates_message() { + disable_colors(); let mut out = String::new(); format_candidates(&mut out, &case_without_candidates()).unwrap(); assert_eq!(out, " no candidates found\n"); @@ -143,6 +178,7 @@ mod tests { #[test] fn actions_with_multiple_candidates() { + disable_colors(); let mut out = String::new(); format_actions(&mut out, &case_with_candidates()).unwrap(); assert!(out.contains("[1-2] select")); @@ -153,6 +189,7 @@ mod tests { #[test] fn actions_with_single_candidate() { + disable_colors(); let mut out = String::new(); format_actions(&mut out, &case_single_candidate()).unwrap(); assert!(out.contains("[1] select")); @@ -161,6 +198,7 @@ mod tests { #[test] fn actions_without_candidates() { + disable_colors(); let mut out = String::new(); format_actions(&mut out, &case_without_candidates()).unwrap(); assert!(!out.contains("select")); @@ -171,6 +209,7 @@ mod tests { #[test] fn present_combines_all_sections() { + disable_colors(); let mut out = String::new(); present(&mut out, &case_with_candidates()).unwrap(); diff --git a/core/src/resolver/session.rs b/core/src/resolver/session.rs index b2b7661..cd7e8c2 100644 --- a/core/src/resolver/session.rs +++ b/core/src/resolver/session.rs @@ -1,3 +1,4 @@ +use owo_colors::{OwoColorize, Stream}; use std::io; use super::{ @@ -44,7 +45,10 @@ fn run_immediate( summary.record(&action); } Err(e) => { - io.write_str(&format!(" error: {e}\n"))?; + io.write_str(&format!( + " {}\n", + format!("error: {e}").if_supports_color(Stream::Stdout, |v| v.red()) + ))?; summary.record(&Action::Skip); } } @@ -100,7 +104,10 @@ fn run_batch(cases: &[RepairCase], io: &mut impl ResolverIO, dry_run: bool) -> i summary.record(&action); } Err(e) => { - io.write_str(&format!(" error: {e}\n"))?; + io.write_str(&format!( + " {}\n", + format!("error: {e}").if_supports_color(Stream::Stdout, |v| v.red()) + ))?; summary.record(&Action::Skip); } } @@ -152,14 +159,23 @@ fn prompt_until_resolved(case: &RepairCase, io: &mut impl ResolverIO) -> io::Res fn format_outcome(io: &mut impl ResolverIO, action: &Action, dry_run: bool) -> io::Result<()> { match (action, dry_run) { (Action::Relink(target), true) => io.write_str(&format!( - " [dry run] would relink -> {}\n", + " {} -> {}\n", + "[dry run] would relink".if_supports_color(Stream::Stdout, |v| v.cyan()), target.display() )), - (Action::Remove, true) => io.write_str(" [dry run] would remove\n"), - (Action::Relink(target), false) => { - io.write_str(&format!(" relinked -> {}\n", target.display())) - } - (Action::Remove, false) => io.write_str(" removed\n"), + (Action::Remove, true) => io.write_str(&format!( + " {}\n", + "[dry run] would remove".if_supports_color(Stream::Stdout, |v| v.cyan()) + )), + (Action::Relink(target), false) => io.write_str(&format!( + " {} -> {}\n", + "relinked".if_supports_color(Stream::Stdout, |v| v.green()), + target.display() + )), + (Action::Remove, false) => io.write_str(&format!( + " {}\n", + "removed".if_supports_color(Stream::Stdout, |v| v.yellow()) + )), (Action::Skip, _) => Ok(()), } } diff --git a/core/src/scanner.rs b/core/src/scanner.rs index c8868eb..6f1f0f6 100644 --- a/core/src/scanner.rs +++ b/core/src/scanner.rs @@ -1,3 +1,4 @@ +use owo_colors::{OwoColorize, Stream}; use std::{ fmt, fs, path::{Path, PathBuf}, @@ -60,7 +61,16 @@ pub struct BrokenSymlink { impl fmt::Display for BrokenSymlink { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{} -> {}", self.link.display(), self.target.display()) + write!( + f, + "{} -> {}", + self.link + .display() + .if_supports_color(Stream::Stdout, |v| v.red()), + self.target + .display() + .if_supports_color(Stream::Stdout, |v| v.red()) + ) } } @@ -231,6 +241,7 @@ mod tests { #[test] fn display_format_is_correct() { + owo_colors::set_override(false); let bs = BrokenSymlink { link: PathBuf::from("/home/user/link"), target: PathBuf::from("/home/user/missing"),