relux 0.6.0

Expect-style integration test framework for interactive shell programs
pub mod check;
pub mod completer;
pub mod completions;
pub mod dump;
pub mod history;
pub mod init;
pub mod new;
pub mod run;

use std::fs;
use std::path::PathBuf;
use std::process;

use clap::Arg;
use clap::ArgAction;
use clap::Command;
use clap::value_parser;
use clap_complete::engine::ArgValueCompleter;

use relux_core::config;
use relux_core::config::ReluxConfig;
use relux_core::diagnostics::ModulePath;
use relux_resolver::FsSourceLoader;
use relux_resolver::discover_test_modules;

pub enum ModuleKind {
    Test,
    Effect,
    Lib,
}

pub fn cli() -> Command {
    Command::new("relux")
        .about("Relux test runner")
        .subcommand_required(true)
        .subcommand(
            Command::new("init").about("Initialize a new Relux project in the current directory"),
        )
        .subcommand(
            Command::new("new")
                .about("Scaffold a new test, effect, or library module")
                .group(
                    clap::ArgGroup::new("kind")
                        .args(["test", "effect", "lib"])
                        .required(true),
                )
                .arg(
                    Arg::new("test")
                        .long("test")
                        .help("Create a test module (e.g. foo/bar/baz)")
                        .value_name("MODULE_PATH")
                        .add(ArgValueCompleter::new(completer::complete_test_dirs)),
                )
                .arg(
                    Arg::new("effect")
                        .long("effect")
                        .help("Create an effect module (e.g. foo/bar/baz)")
                        .value_name("MODULE_PATH")
                        .add(ArgValueCompleter::new(completer::complete_effect_dirs)),
                )
                .arg(
                    Arg::new("lib")
                        .long("lib")
                        .help("Create a library module (e.g. utils/helpers)")
                        .value_name("MODULE_PATH")
                        .add(ArgValueCompleter::new(completer::complete_lib_dirs)),
                ),
        )
        .subcommand(
            Command::new("run")
                .about("Run tests")
                .arg(
                    Arg::new("paths")
                        .short('f')
                        .long("file")
                        .help("Test files or directories to run (default: relux/tests/)")
                        .action(ArgAction::Append)
                        .value_parser(value_parser!(PathBuf))
                        .add(ArgValueCompleter::new(completer::complete_relux_files)),
                )
                .arg(
                    Arg::new("test")
                        .short('t')
                        .long("test")
                        .help("Run only tests with this name (requires exactly one --file)")
                        .action(ArgAction::Append)
                        .add(ArgValueCompleter::new(completer::complete_test_names)),
                )
                .arg(
                    Arg::new("tap")
                        .long("tap")
                        .help("Generate TAP artifact file")
                        .action(ArgAction::SetTrue),
                )
                .arg(
                    Arg::new("junit")
                        .long("junit")
                        .help("Generate JUnit XML artifact file")
                        .action(ArgAction::SetTrue),
                )
                .arg(
                    Arg::new("multiplier")
                        .short('m')
                        .long("timeout-multiplier")
                        .help(
                            "Scale tolerance (~) timeouts by this factor; \
                             assertion (@) timeouts are not scaled",
                        )
                        .value_parser(value_parser!(f64))
                        .default_value("1.0"),
                )
                .arg(
                    Arg::new("progress")
                        .long("progress")
                        .help(
                            "Progress display mode: auto (TUI if TTY), \
                             plain (results only), tui (force TUI)",
                        )
                        .value_parser(["auto", "plain", "tui"])
                        .default_value("auto"),
                )
                .arg(
                    Arg::new("strategy")
                        .long("strategy")
                        .help(
                            "Run strategy: 'all' runs every test; \
                             'fail-fast' stops at first failure",
                        )
                        .value_parser(["all", "fail-fast"])
                        .default_value("all"),
                )
                .arg(
                    Arg::new("rerun")
                        .long("rerun")
                        .help("Re-run only non-passing tests from the latest run")
                        .action(ArgAction::SetTrue),
                )
                .arg(
                    Arg::new("manifest")
                        .long("manifest")
                        .help("Path to the suite manifest file (default: auto-discover Relux.toml)")
                        .value_parser(value_parser!(PathBuf))
                        .add(ArgValueCompleter::new(completer::complete_manifest)),
                )
                .arg(
                    Arg::new("flaky-retries")
                        .long("flaky-retries")
                        .help("Maximum number of retries for flaky-marked tests")
                        .value_parser(clap::value_parser!(u32)),
                )
                .arg(
                    Arg::new("flaky-multiplier")
                        .long("flaky-multiplier")
                        .help(
                            "Exponential timeout multiplier base for flaky retries (default: 1.5)",
                        )
                        .value_parser(|s: &str| {
                            let v: f64 = s
                                .parse()
                                .map_err(|e: std::num::ParseFloatError| e.to_string())?;
                            if v <= 1.0 {
                                Err("multiplier must be greater than 1.0".to_string())
                            } else {
                                Ok(v)
                            }
                        }),
                )
                .arg(
                    Arg::new("jobs")
                        .short('j')
                        .long("jobs")
                        .help("Number of parallel test workers (default: 1)")
                        .value_parser(clap::value_parser!(usize)),
                )
                .arg(
                    Arg::new("test-timeout")
                        .long("test-timeout")
                        .help("Override per-test timeout (humantime string, e.g. '5m', '30s')")
                        .value_name("DURATION")
                        .add(ArgValueCompleter::new(completer::complete_test_timeout)),
                )
                .arg(
                    Arg::new("suite-timeout")
                        .long("suite-timeout")
                        .help("Override suite timeout (humantime string, e.g. '1h', '30m')")
                        .value_name("DURATION")
                        .add(ArgValueCompleter::new(completer::complete_suite_timeout)),
                ),
        )
        .subcommand(
            Command::new("check")
                .about("Validate test files without executing")
                .arg(
                    Arg::new("paths")
                        .help("Test files or directories to check (default: relux/tests/)")
                        .num_args(0..)
                        .value_parser(value_parser!(PathBuf))
                        .add(ArgValueCompleter::new(completer::complete_relux_files)),
                )
                .arg(
                    Arg::new("manifest")
                        .long("manifest")
                        .help("Path to the suite manifest file (default: auto-discover Relux.toml)")
                        .value_parser(value_parser!(PathBuf))
                        .add(ArgValueCompleter::new(completer::complete_manifest)),
                ),
        )
        .subcommand(
            Command::new("history")
                .about("Analyze run history")
                .group(clap::ArgGroup::new("analysis").args([
                    "flaky",
                    "failures",
                    "first-fail",
                    "durations",
                ]))
                .arg(
                    Arg::new("flaky")
                        .long("flaky")
                        .help("Show flakiness rate per test")
                        .action(ArgAction::SetTrue),
                )
                .arg(
                    Arg::new("failures")
                        .long("failures")
                        .help("Show failure frequency and mode distribution")
                        .action(ArgAction::SetTrue),
                )
                .arg(
                    Arg::new("first-fail")
                        .long("first-fail")
                        .help("Show most recent pass-to-fail regression per test")
                        .action(ArgAction::SetTrue),
                )
                .arg(
                    Arg::new("durations")
                        .long("durations")
                        .help("Show duration trends and statistics")
                        .action(ArgAction::SetTrue),
                )
                .arg(
                    Arg::new("tests")
                        .long("tests")
                        .help("Filter to specific test files or directories")
                        .num_args(1..)
                        .value_parser(value_parser!(PathBuf))
                        .add(ArgValueCompleter::new(completer::complete_relux_files)),
                )
                .arg(
                    Arg::new("last")
                        .long("last")
                        .help("Limit analysis to the N most recent runs (default: 10)")
                        .value_parser(value_parser!(usize))
                        .default_value("10")
                        .add(ArgValueCompleter::new(completer::complete_history_last)),
                )
                .arg(
                    Arg::new("top")
                        .long("top")
                        .help("Show only the top N results")
                        .value_parser(value_parser!(usize))
                        .add(ArgValueCompleter::new(completer::complete_history_top)),
                )
                .arg(
                    Arg::new("format")
                        .long("format")
                        .help("Output format")
                        .value_parser(["human", "toml"])
                        .default_value("human"),
                )
                .arg(
                    Arg::new("manifest")
                        .long("manifest")
                        .help("Path to the suite manifest file (default: auto-discover Relux.toml)")
                        .value_parser(value_parser!(PathBuf))
                        .add(ArgValueCompleter::new(completer::complete_manifest)),
                ),
        )
        .subcommand(
            Command::new("completions")
                .about("Install shell completions")
                .arg(
                    Arg::new("shell")
                        .long("shell")
                        .help("Shell to generate completions for (default: autodetect from $SHELL)")
                        .value_parser(["bash", "zsh", "fish"])
                        .add(ArgValueCompleter::new(completer::complete_shell)),
                )
                .arg(
                    Arg::new("install")
                        .long("install")
                        .help("Write the completion script to the target location")
                        .action(ArgAction::SetTrue),
                )
                .arg(
                    Arg::new("path")
                        .long("path")
                        .help("Override the install path for the completion script")
                        .value_parser(value_parser!(PathBuf)),
                ),
        )
        .subcommand(
            Command::new("dump")
                .about("Introspection tools")
                .subcommand_required(true)
                .subcommand(
                    Command::new("tokens").about("Dump lexer tokens").arg(
                        Arg::new("file")
                            .help("File to tokenize")
                            .required(true)
                            .value_parser(value_parser!(PathBuf))
                            .add(ArgValueCompleter::new(completer::complete_relux_files)),
                    ),
                )
                .subcommand(
                    Command::new("ast").about("Dump parsed AST").arg(
                        Arg::new("file")
                            .help("File to parse")
                            .required(true)
                            .value_parser(value_parser!(PathBuf))
                            .add(ArgValueCompleter::new(completer::complete_relux_files)),
                    ),
                )
                .subcommand(
                    Command::new("ir").about("Dump resolved IR").arg(
                        Arg::new("files")
                            .help("Files to resolve")
                            .required(true)
                            .num_args(1..)
                            .value_parser(value_parser!(PathBuf))
                            .add(ArgValueCompleter::new(completer::complete_relux_files)),
                    ),
                ),
        )
}

// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------

pub fn resolve_project(matches: &clap::ArgMatches) -> (PathBuf, ReluxConfig) {
    let result = match matches.get_one::<PathBuf>("manifest") {
        Some(path) => config::load_manifest(path),
        None => config::discover_project_root(),
    };
    result.unwrap_or_else(|e| {
        eprintln!("error: {e}");
        process::exit(1);
    })
}

pub fn read_file(path: &PathBuf) -> String {
    fs::read_to_string(path).unwrap_or_else(|e| {
        eprintln!("error reading {}: {e}", path.display());
        process::exit(1);
    })
}

pub fn resolve_test_paths(
    matches: &clap::ArgMatches,
    project_root: &std::path::Path,
) -> Vec<ModulePath> {
    let relux_dir = project_root.join(config::RELUX_DIR);
    let paths: Option<Vec<PathBuf>> = matches
        .get_many::<PathBuf>("paths")
        .map(|p| p.cloned().collect());

    match paths {
        Some(files) => files
            .iter()
            .filter_map(|f| {
                let abs = if f.is_relative() {
                    std::env::current_dir().ok()?.join(f)
                } else {
                    f.clone()
                };
                let rel = abs.strip_prefix(&relux_dir).ok()?;
                let without_ext = rel.with_extension("");
                let mod_path = without_ext.to_string_lossy().replace('\\', "/");
                Some(ModulePath(mod_path))
            })
            .collect(),
        None => {
            let test_dir = config::tests_dir(project_root);
            discover_test_modules(&test_dir, &relux_dir)
        }
    }
}

pub fn build_source_loader(
    project_root: &std::path::Path,
) -> Box<dyn relux_resolver::SourceLoader> {
    let relux_dir = project_root.join(config::RELUX_DIR);
    Box::new(FsSourceLoader::new(relux_dir, vec![]))
}