Skip to main content

atman_runtime/memory/
spec.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use super::MemoryId;
7use crate::error::RuntimeError;
8use crate::index::AnchorIndex;
9
10const PHASES: &[&str] = &[
11    "research",
12    "design",
13    "implementation",
14    "testing",
15    "retrospective",
16];
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct SpecEntry {
20    pub id: MemoryId,
21    pub feature: String,
22    pub phase: String,
23    pub content: String,
24    pub ts: chrono::DateTime<chrono::Utc>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct SpecDeviation {
29    pub id: MemoryId,
30    pub feature: String,
31    pub section: String,
32    pub delta: String,
33    pub reason: String,
34    pub ts: chrono::DateTime<chrono::Utc>,
35}
36
37#[derive(Clone)]
38pub struct SpecStore {
39    root: PathBuf,
40    anchor_index: Option<Arc<AnchorIndex>>,
41}
42
43impl SpecStore {
44    pub fn new(root: impl Into<PathBuf>) -> Self {
45        Self {
46            root: root.into(),
47            anchor_index: None,
48        }
49    }
50
51    pub fn with_index(mut self, index: Arc<AnchorIndex>) -> Self {
52        self.anchor_index = Some(index);
53        self
54    }
55
56    fn feature_dir(&self, feature: &str) -> PathBuf {
57        self.root.join(feature)
58    }
59
60    fn entries_path(&self, feature: &str) -> PathBuf {
61        self.feature_dir(feature).join("entries.jsonl")
62    }
63
64    fn deviations_path(&self, feature: &str) -> PathBuf {
65        self.feature_dir(feature).join("deviations.jsonl")
66    }
67
68    pub async fn status(&self, feature: &str) -> Result<SpecStatus, RuntimeError> {
69        let entries: Vec<SpecEntry> = super::read_jsonl(&self.entries_path(feature)).await?;
70        if entries.is_empty() {
71            return Ok(SpecStatus {
72                feature: feature.into(),
73                phase: "not_started".into(),
74                entry_count: 0,
75                deviation_count: 0,
76            });
77        }
78        let latest = latest_phase(&entries);
79        let dev_count = super::read_jsonl::<SpecDeviation>(&self.deviations_path(feature))
80            .await?
81            .len();
82        Ok(SpecStatus {
83            feature: feature.into(),
84            phase: latest,
85            entry_count: entries.len(),
86            deviation_count: dev_count,
87        })
88    }
89
90    pub async fn update(
91        &self,
92        feature: &str,
93        phase: &str,
94        content: String,
95    ) -> Result<SpecEntry, RuntimeError> {
96        if !PHASES.contains(&phase) {
97            return Err(RuntimeError::ToolFailed(format!(
98                "spec.update: unknown phase `{phase}` (want one of {})",
99                PHASES.join(", ")
100            )));
101        }
102        let current = self.status(feature).await?;
103        if let Err(msg) = check_phase_transition(&current.phase, phase) {
104            return Err(RuntimeError::ToolFailed(format!("spec.update: {msg}")));
105        }
106        let entry = SpecEntry {
107            id: MemoryId::now(),
108            feature: feature.into(),
109            phase: phase.into(),
110            content,
111            ts: chrono::Utc::now(),
112        };
113        super::append_jsonl(&self.entries_path(feature), &entry).await?;
114        if let Some(idx) = &self.anchor_index
115            && let Err(e) = insert_entry(idx, &entry)
116        {
117            eprintln!(
118                "[atman] spec entry index insert failed (id={}): {e}",
119                entry.id
120            );
121        }
122        Ok(entry)
123    }
124
125    pub async fn deviate(
126        &self,
127        feature: &str,
128        section: String,
129        delta: String,
130        reason: String,
131    ) -> Result<SpecDeviation, RuntimeError> {
132        let current = self.status(feature).await?;
133        if current.phase == "not_started" {
134            return Err(RuntimeError::ToolFailed(
135                "spec.deviate: feature has no entries yet, run spec.update first".into(),
136            ));
137        }
138        let dev = SpecDeviation {
139            id: MemoryId::now(),
140            feature: feature.into(),
141            section,
142            delta,
143            reason,
144            ts: chrono::Utc::now(),
145        };
146        super::append_jsonl(&self.deviations_path(feature), &dev).await?;
147        if let Some(idx) = &self.anchor_index
148            && let Err(e) = insert_deviation(idx, &dev)
149        {
150            eprintln!(
151                "[atman] spec deviation index insert failed (id={}): {e}",
152                dev.id
153            );
154        }
155        Ok(dev)
156    }
157
158    pub async fn deviations(&self, feature: &str) -> Result<Vec<SpecDeviation>, RuntimeError> {
159        super::read_jsonl(&self.deviations_path(feature)).await
160    }
161
162    pub async fn entries(&self, feature: &str) -> Result<Vec<SpecEntry>, RuntimeError> {
163        super::read_jsonl(&self.entries_path(feature)).await
164    }
165}
166
167fn insert_entry(index: &AnchorIndex, entry: &SpecEntry) -> rusqlite::Result<()> {
168    let conn = index.conn();
169    conn.execute(
170        "INSERT OR REPLACE INTO spec_entries (id, feature, phase, content, ts) VALUES (?, ?, ?, ?, ?)",
171        rusqlite::params![
172            entry.id.to_string(),
173            entry.feature,
174            entry.phase,
175            entry.content,
176            entry.ts.to_rfc3339(),
177        ],
178    )?;
179    let rowid = conn.last_insert_rowid();
180    conn.execute(
181        "INSERT OR REPLACE INTO spec_entries_fts (rowid, content) VALUES (?, ?)",
182        rusqlite::params![rowid, entry.content],
183    )?;
184    Ok(())
185}
186
187fn insert_deviation(index: &AnchorIndex, dev: &SpecDeviation) -> rusqlite::Result<()> {
188    let conn = index.conn();
189    conn.execute(
190        "INSERT OR REPLACE INTO spec_deviations (id, feature, section, delta, reason, ts) VALUES (?, ?, ?, ?, ?, ?)",
191        rusqlite::params![
192            dev.id.to_string(),
193            dev.feature,
194            dev.section,
195            dev.delta,
196            dev.reason,
197            dev.ts.to_rfc3339(),
198        ],
199    )?;
200    let rowid = conn.last_insert_rowid();
201    conn.execute(
202        "INSERT OR REPLACE INTO spec_deviations_fts (rowid, delta, reason) VALUES (?, ?, ?)",
203        rusqlite::params![rowid, dev.delta, dev.reason],
204    )?;
205    Ok(())
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
209pub struct SpecStatus {
210    pub feature: String,
211    pub phase: String,
212    pub entry_count: usize,
213    pub deviation_count: usize,
214}
215
216fn latest_phase(entries: &[SpecEntry]) -> String {
217    let mut best = 0usize;
218    for e in entries {
219        if let Some(idx) = PHASES.iter().position(|p| *p == e.phase.as_str())
220            && idx + 1 > best
221        {
222            best = idx + 1;
223        }
224    }
225    if best == 0 {
226        "not_started".into()
227    } else {
228        PHASES[best - 1].into()
229    }
230}
231
232fn check_phase_transition(current: &str, next: &str) -> Result<(), String> {
233    let cur_idx = PHASES.iter().position(|p| *p == current).unwrap_or(0);
234    let next_idx = PHASES
235        .iter()
236        .position(|p| *p == next)
237        .ok_or_else(|| format!("unknown phase `{next}`"))?;
238    let is_first = current == "not_started";
239    if is_first && next != PHASES[0] {
240        return Err(format!(
241            "phase gate: must start with `{}`, not `{next}`",
242            PHASES[0]
243        ));
244    }
245    if !is_first && next_idx > cur_idx + 1 {
246        return Err(format!(
247            "phase gate: cannot skip from `{current}` to `{next}` (must go through {})",
248            PHASES[cur_idx + 1]
249        ));
250    }
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    async fn store() -> (SpecStore, tempfile::TempDir) {
259        let dir = tempfile::tempdir().unwrap();
260        let store = SpecStore::new(dir.path().to_path_buf());
261        (store, dir)
262    }
263
264    #[tokio::test]
265    async fn new_feature_status_is_not_started() {
266        let (s, _dir) = store().await;
267        let st = s.status("x").await.unwrap();
268        assert_eq!(st.phase, "not_started");
269        assert_eq!(st.entry_count, 0);
270    }
271
272    #[tokio::test]
273    async fn update_advances_phase() {
274        let (s, _dir) = store().await;
275        s.update("x", "research", "notes".into()).await.unwrap();
276        assert_eq!(s.status("x").await.unwrap().phase, "research");
277        s.update("x", "design", "spec".into()).await.unwrap();
278        assert_eq!(s.status("x").await.unwrap().phase, "design");
279    }
280
281    #[tokio::test]
282    async fn phase_gate_rejects_skip() {
283        let (s, _dir) = store().await;
284        let err = s
285            .update("x", "implementation", "premature".into())
286            .await
287            .unwrap_err();
288        assert!(format!("{err}").contains("must start with `research`"));
289    }
290
291    #[tokio::test]
292    async fn phase_gate_rejects_backwards() {
293        let (s, _dir) = store().await;
294        s.update("x", "research", "r".into()).await.unwrap();
295        s.update("x", "design", "d".into()).await.unwrap();
296        let err = s
297            .update("x", "testing", "premature".into())
298            .await
299            .unwrap_err();
300        assert!(format!("{err}").contains("cannot skip"), "err: {err}");
301    }
302
303    #[tokio::test]
304    async fn deviate_requires_prior_entry() {
305        let (s, _dir) = store().await;
306        let err = s
307            .deviate("x", "sec".into(), "delta".into(), "why".into())
308            .await
309            .unwrap_err();
310        assert!(format!("{err}").contains("no entries"));
311    }
312
313    #[tokio::test]
314    async fn deviate_appends_to_deviations_file() {
315        let (s, _dir) = store().await;
316        s.update("x", "research", "r".into()).await.unwrap();
317        s.update("x", "design", "d".into()).await.unwrap();
318        s.deviate(
319            "x",
320            "data".into(),
321            "added field".into(),
322            "need array".into(),
323        )
324        .await
325        .unwrap();
326        s.deviate("x", "algo".into(), "changed loop".into(), "perf".into())
327            .await
328            .unwrap();
329        let devs = s.deviations("x").await.unwrap();
330        assert_eq!(devs.len(), 2);
331        assert_eq!(s.status("x").await.unwrap().deviation_count, 2);
332    }
333
334    #[tokio::test]
335    async fn update_and_deviate_dual_write_to_index() {
336        let dir = tempfile::tempdir().unwrap();
337        let index = std::sync::Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
338        let s = SpecStore::new(dir.path().to_path_buf()).with_index(index.clone());
339        s.update(
340            "feat_x",
341            "research",
342            "supercalifragilistic research notes".into(),
343        )
344        .await
345        .unwrap();
346        s.update(
347            "feat_x",
348            "design",
349            "midordermetamorphosis design notes".into(),
350        )
351        .await
352        .unwrap();
353        s.deviate(
354            "feat_x",
355            "sec".into(),
356            "hyperloquacious delta text".into(),
357            "quintessentialpolyphony reason text".into(),
358        )
359        .await
360        .unwrap();
361
362        let conn = index.conn();
363        let entry_count: i64 = conn
364            .query_row(
365                "SELECT COUNT(*) FROM spec_entries",
366                rusqlite::params![],
367                |r| r.get(0),
368            )
369            .unwrap();
370        let dev_count: i64 = conn
371            .query_row(
372                "SELECT COUNT(*) FROM spec_deviations",
373                rusqlite::params![],
374                |r| r.get(0),
375            )
376            .unwrap();
377        assert_eq!(entry_count, 2);
378        assert_eq!(dev_count, 1);
379
380        let entry_fts: i64 = conn
381            .query_row(
382                "SELECT COUNT(*) FROM spec_entries_fts WHERE spec_entries_fts MATCH ?",
383                rusqlite::params!["supercalifragilistic"],
384                |r| r.get(0),
385            )
386            .unwrap();
387        assert_eq!(entry_fts, 1);
388
389        let dev_fts: i64 = conn
390            .query_row(
391                "SELECT COUNT(*) FROM spec_deviations_fts WHERE spec_deviations_fts MATCH ?",
392                rusqlite::params!["quintessentialpolyphony"],
393                |r| r.get(0),
394            )
395            .unwrap();
396        assert_eq!(dev_fts, 1);
397    }
398
399    #[tokio::test]
400    async fn unknown_phase_rejected() {
401        let (s, _dir) = store().await;
402        let err = s.update("x", "brainstorm", "n".into()).await.unwrap_err();
403        assert!(format!("{err}").contains("unknown phase"));
404    }
405}