Skip to main content

bamboo_memory/
plan_store.rs

1//! Plan artifact persistence for plan mode sessions.
2//!
3//! Stores plan artifacts in `${BAMBOO_DATA_DIR}/plan/{session_slug}/`:
4//! - `plan.md` — human-readable plan markdown
5//! - `state.json` — machine-readable plan runtime state
6//! - `cursor.json` — machine-readable execution/resume cursor
7//! - `sections.json` — structured plan section index for precise resume
8//!
9//! For backward compatibility, reads also fall back to the legacy flat file
10//! layout `${BAMBOO_DATA_DIR}/plan/{session_slug}.md` when present.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::fs::{self, File, OpenOptions};
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19const PLAN_FILE_NAME: &str = "plan.md";
20const PLAN_STATE_FILE_NAME: &str = "state.json";
21const PLAN_CURSOR_FILE_NAME: &str = "cursor.json";
22const PLAN_SECTIONS_FILE_NAME: &str = "sections.json";
23const PLAN_ARTIFACT_VERSION: u32 = 1;
24
25/// Error type for plan store operations.
26#[derive(Debug, thiserror::Error)]
27pub enum PlanStoreError {
28    #[error("IO error: {0}")]
29    Io(#[from] std::io::Error),
30    #[error("JSON error: {0}")]
31    Json(#[from] serde_json::Error),
32    #[error("Plan directory not accessible: {0}")]
33    DirectoryNotAccessible(String),
34}
35
36/// Durable machine-readable state for a plan mode session.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct PlanStateArtifact {
39    pub version: u32,
40    pub session_id: String,
41    pub updated_at: DateTime<Utc>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub status: Option<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub active_task_id: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub active_step_id: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub next_step_id: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub active_section_id: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub next_section_id: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub last_completed_task_id: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub last_completed_section_id: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub round_hint: Option<u32>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub plan_hash: Option<String>,
62}
63
64impl PlanStateArtifact {
65    pub fn new(session_id: impl Into<String>) -> Self {
66        Self {
67            version: PLAN_ARTIFACT_VERSION,
68            session_id: session_id.into(),
69            updated_at: Utc::now(),
70            status: None,
71            active_task_id: None,
72            active_step_id: None,
73            next_step_id: None,
74            active_section_id: None,
75            next_section_id: None,
76            last_completed_task_id: None,
77            last_completed_section_id: None,
78            round_hint: None,
79            plan_hash: None,
80        }
81    }
82}
83
84/// Durable machine-readable execution cursor for resuming plan execution.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct PlanCursorArtifact {
87    pub version: u32,
88    pub session_id: String,
89    pub updated_at: DateTime<Utc>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub cursor_type: Option<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub current_task_id: Option<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub current_task_ordinal: Option<u32>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub current_step_id: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub current_section_id: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub next_task_id: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub next_task_ordinal: Option<u32>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub next_section_id: Option<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub last_completed_task_id: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub last_completed_section_id: Option<String>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub last_completed_checkpoint: Option<String>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub round_hint: Option<u32>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub round_id_hint: Option<String>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub suspension_hook_point: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub tool_call_boundary: Option<String>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub resume_note: Option<String>,
122}
123
124impl PlanCursorArtifact {
125    pub fn new(session_id: impl Into<String>) -> Self {
126        Self {
127            version: PLAN_ARTIFACT_VERSION,
128            session_id: session_id.into(),
129            updated_at: Utc::now(),
130            cursor_type: None,
131            current_task_id: None,
132            current_task_ordinal: None,
133            current_step_id: None,
134            current_section_id: None,
135            next_task_id: None,
136            next_task_ordinal: None,
137            next_section_id: None,
138            last_completed_task_id: None,
139            last_completed_section_id: None,
140            last_completed_checkpoint: None,
141            round_hint: None,
142            round_id_hint: None,
143            suspension_hook_point: None,
144            tool_call_boundary: None,
145            resume_note: None,
146        }
147    }
148}
149
150/// Indexed section metadata extracted from persisted plan markdown.
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct PlanSectionArtifact {
153    pub version: u32,
154    pub session_id: String,
155    pub updated_at: DateTime<Utc>,
156    pub sections: Vec<PlanSection>,
157}
158
159impl PlanSectionArtifact {
160    pub fn new(session_id: impl Into<String>, sections: Vec<PlanSection>) -> Self {
161        Self {
162            version: PLAN_ARTIFACT_VERSION,
163            session_id: session_id.into(),
164            updated_at: Utc::now(),
165            sections,
166        }
167    }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct PlanSection {
172    pub id: String,
173    pub heading: String,
174    pub level: u8,
175    pub line_start: usize,
176    pub line_end: usize,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub parent_id: Option<String>,
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub anchor_terms: Vec<String>,
181}
182
183/// Stores and retrieves plan artifacts for sessions.
184#[derive(Debug, Clone)]
185pub struct PlanStore {
186    plans_dir: PathBuf,
187}
188
189impl PlanStore {
190    /// Create a new plan store with the given base data directory.
191    ///
192    /// Artifacts are stored in `{data_dir}/plan/`.
193    pub fn new(data_dir: impl AsRef<Path>) -> Result<Self, PlanStoreError> {
194        let plans_dir = data_dir.as_ref().join("plan");
195        fs::create_dir_all(&plans_dir).map_err(|e| {
196            PlanStoreError::DirectoryNotAccessible(format!(
197                "Failed to create plan directory at {}: {}",
198                plans_dir.display(),
199                e
200            ))
201        })?;
202        Ok(Self { plans_dir })
203    }
204
205    /// Generate a slug from a session ID suitable for use as a filename.
206    ///
207    /// Uses the first 8 characters of the session ID plus a hash of the remainder
208    /// to produce a short, deterministic, filesystem-safe identifier.
209    fn session_slug(session_id: &str) -> String {
210        let clean = session_id
211            .chars()
212            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
213            .collect::<String>();
214
215        if clean.len() <= 16 {
216            return clean;
217        }
218
219        let prefix: String = clean.chars().take(8).collect();
220        let suffix: String = clean
221            .chars()
222            .rev()
223            .take(8)
224            .collect::<Vec<_>>()
225            .into_iter()
226            .rev()
227            .collect();
228        format!(
229            "{}-{}-{:x}",
230            prefix,
231            suffix,
232            seahash::hash(clean.as_bytes())
233        )
234    }
235
236    fn session_dir_path_internal(&self, session_id: &str) -> PathBuf {
237        self.plans_dir.join(Self::session_slug(session_id))
238    }
239
240    fn preferred_plan_file_path(&self, session_id: &str) -> PathBuf {
241        self.session_dir_path_internal(session_id)
242            .join(PLAN_FILE_NAME)
243    }
244
245    fn legacy_plan_file_path(&self, session_id: &str) -> PathBuf {
246        self.plans_dir
247            .join(format!("{}.md", Self::session_slug(session_id)))
248    }
249
250    fn resolved_plan_file_path_internal(&self, session_id: &str) -> PathBuf {
251        let preferred = self.preferred_plan_file_path(session_id);
252        if preferred.exists() {
253            preferred
254        } else {
255            let legacy = self.legacy_plan_file_path(session_id);
256            if legacy.exists() {
257                legacy
258            } else {
259                preferred
260            }
261        }
262    }
263
264    fn ensure_session_dir(&self, session_id: &str) -> Result<PathBuf, PlanStoreError> {
265        let dir = self.session_dir_path_internal(session_id);
266        fs::create_dir_all(&dir)?;
267        Ok(dir)
268    }
269
270    fn unique_temp_path(path: &Path) -> PathBuf {
271        let nanos = SystemTime::now()
272            .duration_since(UNIX_EPOCH)
273            .map(|duration| duration.as_nanos())
274            .unwrap_or(0);
275        let file_name = path
276            .file_name()
277            .and_then(|name| name.to_str())
278            .unwrap_or("artifact");
279        path.with_file_name(format!(".{file_name}.{nanos}.{}.tmp", std::process::id()))
280    }
281
282    fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), PlanStoreError> {
283        if let Some(parent) = path.parent() {
284            fs::create_dir_all(parent)?;
285        }
286
287        let temp_path = Self::unique_temp_path(path);
288        let mut file = OpenOptions::new()
289            .create(true)
290            .truncate(true)
291            .write(true)
292            .open(&temp_path)?;
293        file.write_all(bytes)?;
294        file.flush()?;
295        file.sync_all()?;
296        drop(file);
297
298        fs::rename(&temp_path, path)?;
299
300        if let Some(dir) = path.parent().and_then(|parent| File::open(parent).ok()) {
301            let _ = dir.sync_all();
302        }
303
304        Ok(())
305    }
306
307    fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), PlanStoreError> {
308        let bytes = serde_json::to_vec_pretty(value)?;
309        Self::atomic_write_bytes(path, &bytes)
310    }
311
312    fn read_json_artifact<T: for<'de> Deserialize<'de>>(
313        &self,
314        path: &Path,
315    ) -> Result<Option<T>, PlanStoreError> {
316        if !path.exists() {
317            return Ok(None);
318        }
319        let raw = fs::read_to_string(path)?;
320        Ok(Some(serde_json::from_str(&raw)?))
321    }
322
323    fn normalize_section_token(token: &str) -> String {
324        let mut normalized = String::new();
325        let mut last_was_dash = false;
326
327        for ch in token.chars() {
328            if ch.is_ascii_alphanumeric() {
329                normalized.push(ch.to_ascii_lowercase());
330                last_was_dash = false;
331            } else if (ch.is_whitespace() || matches!(ch, '-' | '_' | ':' | '.')) && !last_was_dash
332            {
333                normalized.push('-');
334                last_was_dash = true;
335            }
336        }
337
338        normalized = normalized.trim_matches('-').to_string();
339        if normalized.is_empty() {
340            "section".to_string()
341        } else {
342            normalized
343        }
344    }
345
346    fn extract_inline_anchor_terms(line: &str, heading: &str) -> Vec<String> {
347        let mut anchors = Vec::new();
348        let heading_trimmed = heading.trim();
349        if !heading_trimmed.is_empty() {
350            anchors.push(heading_trimmed.to_string());
351        }
352
353        let trimmed = line.trim();
354        if let Some((key, value)) = trimmed.split_once(':') {
355            let key = key.trim();
356            let value = value.trim();
357            if matches!(
358                key,
359                "- task_id"
360                    | "task_id"
361                    | "- current_step_id"
362                    | "current_step_id"
363                    | "- step_id"
364                    | "step_id"
365            ) && !value.is_empty()
366            {
367                anchors.push(value.to_string());
368            }
369        }
370
371        anchors.sort();
372        anchors.dedup();
373        anchors
374    }
375
376    fn heading_level_and_title(line: &str) -> Option<(u8, String)> {
377        let trimmed = line.trim_start();
378        let hashes = trimmed.chars().take_while(|ch| *ch == '#').count();
379        if hashes == 0 {
380            return None;
381        }
382        let title = trimmed[hashes..].trim();
383        if title.is_empty() {
384            return None;
385        }
386        Some((hashes as u8, title.to_string()))
387    }
388
389    fn index_plan_sections(session_id: &str, content: &str) -> PlanSectionArtifact {
390        let lines: Vec<&str> = content.lines().collect();
391        let mut sections = Vec::new();
392        let mut heading_indices = Vec::new();
393
394        for (index, line) in lines.iter().enumerate() {
395            if let Some((level, heading)) = Self::heading_level_and_title(line) {
396                heading_indices.push((index, level, heading));
397            }
398        }
399
400        for (position, (line_start, level, heading)) in heading_indices.iter().enumerate() {
401            let line_end = heading_indices
402                .get(position + 1)
403                .map(|(next_start, _, _)| next_start.saturating_sub(1))
404                .unwrap_or_else(|| lines.len().saturating_sub(1));
405
406            let parent_id = heading_indices[..position]
407                .iter()
408                .rev()
409                .find(|(_, candidate_level, _)| *candidate_level < *level)
410                .map(|(_, _, candidate_heading)| Self::normalize_section_token(candidate_heading));
411
412            let mut anchor_terms = vec![heading.clone()];
413            for line in lines[*line_start..=line_end].iter().take(10) {
414                anchor_terms.extend(Self::extract_inline_anchor_terms(line, heading));
415            }
416            anchor_terms.sort();
417            anchor_terms.dedup();
418
419            sections.push(PlanSection {
420                id: Self::normalize_section_token(heading),
421                heading: heading.clone(),
422                level: *level,
423                line_start: *line_start,
424                line_end,
425                parent_id,
426                anchor_terms,
427            });
428        }
429
430        PlanSectionArtifact::new(session_id, sections)
431    }
432
433    /// Path to the session artifact directory for a given session.
434    pub fn session_dir_path(&self, session_id: &str) -> PathBuf {
435        self.session_dir_path_internal(session_id)
436    }
437
438    /// Path to the plan markdown file for a given session.
439    ///
440    /// Returns the currently resolved path. If a legacy flat-file artifact exists and
441    /// the new directory-based artifact does not, this returns the legacy path.
442    pub fn plan_file_path(&self, session_id: &str) -> PathBuf {
443        self.resolved_plan_file_path_internal(session_id)
444    }
445
446    /// Path to the plan machine state artifact for a given session.
447    pub fn state_file_path(&self, session_id: &str) -> PathBuf {
448        self.session_dir_path_internal(session_id)
449            .join(PLAN_STATE_FILE_NAME)
450    }
451
452    /// Path to the plan cursor artifact for a given session.
453    pub fn cursor_file_path(&self, session_id: &str) -> PathBuf {
454        self.session_dir_path_internal(session_id)
455            .join(PLAN_CURSOR_FILE_NAME)
456    }
457
458    /// Path to the indexed sections artifact for a given session.
459    pub fn sections_file_path(&self, session_id: &str) -> PathBuf {
460        self.session_dir_path_internal(session_id)
461            .join(PLAN_SECTIONS_FILE_NAME)
462    }
463
464    /// Write a plan markdown artifact for the given session.
465    ///
466    /// Overwrites any existing directory-based artifact for this session and refreshes
467    /// the section index artifact derived from the markdown.
468    pub fn write_plan(
469        &self,
470        session_id: &str,
471        content: impl AsRef<str>,
472    ) -> Result<PathBuf, PlanStoreError> {
473        self.ensure_session_dir(session_id)?;
474        let content = content.as_ref();
475        let path = self.preferred_plan_file_path(session_id);
476        Self::atomic_write_bytes(&path, content.as_bytes())?;
477
478        let sections = Self::index_plan_sections(session_id, content);
479        let sections_path = self.sections_file_path(session_id);
480        Self::atomic_write_json(&sections_path, &sections)?;
481
482        let legacy_path = self.legacy_plan_file_path(session_id);
483        if legacy_path.exists() {
484            let _ = fs::remove_file(legacy_path);
485        }
486
487        Ok(path)
488    }
489
490    /// Read the plan markdown artifact for the given session, if it exists.
491    pub fn read_plan(&self, session_id: &str) -> Option<String> {
492        let path = self.resolved_plan_file_path_internal(session_id);
493        fs::read_to_string(&path).ok()
494    }
495
496    /// Check whether a plan artifact exists for the given session.
497    pub fn plan_exists(&self, session_id: &str) -> bool {
498        self.resolved_plan_file_path_internal(session_id).exists()
499    }
500
501    /// Write the plan machine state artifact for the given session.
502    pub fn write_state(
503        &self,
504        session_id: &str,
505        state: &PlanStateArtifact,
506    ) -> Result<PathBuf, PlanStoreError> {
507        self.ensure_session_dir(session_id)?;
508        let path = self.state_file_path(session_id);
509        Self::atomic_write_json(&path, state)?;
510        Ok(path)
511    }
512
513    /// Read the plan machine state artifact for the given session, if it exists.
514    pub fn read_state(
515        &self,
516        session_id: &str,
517    ) -> Result<Option<PlanStateArtifact>, PlanStoreError> {
518        self.read_json_artifact(&self.state_file_path(session_id))
519    }
520
521    /// Write the plan cursor artifact for the given session.
522    pub fn write_cursor(
523        &self,
524        session_id: &str,
525        cursor: &PlanCursorArtifact,
526    ) -> Result<PathBuf, PlanStoreError> {
527        self.ensure_session_dir(session_id)?;
528        let path = self.cursor_file_path(session_id);
529        Self::atomic_write_json(&path, cursor)?;
530        Ok(path)
531    }
532
533    /// Read the plan cursor artifact for the given session, if it exists.
534    pub fn read_cursor(
535        &self,
536        session_id: &str,
537    ) -> Result<Option<PlanCursorArtifact>, PlanStoreError> {
538        self.read_json_artifact(&self.cursor_file_path(session_id))
539    }
540
541    /// Read the indexed plan sections artifact for the given session, if it exists.
542    pub fn read_sections(
543        &self,
544        session_id: &str,
545    ) -> Result<Option<PlanSectionArtifact>, PlanStoreError> {
546        self.read_json_artifact(&self.sections_file_path(session_id))
547    }
548
549    /// Delete all plan artifacts for the given session.
550    pub fn delete_plan(&self, session_id: &str) -> Result<(), PlanStoreError> {
551        let legacy_path = self.legacy_plan_file_path(session_id);
552        if legacy_path.exists() {
553            fs::remove_file(&legacy_path)?;
554        }
555
556        let session_dir = self.session_dir_path_internal(session_id);
557        if session_dir.exists() {
558            fs::remove_dir_all(session_dir)?;
559        }
560
561        Ok(())
562    }
563
564    /// Get the root storage directory path.
565    pub fn plans_dir(&self) -> &Path {
566        &self.plans_dir
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    fn temp_store() -> (tempfile::TempDir, PlanStore) {
575        let temp_dir = tempfile::tempdir().unwrap();
576        let store = PlanStore::new(temp_dir.path()).unwrap();
577        (temp_dir, store)
578    }
579
580    #[test]
581    fn session_slug_produces_short_identifier() {
582        let id = "sess-abc123-def456-ghi789";
583        let slug = PlanStore::session_slug(id);
584        assert!(!slug.is_empty());
585        assert!(!slug.contains('/'));
586        assert!(!slug.contains('\\'));
587    }
588
589    #[test]
590    fn write_and_read_plan() {
591        let (_tmp, store) = temp_store();
592        let session_id = "test-session-001";
593        let content = "# Implementation Plan\n\n1. Step one\n2. Step two\n";
594
595        let path = store.write_plan(session_id, content).unwrap();
596        assert!(path.exists());
597        assert!(store.plan_exists(session_id));
598
599        let read = store.read_plan(session_id).unwrap();
600        assert_eq!(read, content);
601    }
602
603    #[test]
604    fn read_nonexistent_plan_returns_none() {
605        let (_tmp, store) = temp_store();
606        assert!(store.read_plan("nonexistent-session").is_none());
607        assert!(!store.plan_exists("nonexistent-session"));
608    }
609
610    #[test]
611    fn write_plan_overwrites_existing() {
612        let (_tmp, store) = temp_store();
613        let session_id = "test-session-002";
614
615        store.write_plan(session_id, "Plan v1").unwrap();
616        store.write_plan(session_id, "Plan v2").unwrap();
617
618        let read = store.read_plan(session_id).unwrap();
619        assert_eq!(read, "Plan v2");
620    }
621
622    #[test]
623    fn delete_plan_removes_artifact_directory() {
624        let (_tmp, store) = temp_store();
625        let session_id = "test-session-003";
626
627        store.write_plan(session_id, "Plan to delete").unwrap();
628        store
629            .write_state(session_id, &PlanStateArtifact::new(session_id))
630            .unwrap();
631        store
632            .write_cursor(session_id, &PlanCursorArtifact::new(session_id))
633            .unwrap();
634        assert!(store.plan_exists(session_id));
635        assert!(store.session_dir_path(session_id).exists());
636
637        store.delete_plan(session_id).unwrap();
638        assert!(!store.plan_exists(session_id));
639        assert!(!store.session_dir_path(session_id).exists());
640    }
641
642    #[test]
643    fn delete_nonexistent_plan_is_noop() {
644        let (_tmp, store) = temp_store();
645        store.delete_plan("never-created").unwrap();
646    }
647
648    #[test]
649    fn plan_file_path_is_under_session_artifact_dir() {
650        let (_tmp, store) = temp_store();
651        let path = store.plan_file_path("some-session");
652        assert!(path.starts_with(&store.plans_dir));
653        assert_eq!(
654            path.file_name().and_then(|n| n.to_str()),
655            Some(PLAN_FILE_NAME)
656        );
657        assert_eq!(path.parent().unwrap().parent().unwrap(), store.plans_dir());
658    }
659
660    #[test]
661    fn session_slug_handles_short_id() {
662        let id = "short";
663        let slug = PlanStore::session_slug(id);
664        assert_eq!(slug, "short");
665    }
666
667    #[test]
668    fn session_slug_strips_special_chars() {
669        let id = "sess/abc\\def:ghi";
670        let slug = PlanStore::session_slug(id);
671        assert!(!slug.contains('/'));
672        assert!(!slug.contains('\\'));
673        assert!(!slug.contains(':'));
674    }
675
676    #[test]
677    fn write_and_read_state_artifact() {
678        let (_tmp, store) = temp_store();
679        let session_id = "state-session-1";
680        let mut state = PlanStateArtifact::new(session_id);
681        state.status = Some("awaiting_approval".to_string());
682        state.active_task_id = Some("task-1".to_string());
683        state.active_section_id = Some("task-1".to_string());
684        state.last_completed_task_id = Some("task-0".to_string());
685        state.round_hint = Some(3);
686
687        let path = store.write_state(session_id, &state).unwrap();
688        assert!(path.exists());
689
690        let read = store.read_state(session_id).unwrap().unwrap();
691        assert_eq!(read, state);
692    }
693
694    #[test]
695    fn write_and_read_cursor_artifact() {
696        let (_tmp, store) = temp_store();
697        let session_id = "cursor-session-1";
698        let mut cursor = PlanCursorArtifact::new(session_id);
699        cursor.cursor_type = Some("task_item".to_string());
700        cursor.current_task_id = Some("task-2".to_string());
701        cursor.current_task_ordinal = Some(2);
702        cursor.current_section_id = Some("task-2".to_string());
703        cursor.next_task_id = Some("task-3".to_string());
704        cursor.next_task_ordinal = Some(3);
705        cursor.last_completed_task_id = Some("task-1".to_string());
706        cursor.round_hint = Some(4);
707        cursor.round_id_hint = Some("round-4".to_string());
708        cursor.suspension_hook_point = Some("AfterToolExecution".to_string());
709        cursor.tool_call_boundary = Some("ExitPlanMode".to_string());
710        cursor.resume_note = Some("Continue from task-2".to_string());
711
712        let path = store.write_cursor(session_id, &cursor).unwrap();
713        assert!(path.exists());
714
715        let read = store.read_cursor(session_id).unwrap().unwrap();
716        assert_eq!(read, cursor);
717    }
718
719    #[test]
720    fn read_plan_falls_back_to_legacy_flat_file() {
721        let (_tmp, store) = temp_store();
722        let session_id = "legacy-session-1";
723        let legacy_path = store.legacy_plan_file_path(session_id);
724        fs::write(&legacy_path, "Legacy plan").unwrap();
725
726        assert_eq!(store.read_plan(session_id).as_deref(), Some("Legacy plan"));
727        assert_eq!(store.plan_file_path(session_id), legacy_path);
728    }
729
730    #[test]
731    fn machine_state_writes_do_not_leave_temp_files_behind() {
732        let (_tmp, store) = temp_store();
733        let session_id = "temp-cleanup-session";
734        let mut state = PlanStateArtifact::new(session_id);
735        state.status = Some("designing".to_string());
736        store.write_state(session_id, &state).unwrap();
737
738        let entries = fs::read_dir(store.session_dir_path(session_id))
739            .unwrap()
740            .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
741            .collect::<Vec<_>>();
742        assert!(entries.iter().all(|name| !name.ends_with(".tmp")));
743        assert!(entries.iter().any(|name| name == PLAN_STATE_FILE_NAME));
744    }
745
746    #[test]
747    fn write_plan_generates_section_index_artifact() {
748        let (_tmp, store) = temp_store();
749        let session_id = "sectioned-session";
750        let plan = "# Plan\n\n## task-alpha\n- task_id: task-alpha\n- do alpha\n\n### step-alpha-1\n- current_step_id: step-alpha-1\n- detail\n\n## task-bravo\n- task_id: task-bravo\n- do bravo\n";
751
752        store.write_plan(session_id, plan).unwrap();
753        let sections = store
754            .read_sections(session_id)
755            .unwrap()
756            .expect("sections should exist");
757
758        assert!(sections.sections.len() >= 3);
759        let task_alpha = sections
760            .sections
761            .iter()
762            .find(|section| section.id == "task-alpha")
763            .expect("task-alpha section");
764        assert_eq!(task_alpha.heading, "task-alpha");
765        assert!(task_alpha
766            .anchor_terms
767            .iter()
768            .any(|term| term == "task-alpha"));
769
770        let step_alpha = sections
771            .sections
772            .iter()
773            .find(|section| section.id == "step-alpha-1")
774            .expect("step-alpha-1 section");
775        assert_eq!(step_alpha.parent_id.as_deref(), Some("task-alpha"));
776        assert!(step_alpha
777            .anchor_terms
778            .iter()
779            .any(|term| term == "step-alpha-1"));
780    }
781}