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(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct SpecReview {
39 pub feature: String,
40 pub design_revision: String,
41 pub approved: bool,
42 pub ts: chrono::DateTime<chrono::Utc>,
43}
44
45#[derive(Clone)]
46pub struct SpecStore {
47 root: PathBuf,
48 anchor_index: Option<Arc<AnchorIndex>>,
49}
50
51impl SpecStore {
52 pub fn new(root: impl Into<PathBuf>) -> Self {
53 Self {
54 root: root.into(),
55 anchor_index: None,
56 }
57 }
58
59 pub fn with_index(mut self, index: Arc<AnchorIndex>) -> Self {
60 self.anchor_index = Some(index);
61 self
62 }
63
64 fn feature_dir(&self, feature: &str) -> PathBuf {
65 self.root.join(feature)
66 }
67
68 fn entries_path(&self, feature: &str) -> PathBuf {
69 self.feature_dir(feature).join("entries.jsonl")
70 }
71
72 fn deviations_path(&self, feature: &str) -> PathBuf {
73 self.feature_dir(feature).join("deviations.jsonl")
74 }
75
76 fn reviews_path(&self, feature: &str) -> PathBuf {
77 self.feature_dir(feature).join("reviews.jsonl")
78 }
79
80 pub async fn design_revision(&self, feature: &str) -> Result<Option<String>, RuntimeError> {
81 let entries = self.entries(feature).await?;
82 if !entries.iter().any(|entry| entry.phase == "design") {
83 return Ok(None);
84 }
85 Ok(Some(revision_for(&render_phase_markdown(
86 feature, "design", &entries,
87 ))))
88 }
89
90 pub async fn phase_markdown(&self, feature: &str, phase: &str) -> Result<String, RuntimeError> {
91 validate_feature(feature)?;
92 if !PHASES.contains(&phase) {
93 return Err(RuntimeError::ToolFailed(format!(
94 "spec.read: unknown phase `{phase}`"
95 )));
96 }
97 let entries = self.entries(feature).await?;
98 Ok(render_phase_markdown(feature, phase, &entries))
99 }
100
101 pub async fn phase_revision(
102 &self,
103 feature: &str,
104 phase: &str,
105 ) -> Result<Option<String>, RuntimeError> {
106 let entries = self.entries(feature).await?;
107 if !entries.iter().any(|entry| entry.phase == phase) {
108 return Ok(None);
109 }
110 Ok(Some(revision_for(
111 &self.phase_markdown(feature, phase).await?,
112 )))
113 }
114
115 pub async fn materialized_revision(
116 &self,
117 feature: &str,
118 phase: &str,
119 ) -> Result<String, RuntimeError> {
120 validate_feature(feature)?;
121 if !PHASES.contains(&phase) {
122 return Err(RuntimeError::ToolFailed(format!(
123 "spec.read: unknown phase `{phase}`"
124 )));
125 }
126 let filename = if phase == "implementation" {
127 "IMPLEMENTATION.md".to_owned()
128 } else {
129 format!("{phase}.md")
130 };
131 let path = self.feature_dir(feature).join(filename);
132 match tokio::fs::read_to_string(path).await {
133 Ok(content) => Ok(revision_for(&content)),
134 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
135 Err(error) => Err(RuntimeError::ToolFailed(format!(
136 "spec.read: materialized file: {error}"
137 ))),
138 }
139 }
140
141 pub async fn review(
142 &self,
143 feature: &str,
144 design_revision: &str,
145 approved: bool,
146 ) -> Result<SpecReview, RuntimeError> {
147 if self.design_revision(feature).await?.as_deref() != Some(design_revision) {
148 return Err(RuntimeError::ToolFailed(
149 "spec.review: design revision is missing or stale".into(),
150 ));
151 }
152 let file_revision = self.materialized_revision(feature, "design").await?;
153 if file_revision != design_revision {
154 return Err(RuntimeError::ToolFailed(
155 "spec.review: materialized design differs from the current revision".into(),
156 ));
157 }
158 let review = SpecReview {
159 feature: feature.into(),
160 design_revision: design_revision.into(),
161 approved,
162 ts: chrono::Utc::now(),
163 };
164 super::append_jsonl(&self.reviews_path(feature), &review).await?;
165 Ok(review)
166 }
167
168 pub async fn status(&self, feature: &str) -> Result<SpecStatus, RuntimeError> {
169 validate_feature(feature)?;
170 let entries: Vec<SpecEntry> = super::read_jsonl(&self.entries_path(feature)).await?;
171 if entries.is_empty() {
172 return Ok(SpecStatus {
173 feature: feature.into(),
174 phase: "not_started".into(),
175 entry_count: 0,
176 deviation_count: 0,
177 design_revision: None,
178 approved_design_revision: None,
179 });
180 }
181 let latest = latest_phase(&entries);
182 let dev_count = super::read_jsonl::<SpecDeviation>(&self.deviations_path(feature))
183 .await?
184 .len();
185 let design_revision = entries
186 .iter()
187 .any(|entry| entry.phase == "design")
188 .then(|| revision_for(&render_phase_markdown(feature, "design", &entries)));
189 let reviews: Vec<SpecReview> = super::read_jsonl(&self.reviews_path(feature)).await?;
190 let materialized_revision = self.materialized_revision(feature, "design").await?;
191 let approved_design_revision =
192 reviews
193 .last()
194 .filter(|review| review.approved)
195 .and_then(|review| {
196 (design_revision.as_deref() == Some(review.design_revision.as_str())
197 && materialized_revision == review.design_revision)
198 .then(|| review.design_revision.clone())
199 });
200 Ok(SpecStatus {
201 feature: feature.into(),
202 phase: latest,
203 entry_count: entries.len(),
204 deviation_count: dev_count,
205 design_revision,
206 approved_design_revision,
207 })
208 }
209
210 pub async fn update(
211 &self,
212 feature: &str,
213 phase: &str,
214 content: String,
215 ) -> Result<SpecEntry, RuntimeError> {
216 if !PHASES.contains(&phase) {
217 return Err(RuntimeError::ToolFailed(format!(
218 "spec.update: unknown phase `{phase}` (want one of {})",
219 PHASES.join(", ")
220 )));
221 }
222 let current = self.status(feature).await?;
223 if let Err(msg) = check_phase_transition(¤t.phase, phase) {
224 return Err(RuntimeError::ToolFailed(format!("spec.update: {msg}")));
225 }
226 let entry = SpecEntry {
227 id: MemoryId::now(),
228 feature: feature.into(),
229 phase: phase.into(),
230 content,
231 ts: chrono::Utc::now(),
232 };
233 super::append_jsonl(&self.entries_path(feature), &entry).await?;
234 if let Some(idx) = &self.anchor_index
235 && let Err(e) = insert_entry(idx, &entry)
236 {
237 let key = format!("spec.index.entry:{}", entry.id);
238 crate::notify!(
239 warn,
240 location = Inline,
241 stack = dedupe(key, 60_000),
242 "spec entry index insert failed (id={}): {e}",
243 entry.id
244 );
245 }
246 Ok(entry)
247 }
248
249 pub async fn deviate(
250 &self,
251 feature: &str,
252 section: String,
253 delta: String,
254 reason: String,
255 ) -> Result<SpecDeviation, RuntimeError> {
256 let current = self.status(feature).await?;
257 if current.phase == "not_started" {
258 return Err(RuntimeError::ToolFailed(
259 "spec.deviate: feature has no entries yet, run spec.update first".into(),
260 ));
261 }
262 let dev = SpecDeviation {
263 id: MemoryId::now(),
264 feature: feature.into(),
265 section,
266 delta,
267 reason,
268 ts: chrono::Utc::now(),
269 };
270 super::append_jsonl(&self.deviations_path(feature), &dev).await?;
271 if let Some(idx) = &self.anchor_index
272 && let Err(e) = insert_deviation(idx, &dev)
273 {
274 let key = format!("spec.index.deviation:{}", dev.id);
275 crate::notify!(
276 warn,
277 location = Inline,
278 stack = dedupe(key, 60_000),
279 "spec deviation index insert failed (id={}): {e}",
280 dev.id
281 );
282 }
283 Ok(dev)
284 }
285
286 pub async fn deviations(&self, feature: &str) -> Result<Vec<SpecDeviation>, RuntimeError> {
287 validate_feature(feature)?;
288 super::read_jsonl(&self.deviations_path(feature)).await
289 }
290
291 pub async fn entries(&self, feature: &str) -> Result<Vec<SpecEntry>, RuntimeError> {
292 validate_feature(feature)?;
293 super::read_jsonl(&self.entries_path(feature)).await
294 }
295}
296
297fn insert_entry(index: &AnchorIndex, entry: &SpecEntry) -> rusqlite::Result<()> {
298 let conn = index.conn();
299 conn.execute(
300 "INSERT OR REPLACE INTO spec_entries (id, feature, phase, content, ts) VALUES (?, ?, ?, ?, ?)",
301 rusqlite::params![
302 entry.id.to_string(),
303 entry.feature,
304 entry.phase,
305 entry.content,
306 entry.ts.to_rfc3339(),
307 ],
308 )?;
309 let rowid = conn.last_insert_rowid();
310 conn.execute(
311 "INSERT OR REPLACE INTO spec_entries_fts (rowid, content) VALUES (?, ?)",
312 rusqlite::params![rowid, entry.content],
313 )?;
314 Ok(())
315}
316
317fn insert_deviation(index: &AnchorIndex, dev: &SpecDeviation) -> rusqlite::Result<()> {
318 let conn = index.conn();
319 conn.execute(
320 "INSERT OR REPLACE INTO spec_deviations (id, feature, section, delta, reason, ts) VALUES (?, ?, ?, ?, ?, ?)",
321 rusqlite::params![
322 dev.id.to_string(),
323 dev.feature,
324 dev.section,
325 dev.delta,
326 dev.reason,
327 dev.ts.to_rfc3339(),
328 ],
329 )?;
330 let rowid = conn.last_insert_rowid();
331 conn.execute(
332 "INSERT OR REPLACE INTO spec_deviations_fts (rowid, delta, reason) VALUES (?, ?, ?)",
333 rusqlite::params![rowid, dev.delta, dev.reason],
334 )?;
335 Ok(())
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
339pub struct SpecStatus {
340 pub feature: String,
341 pub phase: String,
342 pub entry_count: usize,
343 pub deviation_count: usize,
344 pub design_revision: Option<String>,
345 pub approved_design_revision: Option<String>,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct SpecMaterializeResult {
350 pub path: PathBuf,
351 pub revision: String,
352 pub changed: bool,
353}
354
355impl SpecStore {
356 pub async fn materialize(
357 &self,
358 feature: &str,
359 expected_revision: Option<&str>,
360 ) -> Result<SpecMaterializeResult, RuntimeError> {
361 self.materialize_phase(feature, None, expected_revision)
362 .await
363 }
364
365 pub async fn materialize_phase(
366 &self,
367 feature: &str,
368 phase: Option<&str>,
369 expected_revision: Option<&str>,
370 ) -> Result<SpecMaterializeResult, RuntimeError> {
371 validate_feature(feature)?;
372 if let Some(phase) = phase
373 && !PHASES.contains(&phase)
374 {
375 return Err(RuntimeError::ToolFailed(format!(
376 "spec.materialize: unknown phase `{phase}`"
377 )));
378 }
379 let entries: Vec<SpecEntry> = super::read_jsonl(&self.entries_path(feature)).await?;
380 let deviations: Vec<SpecDeviation> =
381 super::read_jsonl(&self.deviations_path(feature)).await?;
382 let markdown = match phase {
383 Some(phase) if phase != "implementation" => {
384 render_phase_markdown(feature, phase, &entries)
385 }
386 _ => render_materialized_markdown(feature, &entries, &deviations),
387 };
388 let revision = revision_for(&markdown);
389 let filename = match phase {
390 Some(phase) if phase != "implementation" => format!("{phase}.md"),
391 _ => "IMPLEMENTATION.md".into(),
392 };
393 let path = self.feature_dir(feature).join(filename);
394 let existing = tokio::fs::read_to_string(&path).await.ok();
395 if let Some(expected) = expected_revision
396 && existing
397 .as_deref()
398 .is_some_and(|text| revision_for(text) != expected)
399 {
400 return Err(RuntimeError::ToolFailed(format!(
401 "spec.materialize: revision conflict (expected {expected})"
402 )));
403 }
404 if existing.as_deref() == Some(markdown.as_str()) {
405 return Ok(SpecMaterializeResult {
406 path,
407 revision,
408 changed: false,
409 });
410 }
411 tokio::fs::create_dir_all(self.feature_dir(feature))
412 .await
413 .map_err(|e| {
414 RuntimeError::ToolFailed(format!("spec.materialize: create feature dir: {e}"))
415 })?;
416 let tmp = path.with_extension("md.tmp");
417 tokio::fs::write(&tmp, markdown.as_bytes())
418 .await
419 .map_err(|e| {
420 RuntimeError::ToolFailed(format!("spec.materialize: write temp file: {e}"))
421 })?;
422 tokio::fs::rename(&tmp, &path).await.map_err(|e| {
423 RuntimeError::ToolFailed(format!("spec.materialize: replace file: {e}"))
424 })?;
425 Ok(SpecMaterializeResult {
426 path,
427 revision,
428 changed: true,
429 })
430 }
431}
432
433fn validate_feature(feature: &str) -> Result<(), RuntimeError> {
434 if feature.is_empty()
435 || feature == "."
436 || feature == ".."
437 || feature.contains('/')
438 || feature.contains('\\')
439 || feature.chars().any(char::is_control)
440 {
441 return Err(RuntimeError::ToolFailed(
442 "spec feature must be a single directory name".into(),
443 ));
444 }
445 Ok(())
446}
447
448fn render_phase_markdown(feature: &str, phase: &str, entries: &[SpecEntry]) -> String {
449 let mut out = format!("# {phase} — {feature}\n\n");
450 for entry in entries.iter().filter(|entry| entry.phase == phase) {
451 out.push_str(&format!("## {}\n\n{}\n\n", entry.id.0, entry.content));
452 }
453 out
454}
455
456fn revision_for(text: &str) -> String {
457 use sha2::{Digest, Sha256};
458 let digest = Sha256::digest(text.as_bytes());
459 digest[..8]
460 .iter()
461 .map(|byte| format!("{byte:02x}"))
462 .collect()
463}
464
465fn render_materialized_markdown(
466 feature: &str,
467 entries: &[SpecEntry],
468 deviations: &[SpecDeviation],
469) -> String {
470 let mut out = format!("# Implementation — {feature}\n\n");
471 for entry in entries {
472 out.push_str(&format!(
473 "## {} — {}\n\n{}\n\n",
474 entry.phase, entry.id.0, entry.content
475 ));
476 }
477 if !deviations.is_empty() {
478 out.push_str("## Deviations\n\n");
479 for deviation in deviations {
480 out.push_str(&format!(
481 "- **{}**: {} — {}\n",
482 deviation.section, deviation.delta, deviation.reason
483 ));
484 }
485 }
486 out
487}
488
489fn latest_phase(entries: &[SpecEntry]) -> String {
490 let mut best = 0usize;
491 for e in entries {
492 if let Some(idx) = PHASES.iter().position(|p| *p == e.phase.as_str())
493 && idx + 1 > best
494 {
495 best = idx + 1;
496 }
497 }
498 if best == 0 {
499 "not_started".into()
500 } else {
501 PHASES[best - 1].into()
502 }
503}
504
505fn check_phase_transition(current: &str, next: &str) -> Result<(), String> {
506 let cur_idx = PHASES.iter().position(|p| *p == current).unwrap_or(0);
507 let next_idx = PHASES
508 .iter()
509 .position(|p| *p == next)
510 .ok_or_else(|| format!("unknown phase `{next}`"))?;
511 let is_first = current == "not_started";
512 if is_first && next != PHASES[0] {
513 return Err(format!(
514 "phase gate: must start with `{}`, not `{next}`",
515 PHASES[0]
516 ));
517 }
518 if !is_first && next_idx > cur_idx + 1 {
519 return Err(format!(
520 "phase gate: cannot skip from `{current}` to `{next}` (must go through {})",
521 PHASES[cur_idx + 1]
522 ));
523 }
524 Ok(())
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 async fn store() -> (SpecStore, tempfile::TempDir) {
532 let dir = tempfile::tempdir().unwrap();
533 let store = SpecStore::new(dir.path().to_path_buf());
534 (store, dir)
535 }
536
537 #[tokio::test]
538 async fn new_feature_status_is_not_started() {
539 let (s, _dir) = store().await;
540 let st = s.status("x").await.unwrap();
541 assert_eq!(st.phase, "not_started");
542 assert_eq!(st.entry_count, 0);
543 }
544
545 #[tokio::test]
546 async fn materialize_is_idempotent_and_detects_revision_conflicts() {
547 let (s, dir) = store().await;
548 s.update("x", "research", "notes".into()).await.unwrap();
549 let first = s.materialize("x", None).await.unwrap();
550 assert!(first.changed);
551 assert!(first.path.exists());
552 let second = s.materialize("x", Some(&first.revision)).await.unwrap();
553 assert!(!second.changed);
554 assert_eq!(first.revision, second.revision);
555 tokio::fs::write(&first.path, "user edit\n").await.unwrap();
556 let error = s.materialize("x", Some(&first.revision)).await.unwrap_err();
557 assert!(error.to_string().contains("revision conflict"));
558 assert_eq!(
559 tokio::fs::read_to_string(dir.path().join("x/IMPLEMENTATION.md"))
560 .await
561 .unwrap(),
562 "user edit\n"
563 );
564 }
565
566 #[tokio::test]
567 async fn materialize_phase_keeps_phase_documents_in_the_store() {
568 let (s, dir) = store().await;
569 s.update("x", "research", "Observed behavior".into())
570 .await
571 .unwrap();
572 s.update("x", "design", "Chosen design".into())
573 .await
574 .unwrap();
575 let research = s
576 .materialize_phase("x", Some("research"), None)
577 .await
578 .unwrap();
579 let design = s
580 .materialize_phase("x", Some("design"), None)
581 .await
582 .unwrap();
583 assert_eq!(research.path, dir.path().join("x/research.md"));
584 assert_eq!(design.path, dir.path().join("x/design.md"));
585 assert!(
586 tokio::fs::read_to_string(&research.path)
587 .await
588 .unwrap()
589 .contains("Observed behavior")
590 );
591 assert!(
592 !tokio::fs::read_to_string(&research.path)
593 .await
594 .unwrap()
595 .contains("Chosen design")
596 );
597 assert!(
598 tokio::fs::read_to_string(&design.path)
599 .await
600 .unwrap()
601 .contains("Chosen design")
602 );
603 assert_eq!(
604 s.materialize_phase("x", Some("implementation"), None)
605 .await
606 .unwrap()
607 .path,
608 dir.path().join("x/IMPLEMENTATION.md")
609 );
610 }
611
612 #[tokio::test]
613 async fn feature_cannot_escape_spec_root() {
614 let (s, _dir) = store().await;
615 assert!(s.status("../outside").await.is_err());
616 assert!(s.materialize("..", None).await.is_err());
617 }
618
619 #[tokio::test]
620 async fn update_advances_phase() {
621 let (s, _dir) = store().await;
622 s.update("x", "research", "notes".into()).await.unwrap();
623 assert_eq!(s.status("x").await.unwrap().phase, "research");
624 s.update("x", "design", "spec".into()).await.unwrap();
625 assert_eq!(s.status("x").await.unwrap().phase, "design");
626 }
627
628 #[tokio::test]
629 async fn approval_tracks_only_the_current_design_revision() {
630 let (s, dir) = store().await;
631 s.update("x", "research", "observations".into())
632 .await
633 .unwrap();
634 s.update("x", "design", "first design".into())
635 .await
636 .unwrap();
637 s.materialize_phase("x", Some("design"), Some(""))
638 .await
639 .unwrap();
640 let first = s.status("x").await.unwrap().design_revision.unwrap();
641 assert!(s.review("x", "stale", true).await.is_err());
642 s.review("x", &first, true).await.unwrap();
643 assert_eq!(
644 s.status("x").await.unwrap().approved_design_revision,
645 Some(first.clone())
646 );
647 s.update("x", "design", "changed design".into())
648 .await
649 .unwrap();
650 assert_eq!(s.status("x").await.unwrap().approved_design_revision, None);
651 s.materialize_phase("x", Some("design"), Some(&first))
652 .await
653 .unwrap();
654 let second = s.status("x").await.unwrap().design_revision.unwrap();
655 s.review("x", &second, false).await.unwrap();
656 assert_eq!(s.status("x").await.unwrap().approved_design_revision, None);
657 s.review("x", &second, true).await.unwrap();
658 assert_eq!(
659 s.status("x").await.unwrap().approved_design_revision,
660 Some(second)
661 );
662 tokio::fs::write(dir.path().join("x/design.md"), "external edit")
663 .await
664 .unwrap();
665 assert_eq!(s.status("x").await.unwrap().approved_design_revision, None);
666 }
667
668 #[tokio::test]
669 async fn phase_gate_rejects_skip() {
670 let (s, _dir) = store().await;
671 let err = s
672 .update("x", "implementation", "premature".into())
673 .await
674 .unwrap_err();
675 assert!(format!("{err}").contains("must start with `research`"));
676 }
677
678 #[tokio::test]
679 async fn phase_gate_rejects_backwards() {
680 let (s, _dir) = store().await;
681 s.update("x", "research", "r".into()).await.unwrap();
682 s.update("x", "design", "d".into()).await.unwrap();
683 let err = s
684 .update("x", "testing", "premature".into())
685 .await
686 .unwrap_err();
687 assert!(format!("{err}").contains("cannot skip"), "err: {err}");
688 }
689
690 #[tokio::test]
691 async fn deviate_requires_prior_entry() {
692 let (s, _dir) = store().await;
693 let err = s
694 .deviate("x", "sec".into(), "delta".into(), "why".into())
695 .await
696 .unwrap_err();
697 assert!(format!("{err}").contains("no entries"));
698 }
699
700 #[tokio::test]
701 async fn deviate_appends_to_deviations_file() {
702 let (s, _dir) = store().await;
703 s.update("x", "research", "r".into()).await.unwrap();
704 s.update("x", "design", "d".into()).await.unwrap();
705 s.deviate(
706 "x",
707 "data".into(),
708 "added field".into(),
709 "need array".into(),
710 )
711 .await
712 .unwrap();
713 s.deviate("x", "algo".into(), "changed loop".into(), "perf".into())
714 .await
715 .unwrap();
716 let devs = s.deviations("x").await.unwrap();
717 assert_eq!(devs.len(), 2);
718 assert_eq!(s.status("x").await.unwrap().deviation_count, 2);
719 }
720
721 #[tokio::test]
722 async fn update_and_deviate_dual_write_to_index() {
723 let dir = tempfile::tempdir().unwrap();
724 let index = std::sync::Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
725 let s = SpecStore::new(dir.path().to_path_buf()).with_index(index.clone());
726 s.update(
727 "feat_x",
728 "research",
729 "supercalifragilistic research notes".into(),
730 )
731 .await
732 .unwrap();
733 s.update(
734 "feat_x",
735 "design",
736 "midordermetamorphosis design notes".into(),
737 )
738 .await
739 .unwrap();
740 s.deviate(
741 "feat_x",
742 "sec".into(),
743 "hyperloquacious delta text".into(),
744 "quintessentialpolyphony reason text".into(),
745 )
746 .await
747 .unwrap();
748
749 let conn = index.conn();
750 let entry_count: i64 = conn
751 .query_row(
752 "SELECT COUNT(*) FROM spec_entries",
753 rusqlite::params![],
754 |r| r.get(0),
755 )
756 .unwrap();
757 let dev_count: i64 = conn
758 .query_row(
759 "SELECT COUNT(*) FROM spec_deviations",
760 rusqlite::params![],
761 |r| r.get(0),
762 )
763 .unwrap();
764 assert_eq!(entry_count, 2);
765 assert_eq!(dev_count, 1);
766
767 let entry_fts: i64 = conn
768 .query_row(
769 "SELECT COUNT(*) FROM spec_entries_fts WHERE spec_entries_fts MATCH ?",
770 rusqlite::params!["supercalifragilistic"],
771 |r| r.get(0),
772 )
773 .unwrap();
774 assert_eq!(entry_fts, 1);
775
776 let dev_fts: i64 = conn
777 .query_row(
778 "SELECT COUNT(*) FROM spec_deviations_fts WHERE spec_deviations_fts MATCH ?",
779 rusqlite::params!["quintessentialpolyphony"],
780 |r| r.get(0),
781 )
782 .unwrap();
783 assert_eq!(dev_fts, 1);
784 }
785
786 #[tokio::test]
787 async fn unknown_phase_rejected() {
788 let (s, _dir) = store().await;
789 let err = s.update("x", "brainstorm", "n".into()).await.unwrap_err();
790 assert!(format!("{err}").contains("unknown phase"));
791 }
792}