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 let key = format!("spec.index.entry:{}", entry.id);
118 crate::notify!(
119 warn,
120 location = Inline,
121 stack = dedupe(key, 60_000),
122 "spec entry index insert failed (id={}): {e}",
123 entry.id
124 );
125 }
126 Ok(entry)
127 }
128
129 pub async fn deviate(
130 &self,
131 feature: &str,
132 section: String,
133 delta: String,
134 reason: String,
135 ) -> Result<SpecDeviation, RuntimeError> {
136 let current = self.status(feature).await?;
137 if current.phase == "not_started" {
138 return Err(RuntimeError::ToolFailed(
139 "spec.deviate: feature has no entries yet, run spec.update first".into(),
140 ));
141 }
142 let dev = SpecDeviation {
143 id: MemoryId::now(),
144 feature: feature.into(),
145 section,
146 delta,
147 reason,
148 ts: chrono::Utc::now(),
149 };
150 super::append_jsonl(&self.deviations_path(feature), &dev).await?;
151 if let Some(idx) = &self.anchor_index
152 && let Err(e) = insert_deviation(idx, &dev)
153 {
154 let key = format!("spec.index.deviation:{}", dev.id);
155 crate::notify!(
156 warn,
157 location = Inline,
158 stack = dedupe(key, 60_000),
159 "spec deviation index insert failed (id={}): {e}",
160 dev.id
161 );
162 }
163 Ok(dev)
164 }
165
166 pub async fn deviations(&self, feature: &str) -> Result<Vec<SpecDeviation>, RuntimeError> {
167 super::read_jsonl(&self.deviations_path(feature)).await
168 }
169
170 pub async fn entries(&self, feature: &str) -> Result<Vec<SpecEntry>, RuntimeError> {
171 super::read_jsonl(&self.entries_path(feature)).await
172 }
173}
174
175fn insert_entry(index: &AnchorIndex, entry: &SpecEntry) -> rusqlite::Result<()> {
176 let conn = index.conn();
177 conn.execute(
178 "INSERT OR REPLACE INTO spec_entries (id, feature, phase, content, ts) VALUES (?, ?, ?, ?, ?)",
179 rusqlite::params![
180 entry.id.to_string(),
181 entry.feature,
182 entry.phase,
183 entry.content,
184 entry.ts.to_rfc3339(),
185 ],
186 )?;
187 let rowid = conn.last_insert_rowid();
188 conn.execute(
189 "INSERT OR REPLACE INTO spec_entries_fts (rowid, content) VALUES (?, ?)",
190 rusqlite::params![rowid, entry.content],
191 )?;
192 Ok(())
193}
194
195fn insert_deviation(index: &AnchorIndex, dev: &SpecDeviation) -> rusqlite::Result<()> {
196 let conn = index.conn();
197 conn.execute(
198 "INSERT OR REPLACE INTO spec_deviations (id, feature, section, delta, reason, ts) VALUES (?, ?, ?, ?, ?, ?)",
199 rusqlite::params![
200 dev.id.to_string(),
201 dev.feature,
202 dev.section,
203 dev.delta,
204 dev.reason,
205 dev.ts.to_rfc3339(),
206 ],
207 )?;
208 let rowid = conn.last_insert_rowid();
209 conn.execute(
210 "INSERT OR REPLACE INTO spec_deviations_fts (rowid, delta, reason) VALUES (?, ?, ?)",
211 rusqlite::params![rowid, dev.delta, dev.reason],
212 )?;
213 Ok(())
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217pub struct SpecStatus {
218 pub feature: String,
219 pub phase: String,
220 pub entry_count: usize,
221 pub deviation_count: usize,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct SpecMaterializeResult {
226 pub path: PathBuf,
227 pub revision: String,
228 pub changed: bool,
229}
230
231impl SpecStore {
232 pub async fn materialize(
233 &self,
234 feature: &str,
235 expected_revision: Option<&str>,
236 ) -> Result<SpecMaterializeResult, RuntimeError> {
237 let entries: Vec<SpecEntry> = super::read_jsonl(&self.entries_path(feature)).await?;
238 let deviations: Vec<SpecDeviation> =
239 super::read_jsonl(&self.deviations_path(feature)).await?;
240 let markdown = render_materialized_markdown(feature, &entries, &deviations);
241 let revision = revision_for(&markdown);
242 let path = self.feature_dir(feature).join("IMPLEMENTATION.md");
243 let existing = tokio::fs::read_to_string(&path).await.ok();
244 if let Some(expected) = expected_revision
245 && existing
246 .as_deref()
247 .is_some_and(|text| revision_for(text) != expected)
248 {
249 return Err(RuntimeError::ToolFailed(format!(
250 "spec.materialize: revision conflict (expected {expected})"
251 )));
252 }
253 if existing.as_deref() == Some(markdown.as_str()) {
254 return Ok(SpecMaterializeResult {
255 path,
256 revision,
257 changed: false,
258 });
259 }
260 tokio::fs::create_dir_all(self.feature_dir(feature))
261 .await
262 .map_err(|e| {
263 RuntimeError::ToolFailed(format!("spec.materialize: create feature dir: {e}"))
264 })?;
265 let tmp = path.with_extension("md.tmp");
266 tokio::fs::write(&tmp, markdown.as_bytes())
267 .await
268 .map_err(|e| {
269 RuntimeError::ToolFailed(format!("spec.materialize: write temp file: {e}"))
270 })?;
271 tokio::fs::rename(&tmp, &path).await.map_err(|e| {
272 RuntimeError::ToolFailed(format!("spec.materialize: replace file: {e}"))
273 })?;
274 Ok(SpecMaterializeResult {
275 path,
276 revision,
277 changed: true,
278 })
279 }
280}
281
282fn revision_for(text: &str) -> String {
283 use sha2::{Digest, Sha256};
284 let digest = Sha256::digest(text.as_bytes());
285 digest[..8]
286 .iter()
287 .map(|byte| format!("{byte:02x}"))
288 .collect()
289}
290
291fn render_materialized_markdown(
292 feature: &str,
293 entries: &[SpecEntry],
294 deviations: &[SpecDeviation],
295) -> String {
296 let mut out = format!("# Implementation — {feature}\n\n");
297 for entry in entries {
298 out.push_str(&format!(
299 "## {} — {}\n\n{}\n\n",
300 entry.phase, entry.id.0, entry.content
301 ));
302 }
303 if !deviations.is_empty() {
304 out.push_str("## Deviations\n\n");
305 for deviation in deviations {
306 out.push_str(&format!(
307 "- **{}**: {} — {}\n",
308 deviation.section, deviation.delta, deviation.reason
309 ));
310 }
311 }
312 out
313}
314
315fn latest_phase(entries: &[SpecEntry]) -> String {
316 let mut best = 0usize;
317 for e in entries {
318 if let Some(idx) = PHASES.iter().position(|p| *p == e.phase.as_str())
319 && idx + 1 > best
320 {
321 best = idx + 1;
322 }
323 }
324 if best == 0 {
325 "not_started".into()
326 } else {
327 PHASES[best - 1].into()
328 }
329}
330
331fn check_phase_transition(current: &str, next: &str) -> Result<(), String> {
332 let cur_idx = PHASES.iter().position(|p| *p == current).unwrap_or(0);
333 let next_idx = PHASES
334 .iter()
335 .position(|p| *p == next)
336 .ok_or_else(|| format!("unknown phase `{next}`"))?;
337 let is_first = current == "not_started";
338 if is_first && next != PHASES[0] {
339 return Err(format!(
340 "phase gate: must start with `{}`, not `{next}`",
341 PHASES[0]
342 ));
343 }
344 if !is_first && next_idx > cur_idx + 1 {
345 return Err(format!(
346 "phase gate: cannot skip from `{current}` to `{next}` (must go through {})",
347 PHASES[cur_idx + 1]
348 ));
349 }
350 Ok(())
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 async fn store() -> (SpecStore, tempfile::TempDir) {
358 let dir = tempfile::tempdir().unwrap();
359 let store = SpecStore::new(dir.path().to_path_buf());
360 (store, dir)
361 }
362
363 #[tokio::test]
364 async fn new_feature_status_is_not_started() {
365 let (s, _dir) = store().await;
366 let st = s.status("x").await.unwrap();
367 assert_eq!(st.phase, "not_started");
368 assert_eq!(st.entry_count, 0);
369 }
370
371 #[tokio::test]
372 async fn materialize_is_idempotent_and_detects_revision_conflicts() {
373 let (s, dir) = store().await;
374 s.update("x", "research", "notes".into()).await.unwrap();
375 let first = s.materialize("x", None).await.unwrap();
376 assert!(first.changed);
377 assert!(first.path.exists());
378 let second = s.materialize("x", Some(&first.revision)).await.unwrap();
379 assert!(!second.changed);
380 assert_eq!(first.revision, second.revision);
381 tokio::fs::write(&first.path, "user edit\n").await.unwrap();
382 let error = s.materialize("x", Some(&first.revision)).await.unwrap_err();
383 assert!(error.to_string().contains("revision conflict"));
384 assert_eq!(
385 tokio::fs::read_to_string(dir.path().join("x/IMPLEMENTATION.md"))
386 .await
387 .unwrap(),
388 "user edit\n"
389 );
390 }
391
392 #[tokio::test]
393 async fn update_advances_phase() {
394 let (s, _dir) = store().await;
395 s.update("x", "research", "notes".into()).await.unwrap();
396 assert_eq!(s.status("x").await.unwrap().phase, "research");
397 s.update("x", "design", "spec".into()).await.unwrap();
398 assert_eq!(s.status("x").await.unwrap().phase, "design");
399 }
400
401 #[tokio::test]
402 async fn phase_gate_rejects_skip() {
403 let (s, _dir) = store().await;
404 let err = s
405 .update("x", "implementation", "premature".into())
406 .await
407 .unwrap_err();
408 assert!(format!("{err}").contains("must start with `research`"));
409 }
410
411 #[tokio::test]
412 async fn phase_gate_rejects_backwards() {
413 let (s, _dir) = store().await;
414 s.update("x", "research", "r".into()).await.unwrap();
415 s.update("x", "design", "d".into()).await.unwrap();
416 let err = s
417 .update("x", "testing", "premature".into())
418 .await
419 .unwrap_err();
420 assert!(format!("{err}").contains("cannot skip"), "err: {err}");
421 }
422
423 #[tokio::test]
424 async fn deviate_requires_prior_entry() {
425 let (s, _dir) = store().await;
426 let err = s
427 .deviate("x", "sec".into(), "delta".into(), "why".into())
428 .await
429 .unwrap_err();
430 assert!(format!("{err}").contains("no entries"));
431 }
432
433 #[tokio::test]
434 async fn deviate_appends_to_deviations_file() {
435 let (s, _dir) = store().await;
436 s.update("x", "research", "r".into()).await.unwrap();
437 s.update("x", "design", "d".into()).await.unwrap();
438 s.deviate(
439 "x",
440 "data".into(),
441 "added field".into(),
442 "need array".into(),
443 )
444 .await
445 .unwrap();
446 s.deviate("x", "algo".into(), "changed loop".into(), "perf".into())
447 .await
448 .unwrap();
449 let devs = s.deviations("x").await.unwrap();
450 assert_eq!(devs.len(), 2);
451 assert_eq!(s.status("x").await.unwrap().deviation_count, 2);
452 }
453
454 #[tokio::test]
455 async fn update_and_deviate_dual_write_to_index() {
456 let dir = tempfile::tempdir().unwrap();
457 let index = std::sync::Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
458 let s = SpecStore::new(dir.path().to_path_buf()).with_index(index.clone());
459 s.update(
460 "feat_x",
461 "research",
462 "supercalifragilistic research notes".into(),
463 )
464 .await
465 .unwrap();
466 s.update(
467 "feat_x",
468 "design",
469 "midordermetamorphosis design notes".into(),
470 )
471 .await
472 .unwrap();
473 s.deviate(
474 "feat_x",
475 "sec".into(),
476 "hyperloquacious delta text".into(),
477 "quintessentialpolyphony reason text".into(),
478 )
479 .await
480 .unwrap();
481
482 let conn = index.conn();
483 let entry_count: i64 = conn
484 .query_row(
485 "SELECT COUNT(*) FROM spec_entries",
486 rusqlite::params![],
487 |r| r.get(0),
488 )
489 .unwrap();
490 let dev_count: i64 = conn
491 .query_row(
492 "SELECT COUNT(*) FROM spec_deviations",
493 rusqlite::params![],
494 |r| r.get(0),
495 )
496 .unwrap();
497 assert_eq!(entry_count, 2);
498 assert_eq!(dev_count, 1);
499
500 let entry_fts: i64 = conn
501 .query_row(
502 "SELECT COUNT(*) FROM spec_entries_fts WHERE spec_entries_fts MATCH ?",
503 rusqlite::params!["supercalifragilistic"],
504 |r| r.get(0),
505 )
506 .unwrap();
507 assert_eq!(entry_fts, 1);
508
509 let dev_fts: i64 = conn
510 .query_row(
511 "SELECT COUNT(*) FROM spec_deviations_fts WHERE spec_deviations_fts MATCH ?",
512 rusqlite::params!["quintessentialpolyphony"],
513 |r| r.get(0),
514 )
515 .unwrap();
516 assert_eq!(dev_fts, 1);
517 }
518
519 #[tokio::test]
520 async fn unknown_phase_rejected() {
521 let (s, _dir) = store().await;
522 let err = s.update("x", "brainstorm", "n".into()).await.unwrap_err();
523 assert!(format!("{err}").contains("unknown phase"));
524 }
525}