use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Output, Stdio};
const RUST_SOURCE: &str = r#"//! Crate docs.
//!
//!#Overview
use std::fmt;
#[derive(Debug)]
pub struct Widget {
pub name: String,
}
impl fmt::Display for Widget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "* {} *", self.name)
}
}
"#;
const RUST_SOURCE_FIXED: &str = r#"//! Crate docs.
//!
//!# Overview
use std::fmt;
#[derive(Debug)]
pub struct Widget {
pub name: String,
}
impl fmt::Display for Widget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "* {} *", self.name)
}
}
"#;
fn rumdl(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_rumdl"))
.current_dir(dir)
.args(args)
.output()
.expect("failed to execute rumdl")
}
fn rumdl_with_stdin(dir: &Path, content: &str, args: &[&str]) -> (String, String) {
let mut child = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.current_dir(dir)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to execute rumdl");
child
.stdin
.as_mut()
.unwrap()
.write_all(content.as_bytes())
.expect("failed to write stdin");
let output = child.wait_with_output().expect("failed to collect rumdl output");
(
String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n"),
String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n"),
)
}
#[test]
fn fmt_rewrites_a_rust_files_doc_comment_and_leaves_the_rust_alone() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("widget.rs"), RUST_SOURCE).unwrap();
let output = rumdl(
dir.path(),
&["fmt", "--color", "never", "--no-cache", "--no-config", "widget.rs"],
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
fs::read_to_string(dir.path().join("widget.rs")).unwrap(),
RUST_SOURCE_FIXED,
"only the doc comment is markdown.\nstdout:\n{stdout}"
);
let diagnostics: Vec<&str> = stdout.lines().filter(|line| line.contains("widget.rs:")).collect();
assert_eq!(
diagnostics,
["widget.rs:3:5: [MD018] No space after # in heading [fixed]"],
"the doc-comment heading is the only finding, and the fix pass resolved it.\nstdout:\n{stdout}"
);
assert!(stdout.contains("Fixed: 1/1 issue in 1 file"), "stdout:\n{stdout}");
}
#[test]
fn the_same_bytes_as_markdown_are_rewritten_throughout() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("widget.md"), RUST_SOURCE).unwrap();
let output = rumdl(
dir.path(),
&["fmt", "--color", "never", "--no-cache", "--no-config", "widget.md"],
);
let fixed = fs::read_to_string(dir.path().join("widget.md")).unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
fixed.contains("# [derive(Debug)]"),
"the attribute reads as a heading in markdown.\nfixed:\n{fixed}\nstdout:\n{stdout}"
);
assert!(
fixed.contains(r#"write!(f, "*{}*", self.name)"#),
"the string literal reads as emphasis in markdown.\nfixed:\n{fixed}\nstdout:\n{stdout}"
);
}
#[test]
fn diff_mode_offers_no_change_to_a_rust_files_source() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("widget.rs"), RUST_SOURCE).unwrap();
let output = rumdl(
dir.path(),
&[
"check",
"--diff",
"--color",
"never",
"--no-cache",
"--no-config",
"widget.rs",
],
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("-//!#Overview") && stdout.contains("+//!# Overview"),
"the doc-comment fix is still offered.\nstdout:\n{stdout}"
);
assert!(
!stdout.contains("derive") && !stdout.contains("write!"),
"no line of Rust is offered for rewriting.\nstdout:\n{stdout}"
);
assert_eq!(
fs::read_to_string(dir.path().join("widget.rs")).unwrap(),
RUST_SOURCE,
"a diff writes nothing"
);
}
#[test]
fn stdin_named_as_a_rust_file_fixes_only_its_doc_comments() {
let dir = tempfile::tempdir().unwrap();
let (stdout, stderr) = rumdl_with_stdin(
dir.path(),
RUST_SOURCE,
&[
"fmt",
"--stdin",
"--stdin-filename",
"widget.rs",
"--color",
"never",
"--no-config",
],
);
assert_eq!(stdout, RUST_SOURCE_FIXED, "stderr:\n{stderr}");
assert!(
stderr.contains("widget.rs:3:5: [MD018] No space after # in heading [fixed]"),
"the doc-comment fix is reported.\nstderr:\n{stderr}"
);
assert!(
stderr.contains("1 issue fixed, 0 issues remaining"),
"stderr:\n{stderr}"
);
}
#[test]
fn stdin_named_as_a_rust_file_reports_only_its_doc_comments() {
let dir = tempfile::tempdir().unwrap();
let (stdout, stderr) = rumdl_with_stdin(
dir.path(),
RUST_SOURCE,
&[
"check",
"--stdin",
"--stdin-filename",
"widget.rs",
"--color",
"never",
"--no-config",
],
);
let diagnostics: Vec<&str> = stdout.lines().filter(|line| line.contains("widget.rs:")).collect();
assert_eq!(
diagnostics,
["widget.rs:3:5: [MD018] No space after # in heading"],
"the doc-comment heading is the only finding.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
fn run_on_rust_file(command: &str, source: &str) -> (String, String) {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("lib.rs"), source).unwrap();
let output = rumdl(
dir.path(),
&[command, "--color", "never", "--no-cache", "--no-config", "lib.rs"],
);
(
fs::read_to_string(dir.path().join("lib.rs")).unwrap(),
String::from_utf8_lossy(&output.stdout).into_owned(),
)
}
fn diagnostics(stdout: &str) -> Vec<&str> {
stdout.lines().filter(|line| line.starts_with("lib.rs:")).collect()
}
#[test]
fn fmt_removing_blank_doc_lines_keeps_each_remaining_lines_prefix() {
let (fixed, stdout) = run_on_rust_file("fmt", "//! text\n//!\n//!\n//!\n//! more\n\npub fn f() {}\n");
assert_eq!(fixed, "//! text\n//!\n//! more\n\npub fn f() {}\n", "stdout:\n{stdout}");
assert_eq!(
diagnostics(&stdout),
[
"lib.rs:3:4: [MD012] Multiple consecutive blank lines between content [fixed]",
"lib.rs:4:4: [MD012] Multiple consecutive blank lines between content [fixed]",
],
"stdout:\n{stdout}"
);
}
#[test]
fn fmt_removing_blank_lines_in_an_indented_doc_comment_keeps_each_lines_prefix() {
let source = "pub struct S;\n\nimpl S {\n /// text\n ///\n ///\n /// more\n pub fn f() {}\n}\n";
let (fixed, stdout) = run_on_rust_file("fmt", source);
assert_eq!(
fixed, "pub struct S;\n\nimpl S {\n /// text\n ///\n /// more\n pub fn f() {}\n}\n",
"stdout:\n{stdout}"
);
}
#[test]
fn fmt_inserting_a_doc_line_keeps_each_later_lines_separator() {
let (fixed, stdout) = run_on_rust_file("fmt", "/// # Heading\n///\tTabbed\n///no-space\npub fn f() {}\n");
assert_eq!(
fixed, "/// # Heading\n///\n///\tTabbed\n///no-space\npub fn f() {}\n",
"stdout:\n{stdout}"
);
assert_eq!(
diagnostics(&stdout),
["lib.rs:1:5: [MD022] Expected 1 blank line below heading [fixed]"],
"stdout:\n{stdout}"
);
}
#[test]
fn fmt_trimming_a_doc_line_to_equal_its_neighbor_keeps_each_lines_prefix() {
let (fixed, stdout) = run_on_rust_file("fmt", "///foo \n /// foo\npub fn f() {}\n");
assert_eq!(fixed, "///foo\n /// foo\npub fn f() {}\n", "stdout:\n{stdout}");
assert_eq!(diagnostics(&stdout).len(), 1, "stdout:\n{stdout}");
}
#[test]
fn a_tab_after_the_doc_prefix_separates_it_from_the_markdown() {
let source = "///\t# Heading\n///\ttext\npub fn f() {}\n";
let (unchanged, stdout) = run_on_rust_file("check", source);
assert_eq!(unchanged, source);
assert_eq!(
diagnostics(&stdout),
["lib.rs:1:5: [MD022] Expected 1 blank line below heading"],
"stdout:\n{stdout}"
);
let (fixed, stdout) = run_on_rust_file("fmt", source);
assert_eq!(
fixed, "///\t# Heading\n///\n///\ttext\npub fn f() {}\n",
"stdout:\n{stdout}"
);
}
#[test]
fn a_tab_separator_is_not_a_leading_tab_but_a_tab_after_the_separator_is() {
let (_, stdout) = run_on_rust_file("check", "/// Some text\n///\tmore\n/// \tcode\npub fn f() {}\n");
let md010: Vec<&str> = diagnostics(&stdout)
.into_iter()
.filter(|line| line.contains("[MD010]"))
.collect();
assert_eq!(
md010,
["lib.rs:3:5: [MD010] Found leading tab, use 4 spaces instead"],
"stdout:\n{stdout}"
);
}
#[test]
fn stdin_named_as_a_rust_file_keeps_each_doc_lines_prefix_when_a_fix_removes_lines() {
let dir = tempfile::tempdir().unwrap();
let (stdout, stderr) = rumdl_with_stdin(
dir.path(),
"//! text\n//!\n//!\n//!\n//! more\n\npub fn f() {}\n",
&[
"fmt",
"--stdin",
"--stdin-filename",
"lib.rs",
"--color",
"never",
"--no-config",
],
);
assert_eq!(
stdout, "//! text\n//!\n//! more\n\npub fn f() {}\n",
"stderr:\n{stderr}"
);
}