Skip to main content

spec_driven_docs/services/
known_issues.rs

1//! Read the known-issue zone as data.
2//!
3//! The index is derived, never stored: a committed listing is a second copy
4//! of what each record already states, and one file every new case edits is
5//! the branch collision the slug case id exists to avoid. Reading through
6//! the same resolver the gates use keeps the listing and the gates agreed on
7//! what counts as a record.
8
9use camino::Utf8Path;
10use serde::Serialize;
11
12use crate::error::AppError;
13use crate::gates::front_matter_values;
14use crate::gates::paths::ki_records;
15use crate::gates::{GateCtx, read_text};
16
17/// One known-issue record, as `sdd ki list` reports it.
18#[derive(Debug, Serialize)]
19pub struct Case {
20    /// The case id: the filename without its extension.
21    pub id: String,
22    /// How this project handles the defect, where the record states it.
23    pub state: Option<String>,
24    /// When the upstream state was last confirmed, where the record states it.
25    pub checked: Option<String>,
26    /// Where the case stands upstream, where the record states it.
27    pub filing: Option<String>,
28    /// The upstream issue or tracker, where the record names one.
29    pub upstream: Option<String>,
30    /// The record's path, relative to the target.
31    pub path: String,
32}
33
34/// Every known-issue record under the target, in path order.
35///
36/// # Errors
37///
38/// [`AppError::Io`] when a record cannot be read.
39pub fn cases(target: &Utf8Path) -> Result<Vec<Case>, AppError> {
40    let ctx = GateCtx::new(target);
41    let mut cases = Vec::new();
42    for record in ki_records(&ctx, &[])? {
43        let text = read_text(&ctx, &record)?;
44        cases.push(Case {
45            id: record.file_stem().unwrap_or_default().to_string(),
46            state: first(&text, "state"),
47            checked: first(&text, "checked"),
48            filing: first(&text, "filing"),
49            upstream: first(&text, "upstream"),
50            path: record.to_string(),
51        });
52    }
53    Ok(cases)
54}
55
56/// The first value a front-matter key carries, where it carries one.
57fn first(text: &str, key: &str) -> Option<String> {
58    front_matter_values(text, key)
59        .into_iter()
60        .find(|value| !value.is_empty())
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn target_with(record: &str) -> tempfile::TempDir {
68        let dir = tempfile::tempdir().unwrap();
69        let records = dir.path().join("_docs/reference/known-issues");
70        std::fs::create_dir_all(&records).unwrap();
71        std::fs::write(records.join("KI-vendor-replays.md"), record).unwrap();
72        dir
73    }
74
75    #[test]
76    fn reads_both_axes_and_the_upstream_reference() {
77        let dir = target_with(
78            "---\nupstream: https://example.invalid/issues/1\nstate: masked\nchecked: 2026-06-18\nfiling: filed\n---\n# V\n",
79        );
80        let target = camino::Utf8Path::from_path(dir.path()).unwrap();
81        let cases = cases(target).unwrap();
82        assert_eq!(cases.len(), 1);
83        assert_eq!(cases[0].id, "KI-vendor-replays");
84        assert_eq!(cases[0].state.as_deref(), Some("masked"));
85        assert_eq!(cases[0].checked.as_deref(), Some("2026-06-18"));
86        assert_eq!(cases[0].filing.as_deref(), Some("filed"));
87        assert_eq!(
88            cases[0].path,
89            "_docs/reference/known-issues/KI-vendor-replays.md"
90        );
91    }
92
93    #[test]
94    fn a_record_stating_no_axis_reports_none_rather_than_failing() {
95        let dir = target_with("---\naffects: client\n---\n# V\n");
96        let target = camino::Utf8Path::from_path(dir.path()).unwrap();
97        let cases = cases(target).unwrap();
98        assert_eq!(cases.len(), 1);
99        assert!(cases[0].state.is_none());
100        assert!(cases[0].checked.is_none());
101        assert!(cases[0].filing.is_none());
102        assert!(cases[0].upstream.is_none());
103    }
104
105    #[test]
106    fn a_record_stating_an_empty_checked_reports_none() {
107        let dir = target_with("---\nstate: masked\nchecked:\n---\n# V\n");
108        let target = camino::Utf8Path::from_path(dir.path()).unwrap();
109        let cases = cases(target).unwrap();
110        assert_eq!(cases.len(), 1);
111        assert!(cases[0].checked.is_none());
112    }
113
114    #[test]
115    fn a_target_with_no_zone_lists_nothing() {
116        let dir = tempfile::tempdir().unwrap();
117        let target = camino::Utf8Path::from_path(dir.path()).unwrap();
118        assert!(cases(target).unwrap().is_empty());
119    }
120}