cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Terminal `Renderer`: passes Markdown through almost unchanged
//! (the contract is "line-based, ANSI-optional output"; literal
//! `# Heading` and `- item` read fine in monospace), and only
//! substitutes typed links via the [`LinkResolver`].
//!
//! A future iteration may strip Markdown markers or add ANSI styling
//! once we have evidence the raw form is friction. For now, the raw
//! form keeps the renderer trivial and the doctest harness simple
//! (the source and the displayed output stay close).

use crate::domain::usecases::doc::render::{LinkResolver, Renderer};

const RECOGNISED_SCHEMES: &[&str] = &["concept", "adr", "ddr", "issue"];

pub struct TerminalRenderer<R: LinkResolver> {
    pub links: R,
}

impl<R: LinkResolver> TerminalRenderer<R> {
    pub fn new(links: R) -> Self {
        Self { links }
    }
}

impl<R: LinkResolver> Renderer for TerminalRenderer<R> {
    fn render(&self, source: &str) -> String {
        let stripped = strip_mermaid_blocks(source);
        substitute_typed_links(&stripped, &self.links)
    }
}

/// Drop ```` ```mermaid ```` fenced blocks. Other fenced blocks
/// (` ```rust `, plain ` ``` ` etc.) pass through unchanged — only
/// mermaid is unreadable at the terminal, and the generator pages
/// always pair the diagram with an equivalent prose/table form.
fn strip_mermaid_blocks(source: &str) -> String {
    let mut out = String::with_capacity(source.len());
    let mut in_mermaid = false;
    let mut just_closed = false;
    for line in source.split_inclusive('\n') {
        let trimmed = line.trim_end_matches('\n');
        if in_mermaid {
            if trimmed.trim_start() == "```" {
                in_mermaid = false;
                just_closed = true;
            }
            continue;
        }
        if trimmed.trim_start() == "```mermaid" {
            in_mermaid = true;
            continue;
        }
        // Skip the single blank line that separated the dropped fence
        // from the next block, otherwise the output keeps two blank
        // lines where the diagram used to be.
        if just_closed && trimmed.is_empty() {
            just_closed = false;
            continue;
        }
        just_closed = false;
        out.push_str(line);
    }
    out
}

/// Walk `source`, replacing each `[text](scheme://target)` whose
/// scheme is recognised by `links.render_link`. Anything else
/// (regular http links, plain text, headings, code blocks) passes
/// through unchanged.
fn substitute_typed_links(source: &str, links: &impl LinkResolver) -> String {
    let mut out = String::with_capacity(source.len());
    let mut rest = source;

    while let Some(open) = rest.find('[') {
        out.push_str(&rest[..open]);
        let after_open = &rest[open + 1..];

        if let Some((text, after_text)) = take_until(after_open, ']') {
            if let Some(after_paren_open) = after_text.strip_prefix('(') {
                if let Some((url, after_url)) = take_until(after_paren_open, ')') {
                    if let Some((scheme, target)) = split_scheme(url) {
                        out.push_str(&links.render_link(text, scheme, target));
                        rest = after_url;
                        continue;
                    }
                }
            }
        }

        // Not a recognised typed-link shape — keep the `[` verbatim
        // and resume scanning right after it.
        out.push('[');
        rest = after_open;
    }

    out.push_str(rest);
    out
}

fn take_until(s: &str, c: char) -> Option<(&str, &str)> {
    s.find(c).map(|i| (&s[..i], &s[i + 1..]))
}

fn split_scheme(url: &str) -> Option<(&str, &str)> {
    for scheme in RECOGNISED_SCHEMES {
        let prefix_len = scheme.len() + 3; // "scheme" + "://"
        if url.len() > prefix_len
            && url.starts_with(scheme)
            && &url[scheme.len()..prefix_len] == "://"
        {
            return Some((scheme, &url[prefix_len..]));
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::infra::driven::doc::links::terminal::TerminalLinkResolver;

    fn render(source: &str) -> String {
        TerminalRenderer::new(TerminalLinkResolver).render(source)
    }

    #[test]
    fn plain_text_is_unchanged() {
        assert_eq!(render("Hello, world!"), "Hello, world!");
    }

    #[test]
    fn markdown_markers_pass_through_verbatim() {
        let src = "# Heading\n\n- item one\n- item two\n";
        assert_eq!(render(src), src);
    }

    #[test]
    fn recognised_concept_link_carries_the_pivot_command() {
        let src = "See [Issue](concept://issue) for details.";
        assert_eq!(render(src), "See Issue (cartu man issue) for details.");
    }

    #[test]
    fn recognised_adr_link_drops_scheme_and_target() {
        let src = "Per [ADR-0017](adr://0017), TSIDs replace integers.";
        assert_eq!(render(src), "Per ADR-0017, TSIDs replace integers.");
    }

    #[test]
    fn http_link_is_not_substituted() {
        let src = "Visit [the site](https://example.com).";
        assert_eq!(render(src), src);
    }

    #[test]
    fn multiple_typed_links_in_one_paragraph_are_all_substituted() {
        let src = "[A](concept://a) and [B](issue://0042) and plain.";
        assert_eq!(render(src), "A (cartu man a) and B and plain.");
    }

    #[test]
    fn malformed_bracket_is_kept_verbatim() {
        let src = "An open [ bracket.";
        assert_eq!(render(src), "An open [ bracket.");
    }

    #[test]
    fn mermaid_block_is_dropped_with_its_fences() {
        let src = "Before.\n\n```mermaid\nstateDiagram-v2\n    a --> b\n```\n\nAfter.\n";
        assert_eq!(render(src), "Before.\n\nAfter.\n");
    }

    #[test]
    fn non_mermaid_fences_pass_through_unchanged() {
        let src = "```rust\nfn main() {}\n```\n";
        assert_eq!(render(src), src);
    }
}