Skip to main content

atomcode_core/setup/
state.rs

1//! setup-state.json — per-project sentinel + accepted/declined log.
2//! Follows sync_marker.rs philosophy: failed parse / unknown future version → None, no error.
3
4use crate::setup::fs_atomic::atomic_write;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::path::{Path, PathBuf};
9
10pub const STATE_FILENAME: &str = "setup-state.json";
11pub const STATE_DIR: &str = ".atomcode";
12pub const CURRENT_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct RecIdRef {
16    pub kind: String,
17    pub slug: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SetupState {
22    pub schema_version: u32,
23    pub signals_hash: String,
24    pub completed_at: DateTime<Utc>,
25    pub atomcode_version: String,
26    pub accepted: Vec<RecIdRef>,
27}
28
29pub fn state_path(project_root: &Path) -> PathBuf {
30    project_root.join(STATE_DIR).join(STATE_FILENAME)
31}
32
33pub fn load_setup_state(project_root: &Path) -> Option<SetupState> {
34    let path = state_path(project_root);
35    let raw = std::fs::read_to_string(&path).ok()?;
36    let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
37    match v.get("schema_version").and_then(|x| x.as_u64()) {
38        Some(n) if n as u32 == CURRENT_SCHEMA_VERSION => serde_json::from_value(v).ok(),
39        // Future or unknown — treat as absent.
40        _ => None,
41    }
42}
43
44pub fn save_setup_state(project_root: &Path, state: &SetupState) -> anyhow::Result<()> {
45    let path = state_path(project_root);
46    let json = serde_json::to_vec_pretty(state)?;
47    atomic_write(&path, &json, 0o644)
48}
49
50/// SHA256 of canonical-normalized content of marker files. CRLF→LF, strip BOM.
51/// Order: sort marker paths lexicographically before hashing.
52pub fn compute_signals_hash(marker_paths: &[PathBuf]) -> String {
53    let mut sorted: Vec<&PathBuf> = marker_paths.iter().collect();
54    sorted.sort();
55    let mut h = Sha256::new();
56    for path in sorted {
57        let bytes = std::fs::read(path).unwrap_or_default();
58        let normalized = normalize_text(&bytes);
59        h.update(path.to_string_lossy().as_bytes());
60        h.update(b"\0");
61        h.update(&normalized);
62        h.update(b"\0");
63    }
64    format!("sha256:{:x}", h.finalize())
65}
66
67/// Canonicalize text bytes for hashing: strip UTF-8 BOM, CRLF→LF.
68fn normalize_text(bytes: &[u8]) -> Vec<u8> {
69    let trimmed = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
70        &bytes[3..]
71    } else {
72        bytes
73    };
74    let mut out = Vec::with_capacity(trimmed.len());
75    let mut i = 0;
76    while i < trimmed.len() {
77        if i + 1 < trimmed.len() && trimmed[i] == b'\r' && trimmed[i + 1] == b'\n' {
78            out.push(b'\n');
79            i += 2;
80        } else {
81            out.push(trimmed[i]);
82            i += 1;
83        }
84    }
85    out
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn load_returns_none_when_state_absent() {
94        let dir = tempfile::tempdir().unwrap();
95        assert!(load_setup_state(dir.path()).is_none());
96    }
97
98    #[test]
99    fn save_then_load_roundtrip() {
100        let dir = tempfile::tempdir().unwrap();
101        let state = SetupState {
102            schema_version: CURRENT_SCHEMA_VERSION,
103            signals_hash: "sha256:abc".into(),
104            completed_at: Utc::now(),
105            atomcode_version: "test".into(),
106            accepted: vec![RecIdRef {
107                kind: "skill".into(),
108                slug: "rust-best-practices".into(),
109            }],
110        };
111        save_setup_state(dir.path(), &state).unwrap();
112        let loaded = load_setup_state(dir.path()).expect("loaded");
113        assert_eq!(loaded.signals_hash, "sha256:abc");
114        assert_eq!(loaded.accepted.len(), 1);
115    }
116
117    #[test]
118    fn load_returns_none_for_future_schema_version() {
119        let dir = tempfile::tempdir().unwrap();
120        std::fs::create_dir_all(dir.path().join(".atomcode")).unwrap();
121        let path = state_path(dir.path());
122        std::fs::write(&path, r#"{"schema_version": 999, "junk": "future"}"#).unwrap();
123        // Per sync_marker philosophy: future version → None, no error.
124        assert!(load_setup_state(dir.path()).is_none());
125    }
126
127    #[test]
128    fn load_returns_none_for_corrupt_json() {
129        let dir = tempfile::tempdir().unwrap();
130        std::fs::create_dir_all(dir.path().join(".atomcode")).unwrap();
131        std::fs::write(&state_path(dir.path()), "{not json").unwrap();
132        assert!(load_setup_state(dir.path()).is_none());
133    }
134
135    #[test]
136    fn signals_hash_normalizes_crlf_to_lf() {
137        let dir = tempfile::tempdir().unwrap();
138        let f = dir.path().join("marker.txt");
139        std::fs::write(&f, b"line1\nline2\n").unwrap();
140        let lf_hash = compute_signals_hash(&[f.clone()]);
141        std::fs::write(&f, b"line1\r\nline2\r\n").unwrap();
142        let crlf_hash = compute_signals_hash(&[f]);
143        assert_eq!(lf_hash, crlf_hash);
144    }
145
146    #[test]
147    fn signals_hash_strips_bom() {
148        let dir = tempfile::tempdir().unwrap();
149        let f = dir.path().join("marker.txt");
150        std::fs::write(&f, b"hello").unwrap();
151        let nobom_hash = compute_signals_hash(&[f.clone()]);
152        std::fs::write(&f, b"\xEF\xBB\xBFhello").unwrap();
153        let bom_hash = compute_signals_hash(&[f]);
154        assert_eq!(nobom_hash, bom_hash);
155    }
156}