From cb73f1917ba543fa4a57dbdc42bf0ad2daccc134 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Wed, 18 Mar 2026 16:09:38 -0400 Subject: [PATCH 1/3] feat(cli): `--format` for JSON output --- cli/Cargo.toml | 1 + cli/src/main.rs | 114 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ef22220..b038d45 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -19,4 +19,5 @@ path = "src/main.rs" [dependencies] clap = { version = "4.6.0", features = ["derive"] } owo-colors.workspace = true +serde_json.workspace = true unrot_core = { version = "0.1.1", path = "../core" } diff --git a/cli/src/main.rs b/cli/src/main.rs index 61bc8a2..f9f6ba3 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,4 +1,4 @@ -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; use owo_colors::{OwoColorize, Stream}; use std::{io::IsTerminal, path::PathBuf}; use unrot_core::{ @@ -16,8 +16,18 @@ fn main() { 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() { + let format = match &cli.subcommand { + Some(Sub::Scan(s)) => s.format.unwrap_or(OutputFormat::Human), + Some(Sub::Fix(_)) => OutputFormat::Human, + Some(Sub::List(l)) => l.format.unwrap_or(OutputFormat::Human), + None => cli.format.unwrap_or(OutputFormat::Human), + }; + + let is_json = format == OutputFormat::Json; + + // JSON implies no color; also --no-color, NO_COLOR env, or piped stdout => plain + if is_json || no_color || std::env::var("NO_COLOR").is_ok() || !std::io::stdout().is_terminal() + { owo_colors::set_override(false); } else { owo_colors::unset_override(); @@ -60,18 +70,28 @@ fn main() { match mode { Mode::List => { - for link in &broken { - println!( - "{}", - link.link - .display() - .if_supports_color(Stream::Stdout, |v| v.red()) - ); + if is_json { + let paths: Vec<&std::path::Path> = + broken.iter().map(|b| b.link.as_path()).collect(); + let wrapper = serde_json::json!({ "broken_symlinks": paths }); + println!("{}", serde_json::to_string_pretty(&wrapper).unwrap()); + } else { + for link in &broken { + println!( + "{}", + link.link + .display() + .if_supports_color(Stream::Stdout, |v| v.red()) + ); + } } return; } Mode::Scan => { - if broken.is_empty() { + if is_json { + let wrapper = serde_json::json!({ "broken_symlinks": &broken }); + println!("{}", serde_json::to_string_pretty(&wrapper).unwrap()); + } else if broken.is_empty() { println!( "{}", "no broken symlinks found".if_supports_color(Stream::Stdout, |v| v.green()) @@ -88,7 +108,12 @@ fn main() { } return; } - Mode::Fix => {} + Mode::Fix => { + if is_json { + eprintln!("--format json is not supported in fix mode"); + std::process::exit(1); + } + } } let cases: Vec = broken @@ -133,6 +158,12 @@ fn resolve_path(path: &std::path::Path) -> PathBuf { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum OutputFormat { + Human, + Json, +} + enum Mode { Scan, Fix, @@ -174,6 +205,10 @@ struct Cli { /// Disable colored output #[arg(long)] no_color: bool, + + /// Output format (json only valid for scan/list) + #[arg(long, value_enum)] + format: Option, } #[derive(Subcommand)] @@ -200,6 +235,10 @@ struct ScanArgs { /// Disable colored output #[arg(long)] no_color: bool, + + /// Output format + #[arg(long, value_enum)] + format: Option, } #[derive(clap::Args)] @@ -240,6 +279,10 @@ struct ListArgs { /// Disable colored output #[arg(long)] no_color: bool, + + /// Output format + #[arg(long, value_enum)] + format: Option, } #[cfg(test)] @@ -366,4 +409,51 @@ mod tests { _ => panic!("expected List subcommand"), } } + + #[test] + fn unrot_scan_format_json() { + let cli = parse(&["scan", "/tmp", "--format", "json"]).unwrap(); + match &cli.subcommand { + Some(Sub::Scan(s)) => assert_eq!(s.format, Some(OutputFormat::Json)), + _ => panic!("expected Scan subcommand"), + } + } + + #[test] + fn unrot_scan_format_human() { + let cli = parse(&["scan", "/tmp", "--format", "human"]).unwrap(); + match &cli.subcommand { + Some(Sub::Scan(s)) => assert_eq!(s.format, Some(OutputFormat::Human)), + _ => panic!("expected Scan subcommand"), + } + } + + #[test] + fn unrot_scan_format_default_is_none() { + let cli = parse(&["scan", "/tmp"]).unwrap(); + match &cli.subcommand { + Some(Sub::Scan(s)) => assert_eq!(s.format, None), + _ => panic!("expected Scan subcommand"), + } + } + + #[test] + fn unrot_list_format_json() { + let cli = parse(&["list", "/tmp", "--format", "json"]).unwrap(); + match &cli.subcommand { + Some(Sub::List(l)) => assert_eq!(l.format, Some(OutputFormat::Json)), + _ => panic!("expected List subcommand"), + } + } + + #[test] + fn unrot_default_format_json() { + let cli = parse(&["--format", "json"]).unwrap(); + assert_eq!(cli.format, Some(OutputFormat::Json)); + } + + #[test] + fn unrot_format_invalid_rejected() { + assert!(parse(&["scan", "--format", "xml"]).is_err()); + } } From a82989f322a938ff7e46cd18c26ddfc6f2d508d7 Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Wed, 18 Mar 2026 16:10:08 -0400 Subject: [PATCH 2/3] feat(core): serialize core structs --- core/Cargo.toml | 1 + core/src/fuzzy.rs | 3 ++- core/src/resolver/model.rs | 2 ++ core/src/scanner.rs | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/core/Cargo.toml b/core/Cargo.toml index 9a74ed4..73393ff 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -15,6 +15,7 @@ categories = ["command-line-utilities", "filesystem"] [dependencies] walkdir = "2.5.0" owo-colors.workspace = true +serde.workspace = true [dev-dependencies] tempfile = "3" diff --git a/core/src/fuzzy.rs b/core/src/fuzzy.rs index 5578a14..ceadc5f 100644 --- a/core/src/fuzzy.rs +++ b/core/src/fuzzy.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use std::path::{Component, Path, PathBuf}; use walkdir::WalkDir; @@ -151,7 +152,7 @@ pub fn find_candidates( candidates } -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct ScoredCandidate { pub path: PathBuf, pub score: f64, diff --git a/core/src/resolver/model.rs b/core/src/resolver/model.rs index d4a4be4..9ddce60 100644 --- a/core/src/resolver/model.rs +++ b/core/src/resolver/model.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use std::{fmt, path::PathBuf}; use crate::fuzzy::ScoredCandidate; @@ -40,6 +41,7 @@ impl fmt::Display for Summary { } } +#[derive(Serialize)] pub struct RepairCase { pub link: PathBuf, pub original_target: PathBuf, diff --git a/core/src/scanner.rs b/core/src/scanner.rs index 6f1f0f6..82d68cf 100644 --- a/core/src/scanner.rs +++ b/core/src/scanner.rs @@ -1,4 +1,5 @@ use owo_colors::{OwoColorize, Stream}; +use serde::Serialize; use std::{ fmt, fs, path::{Path, PathBuf}, @@ -54,6 +55,7 @@ pub fn find_broken_symlinks(path: &Path, ignore: &[String]) -> Vec Date: Wed, 18 Mar 2026 16:10:29 -0400 Subject: [PATCH 3/3] chore(deps): add `serde` and `serde_json` --- Cargo.lock | 3 +++ Cargo.toml | 2 ++ 2 files changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 74c5609..dd29f33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,6 +352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -454,6 +455,7 @@ version = "0.1.1" dependencies = [ "clap", "owo-colors", + "serde_json", "unrot_core", ] @@ -462,6 +464,7 @@ name = "unrot_core" version = "0.1.1" dependencies = [ "owo-colors", + "serde", "tempfile", "walkdir", ] diff --git a/Cargo.toml b/Cargo.toml index 25ef38b..25803e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,5 @@ strip = "none" [workspace.dependencies] owo-colors = { version = "4.3.0", features = ["supports-colors"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149"