use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::cluster::Cluster;
use crate::json::{self, Value};
use crate::scanner::Finding;
pub const STATE_DIR: &str = ".gitwell";
pub const STATE_FILE: &str = "triage.json";
pub const ARCHIVE_SUBDIR: &str = "archives";
pub const SCHEMA_VERSION: i64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionKind {
Resume,
Archive,
Delete,
Skip,
}
impl DecisionKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Resume => "resume",
Self::Archive => "archive",
Self::Delete => "delete",
Self::Skip => "skip",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"resume" => Some(Self::Resume),
"archive" => Some(Self::Archive),
"delete" => Some(Self::Delete),
"skip" => Some(Self::Skip),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct DecisionFinding {
pub repo: String,
pub repo_path: String,
pub kind: String, pub detail: String,
pub stash_sha: Option<String>,
}
impl DecisionFinding {
pub fn from_finding(repo: &str, repo_path: &str, f: &Finding) -> Self {
match f {
Finding::StaleBranch { name, .. } => DecisionFinding {
repo: repo.to_string(),
repo_path: repo_path.to_string(),
kind: "stale_branch".into(),
detail: name.clone(),
stash_sha: None,
},
Finding::Stash {
index,
sha,
message,
..
} => DecisionFinding {
repo: repo.to_string(),
repo_path: repo_path.to_string(),
kind: "stash".into(),
detail: format!("{}: {}", index, message),
stash_sha: Some(sha.clone()),
},
Finding::WipCommit { sha, message, .. } => DecisionFinding {
repo: repo.to_string(),
repo_path: repo_path.to_string(),
kind: "wip_commit".into(),
detail: format!("{} {}", short_sha(sha), message),
stash_sha: None,
},
Finding::OrphanCommit { sha, message, .. } => DecisionFinding {
repo: repo.to_string(),
repo_path: repo_path.to_string(),
kind: "orphan_commit".into(),
detail: format!("{} {}", short_sha(sha), message),
stash_sha: None,
},
Finding::DormantRepo { path, .. } => DecisionFinding {
repo: repo.to_string(),
repo_path: repo_path.to_string(),
kind: "dormant_repo".into(),
detail: path.clone(),
stash_sha: None,
},
}
}
}
fn short_sha(sha: &str) -> &str {
&sha[..sha.len().min(8)]
}
#[derive(Debug, Clone)]
pub struct Decision {
pub session_key: String,
pub session_label: String,
pub decision: DecisionKind,
pub decided_at: String,
pub findings: Vec<DecisionFinding>,
pub executed: bool,
}
#[derive(Debug, Clone, Default)]
pub struct TriageState {
pub decisions: Vec<Decision>,
}
impl TriageState {
pub fn state_path(scan_path: &Path) -> PathBuf {
scan_path.join(STATE_DIR).join(STATE_FILE)
}
pub fn archive_dir(scan_path: &Path) -> PathBuf {
scan_path.join(STATE_DIR).join(ARCHIVE_SUBDIR)
}
pub fn load(scan_path: &Path) -> io::Result<Self> {
let path = Self::state_path(scan_path);
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&path)?;
let v = json::parse(&content)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Self::from_json(&v).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
pub fn save(&self, scan_path: &Path) -> io::Result<()> {
let path = Self::state_path(scan_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let v = self.to_json();
fs::write(&path, json::to_pretty_string(&v))?;
Ok(())
}
pub fn reset(scan_path: &Path) -> io::Result<bool> {
let path = Self::state_path(scan_path);
if path.exists() {
fs::remove_file(&path)?;
Ok(true)
} else {
Ok(false)
}
}
pub fn is_decided(&self, session_key: &str) -> bool {
self.decisions.iter().any(|d| d.session_key == session_key)
}
fn from_json(v: &Value) -> Result<Self, String> {
let version = v
.get("version")
.and_then(|v| v.as_i64())
.ok_or_else(|| "missing 'version'".to_string())?;
if version != SCHEMA_VERSION {
return Err(format!("unsupported schema version {}", version));
}
let decisions_v = v
.get("decisions")
.and_then(|v| v.as_array())
.ok_or_else(|| "missing 'decisions' array".to_string())?;
let mut decisions = Vec::with_capacity(decisions_v.len());
for d in decisions_v {
let session_label = d
.get("session_label")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let session_key = d
.get("session_key")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_else(|| session_label.clone());
let decision_str = d
.get("decision")
.and_then(|v| v.as_str())
.ok_or_else(|| "decision missing 'decision'".to_string())?;
let decision = DecisionKind::parse(decision_str)
.ok_or_else(|| format!("unknown decision kind: {}", decision_str))?;
let decided_at = d
.get("decided_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let executed = d.get("executed").and_then(|v| v.as_bool()).unwrap_or(false);
let findings_v = d
.get("findings")
.and_then(|v| v.as_array())
.ok_or_else(|| "decision missing 'findings'".to_string())?;
let mut findings = Vec::with_capacity(findings_v.len());
for f in findings_v {
findings.push(DecisionFinding {
repo: f.get("repo").and_then(|v| v.as_str()).unwrap_or("").to_string(),
repo_path: f
.get("repo_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
kind: f.get("kind").and_then(|v| v.as_str()).unwrap_or("").to_string(),
detail: f.get("detail").and_then(|v| v.as_str()).unwrap_or("").to_string(),
stash_sha: f.get("stash_sha").and_then(|v| v.as_str()).map(String::from),
});
}
decisions.push(Decision {
session_key,
session_label,
decision,
decided_at,
findings,
executed,
});
}
Ok(TriageState { decisions })
}
fn to_json(&self) -> Value {
let mut decisions = Vec::with_capacity(self.decisions.len());
for d in &self.decisions {
let mut findings_json = Vec::with_capacity(d.findings.len());
for f in &d.findings {
let mut entries = vec![
("repo".to_string(), Value::String(f.repo.clone())),
("repo_path".to_string(), Value::String(f.repo_path.clone())),
("kind".to_string(), Value::String(f.kind.clone())),
("detail".to_string(), Value::String(f.detail.clone())),
];
if let Some(sha) = &f.stash_sha {
entries.push(("stash_sha".to_string(), Value::String(sha.clone())));
}
findings_json.push(Value::Object(entries));
}
let decision = Value::Object(vec![
("session_key".to_string(), Value::String(d.session_key.clone())),
("session_label".to_string(), Value::String(d.session_label.clone())),
("decision".to_string(), Value::String(d.decision.as_str().to_string())),
("decided_at".to_string(), Value::String(d.decided_at.clone())),
("findings".to_string(), Value::Array(findings_json)),
("executed".to_string(), Value::Bool(d.executed)),
]);
decisions.push(decision);
}
Value::Object(vec![
("version".to_string(), Value::Int(SCHEMA_VERSION)),
("decisions".to_string(), Value::Array(decisions)),
])
}
}
pub fn session_key_for(cluster: &Cluster) -> String {
format!("{}:{}", cluster.label, cluster.start_ts)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_json() {
let state = TriageState {
decisions: vec![Decision {
session_key: "auth:1710000000".into(),
session_label: "auth".into(),
decision: DecisionKind::Archive,
decided_at: "2026-04-09T10:30:00Z".into(),
findings: vec![DecisionFinding {
repo: "myapp".into(),
repo_path: "/tmp/myapp".into(),
kind: "stale_branch".into(),
detail: "feature/auth-v2".into(),
stash_sha: None,
}],
executed: false,
}],
};
let s = json::to_pretty_string(&state.to_json());
let parsed = json::parse(&s).unwrap();
let reloaded = TriageState::from_json(&parsed).unwrap();
assert_eq!(reloaded.decisions.len(), 1);
assert_eq!(reloaded.decisions[0].session_key, "auth:1710000000");
assert_eq!(reloaded.decisions[0].decision, DecisionKind::Archive);
assert_eq!(reloaded.decisions[0].findings[0].detail, "feature/auth-v2");
}
}