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).join(PLAN_FILE_NAME)
242 }
243
244 fn legacy_plan_file_path(&self, session_id: &str) -> PathBuf {
245 self.plans_dir
246 .join(format!("{}.md", Self::session_slug(session_id)))
247 }
248
249 fn resolved_plan_file_path_internal(&self, session_id: &str) -> PathBuf {
250 let preferred = self.preferred_plan_file_path(session_id);
251 if preferred.exists() {
252 preferred
253 } else {
254 let legacy = self.legacy_plan_file_path(session_id);
255 if legacy.exists() {
256 legacy
257 } else {
258 preferred
259 }
260 }
261 }
262
263 fn ensure_session_dir(&self, session_id: &str) -> Result<PathBuf, PlanStoreError> {
264 let dir = self.session_dir_path_internal(session_id);
265 fs::create_dir_all(&dir)?;
266 Ok(dir)
267 }
268
269 fn unique_temp_path(path: &Path) -> PathBuf {
270 let nanos = SystemTime::now()
271 .duration_since(UNIX_EPOCH)
272 .map(|duration| duration.as_nanos())
273 .unwrap_or(0);
274 let file_name = path
275 .file_name()
276 .and_then(|name| name.to_str())
277 .unwrap_or("artifact");
278 path.with_file_name(format!(".{file_name}.{nanos}.{}.tmp", std::process::id()))
279 }
280
281 fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), PlanStoreError> {
282 if let Some(parent) = path.parent() {
283 fs::create_dir_all(parent)?;
284 }
285
286 let temp_path = Self::unique_temp_path(path);
287 let mut file = OpenOptions::new()
288 .create(true)
289 .truncate(true)
290 .write(true)
291 .open(&temp_path)?;
292 file.write_all(bytes)?;
293 file.flush()?;
294 file.sync_all()?;
295 drop(file);
296
297 fs::rename(&temp_path, path)?;
298
299 if let Some(parent) = path.parent() {
300 if let Ok(dir) = File::open(parent) {
301 let _ = dir.sync_all();
302 }
303 }
304
305 Ok(())
306 }
307
308 fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), PlanStoreError> {
309 let bytes = serde_json::to_vec_pretty(value)?;
310 Self::atomic_write_bytes(path, &bytes)
311 }
312
313 fn read_json_artifact<T: for<'de> Deserialize<'de>>(
314 &self,
315 path: &Path,
316 ) -> Result<Option<T>, PlanStoreError> {
317 if !path.exists() {
318 return Ok(None);
319 }
320 let raw = fs::read_to_string(path)?;
321 Ok(Some(serde_json::from_str(&raw)?))
322 }
323
324 fn normalize_section_token(token: &str) -> String {
325 let mut normalized = String::new();
326 let mut last_was_dash = false;
327
328 for ch in token.chars() {
329 if ch.is_ascii_alphanumeric() {
330 normalized.push(ch.to_ascii_lowercase());
331 last_was_dash = false;
332 } else if (ch.is_whitespace() || matches!(ch, '-' | '_' | ':' | '.')) && !last_was_dash {
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!(key, "- task_id" | "task_id" | "- current_step_id" | "current_step_id" | "- step_id" | "step_id") {
358 if !value.is_empty() {
359 anchors.push(value.to_string());
360 }
361 }
362 }
363
364 anchors.sort();
365 anchors.dedup();
366 anchors
367 }
368
369 fn heading_level_and_title(line: &str) -> Option<(u8, String)> {
370 let trimmed = line.trim_start();
371 let hashes = trimmed.chars().take_while(|ch| *ch == '#').count();
372 if hashes == 0 {
373 return None;
374 }
375 let title = trimmed[hashes..].trim();
376 if title.is_empty() {
377 return None;
378 }
379 Some((hashes as u8, title.to_string()))
380 }
381
382 fn index_plan_sections(session_id: &str, content: &str) -> PlanSectionArtifact {
383 let lines: Vec<&str> = content.lines().collect();
384 let mut sections = Vec::new();
385 let mut heading_indices = Vec::new();
386
387 for (index, line) in lines.iter().enumerate() {
388 if let Some((level, heading)) = Self::heading_level_and_title(line) {
389 heading_indices.push((index, level, heading));
390 }
391 }
392
393 for (position, (line_start, level, heading)) in heading_indices.iter().enumerate() {
394 let line_end = heading_indices
395 .get(position + 1)
396 .map(|(next_start, _, _)| next_start.saturating_sub(1))
397 .unwrap_or_else(|| lines.len().saturating_sub(1));
398
399 let parent_id = heading_indices[..position]
400 .iter()
401 .rev()
402 .find(|(_, candidate_level, _)| *candidate_level < *level)
403 .map(|(_, _, candidate_heading)| Self::normalize_section_token(candidate_heading));
404
405 let mut anchor_terms = vec![heading.clone()];
406 for line in lines[*line_start..=line_end].iter().take(10) {
407 anchor_terms.extend(Self::extract_inline_anchor_terms(line, heading));
408 }
409 anchor_terms.sort();
410 anchor_terms.dedup();
411
412 sections.push(PlanSection {
413 id: Self::normalize_section_token(heading),
414 heading: heading.clone(),
415 level: *level,
416 line_start: *line_start,
417 line_end,
418 parent_id,
419 anchor_terms,
420 });
421 }
422
423 PlanSectionArtifact::new(session_id, sections)
424 }
425
426 pub fn session_dir_path(&self, session_id: &str) -> PathBuf {
428 self.session_dir_path_internal(session_id)
429 }
430
431 pub fn plan_file_path(&self, session_id: &str) -> PathBuf {
436 self.resolved_plan_file_path_internal(session_id)
437 }
438
439 pub fn state_file_path(&self, session_id: &str) -> PathBuf {
441 self.session_dir_path_internal(session_id)
442 .join(PLAN_STATE_FILE_NAME)
443 }
444
445 pub fn cursor_file_path(&self, session_id: &str) -> PathBuf {
447 self.session_dir_path_internal(session_id)
448 .join(PLAN_CURSOR_FILE_NAME)
449 }
450
451 pub fn sections_file_path(&self, session_id: &str) -> PathBuf {
453 self.session_dir_path_internal(session_id)
454 .join(PLAN_SECTIONS_FILE_NAME)
455 }
456
457 pub fn write_plan(
462 &self,
463 session_id: &str,
464 content: impl AsRef<str>,
465 ) -> Result<PathBuf, PlanStoreError> {
466 self.ensure_session_dir(session_id)?;
467 let content = content.as_ref();
468 let path = self.preferred_plan_file_path(session_id);
469 Self::atomic_write_bytes(&path, content.as_bytes())?;
470
471 let sections = Self::index_plan_sections(session_id, content);
472 let sections_path = self.sections_file_path(session_id);
473 Self::atomic_write_json(§ions_path, §ions)?;
474
475 let legacy_path = self.legacy_plan_file_path(session_id);
476 if legacy_path.exists() {
477 let _ = fs::remove_file(legacy_path);
478 }
479
480 Ok(path)
481 }
482
483 pub fn read_plan(&self, session_id: &str) -> Option<String> {
485 let path = self.resolved_plan_file_path_internal(session_id);
486 fs::read_to_string(&path).ok()
487 }
488
489 pub fn plan_exists(&self, session_id: &str) -> bool {
491 self.resolved_plan_file_path_internal(session_id).exists()
492 }
493
494 pub fn write_state(
496 &self,
497 session_id: &str,
498 state: &PlanStateArtifact,
499 ) -> Result<PathBuf, PlanStoreError> {
500 self.ensure_session_dir(session_id)?;
501 let path = self.state_file_path(session_id);
502 Self::atomic_write_json(&path, state)?;
503 Ok(path)
504 }
505
506 pub fn read_state(&self, session_id: &str) -> Result<Option<PlanStateArtifact>, PlanStoreError> {
508 self.read_json_artifact(&self.state_file_path(session_id))
509 }
510
511 pub fn write_cursor(
513 &self,
514 session_id: &str,
515 cursor: &PlanCursorArtifact,
516 ) -> Result<PathBuf, PlanStoreError> {
517 self.ensure_session_dir(session_id)?;
518 let path = self.cursor_file_path(session_id);
519 Self::atomic_write_json(&path, cursor)?;
520 Ok(path)
521 }
522
523 pub fn read_cursor(
525 &self,
526 session_id: &str,
527 ) -> Result<Option<PlanCursorArtifact>, PlanStoreError> {
528 self.read_json_artifact(&self.cursor_file_path(session_id))
529 }
530
531 pub fn read_sections(
533 &self,
534 session_id: &str,
535 ) -> Result<Option<PlanSectionArtifact>, PlanStoreError> {
536 self.read_json_artifact(&self.sections_file_path(session_id))
537 }
538
539 pub fn delete_plan(&self, session_id: &str) -> Result<(), PlanStoreError> {
541 let legacy_path = self.legacy_plan_file_path(session_id);
542 if legacy_path.exists() {
543 fs::remove_file(&legacy_path)?;
544 }
545
546 let session_dir = self.session_dir_path_internal(session_id);
547 if session_dir.exists() {
548 fs::remove_dir_all(session_dir)?;
549 }
550
551 Ok(())
552 }
553
554 pub fn plans_dir(&self) -> &Path {
556 &self.plans_dir
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 fn temp_store() -> (tempfile::TempDir, PlanStore) {
565 let temp_dir = tempfile::tempdir().unwrap();
566 let store = PlanStore::new(temp_dir.path()).unwrap();
567 (temp_dir, store)
568 }
569
570 #[test]
571 fn session_slug_produces_short_identifier() {
572 let id = "sess-abc123-def456-ghi789";
573 let slug = PlanStore::session_slug(id);
574 assert!(!slug.is_empty());
575 assert!(!slug.contains('/'));
576 assert!(!slug.contains('\\'));
577 }
578
579 #[test]
580 fn write_and_read_plan() {
581 let (_tmp, store) = temp_store();
582 let session_id = "test-session-001";
583 let content = "# Implementation Plan\n\n1. Step one\n2. Step two\n";
584
585 let path = store.write_plan(session_id, content).unwrap();
586 assert!(path.exists());
587 assert!(store.plan_exists(session_id));
588
589 let read = store.read_plan(session_id).unwrap();
590 assert_eq!(read, content);
591 }
592
593 #[test]
594 fn read_nonexistent_plan_returns_none() {
595 let (_tmp, store) = temp_store();
596 assert!(store.read_plan("nonexistent-session").is_none());
597 assert!(!store.plan_exists("nonexistent-session"));
598 }
599
600 #[test]
601 fn write_plan_overwrites_existing() {
602 let (_tmp, store) = temp_store();
603 let session_id = "test-session-002";
604
605 store.write_plan(session_id, "Plan v1").unwrap();
606 store.write_plan(session_id, "Plan v2").unwrap();
607
608 let read = store.read_plan(session_id).unwrap();
609 assert_eq!(read, "Plan v2");
610 }
611
612 #[test]
613 fn delete_plan_removes_artifact_directory() {
614 let (_tmp, store) = temp_store();
615 let session_id = "test-session-003";
616
617 store.write_plan(session_id, "Plan to delete").unwrap();
618 store
619 .write_state(session_id, &PlanStateArtifact::new(session_id))
620 .unwrap();
621 store
622 .write_cursor(session_id, &PlanCursorArtifact::new(session_id))
623 .unwrap();
624 assert!(store.plan_exists(session_id));
625 assert!(store.session_dir_path(session_id).exists());
626
627 store.delete_plan(session_id).unwrap();
628 assert!(!store.plan_exists(session_id));
629 assert!(!store.session_dir_path(session_id).exists());
630 }
631
632 #[test]
633 fn delete_nonexistent_plan_is_noop() {
634 let (_tmp, store) = temp_store();
635 store.delete_plan("never-created").unwrap();
636 }
637
638 #[test]
639 fn plan_file_path_is_under_session_artifact_dir() {
640 let (_tmp, store) = temp_store();
641 let path = store.plan_file_path("some-session");
642 assert!(path.starts_with(&store.plans_dir));
643 assert_eq!(path.file_name().and_then(|n| n.to_str()), Some(PLAN_FILE_NAME));
644 assert_eq!(path.parent().unwrap().parent().unwrap(), store.plans_dir());
645 }
646
647 #[test]
648 fn session_slug_handles_short_id() {
649 let id = "short";
650 let slug = PlanStore::session_slug(id);
651 assert_eq!(slug, "short");
652 }
653
654 #[test]
655 fn session_slug_strips_special_chars() {
656 let id = "sess/abc\\def:ghi";
657 let slug = PlanStore::session_slug(id);
658 assert!(!slug.contains('/'));
659 assert!(!slug.contains('\\'));
660 assert!(!slug.contains(':'));
661 }
662
663 #[test]
664 fn write_and_read_state_artifact() {
665 let (_tmp, store) = temp_store();
666 let session_id = "state-session-1";
667 let mut state = PlanStateArtifact::new(session_id);
668 state.status = Some("awaiting_approval".to_string());
669 state.active_task_id = Some("task-1".to_string());
670 state.active_section_id = Some("task-1".to_string());
671 state.last_completed_task_id = Some("task-0".to_string());
672 state.round_hint = Some(3);
673
674 let path = store.write_state(session_id, &state).unwrap();
675 assert!(path.exists());
676
677 let read = store.read_state(session_id).unwrap().unwrap();
678 assert_eq!(read, state);
679 }
680
681 #[test]
682 fn write_and_read_cursor_artifact() {
683 let (_tmp, store) = temp_store();
684 let session_id = "cursor-session-1";
685 let mut cursor = PlanCursorArtifact::new(session_id);
686 cursor.cursor_type = Some("task_item".to_string());
687 cursor.current_task_id = Some("task-2".to_string());
688 cursor.current_task_ordinal = Some(2);
689 cursor.current_section_id = Some("task-2".to_string());
690 cursor.next_task_id = Some("task-3".to_string());
691 cursor.next_task_ordinal = Some(3);
692 cursor.last_completed_task_id = Some("task-1".to_string());
693 cursor.round_hint = Some(4);
694 cursor.round_id_hint = Some("round-4".to_string());
695 cursor.suspension_hook_point = Some("AfterToolExecution".to_string());
696 cursor.tool_call_boundary = Some("ExitPlanMode".to_string());
697 cursor.resume_note = Some("Continue from task-2".to_string());
698
699 let path = store.write_cursor(session_id, &cursor).unwrap();
700 assert!(path.exists());
701
702 let read = store.read_cursor(session_id).unwrap().unwrap();
703 assert_eq!(read, cursor);
704 }
705
706 #[test]
707 fn read_plan_falls_back_to_legacy_flat_file() {
708 let (_tmp, store) = temp_store();
709 let session_id = "legacy-session-1";
710 let legacy_path = store.legacy_plan_file_path(session_id);
711 fs::write(&legacy_path, "Legacy plan").unwrap();
712
713 assert_eq!(store.read_plan(session_id).as_deref(), Some("Legacy plan"));
714 assert_eq!(store.plan_file_path(session_id), legacy_path);
715 }
716
717 #[test]
718 fn machine_state_writes_do_not_leave_temp_files_behind() {
719 let (_tmp, store) = temp_store();
720 let session_id = "temp-cleanup-session";
721 let mut state = PlanStateArtifact::new(session_id);
722 state.status = Some("designing".to_string());
723 store.write_state(session_id, &state).unwrap();
724
725 let entries = fs::read_dir(store.session_dir_path(session_id))
726 .unwrap()
727 .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
728 .collect::<Vec<_>>();
729 assert!(entries.iter().all(|name| !name.ends_with(".tmp")));
730 assert!(entries.iter().any(|name| name == PLAN_STATE_FILE_NAME));
731 }
732
733 #[test]
734 fn write_plan_generates_section_index_artifact() {
735 let (_tmp, store) = temp_store();
736 let session_id = "sectioned-session";
737 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";
738
739 store.write_plan(session_id, plan).unwrap();
740 let sections = store
741 .read_sections(session_id)
742 .unwrap()
743 .expect("sections should exist");
744
745 assert!(sections.sections.len() >= 3);
746 let task_alpha = sections
747 .sections
748 .iter()
749 .find(|section| section.id == "task-alpha")
750 .expect("task-alpha section");
751 assert_eq!(task_alpha.heading, "task-alpha");
752 assert!(task_alpha.anchor_terms.iter().any(|term| term == "task-alpha"));
753
754 let step_alpha = sections
755 .sections
756 .iter()
757 .find(|section| section.id == "step-alpha-1")
758 .expect("step-alpha-1 section");
759 assert_eq!(step_alpha.parent_id.as_deref(), Some("task-alpha"));
760 assert!(step_alpha
761 .anchor_terms
762 .iter()
763 .any(|term| term == "step-alpha-1"));
764 }
765}