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)
}
}
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;
}
if just_closed && trimmed.is_empty() {
just_closed = false;
continue;
}
just_closed = false;
out.push_str(line);
}
out
}
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;
}
}
}
}
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; 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);
}
}