diff --git a/crates/aurora-curator-core/src/history/mod.rs b/crates/aurora-curator-core/src/history/mod.rs index 6422bcb..e19220d 100644 --- a/crates/aurora-curator-core/src/history/mod.rs +++ b/crates/aurora-curator-core/src/history/mod.rs @@ -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(); diff --git a/crates/aurora-curator-core/src/rename/mod.rs b/crates/aurora-curator-core/src/rename/mod.rs index 340fa28..16c0e84 100644 --- a/crates/aurora-curator-core/src/rename/mod.rs +++ b/crates/aurora-curator-core/src/rename/mod.rs @@ -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"); diff --git a/src-tauri/src/episodes.rs b/src-tauri/src/episodes.rs index a3af326..12f5616 100644 --- a/src-tauri/src/episodes.rs +++ b/src-tauri/src/episodes.rs @@ -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")); + } } diff --git a/src-tauri/src/metadata.rs b/src-tauri/src/metadata.rs index a4e2cca..e61f373 100644 --- a/src-tauri/src/metadata.rs +++ b/src-tauri/src/metadata.rs @@ -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) -> 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")); + } +}