Add CLI flag surface: -rename/--format/--db/--action/--conflict/-non-strict/-h
Ports FileBot's net.filebot.cli flag surface so Aurora Curator can be scripted, not just driven through the GUI. Argument parsing and file-expansion are pure Rust in aurora-curator-core::cli (14 unit tests, no Tauri/network dependency); orchestration lives in src-tauri/src/cli.rs, which reuses the exact same binding vocabulary the GUI's RenamePanel.tsx builds client-side and the exact same TMDB/TVmaze season-caching lookup fetch_metadata already uses, so a naming scheme renders identically whether it's typed into the app or passed via --format. main.rs branches to CLI mode before any Tauri App/window is built at all, using tauri::async_runtime::block_on to drive the async metadata lookups (confirmed safe standalone by reading Tauri's own async_runtime.rs) and a hand-rolled settings::cli_tmdb_token() that replicates Tauri's own app_config_dir() computation (dirs::config_dir().join(identifier), pinned against tauri.conf.json by a new test) so a token saved through the GUI's Fetch Data Settings is found from the CLI path too - the CLI still never accepts a token as an argument. Deliberately not ported: --output <dir> (the rename engine's safety invariant against escaping the source directory tree would need to be relaxed) and -non-strict is accepted as a no-op (local matching has no confidence score to loosen). 16 new tests in src-tauri (10 in cli.rs, 1 pinning settings:: APP_CONFIG_IDENTIFIER) plus 14 in aurora-curator-core's cli module - full workspace suite is 113 passing. README updated with a new "Command-line usage" section and roadmap item 6 marked done for the CLI surface. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QLbx8ov7zMcdDPEqQm4Y98
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5763c6313b
commit
22a3da9f6f
@@ -0,0 +1,458 @@
|
||||
//! Pure argument-parsing and file-expansion logic for Aurora Curator's command-line
|
||||
//! rename mode (`-rename ...`) - a port of FileBot's `net.filebot.cli.ArgumentBean`/
|
||||
//! `CmdlineOperations` flag surface, letting the app be scripted instead of only driven
|
||||
//! through the GUI.
|
||||
//!
|
||||
//! Deliberately split from the actual filesystem/network orchestration, which lives in
|
||||
//! `src-tauri/src/cli.rs` (it needs the Tauri-side settings store for the saved TMDB
|
||||
//! token, which this crate has no access to and shouldn't - see that module's doc
|
||||
//! comment). Everything here is pure logic with no I/O beyond `expand_inputs`'s
|
||||
//! directory walk, so it's fully unit-testable without a filesystem mock or a Tauri app
|
||||
//! at all.
|
||||
//!
|
||||
//! Deliberately NOT ported, matching the project's existing FileBot-compatibility
|
||||
//! philosophy of tracking real usage over completeness: FileBot's `--output <dir>` (the
|
||||
//! rename engine, see `crate::rename`'s own module doc, is deliberately scoped to never
|
||||
//! move a file outside its own source directory tree - relaxing that safety invariant
|
||||
//! for `--output` was judged not worth it, especially since a naming scheme's own `/`
|
||||
//! already covers "organize into a subfolder") and `-non-strict` (accepted for
|
||||
//! script-compatibility so existing FileBot invocations don't hard-fail on it, but it's
|
||||
//! a genuine no-op here - the local matcher has no confidence score to loosen, and
|
||||
//! online disambiguation, see `crate::similarity::best_title_match`, already runs
|
||||
//! unconditionally regardless of any flag).
|
||||
|
||||
use crate::rename::{ConflictPolicy, RenameAction};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Which online source `--db` should resolve TV episodes against. Deliberately its own
|
||||
/// small enum rather than reusing the Tauri crate's `settings::TvMetadataSource` (this
|
||||
/// crate can't depend on that crate, and that enum represents a *persisted GUI
|
||||
/// preference* anyway, not a per-invocation flag) - a CLI run always states its source
|
||||
/// explicitly via `--db` when it wants one; it never falls back to whatever the GUI's
|
||||
/// Fetch Data Settings last had selected.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TvDbChoice {
|
||||
Tmdb,
|
||||
Tvmaze,
|
||||
}
|
||||
|
||||
/// A fully parsed, valid `-rename` invocation.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RenameCliArgs {
|
||||
pub format: String,
|
||||
pub db: Option<TvDbChoice>,
|
||||
pub action: RenameAction,
|
||||
/// `--action test` - print the plan, touch nothing on disk.
|
||||
pub dry_run: bool,
|
||||
pub conflict: ConflictPolicy,
|
||||
/// `-non-strict` was passed - accepted, but see the module doc for why it's a no-op.
|
||||
pub non_strict: bool,
|
||||
pub inputs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CliInvocation {
|
||||
/// No recognized CLI trigger flag was present at all - launch the normal GUI,
|
||||
/// unchanged from before this feature existed.
|
||||
Gui,
|
||||
/// `-h`/`--help` was passed - print usage and exit, touching neither the GUI nor
|
||||
/// any files (checked before, and independently of, `-rename`).
|
||||
Help,
|
||||
Rename(RenameCliArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CliError(pub String);
|
||||
|
||||
impl std::fmt::Display for CliError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The trigger flags that mean "this is a CLI invocation, don't launch the window" -
|
||||
/// exposed separately so `src-tauri/src/main.rs` can decide GUI-vs-CLI with the exact
|
||||
/// same predicate `parse_args` itself uses for its `Gui`/`Help` split, rather than
|
||||
/// duplicating (and risking drifting from) the list of trigger flags.
|
||||
pub fn looks_like_cli_invocation(args: &[String]) -> bool {
|
||||
args.iter().any(|a| a == "-rename" || a == "-h" || a == "--help")
|
||||
}
|
||||
|
||||
/// Parses a full `-rename ...` command line (already stripped of `argv[0]`, the
|
||||
/// executable's own path). Recognizes `-h`/`--help` first and independently of
|
||||
/// `-rename`, so `aurora-curator -rename --help` still prints usage rather than
|
||||
/// demanding a `--format`.
|
||||
pub fn parse_args(args: &[String]) -> Result<CliInvocation, CliError> {
|
||||
if args.iter().any(|a| a == "-h" || a == "--help") {
|
||||
return Ok(CliInvocation::Help);
|
||||
}
|
||||
if !args.iter().any(|a| a == "-rename") {
|
||||
return Ok(CliInvocation::Gui);
|
||||
}
|
||||
|
||||
let mut format: Option<String> = None;
|
||||
let mut db: Option<TvDbChoice> = None;
|
||||
let mut action = RenameAction::Move;
|
||||
let mut dry_run = false;
|
||||
let mut conflict = ConflictPolicy::Skip;
|
||||
let mut non_strict = false;
|
||||
let mut inputs = Vec::new();
|
||||
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = args[i].as_str();
|
||||
match arg {
|
||||
"-rename" => {}
|
||||
"-non-strict" => non_strict = true,
|
||||
"--format" => {
|
||||
i += 1;
|
||||
let value = args
|
||||
.get(i)
|
||||
.ok_or_else(|| CliError("--format needs a value".to_string()))?;
|
||||
format = Some(value.clone());
|
||||
}
|
||||
"--db" => {
|
||||
i += 1;
|
||||
let value = args.get(i).ok_or_else(|| CliError("--db needs a value".to_string()))?;
|
||||
db = Some(match value.to_ascii_lowercase().as_str() {
|
||||
"tmdb" => TvDbChoice::Tmdb,
|
||||
"tvmaze" => TvDbChoice::Tvmaze,
|
||||
other => {
|
||||
return Err(CliError(format!(
|
||||
"unknown --db source \"{other}\" (expected tmdb or tvmaze)"
|
||||
)))
|
||||
}
|
||||
});
|
||||
}
|
||||
"--action" => {
|
||||
i += 1;
|
||||
let value = args
|
||||
.get(i)
|
||||
.ok_or_else(|| CliError("--action needs a value".to_string()))?;
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"move" => action = RenameAction::Move,
|
||||
"copy" => action = RenameAction::Copy,
|
||||
"symlink" => action = RenameAction::Symlink,
|
||||
"test" => dry_run = true,
|
||||
other => {
|
||||
return Err(CliError(format!(
|
||||
"unknown --action \"{other}\" (expected move, copy, symlink, or test)"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
"--conflict" => {
|
||||
i += 1;
|
||||
let value = args
|
||||
.get(i)
|
||||
.ok_or_else(|| CliError("--conflict needs a value".to_string()))?;
|
||||
conflict = match value.to_ascii_lowercase().as_str() {
|
||||
"skip" => ConflictPolicy::Skip,
|
||||
"auto-number" | "autonumber" | "auto" => ConflictPolicy::AutoNumber,
|
||||
"override" => ConflictPolicy::Override,
|
||||
other => {
|
||||
return Err(CliError(format!(
|
||||
"unknown --conflict \"{other}\" (expected skip, auto-number, or override)"
|
||||
)))
|
||||
}
|
||||
};
|
||||
}
|
||||
other if other.starts_with('-') && other != "-" => {
|
||||
return Err(CliError(format!("unrecognized flag \"{other}\"")));
|
||||
}
|
||||
other => inputs.push(PathBuf::from(other)),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let format = format.ok_or_else(|| CliError("-rename needs --format \"<naming scheme>\"".to_string()))?;
|
||||
if inputs.is_empty() {
|
||||
return Err(CliError("-rename needs at least one input file or folder".to_string()));
|
||||
}
|
||||
|
||||
Ok(CliInvocation::Rename(RenameCliArgs {
|
||||
format,
|
||||
db,
|
||||
action,
|
||||
dry_run,
|
||||
conflict,
|
||||
non_strict,
|
||||
inputs,
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_hidden(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn collect(path: &Path, out: &mut Vec<PathBuf>) {
|
||||
if path.is_file() {
|
||||
out.push(path.to_path_buf());
|
||||
} else if path.is_dir() {
|
||||
if let Ok(entries) = std::fs::read_dir(path) {
|
||||
let mut children: Vec<PathBuf> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| !is_hidden(p))
|
||||
.collect();
|
||||
children.sort();
|
||||
for child in children {
|
||||
collect(&child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expands `inputs` (files and/or directories) into a flat, deduplicated, sorted list
|
||||
/// of regular files - directories are walked recursively and hidden entries (dotfiles/
|
||||
/// dotdirs) are skipped, mirroring `src-tauri/src/files.rs`'s `expand()` (used by the
|
||||
/// GUI's own "Select Folder…" picker and drag-and-drop) so a CLI run and a GUI load of
|
||||
/// the same folder produce the same file set. A path that doesn't exist at all is
|
||||
/// silently skipped rather than erroring the whole batch - a typo'd path among several
|
||||
/// good ones shouldn't block processing the rest.
|
||||
pub fn expand_inputs(inputs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
for input in inputs {
|
||||
collect(input, &mut files);
|
||||
}
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
fn s(text: &str) -> String {
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_trigger_flags_means_gui() {
|
||||
// No `-rename`/`-h`/`--help` present at all: treated as a plain GUI launch,
|
||||
// even with other stray args present (e.g. whatever the OS/launcher passes) -
|
||||
// only once `-rename` itself is present does an unrecognized flag become an
|
||||
// error (see `unrecognized_flag_errors_instead_of_being_treated_as_an_input`).
|
||||
assert_eq!(parse_args(&[]).unwrap(), CliInvocation::Gui);
|
||||
assert_eq!(parse_args(&[s("--version")]).unwrap(), CliInvocation::Gui);
|
||||
assert_eq!(parse_args(&[s("some-random-arg")]).unwrap(), CliInvocation::Gui);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_wins_even_alongside_rename() {
|
||||
assert_eq!(parse_args(&[s("--help")]).unwrap(), CliInvocation::Help);
|
||||
assert_eq!(parse_args(&[s("-h")]).unwrap(), CliInvocation::Help);
|
||||
assert_eq!(
|
||||
parse_args(&[s("-rename"), s("--help")]).unwrap(),
|
||||
CliInvocation::Help
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_requires_format() {
|
||||
let err = parse_args(&[s("-rename"), s("file.mkv")]).unwrap_err();
|
||||
assert!(err.0.contains("--format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_requires_at_least_one_input() {
|
||||
let err = parse_args(&[s("-rename"), s("--format"), s("{n}")]).unwrap_err();
|
||||
assert!(err.0.contains("input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_valid_invocation_defaults_move_and_skip() {
|
||||
let parsed = parse_args(&[s("-rename"), s("--format"), s("{n}"), s("file.mkv")]).unwrap();
|
||||
match parsed {
|
||||
CliInvocation::Rename(args) => {
|
||||
assert_eq!(args.format, "{n}");
|
||||
assert_eq!(args.db, None);
|
||||
assert_eq!(args.action, RenameAction::Move);
|
||||
assert_eq!(args.dry_run, false);
|
||||
assert_eq!(args.conflict, ConflictPolicy::Skip);
|
||||
assert_eq!(args.non_strict, false);
|
||||
assert_eq!(args.inputs, vec![PathBuf::from("file.mkv")]);
|
||||
}
|
||||
other => panic!("expected Rename, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_is_case_insensitive_and_rejects_unknown_sources() {
|
||||
let parsed = parse_args(&[
|
||||
s("-rename"),
|
||||
s("--format"),
|
||||
s("{n}"),
|
||||
s("--db"),
|
||||
s("TVMAZE"),
|
||||
s("file.mkv"),
|
||||
])
|
||||
.unwrap();
|
||||
match parsed {
|
||||
CliInvocation::Rename(args) => assert_eq!(args.db, Some(TvDbChoice::Tvmaze)),
|
||||
other => panic!("expected Rename, got {other:?}"),
|
||||
}
|
||||
|
||||
let err = parse_args(&[
|
||||
s("-rename"),
|
||||
s("--format"),
|
||||
s("{n}"),
|
||||
s("--db"),
|
||||
s("thetvdb"),
|
||||
s("file.mkv"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.0.contains("thetvdb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_test_sets_dry_run_without_changing_the_conflict_policy() {
|
||||
let parsed = parse_args(&[
|
||||
s("-rename"),
|
||||
s("--format"),
|
||||
s("{n}"),
|
||||
s("--action"),
|
||||
s("test"),
|
||||
s("file.mkv"),
|
||||
])
|
||||
.unwrap();
|
||||
match parsed {
|
||||
CliInvocation::Rename(args) => {
|
||||
assert_eq!(args.dry_run, true);
|
||||
assert_eq!(args.action, RenameAction::Move);
|
||||
}
|
||||
other => panic!("expected Rename, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_accepts_filebot_style_aliases() {
|
||||
for (alias, expected) in [
|
||||
("skip", ConflictPolicy::Skip),
|
||||
("auto-number", ConflictPolicy::AutoNumber),
|
||||
("autonumber", ConflictPolicy::AutoNumber),
|
||||
("auto", ConflictPolicy::AutoNumber),
|
||||
("override", ConflictPolicy::Override),
|
||||
] {
|
||||
let parsed = parse_args(&[
|
||||
s("-rename"),
|
||||
s("--format"),
|
||||
s("{n}"),
|
||||
s("--conflict"),
|
||||
s(alias),
|
||||
s("file.mkv"),
|
||||
])
|
||||
.unwrap();
|
||||
match parsed {
|
||||
CliInvocation::Rename(args) => assert_eq!(args.conflict, expected, "alias {alias}"),
|
||||
other => panic!("expected Rename, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_strict_flag_is_recorded_but_does_not_consume_a_value() {
|
||||
let parsed = parse_args(&[
|
||||
s("-rename"),
|
||||
s("-non-strict"),
|
||||
s("--format"),
|
||||
s("{n}"),
|
||||
s("file.mkv"),
|
||||
])
|
||||
.unwrap();
|
||||
match parsed {
|
||||
CliInvocation::Rename(args) => {
|
||||
assert_eq!(args.non_strict, true);
|
||||
assert_eq!(args.inputs, vec![PathBuf::from("file.mkv")]);
|
||||
}
|
||||
other => panic!("expected Rename, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecognized_flag_errors_instead_of_being_treated_as_an_input() {
|
||||
let err = parse_args(&[
|
||||
s("-rename"),
|
||||
s("--format"),
|
||||
s("{n}"),
|
||||
s("--output"),
|
||||
s("/tmp"),
|
||||
s("file.mkv"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.0.contains("--output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_inputs_are_all_collected() {
|
||||
let parsed = parse_args(&[
|
||||
s("-rename"),
|
||||
s("--format"),
|
||||
s("{n}"),
|
||||
s("a.mkv"),
|
||||
s("b.mkv"),
|
||||
s("c.mkv"),
|
||||
])
|
||||
.unwrap();
|
||||
match parsed {
|
||||
CliInvocation::Rename(args) => {
|
||||
assert_eq!(
|
||||
args.inputs,
|
||||
vec![PathBuf::from("a.mkv"), PathBuf::from("b.mkv"), PathBuf::from("c.mkv")]
|
||||
);
|
||||
}
|
||||
other => panic!("expected Rename, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
let dir = std::env::temp_dir().join(format!("aurora_cli_test_{n}_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_inputs_walks_directories_recursively_and_skips_hidden_entries() {
|
||||
let root = temp_dir();
|
||||
std::fs::write(root.join("top.mkv"), b"").unwrap();
|
||||
std::fs::write(root.join(".hidden.mkv"), b"").unwrap();
|
||||
std::fs::create_dir_all(root.join("season 1")).unwrap();
|
||||
std::fs::write(root.join("season 1").join("e01.mkv"), b"").unwrap();
|
||||
std::fs::create_dir_all(root.join(".hidden_dir")).unwrap();
|
||||
std::fs::write(root.join(".hidden_dir").join("e02.mkv"), b"").unwrap();
|
||||
|
||||
let files = expand_inputs(&[root.clone()]);
|
||||
|
||||
assert_eq!(files.len(), 2, "found: {files:?}");
|
||||
assert!(files.contains(&root.join("top.mkv")));
|
||||
assert!(files.contains(&root.join("season 1").join("e01.mkv")));
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_inputs_accepts_a_bare_file_and_dedupes_overlapping_inputs() {
|
||||
let root = temp_dir();
|
||||
let file = root.join("only.mkv");
|
||||
std::fs::write(&file, b"").unwrap();
|
||||
|
||||
let files = expand_inputs(&[file.clone(), file.clone(), root.clone()]);
|
||||
assert_eq!(files, vec![file]);
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_inputs_silently_skips_a_nonexistent_path() {
|
||||
let files = expand_inputs(&[PathBuf::from("/definitely/not/a/real/path/anywhere")]);
|
||||
assert_eq!(files, Vec::<PathBuf>::new());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
//! the workspace README for the porting roadmap (matching engine, media metadata
|
||||
//! parsing, web datasource clients, rename engine are not yet ported).
|
||||
|
||||
pub mod cli;
|
||||
pub mod format;
|
||||
pub mod history;
|
||||
pub mod matching;
|
||||
|
||||
Reference in New Issue
Block a user