zagens_runtime_adapters/scratchpad/
schema.rs1use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8#[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#[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#[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#[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
101pub 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#[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#[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(¬e.id)
166}
167
168#[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(¬e.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}