use clap::{Args as ClapArgs, Subcommand};
#[derive(ClapArgs, Debug)]
pub struct Args {
#[command(subcommand)]
pub cmd: ProfileCmd,
}
#[derive(Subcommand, Debug)]
pub enum ProfileCmd {
List,
Show {
id: String,
},
Check {
path: String,
},
Delete {
name: String,
#[arg(long, short = 'y')]
yes: bool,
},
}
pub fn run(args: Args) -> anyhow::Result<()> {
match args.cmd {
ProfileCmd::List => cmd_list(),
ProfileCmd::Show { id } => cmd_show(&id),
ProfileCmd::Check { path } => cmd_check(&path),
ProfileCmd::Delete { name, yes } => cmd_delete(&name, yes),
}
}
fn cmd_list() -> anyhow::Result<()> {
println!("DAP PROFILES (builtin + user overrides)");
println!("{}", "─".repeat(42));
for id in crate::dap::list()? {
println!(" {id}");
}
println!();
println!("SYNC PROFILES (~/.config/dapctl/profiles/)");
println!("{}", "─".repeat(42));
let profiles = crate::config::discover()?;
if profiles.is_empty() {
println!(" (none found — copy an example from the `examples/` directory)");
} else {
for (name, path) in &profiles {
println!(" {name:<24} {}", path.display());
}
}
Ok(())
}
fn cmd_show(id: &str) -> anyhow::Result<()> {
let dap = crate::dap::load(id)?;
println!("DAP PROFILE {}", dap.dap.id);
println!("{}", "─".repeat(42));
println!(" Name {}", dap.dap.name);
println!(" Vendor {}", dap.dap.vendor);
if let Some(fw) = &dap.dap.firmware_min {
println!(" Firmware min {fw}");
}
println!();
println!("FILESYSTEM");
println!(" Preferred {}", dap.filesystem.preferred);
println!(" Supported {}", dap.filesystem.supported.join(", "));
println!(
" Max filename {} bytes",
dap.filesystem.max_filename_bytes
);
println!(" Case sensitive {}", dap.filesystem.case_sensitive);
println!();
println!("CODECS");
println!(" Lossless {}", dap.codecs.lossless.join(", "));
println!(" Lossy {}", dap.codecs.lossy.join(", "));
if !dap.codecs.dsd.is_empty() {
println!(" DSD {}", dap.codecs.dsd.join(", "));
}
println!(
" Max PCM {} Hz / {} bit",
dap.codecs.max_sample_rate_hz, dap.codecs.max_bit_depth
);
println!();
println!("LAYOUT");
println!(" Music root {}", dap.layout.music_root);
println!();
println!("EXCLUDE GLOBS ({} patterns)", dap.exclude.globs.len());
for g in &dap.exclude.globs {
println!(" {g}");
}
Ok(())
}
fn cmd_delete(name: &str, yes: bool) -> anyhow::Result<()> {
let dir = crate::config::profiles_dir()?;
let path = dir.join(format!("{name}.toml"));
if !path.exists() {
return Err(crate::error::ConfigError::NotFound {
name: name.to_owned(),
}
.into());
}
println!("SYNC PROFILE {name}");
println!("{}", "─".repeat(42));
match crate::config::load(&path) {
Ok(p) => {
println!(" source → {}", p.profile.source);
println!(" destination → {}", p.profile.destination);
println!(" mode → {:?}", p.profile.mode);
}
Err(e) => println!(" (could not parse: {e})"),
}
println!();
if !yes {
use std::io::Write;
eprint!("Delete profile {name:?}? [y/N]: ");
std::io::stderr().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
println!("Aborted.");
return Ok(());
}
}
std::fs::remove_file(&path)?;
println!("Deleted {}", path.display());
Ok(())
}
fn cmd_check(path: &str) -> anyhow::Result<()> {
let profile = crate::config::load(std::path::Path::new(path))?;
println!("OK {}", profile.profile.name);
println!(" source → {}", profile.profile.source);
println!(" destination → {}", profile.profile.destination);
println!(" dap_profile → {}", profile.profile.dap_profile);
println!(" mode → {:?}", profile.profile.mode);
let dap = crate::dap::load(&profile.profile.dap_profile)?;
println!(" DAP profile → {} ({})", dap.dap.name, dap.dap.id);
let resolved = crate::config::ResolvedProfile { sync: profile, dap };
let exc = resolved.build_exclude_set()?;
let inc = resolved.build_include_set()?;
println!(
" exclude set → {} patterns",
resolved.all_exclude_globs().count()
);
println!(
" include set → {}",
match &inc {
Some(_) => format!("{} patterns", resolved.sync.filters.include_globs.len()),
None => "all files (no include filter)".to_owned(),
}
);
drop((exc, inc));
Ok(())
}