mk-rs 0.2.4

mk-rust CLI: Plan 9 mk compatible command-line tool
#![forbid(unsafe_code)]

//! Command-line interface for mk-rust.
//!
//! Thin CLI wrapper around mk-core. Parses flags (via clap derive):
//! `-f` (mkfile), `-n` (no-exec), `-e` (explain), `-a` (force),
//! `-k` (keep-going), `-t` (touch), `-p` (NPROC), and more.
//! Reads the mkfile, builds scope, runs `mk_core::build()`, prints results.

use std::collections::HashMap;
use std::io::IsTerminal;
use std::path::PathBuf;

use clap::Parser;

use mk_rs_core::graph::{build_graph_with_nrep, match_metarule};
use mk_rs_core::lex::{tokenize, ShellMode};
use mk_rs_core::parse::Stmt;
use mk_rs_core::sched::{execute, ResolvedRule, SchedOptions};
use mk_rs_core::var::{builtin_scope, import_env, Precedence};
#[cfg(feature = "duckscript")]
use mk_rs_shell::DuckShell;
use mk_rs_shell::{CustomShell, ShShell};

/// mk — maintain (make) related files
///
/// Reads dependency rules from a mkfile and executes recipes
/// to bring targets up to date.
#[derive(Parser)]
#[command(
    name = "mk",
    version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")"),
    about
)]
struct Cli {
    /// Mkfile to read (default: mkfile)
    #[arg(short = 'f', default_value = "mkfile")]
    file: PathBuf,

    /// Print commands but do not execute
    #[arg(short = 'n')]
    no_exec: bool,

    /// Explain why each target is (or is not) being made
    #[arg(short = 'e')]
    explain: bool,

    /// Touch targets instead of running recipes
    #[arg(short = 't')]
    touch: bool,

    /// Assume all targets are out of date
    #[arg(short = 'a')]
    all: bool,

    /// Keep going after errors
    #[arg(short = 'k')]
    keep_going: bool,

    /// Force missing intermediate targets to be built (stub — Phase 1b)
    #[arg(short = 'i')]
    force_intermediates: bool,

    /// Quiet mode: don't print recipes before execution
    #[arg(short = 'q')]
    silent: bool,

    /// Color output: auto, always, never
    #[arg(long, default_value = "auto")]
    color: String,

    /// Debug output: p (parse), g (graph), e (execution) (stub — Phase 2)
    #[arg(short = 'd')]
    debug: Option<String>,

    /// What-if mode: pretend listed targets are modified (stub — Phase 2)
    #[arg(short = 'w')]
    whatif: Option<String>,

    /// Change to directory before building
    #[arg(short = 'C')]
    directory: Option<PathBuf>,

    /// Targets to build (default: first target in mkfile)
    targets: Vec<String>,
}

// Pattern matching for metarules is provided by `mk_rs_core::graph::match_metarule`
// (single source of truth). It handles both `%` and `&` forms. Earlier, a
// local `match_simple` here only covered `%`, which silently broke `&`
// metarules (Bug B). See crates/mk-core/src/graph.rs.

fn main() {
    if let Err(e) = run() {
        eprintln!("mk: {}", e);
        std::process::exit(1);
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    // Stub flags (parsed, not yet implemented)
    let _ = &cli.force_intermediates;
    let _ = &cli.whatif;

    // -d debug flag: print requested debug categories
    if let Some(ref debug) = cli.debug {
        for ch in debug.chars() {
            match ch {
                'p' => eprintln!("mk: debug: parsing enabled"),
                'g' => eprintln!("mk: debug: graph building enabled"),
                'e' => eprintln!("mk: debug: execution enabled"),
                _ => eprintln!("mk: warning: unknown debug flag '{}'", ch),
            }
        }
    }

    // Chdir if -C specified
    if let Some(ref dir) = cli.directory {
        std::env::set_current_dir(dir)?;
    }

    // ── F-045 Phase 5: Build scope BEFORE parse ────────────────────────

    // Build fresh scope with built-ins and environment
    let mut scope = builtin_scope();
    import_env(&mut scope);

    // F-042: Parse CLI VAR=value assignments FIRST, apply with
    // CommandLine precedence (sticky-override S10). CLI vars are visible
    // to all rule headers, include paths, and assignment RHS expansions.
    // The precedence gate in Scope::set prevents mkfile reassigns.
    for arg in std::env::args().skip(1) {
        if let Some(eq_pos) = arg.find('=') {
            if !arg.starts_with('-') {
                let var_name = &arg[..eq_pos];
                let var_value = &arg[eq_pos + 1..];
                scope.set_raw(var_name, var_value, Precedence::CommandLine);
            }
        }
    }

    // Read mkfile: try -f argument first, fall back to "mkfile"
    let input = std::fs::read_to_string(&cli.file)
        .or_else(|_| std::fs::read_to_string("mkfile"))
        .map_err(|_| {
            format!(
                "no mkfile: could not read '{}' or 'mkfile'",
                cli.file.display()
            )
        })?;

    // Lex + Parse with scope (F-045: read-time expansion in parse)
    let tokens = tokenize(&input, ShellMode::Sh)?;
    let stmts = mk_rs_core::parse::parse_with_scope(&tokens, &mut scope)?;

    // Post-parse assign loop REMOVED (F-045): scope is already fully
    // populated by parse_with_scope. Re-setting would re-execute backtick
    // and waste work.

    // Build rules map: target name → resolved rule
    let mut rules: HashMap<String, ResolvedRule> = HashMap::new();
    for stmt in &stmts {
        if let Stmt::Rule(r) = stmt {
            for t in &r.targets {
                rules.insert(
                    t.clone(),
                    ResolvedRule {
                        recipe: r.recipe.clone().unwrap_or_default(),
                        attributes: r.attributes,
                        all_targets: r.targets.clone(),
                    },
                );
            }
        }
    }

    // Determine targets: CLI args or first target of first rule.
    // Filter out VAR=value CLI assignments that clap parsed as positional args.
    let target_names: Vec<String> = if cli.targets.is_empty() {
        let first_rule = stmts
            .iter()
            .find_map(|s| if let Stmt::Rule(r) = s { Some(r) } else { None });
        match first_rule {
            Some(r) => vec![r.targets[0].clone()],
            None => {
                eprintln!("mk: no targets specified and no rules in mkfile");
                std::process::exit(1);
            }
        }
    } else {
        cli.targets
            .iter()
            .filter(|t| !t.contains('='))
            .cloned()
            .collect()
    };

    // Read $NREP from the variable scope (default "1" via builtin_scope).
    // F-056: NREP limits metarule recursion depth.
    // Guard against NREP=0 (no expansion) — mk convention: NREP >= 1.
    let nrep = scope
        .get("NREP")
        .and_then(|v| v.parse::<usize>().ok())
        .unwrap_or(1)
        .max(1);

    // Build DAG
    let mut graph = build_graph_with_nrep(&stmts, &target_names, nrep)?;

    // Resolve metarule recipes for graph nodes without explicit rules
    for node in &graph.nodes {
        if !rules.contains_key(&node.name) {
            for stmt in &stmts {
                if let Stmt::Rule(r) = stmt {
                    if r.is_metarule && !r.is_regex {
                        for pat in &r.targets {
                            if !pat.contains('%') && !pat.contains('&') {
                                continue;
                            }
                            if match_metarule(&node.name, pat).is_some() {
                                rules.insert(
                                    node.name.clone(),
                                    ResolvedRule {
                                        recipe: r.recipe.clone().unwrap_or_default(),
                                        attributes: r.attributes,
                                        all_targets: vec![node.name.clone()], // concrete target, not pattern %.o
                                    },
                                );
                            }
                        }
                    }
                }
            }
        }
    }

    // MKSHELL: allow switching shell via env variable (F-053)
    let mkshell = scope.get("MKSHELL").unwrap_or("/bin/sh").to_string();

    // Select shell via $MKSHELL
    let shell: Box<dyn mk_rs_core::shell::Shell> = {
        if mkshell.contains("duckscript") || mkshell.ends_with(".ds") {
            #[cfg(not(feature = "duckscript"))]
            {
                return Err(
                    "mk: duckscript support not compiled in (rebuild with --features duckscript)"
                        .to_string()
                        .into(),
                );
            }
            #[cfg(feature = "duckscript")]
            {
                Box::new(DuckShell)
            }
        } else if mkshell == "/bin/sh" || mkshell == "sh" {
            Box::new(ShShell)
        } else {
            Box::new(CustomShell::new(&mkshell))
        }
    };

    // Build sched options from CLI flags
    let mkflags = std::env::args()
        .skip(1)
        .filter(|a| a.starts_with('-') || a.contains('='))
        .collect::<Vec<_>>()
        .join(" ");
    let mkargs = std::env::args()
        .skip(1)
        .filter(|a| !a.starts_with('-') && !a.contains('='))
        .collect::<Vec<_>>()
        .join(" ");

    // Resolve --color flag to a boolean
    let use_color = match cli.color.as_str() {
        "always" => true,
        "never" => false,
        _ => std::io::stderr().is_terminal(),
    };

    let opts = SchedOptions {
        keep_going: cli.keep_going,
        no_exec: cli.no_exec,
        explain: cli.explain,
        touch: cli.touch,
        silent: cli.silent,
        all: cli.all,
        nproc: 1, // sequential by default; $NPROC env var overrides
        force_intermediates: cli.force_intermediates,
        mkshell: shell.name().to_string(),
        mkflags,
        mkargs,
        color: use_color,
    };

    // Build environment from variable scope
    let env = scope.export();

    let working_dir = std::env::current_dir()?;

    let outcome = execute(
        &mut graph,
        &rules,
        shell.as_ref(),
        &working_dir,
        &env,
        &opts,
    )?;

    // Print failures (only reachable with -k)
    if !outcome.failed.is_empty() {
        for (target, msg) in &outcome.failed {
            eprintln!("mk: {}: {}", target, msg);
        }
        std::process::exit(1);
    }

    Ok(())
}