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:
Renamr Scaffold
2026-08-29 11:30:51 +00:00
co-authored by Claude Sonnet 5
parent 5763c6313b
commit 22a3da9f6f
9 changed files with 1108 additions and 26 deletions
+458
View File
@@ -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());
}
}
+1
View File
@@ -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;