use std::io::Write;
use std::sync::Mutex;
use std::time::Instant;
use ripsync_core::plan::{Action, SyncPlan};
use ripsync_core::report::{Event, Reporter, RunPhase, RunStatus, Stats};
use ripsync_core::verify::VerificationSummary;
use crate::args::OutputFormat;
pub struct CliReporter {
format: OutputFormat,
quiet: bool,
verbose: u8,
dry_run: bool,
lock: Mutex<()>,
json: Mutex<Vec<serde_json::Value>>,
lifecycle: Mutex<Lifecycle>,
}
struct Lifecycle {
phase: RunPhase,
phase_started: Instant,
timings: serde_json::Map<String, serde_json::Value>,
backend: Option<String>,
backend_reason: Option<String>,
}
impl CliReporter {
#[must_use]
pub fn new(format: OutputFormat, quiet: bool, verbose: u8, dry_run: bool) -> Self {
Self {
format,
quiet,
verbose,
dry_run,
lock: Mutex::new(()),
json: Mutex::new(Vec::new()),
lifecycle: Mutex::new(Lifecycle {
phase: RunPhase::Planning,
phase_started: Instant::now(),
timings: serde_json::Map::new(),
backend: None,
backend_reason: None,
}),
}
}
fn prefix(&self) -> &'static str {
if self.dry_run { "[dry] " } else { "" }
}
fn line(&self, msg: &str) {
let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "{}{msg}", self.prefix());
}
fn record(&self, kind: &str, rel: &str, extra: serde_json::Value) {
let mut obj = serde_json::Map::new();
obj.insert("event".into(), kind.into());
obj.insert("path".into(), rel.into());
if let serde_json::Value::Object(m) = extra {
obj.extend(m);
}
self.json
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(serde_json::Value::Object(obj));
}
fn action_word(action: Action) -> &'static str {
match action {
Action::Copy => "copy",
Action::Update => "update",
Action::Skip => "skip",
}
}
pub fn print_delete_preview(&self, plan: &SyncPlan) {
if plan.deletions.is_empty() || self.format == OutputFormat::Json || self.quiet {
return;
}
let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
let mut out = std::io::stdout().lock();
let _ = writeln!(
out,
"\n\x1b[1;31mTO DELETE ({}):\x1b[0m",
plan.deletions.len()
);
for d in &plan.deletions {
let _ = writeln!(out, " \x1b[31m- {}\x1b[0m", d.rel.display());
}
let _ = writeln!(out);
}
pub fn finish(
&self,
stats: &Stats,
src: &str,
dst: &str,
status: RunStatus,
verification: &VerificationSummary,
) {
match self.format {
OutputFormat::Human => self.finish_human(stats),
OutputFormat::Json => self.finish_json(stats, src, dst, status, verification),
}
}
fn finish_human(&self, s: &Stats) {
if self.quiet {
return;
}
let verb = if self.dry_run { "would " } else { "" };
println!(
"\n{}copy {} · {}update {} · {}delete {} · skip {} · errors {} · {:.2} MB",
verb,
s.copied,
verb,
s.updated,
verb,
s.deleted,
s.skipped,
s.errors,
bytes_to_mb(s.bytes),
);
}
fn finish_json(
&self,
s: &Stats,
src: &str,
dst: &str,
status: RunStatus,
verification: &VerificationSummary,
) {
let events = self.json.lock().unwrap_or_else(|e| e.into_inner()).clone();
let lifecycle = self.lifecycle.lock().unwrap_or_else(|e| e.into_inner());
let report = serde_json::json!({
"src": src,
"dst": dst,
"dry_run": self.dry_run,
"status": status_word(status),
"cancelled": status == RunStatus::Cancelled,
"phase_timings_ms": lifecycle.timings,
"backend": {
"selected": lifecycle.backend,
"reason": lifecycle.backend_reason,
},
"verification": {
"checked": verification.checked,
"mismatches": verification.mismatches.iter().map(|m| serde_json::json!({
"path": m.rel,
"detail": m.detail,
})).collect::<Vec<_>>(),
},
"summary": {
"copied": s.copied,
"updated": s.updated,
"deleted": s.deleted,
"skipped": s.skipped,
"errors": s.errors,
"bytes": s.bytes,
},
"events": events,
});
println!("{}", serde_json::to_string_pretty(&report).unwrap());
}
}
#[allow(clippy::cast_precision_loss)]
fn bytes_to_mb(bytes: u64) -> f64 {
bytes as f64 / (1024.0 * 1024.0)
}
impl Reporter for CliReporter {
fn event(&self, ev: Event) {
let json = self.format == OutputFormat::Json;
match ev {
Event::Phase(phase) => {
let mut lifecycle = self.lifecycle.lock().unwrap_or_else(|e| e.into_inner());
let elapsed = u64::try_from(lifecycle.phase_started.elapsed().as_millis())
.unwrap_or(u64::MAX);
let previous = phase_word(lifecycle.phase).to_string();
lifecycle.timings.insert(previous, elapsed.into());
lifecycle.phase = phase;
lifecycle.phase_started = Instant::now();
}
Event::BackendSelected { backend, reason } => {
let mut lifecycle = self.lifecycle.lock().unwrap_or_else(|e| e.into_inner());
lifecycle.backend = Some(backend.into());
lifecycle.backend_reason = Some(reason.into());
}
Event::Planned { .. }
| Event::PlanningProgress { .. }
| Event::FileStart { .. }
| Event::VerificationProgress { .. }
| Event::Finished { .. } => {}
Event::FileDone { rel, action, bytes } => {
if json {
self.record(
Self::action_word(action),
&rel.display().to_string(),
serde_json::json!({ "bytes": bytes }),
);
} else if !self.quiet {
self.line(&format!(
"{:<7}{} ({bytes} B)",
Self::action_word(action),
rel.display()
));
}
}
Event::DirDone { rel, action } => {
if json {
self.record("mkdir", &rel.display().to_string(), serde_json::json!({}));
} else if !self.quiet && self.verbose >= 1 {
self.line(&format!(
"{:<7}{}/",
Self::action_word(action),
rel.display()
));
}
}
Event::SymlinkDone { rel, action } => {
if json {
self.record("symlink", &rel.display().to_string(), serde_json::json!({}));
} else if !self.quiet {
self.line(&format!(
"{:<7}{} (symlink)",
Self::action_word(action),
rel.display()
));
}
}
Event::Skipped { rel } => {
if json {
self.record("skip", &rel.display().to_string(), serde_json::json!({}));
} else if !self.quiet && self.verbose >= 2 {
self.line(&format!("{:<7}{}", "skip", rel.display()));
}
}
Event::Deleted { rel } => {
if json {
self.record("delete", &rel.display().to_string(), serde_json::json!({}));
} else if !self.quiet {
self.line(&format!("\x1b[31m{:<7}{}\x1b[0m", "delete", rel.display()));
}
}
Event::Failed { rel, error } => {
if json {
self.record(
"error",
&rel.display().to_string(),
serde_json::json!({ "error": error }),
);
} else {
let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "\x1b[31merror\x1b[0m {}: {error}", rel.display());
}
}
Event::VerificationFailed { rel, detail } => {
if json {
self.record(
"verification_error",
&rel.display().to_string(),
serde_json::json!({ "detail": detail }),
);
} else {
let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
eprintln!("verify {}: {detail}", rel.display());
}
}
}
}
}
fn phase_word(phase: RunPhase) -> &'static str {
match phase {
RunPhase::Planning => "planning",
RunPhase::Review => "review",
RunPhase::Copying => "copying",
RunPhase::Deleting => "deleting",
RunPhase::Verifying => "verifying",
RunPhase::Finalizing => "finalizing",
RunPhase::Done => "done",
RunPhase::Cancelled => "cancelled",
RunPhase::Failed => "failed",
}
}
fn status_word(status: RunStatus) -> &'static str {
match status {
RunStatus::Success => "success",
RunStatus::Cancelled => "cancelled",
RunStatus::Failed => "failed",
}
}