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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user