mod cli;
mod config;
mod cover;
mod i18n;
mod info;
mod pipeline;
mod tagger;
mod template;
mod watch;
use std::time::Duration;
use anyhow::Result;
use ncm_core::AudioFormat;
use crate::cli::{CliCommand, ColorChoice};
use crate::i18n::{t, Lang};
use crate::pipeline::RunSummary;
fn main() -> Result<()> {
let lang = prescan_lang().unwrap_or_else(Lang::detect_from_env);
i18n::init(lang.strings());
let mut cmd = cli::build_command(lang.strings());
let matches = cmd.clone().get_matches();
let command = cli::parse(&matches, lang)?;
suggest_subcommand_on_typo(&command, &cmd);
let verbose = match &command {
CliCommand::Decrypt(args) => args.log_level(),
CliCommand::Info(_)
| CliCommand::Cover(_)
| CliCommand::Watch(_)
| CliCommand::Completion(_) => log::LevelFilter::Warn,
};
env_logger::Builder::new()
.filter_level(verbose)
.format_timestamp(None)
.format_target(false)
.init();
match command {
CliCommand::Completion(shell) => {
clap_complete::generate(shell, &mut cmd, "ncm2mp3", &mut std::io::stdout());
}
CliCommand::Info(args) => info::run(&args)?,
CliCommand::Cover(args) => cover::run(&args)?,
CliCommand::Watch(args) => watch::run(&args)?,
CliCommand::Decrypt(mut args) => {
apply_color_choice(args.color);
if !args.no_config {
let file_cfg = if let Some(explicit) = args.config_path.clone() {
config::load_from(&explicit)?
} else {
config::load_default_if_present()?
};
cli::merge_config(&mut args, &matches, &file_cfg);
}
let summary = pipeline::run(&args)?;
print_summary(&summary);
if summary.failed > 0 {
std::process::exit(1);
}
}
}
Ok(())
}
fn apply_color_choice(choice: ColorChoice) {
if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
console::set_colors_enabled_stderr(false);
console::set_colors_enabled(false);
return;
}
match choice {
ColorChoice::Always => {
console::set_colors_enabled_stderr(true);
console::set_colors_enabled(true);
}
ColorChoice::Never => {
console::set_colors_enabled_stderr(false);
console::set_colors_enabled(false);
}
ColorChoice::Auto => {
}
}
}
fn print_summary(summary: &RunSummary) {
let s = t();
let base = s
.msg_summary
.replace("{ok}", &summary.ok.to_string())
.replace("{skipped}", &summary.skipped.to_string())
.replace("{failed}", &summary.failed.to_string());
let elapsed = s
.msg_summary_elapsed
.replace("{elapsed}", &format_elapsed(summary.elapsed));
let breakdown = if summary.by_format.is_empty() {
String::new()
} else {
s.msg_summary_breakdown
.replace("{breakdown}", &format_breakdown(&summary.by_format))
};
eprintln!("\n{base}{elapsed}{breakdown}");
}
fn format_elapsed(d: Duration) -> String {
let total = d.as_secs();
let hours = total / 3600;
let minutes = (total % 3600) / 60;
let seconds = total % 60;
if hours > 0 {
format!("{hours}h{minutes:02}m{seconds:02}s")
} else if minutes > 0 {
format!("{minutes}m{seconds:02}s")
} else {
let millis = d.subsec_millis();
format!("{seconds}.{millis:03}s")
}
}
fn format_breakdown(by_format: &std::collections::HashMap<AudioFormat, usize>) -> String {
let mut entries: Vec<(AudioFormat, usize)> = by_format.iter().map(|(f, n)| (*f, *n)).collect();
entries.sort_by_key(|(f, _)| format_sort_key(*f));
entries
.into_iter()
.map(|(f, n)| format!("{}:{}", format_display(f), n))
.collect::<Vec<_>>()
.join(" ")
}
fn format_sort_key(f: AudioFormat) -> u8 {
match f {
AudioFormat::Mp3 => 0,
AudioFormat::Flac => 1,
AudioFormat::M4a => 2,
AudioFormat::Wav => 3,
AudioFormat::Ogg => 4,
AudioFormat::Unknown => 9,
}
}
fn format_display(f: AudioFormat) -> &'static str {
match f {
AudioFormat::Mp3 => "MP3",
AudioFormat::Flac => "FLAC",
AudioFormat::M4a => "M4A",
AudioFormat::Wav => "WAV",
AudioFormat::Ogg => "OGG",
AudioFormat::Unknown => "?",
}
}
fn suggest_subcommand_on_typo(command: &CliCommand, cmd: &clap::Command) {
let CliCommand::Decrypt(args) = command else {
return; };
let Some(input) = args.input.as_deref() else {
return; };
if input.exists() {
return; }
let raw = input.to_string_lossy();
if raw.contains('/')
|| raw.contains('\\')
|| raw.contains('.')
|| raw.is_empty()
|| raw.len() > 32
{
return;
}
let names: Vec<&str> = cmd.get_subcommands().map(|s| s.get_name()).collect();
let Some(suggestion) = closest_name(&raw, &names) else {
return;
};
let s = t();
eprintln!(
"{}",
s.err_unknown_subcommand
.replace("{value}", &raw)
.replace("{suggestion}", suggestion)
);
eprintln!(" {}", s.tip_subcommands);
std::process::exit(2);
}
fn closest_name<'a>(typed: &str, candidates: &[&'a str]) -> Option<&'a str> {
let typed_lc = typed.to_ascii_lowercase();
let mut best: Option<(&'a str, usize)> = None;
for &cand in candidates {
if cand.to_ascii_lowercase().starts_with(&typed_lc) && typed.len() >= 2 {
return Some(cand); }
let dist = strsim::levenshtein(&typed_lc, &cand.to_ascii_lowercase());
if dist <= 2 && best.map_or(true, |(_, d)| dist < d) {
best = Some((cand, dist));
}
}
best.map(|(name, _)| name)
}
fn prescan_lang() -> Option<Lang> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(val) = arg.strip_prefix("--lang=") {
return Lang::parse(val);
}
if arg == "--lang" || arg == "-L" {
return args.next().and_then(|s| Lang::parse(&s));
}
}
None
}
#[cfg(test)]
mod tests {
use super::closest_name;
const SUBCMDS: &[&str] = &["info", "cover", "watch", "completion"];
#[test]
fn one_letter_swap() {
assert_eq!(closest_name("inf", SUBCMDS), Some("info"));
assert_eq!(closest_name("covar", SUBCMDS), Some("cover"));
assert_eq!(closest_name("wath", SUBCMDS), Some("watch"));
}
#[test]
fn prefix_wins_outright() {
assert_eq!(closest_name("comp", SUBCMDS), Some("completion"));
}
#[test]
fn case_insensitive() {
assert_eq!(closest_name("INFO", SUBCMDS), Some("info"));
assert_eq!(closest_name("Inf", SUBCMDS), Some("info"));
}
#[test]
fn nothing_close_returns_none() {
assert_eq!(closest_name("xyzzy", SUBCMDS), None);
assert_eq!(closest_name("z", SUBCMDS), None);
}
#[test]
fn picks_closest_when_multiple_within_threshold() {
assert_eq!(closest_name("covor", SUBCMDS), Some("cover"));
}
}