Skip to main content

wowlab_common/sim/
job_meta.rs

1//! Job metadata JSON build/parse for the `jobs.meta` jsonb column.
2
3use serde::{Deserialize, Serialize};
4
5/// Failure to parse job metadata JSON.
6#[derive(Debug, thiserror::Error)]
7#[non_exhaustive]
8#[error("JSON parse error: {source}")]
9pub struct JobMetaError {
10    #[source]
11    source: serde_json::Error,
12}
13
14impl JobMetaError {
15    fn parse(source: serde_json::Error) -> Self {
16        Self { source }
17    }
18}
19
20impl From<serde_json::Error> for JobMetaError {
21    fn from(source: serde_json::Error) -> Self {
22        Self::parse(source)
23    }
24}
25
26/// Queryable metadata stored as `meta` jsonb on the `jobs` row.
27#[derive(Clone, Debug, Deserialize, Serialize)]
28#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
29#[cfg_attr(feature = "wasm", tsify(into_wasm_abi))]
30pub struct JobMeta {
31    pub status: String,
32    pub created_at: String,
33    pub completed_at: String,
34    pub pinned: bool,
35    pub spec_id: i32,
36    pub season_id: String,
37}
38
39impl JobMeta {
40    #[must_use]
41    pub fn new_pending() -> Self {
42        Self {
43            status: "pending".to_string(),
44            created_at: String::new(),
45            completed_at: String::new(),
46            pinned: false,
47            spec_id: 0,
48            season_id: String::new(),
49        }
50    }
51}
52
53/// Serialize a [`JobMeta`] to a JSON string.
54///
55/// # Panics
56///
57/// Panics if the fixed `JobMeta` representation cannot be serialized.
58#[must_use]
59pub fn build_job_meta(meta: &JobMeta) -> String {
60    serde_json::to_string(meta).expect("JobMeta serialization should never fail")
61}
62
63/// Parse a JSON string into a [`JobMeta`].
64///
65/// # Errors
66///
67/// Returns an error when `json` does not satisfy the [`JobMeta`] schema.
68pub fn parse_job_meta(json: &str) -> Result<JobMeta, JobMetaError> {
69    Ok(serde_json::from_str(json)?)
70}
71
72#[cfg(test)]
73mod tests {
74    use std::error::Error as _;
75
76    use googletest::prelude::*;
77
78    use super::*;
79
80    #[gtest]
81    fn test_roundtrip() -> Result<()> {
82        let meta = JobMeta {
83            status: "completed".into(),
84            created_at: "2026-03-04T00:00:00Z".into(),
85            completed_at: "2026-03-04T00:01:00Z".into(),
86            pinned: true,
87            spec_id: 253,
88            season_id: "tww-s2".into(),
89        };
90        let json = build_job_meta(&meta);
91        let parsed = parse_job_meta(&json).or_fail()?;
92
93        verify_that!(
94            parsed,
95            matches_pattern!(JobMeta {
96                status: "completed",
97                spec_id: eq(&253),
98                pinned: eq(&true),
99                season_id: "tww-s2",
100                ..
101            })
102        )
103    }
104
105    #[gtest]
106    fn test_new_pending() -> Result<()> {
107        let meta = JobMeta::new_pending();
108
109        verify_that!(
110            meta,
111            matches_pattern!(JobMeta {
112                status: "pending",
113                pinned: eq(&false),
114                spec_id: eq(&0),
115                season_id: "",
116                ..
117            })
118        )
119    }
120
121    #[gtest]
122    fn malformed_empty_and_missing_json_preserve_parse_sources() -> Result<()> {
123        for json in ["{", "", r#"{"status":"pending"}"#] {
124            let error = parse_job_meta(json).err().or_fail()?;
125
126            verify_that!(error.to_string(), starts_with("JSON parse error:"))?;
127            verify_true!(
128                error
129                    .source()
130                    .is_some_and(<dyn std::error::Error>::is::<serde_json::Error>)
131            )?;
132        }
133
134        Ok(())
135    }
136
137    #[gtest]
138    fn unicode_metadata_roundtrips_unchanged() -> Result<()> {
139        let meta = JobMeta {
140            status: "terminé-世界".to_string(),
141            created_at: "époque".to_string(),
142            completed_at: "🦊".to_string(),
143            pinned: false,
144            spec_id: 253,
145            season_id: "saison-été".to_string(),
146        };
147
148        let parsed = parse_job_meta(&build_job_meta(&meta)).or_fail()?;
149
150        verify_that!(
151            parsed,
152            matches_pattern!(JobMeta {
153                status: eq(&meta.status),
154                created_at: eq(&meta.created_at),
155                completed_at: eq(&meta.completed_at),
156                season_id: eq(&meta.season_id),
157                ..
158            })
159        )
160    }
161}