mdr 0.6.0

A lightweight Markdown viewer with live reload and multiple rendering backends
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime};

/// Helper to get the path to the mdr binary built by cargo test.
/// Point a child at a config directory of its own.
///
/// Without this a spawned mdr resolves the *developer's* config: since the file
/// is created on first run and an old backend name is corrected in place, a test
/// run would create or rewrite the real `~/.config/mdr/config.kdl`. Every
/// variable the resolver consults has to be set, not just `HOME`.
fn isolate_config<'a>(cmd: &'a mut Command, home: &std::path::Path) -> &'a mut Command {
    cmd.env("HOME", home)
        .env("USERPROFILE", home)
        .env("XDG_CONFIG_HOME", home.join("xdg"))
        .env("APPDATA", home.join("appdata"))
}

/// A directory of this test's own.
///
/// A fixed name under the system temp directory is shared with every other run
/// of the suite: two at once delete each other's fixtures. The `TempDir` also
/// takes the cleanup off each test's hands.
fn sandbox() -> tempfile::TempDir {
    tempfile::tempdir().expect("a temp directory")
}

fn mdr_bin() -> std::path::PathBuf {
    // cargo test builds the binary in the same target directory
    let mut path = std::env::current_exe().unwrap();
    path.pop(); // remove test binary name
    path.pop(); // remove "deps"
    path.push("mdr");
    path
}

#[test]
fn stdin_pipe_with_list_backends_exits_successfully() {
    // --list-backends exits before backend runs, proving CLI accepts piped stdin
    let sandbox = sandbox();
    let mut cmd = Command::new(mdr_bin());
    let mut child = isolate_config(&mut cmd, sandbox.path())
        .arg("--list-backends")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn mdr");

    if let Some(mut stdin) = child.stdin.take() {
        stdin.write_all(b"# Hello\n").unwrap();
    }

    let output = child.wait_with_output().expect("failed to wait");
    assert!(
        output.status.success(),
        "mdr --list-backends should exit successfully"
    );
}

#[test]
fn a_dash_argument_reads_the_document_from_stdin() {
    // `-` names stdin, not a file called "-". Asserting only that an error
    // message is absent would pass on a crash, so this checks the positive
    // outcome instead: the piped bytes reach the temporary file the backends
    // are handed.
    let sandbox = sandbox();
    let tmp = sandbox.path().join("tmp");
    std::fs::create_dir_all(&tmp).unwrap();
    let home = sandbox.path().join("home");
    std::fs::create_dir_all(&home).unwrap();

    const DOC: &[u8] = b"# From stdin, via a dash\n";

    let mut cmd = Command::new(mdr_bin());
    let mut child = isolate_config(&mut cmd, &home)
        .arg("-")
        .arg("-b")
        .arg("tui")
        .arg("-v")
        .env("TMPDIR", &tmp)
        .env("TMP", &tmp)
        .env("TEMP", &tmp)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn mdr");

    if let Some(mut stdin) = child.stdin.take() {
        stdin.write_all(DOC).unwrap();
    }

    // The tui backend refuses a non-TTY stdout, so this exits on its own rather
    // than needing to be killed — and the exit is the ordinary error path, not
    // a crash.
    let output = child.wait_with_output().expect("failed to wait");
    let stderr = String::from_utf8_lossy(&output.stderr);

    assert!(
        !stderr.contains("file '-' not found"),
        "'-' must not be taken for a filename, got: {stderr}"
    );
    assert!(
        stderr.contains("requires a terminal"),
        "the expected exit is the TTY refusal, not something else, got: {stderr}"
    );

    // The temporary is removed on the way out — another test covers that — so
    // the evidence that stdin was read is the trace mdr leaves under `-v`.
    assert!(
        stderr.contains("piped input stored in"),
        "the piped document should have been stored, got: {stderr}"
    );
}

/// List the `stdin-*.md` files left in `<tmpdir>/mdr`.
fn stdin_temp_files(mdr_dir: &Path) -> Vec<PathBuf> {
    let Ok(entries) = std::fs::read_dir(mdr_dir) else {
        return Vec::new();
    };
    entries
        .flatten()
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.starts_with("stdin-") && n.ends_with(".md"))
        })
        .collect()
}

/// Run `mdr` with `content` piped on stdin and an isolated TMPDIR, and return
/// (exit success, stderr).
fn run_piped(tmpdir: &Path, args: &[&str], content: &[u8]) -> (bool, String) {
    let mut cmd = Command::new(mdr_bin());
    let mut child = isolate_config(&mut cmd, tmpdir)
        .args(args)
        // `std::env::temp_dir()` reads TMPDIR on Unix but TMP then TEMP on
        // Windows, so all three have to be set or the child writes to the real
        // temp directory and the test loses its isolation.
        .env("TMPDIR", tmpdir)
        .env("TMP", tmpdir)
        .env("TEMP", tmpdir)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("failed to spawn mdr");

    if let Some(mut stdin) = child.stdin.take() {
        let _ = stdin.write_all(content);
    }

    let output = child.wait_with_output().expect("failed to wait");
    (
        output.status.success(),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    )
}

#[test]
fn stdin_pipe_temp_file_is_created_then_removed_on_exit() {
    let tmp = tempfile::tempdir().unwrap();
    // Verbose mode reports the temp file it just created, which lets us assert
    // both that it was created and that it is gone once mdr exits.
    let (_, stderr) = run_piped(tmp.path(), &["-", "-b", "tui", "-v"], b"# Temp file test\n");

    let path: PathBuf = stderr
        .lines()
        .find_map(|l| l.split_once("piped input stored in "))
        .map_or_else(
            || panic!("mdr -v should report the stdin temp file, got: {stderr}"),
            |(_, p)| PathBuf::from(p.trim()),
        );

    let mdr_dir = tmp.path().join("mdr");
    assert_eq!(
        path.parent(),
        Some(mdr_dir.as_path()),
        "temp file should live in <tmpdir>/mdr, got {path:?}"
    );
    let name = path.file_name().unwrap().to_string_lossy().into_owned();
    assert!(
        name.starts_with("stdin-") && name.ends_with(".md"),
        "unexpected temp file name: {name}"
    );
    assert!(
        !path.exists(),
        "temp file {path:?} should be removed when mdr exits"
    );
    assert!(
        stdin_temp_files(&mdr_dir).is_empty(),
        "no stdin temp file should be left behind: {:?}",
        stdin_temp_files(&mdr_dir)
    );
}

#[cfg(unix)]
#[test]
fn temp_dir_is_created_with_owner_only_permissions() {
    use std::os::unix::fs::PermissionsExt;

    let tmp = tempfile::tempdir().unwrap();
    let mdr_dir = tmp.path().join("mdr");

    let (_, _stderr) = run_piped(tmp.path(), &["-", "-b", "tui"], b"# perms\n");

    let mode = std::fs::metadata(&mdr_dir).unwrap().permissions().mode() & 0o777;
    assert_eq!(
        mode, 0o700,
        "<tmpdir>/mdr should be created with mode 0700, got {mode:o}"
    );
}

#[test]
fn stale_temp_files_are_removed_at_startup() {
    let tmp = tempfile::tempdir().unwrap();
    let mdr_dir = tmp.path().join("mdr");
    std::fs::create_dir_all(&mdr_dir).unwrap();

    let stale = mdr_dir.join("stdin-999999.md");
    std::fs::write(&stale, "# stale").unwrap();
    std::fs::File::options()
        .write(true)
        .open(&stale)
        .unwrap()
        .set_modified(SystemTime::now() - Duration::from_secs(48 * 3600))
        .unwrap();

    let fresh = mdr_dir.join("stdin-888888.md");
    std::fs::write(&fresh, "# fresh").unwrap();

    let unrelated = mdr_dir.join("keep-me.md");
    std::fs::write(&unrelated, "# keep").unwrap();
    std::fs::File::options()
        .write(true)
        .open(&unrelated)
        .unwrap()
        .set_modified(SystemTime::now() - Duration::from_secs(48 * 3600))
        .unwrap();

    let (_, _stderr) = run_piped(tmp.path(), &["-", "-b", "tui"], b"# cleanup\n");

    assert!(
        !stale.exists(),
        "stdin temp files older than a day should be cleaned up at startup"
    );
    assert!(
        fresh.exists(),
        "recent stdin temp files belong to other running instances and must be kept"
    );
    assert!(
        unrelated.exists(),
        "files that are not stdin temp files must never be touched"
    );
}

#[test]
fn temp_dir_occupied_by_a_regular_file_is_an_error() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(tmp.path().join("mdr"), "not a directory").unwrap();

    let (ok, stderr) = run_piped(tmp.path(), &["-", "-b", "tui"], b"# oops\n");

    assert!(!ok, "mdr should fail when <tmpdir>/mdr is not a directory");
    assert!(
        stderr.contains("temp"),
        "error should mention the temp directory, got: {stderr}"
    );
}

#[cfg(unix)]
#[test]
fn temp_dir_symlink_is_not_followed() {
    let tmp = tempfile::tempdir().unwrap();
    let elsewhere = tmp.path().join("elsewhere");
    std::fs::create_dir_all(&elsewhere).unwrap();
    std::os::unix::fs::symlink(&elsewhere, tmp.path().join("mdr")).unwrap();

    let (ok, _stderr) = run_piped(tmp.path(), &["-", "-b", "tui"], b"# symlink trap\n");

    assert!(!ok, "mdr should refuse a symlinked temp directory");
    assert!(
        stdin_temp_files(&elsewhere).is_empty(),
        "mdr must not write piped content through a symlinked temp directory"
    );
}

#[test]
fn nonexistent_file_shows_error() {
    let sandbox = sandbox();
    let mut cmd = Command::new(mdr_bin());
    let output = isolate_config(&mut cmd, sandbox.path())
        .arg("this_file_does_not_exist.md")
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .expect("failed to run mdr");

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("not found"),
        "should show file not found error, got stderr: {stderr}"
    );
}