Fix serde field-naming bug that blanked show/episode names, real renames, and Undo
`#[serde(tag = "status", rename_all = "camelCase")]` on a tagged enum only
renames the tag's own values ("Found" -> "found"), not the field names
inside each struct-like variant - so `series_name` stayed `series_name` on
the wire instead of becoming `seriesName`. The frontend only ever reads
camelCase, so these fields came back `undefined`, and JSON.stringify
silently drops undefined keys when Tauri invoke args are serialized -
making the bug invisible rather than throwing.
This is why Fetch Data (TMDB/TVmaze/OMDb) blanked out {show name} and
{episode name} in naming schemes even on a full "found" result: reported
by the user with screenshots showing the naming preview correct after
Match but blank after Fetch Data.
The same pattern existed in 4 more places, fixed identically by adding
`rename_all_fields = "camelCase"` alongside the existing tag/rename_all:
- metadata.rs: FetchOutcome (the reported bug) and MovieFetchOutcome
- episodes.rs: EpisodeListOutcome
- history/mod.rs: RevertOutcome (restoredPath) - broke Undo reconciliation
- rename/mod.rs: RenameOutcome (newPath) - broke post-rename item tracking
after a REAL on-disk rename; RenamePanel.tsx calls
fileNameFromPath(result.outcome.newPath), which throws on undefined,
so this was likely the most severe of the five.
No frontend changes needed - the TypeScript side already expected
camelCase throughout. Added 5 permanent regression tests (one per fixed
enum, two in metadata.rs) that assert the exact camelCase JSON keys and
assert the old snake_case keys are absent. cargo test --workspace: 186 ->
191 passing. npm run build: clean, no frontend diff.
This commit is contained in:
@@ -43,7 +43,7 @@ pub struct HistoryBatch {
|
||||
|
||||
/// What happened when reverting one entry of a batch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "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
|
||||
@@ -180,6 +180,22 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
// Same gotcha and same fix as `rename::RenameOutcome` and `metadata.rs`'s
|
||||
// `FetchOutcome` - `rename_all` alone on a tagged enum doesn't reach the field
|
||||
// names inside a struct-like variant; `rename_all_fields` does. Pinned here too.
|
||||
#[test]
|
||||
fn revert_outcome_serializes_with_camel_case_field_names() {
|
||||
let reverted = RevertOutcome::Reverted { restored_path: "/media/Show/original.mkv".to_string() };
|
||||
let json = serde_json::to_string(&reverted).unwrap();
|
||||
assert!(json.contains("\"restoredPath\":\"/media/Show/original.mkv\""), "got: {json}");
|
||||
assert!(!json.contains("restored_path"));
|
||||
|
||||
let conflict = RevertOutcome::SkippedConflict { existing_path: "/media/Show/original.mkv".to_string() };
|
||||
let json = serde_json::to_string(&conflict).unwrap();
|
||||
assert!(json.contains("\"existingPath\":\"/media/Show/original.mkv\""), "got: {json}");
|
||||
assert!(!json.contains("existing_path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverts_a_renamed_file_back_to_its_original_path() {
|
||||
let dir = temp_dir();
|
||||
|
||||
@@ -93,7 +93,7 @@ impl Default for ConflictPolicy {
|
||||
|
||||
/// What happened to one planned rename.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum RenameOutcome {
|
||||
/// Renamed successfully; `new_path` is the file's new full path.
|
||||
Renamed { new_path: String },
|
||||
@@ -329,6 +329,28 @@ mod tests {
|
||||
(dir, file)
|
||||
}
|
||||
|
||||
// `#[serde(tag = "status", rename_all = "camelCase")]` alone only renames the tag's
|
||||
// own values ("Renamed", "SkippedConflict") to their lowercase/camelCase form - it
|
||||
// does NOT rename the field names inside a struct-like variant the way a plain
|
||||
// (non-tagged) `#[serde(rename_all = "camelCase")]` struct would. Without the
|
||||
// separate `rename_all_fields = "camelCase"` this enum now also carries,
|
||||
// `Renamed { new_path }` serialized as a literal `new_path` key, and the frontend's
|
||||
// `newPath`/`existingPath` destructuring silently got `undefined` for both - a real
|
||||
// bug caught in `metadata.rs`'s `FetchOutcome` sibling of this exact pattern (see
|
||||
// that module's `serde_wire_format` tests). This pins the fix here too.
|
||||
#[test]
|
||||
fn rename_outcome_serializes_with_camel_case_field_names() {
|
||||
let renamed = RenameOutcome::Renamed { new_path: "/media/Show/S01E01.mkv".to_string() };
|
||||
let json = serde_json::to_string(&renamed).unwrap();
|
||||
assert!(json.contains("\"newPath\":\"/media/Show/S01E01.mkv\""), "got: {json}");
|
||||
assert!(!json.contains("new_path"));
|
||||
|
||||
let conflict = RenameOutcome::SkippedConflict { existing_path: "/media/Show/S01E01.mkv".to_string() };
|
||||
let json = serde_json::to_string(&conflict).unwrap();
|
||||
assert!(json.contains("\"existingPath\":\"/media/Show/S01E01.mkv\""), "got: {json}");
|
||||
assert!(!json.contains("existing_path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renames_a_file_successfully() {
|
||||
let (dir, file) = temp_file_setup("old.txt", "hello");
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct EpisodeRow {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum EpisodeListOutcome {
|
||||
Found {
|
||||
series_name: String,
|
||||
@@ -90,4 +90,20 @@ mod tests {
|
||||
assert_eq!(row.title, "Ozymandias");
|
||||
assert_eq!(row.air_date.as_deref(), Some("2013-09-15"));
|
||||
}
|
||||
|
||||
// `#[serde(tag = "status", rename_all = "camelCase")]` alone only renames the tag's
|
||||
// own values, not the field names inside a struct-like variant - see
|
||||
// `metadata.rs`'s `serde_wire_format` tests for the full story and the bug this
|
||||
// caught. `rename_all_fields = "camelCase"` on this enum is what actually renames
|
||||
// `series_name` to `seriesName` on the wire; this test pins that.
|
||||
#[test]
|
||||
fn episode_list_outcome_found_serializes_with_camel_case_field_names() {
|
||||
let outcome = EpisodeListOutcome::Found {
|
||||
series_name: "Breaking Bad".to_string(),
|
||||
episodes: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&outcome).unwrap();
|
||||
assert!(json.contains("\"seriesName\":\"Breaking Bad\""), "got: {json}");
|
||||
assert!(!json.contains("series_name"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub struct FetchQuery {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum FetchOutcome {
|
||||
Found {
|
||||
series_name: String,
|
||||
@@ -298,7 +298,7 @@ pub struct MovieFetchQuery {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
#[serde(tag = "status", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum MovieFetchOutcome {
|
||||
Found {
|
||||
title: String,
|
||||
@@ -371,3 +371,45 @@ async fn run_movie_fetch(client: AnyMovieClient, items: Vec<MovieFetchQuery>) ->
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod serde_wire_format {
|
||||
use super::*;
|
||||
|
||||
// `#[serde(tag = "status", rename_all = "camelCase")]` on an enum only renames the
|
||||
// tag's own values (`Found` -> "found") - NOT the field names inside a struct-like
|
||||
// variant, which is a genuinely easy thing to miss since a plain (non-tagged)
|
||||
// `#[serde(rename_all = "camelCase")]` struct DOES rename its fields. Without the
|
||||
// separate `rename_all_fields = "camelCase"` this crate now also carries, `Found {
|
||||
// series_name, episode_title, air_date }` serialized as literal `series_name`/
|
||||
// `episode_title`/`air_date` keys - the frontend's `const { seriesName, episodeTitle
|
||||
// } = tvResult.outcome` destructured `undefined` for both, which then silently
|
||||
// vanished (JSON.stringify drops `undefined` properties) by the time bindings
|
||||
// reached `render_expression`, rendering `{show name}`/`{episode name}` as empty
|
||||
// even for entries the status line correctly reported as "found". These two tests
|
||||
// pin the fix so the same enum shape can't quietly regress it.
|
||||
#[test]
|
||||
fn fetch_outcome_found_serializes_with_camel_case_field_names() {
|
||||
let outcome = FetchOutcome::Found {
|
||||
series_name: "Fire Country".to_string(),
|
||||
episode_title: "Pilot".to_string(),
|
||||
air_date: Some("2024-01-01".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&outcome).unwrap();
|
||||
assert!(json.contains("\"seriesName\":\"Fire Country\""), "got: {json}");
|
||||
assert!(json.contains("\"episodeTitle\":\"Pilot\""), "got: {json}");
|
||||
assert!(json.contains("\"airDate\":\"2024-01-01\""), "got: {json}");
|
||||
assert!(!json.contains("series_name") && !json.contains("episode_title") && !json.contains("air_date"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movie_fetch_outcome_found_serializes_with_camel_case_field_names() {
|
||||
let outcome = MovieFetchOutcome::Found {
|
||||
title: "Inception".to_string(),
|
||||
release_date: Some("2010-07-16".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&outcome).unwrap();
|
||||
assert!(json.contains("\"releaseDate\":\"2010-07-16\""), "got: {json}");
|
||||
assert!(!json.contains("release_date"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user