Skip to main content

terraphim_markdown_parser/
lib.rs

1//! Markdown parser for Terraphim knowledge-graph documents.
2//!
3//! Converts markdown files into typed [`terraphim_types::Document`] values,
4//! extracting titles, tags, and body text for indexing and search.
5use std::collections::HashSet;
6use std::ops::Range;
7use std::str::FromStr;
8
9use markdown::ParseOptions;
10use markdown::mdast::Node;
11use terraphim_types::{Document, DocumentType};
12use thiserror::Error;
13use ulid::Ulid;
14
15pub mod chunk;
16pub mod heading;
17pub mod limits;
18
19pub use chunk::{ContentChunk, chunk_by_headings};
20pub use heading::{
21    HeadingNode, HeadingTree, MatchStrategy, SectionConfig, SectionPattern, SectionType,
22    build_heading_tree, classify_sections,
23};
24pub use limits::{AstStats, LimitKind, ParseLimits, ParseLimitsError, measure_ast};
25
26pub const TERRAPHIM_BLOCK_ID_PREFIX: &str = "terraphim:block-id:";
27
28/// Extract the first H1 heading from markdown content using AST parsing.
29///
30/// Returns the heading text with original case preserved, or `None` if no
31/// `# Heading` is found. Only matches depth-1 headings (`#`, not `##`).
32pub fn extract_first_heading(content: &str) -> Option<String> {
33    let ast = markdown::to_mdast(content, &ParseOptions::gfm()).ok()?;
34    find_first_h1(&ast)
35}
36
37/// Walk the AST to find the first depth-1 heading and collect its text content.
38fn find_first_h1(node: &Node) -> Option<String> {
39    match node {
40        Node::Heading(h) if h.depth == 1 => {
41            let text = collect_text_content(&h.children);
42            if text.is_empty() { None } else { Some(text) }
43        }
44        _ => {
45            if let Some(children) = children(node) {
46                for child in children {
47                    if let Some(heading) = find_first_h1(child) {
48                        return Some(heading);
49                    }
50                }
51            }
52            None
53        }
54    }
55}
56
57/// Recursively collect all text content from AST nodes.
58pub(crate) fn collect_text_content(nodes: &[Node]) -> String {
59    let mut text = String::new();
60    for node in nodes {
61        match node {
62            Node::Text(t) => text.push_str(&t.value),
63            Node::InlineCode(c) => text.push_str(&c.value),
64            other => {
65                if let Some(children) = children(other) {
66                    text.push_str(&collect_text_content(children));
67                }
68            }
69        }
70    }
71    text
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum BlockKind {
76    Paragraph,
77    ListItem,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Block {
82    pub id: Ulid,
83    pub kind: BlockKind,
84
85    /// Byte span of the block in the markdown buffer.
86    ///
87    /// For paragraphs, this includes the block-id comment line plus the paragraph content.
88    /// For list items, this includes the full list item (including nested content).
89    pub span: Range<usize>,
90
91    /// Byte span of the block-id anchor.
92    ///
93    /// For paragraphs, this is the full comment line (including any leading quote/indent prefix).
94    /// For list items, this is the inline HTML comment inside the list item’s first line.
95    pub id_span: Range<usize>,
96}
97
98#[derive(Debug, Clone)]
99pub struct NormalizedMarkdown {
100    pub markdown: String,
101    pub blocks: Vec<Block>,
102    pub ast: Option<markdown::mdast::Node>,
103}
104
105#[derive(Debug, Error)]
106pub enum MarkdownParserError {
107    #[error("failed to parse markdown: {0}")]
108    Markdown(String),
109
110    #[error("missing or invalid terraphim block id for {0:?} at byte offset {1}")]
111    MissingOrInvalidBlockId(BlockKind, usize),
112
113    #[error("markdown limit exceeded: {0} limit of {1} (actual: {2})")]
114    LimitExceeded(LimitKind, usize, usize),
115}
116
117impl From<markdown::message::Message> for MarkdownParserError {
118    fn from(value: markdown::message::Message) -> Self {
119        Self::Markdown(format!("{value:?}"))
120    }
121}
122
123#[derive(Debug, Clone)]
124struct Edit {
125    range: Range<usize>,
126    replacement: String,
127}
128
129impl Edit {
130    fn insert(at: usize, text: String) -> Self {
131        Self {
132            range: at..at,
133            replacement: text,
134        }
135    }
136}
137
138/// Ensure every list item and paragraph has a stable Terraphim block id.
139///
140/// Canonical forms:
141/// - Paragraph: `<!-- terraphim:block-id:<ULID> -->` on its own line immediately before the paragraph
142/// - List item: inline after the marker (and optional task checkbox), e.g. `- <!-- terraphim:block-id:<ULID> --> text`
143pub fn ensure_terraphim_block_ids(markdown: &str) -> Result<String, MarkdownParserError> {
144    let ast = markdown::to_mdast(markdown, &ParseOptions::gfm())?;
145    let mut edits: Vec<Edit> = Vec::new();
146    ensure_block_ids_in_children(&ast, markdown, &mut edits, ParentKind::Other);
147
148    if edits.is_empty() {
149        return Ok(markdown.to_string());
150    }
151
152    // Apply edits from the end of the buffer to the beginning so byte offsets stay valid.
153    edits.sort_by_key(|e| std::cmp::Reverse(e.range.start));
154    let mut out = markdown.to_string();
155    for edit in edits {
156        out.replace_range(edit.range, &edit.replacement);
157    }
158    Ok(out)
159}
160
161/// Normalize markdown into canonical Terraphim form and return the extracted blocks.
162///
163/// Uses an iterative fixed-point loop (up to 4 passes) because inserting inline
164/// block IDs into list items can change how the markdown crate re-parses the
165/// result, exposing adjacent paragraphs as root-level paragraphs without block
166/// IDs. Repeating until the output stabilizes ensures every block is tagged.
167pub fn normalize_markdown(markdown: &str) -> Result<NormalizedMarkdown, MarkdownParserError> {
168    let mut current = ensure_terraphim_block_ids(markdown)?;
169    for _ in 0..4 {
170        let next = ensure_terraphim_block_ids(&current)?;
171        if next == current {
172            break;
173        }
174        current = next;
175    }
176    let blocks = extract_blocks(&current)?;
177    let ast = markdown::to_mdast(&current, &ParseOptions::gfm()).ok();
178    Ok(NormalizedMarkdown {
179        markdown: current,
180        blocks,
181        ast,
182    })
183}
184
185/// A recovered-parse diagnostic describing loss, a parse failure, or a limit violation.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct ParseDiagnostic {
188    /// What went wrong, as a human-readable message.
189    pub message: String,
190    /// Byte offset in the source, when known.
191    pub offset: Option<usize>,
192    /// The limit that was exceeded, when the diagnostic is a limit violation.
193    pub limit: Option<LimitKind>,
194}
195
196/// The outcome of a recovering parse: whatever output could be produced,
197/// explicit diagnostics, and whether the output is complete (loss-free).
198#[derive(Debug, Clone)]
199pub struct ParseReport {
200    /// Normalized output produced before (or without) failure. Empty when
201    /// parsing could not proceed at all.
202    pub normalized: NormalizedMarkdown,
203    /// Every recovered error or loss event encountered.
204    pub diagnostics: Vec<ParseDiagnostic>,
205    /// True only when no loss or error occurred.
206    pub complete: bool,
207}
208
209/// Enforce pre-parse limits, mapping validation errors to typed parser errors.
210fn enforce_input_limits(markdown: &str, limits: &ParseLimits) -> Result<(), MarkdownParserError> {
211    limits.validate_input(markdown).map_err(|e| match e {
212        ParseLimitsError::InputTooLarge { limit, actual } => {
213            MarkdownParserError::LimitExceeded(LimitKind::InputBytes, limit, actual)
214        }
215        ParseLimitsError::LineTooLong { limit, actual } => {
216            MarkdownParserError::LimitExceeded(LimitKind::LineLength, limit, actual)
217        }
218    })
219}
220
221/// Parse the source AST and enforce depth/node limits.
222fn parse_ast_within_limits(
223    markdown: &str,
224    limits: &ParseLimits,
225) -> Result<markdown::mdast::Node, MarkdownParserError> {
226    let ast = markdown::to_mdast(markdown, &ParseOptions::gfm())?;
227    let stats = measure_ast(&ast).ok_or(MarkdownParserError::LimitExceeded(
228        LimitKind::NodeCount,
229        limits.max_nodes,
230        usize::MAX,
231    ))?;
232    if stats.depth > limits.max_depth {
233        return Err(MarkdownParserError::LimitExceeded(
234            LimitKind::NestingDepth,
235            limits.max_depth,
236            stats.depth,
237        ));
238    }
239    if stats.nodes > limits.max_nodes {
240        return Err(MarkdownParserError::LimitExceeded(
241            LimitKind::NodeCount,
242            limits.max_nodes,
243            stats.nodes,
244        ));
245    }
246    Ok(ast)
247}
248
249/// Strict parsing: bounded, lossless, and fail-closed.
250///
251/// Returns the normalized markdown with extracted blocks, or a typed error.
252/// A successful result never hides a parse error, a resource-limit violation,
253/// or silently dropped in-scope content (block extraction is validated).
254pub fn parse_strict(
255    markdown: &str,
256    limits: &ParseLimits,
257) -> Result<NormalizedMarkdown, MarkdownParserError> {
258    enforce_input_limits(markdown, limits)?;
259    parse_ast_within_limits(markdown, limits)?;
260    let normalized = normalize_markdown(markdown)?;
261    if normalized.markdown.len() > limits.max_output_bytes {
262        return Err(MarkdownParserError::LimitExceeded(
263            LimitKind::OutputBytes,
264            limits.max_output_bytes,
265            normalized.markdown.len(),
266        ));
267    }
268    Ok(normalized)
269}
270
271/// Recovering parsing: bounded, and loss-reporting instead of fail-closed.
272///
273/// Returns whatever output could be produced together with a diagnostic for
274/// every recovered error and a [`ParseReport::complete`] flag that is false
275/// whenever any loss occurred. Never returns silently truncated output that
276/// claims to be complete.
277pub fn parse_recovering(
278    markdown: &str,
279    limits: &ParseLimits,
280) -> Result<ParseReport, MarkdownParserError> {
281    let mut diagnostics: Vec<ParseDiagnostic> = Vec::new();
282
283    let normalized = match parse_strict(markdown, limits) {
284        Ok(n) => n,
285        Err(e) => {
286            let (limit, offset) = match &e {
287                MarkdownParserError::LimitExceeded(kind, _, _) => (Some(*kind), None),
288                MarkdownParserError::MissingOrInvalidBlockId(_, offset) => (None, Some(*offset)),
289                MarkdownParserError::Markdown(_) => (None, None),
290            };
291            diagnostics.push(ParseDiagnostic {
292                message: e.to_string(),
293                offset,
294                limit,
295            });
296            NormalizedMarkdown {
297                markdown: String::new(),
298                blocks: Vec::new(),
299                ast: None,
300            }
301        }
302    };
303
304    let complete = diagnostics.is_empty();
305    Ok(ParseReport {
306        normalized,
307        diagnostics,
308        complete,
309    })
310}
311
312/// Convert extracted blocks into Terraphim `Document`s so downstream graph tooling can be reused.
313pub fn blocks_to_documents(source_id: &str, normalized: &NormalizedMarkdown) -> Vec<Document> {
314    normalized
315        .blocks
316        .iter()
317        .map(|block| {
318            let block_id = block.id.to_string();
319            let id = format!("{source_id}#{block_id}");
320            let body = strip_terraphim_block_id_comments(&normalized.markdown[block.span.clone()])
321                .trim()
322                .to_string();
323            let title = first_nonempty_line(&body).unwrap_or_else(|| "Untitled".to_string());
324            Document {
325                id,
326                url: source_id.to_string(),
327                title,
328                body,
329                description: None,
330                summarization: None,
331                stub: None,
332                tags: None,
333                rank: None,
334                source_haystack: None,
335                doc_type: DocumentType::KgEntry,
336                synonyms: None,
337                route: None,
338                priority: None,
339                quality_score: None,
340            }
341        })
342        .collect()
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346enum ParentKind {
347    ListItem,
348    Other,
349}
350
351fn ensure_block_ids_in_children(
352    node: &Node,
353    source: &str,
354    edits: &mut Vec<Edit>,
355    parent: ParentKind,
356) {
357    match node {
358        Node::Root(root) => {
359            ensure_block_ids_in_list(&root.children, source, edits, ParentKind::Other)
360        }
361        Node::Blockquote(bq) => ensure_block_ids_in_list(&bq.children, source, edits, parent),
362        Node::List(list) => ensure_block_ids_in_list(&list.children, source, edits, parent),
363        Node::ListItem(li) => {
364            if let Some(pos) = node.position() {
365                ensure_list_item_inline_id(source, pos.start.offset, edits);
366            }
367            ensure_block_ids_in_list(&li.children, source, edits, ParentKind::ListItem);
368        }
369        _ => {
370            if let Some(children) = children(node) {
371                ensure_block_ids_in_list(children, source, edits, parent);
372            }
373        }
374    }
375}
376
377fn ensure_block_ids_in_list(
378    children: &[Node],
379    source: &str,
380    edits: &mut Vec<Edit>,
381    parent: ParentKind,
382) {
383    let mut first_direct_paragraph_in_list_item = false;
384
385    for (idx, child) in children.iter().enumerate() {
386        match child {
387            Node::ListItem(_) => ensure_block_ids_in_children(child, source, edits, parent),
388            Node::Paragraph(_) => {
389                // The first direct paragraph of a list item is considered owned by the list item’s
390                // inline block id, so we do not insert a separate comment line for it.
391                if parent == ParentKind::ListItem && !first_direct_paragraph_in_list_item {
392                    first_direct_paragraph_in_list_item = true;
393                } else if let Some(pos) = child.position() {
394                    let has_prev_block_id = idx
395                        .checked_sub(1)
396                        .and_then(|prev| parse_block_id_from_html_node(&children[prev]))
397                        .is_some();
398                    if !has_prev_block_id {
399                        edits.push(insert_paragraph_id_comment(source, pos.start.offset));
400                    }
401                }
402            }
403            _ => ensure_block_ids_in_children(child, source, edits, parent),
404        }
405    }
406}
407
408fn insert_paragraph_id_comment(source: &str, paragraph_start: usize) -> Edit {
409    let (line_start, prefix) = line_prefix_at(source, paragraph_start);
410    let id = Ulid::new();
411    Edit::insert(
412        line_start,
413        format!("{prefix}<!-- terraphim:block-id:{id} -->\n"),
414    )
415}
416
417fn ensure_list_item_inline_id(source: &str, list_item_start: usize, edits: &mut Vec<Edit>) {
418    let (line_start, line_end) = line_bounds_at(source, list_item_start);
419    let line = &source[line_start..line_end];
420
421    if let Some((comment_start, comment_end, parsed)) = find_inline_block_id_comment(line) {
422        if parsed.is_some() {
423            return;
424        }
425
426        // Replace invalid block id comment with a fresh one.
427        let replacement = format!("<!-- terraphim:block-id:{} -->", Ulid::new());
428        edits.push(Edit {
429            range: (line_start + comment_start)..(line_start + comment_end),
430            replacement,
431        });
432        return;
433    }
434
435    // No existing comment on the first line; insert it after the list marker and optional checkbox.
436    if let Some(insert_at) = list_item_inline_insert_point(source, list_item_start) {
437        let trailing_space = match source.as_bytes().get(insert_at) {
438            None | Some(b'\n') | Some(b'\r') => "",
439            _ => " ",
440        };
441        edits.push(Edit::insert(
442            insert_at,
443            format!(
444                "<!-- terraphim:block-id:{} -->{trailing_space}",
445                Ulid::new()
446            ),
447        ));
448    }
449}
450
451fn list_item_inline_insert_point(source: &str, list_item_start: usize) -> Option<usize> {
452    let bytes = source.as_bytes();
453    let mut i = list_item_start;
454
455    // Skip indentation and blockquote markers on this line (e.g. "> " prefixes).
456    // We only do a shallow pass to handle common cases like "> - item".
457    loop {
458        while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
459            i += 1;
460        }
461        if bytes.get(i..i + 2) == Some(b"> ") {
462            i += 2;
463            continue;
464        }
465        break;
466    }
467
468    // Unordered list marker
469    if matches!(bytes.get(i), Some(b'-' | b'*' | b'+')) {
470        i += 1;
471        if matches!(bytes.get(i), Some(b' ' | b'\t')) {
472            i += 1;
473        } else {
474            return None;
475        }
476    } else if matches!(bytes.get(i), Some(b'0'..=b'9')) {
477        // Ordered list marker: digits + '.' or ')' + whitespace
478        while matches!(bytes.get(i), Some(b'0'..=b'9')) {
479            i += 1;
480        }
481        if matches!(bytes.get(i), Some(b'.' | b')')) {
482            i += 1;
483        } else {
484            return None;
485        }
486        if matches!(bytes.get(i), Some(b' ' | b'\t')) {
487            i += 1;
488        } else {
489            return None;
490        }
491    } else {
492        return None;
493    }
494
495    // Optional task list checkbox: [ ] / [x] / [X]
496    if bytes.get(i) == Some(&b'[')
497        && matches!(bytes.get(i + 1), Some(b' ' | b'x' | b'X'))
498        && bytes.get(i + 2) == Some(&b']')
499        && matches!(bytes.get(i + 3), Some(b' ' | b'\t'))
500    {
501        i += 4;
502    }
503
504    Some(i)
505}
506
507fn extract_blocks(markdown: &str) -> Result<Vec<Block>, MarkdownParserError> {
508    let ast = markdown::to_mdast(markdown, &ParseOptions::gfm())?;
509    let mut blocks = Vec::new();
510    extract_blocks_from_children(&ast, markdown, &mut blocks, ParentKind::Other)?;
511
512    // Validate uniqueness: ids should be stable and non-duplicated.
513    let mut seen = HashSet::new();
514    for b in &blocks {
515        let id = b.id.to_string();
516        if !seen.insert(id) {
517            // If duplicates exist, it is safer to surface an error rather than silently re-ID.
518            return Err(MarkdownParserError::MissingOrInvalidBlockId(
519                b.kind,
520                b.span.start,
521            ));
522        }
523    }
524
525    Ok(blocks)
526}
527
528fn extract_blocks_from_children(
529    node: &Node,
530    source: &str,
531    blocks: &mut Vec<Block>,
532    parent: ParentKind,
533) -> Result<(), MarkdownParserError> {
534    match node {
535        Node::Root(root) => {
536            extract_blocks_from_list(&root.children, source, blocks, ParentKind::Other)?;
537        }
538        Node::Blockquote(bq) => {
539            extract_blocks_from_list(&bq.children, source, blocks, parent)?;
540        }
541        Node::List(list) => {
542            extract_blocks_from_list(&list.children, source, blocks, parent)?;
543        }
544        Node::ListItem(li) => {
545            let Some(pos) = node.position() else {
546                return Ok(());
547            };
548
549            let Some((id, id_span)) = extract_list_item_id(source, pos.start.offset) else {
550                return Err(MarkdownParserError::MissingOrInvalidBlockId(
551                    BlockKind::ListItem,
552                    pos.start.offset,
553                ));
554            };
555            let start = line_bounds_at(source, pos.start.offset).0;
556            let end = pos.end.offset;
557            blocks.push(Block {
558                id,
559                kind: BlockKind::ListItem,
560                span: start..end,
561                id_span,
562            });
563            extract_blocks_from_list(&li.children, source, blocks, ParentKind::ListItem)?;
564        }
565        _ => {
566            if let Some(children) = children(node) {
567                extract_blocks_from_list(children, source, blocks, parent)?;
568            }
569        }
570    }
571    Ok(())
572}
573
574fn extract_blocks_from_list(
575    children: &[Node],
576    source: &str,
577    blocks: &mut Vec<Block>,
578    parent: ParentKind,
579) -> Result<(), MarkdownParserError> {
580    let mut first_direct_paragraph_in_list_item = false;
581
582    for (idx, child) in children.iter().enumerate() {
583        match child {
584            Node::ListItem(_) => extract_blocks_from_children(child, source, blocks, parent)?,
585            Node::Paragraph(_) => {
586                if parent == ParentKind::ListItem && !first_direct_paragraph_in_list_item {
587                    first_direct_paragraph_in_list_item = true;
588                    continue;
589                }
590
591                let Some(pos) = child.position() else {
592                    continue;
593                };
594
595                let Some((id, anchor_span)) = idx
596                    .checked_sub(1)
597                    .and_then(|prev| {
598                        parse_block_id_from_html_node_with_span(source, &children[prev])
599                    })
600                    .and_then(|(id, span)| id.map(|id| (id, span)))
601                else {
602                    return Err(MarkdownParserError::MissingOrInvalidBlockId(
603                        BlockKind::Paragraph,
604                        pos.start.offset,
605                    ));
606                };
607
608                blocks.push(Block {
609                    id,
610                    kind: BlockKind::Paragraph,
611                    span: anchor_span.start..pos.end.offset,
612                    id_span: anchor_span,
613                })
614            }
615            _ => extract_blocks_from_children(child, source, blocks, parent)?,
616        }
617    }
618
619    Ok(())
620}
621
622fn extract_list_item_id(source: &str, list_item_start: usize) -> Option<(Ulid, Range<usize>)> {
623    let (line_start, line_end) = line_bounds_at(source, list_item_start);
624    let line = &source[line_start..line_end];
625    let (comment_start, comment_end, parsed) = find_inline_block_id_comment(line)?;
626    let id = parsed?;
627    Some((id, (line_start + comment_start)..(line_start + comment_end)))
628}
629
630fn parse_block_id_from_html_node(node: &Node) -> Option<Ulid> {
631    match node {
632        Node::Html(val) => parse_block_id_comment(&val.value),
633        _ => None,
634    }
635}
636
637fn parse_block_id_from_html_node_with_span(
638    source: &str,
639    node: &Node,
640) -> Option<(Option<Ulid>, Range<usize>)> {
641    let Node::Html(val) = node else { return None };
642    let id = parse_block_id_comment(&val.value);
643
644    let Some(pos) = node.position() else {
645        return Some((id, 0..0));
646    };
647
648    let (line_start, line_end) = line_bounds_at(source, pos.start.offset);
649    Some((id, line_start..line_end))
650}
651
652fn parse_block_id_comment(raw_html: &str) -> Option<Ulid> {
653    let html = raw_html.trim();
654    let inner = html
655        .strip_prefix("<!--")
656        .and_then(|s| s.strip_suffix("-->"))?;
657    let inner = inner.trim();
658    let id_str = inner.strip_prefix(TERRAPHIM_BLOCK_ID_PREFIX)?;
659    Ulid::from_str(id_str.trim()).ok()
660}
661
662fn find_inline_block_id_comment(line: &str) -> Option<(usize, usize, Option<Ulid>)> {
663    let start = line.find("<!--")?;
664    let marker = line[start..].find(TERRAPHIM_BLOCK_ID_PREFIX)? + start;
665    let end = line[marker..].find("-->")? + marker + 3;
666
667    let comment_start = start;
668    let comment_end = end;
669    let comment = &line[comment_start..comment_end];
670    Some((comment_start, comment_end, parse_block_id_comment(comment)))
671}
672
673fn line_bounds_at(source: &str, offset: usize) -> (usize, usize) {
674    let line_start = source[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0);
675    let line_end = source[offset..]
676        .find('\n')
677        .map(|i| offset + i)
678        .unwrap_or_else(|| source.len());
679    (line_start, line_end)
680}
681
682fn line_prefix_at(source: &str, offset: usize) -> (usize, String) {
683    let (line_start, _line_end) = line_bounds_at(source, offset);
684    let prefix = &source[line_start..offset];
685    (line_start, prefix.to_string())
686}
687
688fn children(node: &Node) -> Option<&Vec<Node>> {
689    match node {
690        Node::Root(root) => Some(&root.children),
691        Node::Blockquote(bq) => Some(&bq.children),
692        Node::List(list) => Some(&list.children),
693        Node::ListItem(li) => Some(&li.children),
694        Node::Paragraph(p) => Some(&p.children),
695        Node::Heading(h) => Some(&h.children),
696        _ => None,
697    }
698}
699
700fn strip_terraphim_block_id_comments(text: &str) -> String {
701    let mut out = String::with_capacity(text.len());
702    for line in text.lines() {
703        let mut remaining = line;
704        let mut cleaned = String::new();
705        loop {
706            let Some((start, end, _)) = find_inline_block_id_comment(remaining) else {
707                cleaned.push_str(remaining);
708                break;
709            };
710            cleaned.push_str(&remaining[..start]);
711            remaining = &remaining[end..];
712        }
713
714        if cleaned.trim().is_empty() {
715            continue;
716        }
717
718        out.push_str(cleaned.trim_end());
719        out.push('\n')
720    }
721    out
722}
723
724fn first_nonempty_line(text: &str) -> Option<String> {
725    text.lines()
726        .map(|l| l.trim())
727        .find(|l| !l.is_empty())
728        .map(|l| l.chars().take(80).collect::<String>())
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    fn count_block_ids(s: &str) -> usize {
736        s.lines()
737            .filter(|l| l.contains("<!-- terraphim:block-id:"))
738            .count()
739    }
740
741    #[test]
742    fn inserts_paragraph_ids() {
743        let input = "Hello world\n\nSecond paragraph\n";
744        let out = ensure_terraphim_block_ids(input).unwrap();
745        // 2 paragraphs => 2 id comment lines
746        assert_eq!(count_block_ids(&out), 2);
747        assert!(out.contains("Hello world"));
748        assert!(out.contains("Second paragraph"));
749    }
750
751    #[test]
752    fn inserts_list_item_inline_ids() {
753        let input = "- first\n- second\n";
754        let out = ensure_terraphim_block_ids(input).unwrap();
755        assert_eq!(count_block_ids(&out), 2);
756        assert!(out.contains("- <!-- terraphim:block-id:"));
757    }
758
759    #[test]
760    fn normalize_returns_blocks() {
761        let input = "- item\n\nPara\n";
762        let normalized = normalize_markdown(input).unwrap();
763        assert!(normalized.blocks.len() >= 2);
764    }
765
766    #[test]
767    fn extract_first_heading_h1() {
768        let input = "# Bun Package Manager\n\nsynonyms:: npm, yarn\n";
769        assert_eq!(
770            extract_first_heading(input),
771            Some("Bun Package Manager".to_string())
772        );
773    }
774
775    #[test]
776    fn extract_first_heading_skips_h2() {
777        let input = "## Not This\n\n# This One\n";
778        assert_eq!(extract_first_heading(input), Some("This One".to_string()));
779    }
780
781    #[test]
782    fn extract_first_heading_none_when_absent() {
783        let input = "Just some text\n\n## Only H2\n";
784        assert_eq!(extract_first_heading(input), None);
785    }
786
787    #[test]
788    fn extract_first_heading_with_inline_code() {
789        let input = "# The `bun` Runtime\n";
790        assert_eq!(
791            extract_first_heading(input),
792            Some("The bun Runtime".to_string())
793        );
794    }
795
796    #[test]
797    fn iterative_normalization_stabilizes() {
798        // After inserting inline block IDs into list items, the markdown parser
799        // may re-parse the result differently, exposing adjacent text as a new
800        // paragraph without a block ID. The iterative loop must handle this.
801        //
802        // This input has a list item immediately followed by a paragraph with
803        // no blank line separating them, which can trigger re-parsing shifts.
804        let input = "- item one\n- item two\n\nA paragraph after the list.\n\nAnother paragraph.\n";
805
806        // Single pass
807        let pass1 = ensure_terraphim_block_ids(input).unwrap();
808
809        // The iterative normalize_markdown must produce a stable result
810        let normalized = normalize_markdown(input).unwrap();
811
812        // Verify stability: running ensure_terraphim_block_ids again should
813        // produce the same output (the fixed-point was reached).
814        let again = ensure_terraphim_block_ids(&normalized.markdown).unwrap();
815        assert_eq!(
816            again, normalized.markdown,
817            "normalization should be stable after normalize_markdown"
818        );
819
820        // All blocks must have IDs: 2 list items + 2 paragraphs = 4
821        let id_count = normalized
822            .markdown
823            .lines()
824            .filter(|l| l.contains("<!-- terraphim:block-id:"))
825            .count();
826        assert!(
827            id_count >= 4,
828            "expected at least 4 block IDs, got {id_count}; pass1 had {} IDs",
829            count_block_ids(&pass1),
830        );
831    }
832}