use clap::Args as ClapArgs;
use crate::config::Mode;
use crate::diff::EntryKind;
use crate::scan::fmt_bytes;
use crate::transfer::executor::{Options, SyncMode, TranscodeOpts};
#[derive(ClapArgs, Debug)]
pub struct Args {
pub profile: String,
#[arg(long)]
pub dry_run: bool,
}
const ESTIMATED_SPEED_BPS: u64 = 30 * 1024 * 1024;
pub fn run(args: Args, yes: bool) -> anyhow::Result<()> {
let resolved = crate::config::resolve(&args.profile)?;
let source_str = &resolved.sync.profile.source;
let source = camino::Utf8PathBuf::from(source_str);
let destination = crate::scan::resolve_destination(&resolved.sync.profile.destination)?;
let ssh_session = if crate::ssh::SshUri::is_ssh(source_str) {
let uri = crate::ssh::SshUri::parse(source_str)?;
Some(crate::ssh::SshSession::connect(&uri)?)
} else {
None
};
let dry_run = if args.dry_run {
true
} else if yes {
false
} else {
resolved.sync.transfer.dry_run_default
};
let mode = match resolved.sync.profile.mode {
Mode::Mirror => SyncMode::Mirror,
Mode::Additive | Mode::Selective => SyncMode::Additive,
};
tracing::info!(
event = "sync_start",
profile = resolved.sync.profile.name,
source = %source,
destination = %destination,
mode = ?resolved.sync.profile.mode,
dry_run,
);
if ssh_session.is_none() {
let repaired = crate::transfer::repair_dest_mtimes(&source, &destination);
if repaired > 0 {
eprintln!(" repaired mtimes for {repaired} file(s)");
}
}
let result = crate::diff::diff(&resolved, &source, &destination)?;
let plan = &result.plan;
let new_b = plan.total_bytes(EntryKind::New);
let mod_b = plan.total_bytes(EntryKind::Modified);
let orp_b = plan.total_bytes(EntryKind::Orphan);
let transfer_total = new_b + mod_b;
let eta = plan.eta_secs(ESTIMATED_SPEED_BPS);
println!("SYNC {} → {}", resolved.sync.profile.source, destination);
println!(
" profile: {} mode: {:?} dap: {}",
resolved.sync.profile.name, resolved.sync.profile.mode, resolved.dap.dap.id,
);
println!("{}", "─".repeat(62));
println!(
" [+] {:>6} new {}",
plan.count(EntryKind::New),
fmt_bytes(new_b),
);
println!(
" [~] {:>6} modified {}",
plan.count(EntryKind::Modified),
fmt_bytes(mod_b),
);
println!(
" [-] {:>6} orphans {}",
plan.count(EntryKind::Orphan),
fmt_bytes(orp_b),
);
println!(
" [=] {:>6} unchanged {}",
plan.count(EntryKind::Same),
fmt_bytes(plan.total_bytes(EntryKind::Same)),
);
println!("{}", "─".repeat(62));
println!(
" transfer: {} ETA: {}",
fmt_bytes(transfer_total),
fmt_eta(eta),
);
if transfer_total == 0 && plan.count(EntryKind::Orphan) == 0 {
println!("\nNothing to sync.");
return Ok(());
}
let has_deletions = matches!(mode, SyncMode::Mirror) && plan.count(EntryKind::Orphan) > 0;
if dry_run {
println!();
if resolved.sync.transfer.dry_run_default && !yes {
println!(
" (dry run — pass --yes to execute{})",
if has_deletions {
", including deletion of orphans"
} else {
""
}
);
} else {
println!(" (dry run)");
}
return Ok(());
}
if has_deletions && !yes {
anyhow::bail!(
"{} orphan(s) would be deleted in mirror mode. \
Pass --yes to confirm or use mode=additive to skip deletions.",
plan.count(EntryKind::Orphan)
);
}
println!();
let manifest_dir = manifest_dir()?;
let run_id = crate::logging::current_run_id();
let transcode = build_transcode_opts(&resolved);
let opts = Options {
dry_run: false,
mode,
verify: resolved.sync.transfer.verify,
run_id,
manifest_dir,
progress_tx: None,
transcode,
ssh_source: ssh_session,
};
let stats = crate::transfer::execute(plan, &source, &destination, &opts)?;
println!();
println!(
"Sync complete: {} copied, {} deleted, {} failed ({})",
stats.copied,
stats.deleted,
stats.failed,
fmt_eta(stats.elapsed_secs as u64),
);
if stats.failed > 0 {
anyhow::bail!("{} file(s) failed to transfer", stats.failed);
}
tracing::info!(
event = "sync_done",
copied = stats.copied,
deleted = stats.deleted,
failed = stats.failed,
bytes = stats.bytes_written,
);
Ok(())
}
fn build_transcode_opts(resolved: &crate::config::ResolvedProfile) -> Option<TranscodeOpts> {
let tc = &resolved.sync.transcode;
if !tc.enabled || tc.rules.is_empty() {
return None;
}
let dirs = directories::ProjectDirs::from("", "", "dapctl")?;
let cache_path = dirs.cache_dir().join("transcode");
let cache_utf8 = camino::Utf8PathBuf::from_path_buf(cache_path).ok()?;
Some(TranscodeOpts {
rules: tc.rules.clone(),
cache: crate::transcode::Cache::new(cache_utf8),
})
}
fn manifest_dir() -> anyhow::Result<camino::Utf8PathBuf> {
let dirs = directories::ProjectDirs::from("", "", "dapctl")
.ok_or_else(|| anyhow::anyhow!("cannot determine data directory"))?;
let path = dirs.data_local_dir().join("runs");
camino::Utf8PathBuf::from_path_buf(path)
.map_err(|p| anyhow::anyhow!("non-UTF-8 data dir: {}", p.display()))
}
fn fmt_eta(secs: u64) -> String {
if secs == 0 {
return "< 1s".to_owned();
}
if secs < 60 {
return format!("{secs}s");
}
let m = secs / 60;
let s = secs % 60;
if m < 60 {
return format!("{m}m {s:02}s");
}
let h = m / 60;
let m = m % 60;
format!("{h}h {m:02}m")
}