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
+69 -9
View File
@@ -34,7 +34,10 @@ rest of FileBot's full media-metadata bindings (real probed video/audio stream i
are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two
panes always stay positionally paired — and up to 9 naming schemes can be saved as
presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as
Settings/History). See "Porting roadmap" below.
Settings/History). The app can also be driven entirely from the command line —
`aurora-curator -rename --format "<naming scheme>" [options] <file-or-folder>...` — for
scripting batch renames without opening the window at all; see "Command-line usage"
below. See "Porting roadmap" below.
The naming scheme is fully user-editable from the app (the "Naming Scheme" button next
to the New Names list) — the default is `{show name} - {season number}x{episode
@@ -141,11 +144,14 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported),
rename engine (`rename/` — Move/Copy/Symlink actions,
Skip/Auto-number/Override conflict policies, can also organize
into (auto-created) subfolders when
the naming scheme includes `/`) — and a rename history/undo log
the naming scheme includes `/`), a rename history/undo log
(`history/`, a partial `HistoryDialog` port, undo-aware of which
action produced each batch). Not yet ported: full media metadata
bindings, other web datasource clients (TheTVDB/AniDB/OMDb/etc).
No Tauri dependency, so it builds and unit-tests standalone.
action produced each batch), and pure `-rename` command-line
argument parsing/file expansion (`cli/` — no Tauri or network
dependency at all, see "Command-line usage" below). Not yet
ported: full media metadata bindings, other web datasource
clients (TheTVDB/AniDB/OMDb/etc). No Tauri dependency, so it
builds and unit-tests standalone.
src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
(naming-scheme rendering, filename matching for both TV and
movies, metadata fetch — `metadata.rs` for TV episodes (against
@@ -154,8 +160,12 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands
plan, native file/folder dialogs, drag-and-drop expansion), plus
`settings.rs` (TMDB token, and which TV metadata source Fetch Data
uses), `history.rs` (rename history/undo
log), and `presets.rs` (up to 9 saved naming schemes) — all three
stored as JSON in the OS app-config directory — app
log), `presets.rs` (up to 9 saved naming schemes) — all three
stored as JSON in the OS app-config directory — `cli.rs` (the
`-rename` command-line mode's orchestration: builds and executes a
rename plan against `aurora-curator-core::cli`'s parsed arguments,
with no Tauri `App`/window ever created for it — `main.rs` branches
to it before the GUI is touched at all), and app
metadata/icons/window config (tauri.conf.json).
src/ React + TypeScript UI (Vite). Faithful recreation of the original
two-pane rename screen (Aurora theme) wired to real file loading,
@@ -221,6 +231,49 @@ A batch of TV-only files can run entirely on TVmaze with no token configured at
batch containing any movies still needs the TMDB token regardless of which TV source is
selected.
## Command-line usage
The same binary that opens the GUI can also run a batch rename straight from a script or
terminal — a port of FileBot's `net.filebot.cli` flag surface (`-rename`, `--format`,
`--db`, `--action`, `--conflict`), with a couple of deliberate differences noted below.
Passing `-rename` (or `-h`/`--help`) on the command line skips the window entirely:
```sh
aurora-curator -rename --format "{show name} - {season number}x{episode number} - {episode name}" \
--action move --conflict skip ~/Downloads/some-show/
```
- `--format "<naming scheme>"` (required) — the same expression syntax as the GUI's
Naming Scheme field: FileBot short names (`{n}`, `{s00e00}`, `{t}`), human-readable
aliases (`{show name}`, `{episode name}`, `{movie name}`, `{resolution}`, `{video
codec}`, `{release group}`, …), or real Rhai script. `{name}`/`{ext}` fall back to the
file's own current filename/extension when nothing else matched it.
- `--db <tmdb|tvmaze>` (optional) — resolves TV episodes against that online source
before rendering names, the command-line equivalent of clicking "Fetch Data" after
Match. Movies are only looked up online when this is `tmdb` — TVmaze has no movie
catalog, and with no `--db` at all every file uses local filename matching only, same
as Match without Fetch Data in the GUI. `--db tmdb` needs a TMDB token that's already
been saved through the app's own Fetch Data Settings at least once — **the CLI never
accepts an API token as an argument**, so there's nothing secret to leak into your
shell history or a script.
- `--action <move|copy|symlink|test>` (default `move`) — what to do at each computed
target; `test` prints the full plan and touches nothing on disk, useful for checking a
naming scheme before committing to it.
- `--conflict <skip|auto-number|override>` (default `skip`, `autonumber`/`auto` also
accepted) — what happens when the computed target already exists.
- `-non-strict` — accepted for compatibility with existing FileBot scripts, but is a
genuine no-op here: the local matcher has no confidence score to loosen, and online
disambiguation (`similarity::best_title_match`) always runs regardless of this flag.
- Every remaining argument is treated as an input file or folder; folders are expanded
recursively (hidden files/dotfolders skipped), matching exactly what the GUI's own
"Select Folder…" and drag-and-drop do.
Deliberately **not** ported: FileBot's `--output <dir>` flag. The rename engine is
scoped, as a safety invariant, to never move a file outside its own source directory
tree (see "Organizing into subfolders" above) — a naming scheme's own `/` already covers
organizing into a subfolder, and relaxing that invariant just for `--output` wasn't
judged worth it.
## Porting roadmap (not yet done)
Roughly in priority order — each references the original FileBot source file it's
@@ -291,8 +344,15 @@ ported from, for anyone picking this up:
first free slot), or Override (replaces the conflicting file outright — the one
irreversible option, called out with red styling in the UI; undoing that batch later
restores the *source*, never what got overwritten, since undo always uses its own
fixed skip-on-conflict policy no matter which policy the original rename used). Not
yet done: the CLI flag surface itself.
fixed skip-on-conflict policy no matter which policy the original rename used). The
CLI flag surface is done too — `-rename`/`--format`/`--db`/`--action`/`--conflict`/
`-non-strict`/`-h` (`crates/aurora-curator-core/src/cli` for pure argument parsing and
directory expansion, `src-tauri/src/cli.rs` for the orchestration — local matching,
optional TMDB/TVmaze lookup, and the actual rename, all without ever constructing a
Tauri `App`); see "Command-line usage" above. `--output <dir>` and `-get-subtitles`/
`-script`/the rest of FileBot's non-rename CLI modes are not ported — `-get-
subtitles` and `-script` have no analog yet since subtitle-fetching and a script-mode
runtime aren't built at all.
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo,
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the
+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;
+1
View File
@@ -19,3 +19,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
walkdir = "2"
tokio = { version = "1", features = ["sync"] }
dirs = "6"
+495
View File
@@ -0,0 +1,495 @@
//! Orchestration for the `-rename` command-line mode: ties together the pure argument
//! parsing/file-expansion in `aurora_curator_core::cli` with this crate's own
//! Tauri-app-free pieces (`settings::cli_tmdb_token`, `metadata::AnyTvClient`) to
//! actually build a rename plan and execute it - all without ever constructing a Tauri
//! `App`/`Builder`. `main.rs` only calls [`run`] after confirming (via
//! `aurora_curator_core::cli::looks_like_cli_invocation`) that this is a `-rename`/
//! `-h`/`--help` invocation in the first place; a plain GUI launch never reaches this
//! module at all.
//!
//! Async network calls (TMDB/TVmaze lookups) run via `tauri::async_runtime::block_on`
//! rather than a directly-owned tokio runtime - confirmed by reading Tauri 2.11's own
//! `async_runtime.rs` that this lazily creates its own global `Runtime` via `OnceLock`,
//! fully independent of any running `App`, so it's safe to call here with no Tauri app
//! ever built. This also avoids adding a `rt`/`macros` tokio feature to this crate just
//! for the CLI path.
//!
//! [`build_plan`] is this module's Rust-side port of the binding vocabulary
//! `RenamePanel.tsx` builds client-side in the browser (`{n}`, `{s00e00}`, `{show
//! name}`, `{episode name}`, release-tag fields, etc.) - see that file's `runMatching`/
//! Fetch Data handlers, which this deliberately mirrors field-for-field, so a naming
//! scheme that works in the GUI renders identically from the command line.
use aurora_curator_core::cli::{self, CliInvocation, RenameCliArgs, TvDbChoice};
use aurora_curator_core::format::ExpressionFormat;
use aurora_curator_core::matching::{self, ReleaseInfo};
use aurora_curator_core::rename::{self, RenameOutcome, RenamePlanItem};
use aurora_curator_core::string_bindings;
use aurora_curator_core::web::tmdb::TmdbClient;
use aurora_curator_core::web::tvmaze::TvmazeClient;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::metadata::AnyTvClient;
use crate::settings;
const USAGE: &str = "\
Aurora Curator - command-line rename mode
Usage:
aurora-curator -rename --format <naming scheme> [options] <file-or-folder>...
Options:
--format <expr> Naming scheme, e.g. \"{show name} - {season number}x{episode number} - {episode name}\" (required)
--db <tmdb|tvmaze> Resolve TV episodes online against this source before rendering names (optional;
movies are only looked up online when this is tmdb - TVmaze has no movie catalog)
--action <move|copy|symlink|test> What to do with each file (default: move; \"test\" prints the plan without touching anything)
--conflict <skip|auto-number|override> What to do when a computed target already exists (default: skip)
-non-strict Accepted for FileBot script compatibility; has no effect (local matching has no
confidence score to loosen - see the aurora_curator_core::cli module doc)
-h, --help Show this help and exit
A --db source needs a TMDB token already saved through the app's own Fetch Data Settings at least once -
the CLI never accepts a token as a command-line argument.
";
/// Entry point called from `main.rs`. Returns the process exit code.
pub fn run(args: Vec<String>) -> i32 {
match cli::parse_args(&args) {
Ok(CliInvocation::Gui) => {
// `main.rs` only calls `run()` after `looks_like_cli_invocation` already
// confirmed a trigger flag is present, so this arm shouldn't be reachable
// in practice - but returning cleanly here is safer than panicking if it
// ever is.
0
}
Ok(CliInvocation::Help) => {
print!("{USAGE}");
0
}
Ok(CliInvocation::Rename(rename_args)) => run_rename(rename_args),
Err(err) => {
eprintln!("error: {err}");
eprintln!();
eprint!("{USAGE}");
2
}
}
}
fn run_rename(args: RenameCliArgs) -> i32 {
let files = cli::expand_inputs(&args.inputs);
if files.is_empty() {
eprintln!("error: no files found under the given input(s)");
return 1;
}
let compiled = match ExpressionFormat::compile(&args.format) {
Ok(compiled) => compiled,
Err(e) => {
eprintln!("error: invalid --format expression: {e}");
return 2;
}
};
// Resolved once up front (not per-file) so a batch never ends up split across two
// different saved-token states, and so a missing token fails fast instead of after
// however many files were already processed.
let tmdb_token = if args.db == Some(TvDbChoice::Tmdb) {
match settings::cli_tmdb_token() {
Some(token) => Some(token),
None => {
eprintln!(
"error: --db tmdb needs a TMDB token already saved through the app's own Fetch Data Settings"
);
return 2;
}
}
} else {
None
};
let tv_client = match args.db {
None => None,
Some(TvDbChoice::Tvmaze) => Some(AnyTvClient::Tvmaze(TvmazeClient::new())),
Some(TvDbChoice::Tmdb) => Some(AnyTvClient::Tmdb(TmdbClient::new(
tmdb_token.clone().expect("checked above"),
))),
};
// Movies only get an online lookup when --db tmdb was explicitly passed - TVmaze
// has no movie catalog, and no --db at all means "local matching only" for both.
let movie_client = tmdb_token.map(TmdbClient::new);
let items = tauri::async_runtime::block_on(build_plan(&files, &compiled, tv_client, movie_client));
if items.is_empty() {
eprintln!("error: every file's naming scheme failed to render - nothing to rename");
return 1;
}
if args.dry_run {
for item in &items {
println!("{} -> {}", item.source, item.new_name);
}
println!(
"{} file(s) would be renamed (--action test: nothing was touched)",
items.len()
);
return 0;
}
let results = rename::execute_renames(&items, args.action, args.conflict);
print_summary(&results)
}
fn print_summary(results: &[rename::RenameResult]) -> i32 {
let mut renamed = 0;
let mut unchanged = 0;
let mut skipped = 0;
let mut failed = 0;
for result in results {
match &result.outcome {
RenameOutcome::Renamed { new_path } => {
println!("{} -> {new_path}", result.source);
renamed += 1;
}
RenameOutcome::Unchanged => unchanged += 1,
RenameOutcome::SkippedConflict { existing_path } => {
eprintln!("skipped (target already exists): {} -> {existing_path}", result.source);
skipped += 1;
}
RenameOutcome::Failed { error } => {
eprintln!("failed: {} ({error})", result.source);
failed += 1;
}
}
}
println!("{renamed} renamed, {unchanged} unchanged, {skipped} skipped, {failed} failed");
if failed > 0 {
1
} else {
0
}
}
/// `path`'s file stem and (lowercased, dot-free) extension - the same split
/// `src-tauri/src/files.rs`'s `to_file_entry` does for the GUI's own file list, so the
/// `{name}`/`{ext}` bindings and the extension appended to every rendered name match
/// what the GUI would've produced for the same file.
fn split_stem_ext(path: &Path) -> (String, String) {
let name = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let ext = path
.extension()
.map(|s| s.to_string_lossy().to_lowercase())
.unwrap_or_default();
(name, ext)
}
fn insert_release_bindings(bindings: &mut HashMap<String, String>, release: &ReleaseInfo) {
if let Some(resolution) = &release.resolution {
bindings.insert("vf".to_string(), resolution.clone());
bindings.insert("resolution".to_string(), resolution.clone());
}
if let Some(source) = &release.source {
bindings.insert("source".to_string(), source.clone());
}
if let Some(video_codec) = &release.video_codec {
bindings.insert("vc".to_string(), video_codec.clone());
bindings.insert("video codec".to_string(), video_codec.clone());
}
if let Some(audio_codec) = &release.audio_codec {
bindings.insert("ac".to_string(), audio_codec.clone());
bindings.insert("audio codec".to_string(), audio_codec.clone());
}
if let Some(group) = &release.group {
bindings.insert("group".to_string(), group.clone());
bindings.insert("release group".to_string(), group.clone());
}
}
/// Builds the on-disk rename plan for `files`: local matching first (mirrors the GUI's
/// own Match step), then an optional online lookup per file when a client was passed
/// (mirrors Fetch Data) - see the module doc for why the binding names below have to
/// match `RenamePanel.tsx`'s exactly.
async fn build_plan(
files: &[PathBuf],
compiled: &ExpressionFormat,
tv_client: Option<AnyTvClient>,
movie_client: Option<TmdbClient>,
) -> Vec<RenamePlanItem> {
// Cache per (series_query, season), same as the GUI's own `run_fetch` - a whole
// season's worth of files only needs one online search + episode-list fetch.
let mut season_cache: HashMap<(String, i32), crate::metadata::SeasonLookup> = HashMap::new();
let mut items = Vec::with_capacity(files.len());
for path in files {
let filename = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let (name, ext) = split_stem_ext(path);
let mut bindings: HashMap<String, String> = HashMap::new();
bindings.insert("name".to_string(), name);
bindings.insert("ext".to_string(), ext.clone());
if let Some(release) = matching::guess_release_info(&filename) {
insert_release_bindings(&mut bindings, &release);
}
if let Some(episode) = matching::guess_episode(&filename) {
bindings.insert("n".to_string(), episode.series_name.clone());
bindings.insert("s00e00".to_string(), episode.season_episode_label());
bindings.insert("season".to_string(), episode.season.to_string());
bindings.insert("episode".to_string(), episode.episode.to_string());
bindings.insert("show name".to_string(), episode.series_name.clone());
bindings.insert("season number".to_string(), episode.season.to_string());
bindings.insert("episode number".to_string(), format!("{:02}", episode.episode));
if let Some(client) = &tv_client {
let key = (episode.series_name.clone(), episode.season);
if !season_cache.contains_key(&key) {
let looked_up = client.fetch_season(&episode.series_name, episode.season).await;
season_cache.insert(key.clone(), looked_up);
}
if let Some(Ok((series_name, episodes))) = season_cache.get(&key) {
if let Some(found) = episodes.iter().find(|e| e.episode_number == episode.episode) {
bindings.insert("n".to_string(), series_name.clone());
bindings.insert("t".to_string(), found.title.clone());
bindings.insert("show name".to_string(), series_name.clone());
bindings.insert("episode name".to_string(), found.title.clone());
if let Some(air_date) = &found.air_date {
bindings.insert("airdate".to_string(), air_date.clone());
}
}
}
}
} else if let Some(movie) = matching::guess_movie(&filename) {
bindings.insert("n".to_string(), movie.title.clone());
bindings.insert("movie name".to_string(), movie.title.clone());
if let Some(year) = movie.year {
bindings.insert("y".to_string(), year.to_string());
bindings.insert("year".to_string(), year.to_string());
}
if let Some(client) = &movie_client {
if let Ok(info) = client.lookup_movie(&movie.title, movie.year).await {
bindings.insert("n".to_string(), info.title.clone());
bindings.insert("movie name".to_string(), info.title.clone());
if let Some(release_date) = &info.release_date {
bindings.insert("airdate".to_string(), release_date.clone());
// Only overwrite the locally-guessed year if Match didn't
// already find one in the filename itself - same rule
// RenamePanel.tsx's Fetch Data handler uses, since a
// filename-carried year is usually more precise than TMDB's
// release date for disambiguating remakes.
if movie.year.is_none() {
let year: String = release_date.chars().take(4).collect();
bindings.insert("y".to_string(), year.clone());
bindings.insert("year".to_string(), year);
}
}
}
}
}
let rendered = match compiled.format(&string_bindings(bindings)) {
Ok(rendered) => rendered,
Err(e) => {
eprintln!(
"warning: couldn't render a name for {} ({e}) - skipping",
path.display()
);
continue;
}
};
let new_name = if ext.is_empty() { rendered } else { format!("{rendered}.{ext}") };
items.push(RenamePlanItem {
source: path.to_string_lossy().to_string(),
new_name,
});
}
items
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
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_orchestration_test_{n}_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn build_plan_renders_tv_and_movie_files_locally_with_no_online_client() {
let dir = temp_dir();
let tv_file = dir.join("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv");
let movie_file = dir.join("Arrival.2016.1080p.BluRay.x264-GROUP.mkv");
std::fs::write(&tv_file, b"").unwrap();
std::fs::write(&movie_file, b"").unwrap();
let compiled = ExpressionFormat::compile("{n}").unwrap();
let items = tauri::async_runtime::block_on(build_plan(
&[tv_file.clone(), movie_file.clone()],
&compiled,
None,
None,
));
assert_eq!(items.len(), 2);
let tv_item = items
.iter()
.find(|i| i.source == tv_file.to_string_lossy())
.expect("tv file in plan");
assert_eq!(tv_item.new_name, "Breaking Bad.mkv");
let movie_item = items
.iter()
.find(|i| i.source == movie_file.to_string_lossy())
.expect("movie file in plan");
assert_eq!(movie_item.new_name, "Arrival.mkv");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn build_plan_falls_back_to_the_bare_filename_when_nothing_is_recognized() {
let dir = temp_dir();
let file = dir.join("random_video_file.mp4");
std::fs::write(&file, b"").unwrap();
let compiled = ExpressionFormat::compile("{name}").unwrap();
let items = tauri::async_runtime::block_on(build_plan(&[file.clone()], &compiled, None, None));
assert_eq!(items.len(), 1);
assert_eq!(items[0].new_name, "random_video_file.mp4");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn build_plan_includes_release_tag_bindings() {
let dir = temp_dir();
let file = dir.join("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv");
std::fs::write(&file, b"").unwrap();
let compiled = ExpressionFormat::compile("{resolution} {video codec} {release group}").unwrap();
let items = tauri::async_runtime::block_on(build_plan(&[file.clone()], &compiled, None, None));
assert_eq!(items[0].new_name, "720p x264 KILLERS.mkv");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn run_prints_usage_and_returns_zero_for_help() {
assert_eq!(run(vec!["--help".to_string()]), 0);
assert_eq!(run(vec!["-rename".to_string(), "--help".to_string()]), 0);
}
#[test]
fn run_returns_zero_for_a_plain_gui_invocation() {
// `main.rs` wouldn't actually call `run()` in this case (it checks
// `looks_like_cli_invocation` first), but `run()` itself stays defensive and
// returns cleanly rather than panicking if it ever is.
assert_eq!(run(vec![]), 0);
}
#[test]
fn run_reports_a_parse_error_with_a_nonzero_exit_code() {
assert_eq!(run(vec!["-rename".to_string()]), 2);
}
#[test]
fn run_reports_an_error_when_no_files_match_the_inputs() {
let code = run(vec![
"-rename".to_string(),
"--format".to_string(),
"{n}".to_string(),
"/definitely/not/a/real/path/anywhere".to_string(),
]);
assert_eq!(code, 1);
}
#[test]
fn run_dry_run_does_not_touch_the_filesystem() {
let dir = temp_dir();
let file = dir.join("Breaking.Bad.S05E14.mkv");
std::fs::write(&file, b"hello").unwrap();
let code = run(vec![
"-rename".to_string(),
"--format".to_string(),
"{n}".to_string(),
"--action".to_string(),
"test".to_string(),
file.to_string_lossy().to_string(),
]);
assert_eq!(code, 0);
assert!(file.exists(), "dry run must not touch the source file");
assert!(!dir.join("Breaking Bad.mkv").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn run_actually_renames_a_file_with_the_default_move_action() {
let dir = temp_dir();
let file = dir.join("Breaking.Bad.S05E14.mkv");
std::fs::write(&file, b"hello").unwrap();
let code = run(vec![
"-rename".to_string(),
"--format".to_string(),
"{n}".to_string(),
file.to_string_lossy().to_string(),
]);
assert_eq!(code, 0);
assert!(!file.exists());
assert!(dir.join("Breaking Bad.mkv").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn run_fails_cleanly_when_db_tmdb_is_requested_without_a_saved_token() {
// This sandbox has no saved settings.json, so `cli_tmdb_token()` reliably
// returns None here; skip rather than false-fail if that's ever not true (e.g.
// a real token saved from a prior manual run in this same environment).
if settings::cli_tmdb_token().is_some() {
eprintln!("skipping: a real TMDB token is saved in this environment's config dir");
return;
}
let dir = temp_dir();
let file = dir.join("Breaking.Bad.S05E14.mkv");
std::fs::write(&file, b"hello").unwrap();
let code = run(vec![
"-rename".to_string(),
"--format".to_string(),
"{n}".to_string(),
"--db".to_string(),
"tmdb".to_string(),
file.to_string_lossy().to_string(),
]);
assert_eq!(code, 2);
assert!(file.exists(), "should fail before touching any file");
std::fs::remove_dir_all(&dir).ok();
}
}
+8
View File
@@ -1,3 +1,4 @@
mod cli;
mod files;
mod history;
mod metadata;
@@ -79,6 +80,13 @@ async fn execute_rename(
results
}
/// Entry point for the `-rename` command-line mode - see `cli.rs`'s module doc. Never
/// builds a Tauri `App`/`Builder` at all, unlike [`run`] below. Returns the process
/// exit code for `main.rs` to pass to `std::process::exit`.
pub fn run_cli(args: Vec<String>) -> i32 {
cli::run(args)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
+11
View File
@@ -2,5 +2,16 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// `-rename`/`-h`/`--help` on the command line means this is a scripted CLI
// invocation, not a normal double-click GUI launch - see
// `aurora_curator_core::cli`'s module doc. Checked with the exact same predicate
// `cli::parse_args` itself uses internally, so this can never drift out of sync
// with what actually triggers CLI mode. No args at all (the normal launch case)
// falls straight through to the unchanged GUI path below.
let args: Vec<String> = std::env::args().skip(1).collect();
if aurora_curator_core::cli::looks_like_cli_invocation(&args) {
std::process::exit(aurora_curator_lib::run_cli(args));
}
aurora_curator_lib::run();
}
+15 -12
View File
@@ -53,20 +53,23 @@ pub struct FetchResult {
/// One episode of a resolved season - the shape both TMDB's and TVmaze's own
/// per-source episode types get mapped into by `AnyTvClient::fetch_season`, so the
/// caching/lookup loop in `run_fetch` never needs to know which source produced a
/// result.
struct LocalEpisode {
episode_number: i32,
title: String,
air_date: Option<String>,
/// result. `pub(crate)` (not just used by the GUI's own `fetch_metadata` command below)
/// so `cli.rs`'s `-rename --db` orchestration can reuse the exact same season-caching
/// lookup rather than duplicating it - see that module's doc comment.
pub(crate) struct LocalEpisode {
pub(crate) episode_number: i32,
pub(crate) title: String,
pub(crate) air_date: Option<String>,
}
type SeasonLookup = Result<(String, Vec<LocalEpisode>), String>;
pub(crate) type SeasonLookup = Result<(String, Vec<LocalEpisode>), String>;
/// Either TV metadata client, picked once per `fetch_metadata` call based on the
/// stored `TvMetadataSource` setting, then reused across every item in the batch (so a
/// setting change mid-batch can't produce mixed-source results within one Fetch Data
/// click).
enum AnyTvClient {
/// Either TV metadata client, picked once per `fetch_metadata` call (or, for the CLI,
/// once per `-rename --db` invocation) based on the chosen source, then reused across
/// every item in the batch (so a setting change mid-batch, or a long CLI run, can't
/// produce mixed-source results within one Fetch Data click or one `-rename` command).
/// `pub(crate)`: see `LocalEpisode`'s doc comment above.
pub(crate) enum AnyTvClient {
Tmdb(TmdbClient),
Tvmaze(TvmazeClient),
}
@@ -81,7 +84,7 @@ impl AnyTvClient {
}
}
async fn fetch_season(&self, series_query: &str, season: i32) -> SeasonLookup {
pub(crate) async fn fetch_season(&self, series_query: &str, season: i32) -> SeasonLookup {
match self {
AnyTvClient::Tmdb(client) => {
let best = client
+50 -5
View File
@@ -1,17 +1,30 @@
//! Small persisted app settings - the TMDB API token, and (since the TVmaze datasource
//! was added) which TV metadata source Fetch Data should use ("Fetch Data" is the only
//! thing in the app that needs settings at all; everything else works offline). Stored
//! as plain JSON in Tauri's per-OS app config directory (e.g.
//! `~/Library/Application Support/com.auroracurator.app/settings.json` on macOS,
//! `~/.config/aurora-curator/settings.json` on Linux) - not the original FileBot's config
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora Curator's
//! own, analogous location for its own settings.
//! as plain JSON in the OS's standard per-app config directory, joined with the app's
//! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json`
//! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\
//! com.auroracurator.app\settings.json` on Windows - not the original FileBot's config
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora
//! Curator's own, analogous location for its own settings.
//!
//! The GUI reaches that directory through a running Tauri `AppHandle`
//! (`app.path().app_config_dir()`); the `-rename` CLI mode (`cli.rs`) never builds a
//! Tauri app at all, so it needs the exact same directory computed a different way -
//! see [`cli_tmdb_token`] below, which replicates Tauri's own `dirs::config_dir()
//! .join(identifier)` computation directly (confirmed against Tauri 2.11's
//! `path/desktop.rs` source) so both code paths are guaranteed to read/write the same
//! file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s
//! `identifier` field by hand - there's no way to share a constant with the JSON config.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
/// Must match `tauri.conf.json`'s `identifier` field exactly - see the module doc.
const APP_CONFIG_IDENTIFIER: &str = "com.auroracurator.app";
/// Which online source Fetch Data resolves TV episodes against. Movies always use
/// TMDB regardless of this setting - TVmaze doesn't have a movie catalog at all, so
/// there's nothing to pick between for the movie half of Fetch Data.
@@ -66,6 +79,20 @@ pub fn tmdb_token(app: &AppHandle) -> Option<String> {
load(app).tmdb_token.filter(|t| !t.trim().is_empty())
}
/// Same as [`tmdb_token`], but for the `-rename` CLI path, which has no `AppHandle` to
/// ask (it never builds a Tauri app at all - see `cli.rs`'s module doc). Resolves the
/// config directory itself using the identical `dirs::config_dir().join(identifier)`
/// computation Tauri's own `AppHandle::path().app_config_dir()` uses internally, so a
/// token saved through the GUI's Fetch Data Settings is found here too. Aurora Curator
/// still never accepts an API token as a CLI argument - `--db tmdb` only works once a
/// token has been saved through the app itself at least once.
pub fn cli_tmdb_token() -> Option<String> {
let dir = dirs::config_dir()?.join(APP_CONFIG_IDENTIFIER);
let contents = fs::read_to_string(dir.join("settings.json")).ok()?;
let settings: StoredSettings = serde_json::from_str(&contents).ok()?;
settings.tmdb_token.filter(|t| !t.trim().is_empty())
}
#[tauri::command]
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
tmdb_token(&app)
@@ -114,6 +141,24 @@ mod tests {
assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\"");
}
#[test]
fn app_config_identifier_matches_tauri_conf_json() {
// `cli_tmdb_token()` recomputes the GUI's settings-file path by hand (no
// `AppHandle` available in `-rename` mode), so this constant has to be kept in
// sync with `tauri.conf.json`'s `identifier` field manually - see the module
// doc. There's no way to share a single source of truth between a Rust const
// and the JSON config, so this test at least pins the value we're relying on:
// a change to `tauri.conf.json` without a matching change here would silently
// make the CLI look in the wrong directory for a token the GUI already saved.
let conf = include_str!("../tauri.conf.json");
let parsed: serde_json::Value = serde_json::from_str(conf).unwrap();
assert_eq!(
parsed["identifier"].as_str(),
Some(APP_CONFIG_IDENTIFIER),
"APP_CONFIG_IDENTIFIER must match tauri.conf.json's identifier field"
);
}
#[test]
fn stored_settings_without_a_tv_metadata_source_field_still_deserializes() {
// Settings written before this field existed (just `{"tmdbToken": "..."}` or