mod backend;
mod core;
use clap::Parser;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::process;
use std::time::{Duration, SystemTime};
#[derive(Parser)]
#[command(
name = "mdr",
version,
about = "Lightweight Markdown viewer with live reload"
)]
struct Cli {
file: Option<PathBuf>,
#[arg(short, long, value_parser = parse_backend)]
backend: Option<String>,
#[arg(short, long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(short, long)]
list_backends: bool,
#[arg(long)]
offline: bool,
#[arg(short, long, value_name = "BACKEND", value_parser = parse_backend)]
set_default_backend: Option<String>,
#[arg(short, long, value_name = "THEME", value_parser = parse_theme)]
theme: Option<String>,
#[arg(short, long)]
verbose: bool,
}
fn backend_is_compiled(name: &str) -> bool {
if name == "auto" {
return true;
}
if name == "gui" {
return cfg!(feature = "egui-backend");
}
if name == "tui" {
return cfg!(feature = "tui-backend");
}
if name == "web" {
return cfg!(feature = "webview-backend");
}
false
}
fn backend_feature(name: &str) -> &'static str {
match name {
"gui" => "egui-backend",
"tui" => "tui-backend",
"web" => "webview-backend",
_ => "",
}
}
fn print_backends() {
fn status(compiled: bool) -> &'static str {
if compiled {
"✓ compiled"
} else {
"✗ not compiled"
}
}
eprintln!("Available backends:");
eprintln!(
" gui Native window (OpenGL) [{}]",
status(backend_is_compiled("gui"))
);
eprintln!(
" tui Terminal UI with image support [{}]",
status(backend_is_compiled("tui"))
);
eprintln!(
" web System webview (WebKit/WebView2) [{}]",
status(backend_is_compiled("web"))
);
eprintln!(" auto Auto-detect best available (default)");
}
fn parse_theme(s: &str) -> Result<String, String> {
match core::Theme::parse(s) {
Some(_) => Ok(s.to_string()),
None => Err(format!(
"unknown theme '{s}', expected 'auto', 'dark' or 'light'"
)),
}
}
fn parse_backend(s: &str) -> Result<String, String> {
if core::config::is_valid_backend(s) {
Ok(s.to_string())
} else {
Err(format!(
"unknown backend '{s}', expected one of: {}",
core::config::BACKENDS.join(", ")
))
}
}
fn detect_backend() -> &'static str {
let is_ssh = std::env::var("SSH_CONNECTION").is_ok() || std::env::var("SSH_TTY").is_ok();
let has_display = std::env::var("DISPLAY").is_ok()
|| std::env::var("WAYLAND_DISPLAY").is_ok()
|| cfg!(target_os = "macos")
|| cfg!(target_os = "windows");
if is_ssh {
#[cfg(feature = "tui-backend")]
return "tui";
}
if has_display {
#[cfg(feature = "egui-backend")]
return "gui";
#[cfg(all(not(feature = "egui-backend"), feature = "webview-backend"))]
return "web";
}
#[cfg(feature = "tui-backend")]
return "tui";
#[cfg(not(feature = "tui-backend"))]
{
#[cfg(feature = "egui-backend")]
return "gui";
#[cfg(all(not(feature = "egui-backend"), feature = "webview-backend"))]
return "web";
#[cfg(not(any(feature = "egui-backend", feature = "webview-backend")))]
{
eprintln!("Error: no backend compiled");
process::exit(1);
}
}
}
const STALE_TMP_AGE: Duration = Duration::from_secs(24 * 60 * 60);
fn stdin_tmp_dir() -> PathBuf {
std::env::temp_dir().join("mdr")
}
#[cfg(unix)]
fn ensure_tmp_dir(dir: &Path) -> io::Result<()> {
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
match std::fs::DirBuilder::new().mode(0o700).create(dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
let meta = std::fs::symlink_metadata(dir)?;
if !meta.is_dir() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"exists and is not a directory",
));
}
if meta.uid() != unsafe { libc::getuid() } {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"is owned by another user",
));
}
if meta.permissions().mode() & 0o077 != 0 {
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
Ok(())
}
Err(e) => Err(e),
}
}
#[cfg(not(unix))]
fn ensure_tmp_dir(dir: &Path) -> io::Result<()> {
std::fs::create_dir_all(dir)
}
fn stdin_tmp_name() -> String {
use std::hash::{BuildHasher, Hasher, RandomState};
let rand = RandomState::new().build_hasher().finish();
format!("stdin-{}-{:016x}.md", process::id(), rand)
}
fn write_stdin_tmp_file(dir: &Path, content: &str) -> io::Result<PathBuf> {
let path = dir.join(stdin_tmp_name());
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&path)?;
file.write_all(content.as_bytes())?;
Ok(path)
}
fn cleanup_stale_tmp_files(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let now = SystemTime::now();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with("stdin-") || !name.ends_with(".md") {
continue;
}
let path = entry.path();
let Ok(meta) = std::fs::symlink_metadata(&path) else {
continue;
};
if !meta.is_file() {
continue;
}
let Ok(modified) = meta.modified() else {
continue;
};
if now
.duration_since(modified)
.is_ok_and(|age| age >= STALE_TMP_AGE)
{
let _ = std::fs::remove_file(&path);
}
}
}
fn read_stdin_to_tmpfile() -> Result<PathBuf, String> {
let mut content = String::new();
io::stdin()
.lock()
.read_to_string(&mut content)
.map_err(|e| format!("failed to read from stdin: {e}"))?;
let dir = stdin_tmp_dir();
ensure_tmp_dir(&dir)
.map_err(|e| format!("failed to create temp directory '{}': {}", dir.display(), e))?;
cleanup_stale_tmp_files(&dir);
let path = write_stdin_tmp_file(&dir, &content)
.map_err(|e| format!("failed to write temp file in '{}': {}", dir.display(), e))?;
crate::vlog!("piped input stored in {}", path.display());
Ok(path)
}
fn main() {
let mut tmp_file: Option<PathBuf> = None;
let code = run(&mut tmp_file);
if let Some(path) = &tmp_file {
let _ = std::fs::remove_file(path);
}
process::exit(code);
}
fn run(tmp_file: &mut Option<PathBuf>) -> i32 {
let cli = Cli::parse();
if cli.list_backends {
print_backends();
return 0;
}
let (default_path, may_create) = if cli.config.is_none() {
let (path, confident) = core::config::default_location();
(Some(path), confident)
} else {
(None, false)
};
let cfg_path = cli.config.clone().or(default_path).unwrap_or_default();
let mut created_config = false;
if may_create {
match core::config::ensure_exists(&cfg_path) {
Ok(true) => created_config = true,
Ok(false) => {}
Err(e) => eprintln!(
"mdr: warning: could not create '{}': {e}",
cfg_path.display()
),
}
}
if let Some(backend) = &cli.set_default_backend {
if !backend_is_compiled(backend) {
eprintln!(
"Error: {backend} backend not compiled. Rebuild with --features {}",
backend_feature(backend)
);
return 1;
}
if !cfg_path.exists() {
eprintln!("Error: config file '{}' not found", cfg_path.display());
return 1;
}
return match core::config::set_backend(&cfg_path, backend) {
Ok(()) => {
eprintln!("Set backend to {backend} in {}", cfg_path.display());
0
}
Err(e) => {
eprintln!("Error: {e}");
1
}
};
}
let cfg = if cli.config.is_some() && !cfg_path.exists() {
eprintln!("Error: config file '{}' not found", cfg_path.display());
return 1;
} else {
core::config::load(&cfg_path).unwrap_or_else(|e| {
eprintln!("mdr: config error ({}): {}", cfg_path.display(), e);
core::config::Config::default()
})
};
core::set_verbose(cli.verbose || cfg.verbose.unwrap_or(false));
if created_config {
vlog!("created config file: {}", cfg_path.display());
} else if cfg_path.exists() {
vlog!("config file: {}", cfg_path.display());
} else {
vlog!("no config file at {}", cfg_path.display());
}
core::set_offline(cli.offline || cfg.offline.unwrap_or(false));
core::set_theme(
cli.theme
.as_deref()
.or(cfg.theme.as_deref())
.and_then(core::Theme::parse)
.unwrap_or_default(),
);
let from_stdin = |tmp_file: &mut Option<PathBuf>| match read_stdin_to_tmpfile() {
Ok(path) => {
if let Ok(cwd) = std::env::current_dir() {
core::set_document_base(cwd);
}
*tmp_file = Some(path.clone());
Ok(path)
}
Err(e) => {
eprintln!("Error: {e}");
Err(1)
}
};
let file = match cli.file {
Some(f) if f.as_os_str() == "-" => match from_stdin(tmp_file) {
Ok(path) => path,
Err(code) => return code,
},
Some(f) => {
if !f.exists() {
eprintln!("Error: file '{}' not found", f.display());
return 1;
}
f
}
None => {
if io::stdin().is_terminal() {
eprintln!("Error: missing required argument <FILE>");
eprintln!("Usage: mdr <FILE> [OPTIONS]");
eprintln!(" cat file.md | mdr [OPTIONS]");
eprintln!("Try 'mdr --help' for more information.");
return 1;
}
match from_stdin(tmp_file) {
Ok(path) => path,
Err(code) => return code,
}
}
};
let backend_str = cli
.backend
.or(cfg.backend)
.unwrap_or_else(|| "auto".to_string());
let backend = if backend_str == "auto" {
detect_backend()
} else {
backend_str.as_str()
};
let result = match backend {
#[cfg(feature = "egui-backend")]
"gui" => backend::egui::run(file),
#[cfg(not(feature = "egui-backend"))]
"gui" => {
eprintln!("Error: gui backend not compiled. Rebuild with --features egui-backend");
return 1;
}
#[cfg(feature = "webview-backend")]
"web" => backend::webview::run(file),
#[cfg(not(feature = "webview-backend"))]
"web" => {
eprintln!("Error: web backend not compiled. Rebuild with --features webview-backend");
return 1;
}
#[cfg(feature = "tui-backend")]
"tui" => backend::tui::run(file),
#[cfg(not(feature = "tui-backend"))]
"tui" => {
eprintln!("Error: tui backend not compiled. Rebuild with --features tui-backend");
return 1;
}
other => {
eprintln!("Error: unknown backend '{other}'");
return 1;
}
};
if let Err(e) = result {
eprintln!("Error: {e}");
return 1;
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_backend_name_maps_to_a_cargo_feature() {
for name in core::config::BACKENDS {
if *name == "auto" {
continue;
}
assert!(
!backend_feature(name).is_empty(),
"backend '{name}' has no Cargo feature"
);
}
}
#[test]
fn the_help_lists_every_value_the_parsers_accept() {
use clap::CommandFactory;
let command = Cli::command();
let help_for = |long: &str| {
command
.get_arguments()
.find(|a| a.get_long() == Some(long))
.unwrap_or_else(|| panic!("no --{long} argument"))
.get_help()
.map(ToString::to_string)
.unwrap_or_default()
};
let backend_help = help_for("backend");
for backend in core::config::BACKENDS {
assert!(
backend_help.contains(backend),
"--backend accepts '{backend}' but its help does not say so: {backend_help}"
);
}
let set_default_help = help_for("set-default-backend");
assert!(
!set_default_help.is_empty(),
"--set-default-backend should describe itself"
);
let theme_help = help_for("theme");
for theme in ["auto", "dark", "light"] {
assert!(
core::Theme::parse(theme).is_some(),
"'{theme}' should be a theme"
);
assert!(
theme_help.contains(theme),
"--theme accepts '{theme}' but its help does not say so: {theme_help}"
);
}
}
#[test]
fn every_long_option_has_its_short_form() {
let long = Cli::try_parse_from(["mdr", "--theme", "light", "--config", "c.kdl", "f.md"])
.expect("long forms parse");
let short = Cli::try_parse_from(["mdr", "-t", "light", "-c", "c.kdl", "f.md"])
.expect("short forms parse");
assert_eq!(long.theme, short.theme);
assert_eq!(long.config, short.config);
assert!(Cli::try_parse_from(["mdr", "-l"]).unwrap().list_backends);
assert_eq!(
Cli::try_parse_from(["mdr", "-s", "tui"])
.unwrap()
.set_default_backend
.as_deref(),
Some("tui")
);
assert_eq!(
Cli::try_parse_from(["mdr", "-b", "web", "f.md"])
.unwrap()
.backend
.as_deref(),
Some("web")
);
assert!(Cli::try_parse_from(["mdr", "-v", "f.md"]).unwrap().verbose);
}
#[test]
fn cli_parses_and_validates_the_theme_flag() {
let cli = Cli::try_parse_from(["mdr", "--theme", "light", "f.md"]).unwrap();
assert_eq!(cli.theme.as_deref(), Some("light"));
assert!(Cli::try_parse_from(["mdr", "--theme", "neon", "f.md"]).is_err());
assert!(
Cli::try_parse_from(["mdr", "f.md"])
.unwrap()
.theme
.is_none()
);
}
#[test]
fn cli_parses_offline_flag() {
let cli = Cli::try_parse_from(["mdr", "--offline", "file.md"]).unwrap();
assert!(cli.offline);
let cli = Cli::try_parse_from(["mdr", "file.md"]).unwrap();
assert!(!cli.offline);
}
#[test]
fn write_stdin_tmp_file_writes_content_under_a_unique_name() {
let dir = tempfile::tempdir().unwrap();
let a = write_stdin_tmp_file(dir.path(), "# piped\n").unwrap();
let b = write_stdin_tmp_file(dir.path(), "# piped\n").unwrap();
assert_ne!(a, b, "two runs must not collide on the same file name");
assert_eq!(std::fs::read_to_string(&a).unwrap(), "# piped\n");
let name = a.file_name().unwrap().to_string_lossy().into_owned();
assert!(name.starts_with(&format!("stdin-{}-", process::id())));
assert!(name.ends_with(".md"));
}
#[cfg(unix)]
#[test]
fn write_stdin_tmp_file_creates_owner_only_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = write_stdin_tmp_file(dir.path(), "secret").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[cfg(unix)]
#[test]
fn ensure_tmp_dir_creates_an_owner_only_directory() {
use std::os::unix::fs::PermissionsExt;
let base = tempfile::tempdir().unwrap();
let dir = base.path().join("mdr");
ensure_tmp_dir(&dir).unwrap();
let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "expected 0700, got {mode:o}");
}
#[cfg(unix)]
#[test]
fn ensure_tmp_dir_tightens_a_loose_existing_directory() {
use std::os::unix::fs::PermissionsExt;
let base = tempfile::tempdir().unwrap();
let dir = base.path().join("mdr");
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
ensure_tmp_dir(&dir).unwrap();
let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "expected 0700, got {mode:o}");
}
#[cfg(unix)]
#[test]
fn ensure_tmp_dir_refuses_a_symlink() {
let base = tempfile::tempdir().unwrap();
let target = base.path().join("elsewhere");
std::fs::create_dir(&target).unwrap();
let dir = base.path().join("mdr");
std::os::unix::fs::symlink(&target, &dir).unwrap();
assert!(
ensure_tmp_dir(&dir).is_err(),
"a symlinked temp directory must be refused, not followed"
);
}
#[test]
fn ensure_tmp_dir_refuses_a_regular_file() {
let base = tempfile::tempdir().unwrap();
let dir = base.path().join("mdr");
std::fs::write(&dir, "not a directory").unwrap();
assert!(ensure_tmp_dir(&dir).is_err());
}
#[test]
fn cleanup_stale_tmp_files_only_removes_old_stdin_files() {
let dir = tempfile::tempdir().unwrap();
let old = SystemTime::now() - Duration::from_secs(48 * 3600);
let write_aged = |name: &str, aged: bool| {
let path = dir.path().join(name);
std::fs::write(&path, "x").unwrap();
if aged {
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(old)
.unwrap();
}
path
};
let stale = write_aged("stdin-1-deadbeef.md", true);
let fresh = write_aged("stdin-2-cafebabe.md", false);
let unrelated = write_aged("notes.md", true);
let other_ext = write_aged("stdin-3.txt", true);
cleanup_stale_tmp_files(dir.path());
assert!(!stale.exists(), "old stdin temp files must be removed");
assert!(fresh.exists(), "recent stdin temp files must be kept");
assert!(unrelated.exists(), "other files must never be touched");
assert!(other_ext.exists(), "other files must never be touched");
}
#[test]
fn cleanup_stale_tmp_files_ignores_a_missing_directory() {
cleanup_stale_tmp_files(Path::new("/nonexistent/mdr-no-such-dir"));
}
}