Skip to main content

gitwell/
triage_state.rs

1//! Persistent state for the triage subsystem.
2//!
3//! Decisions made during `gitwell triage` are written to
4//! `<scan_path>/.gitwell/triage.json`. On a re-run, sessions whose
5//! "session key" already appears in state are skipped so the user isn't
6//! asked the same question twice. The `gitwell execute` subcommand
7//! reads the same file to perform the queued actions.
8//!
9//! Schema (version 1):
10//!
11//! ```json
12//! {
13//!   "version": 1,
14//!   "decisions": [
15//!     {
16//!       "session_key": "auth:1710000000",
17//!       "session_label": "auth",
18//!       "decision": "archive",
19//!       "decided_at": "2026-04-09T10:30:00Z",
20//!       "findings": [
21//!         {
22//!           "repo": "myapp",
23//!           "repo_path": "/Users/me/code/myapp",
24//!           "kind": "stale_branch",
25//!           "detail": "feature/auth-v2"
26//!         }
27//!       ],
28//!       "executed": false
29//!     }
30//!   ]
31//! }
32//! ```
33
34use 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, // "stale_branch" | "stash" | "wip_commit" | "orphan_commit" | "dormant_repo"
82    pub detail: String,
83    /// Only populated for stashes — lets the executor find the stash by
84    /// commit SHA even if its index has shifted.
85    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    /// Stable identifier for the session — used to skip already-decided
142    /// sessions on re-run. Format: `{label}:{start_ts}`.
143    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    /// Load state. Returns an empty state if the file doesn't exist yet.
166    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    /// Delete the state file (used by `gitwell triage --reset`).
188    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                // Fall back to label for any legacy file.
227                .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
305/// Stable-ish identifier for a cluster across runs. If the same cluster
306/// reappears with the same label and earliest timestamp, we treat it as
307/// already decided.
308pub 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}