Skip to main content

buildlog_consultant/
problem.rs

1//! JSON-from-kind dispatcher for `Problem` impls.
2//!
3//! This module wires up [`problem_from_json`], which reconstructs a
4//! `Box<dyn Problem>` from the `(kind, json)` pair produced by
5//! `Problem::kind()` + `Problem::json()`. Individual `Problem` impls
6//! register themselves with the dispatcher via the
7//! [`register_problem_de!`](crate::register_problem_de) /
8//! [`register_problem_de_fn!`](crate::register_problem_de_fn) macros,
9//! colocated with their `inventory::submit! { ProblemKindInfo {...} }`
10//! block in the file that defines the impl.
11//!
12//! The set covered in-tree is curated for round-trip parity with
13//! `Problem::json()` for the kinds the Janitor's followup
14//! orchestration cares about — primarily the dependency-related
15//! ones that map to `ognibuild::buildlog::problem_to_dependency`.
16
17use crate::Problem;
18
19/// Function pointer that turns a JSON details payload back into a
20/// `Box<dyn Problem>`. Registered alongside [`crate::ProblemKindInfo`] so
21/// callers can reconstruct a typed problem from
22/// `(kind: &str, details: &serde_json::Value)` — the shape the
23/// Janitor stores in `run.failure_details` and the Python original
24/// reconstructed via `problem_clses[kind].from_json(details)`.
25pub type ProblemFromJsonFn = fn(&serde_json::Value) -> Result<Box<dyn Problem>, serde_json::Error>;
26
27/// Pair of `(kind, deserializer)` used to power
28/// [`problem_from_json`]. Each `Problem` impl that wants to be
29/// reconstructable from JSON registers one of these via
30/// `inventory::submit!`. The crate ships registrations for the
31/// problem types we know how to round-trip; out-of-tree
32/// `Problem` impls can register their own.
33///
34/// The `register_problem_de!` macro generates this entry from a
35/// `serde::Deserialize` impl on the target struct.
36pub struct ProblemDeserializer {
37    /// The problem kind identifier (matches `ProblemKindInfo.kind`).
38    pub kind: &'static str,
39    /// Function that takes the JSON details and produces a typed
40    /// `Box<dyn Problem>` (or a `serde_json::Error` if the shape
41    /// doesn't match).
42    pub from_json: ProblemFromJsonFn,
43}
44
45inventory::collect!(ProblemDeserializer);
46
47/// Reconstruct a `Box<dyn Problem>` from a `(kind, details)`
48/// pair. Returns `None` when no deserializer is registered for
49/// `kind`, or when the registered deserializer rejects the JSON
50/// shape (the inner error is surfaced via `log::warn!`).
51///
52/// Mirrors Python's `problem_clses[kind].from_json(details)`.
53pub fn problem_from_json(kind: &str, details: &serde_json::Value) -> Option<Box<dyn Problem>> {
54    for entry in inventory::iter::<ProblemDeserializer> {
55        if entry.kind == kind {
56            match (entry.from_json)(details) {
57                Ok(p) => return Some(p),
58                Err(e) => {
59                    log::warn!(
60                        "buildlog-consultant: failed to deserialize problem of kind {:?}: {}",
61                        kind,
62                        e
63                    );
64                    return None;
65                }
66            }
67        }
68    }
69    None
70}
71
72/// Helper macro for registering a `(kind, struct)` pair with the
73/// JSON-from-kind dispatcher. The struct must implement
74/// `serde::Deserialize` so the JSON details can round-trip
75/// through `serde_json::from_value`. The struct must also
76/// implement `Problem` so the boxed result satisfies the trait.
77///
78/// Use site:
79/// ```ignore
80/// crate::register_problem_de!(MissingFile, "missing-file");
81/// ```
82#[macro_export]
83macro_rules! register_problem_de {
84    ($ty:ty, $kind:expr) => {
85        ::inventory::submit! {
86            $crate::ProblemDeserializer {
87                kind: $kind,
88                from_json: |v| {
89                    ::serde_json::from_value::<$ty>(v.clone())
90                        .map(|p| Box::new(p) as Box<dyn $crate::Problem>)
91                },
92            }
93        }
94    };
95}
96
97/// Register a deserializer with a custom function — for
98/// problems whose JSON shape doesn't trivially round-trip
99/// through `serde::Deserialize` (e.g. fields renamed in
100/// `json()`, optional fields with defaults, etc.).
101#[macro_export]
102macro_rules! register_problem_de_fn {
103    ($kind:expr, $fn:expr) => {
104        ::inventory::submit! {
105            $crate::ProblemDeserializer {
106                kind: $kind,
107                from_json: $fn,
108            }
109        }
110    };
111}
112
113#[cfg(test)]
114mod tests {
115    use crate::Problem;
116
117    /// Round-trip parity: every Problem::json() output should
118    /// deserialize back into a Problem of the same kind via
119    /// `problem_from_json`. If a mismatch shows up here it's
120    /// because someone added/changed a field in `json()` without
121    /// updating the matching deserializer registration.
122    fn roundtrip<P: Problem + 'static>(p: &P) {
123        let kind = p.kind().to_string();
124        let json = p.json();
125        let back = crate::problem_from_json(&kind, &json)
126            .unwrap_or_else(|| panic!("no deserializer for kind {:?}", kind));
127        assert_eq!(back.kind(), kind);
128        assert_eq!(back.json(), json, "round-trip mismatch for kind {}", kind);
129    }
130
131    #[test]
132    fn roundtrip_missing_file() {
133        roundtrip(&crate::problems::common::MissingFile::new(
134            "/usr/bin/foo".into(),
135        ));
136    }
137
138    #[test]
139    fn roundtrip_missing_command() {
140        roundtrip(&crate::problems::common::MissingCommand("foo".into()));
141    }
142
143    #[test]
144    fn roundtrip_vcs_control_directory_needed() {
145        roundtrip(&crate::problems::common::VcsControlDirectoryNeeded::new(
146            vec!["git", "bzr"],
147        ));
148    }
149
150    #[test]
151    fn roundtrip_missing_python_module() {
152        roundtrip(&crate::problems::common::MissingPythonModule::simple(
153            "numpy".to_string(),
154        ));
155    }
156
157    #[test]
158    fn roundtrip_missing_python_distribution() {
159        roundtrip(&crate::problems::common::MissingPythonDistribution {
160            distribution: "twisted".to_string(),
161            python_version: Some(3),
162            minimum_version: Some("18.0".to_string()),
163        });
164    }
165
166    #[test]
167    fn roundtrip_missing_c_header() {
168        roundtrip(&crate::problems::common::MissingCHeader::new(
169            "stdio.h".into(),
170        ));
171    }
172
173    #[test]
174    fn roundtrip_missing_go_package() {
175        roundtrip(&crate::problems::common::MissingGoPackage {
176            package: "github.com/foo/bar".into(),
177        });
178    }
179
180    #[test]
181    fn roundtrip_missing_node_module() {
182        roundtrip(&crate::problems::common::MissingNodeModule("foo".into()));
183    }
184
185    #[test]
186    fn roundtrip_missing_node_package() {
187        roundtrip(&crate::problems::common::MissingNodePackage("foo".into()));
188    }
189
190    #[test]
191    fn roundtrip_missing_vala_package() {
192        roundtrip(&crate::problems::common::MissingValaPackage(
193            "foo-1.0".into(),
194        ));
195    }
196
197    #[test]
198    fn roundtrip_missing_library() {
199        roundtrip(&crate::problems::common::MissingLibrary("foo".into()));
200    }
201
202    #[test]
203    fn roundtrip_missing_haskell_module() {
204        roundtrip(&crate::problems::common::MissingHaskellModule::new(
205            "Foo.Bar".into(),
206        ));
207    }
208
209    #[test]
210    fn roundtrip_missing_pkg_config() {
211        roundtrip(&crate::problems::common::MissingPkgConfig {
212            module: "foo".into(),
213            minimum_version: Some("1.0".into()),
214        });
215    }
216
217    #[test]
218    fn roundtrip_missing_python_module_with_versions() {
219        roundtrip(&crate::problems::common::MissingPythonModule {
220            module: "django".into(),
221            python_version: Some(3),
222            minimum_version: Some("4.2".into()),
223        });
224    }
225
226    #[test]
227    fn roundtrip_missing_python_distribution_simple() {
228        roundtrip(&crate::problems::common::MissingPythonDistribution {
229            distribution: "twisted".into(),
230            python_version: None,
231            minimum_version: None,
232        });
233    }
234
235    #[test]
236    fn roundtrip_missing_pkg_config_no_version() {
237        roundtrip(&crate::problems::common::MissingPkgConfig {
238            module: "glib-2.0".into(),
239            minimum_version: None,
240        });
241    }
242
243    #[test]
244    fn roundtrip_missing_haskell_dependencies() {
245        roundtrip(&crate::problems::common::MissingHaskellDependencies(vec![
246            "aeson".into(),
247            "text".into(),
248        ]));
249    }
250
251    #[test]
252    fn roundtrip_missing_autoconf_macro_default() {
253        roundtrip(&crate::problems::common::MissingAutoconfMacro::new(
254            "AC_PROG_CC".into(),
255        ));
256    }
257
258    #[test]
259    fn roundtrip_missing_autoconf_macro_need_rebuild() {
260        let mut p = crate::problems::common::MissingAutoconfMacro::new("PKG_CHECK_MODULES".into());
261        p.need_rebuild = true;
262        roundtrip(&p);
263    }
264
265    #[test]
266    fn roundtrip_unsatisfied_apt_dependencies() {
267        roundtrip(&crate::problems::debian::UnsatisfiedAptDependencies(
268            "libssl-dev (>= 1.1)".into(),
269        ));
270    }
271
272    #[test]
273    fn roundtrip_apt_package_unknown() {
274        roundtrip(&crate::problems::debian::AptPackageUnknown(
275            "nonexistent-pkg".into(),
276        ));
277    }
278
279    #[test]
280    fn missing_haskell_dependencies_accepts_bare_array() {
281        // The deserializer accepts both {"deps": [...]} (the json() shape)
282        // and a bare array (legacy shape). Verify the bare-array path.
283        let v = serde_json::json!(["aeson", "text"]);
284        let p = crate::problem_from_json("missing-haskell-dependencies", &v)
285            .expect("bare array should deserialize");
286        assert_eq!(p.json(), serde_json::json!({"deps": ["aeson", "text"]}),);
287    }
288
289    #[test]
290    fn apt_package_unknown_accepts_bare_string() {
291        // The deserializer accepts both {"package": "..."} (the json() shape)
292        // and a bare JSON string. Verify the bare-string path.
293        let v = serde_json::json!("foo-pkg");
294        let p = crate::problem_from_json("apt-package-unknown", &v)
295            .expect("bare string should deserialize");
296        assert_eq!(p.json(), serde_json::json!({"package": "foo-pkg"}));
297    }
298
299    #[test]
300    fn missing_file_missing_path_returns_none() {
301        // Required field absent — deserializer should fail and the
302        // dispatcher should swallow the error into None.
303        let v = serde_json::json!({});
304        assert!(crate::problem_from_json("missing-file", &v).is_none());
305    }
306
307    #[test]
308    fn missing_file_wrong_type_returns_none() {
309        // Required field is the wrong JSON type — also rejected.
310        let v = serde_json::json!({"path": 42});
311        assert!(crate::problem_from_json("missing-file", &v).is_none());
312    }
313
314    #[test]
315    fn missing_python_module_only_required_field() {
316        // Optional fields absent (python_version, minimum_version) —
317        // deserializer should still succeed.
318        let v = serde_json::json!({"module": "numpy"});
319        let p = crate::problem_from_json("missing-python-module", &v)
320            .expect("optional fields should be optional");
321        assert_eq!(p.kind(), "missing-python-module");
322        assert_eq!(
323            p.json(),
324            serde_json::json!({
325                "module": "numpy",
326                "python_version": null,
327                "minimum_version": null,
328            }),
329        );
330    }
331
332    #[test]
333    fn missing_autoconf_macro_omits_need_rebuild() {
334        // need_rebuild is optional in the deserializer (defaults to false).
335        let v = serde_json::json!({"macro": "AC_PROG_CC"});
336        let p = crate::problem_from_json("missing-autoconf-macro", &v)
337            .expect("need_rebuild should be optional");
338        assert_eq!(
339            p.json(),
340            serde_json::json!({"macro": "AC_PROG_CC", "need_rebuild": false}),
341        );
342    }
343
344    #[test]
345    fn unknown_kind_returns_none() {
346        let v = serde_json::json!({"foo": "bar"});
347        assert!(crate::problem_from_json("not-a-real-kind", &v).is_none());
348    }
349}