luff 0.2.1

Print files with formatting
Documentation
//! Output renderers for the WASM processing pipeline.
//!
//! Self-contained markdown and tree formatters that operate on
//! `&[VirtualFile]` slices via `fmt::Write`, decoupled from I/O,
//! terminal concerns, and pipeline orchestration.
//!
//! Effective visibility is capped by the parent module (`pub(crate)`
//! in `mod.rs`). Functions are `pub(crate)` here to be explicit about
//! the intended boundary — they exist for use by the processor and
//! potential future CLI→WASM bridge or WIT binding layers, not as
//! top-level public API.

use std::collections::BTreeMap;
use std::fmt;

use super::virtual_fs::VirtualFile;

/// Writes files as markdown code blocks with variable-length fences.
///
/// Uses a variable-length backtick fence (`CommonMark` §4.5) so that
/// file content containing triple-backtick sequences does not break
/// the code block. The fence length is the longest consecutive run
/// of backticks in the content plus one, with a minimum of three.
pub fn write_markdown<W: fmt::Write>(files: &[VirtualFile], sink: &mut W) -> fmt::Result {
    for (i, file) in files.iter().enumerate() {
        if i > 0 {
            writeln!(sink)?;
        }

        let lang = file.extension().unwrap_or("");
        let fence = "`".repeat(min_fence_len(file.content()));

        writeln!(sink, "## `{}`", file.normalized_path())?;
        writeln!(sink)?;
        writeln!(sink, "{fence}{lang}")?;
        // Write content, ensuring it ends with a newline.
        write!(sink, "{}", file.content())?;
        if !file.content().ends_with('\n') {
            writeln!(sink)?;
        }
        writeln!(sink, "{fence}")?;
    }
    Ok(())
}

/// Returns the minimum backtick fence length needed to safely wrap
/// `content` in a fenced code block (`CommonMark` §4.5).
///
/// Scans for the longest consecutive run of backtick characters and
/// returns that length plus one, with a floor of three.
pub fn min_fence_len(content: &str) -> usize {
    let mut max_run: usize = 0;
    let mut current_run: usize = 0;
    for &b in content.as_bytes() {
        if b == b'`' {
            current_run += 1;
            if current_run > max_run {
                max_run = current_run;
            }
        } else {
            current_run = 0;
        }
    }
    if max_run >= 3 { max_run + 1 } else { 3 }
}

/// Writes a directory tree using box-drawing characters.
///
/// `root_label` is printed as the first line (e.g. `"."` or a
/// project name). Children are rendered with `BTreeMap` ordering
/// for deterministic output.
pub fn write_tree<W: fmt::Write>(
    files: &[VirtualFile],
    root_label: &str,
    sink: &mut W,
) -> fmt::Result {
    let root = build_tree(files);
    writeln!(sink, "{root_label}")?;
    let mut prefix_buf = String::new();
    render_children(sink, &root.children, &mut prefix_buf)
}

/// A node in the virtual directory tree.
///
/// Leaf vs. directory is determined structurally: a node with an
/// empty `children` map is a leaf (file). A node with children is
/// rendered as a directory with a trailing `/`. This means a path
/// that appears both as a file and a directory prefix (e.g.
/// `["src", "src/main.rs"]`) renders as a directory — children win.
#[derive(Debug, Default)]
struct TreeNode {
    /// Child nodes keyed by path component name.
    children: BTreeMap<String, Self>,
}

/// Builds a tree from sorted virtual files.
fn build_tree(files: &[VirtualFile]) -> TreeNode {
    let mut root = TreeNode::default();
    for file in files {
        insert_path(&mut root, file.normalized_path());
    }
    root
}

/// Recursively inserts a `/`-separated path into the tree.
///
/// Uses `split_once('/')` to walk one component at a time, avoiding
/// a per-file `Vec<&str>` allocation that a collect-then-slice
/// approach would require. Also correctly handles trailing-slash
/// paths (e.g. `"src/"`) by ignoring the empty final component.
///
/// **Defense-in-depth**: guards against empty components and leading
/// slashes even though `validate_path` rejects them, because
/// `new_unchecked` can bypass validation.
fn insert_path(node: &mut TreeNode, path: &str) {
    if let Some((dir, rest)) = path.split_once('/') {
        // Guard against leading or consecutive slashes producing
        // empty components (e.g. `"/foo"` or `"a//b"`).
        if dir.is_empty() {
            insert_path(node, rest);
        } else {
            let child = node.children.entry(dir.to_owned()).or_default();
            insert_path(child, rest);
        }
    } else if !path.is_empty() {
        // Ensure the leaf node exists in the tree.
        let _ = node.children.entry(path.to_owned()).or_default();
    }
}

/// Recursively renders tree children with box-drawing connectors.
///
/// `prefix_buf` is a mutable scratch buffer for the current indentation
/// prefix. We push onto it before recursing and truncate back afterward,
/// avoiding a `format!` allocation per node.
fn render_children<W: fmt::Write>(
    sink: &mut W,
    children: &BTreeMap<String, TreeNode>,
    prefix_buf: &mut String,
) -> fmt::Result {
    let count = children.len();
    for (i, (name, node)) in children.iter().enumerate() {
        let is_last = i + 1 == count;
        let connector = if is_last { "└── " } else { "├── " };

        // A node with children is a directory regardless of whether
        // it was also inserted as a leaf (e.g. paths ["src", "src/main.rs"]).
        let suffix = if node.children.is_empty() { "" } else { "/" };

        writeln!(sink, "{prefix_buf}{connector}{name}{suffix}")?;

        if !node.children.is_empty() {
            let segment = if is_last { "    " } else { "" };
            let prev_len = prefix_buf.len();
            prefix_buf.push_str(segment);
            render_children(sink, &node.children, prefix_buf)?;
            prefix_buf.truncate(prev_len);
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn file(path: &str, content: &str) -> VirtualFile {
        VirtualFile::new_unchecked(path, content)
    }

    #[test]
    fn min_fence_len_no_backticks() {
        assert_eq!(min_fence_len("hello world"), 3);
    }

    #[test]
    fn min_fence_len_short_backticks() {
        assert_eq!(min_fence_len("some `inline` code"), 3);
        assert_eq!(min_fence_len("``double``"), 3);
    }

    #[test]
    fn min_fence_len_triple_backticks() {
        assert_eq!(min_fence_len("```\ncode\n```"), 4);
    }

    #[test]
    fn min_fence_len_many_backticks() {
        assert_eq!(min_fence_len("``````"), 7);
    }

    #[test]
    fn markdown_single_file() {
        let mut out = String::new();
        write_markdown(&[file("src/main.rs", "fn main() {}\n")], &mut out).unwrap();
        assert_eq!(out, "## `src/main.rs`\n\n```rs\nfn main() {}\n```\n");
    }

    #[test]
    fn markdown_adds_trailing_newline_to_content() {
        let mut out = String::new();
        write_markdown(&[file("f.txt", "no newline")], &mut out).unwrap();
        assert!(out.contains("no newline\n```"));
    }

    #[test]
    fn markdown_variable_fence_for_backtick_content() {
        let content = "before\n```\ninner\n```\nafter\n";
        let mut out = String::new();
        write_markdown(&[file("tricky.md", content)], &mut out).unwrap();
        assert!(
            out.contains("````"),
            "fence should be at least 4 backticks when content contains ```"
        );
        assert!(out.contains(content));
    }

    #[test]
    fn markdown_long_backtick_runs() {
        let content = "some ```````` long run\n";
        let mut out = String::new();
        write_markdown(&[file("f.txt", content)], &mut out).unwrap();
        assert!(
            out.contains("`````````"),
            "fence should be at least 9 backticks: {out}"
        );
    }

    #[test]
    fn markdown_empty_file_list() {
        let mut out = String::new();
        write_markdown(&[], &mut out).unwrap();
        assert!(out.is_empty());
    }

    #[test]
    fn tree_basic_structure() {
        let files = &[
            file("Cargo.toml", ""),
            file("src/lib.rs", ""),
            file("src/main.rs", ""),
        ];
        let mut out = String::new();
        write_tree(files, "project", &mut out).unwrap();

        assert!(out.starts_with("project\n"));
        assert!(out.contains("Cargo.toml"));
        assert!(out.contains("src/"));
        assert!(out.contains("main.rs"));
        assert!(out.contains("lib.rs"));
    }

    #[test]
    fn tree_empty_input() {
        let mut out = String::new();
        write_tree(&[], "root", &mut out).unwrap();
        assert_eq!(out, "root\n");
    }

    #[test]
    fn tree_component_that_is_both_file_and_directory() {
        // If someone passes both "src" (a file) and "src/main.rs",
        // the "src" node has children → it must render with a trailing
        // slash, not bare.
        let files = &[file("src", "file content"), file("src/main.rs", "")];
        let mut out = String::new();
        write_tree(files, ".", &mut out).unwrap();

        assert!(
            out.contains("src/"),
            "node with children must show trailing slash: {out}"
        );
        assert!(out.contains("main.rs"));
    }

    #[test]
    fn tree_trailing_slash_path_does_not_create_ghost_node() {
        // Defense-in-depth: `validate_path` rejects trailing slashes,
        // but `new_unchecked` bypasses validation. Verify the tree
        // renderer handles this gracefully rather than creating an
        // empty-name child under the "src" node.
        let files = &[file("src/", "")];
        let mut out = String::new();
        write_tree(files, ".", &mut out).unwrap();

        assert!(!out.contains("└── \n"), "ghost empty-name node: {out}");
    }
}