wisp/components/plan_review/
document.rs1use tui::{MarkdownHeading, parse_markdown_headings};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct PlanDocument {
5 pub path: String,
6 pub lines: Vec<PlanSourceLine>,
7 pub outline: Vec<PlanSection>,
8}
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct PlanSourceLine {
12 pub line_no: usize,
13 pub text: String,
14 pub section_index: Option<usize>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct PlanSection {
19 pub title: String,
20 pub level: u8,
21 pub first_line_no: usize,
22}
23
24impl PlanDocument {
25 pub fn parse(path: impl Into<String>, markdown: &str) -> Self {
26 let outline = parse_markdown_headings(markdown).into_iter().map(PlanSection::from).collect::<Vec<_>>();
27 let mut lines = markdown
28 .split('\n')
29 .enumerate()
30 .map(|(index, raw_line)| PlanSourceLine {
31 line_no: index + 1,
32 text: raw_line.trim_end_matches('\r').to_string(),
33 section_index: None,
34 })
35 .collect::<Vec<_>>();
36
37 assign_section_indices(&mut lines, &outline);
38
39 Self { path: path.into(), lines, outline }
40 }
41
42 pub fn section_title_for(&self, line: &PlanSourceLine) -> Option<&str> {
43 line.section_index.and_then(|index| self.outline.get(index)).map(|section| section.title.as_str())
44 }
45
46 pub fn markdown_text(&self) -> String {
47 self.lines.iter().map(|line| line.text.as_str()).collect::<Vec<_>>().join("\n")
48 }
49
50 pub fn line_count(&self) -> usize {
51 self.lines.len()
52 }
53
54 pub fn line_by_no(&self, line_no: usize) -> Option<&PlanSourceLine> {
55 line_no.checked_sub(1).and_then(|index| self.lines.get(index))
56 }
57}
58
59fn assign_section_indices(lines: &mut [PlanSourceLine], outline: &[PlanSection]) {
60 let mut outline_index = 0;
61 let mut current_section: Option<usize> = None;
62
63 for line in lines {
64 while let Some(section) = outline.get(outline_index)
65 && section.first_line_no <= line.line_no
66 {
67 current_section = Some(outline_index);
68 outline_index += 1;
69 }
70
71 line.section_index = current_section;
72 }
73}
74
75impl From<MarkdownHeading> for PlanSection {
76 fn from(value: MarkdownHeading) -> Self {
77 Self { title: value.title, level: value.level, first_line_no: value.source_line_no }
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn parse_preserves_source_line_numbers() {
87 let document = PlanDocument::parse("plan.md", "# Title\n\n- item\nparagraph");
88
89 let line_numbers: Vec<_> = document.lines.iter().map(|line| line.line_no).collect();
90 assert_eq!(line_numbers, vec![1, 2, 3, 4]);
91 }
92
93 #[test]
94 fn parse_builds_outline_from_headings() {
95 let document = PlanDocument::parse("plan.md", "# Top\n## Child\ntext");
96
97 assert_eq!(document.outline.len(), 2);
98 assert_eq!(document.outline[0].title, "Top");
99 assert_eq!(document.outline[0].first_line_no, 1);
100 assert_eq!(document.outline[1].title, "Child");
101 assert_eq!(document.outline[1].first_line_no, 2);
102 }
103
104 #[test]
105 fn parse_preserves_raw_source_lines_for_feedback() {
106 let document = PlanDocument::parse("plan.md", "# Intro\n`inline` and **bold**\n```rust");
107
108 assert_eq!(document.lines[1].text, "`inline` and **bold**");
109 assert_eq!(document.lines[2].text, "```rust");
110 assert_eq!(document.markdown_text(), "# Intro\n`inline` and **bold**\n```rust");
111 }
112
113 #[test]
114 fn parse_tracks_active_section_title_for_lines() {
115 let document = PlanDocument::parse("plan.md", "# Intro\nline\n## Details\nmore");
116
117 assert_eq!(document.section_title_for(&document.lines[0]), Some("Intro"));
118 assert_eq!(document.section_title_for(&document.lines[1]), Some("Intro"));
119 assert_eq!(document.section_title_for(&document.lines[2]), Some("Details"));
120 assert_eq!(document.section_title_for(&document.lines[3]), Some("Details"));
121 }
122
123 #[test]
124 fn line_by_no_returns_line_when_present() {
125 let document = PlanDocument::parse("plan.md", "first\nsecond");
126 let line = document.line_by_no(2).expect("line exists");
127 assert_eq!(line.text, "second");
128 }
129}