mlux 1.11.0

A rich Markdown viewer for modern terminals
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;

use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use log::info;

use mlux::app_context::{AppContext, AppContextBuilder};
use mlux::config;
use mlux::input_source::{self, InputSource};

/// Default sidebar width in typst points for headless (non-terminal) rendering.
const DEFAULT_SIDEBAR_WIDTH_PT: f64 = 40.0;

fn long_version() -> &'static str {
    let base = env!("CARGO_PKG_VERSION");
    let hash = option_env!("MLUX_BUILD_GIT_HASH").unwrap_or("");
    let profile = option_env!("MLUX_BUILD_PROFILE").unwrap_or("unknown");
    let describe = option_env!("MLUX_BUILD_GIT_DESCRIBE").unwrap_or("");

    // git describe --tags --always output patterns:
    //   ""                     → no git (tarball, crates.io): use Cargo version
    //   starts with 'v'       → tag present: use as-is (e.g. "v0.4.1" or "v0.4.1-3-ge0e4555")
    //   otherwise             → no tags (shallow clone etc): "{base}-dev+{hash}"
    let version = if describe.is_empty() {
        base.to_string()
    } else if describe.starts_with('v') {
        describe.to_string()
    } else {
        format!("{base}-dev+{describe}")
    };

    if hash.is_empty() {
        format!("{version} ({profile})").leak()
    } else {
        format!("{version} (rev {hash}, {profile})").leak()
    }
}

#[derive(Parser)]
#[command(name = "mlux", version = long_version(), about = "Markdown viewer and renderer powered by Typst")]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,

    /// Input Markdown file (for view mode; use `-` for stdin)
    #[arg(global = true)]
    input: Option<PathBuf>,

    /// Theme name (loaded from themes/{name}.typ)
    #[arg(long, global = true)]
    theme: Option<String>,

    /// Disable automatic file watching (viewer reloads on file change by default)
    #[arg(long, global = true)]
    no_watch: bool,

    /// Disable Landlock filesystem sandbox (Linux only; fork is always used)
    #[arg(long, global = true)]
    no_sandbox: bool,

    /// Allow fetching remote images (http/https URLs) in Markdown
    #[arg(long, global = true)]
    allow_remote_images: bool,

    /// Log output file path (enables logging when specified)
    #[arg(long, global = true)]
    log: Option<PathBuf>,
}

#[derive(Subcommand)]
enum Command {
    /// Render Markdown to PNG
    Render {
        /// Input Markdown file (use `-` for stdin)
        input: PathBuf,

        /// Output PNG file
        #[arg(short, long, default_value = "output.png")]
        output: PathBuf,

        /// Page width in pt
        #[arg(long)]
        width: Option<f64>,

        /// Output resolution in PPI
        #[arg(long)]
        ppi: Option<f32>,

        /// Tile height in pt
        #[arg(long)]
        tile_height: Option<f64>,

        /// Dump frame tree to stderr
        #[arg(long)]
        dump: bool,
    },
}

fn main() {
    let cli = Cli::parse();

    if let Some(log_path) = &cli.log {
        let file = std::fs::File::create(log_path).expect("failed to open log file");
        env_logger::Builder::from_default_env()
            .target(env_logger::Target::Pipe(Box::new(file)))
            .init();
    } else if cli.command.is_some() {
        env_logger::init();
    }
    // viewer mode + no --log → logger not initialized (no log output)

    // Load config file and merge CLI overrides
    let mut cfg = match config::load_config() {
        Ok(c) => c,
        Err(e) => {
            eprintln!("Error: {e:#}");
            std::process::exit(1);
        }
    };

    // Extract render-subcommand CLI overrides (width/ppi/tile_height)
    let (render_width, render_ppi, render_tile_height) = match &cli.command {
        Some(Command::Render {
            width,
            ppi,
            tile_height,
            ..
        }) => (*width, *ppi, *tile_height),
        None => (None, None, None),
    };

    // Build CliOverrides for viewer config reload support
    let cli_overrides = config::CliOverrides {
        theme: cli.theme.clone(),
        width: render_width,
        ppi: render_ppi,
        tile_height: render_tile_height,
        allow_remote_images: cli.allow_remote_images,
    };

    cfg.merge_cli(cli.theme, render_width, render_ppi, render_tile_height);
    let config = cfg.resolve();

    // Theme detection: only when theme is "auto" and stdout is a TTY
    let detected_light = if config.theme == "auto" {
        use std::io::IsTerminal;
        if std::io::stdout().is_terminal() {
            let _raw = crossterm::terminal::enable_raw_mode();
            let result = mlux::viewer::detect_terminal_theme(std::time::Duration::from_millis(100))
                == mlux::viewer::TerminalTheme::Light;
            let _ = crossterm::terminal::disable_raw_mode();
            result
        } else {
            false
        }
    } else {
        false
    };

    // Build AppContext: shared initialization for both modes
    let app = match AppContextBuilder::new(config, cli_overrides)
        .load_fonts()
        .set_detected_light(detected_light)
        .build()
    {
        Ok(app) => app,
        Err(e) => {
            eprintln!("Error: {e:#}");
            std::process::exit(1);
        }
    };

    // Build InputSource: shared for both modes
    let render_input_path = cli
        .command
        .as_ref()
        .map(|Command::Render { input, .. }| input.clone());
    let input_source = build_input_source(cli.input.or(render_input_path));

    let result = match cli.command {
        Some(Command::Render { output, dump, .. }) => {
            cmd_render(app, input_source, output, dump, cli.no_sandbox)
        }
        None => mlux::viewer::run(app, input_source, !cli.no_watch, cli.no_sandbox),
    };

    if let Err(e) = result {
        let msg = format!("{e:#}");
        if msg.contains("[BUG]") {
            eprintln!("\x1b[1;31m{msg}\x1b[0m");
        } else {
            eprintln!("Error: {msg}");
        }
        std::process::exit(1);
    }
}

fn build_input_source(input: Option<PathBuf>) -> InputSource {
    if input_source::is_stdin_input(input.as_deref()) {
        InputSource::Stdin(input_source::StdinReader::new())
    } else {
        match input {
            Some(p) => match p.canonicalize() {
                Ok(canonical) => InputSource::File(canonical),
                Err(e) => {
                    eprintln!("Error: failed to resolve {}: {e}", p.display());
                    std::process::exit(1);
                }
            },
            None => {
                eprintln!("Error: input file required (or pipe via stdin)");
                std::process::exit(1);
            }
        }
    }
}

fn cmd_render(
    app: AppContext,
    mut input: InputSource,
    output: PathBuf,
    dump: bool,
    no_sandbox: bool,
) -> Result<()> {
    let pipeline_start = Instant::now();

    let markdown = input.read_all()?;

    if markdown.trim().is_empty() {
        anyhow::bail!("input file is empty or contains only whitespace");
    }

    let read_base = match &input {
        InputSource::File(path) => path.parent().map(|d| d.to_path_buf()),
        InputSource::Stdin(_) => None,
    };

    let params = app.build_params(
        &markdown,
        read_base.as_deref(),
        app.config.width,
        DEFAULT_SIDEBAR_WIDTH_PT,
        app.config.viewer.tile_height,
    );

    if dump {
        let mut child = mlux::fork_render::fork_dump(&params, read_base.as_deref(), no_sandbox)?;
        let code = child.wait()?;
        if code != 0 {
            anyhow::bail!("dump failed (child exited with code {code})");
        }
        return Ok(());
    }

    let output_parent = output.parent().unwrap_or_else(|| std::path::Path::new("."));
    fs::create_dir_all(output_parent).ok();

    let stem = output
        .file_stem()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();
    let ext = output
        .extension()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();

    cmd_render_fork(
        &params,
        read_base.as_deref(),
        output_parent,
        &stem,
        &ext,
        &input,
        pipeline_start,
        no_sandbox,
    )
}

/// Render via fork+IPC: child compiles/renders in a forked process.
#[allow(clippy::too_many_arguments)]
fn cmd_render_fork(
    params: &mlux::pipeline::BuildParams<'_>,
    read_base: Option<&Path>,
    output_parent: &Path,
    stem: &str,
    ext: &str,
    input: &InputSource,
    pipeline_start: Instant,
    no_sandbox: bool,
) -> Result<()> {
    use mlux::fork_render::spawn_renderer;

    let (meta, mut renderer, mut _child) = spawn_renderer(params, read_base, no_sandbox)?;

    let mut files = Vec::new();
    for i in 0..meta.tile_count {
        let pngs = renderer.render_tile_pair(i)?;
        let filename = format!("{}-{:03}.{}", stem, i, ext);
        let path = output_parent.join(&filename);
        fs::write(&path, &pngs.content)
            .with_context(|| format!("failed to write {}", path.display()))?;
        files.push((filename, pngs.content.len()));
    }
    renderer.shutdown();

    info!(
        "cmd_render: total pipeline completed in {:.1}ms",
        pipeline_start.elapsed().as_secs_f64() * 1000.0
    );

    let input_name = input.display_name();
    eprintln!("rendered {} -> {} tile(s):", input_name, meta.tile_count);
    for (filename, size) in &files {
        eprintln!("  {} ({} bytes)", filename, size);
    }

    Ok(())
}