atomcode_core/setup/
state.rs1use 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 _ => 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
50pub 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
67fn 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 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}