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(¤t.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}