Skip to main content

zagens_runtime_adapters/scratchpad/
schema.rs

1//! Audit scratchpad JSON schemas (`inventory.json`, `notes.jsonl` lines).
2
3use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// Inventory area status.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AreaStatus {
12    Pending,
13    InProgress,
14    Done,
15    Deferred,
16}
17
18impl AreaStatus {
19    #[must_use]
20    #[allow(clippy::should_implement_trait)]
21    pub fn from_str(value: &str) -> Option<Self> {
22        match value.trim().to_lowercase().as_str() {
23            "pending" => Some(Self::Pending),
24            "in_progress" | "inprogress" => Some(Self::InProgress),
25            "done" => Some(Self::Done),
26            "deferred" => Some(Self::Deferred),
27            _ => None,
28        }
29    }
30
31    #[must_use]
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Self::Pending => "pending",
35            Self::InProgress => "in_progress",
36            Self::Done => "done",
37            Self::Deferred => "deferred",
38        }
39    }
40}
41
42/// One row in `inventory.json`.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct InventoryArea {
45    pub id: String,
46    pub path: String,
47    pub status: AreaStatus,
48    #[serde(default)]
49    pub notes: String,
50}
51
52/// `inventory.json` root.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Inventory {
55    #[serde(default)]
56    pub run_id: String,
57    #[serde(default)]
58    pub created_at: String,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub completed_at: Option<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub scope: Option<String>,
63    pub areas: Vec<InventoryArea>,
64}
65
66/// Parsed `notes.jsonl` line (Phase A fields optional).
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct NoteLine {
69    #[serde(default)]
70    pub id: String,
71    #[serde(default)]
72    pub ts: String,
73    #[serde(default)]
74    pub area_id: String,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub area: Option<String>,
77    #[serde(default)]
78    pub kind: String,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub severity: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub title: Option<String>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub file: Option<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub line: Option<u64>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub line_end: Option<u64>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub claim: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub evidence: Option<String>,
93    #[serde(default)]
94    pub status: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub source: Option<String>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub supersedes: Option<String>,
99}
100
101/// Normalize a raw JSON object from disk or tool input into a [`NoteLine`].
102pub fn parse_note_line(raw: &Value, line_no: usize) -> NoteLine {
103    let mut note: NoteLine = serde_json::from_value(raw.clone()).unwrap_or_else(|_| NoteLine {
104        id: String::new(),
105        ts: String::new(),
106        area_id: String::new(),
107        area: None,
108        kind: "meta".to_string(),
109        severity: None,
110        title: None,
111        file: None,
112        line: None,
113        line_end: None,
114        claim: None,
115        evidence: None,
116        status: String::new(),
117        source: None,
118        supersedes: None,
119    });
120
121    if note.id.is_empty() {
122        note.id = format!("legacy-{line_no}");
123    }
124    if note.ts.is_empty() {
125        note.ts = String::new();
126    }
127    if note.kind.is_empty() {
128        note.kind = raw
129            .get("kind")
130            .and_then(|v| v.as_str())
131            .unwrap_or("meta")
132            .to_string();
133    }
134    if note.status.is_empty() {
135        note.status = match note.kind.as_str() {
136            "finding" => "verified".to_string(),
137            "todo" => "open".to_string(),
138            _ => "open".to_string(),
139        };
140    }
141    if note.area_id.is_empty() {
142        note.area_id = raw
143            .get("area_id")
144            .and_then(|v| v.as_str())
145            .unwrap_or("_global")
146            .to_string();
147    }
148    note
149}
150
151/// Severity strings treated as HIGH tier for validation and L1 summary.
152#[must_use]
153pub fn is_high_severity(severity: Option<&str>) -> bool {
154    matches!(
155        severity.map(str::to_uppercase).as_deref(),
156        Some("HIGH") | Some("BLOCKER")
157    )
158}
159
160/// Whether this note counts as a verified finding for status tallies.
161#[must_use]
162pub fn is_verified_finding(note: &NoteLine, superseded: &HashSet<String>) -> bool {
163    note.kind == "finding"
164        && note.status.eq_ignore_ascii_case("verified")
165        && !superseded.contains(&note.id)
166}
167
168/// Whether this note counts as an open finding.
169#[must_use]
170pub fn is_open_finding(note: &NoteLine, superseded: &HashSet<String>) -> bool {
171    note.kind == "finding"
172        && note.status.eq_ignore_ascii_case("open")
173        && !superseded.contains(&note.id)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serde_json::json;
180
181    #[test]
182    fn parse_phase_a_line_defaults() {
183        let raw = json!({
184            "kind": "cleared",
185            "area_id": "area-core",
186            "claim": "ok"
187        });
188        let note = parse_note_line(&raw, 3);
189        assert_eq!(note.id, "legacy-3");
190        assert_eq!(note.area_id, "area-core");
191        assert_eq!(note.kind, "cleared");
192        assert_eq!(note.status, "open");
193        assert_eq!(note.kind, "cleared");
194    }
195
196    #[test]
197    fn finding_defaults_verified_status() {
198        let raw = json!({
199            "id": "note-1",
200            "kind": "finding",
201            "area_id": "a",
202            "severity": "LOW"
203        });
204        let note = parse_note_line(&raw, 1);
205        assert_eq!(note.status, "verified");
206    }
207}