use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_treemd"))
}
const FIXTURE: &str = "\
# Title
Intro paragraph.
## Installation
Install steps here.
```rust
// `# fake heading` inside a code block must be ignored by parsing.
let x = 1;
```
## Usage
Some usage notes.
### Advanced
Deep section.
## Conclusion
End of doc.
";
fn fixture_file() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"treemd-it-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("doc.md");
std::fs::write(&path, FIXTURE).expect("write fixture");
path
}
fn run(args: &[&str]) -> (String, String, i32) {
let out = Command::new(bin())
.args(args)
.output()
.expect("spawn treemd");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
fn run_with_stdin(args: &[&str], input: &str) -> (String, String, i32) {
let mut child = Command::new(bin())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn treemd");
child
.stdin
.as_mut()
.expect("stdin")
.write_all(input.as_bytes())
.expect("write stdin");
let out = child.wait_with_output().expect("wait");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
#[test]
fn version_flag_prints_version_and_exits_zero() {
let (stdout, _, code) = run(&["--version"]);
assert_eq!(code, 0);
assert!(
stdout.contains("treemd"),
"version output should mention treemd, got: {stdout}"
);
}
#[test]
fn help_flag_prints_usage_and_exits_zero() {
let (stdout, _, code) = run(&["--help"]);
assert_eq!(code, 0);
assert!(stdout.contains("Usage"), "help output should contain Usage");
assert!(
stdout.contains("--list") && stdout.contains("--tree"),
"help should mention --list and --tree"
);
}
#[test]
fn query_help_prints_query_docs_and_exits_zero() {
let (stdout, _, code) = run(&["--query-help"]);
assert_eq!(code, 0);
assert!(stdout.contains("Query Language") || stdout.contains("ELEMENT SELECTORS"));
}
#[test]
fn list_plain_emits_all_headings() {
let f = fixture_file();
let (stdout, _, code) = run(&["-l", f.to_str().unwrap()]);
assert_eq!(code, 0);
let lines: Vec<_> = stdout.lines().collect();
assert_eq!(lines.len(), 5, "expected 5 heading lines, got: {:?}", lines);
assert!(lines[0].starts_with("# Title"));
assert!(lines[1].starts_with("## Installation"));
assert!(lines[2].starts_with("## Usage"));
assert!(lines[3].starts_with("### Advanced"));
assert!(lines[4].starts_with("## Conclusion"));
}
#[test]
fn list_with_level_filter_keeps_only_that_level() {
let f = fixture_file();
let (stdout, _, code) = run(&["-l", "-L", "2", f.to_str().unwrap()]);
assert_eq!(code, 0);
for line in stdout.lines() {
assert!(line.starts_with("## "), "non-h2 line leaked: {line}");
}
assert_eq!(stdout.lines().count(), 3); }
#[test]
fn list_with_text_filter_is_case_insensitive() {
let f = fixture_file();
let (stdout, _, code) = run(&["-l", "--filter", "INSTALL", f.to_str().unwrap()]);
assert_eq!(code, 0);
assert_eq!(stdout.trim(), "## Installation");
}
#[test]
fn list_json_output_is_valid_and_has_expected_shape() {
let f = fixture_file();
let (stdout, _, code) = run(&["-l", "-o", "json", f.to_str().unwrap()]);
assert_eq!(code, 0);
let v: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON");
let doc = &v["document"];
assert_eq!(doc["metadata"]["headingCount"], 5);
assert_eq!(doc["metadata"]["maxDepth"], 3);
let sections = doc["sections"].as_array().expect("sections array");
assert_eq!(sections.len(), 1);
assert_eq!(sections[0]["title"], "Title");
let children = sections[0]["children"].as_array().expect("children");
assert_eq!(children.len(), 3); }
#[test]
fn tree_output_uses_box_drawing_chars() {
let f = fixture_file();
let (stdout, _, code) = run(&["--tree", f.to_str().unwrap()]);
assert_eq!(code, 0);
assert!(stdout.contains("├") || stdout.contains("└"), "no branches");
assert!(stdout.contains("Title"));
assert!(stdout.contains("Advanced"));
}
#[test]
fn tree_with_filter_narrows_tree() {
let f = fixture_file();
let (stdout, _, code) = run(&["--tree", "--filter", "Install", f.to_str().unwrap()]);
assert_eq!(code, 0);
assert!(stdout.contains("Installation"));
assert!(!stdout.contains("Title"), "non-matching headings leaked");
assert!(!stdout.contains("Usage"), "non-matching headings leaked");
assert!(
!stdout.contains("Conclusion"),
"non-matching headings leaked"
);
}
#[test]
fn tree_with_level_narrows_tree() {
let f = fixture_file();
let (stdout, _, code) = run(&["--tree", "-L", "2", f.to_str().unwrap()]);
assert_eq!(code, 0);
assert!(stdout.contains("Installation"));
assert!(stdout.contains("Usage"));
assert!(stdout.contains("Conclusion"));
assert!(!stdout.contains("# Title"), "h1 leaked: {stdout}");
assert!(!stdout.contains("Advanced"), "h3 leaked: {stdout}");
}
#[test]
fn section_extracts_named_section_only() {
let f = fixture_file();
let (stdout, _, code) = run(&["-s", "Installation", f.to_str().unwrap()]);
assert_eq!(code, 0);
assert!(stdout.contains("Install steps here"));
assert!(!stdout.contains("Some usage notes"));
}
#[test]
fn section_missing_exits_nonzero() {
let f = fixture_file();
let (_, stderr, code) = run(&["-s", "Nonexistent", f.to_str().unwrap()]);
assert_ne!(code, 0, "missing section should exit nonzero");
assert!(stderr.contains("not found"));
}
#[test]
fn count_reports_per_level_and_total() {
let f = fixture_file();
let (stdout, _, code) = run(&["--count", f.to_str().unwrap()]);
assert_eq!(code, 0);
assert!(stdout.contains("#: 1"), "expected one h1");
assert!(stdout.contains("##: 3"), "expected three h2");
assert!(stdout.contains("###: 1"), "expected one h3");
assert!(stdout.contains("Total: 5"));
}
#[test]
fn query_h2_returns_only_h2_headings() {
let f = fixture_file();
let (stdout, _, code) = run(&["-q", ".h2", f.to_str().unwrap()]);
assert_eq!(code, 0, "stdout: {stdout}");
assert!(stdout.contains("Installation"));
assert!(stdout.contains("Usage"));
assert!(stdout.contains("Conclusion"));
assert!(!stdout.contains("Advanced"), "h3 leaked into h2 query");
}
#[test]
fn query_invalid_syntax_exits_nonzero() {
let f = fixture_file();
let (_, stderr, code) = run(&["-q", ".h2 | nonexistent_fn(((", f.to_str().unwrap()]);
assert_ne!(code, 0);
assert!(!stderr.is_empty(), "expected an error message on stderr");
}
#[test]
fn list_reads_markdown_from_stdin() {
let input = "# Alpha\n## Beta\n";
let (stdout, _, code) = run_with_stdin(&["-l", "-"], input);
assert_eq!(code, 0, "stdout: {stdout}");
let lines: Vec<_> = stdout.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "# Alpha");
assert_eq!(lines[1], "## Beta");
}
#[test]
fn at_line_finds_enclosing_heading() {
let f = fixture_file();
let usage_line = FIXTURE
.lines()
.position(|l| l.starts_with("## Usage"))
.expect("Usage heading present")
+ 1;
let target = usage_line + 1;
let (stdout, stderr, code) = run(&["--at-line", &target.to_string(), f.to_str().unwrap()]);
assert_eq!(code, 0, "stderr: {stderr}");
assert_eq!(stdout.trim(), "## Usage");
}
#[test]
fn at_line_on_heading_line_returns_that_heading() {
let f = fixture_file();
let install_line = FIXTURE
.lines()
.position(|l| l.starts_with("## Installation"))
.expect("Installation heading present")
+ 1;
let (stdout, _, code) = run(&["--at-line", &install_line.to_string(), f.to_str().unwrap()]);
assert_eq!(code, 0);
assert_eq!(stdout.trim(), "## Installation");
}
#[test]
fn at_line_before_first_heading_exits_nonzero() {
let dir = std::env::temp_dir().join(format!("treemd-it-noheading-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.md");
std::fs::write(&path, "lorem\nipsum\ndolor\nsit\n# Hello\nbody\n").unwrap();
let (_, stderr, code) = run(&["--at-line", "2", path.to_str().unwrap()]);
assert_ne!(code, 0);
assert!(stderr.contains("No heading"));
}
#[test]
fn at_line_zero_is_rejected() {
let f = fixture_file();
let (_, stderr, code) = run(&["--at-line", "0", f.to_str().unwrap()]);
assert_ne!(code, 0);
assert!(stderr.contains(">= 1"));
}
#[test]
fn section_with_inline_markdown_in_heading() {
let dir = std::env::temp_dir().join(format!("treemd-it-fmt-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.md");
std::fs::write(
&path,
"# Top\n\n## **Bold** Section\nbody-of-bold\n\n## Next\nbody-of-next\n",
)
.unwrap();
let (stdout, stderr, code) = run(&["-s", "Bold Section", path.to_str().unwrap()]);
assert_eq!(code, 0, "stderr: {stderr}");
assert!(
stdout.contains("body-of-bold"),
"expected body of formatted-heading section, got: {stdout:?}"
);
assert!(!stdout.contains("body-of-next"));
}