stakk 2.1.3

A CLI tool that bridges Jujutsu (jj) bookmarks to GitHub stacked pull requests
//! `stakk docs` — print the documentation bundled into the binary.
//!
//! The topics are the real Markdown files under `docs/`, so there is one source
//! of truth and the text can never describe a build other than the one running
//! it. `DocTopic` and `source` are generated by `build.rs` from that directory:
//! one variant per file, named after the file and documented with the `summary`
//! from its preamble. Adding a topic is therefore adding a file — see
//! `build.rs` for the preamble format.
//!
//! Output depends on where it is going. At a terminal the prose is re-flowed to
//! the terminal width, because the sources use semantic line breaks that would
//! otherwise read as ragged fragments. Redirected, the source is emitted
//! verbatim — `stakk docs agents >> AGENTS.md` writes what is in
//! `docs/agents.md` below its preamble, which is the whole document.

use std::fmt::Write as _;

use clap::ValueEnum;

use crate::markdown::wrap::wrap_markdown;

// `DocTopic` and `source`, generated from `docs/`.
include!(concat!(env!("OUT_DIR"), "/doc_topics.rs"));

/// Used when stdout is a terminal of unknown size.
const FALLBACK_WIDTH: usize = 80;
/// Prose past this width is tiring to read, however wide the terminal is.
const MAX_WIDTH: usize = 100;

/// The topic list printed by a bare `stakk docs`.
///
/// Generated from the `DocTopic` variants and their doc comments, so it cannot
/// drift from the enum or from `stakk docs --help`.
pub(crate) fn index() -> String {
    let mut out = String::from("stakk documentation topics:\n\n");

    for topic in DocTopic::value_variants() {
        let value = topic
            .to_possible_value()
            .expect("every DocTopic variant has a possible value");
        let help = value
            .get_help()
            .map_or_else(String::new, ToString::to_string);
        writeln!(out, "  {:<10}  {help}", value.get_name())
            .expect("writing to a String cannot fail");
    }

    out.push_str("\nRun `stakk docs <topic>` to print one.\n");
    out.push_str("The same documents live in docs/ at https://github.com/glennib/stakk\n");
    out
}

/// Render a topic.
///
/// `width` is `None` when the output is redirected, which emits the source
/// verbatim; `Some(width)` re-flows the prose for a terminal that wide.
pub(crate) fn render(topic: DocTopic, width: Option<usize>) -> String {
    match width {
        // Verbatim: a redirect must reproduce the source file byte for byte.
        None => source(topic).to_string(),
        Some(width) => wrap_markdown(source(topic), width),
    }
}

/// Print a topic, or the index when no topic is given.
pub(crate) fn print(topic: Option<DocTopic>) {
    let Some(topic) = topic else {
        print!("{}", index());
        return;
    };

    if console::Term::stdout().is_term() {
        println!("{}", render(topic, Some(terminal_width())));
    } else {
        print!("{}", render(topic, None));
    }
}

/// Width to re-flow to.
///
/// `COLUMNS` is checked first because `console` reads the terminal size over
/// ioctl and never consults it — without this the variable would be silently
/// ignored, and there would be no way to exercise a narrow terminal.
fn terminal_width() -> usize {
    if let Ok(value) = std::env::var("COLUMNS")
        && let Ok(columns) = value.trim().parse::<usize>()
        && columns > 0
    {
        return columns.min(MAX_WIDTH);
    }

    console::Term::stdout()
        .size_checked()
        .map_or(FALLBACK_WIDTH, |(_, columns)| columns as usize)
        .min(MAX_WIDTH)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::markdown::unwrap::fence_marker;

    /// Longest line permitted inside a fenced block. Fences pass through the
    /// wrapper verbatim by design, so an over-long example would overflow every
    /// narrow terminal. rumdl formats `docs/` at 120 columns and will not catch
    /// this.
    const MAX_FENCE_WIDTH: usize = 76;

    /// The directory the topics are generated from.
    const DOCS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/docs");

    fn all_topics() -> Vec<DocTopic> {
        DocTopic::value_variants().to_vec()
    }

    fn topic_name(topic: DocTopic) -> String {
        topic
            .to_possible_value()
            .expect("every DocTopic variant has a possible value")
            .get_name()
            .to_string()
    }

    /// The file a topic was generated from.
    fn topic_file(topic: DocTopic) -> String {
        let path = format!("{DOCS_DIR}/{}.md", topic_name(topic));
        std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"))
    }

    /// Every line inside a fenced code block, fence markers excluded.
    fn fenced_lines(text: &str) -> Vec<String> {
        let mut open: Option<String> = None;
        let mut lines = Vec::new();

        for line in text.lines() {
            match &open {
                Some(marker) => {
                    if fence_marker(line).as_ref() == Some(marker) {
                        open = None;
                    } else {
                        lines.push(line.to_string());
                    }
                }
                None => open = fence_marker(line),
            }
        }

        lines
    }

    #[test]
    fn redirected_output_is_byte_identical_to_the_bundled_source() {
        for topic in all_topics() {
            assert!(!source(topic).is_empty(), "{topic:?} source is empty");
            assert_eq!(
                render(topic, None),
                source(topic),
                "{topic:?} is not reproduced verbatim when redirected"
            );
        }
    }

    /// What a redirect writes is the file below its preamble.
    ///
    /// `source` reads a copy `build.rs` wrote, so this is the only check that
    /// the copy is the document and that nothing but the metadata comment was
    /// dropped on the way — the property `stakk docs agents >> AGENTS.md`
    /// depends on.
    #[test]
    fn the_bundled_source_is_the_file_below_its_preamble() {
        for topic in all_topics() {
            let file = topic_file(topic);
            let body = source(topic);
            assert!(
                file.ends_with(body),
                "{topic:?}: the bundled text is not the tail of its file",
            );

            // Compared with LF terminators: a Windows checkout converts the
            // documents to CRLF, and what this asserts is the shape of the
            // preamble, not what git wrote to disk.
            let preamble = file[..file.len() - body.len()].replace("\r\n", "\n");
            assert!(
                preamble.starts_with("<!--- stakk-docs\n"),
                "{topic:?}: dropped a prefix that is not a preamble: {preamble:?}",
            );
            assert!(
                preamble.ends_with("--->\n\n"),
                "{topic:?}: dropped more than the preamble: {preamble:?}",
            );
        }
    }

    /// Alphabetical, because that is the order `stakk docs` and `--help` list
    /// topics in and nothing else decides it — `read_dir` order is whatever the
    /// filesystem says.
    #[test]
    fn topics_are_listed_alphabetically() {
        let names: Vec<String> = all_topics().into_iter().map(topic_name).collect();
        let mut sorted = names.clone();
        sorted.sort();
        assert_eq!(names, sorted, "topics are not listed alphabetically");
    }

    #[test]
    fn terminal_output_is_reflowed() {
        // Guards against the TTY path silently degrading to verbatim: the docs
        // use semantic line breaks, so folding them must change the text.
        for topic in all_topics() {
            assert_ne!(
                render(topic, Some(80)),
                source(topic),
                "{topic:?} was not re-flowed at width 80"
            );
        }
    }

    #[test]
    fn fenced_content_survives_wrapping_verbatim() {
        for topic in all_topics() {
            let src = source(topic);
            let expected = fenced_lines(src);
            for width in [40, 60, 80, 120] {
                let wrapped = wrap_markdown(src, width);
                assert_eq!(
                    fenced_lines(&wrapped),
                    expected,
                    "{topic:?} fenced content changed at width {width}"
                );
            }
        }
    }

    #[test]
    fn fenced_lines_fit_a_narrow_terminal() {
        for topic in all_topics() {
            for line in fenced_lines(source(topic)) {
                assert!(
                    line.chars().count() <= MAX_FENCE_WIDTH,
                    "{topic:?}: fenced line is {} chars (max {MAX_FENCE_WIDTH}): {line}",
                    line.chars().count()
                );
            }
        }
    }

    #[test]
    fn index_lists_every_topic() {
        let index = index();
        for topic in all_topics() {
            let name = topic_name(topic);
            assert!(index.contains(&name), "index is missing {name}");
        }
    }

    #[test]
    fn index_describes_a_topic_for_coding_agents() {
        assert!(
            index().contains("coding agents"),
            "one index entry should name coding agents, so an agent scanning the index picks it"
        );
    }

    /// The topics are exactly the `docs/*.md` files.
    ///
    /// `build.rs` generates the topics from a directory listing it takes at
    /// build time, so this reads the directory again at test time: it catches a
    /// stale `OUT_DIR` and would catch `build.rs` growing a filter that quietly
    /// drops a document, which would otherwise ship unreachable from the binary
    /// and missing from its index.
    #[test]
    fn the_topics_are_the_docs_directory() {
        let mut files: Vec<String> = std::fs::read_dir(DOCS_DIR)
            .expect("docs/ exists")
            .map(|entry| entry.expect("readable dir entry").path())
            .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
            .map(|path| {
                path.file_stem()
                    .expect("a .md path has a stem")
                    .to_string_lossy()
                    .into_owned()
            })
            .collect();
        files.sort();

        let topics: Vec<String> = all_topics().into_iter().map(topic_name).collect();
        assert_eq!(files, topics, "the topics and docs/ have drifted apart");
    }

    #[test]
    fn columns_overrides_the_detected_width() {
        // Serialized implicitly: no other test touches COLUMNS.
        unsafe { std::env::set_var("COLUMNS", "37") };
        let width = terminal_width();
        unsafe { std::env::remove_var("COLUMNS") };
        assert_eq!(width, 37);
    }

    /// Blank the *values* out of clap's `[env: NAME=value]` annotations.
    ///
    /// clap prints the value a variable currently holds, so a developer with
    /// `STAKK_CONFIG` set would otherwise see the help snapshot fail for a
    /// reason that has nothing to do with the code.
    fn without_env_values(help: &str) -> String {
        help.lines()
            .map(|line| match line.split_once("[env: ") {
                Some((before, rest)) => match rest.split_once('=') {
                    Some((name, _)) => format!("{before}[env: {name}=]"),
                    None => line.to_string(),
                },
                None => line.to_string(),
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    /// What a bare `stakk docs` prints.
    ///
    /// The topic names, their summaries, the order they come in and the layout
    /// around them are all user-visible, and all of them are derived rather
    /// than written out — so this pins the derivation itself.
    #[test]
    fn index_output() {
        insta::assert_snapshot!(index());
    }

    /// What `stakk docs --help` prints.
    ///
    /// The same names and summaries reach the user by a second path, clap's
    /// possible-value help, built from a different part of the same
    /// definitions. Pinning both catches a change that moves only one of them.
    #[test]
    fn docs_help_output() {
        use clap::CommandFactory as _;

        let help = crate::cli::Cli::command()
            .try_get_matches_from(["stakk", "docs", "--help"])
            .expect_err("--help leaves clap through the error path")
            .to_string();
        insta::assert_snapshot!(without_env_values(&help));
    }
}