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    /// Where the case stands upstream, where the record states it.
25    pub filing: Option<String>,
26    /// The upstream issue or tracker, where the record names one.
27    pub upstream: Option<String>,
28    /// The record's path, relative to the target.
29    pub path: String,
30}
31
32/// Every known-issue record under the target, in path order.
33///
34/// # Errors
35///
36/// [`AppError::Io`] when a record cannot be read.
37pub fn cases(target: &Utf8Path) -> Result<Vec<Case>, AppError> {
38    let ctx = GateCtx::new(target);
39    let mut cases = Vec::new();
40    for record in ki_records(&ctx, &[])? {
41        let text = read_text(&ctx, &record)?;
42        cases.push(Case {
43            id: record.file_stem().unwrap_or_default().to_string(),
44            state: first(&text, "state"),
45            filing: first(&text, "filing"),
46            upstream: first(&text, "upstream"),
47            path: record.to_string(),
48        });
49    }
50    Ok(cases)
51}
52
53/// The first value a front-matter key carries, where it carries one.
54fn first(text: &str, key: &str) -> Option<String> {
55    front_matter_values(text, key)
56        .into_iter()
57        .find(|value| !value.is_empty())
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    fn target_with(record: &str) -> tempfile::TempDir {
65        let dir = tempfile::tempdir().unwrap();
66        let records = dir.path().join("_docs/reference/known-issues");
67        std::fs::create_dir_all(&records).unwrap();
68        std::fs::write(records.join("KI-vendor-replays.md"), record).unwrap();
69        dir
70    }
71
72    #[test]
73    fn reads_both_axes_and_the_upstream_reference() {
74        let dir = target_with(
75            "---\nupstream: https://example.invalid/issues/1\nstate: masked\nfiling: filed\n---\n# V\n",
76        );
77        let target = camino::Utf8Path::from_path(dir.path()).unwrap();
78        let cases = cases(target).unwrap();
79        assert_eq!(cases.len(), 1);
80        assert_eq!(cases[0].id, "KI-vendor-replays");
81        assert_eq!(cases[0].state.as_deref(), Some("masked"));
82        assert_eq!(cases[0].filing.as_deref(), Some("filed"));
83        assert_eq!(
84            cases[0].path,
85            "_docs/reference/known-issues/KI-vendor-replays.md"
86        );
87    }
88
89    #[test]
90    fn a_record_stating_no_axis_reports_none_rather_than_failing() {
91        let dir = target_with("---\naffects: client\n---\n# V\n");
92        let target = camino::Utf8Path::from_path(dir.path()).unwrap();
93        let cases = cases(target).unwrap();
94        assert_eq!(cases.len(), 1);
95        assert!(cases[0].state.is_none());
96        assert!(cases[0].filing.is_none());
97        assert!(cases[0].upstream.is_none());
98    }
99
100    #[test]
101    fn a_target_with_no_zone_lists_nothing() {
102        let dir = tempfile::tempdir().unwrap();
103        let target = camino::Utf8Path::from_path(dir.path()).unwrap();
104        assert!(cases(target).unwrap().is_empty());
105    }
106}