Rebrand: Renamr -> Aurora Curator (new logo/app icon, renamed crate aurora-curator-core, package/identifier/window-title updates, README + in-app text)

This commit is contained in:
Renamr Scaffold
2026-08-29 10:07:09 +00:00
parent 7dd9ad2078
commit 7097b1ede7
42 changed files with 132 additions and 153 deletions
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "aurora-curator-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Aurora Curator core: naming-expression engine, media matching, and rename engine (UI-agnostic)."
[dependencies]
# Embeddable scripting engine powering the {expression} naming-scheme language.
# Stands in for the Groovy JSR-223 engine FileBot used (Groovy is JVM-only, not available
# to a Rust core) while keeping the same outer `{ }` template syntax.
rhai = { version = "1.19", features = ["sync"] }
thiserror = "1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Filename-based season/episode detection (local, no lookahead/lookbehind support
# unlike Java's regex engine used by the original SeasonEpisodeMatcher, so patterns
# are adapted to rely on \b word boundaries instead).
regex = "1"
# TMDB metadata client ("Fetch Data"). rustls instead of the default openssl-backed TLS
# so this doesn't need system OpenSSL dev headers on every platform we target.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "macros"] }
[dev-dependencies]
# Local HTTP mock server so the TMDB client's request/response handling can be unit
# tested without live network access or a real API key.
mockito = "1"
@@ -0,0 +1,16 @@
use rhai::Engine;
use std::sync::OnceLock;
use super::functions;
/// Shared Rhai engine, built once. Engines are relatively expensive to construct
/// (loading the standard package), so every [`crate::format::ExpressionFormat`]
/// compiles against this single shared instance.
pub(super) fn shared() -> &'static Engine {
static ENGINE: OnceLock<Engine> = OnceLock::new();
ENGINE.get_or_init(|| {
let mut engine = Engine::new();
functions::register(&mut engine);
engine
})
}
@@ -0,0 +1,200 @@
use rhai::{Dynamic, Scope, AST};
use std::collections::HashMap;
use thiserror::Error;
use super::engine;
/// The variables available inside a `{ }` expression, e.g. `n` -> show name.
pub type Bindings = HashMap<String, Dynamic>;
#[derive(Debug, Error)]
pub enum ExpressionError {
#[error("syntax error in expression `{expression}`: {message}")]
Syntax { expression: String, message: String },
#[error("expression yields empty value: {0}")]
Empty(String),
}
enum Segment {
Literal(String),
/// Fast path for bare identifiers like `{n}` or `{s00e00}`: skips the script engine
/// entirely, both for speed and so an undefined binding fails predictably.
Variable(String),
Script(AST),
}
/// A compiled naming-scheme expression, e.g. `"{n} - {s00e00} - {t}"`.
///
/// Compilation is separate from formatting (like `java.text.Format`/FileBot's
/// `ExpressionFormat`) so a pattern is parsed once and then applied to many files.
#[derive(Debug)]
pub struct ExpressionFormat {
expression: String,
segments: Vec<Segment>,
}
impl std::fmt::Debug for Segment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Segment::Literal(s) => write!(f, "Literal({s:?})"),
Segment::Variable(s) => write!(f, "Variable({s:?})"),
Segment::Script(_) => write!(f, "Script(..)"),
}
}
}
impl ExpressionFormat {
pub fn compile(expression: &str) -> Result<Self, ExpressionError> {
let segments = parse(expression)?;
Ok(Self {
expression: expression.to_string(),
segments,
})
}
pub fn expression(&self) -> &str {
&self.expression
}
/// Renders this pattern against the given bindings. Mirrors FileBot's semantics:
/// a scriptlet that fails to evaluate (e.g. references an undefined/empty binding)
/// simply contributes nothing, but the *overall* result must be non-empty.
pub fn format(&self, bindings: &Bindings) -> Result<String, ExpressionError> {
let engine = engine::shared();
let mut out = String::new();
for segment in &self.segments {
match segment {
Segment::Literal(text) => out.push_str(text),
Segment::Variable(name) => {
if let Some(value) = bindings.get(name) {
if !value.is_unit() {
out.push_str(&value.to_string());
}
}
}
Segment::Script(ast) => {
let mut scope = Scope::new();
for (key, value) in bindings {
scope.push_dynamic(key.clone(), value.clone());
}
if let Ok(value) = engine.eval_ast_with_scope::<Dynamic>(&mut scope, ast) {
if !value.is_unit() {
out.push_str(&value.to_string());
}
}
}
}
}
if out.is_empty() {
return Err(ExpressionError::Empty(self.expression.clone()));
}
Ok(out)
}
}
fn is_identifier(token: &str) -> bool {
let mut chars = token.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
fn compile_scriptlet(token: &str, expression: &str) -> Result<Segment, ExpressionError> {
if is_identifier(token) {
return Ok(Segment::Variable(token.to_string()));
}
match engine::shared().compile(token) {
Ok(ast) => Ok(Segment::Script(ast)),
Err(e) => {
// Not valid as a single bare identifier, and not valid as a Rhai script
// either - but if it otherwise looks like a plain, human-typed field name
// (e.g. "show name": just letters/digits/spaces/underscores), treat the
// whole thing as a literal binding key instead of surfacing a syntax
// error. This is what lets a naming scheme like
// `{show name} - {season number}x{episode number} - {episode name}` work
// without every such phrase needing to be a reserved single-word binding.
// Genuine script syntax errors (unmatched parens/quotes, bad operators,
// etc.) still get reported exactly as before.
let trimmed = token.trim();
if is_plain_field_name(trimmed) {
Ok(Segment::Variable(trimmed.to_string()))
} else {
Err(ExpressionError::Syntax {
expression: expression.to_string(),
message: e.to_string(),
})
}
}
}
}
/// True for a token that reads like a plain (possibly multi-word) field name rather
/// than code: starts with a letter or underscore, and contains only letters, digits,
/// underscores, and internal spaces. Only consulted as a fallback after Rhai has
/// already rejected the token as a script.
fn is_plain_field_name(token: &str) -> bool {
let mut chars = token.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_' || c == ' ')
}
/// Splits an expression into literal text and `{ }` scriptlets, honoring nested braces
/// (so a scriptlet may itself contain `{ }`, e.g. a map literal or block).
fn parse(expression: &str) -> Result<Vec<Segment>, ExpressionError> {
let mut segments = Vec::new();
let mut token = String::new();
let mut level = 0i32;
for c in expression.chars() {
match c {
'{' => {
if level == 0 {
if !token.is_empty() {
segments.push(Segment::Literal(std::mem::take(&mut token)));
}
} else {
token.push(c);
}
level += 1;
}
'}' => {
level -= 1;
if level < 0 {
return Err(ExpressionError::Syntax {
expression: expression.to_string(),
message: "unexpected '}'".to_string(),
});
}
if level == 0 {
if !token.is_empty() {
segments.push(compile_scriptlet(&std::mem::take(&mut token), expression)?);
}
} else {
token.push(c);
}
}
_ => token.push(c),
}
}
if level != 0 {
return Err(ExpressionError::Syntax {
expression: expression.to_string(),
message: "missing '}'".to_string(),
});
}
if !token.is_empty() {
segments.push(Segment::Literal(token));
}
Ok(segments)
}
@@ -0,0 +1,52 @@
//! Global helper functions available inside every `{ }` expression, e.g. `{any([x, y])}`.
//!
//! Starter subset ported from FileBot's `ExpressionFormatFunctions.java`. Rhai already
//! provides `to_upper`, `to_lower`, `trim`, `sub_string`, `len`, `contains`, `pad` and
//! friends as built-in string methods, so this module only adds the FileBot-specific
//! helpers that don't already have a Rhai equivalent. More helpers (csv/read_lines/
//! quote_bash/quote_powershell, etc.) are tracked in the porting backlog.
use rhai::{Array, Dynamic, Engine};
fn is_empty_value(value: &Dynamic) -> bool {
if value.is_unit() {
return true;
}
if let Some(s) = value.clone().try_cast::<rhai::ImmutableString>() {
return s.is_empty();
}
if let Some(arr) = value.clone().try_cast::<Array>() {
return arr.is_empty();
}
false
}
/// Returns the first array element that isn't empty/unit, or `()` if all are empty.
fn any_of(candidates: Array) -> Dynamic {
candidates
.into_iter()
.find(|v| !is_empty_value(v))
.unwrap_or(Dynamic::UNIT)
}
/// Returns all non-empty elements, preserving order.
fn all_of(candidates: Array) -> Array {
candidates.into_iter().filter(|v| !is_empty_value(v)).collect()
}
/// Concatenates all non-empty elements' string forms with no separator.
fn concat(parts: Array) -> String {
parts
.into_iter()
.filter(|v| !is_empty_value(v))
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join("")
}
pub fn register(engine: &mut Engine) {
engine.register_fn("is_empty_value", is_empty_value);
engine.register_fn("any", any_of);
engine.register_fn("all_of", all_of);
engine.register_fn("concat", concat);
}
@@ -0,0 +1,18 @@
//! Aurora Curator's naming-scheme expression language.
//!
//! A format string is literal text with `{ ... }` scriptlets spliced in, e.g.
//! `"{n} - {s00e00} - {t}"`
//! Everything inside `{ }` is evaluated as an expression against a set of "bindings"
//! (the variables available to the expression, e.g. `n` = show name).
//!
//! This keeps FileBot's outer `{ }` template syntax and simple-binding behavior
//! (`{n}`, `{s00e00}`, ...), so existing simple rename patterns keep working unchanged.
//! The inner expression *language* is Rhai rather than Groovy (Groovy is JVM-only and
//! this core is plain Rust) -- see the crate README for the compatibility notes on more
//! complex chained-method expressions.
mod engine;
mod expression;
mod functions;
pub use expression::{ExpressionError, ExpressionFormat};
@@ -0,0 +1,421 @@
//! Rename history: a persisted log of past rename batches (what changed, from what to
//! what) and the ability to revert one - Aurora Curator's only safety net beyond the
//! confirmation-before-execute step, since a plain filesystem rename has no OS-level
//! undo. Loosely inspired by `net.filebot.HistoryDialog`/the original's rename-history
//! log, but as a flat JSON-backed undo stack (see [`push_batch`]) rather than a full
//! session/element log - simpler, and all this app actually needs.
//!
//! This module only implements the *logic* (recording, trimming, reverting); where the
//! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same
//! split as `rename` (pure logic here) vs. the Tauri command that calls it.
use crate::rename::RenameAction;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// One file's old path -> new path, as recorded right after a successful rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryEntry {
pub source: String,
pub new_path: String,
}
/// One executed rename operation - everything renamed together in a single "Rename N
/// Files" confirmation - undoable as a unit. All entries in a batch share one
/// [`RenameAction`], since the UI picks a single action per confirmation, not per file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryBatch {
pub id: String,
/// Unix epoch milliseconds when the batch was executed. Kept as a plain number
/// (no date/time dependency needed in this crate) so the frontend can format it
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
pub timestamp: u64,
/// What was actually done to produce `entries.new_path` from `entries.source`.
/// `#[serde(default)]` so a `history.json` written before this field existed (every
/// batch back then was necessarily a Move) still deserializes cleanly.
#[serde(default)]
pub action: RenameAction,
pub entries: Vec<HistoryEntry>,
}
/// What happened when reverting one entry of a batch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum RevertOutcome {
/// For a Move batch: successfully moved back to its original path. For a Copy or
/// Symlink batch: the copy/symlink that was created has been removed - the original
/// file was never touched in the first place, so `restored_path` here just names it
/// for the caller rather than describing something that was actually moved.
Reverted { restored_path: String },
/// A different file already exists at the original path; left untouched - the same
/// skip-never-overwrite policy `rename::execute_renames` uses. Only possible for a
/// Move revert; Copy/Symlink reverts never touch the original path at all.
SkippedConflict { existing_path: String },
/// The renamed/copied/linked file isn't where the history says it should be
/// anymore (moved, deleted, or already reverted some other way), or the OS reported
/// an error.
Failed { error: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct RevertResult {
/// The entry's `new_path` (i.e. where the file was expected to be found, before
/// reverting) - lets the caller map a result back to a specific history entry.
pub source: String,
pub outcome: RevertOutcome,
}
fn same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(pa), Ok(pb)) => pa == pb,
_ => false,
}
}
fn revert_one(entry: &HistoryEntry, action: RenameAction) -> RevertOutcome {
let current = Path::new(&entry.new_path);
let original = Path::new(&entry.source);
match action {
RenameAction::Move => {
if !current.exists() {
return RevertOutcome::Failed {
error: "the renamed file is no longer at its recorded location".to_string(),
};
}
if original.exists() && !same_file(original, current) {
return RevertOutcome::SkippedConflict {
existing_path: original.to_string_lossy().to_string(),
};
}
// The original's parent directory should still exist (nothing in Aurora Curator
// deletes directories), but recreate it defensively in case it was removed
// by hand since - cheap, and `create_dir_all` is a no-op when it's already
// there.
if let Some(parent) = original.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return RevertOutcome::Failed {
error: format!("couldn't recreate the original folder: {e}"),
};
}
}
match fs::rename(current, original) {
Ok(()) => RevertOutcome::Reverted {
restored_path: original.to_string_lossy().to_string(),
},
Err(e) => RevertOutcome::Failed { error: e.to_string() },
}
}
RenameAction::Copy | RenameAction::Symlink => {
// Copy/Symlink never touched `original` in the first place - the file that
// was created lives only at `current`, so undoing is just removing it.
// `symlink_metadata` (rather than `exists`, which follows links) is used so
// a broken symlink - its target moved or deleted separately - still counts
// as present and removable, instead of being reported as already gone.
if fs::symlink_metadata(current).is_err() {
return RevertOutcome::Failed {
error: "the copy/symlink is no longer at its recorded location".to_string(),
};
}
match fs::remove_file(current) {
Ok(()) => RevertOutcome::Reverted {
restored_path: original.to_string_lossy().to_string(),
},
Err(e) => RevertOutcome::Failed { error: e.to_string() },
}
}
}
}
/// Reverts every entry in a batch, in order, all under the batch's recorded
/// [`RenameAction`]. Like [`crate::rename::execute_renames`], entries are processed
/// sequentially (not in parallel) so a collision between two entries in the same batch
/// resolves deterministically rather than racing.
pub fn revert_batch(batch: &HistoryBatch) -> Vec<RevertResult> {
batch
.entries
.iter()
.map(|entry| RevertResult {
source: entry.new_path.clone(),
outcome: revert_one(entry, batch.action),
})
.collect()
}
/// Maximum number of batches kept in the persisted history - once the log grows past
/// this, the oldest batches are dropped so the history file can't grow unbounded over
/// a long-lived install.
pub const MAX_HISTORY_BATCHES: usize = 50;
/// Adds a new batch to the front of `history` (most-recent-first) and trims the log
/// back down to [`MAX_HISTORY_BATCHES`]. A batch with no entries is not recorded at
/// all - a rename operation where every item was unchanged/skipped/failed has nothing
/// to undo, so it shouldn't clutter the history.
pub fn push_batch(history: &mut Vec<HistoryBatch>, batch: HistoryBatch) {
if batch.entries.is_empty() {
return;
}
history.insert(0, batch);
history.truncate(MAX_HISTORY_BATCHES);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_dir() -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("aurora_curator_history_test_{}_{}", std::process::id(), n));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reverts_a_renamed_file_back_to_its_original_path() {
let dir = temp_dir();
let original = dir.join("Breaking.Bad.S05E14.mkv");
let renamed = dir.join("Ozymandias.mkv");
fs::write(&renamed, "video").unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
match &results[0].outcome {
RevertOutcome::Reverted { restored_path } => {
assert_eq!(Path::new(restored_path), original.as_path());
assert!(original.exists());
assert!(!renamed.exists());
}
other => panic!("expected Reverted, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn reverting_into_a_subfolder_target_recreates_a_deleted_original_folder() {
let dir = temp_dir();
let sub = dir.join("Breaking Bad");
fs::create_dir_all(&sub).unwrap();
let original = sub.join("Ozymandias.mkv");
let renamed = dir.join("flat.mkv");
fs::write(&renamed, "video").unwrap();
// Simulate the organized-into subfolder having been removed since the rename.
fs::remove_dir_all(&sub).unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
assert!(original.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn skips_revert_when_something_else_now_occupies_the_original_path() {
let dir = temp_dir();
let original = dir.join("original.mkv");
fs::write(&original, "a new unrelated file").unwrap();
let renamed = dir.join("renamed.mkv");
fs::write(&renamed, "video").unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(
results[0].outcome,
RevertOutcome::SkippedConflict { .. }
));
// Neither file should have been touched.
assert_eq!(fs::read_to_string(&original).unwrap(), "a new unrelated file");
assert_eq!(fs::read_to_string(&renamed).unwrap(), "video");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn fails_gracefully_when_the_renamed_file_is_no_longer_there() {
let dir = temp_dir();
let original = dir.join("original.mkv");
let renamed = dir.join("renamed.mkv"); // never created
let batch = HistoryBatch {
id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn push_batch_ignores_empty_batches() {
let mut history = Vec::new();
push_batch(
&mut history,
HistoryBatch {
id: "empty".to_string(),
timestamp: 0,
action: RenameAction::Move,
entries: vec![],
},
);
assert!(history.is_empty());
}
#[test]
fn push_batch_orders_most_recent_first_and_trims_to_the_cap() {
let mut history = Vec::new();
for i in 0..(MAX_HISTORY_BATCHES + 5) {
push_batch(
&mut history,
HistoryBatch {
id: format!("batch-{i}"),
timestamp: i as u64,
action: RenameAction::Move,
entries: vec![HistoryEntry {
source: "a".to_string(),
new_path: "b".to_string(),
}],
},
);
}
assert_eq!(history.len(), MAX_HISTORY_BATCHES);
// Most recently pushed batch is first.
assert_eq!(history[0].id, format!("batch-{}", MAX_HISTORY_BATCHES + 4));
// The oldest batches (0..5) were dropped to stay under the cap.
assert!(!history.iter().any(|b| b.id == "batch-0"));
}
#[test]
fn reverting_a_copy_batch_deletes_the_copy_and_leaves_the_original_untouched() {
let dir = temp_dir();
let original = dir.join("original.txt");
fs::write(&original, "hello").unwrap();
let copy = dir.join("copied.txt");
fs::write(&copy, "hello").unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
action: RenameAction::Copy,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: copy.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
match &results[0].outcome {
RevertOutcome::Reverted { restored_path } => {
assert_eq!(Path::new(restored_path), original.as_path());
}
other => panic!("expected Reverted, got {other:?}"),
}
// The copy is gone...
assert!(!copy.exists());
// ...but the original was never touched in the first place.
assert!(original.exists());
assert_eq!(fs::read_to_string(&original).unwrap(), "hello");
fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn reverting_a_symlink_batch_removes_the_link_not_the_file_it_points_to() {
let dir = temp_dir();
let original = dir.join("original.txt");
fs::write(&original, "hello").unwrap();
let link = dir.join("linked.txt");
std::os::unix::fs::symlink(&original, &link).unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
action: RenameAction::Symlink,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: link.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
// The symlink is gone...
assert!(fs::symlink_metadata(&link).is_err());
// ...but the real file it pointed at is completely unaffected.
assert!(original.exists());
assert_eq!(fs::read_to_string(&original).unwrap(), "hello");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn reverting_a_copy_batch_fails_gracefully_when_the_copy_is_already_gone() {
let dir = temp_dir();
let original = dir.join("original.txt");
fs::write(&original, "hello").unwrap();
let copy = dir.join("copied.txt"); // never created
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
action: RenameAction::Copy,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: copy.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
// The original should still be completely untouched.
assert!(original.exists());
fs::remove_dir_all(&dir).ok();
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Aurora Curator core: the UI-agnostic engine behind the Aurora Curator media renamer.
//!
//! This crate has no Tauri/UI dependency so it can be built and unit-tested standalone.
//! It's the piece being ported first from the original FileBot (Java) codebase; see
//! the workspace README for the porting roadmap (matching engine, media metadata
//! parsing, web datasource clients, rename engine are not yet ported).
pub mod format;
pub mod history;
pub mod matching;
pub mod rename;
pub mod similarity;
pub mod web;
use rhai::Dynamic;
use std::collections::HashMap;
/// Convenience constructor for [`format::ExpressionFormat`] bindings from plain strings,
/// which is the common case (binding values are almost always show/movie/episode text).
pub fn string_bindings<I, K, V>(pairs: I) -> HashMap<String, Dynamic>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
pairs
.into_iter()
.map(|(k, v)| (k.into(), Dynamic::from(v.into())))
.collect()
}
@@ -0,0 +1,472 @@
//! Local, filename-based season/episode detection.
//!
//! This ports the pattern-matching half of the original FileBot's
//! `similarity/SeasonEpisodeMatcher.java`: given a filename, guess a season/episode
//! number pair and the series-name text that precedes it. Unlike the original (which
//! runs on Java's regex engine and leans on lookahead/lookbehind), Rust's `regex` crate
//! has neither, so every pattern here is rewritten to rely on `\b` word boundaries and
//! character classes instead.
//!
//! Deliberately scoped to *local* filename parsing only - there is no metadata lookup
//! here, no online API calls, and no fuzzy title matching against a show database (that
//! is the much larger `EpisodeMetrics`-based engine, not yet ported). This is the
//! "works offline the moment you load real files" increment.
use regex::Regex;
use serde::Serialize;
use std::sync::OnceLock;
/// A season/episode number pair guessed from a filename, plus the raw series-name text
/// that appeared before the match.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EpisodeGuess {
pub series_name: String,
pub season: i32,
pub episode: i32,
}
impl EpisodeGuess {
/// Formats as FileBot's conventional `{s00e00}` binding value, e.g. `S01E01`.
pub fn season_episode_label(&self) -> String {
format!("S{:02}E{:02}", self.season, self.episode)
}
}
fn season_episode_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\bseason\s*(\d{1,4})\D{0,5}episode\s*(\d{1,3})\b").unwrap()
})
}
fn s00e00_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\bs(\d{1,2})[^a-zA-Z0-9]{0,3}e(\d{1,3})\b").unwrap())
}
fn sxe_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\b(\d{1,2})x(\d{2,3})\b").unwrap())
}
fn year_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\b(19\d{2}|20\d{2})\b").unwrap())
}
/// Common release-name quality/source tags. Not exhaustive (there's no bound on what a
/// release group might tack on), just the handful common enough to reliably mark where
/// a movie title ends when no release year is present either.
fn quality_token_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\b(720p|1080p|2160p|480p|bluray|blu-ray|web-?dl|webrip|hdtv|dvdrip|brrip|x264|x265|h264|h265|hevc)\b").unwrap()
})
}
/// Cleans up the raw text preceding a season/episode match into a plausible series
/// name: `.`/`_` used as word separators become spaces, and leading/trailing
/// whitespace/dash/dot clutter is trimmed.
fn clean_series_name(raw: &str) -> String {
let replaced: String = raw
.chars()
.map(|c| if c == '.' || c == '_' { ' ' } else { c })
.collect();
let trimmed = replaced.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '.');
trimmed.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Like `clean_series_name`, but also trims a trailing unmatched opening bracket - a
/// movie title guess is typically cut right before a `(year)` or `[tag]`, which leaves
/// the bracket character itself dangling at the end (e.g. `"The Matrix ("`).
fn clean_movie_title(raw: &str) -> String {
let base = clean_series_name(raw);
base.trim_end_matches(|c: char| c == '(' || c == '[' || c == '{' || c.is_whitespace())
.to_string()
}
/// Tries each season/episode pattern in priority order (most explicit/specific first)
/// and returns the first match, with the series name guessed from the text before it.
///
/// Returns `None` if no pattern matches - this is expected for movies, already-clean
/// filenames, or anything that just doesn't carry a season/episode marker in its name.
pub fn guess_episode(filename: &str) -> Option<EpisodeGuess> {
// `_` counts as a word character to regex's `\b`, so "show_name_s02e03" would
// otherwise fail to find a boundary right before the `s`. Match against a
// space-normalized copy instead (same length, so match byte-offsets still line up
// with the original string for slicing out the series-name prefix).
let normalized: String = filename
.chars()
.map(|c| if c == '_' { ' ' } else { c })
.collect();
let patterns = [season_episode_pattern(), s00e00_pattern(), sxe_pattern()];
for pattern in patterns {
if let Some(caps) = pattern.captures(&normalized) {
let whole = caps.get(0).unwrap();
let season: i32 = caps.get(1)?.as_str().parse().ok()?;
let episode: i32 = caps.get(2)?.as_str().parse().ok()?;
let prefix = &filename[..whole.start()];
let series_name = clean_series_name(prefix);
return Some(EpisodeGuess {
series_name,
season,
episode,
});
}
}
None
}
/// A movie title guessed from a filename, plus a release year if the filename's own
/// text happened to carry one (release names conventionally look like
/// `Title.Year.Quality-Group`, e.g. `Inception.2010.1080p.BluRay.x264`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MovieGuess {
pub title: String,
pub year: Option<i32>,
}
/// Guesses a movie title (and, if present, a release year) from a filename. Unlike
/// `guess_episode`, this doesn't look for a specific marker that's either there or
/// not - it just cuts the title off at the earliest release-year or quality/source tag
/// it can find (preferring a year on a tie, since a year almost always follows the
/// title directly in real release names), and falls back to treating the whole
/// (cleaned) filename as the title when neither is present. Only returns `None` when
/// there's nothing left to call a title at all, e.g. a filename that's just a bare
/// year or quality tag.
///
/// Callers are expected to try `guess_episode` first and only fall back to this when
/// it returns `None` - a real release name is either an episode or a movie, not both,
/// and this function has no way to tell the difference on its own.
pub fn guess_movie(filename: &str) -> Option<MovieGuess> {
let normalized: String = filename
.chars()
.map(|c| if c == '_' { ' ' } else { c })
.collect();
let year_match = year_pattern().find(&normalized);
let quality_match = quality_token_pattern().find(&normalized);
let (title_end, year) = match (year_match, quality_match) {
(Some(y), Some(q)) if y.start() <= q.start() => (y.start(), y.as_str().parse().ok()),
(Some(y), None) => (y.start(), y.as_str().parse().ok()),
(_, Some(q)) => (q.start(), None),
(None, None) => (normalized.len(), None),
};
let title = clean_movie_title(&filename[..title_end]);
if title.is_empty() {
return None;
}
Some(MovieGuess { title, year })
}
/// Release-tag metadata guessed from a filename's own text: resolution, source
/// (capture/rip type), video codec, audio codec, and release group. Ports the
/// filename-parsing half of the original FileBot's `ReleaseInfo` class - the same
/// regex-based tag recognition `MediaBindingBean` falls back to when no real media
/// probe (ffprobe/MediaInfo) is available, which Aurora Curator never has, so this is the
/// only source for these fields here. Deliberately independent of `guess_episode`/
/// `guess_movie`: a release name carries these tags (or doesn't) regardless of
/// whether it's a TV episode or a movie, so callers run this alongside whichever of
/// those two applies.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseInfo {
pub resolution: Option<String>,
pub source: Option<String>,
pub video_codec: Option<String>,
pub audio_codec: Option<String>,
pub group: Option<String>,
}
impl ReleaseInfo {
fn is_empty(&self) -> bool {
self.resolution.is_none()
&& self.source.is_none()
&& self.video_codec.is_none()
&& self.audio_codec.is_none()
&& self.group.is_none()
}
}
fn resolution_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\b(480p|576p|720p|1080p|1080i|2160p|4320p)\b").unwrap())
}
// Longer/more specific alternatives are listed before shorter ones they'd otherwise be
// shadowed by (e.g. "web-dl" before "web"), since regex alternation takes the first
// branch that matches at a given position, not the longest.
fn source_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)\b(web-?dl|webrip|web|blu-?ray|bdrip|brrip|hddvd|dvdscr|dvdrip|dvd|hdtv|pdtv|sdtv|dsr)\b")
.unwrap()
})
}
fn video_codec_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\b(x264|x265|h\.?264|h\.?265|hevc|xvid|divx|avc)\b").unwrap())
}
fn audio_codec_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\b(dts-hd|dts|ddp5\.1|ddp|eac3|ac3|truehd|flac|aac|mp3)\b").unwrap())
}
// The release group is conventionally the last `-TAG` right before the file
// extension (e.g. `...x264-KILLERS.mkv`); only matches when there's a leading dash so
// a bare trailing quality tag like `...x264` (no group) is correctly left alone.
fn release_group_pattern() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"-([A-Za-z0-9][A-Za-z0-9.]{1,14})$").unwrap())
}
/// Strips a trailing `.ext` (if any) so end-anchored patterns like the release-group
/// one can match against the release name itself rather than the file extension.
fn strip_extension(filename: &str) -> &str {
match filename.rfind('.') {
Some(i) if i > 0 => &filename[..i],
_ => filename,
}
}
fn canonical_source(raw: &str) -> String {
match raw.to_lowercase().replace(['-', ' '], "").as_str() {
"webdl" => "WEB-DL",
"webrip" => "WEBRip",
"web" => "WEB",
"bluray" => "BluRay",
"bdrip" => "BDRip",
"brrip" => "BRRip",
"hddvd" => "HDDVD",
"dvdscr" => "DVDSCR",
"dvdrip" => "DVDRip",
"dvd" => "DVD",
"hdtv" => "HDTV",
"pdtv" => "PDTV",
"sdtv" => "SDTV",
"dsr" => "DSR",
_ => return raw.to_string(),
}
.to_string()
}
fn canonical_video_codec(raw: &str) -> String {
match raw.to_lowercase().replace(['.', ' '], "").as_str() {
"x264" => "x264",
"x265" => "x265",
"h264" => "H.264",
"h265" => "H.265",
"hevc" => "HEVC",
"xvid" => "XviD",
"divx" => "DivX",
"avc" => "AVC",
_ => return raw.to_string(),
}
.to_string()
}
fn canonical_audio_codec(raw: &str) -> String {
match raw.to_lowercase().replace(['-', '.', ' '], "").as_str() {
"dtshd" => "DTS-HD",
"dts" => "DTS",
"ddp51" => "DDP5.1",
"ddp" => "DDP",
"eac3" => "EAC3",
"ac3" => "AC3",
"truehd" => "TrueHD",
"flac" => "FLAC",
"aac" => "AAC",
"mp3" => "MP3",
_ => return raw.to_string(),
}
.to_string()
}
/// Guesses release-tag metadata from a filename's own text (see `ReleaseInfo`).
/// Every field is independent and optional; returns `None` only when nothing at all
/// was recognized, matching the `guess_episode`/`guess_movie` convention of `None`
/// meaning "nothing local to offer here", not "this file has no metadata".
pub fn guess_release_info(filename: &str) -> Option<ReleaseInfo> {
let base = strip_extension(filename);
let info = ReleaseInfo {
resolution: resolution_pattern()
.find(base)
.map(|m| m.as_str().to_lowercase()),
source: source_pattern().find(base).map(|m| canonical_source(m.as_str())),
video_codec: video_codec_pattern()
.find(base)
.map(|m| canonical_video_codec(m.as_str())),
audio_codec: audio_codec_pattern()
.find(base)
.map(|m| canonical_audio_codec(m.as_str())),
group: release_group_pattern()
.captures(base)
.map(|c| c[1].to_string()),
};
if info.is_empty() {
None
} else {
Some(info)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_dotted_s00e00_style() {
let guess = guess_episode("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS").unwrap();
assert_eq!(guess.series_name, "Breaking Bad");
assert_eq!(guess.season, 5);
assert_eq!(guess.episode, 14);
assert_eq!(guess.season_episode_label(), "S05E14");
}
#[test]
fn matches_dashed_sxe_style() {
let guess = guess_episode("Reacher - 1x01 - Welcome to Margrave").unwrap();
assert_eq!(guess.series_name, "Reacher");
assert_eq!(guess.season, 1);
assert_eq!(guess.episode, 1);
assert_eq!(guess.season_episode_label(), "S01E01");
}
#[test]
fn matches_verbose_season_episode_words() {
let guess = guess_episode("The Office Season 4 Episode 9 - Dinner Party").unwrap();
assert_eq!(guess.series_name, "The Office");
assert_eq!(guess.season, 4);
assert_eq!(guess.episode, 9);
}
#[test]
fn matches_underscored_prefix() {
let guess = guess_episode("some_show_name_s02e03_episode_title").unwrap();
assert_eq!(guess.series_name, "some show name");
assert_eq!(guess.season, 2);
assert_eq!(guess.episode, 3);
}
#[test]
fn returns_none_for_movie_filenames() {
assert!(guess_episode("Inception.2010.1080p.BluRay.x264").is_none());
assert!(guess_episode("The Matrix (1999)").is_none());
}
#[test]
fn prefers_more_specific_pattern_over_sxe() {
// s00e00 pattern should win over the looser 1x01-style pattern when both could
// technically apply to fragments of the same string.
let guess = guess_episode("Show.Name.S01E02.1080p").unwrap();
assert_eq!(guess.season, 1);
assert_eq!(guess.episode, 2);
}
#[test]
fn guesses_a_movie_title_and_year_from_a_dotted_release_name() {
let guess = guess_movie("Inception.2010.1080p.BluRay.x264").unwrap();
assert_eq!(guess.title, "Inception");
assert_eq!(guess.year, Some(2010));
}
#[test]
fn guesses_a_movie_title_and_year_with_a_parenthesized_year() {
let guess = guess_movie("The Matrix (1999)").unwrap();
assert_eq!(guess.title, "The Matrix");
assert_eq!(guess.year, Some(1999));
}
#[test]
fn falls_back_to_a_quality_token_boundary_when_theres_no_year() {
let guess = guess_movie("Some.Movie.Title.WEBRip.x264").unwrap();
assert_eq!(guess.title, "Some Movie Title");
assert_eq!(guess.year, None);
}
#[test]
fn an_already_clean_filename_becomes_the_whole_title() {
let guess = guess_movie("The Matrix").unwrap();
assert_eq!(guess.title, "The Matrix");
assert_eq!(guess.year, None);
}
#[test]
fn returns_none_when_theres_nothing_but_a_year_left() {
assert!(guess_movie("2010").is_none());
}
#[test]
fn extracts_full_release_info_from_a_tv_episode_filename() {
let info = guess_release_info("Breaking.Bad.S05E14.720p.HDTV.x264-KILLERS.mkv").unwrap();
assert_eq!(info.resolution.as_deref(), Some("720p"));
assert_eq!(info.source.as_deref(), Some("HDTV"));
assert_eq!(info.video_codec.as_deref(), Some("x264"));
assert_eq!(info.audio_codec, None);
assert_eq!(info.group.as_deref(), Some("KILLERS"));
}
#[test]
fn extracts_release_info_with_mixed_case_group_preserved() {
let info = guess_release_info("The.Office.US.S03E01.DVDRip.XviD-SAiNTS.avi").unwrap();
assert_eq!(info.source.as_deref(), Some("DVDRip"));
assert_eq!(info.video_codec.as_deref(), Some("XviD"));
assert_eq!(info.group.as_deref(), Some("SAiNTS"));
}
#[test]
fn extracts_release_info_from_a_movie_filename_with_audio_codec_and_no_group() {
let info = guess_release_info("Inception.2010.1080p.BluRay.DTS.x264.mkv").unwrap();
assert_eq!(info.resolution.as_deref(), Some("1080p"));
assert_eq!(info.source.as_deref(), Some("BluRay"));
assert_eq!(info.video_codec.as_deref(), Some("x264"));
assert_eq!(info.audio_codec.as_deref(), Some("DTS"));
assert_eq!(info.group, None);
}
#[test]
fn normalizes_web_dl_distinct_from_bare_web() {
let webdl = guess_release_info("Show.S01E01.WEB-DL.mkv").unwrap();
assert_eq!(webdl.source.as_deref(), Some("WEB-DL"));
let web = guess_release_info("Show.S01E02.WEB.mkv").unwrap();
assert_eq!(web.source.as_deref(), Some("WEB"));
}
#[test]
fn normalizes_h264_and_hevc_video_codec_spellings() {
let dotted = guess_release_info("Movie.2020.h.264.mkv").unwrap();
assert_eq!(dotted.video_codec.as_deref(), Some("H.264"));
let hevc = guess_release_info("Movie.2020.HEVC.mkv").unwrap();
assert_eq!(hevc.video_codec.as_deref(), Some("HEVC"));
}
#[test]
fn does_not_mistake_a_bare_trailing_quality_tag_for_a_release_group() {
// No leading dash before "x264" here - nothing to call a release group.
let info = guess_release_info("Some.Movie.2020.1080p.x264.mkv").unwrap();
assert_eq!(info.group, None);
}
#[test]
fn returns_none_for_a_filename_with_no_recognizable_release_tags() {
assert!(guess_release_info("The Matrix.mkv").is_none());
}
}
@@ -0,0 +1,849 @@
//! The on-disk rename operation: given a planned `old path -> new relative target`
//! mapping, actually renames (and, when the target includes subfolders, moves) the
//! files.
//!
//! Every rename stays rooted at the source file's own directory - `new_name` is always
//! resolved relative to `source`'s parent, never as an absolute path, and can never
//! escape that directory tree via `..`. Within that directory tree, though, `new_name`
//! may contain `/` to organize files into subfolders as part of renaming (e.g. a naming
//! scheme like `{show name}/{episode name}` - the naming-expression engine already
//! treats `/` between two `{ }` scriptlets as ordinary literal text, so no changes were
//! needed there; this is purely about the rename engine trusting a `/` in the computed
//! name to mean "put it in a subfolder" instead of rejecting it outright). Any missing
//! intermediate directories are created automatically. What happens when the computed
//! target already exists is chosen per batch via [`ConflictPolicy`] - skip (the
//! original, and still default, behavior), auto-number, or override - a port of
//! `net.filebot.cli.CmdlineOperations`'s `--conflict` flag. The UI is expected to show
//! the caller a confirmation of the full plan (and let them choose both a
//! [`RenameAction`] and a [`ConflictPolicy`]) before calling [`execute_renames`]; every
//! batch that action actually touches a file for is also recorded to the rename-history
//! log one layer up (`aurora_curator_core::history`), so it can be undone from there. Undoing
//! always uses skip-on-conflict regardless of what policy the original rename used
//! (see `history::revert_one`) - overwriting an unrelated file as a side effect of an
//! *undo* would be a much worse surprise than leaving it for the user to sort out by
//! hand.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
/// One planned rename: `source` is the file's current full path, `new_name` is the
/// target it should have afterward, resolved relative to `source`'s own directory -
/// just a filename for a same-directory rename, or a `/`-separated relative path (e.g.
/// `"Breaking Bad/Ozymandias.mkv"`) to also move the file into a (possibly new)
/// subfolder.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenamePlanItem {
pub source: String,
pub new_name: String,
}
/// What to actually do at the target path once it's been resolved and cleared of
/// conflicts - a port of the handful of `--action` modes `net.filebot.cli
/// .CmdlineOperations` supports (`move`, the default, plus `copy`/`symlink`; `keeplink`
/// and `test` are not ported). Applies to a whole batch at once - the UI picks one
/// action per "Rename" confirmation, not per file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RenameAction {
/// Renames/moves the file in place - the only action Aurora Curator had before this. The
/// source file no longer exists afterward; the item now lives at the target path.
Move,
/// Copies the file's contents to the target path, leaving the source untouched.
Copy,
/// Creates a symlink at the target path pointing back at the (untouched) source
/// file. Unix: `std::os::unix::fs::symlink`. Windows: `symlink_file` (creating a
/// file symlink there normally requires Developer Mode or an elevated process -
/// surfaced to the caller as an ordinary `Failed` outcome if it's rejected, not a
/// special case).
Symlink,
}
impl Default for RenameAction {
fn default() -> Self {
RenameAction::Move
}
}
/// What to do when a computed target already exists (and isn't just the source file
/// itself under a different case) - a port of `CmdlineOperations`'s `--conflict` flag.
/// Applies to a whole batch at once, same as [`RenameAction`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ConflictPolicy {
/// Leave the existing file alone and report the item as skipped. The original (and
/// still default) behavior - never destroys anything.
Skip,
/// Save alongside the conflicting file instead, as `"Name (1).ext"`, `"Name
/// (2).ext"`, etc. - the first name in that sequence that isn't already taken in
/// the target directory (tries up to 999 before giving up).
AutoNumber,
/// Replace whatever's at the target path. Irreversible for the file that gets
/// overwritten - undo can restore what the *source* becomes, never what conflict
/// resolution destroyed at the target.
Override,
}
impl Default for ConflictPolicy {
fn default() -> Self {
ConflictPolicy::Skip
}
}
/// What happened to one planned rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum RenameOutcome {
/// Renamed successfully; `new_path` is the file's new full path.
Renamed { new_path: String },
/// The computed name was already the file's current name - nothing to do.
Unchanged,
/// A different file already exists at the target path; left untouched.
SkippedConflict { existing_path: String },
/// The rename was rejected or the OS reported an error.
Failed { error: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct RenameResult {
pub source: String,
pub outcome: RenameOutcome,
}
/// Validates a computed target, relative to the source file's own directory. `/` is
/// allowed as a subdirectory separator - that's what lets a naming scheme like
/// `{show name}/{episode name}` organize files into subfolders - but every path
/// component is checked to make sure the target can never escape that directory tree:
/// no empty components (a leading, trailing, or doubled `/` - which also catches an
/// absolute-looking `/etc/passwd`-style path, since splitting it on `/` yields a
/// leading empty component), and no `.`/`..` components. Backslashes are rejected
/// outright rather than treated as an alternate separator - Aurora Curator always uses `/` for
/// the naming scheme's folder separator regardless of OS, so a literal `\` in a
/// computed name is almost certainly a mistake, not an intentional Windows path.
fn validate_relative_target(name: &str) -> Result<(), &'static str> {
if name.is_empty() {
return Err("empty");
}
if name.contains('\\') {
return Err("backslashes aren't allowed - use / to organize into subfolders");
}
for component in name.split('/') {
if component.is_empty() {
return Err("contains an empty path segment (e.g. a leading, trailing, or doubled '/')");
}
if component == "." || component == ".." {
return Err("contains a '.' or '..' path segment");
}
}
Ok(())
}
/// True if `a` and `b` refer to the same underlying file (used to allow case-only
/// renames on case-insensitive filesystems, where the target "already exists" check
/// would otherwise find the source file itself).
fn same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(pa), Ok(pb)) => pa == pb,
_ => false,
}
}
#[cfg(unix)]
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(source, target)
}
#[cfg(windows)]
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(source, target)
}
#[cfg(not(any(unix, windows)))]
fn create_symlink(_source: &Path, _target: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symlinks aren't supported on this platform",
))
}
/// Finds the first `"<stem> (N).<ext>"` (or `"<stem> (N)"` with no extension) in
/// `target`'s own directory that isn't already taken, starting at N=1. Tries up to 999
/// before giving up (`None`) - a directory with 999+ same-named conflicts is almost
/// certainly a sign something else is wrong, not a case to loop on forever.
fn next_available_name(target: &Path) -> Option<PathBuf> {
let parent = target.parent()?;
let stem = target.file_stem()?.to_string_lossy().to_string();
let ext = target.extension().map(|e| e.to_string_lossy().to_string());
for n in 1..=999 {
let candidate_name = match &ext {
Some(e) => format!("{stem} ({n}).{e}"),
None => format!("{stem} ({n})"),
};
let candidate = parent.join(candidate_name);
if !candidate.exists() {
return Some(candidate);
}
}
None
}
/// Resolves a conflict at `target` (which the caller has already confirmed exists and
/// isn't `source` under a different case) according to `conflict`. Returns the target
/// path to actually use, or the terminal outcome to report instead (skip, or a failure
/// - e.g. auto-numbering exhausted its attempts, or removing an existing file ahead of
/// an Override-Symlink failed).
///
/// A conflicting target that's a *directory* is only ever a special case for Override
/// (which falls back to skipping it) - Override is for replacing one file with
/// another, never for deleting a whole directory tree out from under the caller. Skip
/// treats it like any other conflict already, and AutoNumber doesn't need to touch it
/// at all - it just tries the next number instead.
fn resolve_conflict(target: &Path, action: RenameAction, conflict: ConflictPolicy) -> Result<PathBuf, RenameOutcome> {
match conflict {
ConflictPolicy::Skip => Err(RenameOutcome::SkippedConflict {
existing_path: target.to_string_lossy().to_string(),
}),
// Doesn't matter whether the conflicting entry is a file or a directory here -
// auto-numbering never touches it either way, it just tries the next name.
ConflictPolicy::AutoNumber => next_available_name(target).ok_or_else(|| RenameOutcome::Failed {
error: "couldn't find a free auto-numbered name (tried up to 999)".to_string(),
}),
ConflictPolicy::Override => {
// Override is for replacing one file with another - never for deleting a
// whole directory tree out from under the caller. Falls back to Skip's
// behavior for that one case.
if target.is_dir() {
return Err(RenameOutcome::SkippedConflict {
existing_path: target.to_string_lossy().to_string(),
});
}
// fs::rename and fs::copy both already replace an existing regular file at
// the destination on their own - only Symlink's underlying syscall fails
// outright when the target path already exists, so that's the one case that
// needs the old file removed first.
if action == RenameAction::Symlink {
if let Err(e) = fs::remove_file(target) {
return Err(RenameOutcome::Failed {
error: format!("couldn't remove the existing file before overriding it: {e}"),
});
}
}
Ok(target.to_path_buf())
}
}
}
fn rename_one(source: &Path, new_name: &str, action: RenameAction, conflict: ConflictPolicy) -> RenameOutcome {
if let Err(reason) = validate_relative_target(new_name) {
return RenameOutcome::Failed {
error: format!("\"{new_name}\" is not a valid target: {reason}"),
};
}
let parent = match source.parent() {
Some(p) => p,
None => {
return RenameOutcome::Failed {
error: "source has no parent directory".to_string(),
}
}
};
let target = parent.join(new_name);
if target == source {
// Nothing meaningful to do for any action - Move has nothing to move, and
// Copy/Symlink onto the source's own path would just be a no-op self-reference
// at best (or an outright error at worst, e.g. `fs::copy` onto the same path).
return RenameOutcome::Unchanged;
}
let target = if target.exists() && !same_file(&target, source) {
match resolve_conflict(&target, action, conflict) {
Ok(resolved) => resolved,
Err(outcome) => return outcome,
}
} else {
target
};
// `new_name` may include subdirectory components (organizing files into folders) -
// make sure the destination directory tree exists before attempting the actual
// operation. `create_dir_all` is a no-op (Ok) when the directory already exists.
if let Some(target_parent) = target.parent() {
if let Err(e) = fs::create_dir_all(target_parent) {
return RenameOutcome::Failed {
error: format!("couldn't create destination folder: {e}"),
};
}
}
let result = match action {
RenameAction::Move => fs::rename(source, &target),
RenameAction::Copy => fs::copy(source, &target).map(|_bytes| ()),
RenameAction::Symlink => create_symlink(source, &target),
};
match result {
Ok(()) => RenameOutcome::Renamed {
new_path: target.to_string_lossy().to_string(),
},
Err(e) => RenameOutcome::Failed {
error: e.to_string(),
},
}
}
/// Executes a batch of planned renames in order, all under the same [`RenameAction`]
/// and [`ConflictPolicy`]. Items are processed sequentially (not in parallel) so that
/// two items whose computed names collide with each other resolve the same way an
/// on-disk collision would: under Skip, the first one through wins and the second is
/// reported as a skipped conflict; under AutoNumber, the second gets bumped to " (1)";
/// under Override, the second replaces what the first just wrote.
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction, conflict: ConflictPolicy) -> Vec<RenameResult> {
items
.iter()
.map(|item| RenameResult {
source: item.source.clone(),
outcome: rename_one(Path::new(&item.source), &item.new_name, action, conflict),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// Creates a fresh temp directory with one file in it, for tests that need to touch
/// the real filesystem. Returns (dir, file_path).
fn temp_file_setup(file_name: &str, contents: &str) -> (std::path::PathBuf, std::path::PathBuf) {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("aurora_curator_rename_test_{}_{}", std::process::id(), n));
fs::create_dir_all(&dir).unwrap();
let file = dir.join(file_name);
fs::write(&file, contents).unwrap();
(dir, file)
}
#[test]
fn renames_a_file_successfully() {
let (dir, file) = temp_file_setup("old.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "new.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert_eq!(results.len(), 1);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert!(new_path.ends_with("new.txt"));
assert!(Path::new(new_path).exists());
assert!(!file.exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn reports_unchanged_when_name_is_already_correct() {
let (dir, file) = temp_file_setup("same.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "same.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert_eq!(results[0].outcome, RenameOutcome::Unchanged);
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn skips_when_target_already_exists() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::write(dir.join("b.txt"), "bbb").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
}
other => panic!("expected SkippedConflict, got {other:?}"),
}
// Neither file should have been touched.
assert!(file.exists());
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn second_item_in_batch_skips_when_it_collides_with_the_first() {
let (dir, file_a) = temp_file_setup("a.txt", "aaa");
let file_b = dir.join("c.txt");
fs::write(&file_b, "ccc").unwrap();
let items = vec![
RenamePlanItem {
source: file_a.to_string_lossy().to_string(),
new_name: "target.txt".to_string(),
},
RenamePlanItem {
source: file_b.to_string_lossy().to_string(),
new_name: "target.txt".to_string(),
},
];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. }));
assert!(matches!(
results[1].outcome,
RenameOutcome::SkippedConflict { .. }
));
// The second file's original content should be untouched.
assert_eq!(fs::read_to_string(&file_b).unwrap(), "ccc");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_path_traversal_in_new_name() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "../escaped.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn organizes_into_a_new_subfolder() {
let (dir, file) = temp_file_setup("Breaking.Bad.S05E14.mkv", "video");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv");
assert_eq!(Path::new(new_path), expected.as_path());
assert!(expected.exists());
assert!(!file.exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn organizes_into_nested_subfolders_that_dont_exist_yet() {
let (dir, file) = temp_file_setup("ep.mkv", "video");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), expected.as_path());
assert!(expected.exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn skips_when_the_organized_target_already_exists() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::create_dir_all(dir.join("Sub")).unwrap();
fs::write(dir.join("Sub").join("b.txt"), "bbb").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Sub/b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(matches!(
results[0].outcome,
RenameOutcome::SkippedConflict { .. }
));
assert!(file.exists());
assert_eq!(fs::read_to_string(dir.join("Sub").join("b.txt")).unwrap(), "bbb");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_backslashes_in_new_name() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Sub\\b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_doubled_and_leading_slashes() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
for bad_name in ["Sub//b.txt", "/etc/passwd", "Sub/b.txt/"] {
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: bad_name.to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(
matches!(results[0].outcome, RenameOutcome::Failed { .. }),
"expected {bad_name:?} to be rejected"
);
}
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_action_leaves_the_source_file_in_place() {
let (dir, file) = temp_file_setup("original.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "copied.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Copy, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert!(new_path.ends_with("copied.txt"));
assert!(Path::new(new_path).exists());
assert_eq!(fs::read_to_string(new_path).unwrap(), "hello");
// The whole point of Copy: the source is untouched.
assert!(file.exists());
assert_eq!(fs::read_to_string(&file).unwrap(), "hello");
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_action_can_also_organize_into_a_new_subfolder() {
let (dir, file) = temp_file_setup("original.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Sub/copied.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Copy, ConflictPolicy::Skip);
let expected = dir.join("Sub").join("copied.txt");
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), expected.as_path());
assert!(expected.exists());
assert!(file.exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn symlink_action_leaves_the_source_file_in_place_and_points_back_at_it() {
let (dir, file) = temp_file_setup("original.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "linked.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Symlink, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
let link = Path::new(new_path);
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
assert_eq!(fs::read_link(link).unwrap(), file);
// Reading through the link gets the source's real content...
assert_eq!(fs::read_to_string(link).unwrap(), "hello");
// ...and the source itself is completely untouched.
assert!(file.exists());
assert!(!fs::symlink_metadata(&file).unwrap().file_type().is_symlink());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn conflict_policy_applies_the_same_way_to_copy_and_symlink() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::write(dir.join("b.txt"), "bbb").unwrap();
for action in [RenameAction::Copy, RenameAction::Symlink] {
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, action, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
}
other => panic!("expected SkippedConflict for {action:?}, got {other:?}"),
}
}
// Neither original file nor the pre-existing conflicting file should be touched.
assert!(file.exists());
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_and_symlink_report_unchanged_when_the_computed_name_is_already_the_source() {
let (dir, file) = temp_file_setup("same.txt", "hello");
for action in [RenameAction::Copy, RenameAction::Symlink] {
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "same.txt".to_string(),
}];
let results = execute_renames(&items, action, ConflictPolicy::Skip);
assert_eq!(
results[0].outcome,
RenameOutcome::Unchanged,
"expected Unchanged for {action:?}"
);
}
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_number_saves_alongside_the_conflicting_file_instead_of_skipping() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::write(dir.join("b.txt"), "bbb").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("b (1).txt"));
assert!(Path::new(new_path).exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
// The original conflicting file must still be exactly as it was.
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
assert!(!file.exists()); // Move: the source is gone, relocated to "b (1).txt"
fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_number_keeps_counting_past_names_that_are_also_taken() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::write(dir.join("b.txt"), "bbb").unwrap();
fs::write(dir.join("b (1).txt"), "existing 1").unwrap();
fs::write(dir.join("b (2).txt"), "existing 2").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("b (3).txt"));
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_number_handles_extensionless_names_and_organized_subfolders() {
let (dir, file) = temp_file_setup("a", "aaa");
fs::write(dir.join("README"), "existing").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "README".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("README (1)"));
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn second_item_in_batch_auto_numbers_when_it_collides_with_the_first() {
let (dir, file_a) = temp_file_setup("a.txt", "aaa");
let file_b = dir.join("c.txt");
fs::write(&file_b, "ccc").unwrap();
let items = vec![
RenamePlanItem {
source: file_a.to_string_lossy().to_string(),
new_name: "target.txt".to_string(),
},
RenamePlanItem {
source: file_b.to_string_lossy().to_string(),
new_name: "target.txt".to_string(),
},
];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
assert_eq!(
results[0].outcome,
RenameOutcome::Renamed {
new_path: dir.join("target.txt").to_string_lossy().to_string()
}
);
assert_eq!(
results[1].outcome,
RenameOutcome::Renamed {
new_path: dir.join("target (1).txt").to_string_lossy().to_string()
}
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn override_replaces_the_conflicting_file_for_move_and_copy() {
for action in [RenameAction::Move, RenameAction::Copy] {
let (dir, file) = temp_file_setup("a.txt", "new content");
fs::write(dir.join("b.txt"), "old content").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, action, ConflictPolicy::Override);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("b.txt"));
assert_eq!(
fs::read_to_string(new_path).unwrap(),
"new content",
"expected b.txt to now hold the source's content for {action:?}"
);
}
other => panic!("expected Renamed for {action:?}, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
}
#[cfg(unix)]
#[test]
fn override_removes_an_existing_file_before_creating_a_symlink_there() {
let (dir, file) = temp_file_setup("a.txt", "hello");
fs::write(dir.join("b.txt"), "old content").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Symlink, ConflictPolicy::Override);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
let link = Path::new(new_path);
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(link).unwrap(), "hello");
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn override_never_deletes_a_conflicting_directory() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::create_dir_all(dir.join("b.txt")).unwrap(); // a DIRECTORY named "b.txt"
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Override);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
}
other => panic!("expected SkippedConflict (never delete a directory), got {other:?}"),
}
assert!(dir.join("b.txt").is_dir());
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_number_also_never_deletes_a_conflicting_directory() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::create_dir_all(dir.join("b.txt")).unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
// AutoNumber should sidestep the directory entirely by picking "b (1).txt"
// rather than touching "b.txt" at all.
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("b (1).txt"));
}
other => panic!("expected Renamed, got {other:?}"),
}
assert!(dir.join("b.txt").is_dir());
fs::remove_dir_all(&dir).ok();
}
}
@@ -0,0 +1,158 @@
//! Lightweight text-similarity scoring, used to disambiguate between several search
//! results for the same query. A partial port of the *scoring* idea behind FileBot's
//! `net.filebot.similarity.EpisodeMetrics` (which combines several metrics: name,
//! episode numbering, air date, etc). This only ports the show-name half: given a
//! search query and a list of candidate titles, it picks the title that's the closest
//! textual match rather than trusting whichever result an external API happened to
//! rank first - TMDB ranks by relevance/popularity, not by exact name match, so a
//! more popular but differently-named show can otherwise beat an exact-but-less-popular
//! one straight to the top of the list. `DateMatcher` (the other half of the original
//! `EpisodeMetrics`) is not ported - Aurora Curator doesn't do date-based episode identification.
use std::collections::HashMap;
/// Lowercases, strips a trailing 4-digit-year parenthetical (e.g. "(2013)" - release
/// metadata that never appears in a real filename, but commonly appears in TMDB's own
/// titles for remakes/reboots), and collapses every run of punctuation/whitespace down
/// to a single space, so two titles that differ only in casing, punctuation, or an
/// appended year compare as equal.
pub fn normalize_title(input: &str) -> String {
let mut without_year = input.to_string();
if let Some(start) = without_year.rfind('(') {
let inner = &without_year[start + 1..];
if let Some(end) = inner.find(')') {
let candidate = &inner[..end];
if candidate.len() == 4 && candidate.chars().all(|c| c.is_ascii_digit()) {
without_year.replace_range(start..start + end + 2, "");
}
}
}
let mut out = String::with_capacity(without_year.len());
let mut last_was_space = true; // avoid a leading space in the output
for c in without_year.chars() {
if c.is_alphanumeric() {
out.extend(c.to_lowercase());
last_was_space = false;
} else if !last_was_space {
out.push(' ');
last_was_space = true;
}
}
out.trim_end().to_string()
}
/// Character-bigram multiset of a string, e.g. "abc" -> {ab: 1, bc: 1}.
fn bigrams(s: &str) -> HashMap<(char, char), u32> {
let chars: Vec<char> = s.chars().collect();
let mut map = HashMap::new();
if chars.len() < 2 {
// Too short for a real bigram - fall back to a single-char "bigram" so a
// one-letter title still contributes something rather than always scoring 0.
if let Some(&c) = chars.first() {
*map.entry((c, '\0')).or_insert(0) += 1;
}
return map;
}
for w in chars.windows(2) {
*map.entry((w[0], w[1])).or_insert(0) += 1;
}
map
}
/// Sorensen-Dice coefficient over character bigrams of the normalized titles: 1.0 for
/// an exact match (after normalization), trending toward 0.0 for unrelated strings,
/// while staying tolerant of the small differences (punctuation, word order, a
/// trailing "Part 2") that would sink a naive exact-string comparison.
pub fn title_similarity(a: &str, b: &str) -> f64 {
let na = normalize_title(a);
let nb = normalize_title(b);
if na == nb {
return 1.0;
}
let ba = bigrams(&na);
let bb = bigrams(&nb);
let total: u32 = ba.values().sum::<u32>() + bb.values().sum::<u32>();
if total == 0 {
return 0.0;
}
let mut overlap = 0u32;
for (bigram, &count) in &ba {
if let Some(&other_count) = bb.get(bigram) {
overlap += count.min(other_count);
}
}
(2.0 * overlap as f64) / total as f64
}
/// Picks whichever candidate's title is textually closest to `query`, provided its
/// score clears `threshold`. Below that, none of the candidates are considered a
/// plausible match for the query at all and `None` is returned - it's better to
/// report "not found" than to confidently rename files against the wrong show. Ties
/// keep the earlier candidate, which preserves the caller's own ranking (e.g. TMDB's
/// relevance/popularity order) as the tiebreaker when two titles score identically.
pub fn best_title_match<'a, T>(
query: &str,
candidates: &'a [T],
title_of: impl Fn(&T) -> &str,
threshold: f64,
) -> Option<&'a T> {
let mut best: Option<(&'a T, f64)> = None;
for candidate in candidates {
let score = title_similarity(query, title_of(candidate));
if score < threshold {
continue;
}
match best {
Some((_, best_score)) if score <= best_score => {}
_ => best = Some((candidate, score)),
}
}
best.map(|(candidate, _)| candidate)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match_after_case_and_punctuation_normalization_scores_1() {
assert_eq!(title_similarity("Breaking Bad", "breaking-bad"), 1.0);
assert_eq!(title_similarity("The Office (US)", "the office, us!"), 1.0);
}
#[test]
fn trailing_release_year_parenthetical_is_ignored() {
assert_eq!(title_similarity("Chernobyl", "Chernobyl (2019)"), 1.0);
}
#[test]
fn unrelated_titles_score_low() {
let score = title_similarity("Zzyzx Nonexistent Show", "Totally Unrelated Program");
assert!(score < 0.3, "expected a low score, got {score}");
}
#[test]
fn best_title_match_prefers_the_closer_textual_match_over_list_order() {
let candidates = vec!["Chucky".to_string(), "Chuck".to_string()];
let best = best_title_match("Chuck", &candidates, |s| s.as_str(), 0.3);
assert_eq!(best.map(String::as_str), Some("Chuck"));
}
#[test]
fn best_title_match_keeps_the_earlier_candidate_on_a_tie() {
let candidates = vec!["Show A".to_string(), "Show A".to_string()];
let best = best_title_match("Show A", &candidates, |s| s.as_str(), 0.3);
// Both score 1.0 - the first one (by identity, not just value) should win.
assert!(best.is_some_and(|s| std::ptr::eq(s, &candidates[0])));
}
#[test]
fn best_title_match_returns_none_when_nothing_clears_the_threshold() {
let candidates = vec!["Totally Unrelated Program".to_string()];
let best = best_title_match("Zzyzx Nonexistent Show", &candidates, |s| s.as_str(), 0.3);
assert_eq!(best, None);
}
}
@@ -0,0 +1,5 @@
//! Online metadata clients ("Fetch Data"). Currently just TMDB; the original FileBot
//! supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since it's the
//! most widely useful for both TV and movies and has a simple, well-documented API.
pub mod tmdb;
+513
View File
@@ -0,0 +1,513 @@
//! A small client for [TMDB](https://www.themoviedb.org/)'s v3 API: search for a TV
//! show or movie by name, then look up real metadata (a specific episode's title/air
//! date, or a movie's canonical title/release date).
//!
//! Ports the *lookup* half of what `net.filebot.web.TheMovieDBClient` did in the
//! original. Authenticates with a TMDB **API Read Access Token** (the Bearer-token
//! method TMDB documents as the current default) rather than the older `api_key` query
//! parameter.
use serde::Deserialize;
use thiserror::Error;
use crate::similarity::best_title_match;
const DEFAULT_BASE_URL: &str = "https://api.themoviedb.org/3";
/// Minimum name-similarity score (see `similarity::title_similarity`) a TMDB search
/// result must clear to be considered a plausible match for the query at all. Below
/// this, `lookup_episode` reports `NotFound` rather than confidently picking whichever
/// result TMDB happened to rank first - popularity/relevance ranking is not the same
/// thing as an accurate name match.
const NAME_MATCH_THRESHOLD: f64 = 0.35;
#[derive(Debug, Error)]
pub enum TmdbError {
#[error("network error talking to TMDB: {0}")]
Network(String),
#[error("TMDB API error (status {status}): {message}")]
Api { status: u16, message: String },
#[error("no TMDB match found for \"{0}\"")]
NotFound(String),
}
#[derive(Debug, Clone, Deserialize)]
struct SearchTvResponse {
#[serde(default)]
results: Vec<TvResult>,
}
/// One TMDB search hit. Only the fields Aurora Curator actually uses are kept.
#[derive(Debug, Clone, Deserialize)]
pub struct TvResult {
pub id: u64,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
struct SeasonResponse {
#[serde(default)]
episodes: Vec<EpisodeResult>,
}
#[derive(Debug, Clone, Deserialize)]
struct EpisodeResult {
episode_number: i64,
name: String,
#[serde(default)]
air_date: Option<String>,
}
/// Real metadata for one episode, resolved from TMDB - contrast with
/// `matching::EpisodeGuess`, which only has whatever a filename pattern implied.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpisodeInfo {
/// TMDB's canonical show name (may differ in punctuation/capitalization from
/// whatever the local filename guess produced).
pub series_name: String,
pub episode_title: String,
pub air_date: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct SearchMovieResponse {
#[serde(default)]
results: Vec<MovieResult>,
}
/// One TMDB movie search hit. Only the fields Aurora Curator actually uses are kept.
#[derive(Debug, Clone, Deserialize)]
pub struct MovieResult {
pub id: u64,
pub title: String,
#[serde(default)]
pub release_date: Option<String>,
}
/// Real metadata for one movie, resolved from TMDB - contrast with
/// `matching::MovieGuess`, which only has whatever a filename pattern implied.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovieInfo {
/// TMDB's canonical title (may differ in punctuation/capitalization from whatever
/// the local filename guess produced).
pub title: String,
/// Full release date (`YYYY-MM-DD`), same shape TMDB returns it in - not just the
/// year, since that's what a naming scheme's `{airdate}`-equivalent field wants.
pub release_date: Option<String>,
}
pub struct TmdbClient {
http: reqwest::Client,
token: String,
base_url: String,
}
impl TmdbClient {
pub fn new(token: impl Into<String>) -> Self {
Self {
http: reqwest::Client::new(),
token: token.into(),
base_url: DEFAULT_BASE_URL.to_string(),
}
}
/// Overrides the API base URL - used by tests to point at a local mock server
/// instead of the real TMDB API.
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
async fn get_json<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, TmdbError> {
let url = format!("{}{path}", self.base_url);
let response = self
.http
.get(&url)
.bearer_auth(&self.token)
.query(query)
.send()
.await
.map_err(|e| TmdbError::Network(e.to_string()))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
return Err(TmdbError::Api { status, message });
}
response
.json::<T>()
.await
.map_err(|e| TmdbError::Network(e.to_string()))
}
/// Searches for a TV show by name. Results come back TMDB-ranked (relevance /
/// popularity); callers wanting "the best guess" should just take the first one.
pub async fn search_tv(&self, query: &str) -> Result<Vec<TvResult>, TmdbError> {
let parsed: SearchTvResponse = self.get_json("/search/tv", &[("query", query)]).await?;
Ok(parsed.results)
}
/// Fetches every episode of one season in a single request. Prefer this over
/// calling `episode()` per-episode when looking up several episodes from the same
/// season (a whole loaded folder is usually one season) - the season endpoint
/// already returns the full episode list, so there's no need to re-fetch it per
/// episode; cache this call's result and look episodes up from it locally instead.
pub async fn season_episodes(&self, series_id: u64, season: i32) -> Result<Vec<SeasonEpisode>, TmdbError> {
let path = format!("/tv/{series_id}/season/{season}");
let parsed: SeasonResponse = self.get_json(&path, &[]).await?;
Ok(parsed
.episodes
.into_iter()
.map(|e| SeasonEpisode {
episode_number: e.episode_number as i32,
title: e.name,
air_date: e.air_date,
})
.collect())
}
/// Looks up one episode's real title/air date from a known TMDB series id. Fetches
/// the whole season each call - if you need several episodes from the same season,
/// call `season_episodes()` once instead and look them up locally.
pub async fn episode(&self, series_id: u64, season: i32, episode: i32) -> Result<EpisodeResultInfo, TmdbError> {
let episodes = self.season_episodes(series_id, season).await?;
episodes
.into_iter()
.find(|e| e.episode_number == episode)
.map(|e| EpisodeResultInfo {
episode_title: e.title,
air_date: e.air_date,
})
.ok_or_else(|| TmdbError::NotFound(format!("S{season:02}E{episode:02}")))
}
/// Searches for a TV show by name and returns the single best-matching result (see
/// `similarity::best_title_match`), or `None` if nothing in the results is a
/// plausible match at all. Exposed separately from `lookup_episode` so callers that
/// need to resolve a show once and then fetch several episodes from it (e.g. a
/// whole loaded season's worth of files) can reuse the resolved id instead of
/// re-running disambiguation per episode.
pub async fn search_tv_best_match(&self, query: &str) -> Result<Option<TvResult>, TmdbError> {
let results = self.search_tv(query).await?;
Ok(best_title_match(query, &results, |r| r.name.as_str(), NAME_MATCH_THRESHOLD).cloned())
}
/// Convenience: search for the best-matching show by name, then fetch a specific
/// episode. Disambiguates a multi-result search by picking whichever result's name
/// is textually closest to `series_query` (see `search_tv_best_match`) rather than
/// just trusting TMDB's own top-ranked (relevance/popularity) result - so a more
/// popular but differently-named show doesn't get picked over an exact match that's
/// merely ranked lower. If nothing in the results is a plausible match at all,
/// reports `NotFound` instead of guessing.
pub async fn lookup_episode(
&self,
series_query: &str,
season: i32,
episode: i32,
) -> Result<EpisodeInfo, TmdbError> {
let best = self
.search_tv_best_match(series_query)
.await?
.ok_or_else(|| TmdbError::NotFound(series_query.to_string()))?;
let info = self.episode(best.id, season, episode).await?;
Ok(EpisodeInfo {
series_name: best.name.clone(),
episode_title: info.episode_title,
air_date: info.air_date,
})
}
/// Searches for a movie by title, optionally narrowed to a specific release year
/// (TMDB's own `primary_release_year` filter - helps disambiguate a remake from
/// the original before name-similarity scoring even runs). Results come back
/// TMDB-ranked, same caveat as `search_tv`.
pub async fn search_movie(&self, query: &str, year: Option<i32>) -> Result<Vec<MovieResult>, TmdbError> {
let year_str;
let mut params: Vec<(&str, &str)> = vec![("query", query)];
if let Some(y) = year {
year_str = y.to_string();
params.push(("primary_release_year", &year_str));
}
let parsed: SearchMovieResponse = self.get_json("/search/movie", &params).await?;
Ok(parsed.results)
}
/// Convenience: search for the best-matching movie by title (and optional release
/// year), picking whichever result's title is textually closest to `query` - same
/// disambiguation approach as `lookup_episode`, so a more popular but
/// differently-titled movie doesn't get picked over an exact match ranked lower.
/// Unlike TV, a movie search result already carries everything needed (title,
/// release date), so there's no second per-item request the way `episode()` needs.
pub async fn lookup_movie(&self, query: &str, year: Option<i32>) -> Result<MovieInfo, TmdbError> {
let results = self.search_movie(query, year).await?;
let best = best_title_match(query, &results, |r| r.title.as_str(), NAME_MATCH_THRESHOLD)
.ok_or_else(|| TmdbError::NotFound(query.to_string()))?;
Ok(MovieInfo {
title: best.title.clone(),
release_date: best.release_date.clone(),
})
}
}
/// Just the episode-specific fields, before the series name (known by the caller of
/// `episode()`, which already has a series id) is attached.
pub struct EpisodeResultInfo {
pub episode_title: String,
pub air_date: Option<String>,
}
/// One episode of a season, as returned by `season_episodes()`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeasonEpisode {
pub episode_number: i32,
pub title: String,
pub air_date: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn looks_up_a_real_episode_end_to_end() {
let mut server = mockito::Server::new_async().await;
let _search_mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Breaking Bad".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":1396,"name":"Breaking Bad"}]}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/tv/1396/season/5")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"episodes":[
{"episode_number":13,"name":"To'hajiilee","air_date":"2013-09-08"},
{"episode_number":14,"name":"Ozymandias","air_date":"2013-09-15"}
]}"#,
)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let info = client.lookup_episode("Breaking Bad", 5, 14).await.unwrap();
assert_eq!(
info,
EpisodeInfo {
series_name: "Breaking Bad".to_string(),
episode_title: "Ozymandias".to_string(),
air_date: Some("2013-09-15".to_string()),
}
);
}
#[tokio::test]
async fn reports_not_found_when_search_has_no_results() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let result = client.lookup_episode("Some Nonexistent Show", 1, 1).await;
assert!(matches!(result, Err(TmdbError::NotFound(_))));
}
#[tokio::test]
async fn reports_not_found_when_the_season_has_no_such_episode() {
let mut server = mockito::Server::new_async().await;
let _search_mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":42,"name":"Some Show"}]}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/tv/42/season/1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"episodes":[{"episode_number":1,"name":"Pilot","air_date":"2020-01-01"}]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
// Season 1 only has episode 1 in this mock - asking for episode 99 should miss.
let result = client.lookup_episode("Some Show", 1, 99).await;
assert!(matches!(result, Err(TmdbError::NotFound(_))));
}
#[tokio::test]
async fn picks_the_exact_name_match_over_a_more_popular_but_wrong_result() {
let mut server = mockito::Server::new_async().await;
// TMDB ranks results by relevance/popularity, not exact-name-match - simulate
// it putting a differently-named (but more popular) show first, with the
// actually-correct exact match ranked second. Before the similarity-based
// disambiguation, `lookup_episode` just took `.next()` and would've picked
// "Chucky" here, which is wrong.
let _search_mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Chuck".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":1,"name":"Chucky"},{"id":2,"name":"Chuck"}]}"#)
.create_async()
.await;
let _season_mock = server
.mock("GET", "/tv/2/season/1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"episodes":[{"episode_number":1,"name":"Chuck Versus the Intersect","air_date":"2007-09-24"}]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let info = client.lookup_episode("Chuck", 1, 1).await.unwrap();
assert_eq!(info.series_name, "Chuck");
assert_eq!(info.episode_title, "Chuck Versus the Intersect");
}
#[tokio::test]
async fn reports_not_found_when_no_search_result_is_a_plausible_name_match() {
let mut server = mockito::Server::new_async().await;
// TMDB returned *something*, but nothing in the results is actually close to
// the query - should report NotFound rather than confidently renaming against
// an unrelated show.
let _mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":9,"name":"Totally Unrelated Program"}]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let result = client.lookup_episode("Zzyzx Nonexistent Show", 1, 1).await;
assert!(matches!(result, Err(TmdbError::NotFound(_))));
}
#[tokio::test]
async fn looks_up_a_real_movie_end_to_end() {
let mut server = mockito::Server::new_async().await;
let _search_mock = server
.mock("GET", "/search/movie")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("query".into(), "Inception".into()),
mockito::Matcher::UrlEncoded("primary_release_year".into(), "2010".into()),
]))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[{"id":27205,"title":"Inception","release_date":"2010-07-15"}]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let info = client.lookup_movie("Inception", Some(2010)).await.unwrap();
assert_eq!(
info,
MovieInfo {
title: "Inception".to_string(),
release_date: Some("2010-07-15".to_string()),
}
);
}
#[tokio::test]
async fn picks_the_exact_movie_title_match_over_a_more_popular_but_wrong_result() {
let mut server = mockito::Server::new_async().await;
let _search_mock = server
.mock("GET", "/search/movie")
.match_query(mockito::Matcher::UrlEncoded("query".into(), "Up".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"results":[
{"id":1,"title":"Wake Up","release_date":"2015-01-01"},
{"id":2,"title":"Up","release_date":"2009-05-29"}
]}"#,
)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let info = client.lookup_movie("Up", None).await.unwrap();
assert_eq!(info.title, "Up");
assert_eq!(info.release_date, Some("2009-05-29".to_string()));
}
#[tokio::test]
async fn reports_not_found_when_movie_search_has_no_results() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search/movie")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"results":[]}"#)
.create_async()
.await;
let client = TmdbClient::new("test-token").with_base_url(server.url());
let result = client.lookup_movie("Some Nonexistent Movie", None).await;
assert!(matches!(result, Err(TmdbError::NotFound(_))));
}
#[tokio::test]
async fn surfaces_an_invalid_token_as_an_api_error() {
let mut server = mockito::Server::new_async().await;
let _mock = server
.mock("GET", "/search/tv")
.match_query(mockito::Matcher::Any)
.with_status(401)
.with_header("content-type", "application/json")
.with_body(r#"{"status_message":"Invalid API key: You must be granted a valid key.","status_code":7}"#)
.create_async()
.await;
let client = TmdbClient::new("bad-token").with_base_url(server.url());
let result = client.search_tv("anything").await;
match result {
Err(TmdbError::Api { status, .. }) => assert_eq!(status, 401),
other => panic!("expected Api error, got {other:?}"),
}
}
}
@@ -0,0 +1,99 @@
use aurora_curator_core::format::ExpressionFormat;
use aurora_curator_core::string_bindings;
#[test]
fn simple_variable_substitution() {
let fmt = ExpressionFormat::compile("{n} - {s00e00} - {t}").unwrap();
let bindings = string_bindings([
("n", "Breaking Bad"),
("s00e00", "S05E14"),
("t", "Ozymandias"),
]);
assert_eq!(fmt.format(&bindings).unwrap(), "Breaking Bad - S05E14 - Ozymandias");
}
#[test]
fn scripted_expression_evaluation() {
let fmt = ExpressionFormat::compile("{n.to_upper()}").unwrap();
let bindings = string_bindings([("n", "Breaking Bad")]);
assert_eq!(fmt.format(&bindings).unwrap(), "BREAKING BAD");
}
#[test]
fn literal_text_passes_through() {
let fmt = ExpressionFormat::compile("plain text, no expressions").unwrap();
assert_eq!(fmt.format(&Default::default()).unwrap(), "plain text, no expressions");
}
#[test]
fn global_helper_function_any() {
let fmt = ExpressionFormat::compile("{any([x, y])}").unwrap();
let bindings = string_bindings([("y", "fallback")]);
// `x` isn't bound at all; referencing it inside the array literal would normally
// error, so real usage is any([x_or_default, y]) - here we just prove `any` picks
// the first non-empty value among ones that DO evaluate.
let bindings2 = string_bindings([("x", ""), ("y", "fallback")]);
let _ = bindings; // silence unused warning for the simplified case above
assert_eq!(fmt.format(&bindings2).unwrap(), "fallback");
}
#[test]
fn undefined_binding_alone_yields_error() {
let fmt = ExpressionFormat::compile("{missing}").unwrap();
let result = fmt.format(&Default::default());
assert!(result.is_err(), "expected empty-result error, got {:?}", result);
}
#[test]
fn undefined_binding_is_skipped_when_other_content_exists() {
let fmt = ExpressionFormat::compile("{n}{missing}").unwrap();
let bindings = string_bindings([("n", "Show")]);
assert_eq!(fmt.format(&bindings).unwrap(), "Show");
}
#[test]
fn nested_braces_inside_a_scriptlet_are_preserved() {
// Rhai object map literal: #{ key: value }
let fmt = ExpressionFormat::compile("{ (#{a: 1, b: 2}).a }").unwrap();
assert_eq!(fmt.format(&Default::default()).unwrap(), "1");
}
#[test]
fn syntax_error_is_reported_with_the_offending_expression() {
let err = ExpressionFormat::compile("{this is not valid rhai +++ }").unwrap_err();
let message = err.to_string();
assert!(message.contains("syntax error"), "message was: {message}");
}
#[test]
fn multi_word_plain_field_names_are_treated_as_literal_binding_keys() {
// "show name" etc. aren't valid Rhai (two bare identifiers with no operator between
// them), so this only works because compile_scriptlet falls back to a literal key
// lookup when a token looks like a plain human-typed field name rather than code.
let fmt = ExpressionFormat::compile("{show name} - {season number}x{episode number} - {episode name}").unwrap();
let bindings = string_bindings([
("show name", "Reacher"),
("season number", "1"),
("episode number", "1"),
("episode name", "Welcome to Margrave"),
]);
assert_eq!(fmt.format(&bindings).unwrap(), "Reacher - 1x1 - Welcome to Margrave");
}
#[test]
fn rhai_expressions_still_take_priority_over_plain_field_name_fallback() {
// Contains spaces too, but it's valid Rhai (string concatenation) - should compile
// and run as a script, never falling through to the plain-field-name path.
let fmt = ExpressionFormat::compile(r#"{n + " " + t}"#).unwrap();
let bindings = string_bindings([("n", "Breaking Bad"), ("t", "Ozymandias")]);
assert_eq!(fmt.format(&bindings).unwrap(), "Breaking Bad Ozymandias");
}
#[test]
fn genuinely_invalid_syntax_with_spaces_still_errors() {
// Has spaces like a plain field name might, but also punctuation that no field name
// would ever contain - should still be reported as a real syntax error, not
// silently treated as a (wrong) literal key.
let err = ExpressionFormat::compile("{show name (}").unwrap_err();
assert!(err.to_string().contains("syntax error"), "message was: {err}");
}