1use std::fs;
35use std::io;
36use std::path::{Path, PathBuf};
37
38use crate::cluster::Cluster;
39use crate::json::{self, Value};
40use crate::scanner::Finding;
41
42pub const STATE_DIR: &str = ".gitwell";
43pub const STATE_FILE: &str = "triage.json";
44pub const ARCHIVE_SUBDIR: &str = "archives";
45
46pub const SCHEMA_VERSION: i64 = 1;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum DecisionKind {
50 Resume,
51 Archive,
52 Delete,
53 Skip,
54}
55
56impl DecisionKind {
57 pub fn as_str(&self) -> &'static str {
58 match self {
59 Self::Resume => "resume",
60 Self::Archive => "archive",
61 Self::Delete => "delete",
62 Self::Skip => "skip",
63 }
64 }
65
66 pub fn parse(s: &str) -> Option<Self> {
67 match s {
68 "resume" => Some(Self::Resume),
69 "archive" => Some(Self::Archive),
70 "delete" => Some(Self::Delete),
71 "skip" => Some(Self::Skip),
72 _ => None,
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
78pub struct DecisionFinding {
79 pub repo: String,
80 pub repo_path: String,
81 pub kind: String, pub detail: String,
83 pub stash_sha: Option<String>,
86}
87
88impl DecisionFinding {
89 pub fn from_finding(repo: &str, repo_path: &str, f: &Finding) -> Self {
90 match f {
91 Finding::StaleBranch { name, .. } => DecisionFinding {
92 repo: repo.to_string(),
93 repo_path: repo_path.to_string(),
94 kind: "stale_branch".into(),
95 detail: name.clone(),
96 stash_sha: None,
97 },
98 Finding::Stash {
99 index,
100 sha,
101 message,
102 ..
103 } => DecisionFinding {
104 repo: repo.to_string(),
105 repo_path: repo_path.to_string(),
106 kind: "stash".into(),
107 detail: format!("{}: {}", index, message),
108 stash_sha: Some(sha.clone()),
109 },
110 Finding::WipCommit { sha, message, .. } => DecisionFinding {
111 repo: repo.to_string(),
112 repo_path: repo_path.to_string(),
113 kind: "wip_commit".into(),
114 detail: format!("{} {}", short_sha(sha), message),
115 stash_sha: None,
116 },
117 Finding::OrphanCommit { sha, message, .. } => DecisionFinding {
118 repo: repo.to_string(),
119 repo_path: repo_path.to_string(),
120 kind: "orphan_commit".into(),
121 detail: format!("{} {}", short_sha(sha), message),
122 stash_sha: None,
123 },
124 Finding::DormantRepo { path, .. } => DecisionFinding {
125 repo: repo.to_string(),
126 repo_path: repo_path.to_string(),
127 kind: "dormant_repo".into(),
128 detail: path.clone(),
129 stash_sha: None,
130 },
131 }
132 }
133}
134
135fn short_sha(sha: &str) -> &str {
136 &sha[..sha.len().min(8)]
137}
138
139#[derive(Debug, Clone)]
140pub struct Decision {
141 pub session_key: String,
144 pub session_label: String,
145 pub decision: DecisionKind,
146 pub decided_at: String,
147 pub findings: Vec<DecisionFinding>,
148 pub executed: bool,
149}
150
151#[derive(Debug, Clone, Default)]
152pub struct TriageState {
153 pub decisions: Vec<Decision>,
154}
155
156impl TriageState {
157 pub fn state_path(scan_path: &Path) -> PathBuf {
158 scan_path.join(STATE_DIR).join(STATE_FILE)
159 }
160
161 pub fn archive_dir(scan_path: &Path) -> PathBuf {
162 scan_path.join(STATE_DIR).join(ARCHIVE_SUBDIR)
163 }
164
165 pub fn load(scan_path: &Path) -> io::Result<Self> {
167 let path = Self::state_path(scan_path);
168 if !path.exists() {
169 return Ok(Self::default());
170 }
171 let content = fs::read_to_string(&path)?;
172 let v = json::parse(&content)
173 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
174 Self::from_json(&v).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
175 }
176
177 pub fn save(&self, scan_path: &Path) -> io::Result<()> {
178 let path = Self::state_path(scan_path);
179 if let Some(parent) = path.parent() {
180 fs::create_dir_all(parent)?;
181 }
182 let v = self.to_json();
183 fs::write(&path, json::to_pretty_string(&v))?;
184 Ok(())
185 }
186
187 pub fn reset(scan_path: &Path) -> io::Result<bool> {
189 let path = Self::state_path(scan_path);
190 if path.exists() {
191 fs::remove_file(&path)?;
192 Ok(true)
193 } else {
194 Ok(false)
195 }
196 }
197
198 pub fn is_decided(&self, session_key: &str) -> bool {
199 self.decisions.iter().any(|d| d.session_key == session_key)
200 }
201
202 fn from_json(v: &Value) -> Result<Self, String> {
203 let version = v
204 .get("version")
205 .and_then(|v| v.as_i64())
206 .ok_or_else(|| "missing 'version'".to_string())?;
207 if version != SCHEMA_VERSION {
208 return Err(format!("unsupported schema version {}", version));
209 }
210 let decisions_v = v
211 .get("decisions")
212 .and_then(|v| v.as_array())
213 .ok_or_else(|| "missing 'decisions' array".to_string())?;
214
215 let mut decisions = Vec::with_capacity(decisions_v.len());
216 for d in decisions_v {
217 let session_label = d
218 .get("session_label")
219 .and_then(|v| v.as_str())
220 .unwrap_or("")
221 .to_string();
222 let session_key = d
223 .get("session_key")
224 .and_then(|v| v.as_str())
225 .map(String::from)
226 .unwrap_or_else(|| session_label.clone());
228 let decision_str = d
229 .get("decision")
230 .and_then(|v| v.as_str())
231 .ok_or_else(|| "decision missing 'decision'".to_string())?;
232 let decision = DecisionKind::parse(decision_str)
233 .ok_or_else(|| format!("unknown decision kind: {}", decision_str))?;
234 let decided_at = d
235 .get("decided_at")
236 .and_then(|v| v.as_str())
237 .unwrap_or("")
238 .to_string();
239 let executed = d.get("executed").and_then(|v| v.as_bool()).unwrap_or(false);
240
241 let findings_v = d
242 .get("findings")
243 .and_then(|v| v.as_array())
244 .ok_or_else(|| "decision missing 'findings'".to_string())?;
245 let mut findings = Vec::with_capacity(findings_v.len());
246 for f in findings_v {
247 findings.push(DecisionFinding {
248 repo: f.get("repo").and_then(|v| v.as_str()).unwrap_or("").to_string(),
249 repo_path: f
250 .get("repo_path")
251 .and_then(|v| v.as_str())
252 .unwrap_or("")
253 .to_string(),
254 kind: f.get("kind").and_then(|v| v.as_str()).unwrap_or("").to_string(),
255 detail: f.get("detail").and_then(|v| v.as_str()).unwrap_or("").to_string(),
256 stash_sha: f.get("stash_sha").and_then(|v| v.as_str()).map(String::from),
257 });
258 }
259
260 decisions.push(Decision {
261 session_key,
262 session_label,
263 decision,
264 decided_at,
265 findings,
266 executed,
267 });
268 }
269 Ok(TriageState { decisions })
270 }
271
272 fn to_json(&self) -> Value {
273 let mut decisions = Vec::with_capacity(self.decisions.len());
274 for d in &self.decisions {
275 let mut findings_json = Vec::with_capacity(d.findings.len());
276 for f in &d.findings {
277 let mut entries = vec![
278 ("repo".to_string(), Value::String(f.repo.clone())),
279 ("repo_path".to_string(), Value::String(f.repo_path.clone())),
280 ("kind".to_string(), Value::String(f.kind.clone())),
281 ("detail".to_string(), Value::String(f.detail.clone())),
282 ];
283 if let Some(sha) = &f.stash_sha {
284 entries.push(("stash_sha".to_string(), Value::String(sha.clone())));
285 }
286 findings_json.push(Value::Object(entries));
287 }
288 let decision = Value::Object(vec![
289 ("session_key".to_string(), Value::String(d.session_key.clone())),
290 ("session_label".to_string(), Value::String(d.session_label.clone())),
291 ("decision".to_string(), Value::String(d.decision.as_str().to_string())),
292 ("decided_at".to_string(), Value::String(d.decided_at.clone())),
293 ("findings".to_string(), Value::Array(findings_json)),
294 ("executed".to_string(), Value::Bool(d.executed)),
295 ]);
296 decisions.push(decision);
297 }
298 Value::Object(vec![
299 ("version".to_string(), Value::Int(SCHEMA_VERSION)),
300 ("decisions".to_string(), Value::Array(decisions)),
301 ])
302 }
303}
304
305pub fn session_key_for(cluster: &Cluster) -> String {
309 format!("{}:{}", cluster.label, cluster.start_ts)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn round_trips_through_json() {
318 let state = TriageState {
319 decisions: vec![Decision {
320 session_key: "auth:1710000000".into(),
321 session_label: "auth".into(),
322 decision: DecisionKind::Archive,
323 decided_at: "2026-04-09T10:30:00Z".into(),
324 findings: vec![DecisionFinding {
325 repo: "myapp".into(),
326 repo_path: "/tmp/myapp".into(),
327 kind: "stale_branch".into(),
328 detail: "feature/auth-v2".into(),
329 stash_sha: None,
330 }],
331 executed: false,
332 }],
333 };
334 let s = json::to_pretty_string(&state.to_json());
335 let parsed = json::parse(&s).unwrap();
336 let reloaded = TriageState::from_json(&parsed).unwrap();
337 assert_eq!(reloaded.decisions.len(), 1);
338 assert_eq!(reloaded.decisions[0].session_key, "auth:1710000000");
339 assert_eq!(reloaded.decisions[0].decision, DecisionKind::Archive);
340 assert_eq!(reloaded.decisions[0].findings[0].detail, "feature/auth-v2");
341 }
342}