Skip to main content

thoth_cli/
utils.rs

1use crate::code_block_popup::CodeBlock;
2use anyhow::Result;
3use std::fs::File;
4use std::io::Write;
5use std::path::PathBuf;
6use tui_textarea::TextArea;
7
8/// Notes file parsed into blocks of `(title, lines)`.
9/// `preamble_lines` holds any content found before the first block header
10/// so it can be preserved when the file is rewritten.
11#[derive(Debug, Default, Clone, PartialEq)]
12pub struct ParsedNotes {
13    pub preamble_lines: Vec<String>,
14    pub blocks: Vec<(String, Vec<String>)>,
15}
16
17fn unescape_line(line: &str) -> String {
18    match line.strip_prefix("\\#") {
19        Some(rest) => format!("#{}", rest),
20        None => line.to_string(),
21    }
22}
23
24fn escape_line(line: &str, in_code_block: bool) -> String {
25    if in_code_block || !line.starts_with('#') {
26        line.to_string()
27    } else {
28        format!("\\{}", line)
29    }
30}
31
32/// Parse the notes file format into blocks. Lines starting with `# ` (outside
33/// code blocks) start a new block; the escaped form `\# ` is kept as content.
34pub fn parse_notes(content: &str) -> ParsedNotes {
35    let mut parsed = ParsedNotes::default();
36    let mut current_title = String::new();
37    let mut current_lines: Vec<String> = Vec::new();
38    let mut in_code_block = false;
39    let mut seen_title = false;
40
41    for line in content.lines() {
42        if line.trim().starts_with("```") {
43            in_code_block = !in_code_block;
44        }
45        if !in_code_block && line.starts_with("# ") {
46            if seen_title && !current_title.is_empty() {
47                parsed
48                    .blocks
49                    .push((current_title.clone(), std::mem::take(&mut current_lines)));
50            }
51            seen_title = true;
52            current_title = line[2..].to_string();
53        } else if seen_title {
54            current_lines.push(unescape_line(line));
55        } else {
56            parsed.preamble_lines.push(line.to_string());
57        }
58    }
59
60    if seen_title && !current_title.is_empty() {
61        parsed.blocks.push((current_title, current_lines));
62    }
63
64    parsed
65}
66
67/// Write a `ParsedNotes` back to a writer, escaping content lines that start
68/// with `#` so they are not mistaken for block headers on reload.
69pub fn write_notes<W: Write>(writer: &mut W, parsed: &ParsedNotes) -> Result<()> {
70    for line in &parsed.preamble_lines {
71        writeln!(writer, "{}", line)?;
72    }
73    let mut in_code_block = false;
74    for (title, lines) in &parsed.blocks {
75        writeln!(writer, "# {}", title)?;
76        for line in lines {
77            writeln!(writer, "{}", escape_line(line, in_code_block))?;
78            if line.trim().starts_with("```") {
79                in_code_block = !in_code_block;
80            }
81        }
82    }
83    Ok(())
84}
85
86pub fn extract_code_blocks(content: &str) -> Vec<CodeBlock> {
87    let mut code_blocks = Vec::new();
88    let mut in_code_block = false;
89    let mut current_block = String::new();
90    let mut current_language = String::new();
91    let mut start_line = 0;
92
93    for (i, line) in content.lines().enumerate() {
94        if line.trim().starts_with("```") {
95            if in_code_block {
96                // End of code block
97                code_blocks.push(CodeBlock::new(
98                    current_block.trim_end().to_string(),
99                    current_language.clone(),
100                    start_line,
101                    i,
102                ));
103                current_block.clear();
104                current_language.clear();
105                in_code_block = false;
106            } else {
107                // Start of code block
108                in_code_block = true;
109                start_line = i;
110
111                let lang_part = line.trim_start_matches('`').trim();
112                current_language = lang_part.to_string();
113            }
114        } else if in_code_block {
115            current_block.push_str(line);
116            current_block.push('\n');
117        }
118    }
119
120    if in_code_block && !current_block.is_empty() {
121        code_blocks.push(CodeBlock::new(
122            current_block.trim_end().to_string(),
123            current_language,
124            start_line,
125            content.lines().count() - 1,
126        ));
127    }
128
129    code_blocks
130}
131
132pub fn save_textareas(textareas: &[TextArea], titles: &[String], file_path: PathBuf) -> Result<()> {
133    let parsed = ParsedNotes {
134        preamble_lines: Vec::new(),
135        blocks: textareas
136            .iter()
137            .zip(titles.iter())
138            .map(|(textarea, title)| (title.clone(), textarea.lines().to_vec()))
139            .collect(),
140    };
141    let mut file = File::create(file_path)?;
142    write_notes(&mut file, &parsed)
143}
144
145pub fn load_textareas(file_path: PathBuf) -> Result<(Vec<TextArea<'static>>, Vec<String>)> {
146    let content = std::fs::read_to_string(file_path)?;
147    let parsed = parse_notes(&content);
148    let mut textareas = Vec::with_capacity(parsed.blocks.len().max(1));
149    let mut titles = Vec::with_capacity(parsed.blocks.len().max(1));
150
151    // Preserve content that appears before the first block header instead of
152    // silently discarding it.
153    if !parsed.preamble_lines.is_empty() {
154        let mut preamble_textarea = TextArea::default();
155        for line in &parsed.preamble_lines {
156            preamble_textarea.insert_str(line);
157            preamble_textarea.insert_newline();
158        }
159        textareas.push(preamble_textarea);
160        titles.push(String::from("Untitled"));
161    }
162
163    for (title, lines) in parsed.blocks {
164        let mut textarea = TextArea::default();
165        for line in &lines {
166            textarea.insert_str(line);
167            textarea.insert_newline();
168        }
169        textareas.push(textarea);
170        titles.push(title);
171    }
172
173    Ok((textareas, titles))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_extract_code_blocks() {
182        let content = r#"# Test Document
183        
184```rust
185fn main() {
186    println!("Hello, world!");
187}
188```
189
190Some text here
191
192```python
193def hello():
194    print("Hello")
195```"#;
196
197        let blocks = extract_code_blocks(content);
198        assert_eq!(blocks.len(), 2);
199        assert_eq!(blocks[0].language, "rust");
200        assert_eq!(
201            blocks[0].content,
202            "fn main() {\n    println!(\"Hello, world!\");\n}"
203        );
204        assert_eq!(blocks[1].language, "python");
205        assert_eq!(blocks[1].content, "def hello():\n    print(\"Hello\")");
206    }
207
208    #[test]
209    fn test_extract_empty_code_blocks() {
210        let content = "```rust\n```";
211        let blocks = extract_code_blocks(content);
212        assert_eq!(blocks.len(), 1);
213        assert_eq!(blocks[0].language, "rust");
214        assert_eq!(blocks[0].content, "");
215    }
216
217    #[test]
218    fn test_unclosed_code_block() {
219        let content = "```js\nlet x = 1;";
220        let blocks = extract_code_blocks(content);
221        assert_eq!(blocks.len(), 1);
222        assert_eq!(blocks[0].language, "js");
223        assert_eq!(blocks[0].content, "let x = 1;");
224    }
225
226    #[test]
227    fn test_parse_notes_multi_block() {
228        let content = "# First\nline one\n# Second\nline two\nline three";
229        let parsed = parse_notes(content);
230        assert_eq!(parsed.preamble_lines.len(), 0);
231        assert_eq!(parsed.blocks.len(), 2);
232        assert_eq!(
233            parsed.blocks[0],
234            ("First".to_string(), vec!["line one".to_string()])
235        );
236        assert_eq!(
237            parsed.blocks[1],
238            (
239                "Second".to_string(),
240                vec!["line two".to_string(), "line three".to_string()]
241            )
242        );
243    }
244
245    #[test]
246    fn test_write_roundtrip_with_escapes() {
247        let content = "# Notes\nplain text\n\\# not a header\n```rust\n# this is code\n```";
248        let parsed = parse_notes(content);
249        assert_eq!(parsed.blocks.len(), 1);
250        let (title, lines) = &parsed.blocks[0];
251        assert_eq!(title, "Notes");
252        assert_eq!(
253            lines,
254            &vec![
255                "plain text".to_string(),
256                "# not a header".to_string(),
257                "```rust".to_string(),
258                "# this is code".to_string(),
259                "```".to_string(),
260            ]
261        );
262
263        let mut buffer = Vec::new();
264        write_notes(&mut buffer, &parsed).unwrap();
265        // Rewritten files always end with a trailing newline
266        assert_eq!(String::from_utf8(buffer).unwrap(), format!("{}\n", content));
267    }
268
269    #[test]
270    fn test_preamble_preserved() {
271        let content = "some preamble text\n# Only block\ncontent\n";
272        let parsed = parse_notes(content);
273        assert_eq!(
274            parsed.preamble_lines,
275            vec!["some preamble text".to_string()]
276        );
277        assert_eq!(parsed.blocks.len(), 1);
278
279        let mut buffer = Vec::new();
280        write_notes(&mut buffer, &parsed).unwrap();
281        assert_eq!(String::from_utf8(buffer).unwrap(), content);
282    }
283
284    #[test]
285    fn test_headings_inside_code_blocks_are_content() {
286        let content = "# Block\n```md\n# Header inside code\n```";
287        let parsed = parse_notes(content);
288        assert_eq!(parsed.blocks.len(), 1);
289        assert_eq!(parsed.blocks[0].1.len(), 3);
290    }
291}