use std::fmt::Write as _;
use clap::ValueEnum;
use crate::markdown::wrap::wrap_markdown;
include!(concat!(env!("OUT_DIR"), "/doc_topics.rs"));
const FALLBACK_WIDTH: usize = 80;
const MAX_WIDTH: usize = 100;
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
}
pub(crate) fn render(topic: DocTopic, width: Option<usize>) -> String {
match width {
None => source(topic).to_string(),
Some(width) => wrap_markdown(source(topic), width),
}
}
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));
}
}
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;
const MAX_FENCE_WIDTH: usize = 76;
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()
}
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}"))
}
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"
);
}
}
#[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",
);
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:?}",
);
}
}
#[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() {
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"
);
}
#[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() {
unsafe { std::env::set_var("COLUMNS", "37") };
let width = terminal_width();
unsafe { std::env::remove_var("COLUMNS") };
assert_eq!(width, 37);
}
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")
}
#[test]
fn index_output() {
insta::assert_snapshot!(index());
}
#[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));
}
}