feat: add quiet/verbose modes with command logging

This commit is contained in:
2026-01-01 18:05:13 -05:00
parent 2e55677e35
commit 5ebf256a58
5 changed files with 337 additions and 31 deletions

View File

@@ -49,6 +49,16 @@ vid-repair fix --policy aggressive /path/to/videos
vid-repair scan --json /path/to/videos vid-repair scan --json /path/to/videos
``` ```
### Verbosity 🔈
```bash
# More detail (commands + error lines)
vid-repair fix -v /path/to/videos
# Less detail (summary only)
vid-repair scan -q /path/to/videos
```
**Note:** `fix` always performs a full scan first, then repairs only if issues are found. **Note:** `fix` always performs a full scan first, then repairs only if issues are found.
Use `scan` when you just want a report without making changes. Use `scan` when you just want a report without making changes.

View File

@@ -32,6 +32,8 @@ keep_original = false
json = false json = false
# Pretty-print JSON output. # Pretty-print JSON output.
pretty = true pretty = true
# quiet|normal|verbose - controls console verbosity.
verbosity = "normal"
[performance] [performance]
# 0 = auto (num CPU threads). Higher values spawn more concurrent scans. # 0 = auto (num CPU threads). Higher values spawn more concurrent scans.

View File

@@ -129,6 +129,8 @@ pub struct ReportConfig {
pub json: bool, pub json: bool,
#[serde(default)] #[serde(default)]
pub pretty: bool, pub pretty: bool,
#[serde(default)]
pub verbosity: Verbosity,
} }
impl Default for ReportConfig { impl Default for ReportConfig {
@@ -136,10 +138,25 @@ impl Default for ReportConfig {
Self { Self {
json: false, json: false,
pretty: true, pretty: true,
verbosity: Verbosity::Normal,
} }
} }
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Verbosity {
Quiet,
Normal,
Verbose,
}
impl Default for Verbosity {
fn default() -> Self {
Verbosity::Normal
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceConfig { pub struct PerformanceConfig {
#[serde(default)] #[serde(default)]
@@ -185,6 +202,7 @@ pub struct ConfigOverrides {
pub output_dir: Option<String>, pub output_dir: Option<String>,
pub keep_original: Option<bool>, pub keep_original: Option<bool>,
pub json: Option<bool>, pub json: Option<bool>,
pub verbosity: Option<Verbosity>,
pub jobs: Option<usize>, pub jobs: Option<usize>,
pub watch: Option<bool>, pub watch: Option<bool>,
} }
@@ -215,6 +233,9 @@ impl Config {
if let Some(value) = overrides.json { if let Some(value) = overrides.json {
self.report.json = value; self.report.json = value;
} }
if let Some(value) = overrides.verbosity {
self.report.verbosity = value;
}
if let Some(value) = overrides.jobs { if let Some(value) = overrides.jobs {
self.performance.jobs = value; self.performance.jobs = value;
} }

View File

@@ -15,7 +15,7 @@ use crate::scan::{scan_file, DecodeProgress};
use crate::rules::RuleSet; use crate::rules::RuleSet;
pub fn apply_fix(path: &Path, plan: &FixPlan, config: &Config, ruleset: &RuleSet) -> Result<FixOutcome> { pub fn apply_fix(path: &Path, plan: &FixPlan, config: &Config, ruleset: &RuleSet) -> Result<FixOutcome> {
apply_fix_with_progress(path, plan, config, ruleset, None, None) apply_fix_with_progress(path, plan, config, ruleset, None, None, None)
} }
pub fn apply_fix_with_progress( pub fn apply_fix_with_progress(
@@ -25,6 +25,7 @@ pub fn apply_fix_with_progress(
ruleset: &RuleSet, ruleset: &RuleSet,
duration: Option<f64>, duration: Option<f64>,
progress: Option<Arc<dyn Fn(DecodeProgress) + Send + Sync>>, progress: Option<Arc<dyn Fn(DecodeProgress) + Send + Sync>>,
log_cmd: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Result<FixOutcome> { ) -> Result<FixOutcome> {
if plan.actions.is_empty() { if plan.actions.is_empty() {
return Ok(FixOutcome { return Ok(FixOutcome {
@@ -43,7 +44,15 @@ pub fn apply_fix_with_progress(
let action = &plan.actions[0]; let action = &plan.actions[0];
let output = prepare_output_path(path, config)?; let output = prepare_output_path(path, config)?;
run_ffmpeg_fix(path, &output.temp_path, action.kind, config, duration, progress)?; run_ffmpeg_fix(
path,
&output.temp_path,
action.kind,
config,
duration,
progress,
log_cmd,
)?;
let verification = scan_file(&output.temp_path, config, ruleset) let verification = scan_file(&output.temp_path, config, ruleset)
.with_context(|| format!("Failed to verify output {}", output.temp_path.display()))?; .with_context(|| format!("Failed to verify output {}", output.temp_path.display()))?;
@@ -78,26 +87,44 @@ fn run_ffmpeg_fix(
config: &Config, config: &Config,
duration: Option<f64>, duration: Option<f64>,
progress: Option<Arc<dyn Fn(DecodeProgress) + Send + Sync>>, progress: Option<Arc<dyn Fn(DecodeProgress) + Send + Sync>>,
log_cmd: Option<Arc<dyn Fn(String) + Send + Sync>>,
) -> Result<()> { ) -> Result<()> {
let mut cmd = Command::new(&config.ffmpeg_path); let mut args = vec![
cmd.arg("-y").arg("-v").arg("error").arg("-i").arg(path); "-y".to_string(),
"-v".to_string(),
"error".to_string(),
"-i".to_string(),
path.display().to_string(),
];
match kind { match kind {
FixKind::Remux => { FixKind::Remux => {
cmd.arg("-c").arg("copy"); args.extend(["-c", "copy"].iter().map(|s| s.to_string()));
} }
FixKind::Faststart => { FixKind::Faststart => {
cmd.arg("-c").arg("copy"); args.extend(["-c", "copy", "-movflags", "+faststart"].iter().map(|s| s.to_string()));
cmd.arg("-movflags").arg("+faststart");
} }
FixKind::Reencode => { FixKind::Reencode => {
cmd.arg("-c:v").arg("libx264"); args.extend(
cmd.arg("-c:a").arg("aac"); ["-c:v", "libx264", "-c:a", "aac", "-movflags", "+faststart"]
cmd.arg("-movflags").arg("+faststart"); .iter()
.map(|s| s.to_string()),
);
} }
} }
cmd.arg(output); if progress.is_some() {
args.extend(["-nostats", "-progress", "pipe:2"].iter().map(|s| s.to_string()));
}
args.push(output.display().to_string());
if let Some(log_cmd) = log_cmd {
log_cmd(format_command_line(&config.ffmpeg_path, &args));
}
let mut cmd = Command::new(&config.ffmpeg_path);
cmd.args(&args);
if progress.is_none() { if progress.is_none() {
let output = cmd let output = cmd
@@ -112,7 +139,6 @@ fn run_ffmpeg_fix(
return Ok(()); return Ok(());
} }
cmd.arg("-nostats").arg("-progress").arg("pipe:2");
cmd.stdout(Stdio::null()).stderr(Stdio::piped()); cmd.stdout(Stdio::null()).stderr(Stdio::piped());
let mut child = cmd let mut child = cmd
@@ -371,3 +397,25 @@ fn parse_out_time(value: &str) -> Option<f64> {
None None
} }
fn format_command_line(program: &str, args: &[String]) -> String {
let mut line = String::new();
line.push_str(&quote_arg(program));
for arg in args {
line.push(' ');
line.push_str(&quote_arg(arg));
}
line
}
fn quote_arg(arg: &str) -> String {
if arg.is_empty() {
return "\"\"".to_string();
}
if arg.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') {
let escaped = arg.replace('"', "\\\"");
format!("\"{}\"", escaped)
} else {
arg.to_string()
}
}

View File

@@ -8,7 +8,7 @@ use clap::{Parser, Subcommand, ValueEnum};
use rayon::prelude::*; use rayon::prelude::*;
use rayon::ThreadPoolBuilder; use rayon::ThreadPoolBuilder;
use vid_repair_core::config::{Config, ConfigOverrides, FixPolicy, ScanDepth}; use vid_repair_core::config::{Config, ConfigOverrides, FixPolicy, ScanDepth, Verbosity};
use vid_repair_core::fix::{self, FixOutcome, FixPlan}; use vid_repair_core::fix::{self, FixOutcome, FixPlan};
use vid_repair_core::report::{ use vid_repair_core::report::{
render_fix_line, render_json, render_scan_line, render_summary, FixJsonReport, render_fix_line, render_json, render_scan_line, render_summary, FixJsonReport,
@@ -30,6 +30,14 @@ struct Cli {
#[arg(long)] #[arg(long)]
json: bool, json: bool,
/// Increase verbosity (show commands and error details)
#[arg(short = 'v', long, action = clap::ArgAction::Count, conflicts_with = "quiet")]
verbose: u8,
/// Reduce output (hide progress/details)
#[arg(short = 'q', long, conflicts_with = "verbose")]
quiet: bool,
/// Number of parallel jobs (0 = auto) /// Number of parallel jobs (0 = auto)
#[arg(long)] #[arg(long)]
jobs: Option<usize>, jobs: Option<usize>,
@@ -50,6 +58,8 @@ struct Cli {
struct CommonArgs { struct CommonArgs {
config: Option<PathBuf>, config: Option<PathBuf>,
json: bool, json: bool,
verbose: u8,
quiet: bool,
jobs: Option<usize>, jobs: Option<usize>,
ffmpeg_path: Option<String>, ffmpeg_path: Option<String>,
ffprobe_path: Option<String>, ffprobe_path: Option<String>,
@@ -200,6 +210,8 @@ fn run() -> Result<i32> {
let Cli { let Cli {
config, config,
json, json,
verbose,
quiet,
jobs, jobs,
ffmpeg_path, ffmpeg_path,
ffprobe_path, ffprobe_path,
@@ -209,6 +221,8 @@ fn run() -> Result<i32> {
let common = CommonArgs { let common = CommonArgs {
config, config,
json, json,
verbose,
quiet,
jobs, jobs,
ffmpeg_path, ffmpeg_path,
ffprobe_path, ffprobe_path,
@@ -266,6 +280,11 @@ fn handle_scan(args: ScanArgs, common: &CommonArgs) -> Result<i32> {
if common.json { if common.json {
overrides.json = Some(true); overrides.json = Some(true);
} }
if common.quiet {
overrides.verbosity = Some(Verbosity::Quiet);
} else if common.verbose > 0 {
overrides.verbosity = Some(Verbosity::Verbose);
}
if let Some(jobs) = common.jobs { if let Some(jobs) = common.jobs {
overrides.jobs = Some(jobs); overrides.jobs = Some(jobs);
} }
@@ -298,7 +317,7 @@ fn handle_scan(args: ScanArgs, common: &CommonArgs) -> Result<i32> {
return Ok(0); return Ok(0);
} }
if !config.report.json { if show_phase(&config) {
eprintln!("Scanning {} file(s)...", files.len()); eprintln!("Scanning {} file(s)...", files.len());
} }
@@ -314,8 +333,10 @@ fn handle_scan(args: ScanArgs, common: &CommonArgs) -> Result<i32> {
let json = render_json(&payload, config.report.pretty)?; let json = render_json(&payload, config.report.pretty)?;
println!("{}", json); println!("{}", json);
} else { } else {
for scan in &scans { if !is_quiet(&config) {
println!("{}", render_scan_line(scan)); for scan in &scans {
println!("{}", render_scan_line(scan));
}
} }
println!("{}", render_summary(&scans, None)); println!("{}", render_summary(&scans, None));
} }
@@ -345,6 +366,11 @@ fn handle_fix(args: FixArgs, common: &CommonArgs) -> Result<i32> {
if common.json { if common.json {
overrides.json = Some(true); overrides.json = Some(true);
} }
if common.quiet {
overrides.verbosity = Some(Verbosity::Quiet);
} else if common.verbose > 0 {
overrides.verbosity = Some(Verbosity::Verbose);
}
overrides.jobs = common.jobs; overrides.jobs = common.jobs;
overrides.scan_depth = args.scan_depth.map(ScanDepth::from); overrides.scan_depth = args.scan_depth.map(ScanDepth::from);
if args.recursive { if args.recursive {
@@ -378,7 +404,7 @@ fn handle_fix(args: FixArgs, common: &CommonArgs) -> Result<i32> {
return Ok(0); return Ok(0);
} }
if !config.report.json { if show_phase(&config) {
if args.dry_run { if args.dry_run {
eprintln!("Planning fixes for {} file(s)...", files.len()); eprintln!("Planning fixes for {} file(s)...", files.len());
} else { } else {
@@ -399,8 +425,10 @@ fn handle_fix(args: FixArgs, common: &CommonArgs) -> Result<i32> {
let json = render_json(&payload, config.report.pretty)?; let json = render_json(&payload, config.report.pretty)?;
println!("{}", json); println!("{}", json);
} else { } else {
for (scan, fix) in scans.iter().zip(fixes.iter()) { if !is_quiet(&config) {
println!("{}", render_fix_line(scan, fix)); for (scan, fix) in scans.iter().zip(fixes.iter()) {
println!("{}", render_fix_line(scan, fix));
}
} }
println!("{}", render_summary(&scans, Some(&fixes))); println!("{}", render_summary(&scans, Some(&fixes)));
} }
@@ -433,7 +461,7 @@ fn run_scans(files: Vec<PathBuf>, config: &Config, ruleset: &RuleSet) -> Result<
let errors = AtomicUsize::new(0); let errors = AtomicUsize::new(0);
let started = AtomicUsize::new(0); let started = AtomicUsize::new(0);
let total = files.len(); let total = files.len();
let show_progress = !config.report.json && std::io::stderr().is_terminal(); let show_progress = progress_enabled(config);
let in_place = show_progress && jobs.is_none(); let in_place = show_progress && jobs.is_none();
let scans = if let Some(jobs) = jobs { let scans = if let Some(jobs) = jobs {
@@ -449,13 +477,25 @@ fn run_scans(files: Vec<PathBuf>, config: &Config, ruleset: &RuleSet) -> Result<
eprint!("[SCAN {}/{}] {}", idx, total, path.display()); eprint!("[SCAN {}/{}] {}", idx, total, path.display());
let _ = std::io::stderr().flush(); let _ = std::io::stderr().flush();
} }
if is_verbose(config) {
eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, path));
eprintln!(
"{}",
render_decode_cmd(&config.ffmpeg_path, path, config.scan.depth, show_progress)
);
}
let progress = if show_progress { let progress = if show_progress {
Some(make_progress_callback("SCAN", idx, total, path, in_place)) Some(make_progress_callback("SCAN", idx, total, path, in_place))
} else { } else {
None None
}; };
match scan_file_with_progress(path, config, ruleset, progress) { match scan_file_with_progress(path, config, ruleset, progress) {
Ok(scan) => Some(scan), Ok(scan) => {
if is_verbose(config) {
emit_verbose_scan_details(config, &scan);
}
Some(scan)
}
Err(err) => { Err(err) => {
if in_place { if in_place {
eprintln!(); eprintln!();
@@ -479,13 +519,25 @@ fn run_scans(files: Vec<PathBuf>, config: &Config, ruleset: &RuleSet) -> Result<
eprint!("[SCAN {}/{}] {}", idx, total, path.display()); eprint!("[SCAN {}/{}] {}", idx, total, path.display());
let _ = std::io::stderr().flush(); let _ = std::io::stderr().flush();
} }
if is_verbose(config) {
eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, path));
eprintln!(
"{}",
render_decode_cmd(&config.ffmpeg_path, path, config.scan.depth, show_progress)
);
}
let progress = if show_progress { let progress = if show_progress {
Some(make_progress_callback("SCAN", idx, total, path, in_place)) Some(make_progress_callback("SCAN", idx, total, path, in_place))
} else { } else {
None None
}; };
match scan_file_with_progress(path, config, ruleset, progress) { match scan_file_with_progress(path, config, ruleset, progress) {
Ok(scan) => Some(scan), Ok(scan) => {
if is_verbose(config) {
emit_verbose_scan_details(config, &scan);
}
Some(scan)
}
Err(err) => { Err(err) => {
if in_place { if in_place {
eprintln!(); eprintln!();
@@ -535,10 +587,16 @@ fn process_fix_batch(
let mut fixes = Vec::new(); let mut fixes = Vec::new();
let mut errors = 0usize; let mut errors = 0usize;
let total = files.len(); let total = files.len();
let show_progress = !config.report.json && std::io::stderr().is_terminal(); let show_progress = progress_enabled(config);
let in_place = show_progress; let in_place = show_progress;
let mut idx = 0usize; let mut idx = 0usize;
let log_cmd: Option<Arc<dyn Fn(String) + Send + Sync>> = if is_verbose(config) {
Some(Arc::new(|cmd: String| eprintln!("[CMD] {}", cmd)))
} else {
None
};
for path in files { for path in files {
idx += 1; idx += 1;
if show_progress && !in_place { if show_progress && !in_place {
@@ -547,6 +605,13 @@ fn process_fix_batch(
eprint!("[SCAN {}/{}] {}", idx, total, path.display()); eprint!("[SCAN {}/{}] {}", idx, total, path.display());
let _ = std::io::stderr().flush(); let _ = std::io::stderr().flush();
} }
if is_verbose(config) {
eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, &path));
eprintln!(
"{}",
render_decode_cmd(&config.ffmpeg_path, &path, config.scan.depth, show_progress)
);
}
let scan_progress = if show_progress { let scan_progress = if show_progress {
Some(make_progress_callback("SCAN", idx, total, &path, in_place)) Some(make_progress_callback("SCAN", idx, total, &path, in_place))
} else { } else {
@@ -563,11 +628,14 @@ fn process_fix_batch(
continue; continue;
} }
}; };
if show_progress { if show_phase(config) {
eprintln!("{}", render_scan_result_line(&scan)); eprintln!("{}", render_scan_result_line(&scan));
} }
if is_verbose(config) {
emit_verbose_scan_details(config, &scan);
}
let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy);
if show_progress { if show_phase(config) {
eprintln!("{}", render_plan_line(&plan)); eprintln!("{}", render_plan_line(&plan));
} }
let outcome = if dry_run { let outcome = if dry_run {
@@ -587,6 +655,7 @@ fn process_fix_batch(
} else { } else {
None None
}; };
let log_cmd = log_cmd.clone();
match fix::executor::apply_fix_with_progress( match fix::executor::apply_fix_with_progress(
&path, &path,
&plan, &plan,
@@ -594,6 +663,7 @@ fn process_fix_batch(
ruleset, ruleset,
scan.probe.duration, scan.probe.duration,
fix_progress, fix_progress,
log_cmd,
) { ) {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(err) => FixOutcome { Err(err) => FixOutcome {
@@ -623,14 +693,28 @@ fn process_fix_batch_parallel(
let errors = AtomicUsize::new(0); let errors = AtomicUsize::new(0);
let started = AtomicUsize::new(0); let started = AtomicUsize::new(0);
let total = files.len(); let total = files.len();
let show_progress = !config.report.json && std::io::stderr().is_terminal(); let show_progress = progress_enabled(config);
let log_cmd: Option<Arc<dyn Fn(String) + Send + Sync>> = if is_verbose(config) {
Some(Arc::new(|cmd: String| eprintln!("[CMD] {}", cmd)))
} else {
None
};
let results = files let results = files
.par_iter() .par_iter()
.filter_map(|path| { .filter_map(|path| {
let log_cmd = log_cmd.clone();
let idx = started.fetch_add(1, Ordering::SeqCst) + 1; let idx = started.fetch_add(1, Ordering::SeqCst) + 1;
if show_progress { if show_progress {
eprintln!("[SCAN {}/{}] {}", idx, total, path.display()); eprintln!("[SCAN {}/{}] {}", idx, total, path.display());
} }
if is_verbose(config) {
eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, path));
eprintln!(
"{}",
render_decode_cmd(&config.ffmpeg_path, path, config.scan.depth, show_progress)
);
}
let scan_progress = if show_progress { let scan_progress = if show_progress {
Some(make_progress_callback("SCAN", idx, total, path, false)) Some(make_progress_callback("SCAN", idx, total, path, false))
} else { } else {
@@ -644,11 +728,14 @@ fn process_fix_batch_parallel(
return None; return None;
} }
}; };
if show_progress { if show_phase(config) {
eprintln!("{}", render_scan_result_line(&scan)); eprintln!("{}", render_scan_result_line(&scan));
} }
if is_verbose(config) {
emit_verbose_scan_details(config, &scan);
}
let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy);
if show_progress { if show_phase(config) {
eprintln!("{}", render_plan_line(&plan)); eprintln!("{}", render_plan_line(&plan));
} }
let outcome = if dry_run { let outcome = if dry_run {
@@ -672,6 +759,7 @@ fn process_fix_batch_parallel(
ruleset, ruleset,
scan.probe.duration, scan.probe.duration,
fix_progress, fix_progress,
log_cmd,
) { ) {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(err) => FixOutcome { Err(err) => FixOutcome {
@@ -696,7 +784,7 @@ fn process_fix_batch_parallel(
fn watch_scan(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet) -> Result<()> { fn watch_scan(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet) -> Result<()> {
println!("Watch mode enabled. Waiting for files to settle..."); println!("Watch mode enabled. Waiting for files to settle...");
watch::watch_paths(&paths, config, |path| { watch::watch_paths(&paths, config, |path| {
let show_progress = !config.report.json && std::io::stderr().is_terminal(); let show_progress = progress_enabled(config);
let in_place = show_progress; let in_place = show_progress;
if show_progress && !in_place { if show_progress && !in_place {
eprintln!("[SCAN] {}", path.display()); eprintln!("[SCAN] {}", path.display());
@@ -704,6 +792,13 @@ fn watch_scan(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet) -> Result
eprint!("[SCAN] {}", path.display()); eprint!("[SCAN] {}", path.display());
let _ = std::io::stderr().flush(); let _ = std::io::stderr().flush();
} }
if is_verbose(config) {
eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, &path));
eprintln!(
"{}",
render_decode_cmd(&config.ffmpeg_path, &path, config.scan.depth, show_progress)
);
}
let scan_progress = if show_progress { let scan_progress = if show_progress {
Some(make_progress_callback("SCAN", 1, 1, &path, in_place)) Some(make_progress_callback("SCAN", 1, 1, &path, in_place))
} else { } else {
@@ -711,6 +806,12 @@ fn watch_scan(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet) -> Result
}; };
match scan_file_with_progress(&path, config, ruleset, scan_progress) { match scan_file_with_progress(&path, config, ruleset, scan_progress) {
Ok(scan) => { Ok(scan) => {
if show_phase(config) {
eprintln!("{}", render_scan_result_line(&scan));
}
if is_verbose(config) {
emit_verbose_scan_details(config, &scan);
}
println!("{}", render_scan_line(&scan)); println!("{}", render_scan_line(&scan));
} }
Err(err) => { Err(err) => {
@@ -722,8 +823,13 @@ fn watch_scan(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet) -> Result
fn watch_fix(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet, dry_run: bool) -> Result<()> { fn watch_fix(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet, dry_run: bool) -> Result<()> {
println!("Watch mode enabled. Waiting for files to settle..."); println!("Watch mode enabled. Waiting for files to settle...");
let log_cmd: Option<Arc<dyn Fn(String) + Send + Sync>> = if is_verbose(config) {
Some(Arc::new(|cmd: String| eprintln!("[CMD] {}", cmd)))
} else {
None
};
watch::watch_paths(&paths, config, |path| { watch::watch_paths(&paths, config, |path| {
let show_progress = !config.report.json && std::io::stderr().is_terminal(); let show_progress = progress_enabled(config);
let in_place = show_progress; let in_place = show_progress;
if show_progress && !in_place { if show_progress && !in_place {
eprintln!("[SCAN] {}", path.display()); eprintln!("[SCAN] {}", path.display());
@@ -731,6 +837,13 @@ fn watch_fix(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet, dry_run: b
eprint!("[SCAN] {}", path.display()); eprint!("[SCAN] {}", path.display());
let _ = std::io::stderr().flush(); let _ = std::io::stderr().flush();
} }
if is_verbose(config) {
eprintln!("{}", render_ffprobe_cmd(&config.ffprobe_path, &path));
eprintln!(
"{}",
render_decode_cmd(&config.ffmpeg_path, &path, config.scan.depth, show_progress)
);
}
let scan_progress = if show_progress { let scan_progress = if show_progress {
Some(make_progress_callback("SCAN", 1, 1, &path, in_place)) Some(make_progress_callback("SCAN", 1, 1, &path, in_place))
} else { } else {
@@ -739,10 +852,13 @@ fn watch_fix(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet, dry_run: b
match scan_file_with_progress(&path, config, ruleset, scan_progress) { match scan_file_with_progress(&path, config, ruleset, scan_progress) {
Ok(scan) => { Ok(scan) => {
let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy);
if show_progress { if show_phase(config) {
eprintln!("{}", render_scan_result_line(&scan)); eprintln!("{}", render_scan_result_line(&scan));
eprintln!("{}", render_plan_line(&plan)); eprintln!("{}", render_plan_line(&plan));
} }
if is_verbose(config) {
emit_verbose_scan_details(config, &scan);
}
let outcome = if dry_run { let outcome = if dry_run {
fix::planner::plan_outcome(plan) fix::planner::plan_outcome(plan)
} else { } else {
@@ -760,6 +876,7 @@ fn watch_fix(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet, dry_run: b
} else { } else {
None None
}; };
let log_cmd = log_cmd.clone();
match fix::executor::apply_fix_with_progress( match fix::executor::apply_fix_with_progress(
&path, &path,
&plan, &plan,
@@ -767,6 +884,7 @@ fn watch_fix(paths: Vec<PathBuf>, config: &Config, ruleset: &RuleSet, dry_run: b
ruleset, ruleset,
scan.probe.duration, scan.probe.duration,
fix_progress, fix_progress,
log_cmd,
) { ) {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(err) => { Err(err) => {
@@ -926,6 +1044,113 @@ fn render_plan_line(plan: &FixPlan) -> String {
} }
} }
fn is_quiet(config: &Config) -> bool {
matches!(config.report.verbosity, Verbosity::Quiet)
}
fn is_verbose(config: &Config) -> bool {
matches!(config.report.verbosity, Verbosity::Verbose)
}
fn show_phase(config: &Config) -> bool {
!config.report.json && !is_quiet(config)
}
fn progress_enabled(config: &Config) -> bool {
show_phase(config) && std::io::stderr().is_terminal()
}
fn emit_verbose_scan_details(config: &Config, scan: &ScanOutcome) {
if !is_verbose(config) || config.report.json {
return;
}
if !scan.decode_errors.is_empty() {
eprintln!(
"[DECODE] {} error line(s)",
scan.decode_errors.len()
);
for line in &scan.decode_errors {
eprintln!(" {}", line);
}
}
if !scan.issues.is_empty() {
eprintln!("[ISSUES] {} total", scan.issues.len());
for issue in &scan.issues {
eprintln!(
" - {} [{:?}] {}",
issue.code, issue.severity, issue.message
);
for evidence in &issue.evidence {
eprintln!(" > {}", evidence);
}
}
}
}
fn render_ffprobe_cmd(ffprobe_path: &str, path: &Path) -> String {
let args = vec![
"-v".to_string(),
"error".to_string(),
"-print_format".to_string(),
"json".to_string(),
"-show_format".to_string(),
"-show_streams".to_string(),
path.display().to_string(),
];
format!("[CMD] {}", format_command_line(ffprobe_path, &args))
}
fn render_decode_cmd(
ffmpeg_path: &str,
path: &Path,
depth: ScanDepth,
with_progress: bool,
) -> String {
let mut args = vec![
"-v".to_string(),
"error".to_string(),
"-i".to_string(),
path.display().to_string(),
];
match depth {
ScanDepth::Quick => args.extend(["-t", "5"].iter().map(|s| s.to_string())),
ScanDepth::Standard => args.extend(["-t", "30"].iter().map(|s| s.to_string())),
ScanDepth::Deep => {}
}
if with_progress {
args.extend(["-nostats", "-progress", "pipe:1"].iter().map(|s| s.to_string()));
}
args.extend(["-f", "null", "-"].iter().map(|s| s.to_string()));
format!("[CMD] {}", format_command_line(ffmpeg_path, &args))
}
fn format_command_line(program: &str, args: &[String]) -> String {
let mut line = String::new();
line.push_str(&quote_arg(program));
for arg in args {
line.push(' ');
line.push_str(&quote_arg(arg));
}
line
}
fn quote_arg(arg: &str) -> String {
if arg.is_empty() {
return "\"\"".to_string();
}
if arg.chars().any(|c| c.is_whitespace() || c == '"' || c == '\'') {
let escaped = arg.replace('"', "\\\"");
format!("\"{}\"", escaped)
} else {
arg.to_string()
}
}
fn emit_progress_line(line: &str, done: bool, in_place: bool, state: Arc<Mutex<ProgressState>>) { fn emit_progress_line(line: &str, done: bool, in_place: bool, state: Arc<Mutex<ProgressState>>) {
if !in_place { if !in_place {
eprintln!("{}", line); eprintln!("{}", line);