1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::fs::{self, File, OpenOptions};
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17
18const PLAN_FILE_NAME: &str = "plan.md";
19const PLAN_STATE_FILE_NAME: &str = "state.json";
20const PLAN_CURSOR_FILE_NAME: &str = "cursor.json";
21const PLAN_SECTIONS_FILE_NAME: &str = "sections.json";
22const PLAN_ARTIFACT_VERSION: u32 = 1;
23
24#[derive(Debug, thiserror::Error)]
26pub enum PlanStoreError {
27 #[error("IO error: {0}")]
28 Io(#[from] std::io::Error),
29 #[error("JSON error: {0}")]
30 Json(#[from] serde_json::Error),
31 #[error("Plan directory not accessible: {0}")]
32 DirectoryNotAccessible(String),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct PlanStateArtifact {
38 pub version: u32,
39 pub session_id: String,
40 pub updated_at: DateTime<Utc>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub status: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub active_task_id: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub active_step_id: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub next_step_id: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub active_section_id: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub next_section_id: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub last_completed_task_id: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub last_completed_section_id: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub round_hint: Option<u32>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub plan_hash: Option<String>,
61}
62
63impl PlanStateArtifact {
64 pub fn new(session_id: impl Into<String>) -> Self {
65 Self {
66 version: PLAN_ARTIFACT_VERSION,
67 session_id: session_id.into(),
68 updated_at: Utc::now(),
69 status: None,
70 active_task_id: None,
71 active_step_id: None,
72 next_step_id: None,
73 active_section_id: None,
74 next_section_id: None,
75 last_completed_task_id: None,
76 last_completed_section_id: None,
77 round_hint: None,
78 plan_hash: None,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
85pub struct PlanCursorArtifact {
86 pub version: u32,
87 pub session_id: String,
88 pub updated_at: DateTime<Utc>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub cursor_type: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub current_task_id: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub current_task_ordinal: Option<u32>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub current_step_id: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub current_section_id: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub next_task_id: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub next_task_ordinal: Option<u32>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub next_section_id: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub last_completed_task_id: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub last_completed_section_id: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub last_completed_checkpoint: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub round_hint: Option<u32>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub round_id_hint: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub suspension_hook_point: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub tool_call_boundary: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub resume_note: Option<String>,
121}
122
123impl PlanCursorArtifact {
124 pub fn new(session_id: impl Into<String>) -> Self {
125 Self {
126 version: PLAN_ARTIFACT_VERSION,
127 session_id: session_id.into(),
128 updated_at: Utc::now(),
129 cursor_type: None,
130 current_task_id: None,
131 current_task_ordinal: None,
132 current_step_id: None,
133 current_section_id: None,
134 next_task_id: None,
135 next_task_ordinal: None,
136 next_section_id: None,
137 last_completed_task_id: None,
138 last_completed_section_id: None,
139 last_completed_checkpoint: None,
140 round_hint: None,
141 round_id_hint: None,
142 suspension_hook_point: None,
143 tool_call_boundary: None,
144 resume_note: None,
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
151pub struct PlanSectionArtifact {
152 pub version: u32,
153 pub session_id: String,
154 pub updated_at: DateTime<Utc>,
155 pub sections: Vec<PlanSection>,
156}
157
158impl PlanSectionArtifact {
159 pub fn new(session_id: impl Into<String>, sections: Vec<PlanSection>) -> Self {
160 Self {
161 version: PLAN_ARTIFACT_VERSION,
162 session_id: session_id.into(),
163 updated_at: Utc::now(),
164 sections,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
170pub struct PlanSection {
171 pub id: String,
172 pub heading: String,
173 pub level: u8,
174 pub line_start: usize,
175 pub line_end: usize,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub parent_id: Option<String>,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub anchor_terms: Vec<String>,
180}
181
182#[derive(Debug, Clone)]
184pub struct PlanStore {
185 plans_dir: PathBuf,
186}
187
188impl PlanStore {
189 pub fn new(data_dir: impl AsRef<Path>) -> Result<Self, PlanStoreError> {
193 let plans_dir = data_dir.as_ref().join("plan");
194 fs::create_dir_all(&plans_dir).map_err(|e| {
195 PlanStoreError::DirectoryNotAccessible(format!(
196 "Failed to create plan directory at {}: {}",
197 plans_dir.display(),
198 e
199 ))
200 })?;
201 Ok(Self { plans_dir })
202 }
203
204 fn session_slug(session_id: &str) -> String {
209 let clean = session_id
210 .chars()
211 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
212 .collect::<String>();
213
214 if clean.len() <= 16 {
215 return clean;
216 }
217
218 let prefix: String = clean.chars().take(8).collect();
219 let suffix: String = clean
220 .chars()
221 .rev()
222 .take(8)
223 .collect::<Vec<_>>()
224 .into_iter()
225 .rev()
226 .collect();
227 format!(
228 "{}-{}-{:x}",
229 prefix,
230 suffix,
231 seahash::hash(clean.as_bytes())
232 )
233 }
234
235 fn session_dir_path_internal(&self, session_id: &str) -> PathBuf {
236 self.plans_dir.join(Self::session_slug(session_id))
237 }
238
239 fn preferred_plan_file_path(&self, session_id: &str) -> PathBuf {
240 self.session_dir_path_internal(session_id)
241 .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 atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), PlanStoreError> {
270 if let Some(parent) = path.parent() {
271 fs::create_dir_all(parent)?;
272 }
273
274 let temp_path = crate::atomic_fs::unique_temp_path(path);
275 let mut file = OpenOptions::new()
276 .create(true)
277 .truncate(true)
278 .write(true)
279 .open(&temp_path)?;
280 file.write_all(bytes)?;
281 file.flush()?;
282 file.sync_all()?;
283 drop(file);
284
285 fs::rename(&temp_path, path)?;
286
287 if let Some(dir) = path.parent().and_then(|parent| File::open(parent).ok()) {
288 let _ = dir.sync_all();
289 }
290
291 Ok(())
292 }
293
294 fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), PlanStoreError> {
295 let bytes = serde_json::to_vec_pretty(value)?;
296 Self::atomic_write_bytes(path, &bytes)
297 }
298
299 fn read_json_artifact<T: for<'de> Deserialize<'de>>(
300 &self,
301 path: &Path,
302 ) -> Result<Option<T>, PlanStoreError> {
303 if !path.exists() {
304 return Ok(None);
305 }
306 let raw = fs::read_to_string(path)?;
307 Ok(Some(serde_json::from_str(&raw)?))
308 }
309
310 fn normalize_section_token(token: &str) -> String {
311 let mut normalized = String::new();
312 let mut last_was_dash = false;
313
314 for ch in token.chars() {
315 if ch.is_ascii_alphanumeric() {
316 normalized.push(ch.to_ascii_lowercase());
317 last_was_dash = false;
318 } else if (ch.is_whitespace() || matches!(ch, '-' | '_' | ':' | '.')) && !last_was_dash
319 {
320 normalized.push('-');
321 last_was_dash = true;
322 }
323 }
324
325 normalized = normalized.trim_matches('-').to_string();
326 if normalized.is_empty() {
327 "section".to_string()
328 } else {
329 normalized
330 }
331 }
332
333 fn extract_inline_anchor_terms(line: &str, heading: &str) -> Vec<String> {
334 let mut anchors = Vec::new();
335 let heading_trimmed = heading.trim();
336 if !heading_trimmed.is_empty() {
337 anchors.push(heading_trimmed.to_string());
338 }
339
340 let trimmed = line.trim();
341 if let Some((key, value)) = trimmed.split_once(':') {
342 let key = key.trim();
343 let value = value.trim();
344 if matches!(
345 key,
346 "- task_id"
347 | "task_id"
348 | "- current_step_id"
349 | "current_step_id"
350 | "- step_id"
351 | "step_id"
352 ) && !value.is_empty()
353 {
354 anchors.push(value.to_string());
355 }
356 }
357
358 anchors.sort();
359 anchors.dedup();
360 anchors
361 }
362
363 fn heading_level_and_title(line: &str) -> Option<(u8, String)> {
364 let trimmed = line.trim_start();
365 let hashes = trimmed.chars().take_while(|ch| *ch == '#').count();
366 if hashes == 0 {
367 return None;
368 }
369 let title = trimmed[hashes..].trim();
370 if title.is_empty() {
371 return None;
372 }
373 Some((hashes as u8, title.to_string()))
374 }
375
376 fn index_plan_sections(session_id: &str, content: &str) -> PlanSectionArtifact {
377 let lines: Vec<&str> = content.lines().collect();
378 let mut sections = Vec::new();
379 let mut heading_indices = Vec::new();
380
381 for (index, line) in lines.iter().enumerate() {
382 if let Some((level, heading)) = Self::heading_level_and_title(line) {
383 heading_indices.push((index, level, heading));
384 }
385 }
386
387 for (position, (line_start, level, heading)) in heading_indices.iter().enumerate() {
388 let line_end = heading_indices
389 .get(position + 1)
390 .map(|(next_start, _, _)| next_start.saturating_sub(1))
391 .unwrap_or_else(|| lines.len().saturating_sub(1));
392
393 let parent_id = heading_indices[..position]
394 .iter()
395 .rev()
396 .find(|(_, candidate_level, _)| *candidate_level < *level)
397 .map(|(_, _, candidate_heading)| Self::normalize_section_token(candidate_heading));
398
399 let mut anchor_terms = vec![heading.clone()];
400 for line in lines[*line_start..=line_end].iter().take(10) {
401 anchor_terms.extend(Self::extract_inline_anchor_terms(line, heading));
402 }
403 anchor_terms.sort();
404 anchor_terms.dedup();
405
406 sections.push(PlanSection {
407 id: Self::normalize_section_token(heading),
408 heading: heading.clone(),
409 level: *level,
410 line_start: *line_start,
411 line_end,
412 parent_id,
413 anchor_terms,
414 });
415 }
416
417 PlanSectionArtifact::new(session_id, sections)
418 }
419
420 pub fn session_dir_path(&self, session_id: &str) -> PathBuf {
422 self.session_dir_path_internal(session_id)
423 }
424
425 pub fn plan_file_path(&self, session_id: &str) -> PathBuf {
430 self.resolved_plan_file_path_internal(session_id)
431 }
432
433 pub fn state_file_path(&self, session_id: &str) -> PathBuf {
435 self.session_dir_path_internal(session_id)
436 .join(PLAN_STATE_FILE_NAME)
437 }
438
439 pub fn cursor_file_path(&self, session_id: &str) -> PathBuf {
441 self.session_dir_path_internal(session_id)
442 .join(PLAN_CURSOR_FILE_NAME)
443 }
444
445 pub fn sections_file_path(&self, session_id: &str) -> PathBuf {
447 self.session_dir_path_internal(session_id)
448 .join(PLAN_SECTIONS_FILE_NAME)
449 }
450
451 pub fn write_plan(
456 &self,
457 session_id: &str,
458 content: impl AsRef<str>,
459 ) -> Result<PathBuf, PlanStoreError> {
460 self.ensure_session_dir(session_id)?;
461 let content = content.as_ref();
462 let path = self.preferred_plan_file_path(session_id);
463 Self::atomic_write_bytes(&path, content.as_bytes())?;
464
465 let sections = Self::index_plan_sections(session_id, content);
466 let sections_path = self.sections_file_path(session_id);
467 Self::atomic_write_json(§ions_path, §ions)?;
468
469 let legacy_path = self.legacy_plan_file_path(session_id);
470 if legacy_path.exists() {
471 let _ = fs::remove_file(legacy_path);
472 }
473
474 Ok(path)
475 }
476
477 pub fn read_plan(&self, session_id: &str) -> Option<String> {
479 let path = self.resolved_plan_file_path_internal(session_id);
480 fs::read_to_string(&path).ok()
481 }
482
483 pub fn plan_exists(&self, session_id: &str) -> bool {
485 self.resolved_plan_file_path_internal(session_id).exists()
486 }
487
488 pub fn write_state(
490 &self,
491 session_id: &str,
492 state: &PlanStateArtifact,
493 ) -> Result<PathBuf, PlanStoreError> {
494 self.ensure_session_dir(session_id)?;
495 let path = self.state_file_path(session_id);
496 Self::atomic_write_json(&path, state)?;
497 Ok(path)
498 }
499
500 pub fn read_state(
502 &self,
503 session_id: &str,
504 ) -> Result<Option<PlanStateArtifact>, PlanStoreError> {
505 self.read_json_artifact(&self.state_file_path(session_id))
506 }
507
508 pub fn write_cursor(
510 &self,
511 session_id: &str,
512 cursor: &PlanCursorArtifact,
513 ) -> Result<PathBuf, PlanStoreError> {
514 self.ensure_session_dir(session_id)?;
515 let path = self.cursor_file_path(session_id);
516 Self::atomic_write_json(&path, cursor)?;
517 Ok(path)
518 }
519
520 pub fn read_cursor(
522 &self,
523 session_id: &str,
524 ) -> Result<Option<PlanCursorArtifact>, PlanStoreError> {
525 self.read_json_artifact(&self.cursor_file_path(session_id))
526 }
527
528 pub fn read_sections(
530 &self,
531 session_id: &str,
532 ) -> Result<Option<PlanSectionArtifact>, PlanStoreError> {
533 self.read_json_artifact(&self.sections_file_path(session_id))
534 }
535
536 pub fn delete_plan(&self, session_id: &str) -> Result<(), PlanStoreError> {
538 let legacy_path = self.legacy_plan_file_path(session_id);
539 if legacy_path.exists() {
540 fs::remove_file(&legacy_path)?;
541 }
542
543 let session_dir = self.session_dir_path_internal(session_id);
544 if session_dir.exists() {
545 fs::remove_dir_all(session_dir)?;
546 }
547
548 Ok(())
549 }
550
551 pub fn plans_dir(&self) -> &Path {
553 &self.plans_dir
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 fn temp_store() -> (tempfile::TempDir, PlanStore) {
562 let temp_dir = tempfile::tempdir().unwrap();
563 let store = PlanStore::new(temp_dir.path()).unwrap();
564 (temp_dir, store)
565 }
566
567 #[test]
568 fn session_slug_produces_short_identifier() {
569 let id = "sess-abc123-def456-ghi789";
570 let slug = PlanStore::session_slug(id);
571 assert!(!slug.is_empty());
572 assert!(!slug.contains('/'));
573 assert!(!slug.contains('\\'));
574 }
575
576 #[test]
577 fn write_and_read_plan() {
578 let (_tmp, store) = temp_store();
579 let session_id = "test-session-001";
580 let content = "# Implementation Plan\n\n1. Step one\n2. Step two\n";
581
582 let path = store.write_plan(session_id, content).unwrap();
583 assert!(path.exists());
584 assert!(store.plan_exists(session_id));
585
586 let read = store.read_plan(session_id).unwrap();
587 assert_eq!(read, content);
588 }
589
590 #[test]
591 fn read_nonexistent_plan_returns_none() {
592 let (_tmp, store) = temp_store();
593 assert!(store.read_plan("nonexistent-session").is_none());
594 assert!(!store.plan_exists("nonexistent-session"));
595 }
596
597 #[test]
598 fn write_plan_overwrites_existing() {
599 let (_tmp, store) = temp_store();
600 let session_id = "test-session-002";
601
602 store.write_plan(session_id, "Plan v1").unwrap();
603 store.write_plan(session_id, "Plan v2").unwrap();
604
605 let read = store.read_plan(session_id).unwrap();
606 assert_eq!(read, "Plan v2");
607 }
608
609 #[test]
610 fn delete_plan_removes_artifact_directory() {
611 let (_tmp, store) = temp_store();
612 let session_id = "test-session-003";
613
614 store.write_plan(session_id, "Plan to delete").unwrap();
615 store
616 .write_state(session_id, &PlanStateArtifact::new(session_id))
617 .unwrap();
618 store
619 .write_cursor(session_id, &PlanCursorArtifact::new(session_id))
620 .unwrap();
621 assert!(store.plan_exists(session_id));
622 assert!(store.session_dir_path(session_id).exists());
623
624 store.delete_plan(session_id).unwrap();
625 assert!(!store.plan_exists(session_id));
626 assert!(!store.session_dir_path(session_id).exists());
627 }
628
629 #[test]
630 fn delete_nonexistent_plan_is_noop() {
631 let (_tmp, store) = temp_store();
632 store.delete_plan("never-created").unwrap();
633 }
634
635 #[test]
636 fn plan_file_path_is_under_session_artifact_dir() {
637 let (_tmp, store) = temp_store();
638 let path = store.plan_file_path("some-session");
639 assert!(path.starts_with(&store.plans_dir));
640 assert_eq!(
641 path.file_name().and_then(|n| n.to_str()),
642 Some(PLAN_FILE_NAME)
643 );
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
753 .anchor_terms
754 .iter()
755 .any(|term| term == "task-alpha"));
756
757 let step_alpha = sections
758 .sections
759 .iter()
760 .find(|section| section.id == "step-alpha-1")
761 .expect("step-alpha-1 section");
762 assert_eq!(step_alpha.parent_id.as_deref(), Some("task-alpha"));
763 assert!(step_alpha
764 .anchor_terms
765 .iter()
766 .any(|term| term == "step-alpha-1"));
767 }
768}