1use 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#[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#[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#[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#[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#[derive(Debug, Clone)]
185pub struct PlanStore {
186 plans_dir: PathBuf,
187}
188
189impl PlanStore {
190 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 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 pub fn session_dir_path(&self, session_id: &str) -> PathBuf {
435 self.session_dir_path_internal(session_id)
436 }
437
438 pub fn plan_file_path(&self, session_id: &str) -> PathBuf {
443 self.resolved_plan_file_path_internal(session_id)
444 }
445
446 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 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 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 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(§ions_path, §ions)?;
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 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 pub fn plan_exists(&self, session_id: &str) -> bool {
498 self.resolved_plan_file_path_internal(session_id).exists()
499 }
500
501 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 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 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 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 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 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 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}