//! Cli commands implementation.

use std::collections::HashSet;
use std::ffi::OsString;
use std::fmt::Write;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};

use serde_yaml::{Value, from_reader};

use crate::config::load::{LoadError, load_config};
use crate::config::schema::FileConfig;
use crate::core::cli::{Cli, Command, WsCommand};
use crate::core::config::{ResolveError, ResolvedConfig, discover, resolve_all, resolve_single};
use crate::core::exec::{Cmd, ExecError, Runner};
use crate::core::log;

/// Errors produced by command execution and configuration resolution.
#[derive(Debug)]
pub enum KazeError {
    /// I/O failure while interacting with the filesystem.
    Io(std::io::Error),
    /// Failure while executing an external command.
    Exec(ExecError),
    /// Failure while loading configuration files.
    Load(LoadError),
    /// Failure while resolving configuration values.
    Resolve(ResolveError),
    /// Generic user-facing error message.
    Msg(String),
}

impl From<std::io::Error> for KazeError {
    fn from(value: std::io::Error) -> Self {
        Self::Io(value)
    }
}

impl From<ExecError> for KazeError {
    fn from(value: ExecError) -> Self {
        Self::Exec(value)
    }
}

impl From<LoadError> for KazeError {
    fn from(value: LoadError) -> Self {
        Self::Load(value)
    }
}

impl From<ResolveError> for KazeError {
    fn from(value: ResolveError) -> Self {
        Self::Resolve(value)
    }
}

/// Maps YAML parsing failures into user-facing errors.
impl From<serde_yaml::Error> for KazeError {
    fn from(value: serde_yaml::Error) -> Self {
        Self::Msg(format!("failed to load yaml: {value:?}"))
    }
}

/**
 * Runs the selected CLI command.
 *
 * # Errors
 * Returns a `KazeError` when configuration discovery, loading, resolution, or
 * command execution fails.
 */
pub fn run(cli: &Cli) -> Result<(), KazeError> {
    log::set_verbosity(cli.verbose);
    let ctx = discover(cli.project.as_deref(), cli)
        .map_err(|e| KazeError::Msg(format!("project_dir discovery failed: {e:?}")))?;

    // do not load config for `init` and `boards` commands
    let command = &cli.command;
    let file_cfg = match *command {
        Command::Init(_) | Command::Boards => FileConfig::default(),
        _ => load_config(&ctx.project_dir)
            .map_err(|e| KazeError::Msg(format!("load failed: {e:?}")))?,
    };

    let is_profile_cmd = matches!(
        command,
        Command::Conf(_)
            | Command::Build(_)
            | Command::Run(_)
            | Command::Flash(_)
            | Command::Menuconfig(_)
            | Command::Runners
            | Command::Bom(_)
    );

    log_command_selection(command);

    if cli.all && is_profile_cmd && !file_cfg.profiles.is_empty() {
        let rcs = resolve_all(cli, &ctx, &file_cfg);
        let mut any_failed = false;

        for item in rcs {
            match item {
                Ok(rc) => {
                    log_profile_status(&rc, &file_cfg);
                    if let Err(e) = run_cmd(
                        &rc,
                        cli.verbose,
                        cli.dry_run,
                        cli.preclean,
                        command,
                        is_profile_cmd,
                        &file_cfg,
                    ) {
                        any_failed = true;
                        log::error(format!(
                            "command failed for profile {:?}: {e:?}",
                            rc.profile.clone()
                        ));
                    }
                }
                Err(e) => {
                    return Err(KazeError::Msg(format!("resolve failed: {e:?}")));
                }
            }
        }

        if any_failed {
            return Err(KazeError::Msg(
                "command failed for one or more profiles".into(),
            ));
        }

        return Ok(());
    }

    let rc = resolve_single(cli, &ctx, &file_cfg)
        .map_err(|e| KazeError::Msg(format!("resolve failed: {e:?}")))?;

    if is_profile_cmd {
        log_profile_status(&rc, &file_cfg);
    }

    run_cmd(
        &rc,
        cli.verbose,
        cli.dry_run,
        cli.preclean,
        command,
        is_profile_cmd,
        &file_cfg,
    )?;

    Ok(())
}

/// Logs the selected command name.
fn log_command_selection(command: &Command) {
    let name = match *command {
        Command::Init(_) => "init",
        Command::Boards => "boards",
        Command::Runners => "runners",
        Command::Profiles => "profiles",
        Command::Clean => "clean",
        Command::Conf(_) => "conf",
        Command::Build(_) => "build",
        Command::Run(_) => "run",
        Command::Flash(_) => "flash",
        Command::Menuconfig(_) => "menuconfig",
        Command::Bom(_) => "bom",
        Command::Workspace(_) => "workspace",
    };
    log::info(format!("Running command '{name}'"));
}

/// Logs the resolved profile selection status.
fn log_profile_status(rc: &ResolvedConfig, file_cfg: &FileConfig) {
    if file_cfg.profiles.is_empty() || rc.profile.is_none() {
        log::info("Profiles not configured - running in profile-less mode");
    } else if let Some(ref profile) = rc.profile {
        log::info(format!("Using profile '{profile}'"));
    }
}

/**
 * Dispatches the selected CLI command with the resolved config and flags.
 *
 * # Errors
 * Returns `KazeError` if the command handler fails.
 */
fn run_cmd(
    rc: &ResolvedConfig,
    verbose: u8,
    dry_run: bool,
    preclean: bool,
    command: &Command,
    is_profile_cmd: bool,
    file_cfg: &FileConfig,
) -> Result<(), KazeError> {
    let runner = Runner {
        verbose: match command {
            &Command::Boards => 3,
            _ => verbose,
        },
        dry_run,
    };

    if preclean && is_profile_cmd {
        clean_dir_safe(&rc.build_dir, &rc.ctx.project_dir, dry_run)?;
    }

    match *command {
        Command::Init(ref i) => cmd_init(rc, i.force)?,
        Command::Profiles => cmd_profiles(file_cfg),
        Command::Clean => cmd_clean_root(&rc.build_root, &rc.ctx.project_dir, dry_run)?,
        Command::Conf(ref p) => {
            let args = merge_args(&rc.args_conf, &p.extra);
            cmd_conf(runner, rc, &args)?;
        }
        Command::Build(ref p) => {
            let args = merge_args(&rc.args_build, &p.extra);
            cmd_build(runner, rc, &args)?;
        }
        Command::Flash(ref f) => {
            let args = merge_args(&rc.args_flash, &f.phase.extra);
            cmd_flash(runner, rc, f.sys.list, f.sys.image.clone(), &args)?;
        }
        Command::Run(ref r) => {
            let args = merge_args(&rc.args_run, &r.phase.extra);
            cmd_run(
                runner,
                rc,
                r.norebuild,
                r.sys.list,
                r.sys.image.clone(),
                &args,
            )?;
        }
        Command::Boards => cmd_west_boards(runner)?,
        Command::Runners => cmd_runners(rc)?,
        Command::Menuconfig(ref m) => cmd_menuconfig(runner, rc, m.sys.list, m.sys.image.clone())?,
        Command::Bom(ref b) => cmd_bom(runner, rc, b.sys.list, b.sys.image.clone())?,
        Command::Workspace(ref w) => cmd_workspace(runner, rc, &w.command)?,
    }

    if is_profile_cmd {
        if let Err(err) = ensure_compile_commands_link(rc, file_cfg) {
            log::error(format!("failed to link compile_commands.json: {err:?}"));
        }
    }

    Ok(())
}

/// Merges config args with CLI extra args (config first, then CLI).
fn merge_args(config: &[String], extra: &[String]) -> Vec<String> {
    let mut merged = Vec::with_capacity(config.len().saturating_add(extra.len()));
    for arg in config.iter().chain(extra.iter()) {
        if arg.trim().is_empty() {
            continue;
        }
        merged.push(arg.clone());
    }
    merged
}

/// Appends non-empty args to the command.
fn push_filtered_args(cmd: &mut Cmd, extra: &[String]) {
    for arg in extra {
        if arg.trim().is_empty() {
            continue;
        }
        cmd.args.push(OsString::from(arg));
    }
}

/**
 * Runs a command while showing a spinner at normal verbosity.
 *
 * # Errors
 * Returns `KazeError` if the command fails.
 */
fn run_with_spinner_message(runner: Runner, cmd: &Cmd, message: &str) -> Result<(), KazeError> {
    if runner.verbose == 2 {
        runner.run_with_spinner(cmd, message)?;
    } else {
        log::info(message);
        runner.run(cmd)?;
    }
    Ok(())
}

/**
 * Ensures `build/compile_commands.json` points to the selected profile's output.
 *
 * # Arguments
 * - `rc`: Resolved configuration for the active profile.
 * - `file_cfg`: File configuration with build and profile settings.
 *
 * # Returns
 * `Ok(())` when the link is created, already exists, or is skipped.
 *
 * # Errors
 * Returns `KazeError` if filesystem operations fail.
 */
fn ensure_compile_commands_link(
    rc: &ResolvedConfig,
    file_cfg: &FileConfig,
) -> Result<(), KazeError> {
    // exit if compile_commands linkage disabled
    if matches!(file_cfg.build.link_compile_commands, Some(false)) {
        return Ok(());
    }

    let resolved_profile = resolve_default_profile(file_cfg);
    // don't link if profile-less layout and non-sysbuild project
    if resolved_profile.is_none() && !rc.sysbuild {
        return Ok(());
    }

    // check if compile_commands already linked
    let link_path = rc.build_root.join("compile_commands.json");
    if let Ok(meta) = fs::symlink_metadata(&link_path) {
        let ft = meta.file_type();
        if ft.is_symlink() {
            return Ok(());
        }
        if meta.is_file() {
            fs::remove_file(&link_path)?;
        } else {
            return Ok(());
        }
    }

    fs::create_dir_all(&rc.build_root)?;

    // if profile build layout add profil to path
    let mut target = rc.build_root.clone();
    if let Some(profile) = resolved_profile {
        target = target.join(profile);
    }

    // select image if sysbuild project
    if rc.sysbuild {
        let image = resolve_sysbuild_image(
            rc,
            rc.ctx
                .project_dir
                .file_name()
                .map(|s| s.to_string_lossy().into_owned()),
        )?;

        target = target.join(image);
    }

    // link compile commands
    target = target.join("compile_commands.json");
    create_symlink(&target, &link_path)?;

    Ok(())
}

/**
 * Resolves the default or first configured profile name.
 *
 * # Returns
 * Default profile when configured and valid, otherwise the first declared profile.
 */
fn resolve_default_profile(file_cfg: &FileConfig) -> Option<String> {
    if let Some(ref name) = file_cfg.project.default_profile {
        if file_cfg.profiles.contains_key(name) {
            return Some(name.clone());
        }
    }

    file_cfg.profiles.keys().next().cloned()
}

/**
 * Creates a symlink to the `compile_commands.json` target.
 *
 * # Errors
 * Returns `KazeError` if the symlink cannot be created.
 */
fn create_symlink(target: &Path, link: &Path) -> Result<(), KazeError> {
    #[cfg(unix)]
    {
        std::os::unix::fs::symlink(target, link)?;
    }
    #[cfg(windows)]
    {
        std::os::windows::fs::symlink_file(target, link)?;
    }
    Ok(())
}

/**
 * Creates a default `kaze.toml` in the project root.
 *
 * # Arguments
 * - `project_dir`: Project root directory that will contain `kaze.toml`.
 *
 * # Returns
 * `Ok(())` after writing the template file.
 *
 * # Errors
 * Returns `KazeError` if the file already exists or the template cannot be
 * written to disk.
 */
fn cmd_init(rc: &ResolvedConfig, force: bool) -> Result<(), KazeError> {
    log::info("Initializing kaze.toml");
    let path = &rc.ctx.project_dir.join("kaze.toml");
    if path.exists() && !force {
        return Err(KazeError::Msg("kaze.toml already exists".into()));
    }

    let template = format!(
        r#"# Project config
[project]
board = {:?}
runner = {:?}
# args = {{ conf = [""], build = [""], run = [""], flash = [""] }}
# default_profile = "sim"

# Build dir config
[build]
root = "build"

# Zephyr workspace override
# [zephyr]
# workspace = ""
# base = ""

# SPDX BoM generation
# [bom]
# build = true
# version = "2.2"

# Build profiles
# [profile.sim]
# board = "native_sim"
# runner = "native"
# args = {{ conf = [""], build = [""], run = [""], flash = [""] }}

# [profile.prod]
# args = {{ conf = [""], build = [""], run = [""], flash = [""] }}
"#,
        &rc.board.clone(),
        rc.runner
            .as_ref()
            .map_or_else(|| "openocd".to_owned(), std::clone::Clone::clone)
    );

    if force && fs::exists(path)? {
        fs::remove_file(path)?;
    }

    fs::write(path, template)?;
    log::info(format!("created {}", path.display()));
    Ok(())
}

/**
 * Prints configured profiles or a placeholder when none exist.
 *
 * # Arguments
 * - `file_cfg`: Loaded configuration containing profile definitions.
 */
fn cmd_profiles(file_cfg: &FileConfig) {
    log::info("Listing configured profiles");
    if file_cfg.profiles.is_empty() {
        println!("(no profiles configured)");
        return;
    }

    println!("Configured profiles:\n");
    for (i, name) in file_cfg.profiles.keys().enumerate() {
        println!("{i:>2}: {name}");
    }
}

/**
 * Cleans the build root directory with safety checks.
 *
 * # Arguments
 * - `build_root`: Root build directory to delete.
 * - `project_dir`: Project root used to validate the delete target.
 * - `dry_run`: When true, only log the delete target instead of removing it.
 *
 * # Returns
 * `Ok(())` once the cleanup completes or is skipped.
 *
 * # Errors
 * Returns `KazeError` if the delete target is outside the project root, points
 * at the project root, or cannot be removed.
 */
fn cmd_clean_root(build_root: &Path, project_dir: &Path, dry_run: bool) -> Result<(), KazeError> {
    log::info("Cleaning build directory");
    clean_dir_safe(build_root, project_dir, dry_run)?;
    log::info("Clean completed");
    Ok(())
}

/**
 * Runs the configure phase (`cmake`) for the resolved configuration.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `extra`: Extra arguments to append to the `cmake` invocation.
 *
 * # Returns
 * `Ok(())` after the configure command succeeds.
 *
 * # Errors
 * Returns `KazeError` if the build directory cannot be created or if the
 * configure command fails to execute.
 */
fn cmd_conf(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
    fs::create_dir_all(&rc.build_dir)?;

    if rc.bom_build {
        if rc.sysbuild {
            let images = find_images(rc)?;
            for image in images {
                ensure_bom_conf(runner, rc, &rc.build_dir.join(image))?;
            }
        } else {
            ensure_bom_conf(runner, rc, &rc.build_dir)?;
        }
    }

    let message = if rc.sysbuild {
        "Configuring sysbuild project"
    } else {
        "Configuring project"
    };

    let cmd = build_conf_cmd(rc, extra)?;
    run_with_spinner_message(runner, &cmd, message)?;
    log::info("Configure completed");

    Ok(())
}

/**
 * Generates SPDX `BoM` for a selected profile app/image.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `list`: Prompt to list images.
 * - `img`: Selected sysbuild image.
 *
 * # Returns
 * `Ok(())` on successful `BoM` generation.
 *
 * # Errors
 * Returns `KazeError` if fails to generate `BoM` or any of the underlying command calls fail.
 */
fn cmd_bom(
    runner: Runner,
    rc: &ResolvedConfig,
    list: bool,
    img: Option<String>,
) -> Result<(), KazeError> {
    if !rc.bom_build {
        log::info("BoM generation was not enabled for this project.");
        return Err(KazeError::Msg("BoM generation disabled".into()));
    }

    if rc.sysbuild && list {
        return cmd_list(rc);
    } else if !rc.sysbuild && list {
        log::error("Option --list is only supported for sysbuild projects");
        return Err(KazeError::Msg(
            "Non-sysbuild project. No images to list".into(),
        ));
    }
    if !rc.sysbuild && img.is_some() {
        log::error("Option --image is only supported for sysbuild projects");
        return Err(KazeError::Msg(
            "Non-sysbuild project. No images to select".into(),
        ));
    }

    let mut build_dir = rc.build_dir.clone();
    if rc.sysbuild {
        let image = resolve_sysbuild_image(rc, img).inspect_err(|_| {
            log::error("Failed to resolve image");
        })?;
        log::info(format!("Generating SPDX `BoM` for sysbuild image: {image}"));
        build_dir = build_dir.join(image);
    }

    ensure_built(runner, rc)?;

    let cmd = Cmd::new("west")
        .arg("spdx")
        .arg("--spdx-version")
        .arg(rc.bom_v.clone())
        .arg("-d")
        .arg(os(build_dir.as_path()));

    runner.run_with_spinner(&cmd, "Generating SPDX BoM")?;

    log::info(format!(
        "SPDX BoM generated in: {}",
        build_dir.join("spdx").display()
    ));

    Ok(())
}

/**
 * Ensure the build directory is initialized for `BoM` generation.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for active profile.
 * - `build_dir`: Build directory.
 *
 * # Returns
 * `Ok(())` on success.
 *
 * # Errors
 * Returns `KazeError` if it fails to configure `BoM` generation or fails to build.
 */
fn ensure_bom_conf(runner: Runner, rc: &ResolvedConfig, build_dir: &Path) -> Result<(), KazeError> {
    if build_dir.join(".cmake/api/v1/query/codemodel-v2").is_file() {
        return Ok(());
    }

    let cmd = Cmd::new("west")
        .arg("spdx")
        .arg("--spdx-version")
        .arg(rc.bom_v.clone())
        .arg("--init")
        .arg("-d")
        .arg(os(build_dir));
    runner.run_with_spinner(
        &cmd,
        &format!("Configuring BoM generation in {}", build_dir.display()),
    )?;

    Ok(())
}

/**
 * Runs menuconfig (`ninja menuconfig`).
 *
 * # Arguments
 * - `runner`: Command runner used to configure project if not configured.
 * - `rc`: Resolved configuration for the active profile.
 * - `list`: Prompt to list images.
 * - `img`: Selected sysbuild image.
 *
 * # Returns
 * `Ok(())` after menuconfig successfully completed.
 *
 * # Errors
 * Returns `KazeError` if the config step fails or menuconfig command fails.
 */
fn cmd_menuconfig(
    runner: Runner,
    rc: &ResolvedConfig,
    list: bool,
    img: Option<String>,
) -> Result<(), KazeError> {
    ensure_configured(runner, rc)?;

    if rc.sysbuild && list {
        return cmd_list(rc);
    } else if !rc.sysbuild && list {
        log::error("Option --list is only supported for sysbuild projects");
        return Err(KazeError::Msg(
            "Non-sysbuild project. No images to list".into(),
        ));
    }

    let mut build_dir = rc.build_dir.clone();
    if rc.sysbuild {
        let image = resolve_sysbuild_image(rc, img).inspect_err(|_| {
            log::error("Failed to resolve image");
        })?;
        log::info(format!("Running menuconfig for image: {image}"));
        build_dir = build_dir.join(image);
    }

    log::info("Running menuconfig...");
    let cmd = Cmd::new("ninja")
        .arg("-C")
        .arg(os(build_dir.as_path()))
        .arg("menuconfig");

    let menuconf_runner = Runner {
        verbose: 3,
        dry_run: runner.dry_run,
    };
    menuconf_runner.run(&cmd)?;

    Ok(())
}

/**
 * Builds the configure phase command without executing it.
 *
 * # Arguments
 * - `rc`: Resolved configuration for the active profile.
 * - `extra`: Extra arguments to append to the `cmake` invocation.
 *
 * # Returns
 * The configured `Cmd` for the configure phase.
 *
 * # Errors
 * Returns `KazeError` if sysbuild requires `ZEPHYR_BASE` but it is missing.
 */
fn build_conf_cmd(rc: &ResolvedConfig, extra: &[String]) -> Result<Cmd, KazeError> {
    let source_dir = if rc.sysbuild {
        let zb = rc
            .zephyr_base
            .as_ref()
            .ok_or_else(|| KazeError::Msg("Sysbuild requires ZEPHYR_BASE to be set".into()))?;
        zb.join("share").join("sysbuild")
    } else {
        rc.ctx.project_dir.clone()
    };

    // cmake -B <build_dir> -S <source_dir> -G Ninja -DBOARD=<board>
    let mut cmd = Cmd::new("cmake")
        .arg("-B")
        .arg(os(rc.build_dir.as_path()))
        .arg("-S")
        .arg(os(source_dir.as_path()))
        .arg("-G")
        .arg("Ninja")
        .arg(format!("-DBOARD={}", rc.board));

    if let Some(zb) = rc.zephyr_base.as_ref() {
        cmd = cmd.arg(format!("-DZEPHYR_BASE={}", zb.display()));
    }

    if rc.sysbuild {
        cmd = cmd.arg(format!("-DAPP_DIR={}", rc.ctx.project_dir.display()));
    }

    if rc.bom_build {
        cmd = cmd.arg("-DCONFIG_BUILD_OUTPUT_META=y");
    }

    if let Some(rn) = rc.runner.as_ref() {
        cmd = cmd.arg(format!("-DBOARD_FLASH_RUNNER={rn}"));
    }

    push_filtered_args(&mut cmd, extra);
    Ok(cmd)
}

/**
 * Ensures a build directory is configured, running configure if needed (not configured or
 * cofigured with a different board).
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 *
 * # Returns
 * `Ok(())` when the build directory is configured.
 *
 * # Errors
 * Returns `KazeError` if configuration fails.
 */
fn ensure_configured(runner: Runner, rc: &ResolvedConfig) -> Result<(), KazeError> {
    if is_configured(&rc.build_dir) {
        let mut need_rebuild = false;

        if let Some(cached) = get_cache_var(&rc.build_dir, "BOARD")? {
            if cached != rc.board {
                log::info("Board changed, cleaning build directory");
                need_rebuild = true;
            }
        }
        if rc.bom_build {
            let mut build_dir = rc.build_dir.clone();

            if rc.sysbuild {
                if let Some(image) = find_images(rc)?.first().cloned() {
                    build_dir = build_dir.join(image);
                } else {
                    return Err(KazeError::Msg(
                        "Failed to resolve default sysbuild image".to_owned(),
                    ));
                }
            }

            if !build_dir.join(".cmake/api/v1/query/codemodel-v2").is_file() {
                log::info("BoM gerneration config changed, cleaning build directory");
                need_rebuild = true;
            }
        }

        if need_rebuild {
            clean_dir_safe(&rc.build_dir, &rc.ctx.project_dir, runner.dry_run)?;
            cmd_conf(runner, rc, &rc.args_conf)?;
        }

        return Ok(());
    }

    cmd_conf(runner, rc, &rc.args_conf)?;
    Ok(())
}

/**
 * Runs the build phase (`ninja`) for the resolved configuration.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `extra`: Extra arguments to append to the `ninja` invocation.
 *
 * # Returns
 * `Ok(())` after a successful build.
 *
 * # Errors
 * Returns `KazeError` if configuration or the build command fails.
 */
fn cmd_build(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
    ensure_configured(runner, rc)?;

    // ninja -C <build_dir>
    let mut cmd = Cmd::new("ninja").arg("-C").arg(os(rc.build_dir.as_path()));

    // --- <extra>
    push_filtered_args(&mut cmd, extra);

    // run cmd
    run_with_spinner_message(runner, &cmd, "Building project")?;
    log::info("Build completed");

    Ok(())
}

/**
 * Ensures the project is built, invoking the build phase as needed.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 *
 * # Returns
 * `Ok(())` after the build completes.
 *
 * # Errors
 * Returns `KazeError` if the build command fails.
 */
fn ensure_built(runner: Runner, rc: &ResolvedConfig) -> Result<(), KazeError> {
    cmd_build(runner, rc, &rc.args_build)?;
    Ok(())
}

/**
 * Runs the flash phase (`west flash`) for the resolved configuration.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `extra`: Extra arguments to append to the `west flash` invocation.
 * - `list`: Prompt to list images.
 * - `img`: Selected sysbuild image.
 *
 * # Returns
 * `Ok(())` after flashing completes.
 *
 * # Errors
 * Returns `KazeError` if the build step fails or the flash command fails.
 */
fn cmd_flash(
    runner: Runner,
    rc: &ResolvedConfig,
    list: bool,
    img: Option<String>,
    extra: &[String],
) -> Result<(), KazeError> {
    ensure_built(runner, rc)?;

    if rc.sysbuild && list {
        return cmd_list(rc);
    } else if !rc.sysbuild && list {
        log::error("Option --list is only supported for sysbuild projects");
        return Err(KazeError::Msg(
            "Non-sysbuild project. No images to list".into(),
        ));
    }
    if !rc.sysbuild && img.is_some() {
        log::error("Option --image is only supported for sysbuild projects");
        return Err(KazeError::Msg(
            "Non-sysbuild project. No images to select".into(),
        ));
    }

    let mut build_dir = rc.build_dir.clone();
    if rc.sysbuild {
        let image = resolve_sysbuild_image(rc, img).inspect_err(|_| {
            log::error("Failed to resolve image");
        })?;
        log::info(format!("Flashing sysbuild image: {image}"));
        build_dir = build_dir.join(image);
    }

    // west flash -d <build_dir>
    let mut cmd = Cmd::new("west")
        .arg("flash")
        .arg("-d")
        .arg(os(build_dir.as_path()));

    // [--runner <runner>]
    if let Some(rn) = rc.runner.as_ref() {
        cmd = cmd.arg("--runner").arg(rn);
    }

    push_filtered_args(&mut cmd, extra);

    run_with_spinner_message(runner, &cmd, "Flashing project")?;
    log::info("Flash completed");

    Ok(())
}

/**
 * Runs the configured target, rebuilding unless `norebuild` is set.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `norebuild`: When true, skip the pre-run build step.
 * - `list`: Prompt to list images.
 * - `img`: Selected sysbuild image.
 * - `extra`: Extra arguments to pass to the simulator binary.
 *
 * # Returns
 * `Ok(())` after the run command completes.
 *
 * # Errors
 * Returns `KazeError` if the build step fails or the run command fails.
 */
fn cmd_run(
    runner: Runner,
    rc: &ResolvedConfig,
    norebuild: bool,
    list: bool,
    img: Option<String>,
    extra: &[String],
) -> Result<(), KazeError> {
    if rc.sysbuild && list {
        return cmd_list(rc);
    } else if !rc.sysbuild && list {
        log::error("Option --list is only supported for sysbuild projects");
        return Err(KazeError::Msg(
            "Non-sysbuild project. No images to list.".into(),
        ));
    }
    if !rc.sysbuild && img.is_some() {
        log::error("Option --image is only supported for sysbuild projects");
        return Err(KazeError::Msg(
            "Non-sysbuild project. No images to select.".into(),
        ));
    }

    let mut build_dir = rc.build_dir.clone();
    if rc.sysbuild {
        let image = resolve_sysbuild_image(rc, img).inspect_err(|_| {
            log::error("Failed to resolve image");
        })?;
        log::info(format!("Running sysbuild image: {image}"));
        build_dir = build_dir.join(image);
    }

    // try running `zephyr/zephyr.exe` or `zephyr/zephyr` if found
    let candidates = [
        PathBuf::from("zephyr/zephyr.exe"),
        PathBuf::from("zephyr/zephyr"),
    ];

    let run_runner = Runner {
        verbose: 3,
        dry_run: runner.dry_run,
    };

    for rel in candidates {
        let full = build_dir.join(&rel);
        if full.is_file() {
            if !norebuild {
                ensure_built(runner, rc)?;
            }
            let mut cmd = Cmd::new(os(&rel)).cwd(build_dir.as_path());
            push_filtered_args(&mut cmd, extra);
            run_with_spinner_message(run_runner, &cmd, "Running project")?;
            log::info("Run completed");
            return Ok(());
        }
    }

    // otherwise fallback to ninja run
    if !norebuild {
        ensure_built(runner, rc)?;
    }

    let mut cmd = Cmd::new("ninja")
        .arg("-C")
        .arg(os(build_dir.as_path()))
        .arg("run");
    push_filtered_args(&mut cmd, extra);

    run_with_spinner_message(run_runner, &cmd, "Running project")?;
    log::info("Run completed");

    Ok(())
}

/**
 * Run/Flash subcommand to list available sysbuild images.
 *
 * # Arguments
 * - `rc`: Resolved configuration for the active profile.
 *
 * # Returns
 * `Ok(())` after subcommand completion.
 *
 * # Errors
 * Returns `KazeError` on image resolution fail.
 */
fn cmd_list(rc: &ResolvedConfig) -> Result<(), KazeError> {
    let images = find_images(rc)?;

    log::info("Listing sysbuild images");
    println!("Available sysbuild images:\n");
    for (i, name) in images.iter().enumerate() {
        println!("{}: {name}", i.saturating_add(1));
    }
    println!();

    Ok(())
}

/**
 * Finds available images in a sysbuild project.
 *
 * # Arguments
 * - `build_dir`: Build directory.
 *
 * # Returns
 * A list of available images if it succeeds.
 *
 * # Errors
 * Returns `KazeError` if it fails to find images or the project is not a sysbuild project.
 */
fn find_images(rc: &ResolvedConfig) -> Result<Vec<String>, KazeError> {
    let mut images = Vec::new();

    if is_configured(&rc.build_dir) {
        let non_image_dirs: HashSet<&'static str> = [
            ".cmake",
            "CMakeFiles",
            "Kconfig",
            "modules",
            "zephyr",
            "_sysbuild",
        ]
        .into_iter()
        .collect();

        for entry in fs::read_dir(&rc.build_dir)? {
            let entry = entry?;
            let path = entry.path();

            if !path.is_dir() {
                continue;
            }

            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
                continue;
            };

            if non_image_dirs.contains(name) {
                continue;
            }

            images.push(name.to_owned());
        }

        images.sort();
    } else {
        let Some(project_name) = rc.ctx.project_dir.file_name().and_then(|n| n.to_str()) else {
            return Err(KazeError::Msg(
                "Coulc not resolve project image name".to_owned(),
            ));
        };

        images.push(project_name.to_owned());

        for entry in fs::read_dir(rc.ctx.project_dir.join("sysbuild"))? {
            let entry = entry?;
            let path = entry.path();

            let Some(name) = path.file_stem().and_then(|n| n.to_str()) else {
                continue;
            };

            images.push(name.to_owned());
        }
    }

    Ok(images)
}

/**
 * Resolves the sysbuild image name based on the selection.
 *
 * # Arguments
 * - `build_dir`: Sysbuild output root containing per-image directories.
 * - `img`: Optional image name or 1-based index string.
 *
 * # Returns
 * The resolved image name.
 *
 * # Errors
 * Returns `KazeError` when the selection is invalid or images are missing.
 */
fn resolve_sysbuild_image(rc: &ResolvedConfig, img: Option<String>) -> Result<String, KazeError> {
    let images = find_images(rc)?;

    if images.is_empty() {
        return Err(KazeError::Msg("No sysbuild images found".into()));
    }

    if let Some(img) = img {
        if let Ok(index) = img.parse::<usize>() {
            if index == 0 || index > images.len() {
                return Err(KazeError::Msg(format!(
                    "Invalid sysbuild image index: {index}"
                )));
            }
            if let Some(selected) = images.get(index.saturating_sub(1)) {
                return Ok(selected.clone());
            }
            return Err(KazeError::Msg(format!(
                "Invalid sysbuild image index: {index}"
            )));
        }

        if images.iter().any(|name| name == &img) {
            return Ok(img);
        }

        return Err(KazeError::Msg(format!("Unknown sysbuild image: {img}")));
    }

    if let Some(image) = images.iter().find(|name| name.as_str() != "mcuboot") {
        return Ok(image.clone());
    }

    Ok(images.first().expect("images not empty").clone())
}

/**
 * Prints the flash runners detected in the build output's `runners.yaml`.
 *
 * # Arguments
 * - `rc`: Resolved configuration for the active profile.
 *
 * # Errors
 * Returns a `KazeError` when the runners file is missing or cannot be parsed.
 */
fn cmd_runners(rc: &ResolvedConfig) -> Result<(), KazeError> {
    log::info("Listing available runners");
    let yaml_path = &rc.build_dir.join("zephyr/runners.yaml");
    if !yaml_path.is_file() {
        log::info(
            "Runner config not found in build dir. Try building the project first with `kaze \
             build`.",
        );
        return Err(KazeError::Msg(
            "Runner config not found in build dir. Try buidling the project first with `kaze \
             build`."
                .into(),
        ));
    }

    let runners_yml = read_runners_yaml(yaml_path.as_path())?;
    let runners = yaml_str_list(&runners_yml, "runners");
    let flash_runner = yaml_str(&runners_yml, "flash-runner");

    let board = rc.board.clone();
    let output = format_runners_output(&board, &runners, flash_runner);
    print!("{output}");

    Ok(())
}

/**
 * Loads the YAML document containing runner metadata.
 *
 * # Arguments
 * - `path`: Path to the YAML file in the build output.
 *
 * # Returns
 * Parsed YAML document as a `serde_yaml::Value`.
 *
 * # Errors
 * Returns a `KazeError` when the file cannot be opened or parsed.
 */
fn read_runners_yaml(path: &Path) -> Result<Value, KazeError> {
    let f = File::open(path)?;
    Ok(from_reader(f)?)
}

/**
 * Reads a string field from a YAML mapping.
 *
 * # Arguments
 * - `v`: YAML document to read from.
 * - `key`: Mapping key to extract.
 *
 * # Returns
 * The mapped string if present and of the correct type.
 */
fn yaml_str(v: &Value, key: &str) -> Option<String> {
    v.get(key)?.as_str().map(std::convert::Into::into)
}

/**
 * Reads a list of strings from a YAML mapping.
 *
 * # Arguments
 * - `v`: YAML document to read from.
 * - `key`: Mapping key to extract.
 *
 * # Returns
 * A list of strings (empty if the key is missing or the type is unexpected).
 */
fn yaml_str_list(v: &Value, key: &str) -> Vec<String> {
    v.get(key)
        .and_then(|x| x.as_sequence())
        .into_iter()
        .flatten()
        .filter_map(|x| x.as_str().map(std::convert::Into::into))
        .collect()
}

/// Formats the runners output for display.
fn format_runners_output(board: &str, runners: &[String], flash_runner: Option<String>) -> String {
    let mut out = String::new();
    if writeln!(out, "Runners available for `{board}`:\n").is_err() {
        return out;
    }
    if runners.is_empty() {
        if writeln!(out, "None.\n").is_err() {
            return out;
        }
    } else {
        for (i, rn) in runners.iter().enumerate() {
            if writeln!(out, "{i:>2}: {rn}").is_err() {
                return out;
            }
        }
        out.push('\n');
    }
    if let Some(rn) = flash_runner {
        if writeln!(out, "Configured flash runner: {rn}").is_err() {
            return out;
        }
    }
    out
}

/**
 * Lists Zephyr boards via `west boards`.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 *
 * # Returns
 * `Ok(())` after the command succeeds.
 *
 * # Errors
 * Returns `KazeError` if the `west` command fails.
 */
fn cmd_west_boards(runner: Runner) -> Result<(), KazeError> {
    log::info("Listing available boards");
    let cmd = Cmd::new("west").arg("boards");
    runner.run(&cmd)?;
    Ok(())
}

/**
 * Zephyr workspace management command.
 *
 * Runs a workspace management subcommand.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration.
 * - `cmd`: Workspace subcommand to run.
 *
 * # Returns
 * `Ok(())` after ws subcommand completes.
 *
 * # Errors
 * Returns `KazeError` if the underlying subcommand fails or zephyr workspace is misconfigured.
 */
fn cmd_workspace(runner: Runner, rc: &ResolvedConfig, cmd: &WsCommand) -> Result<(), KazeError> {
    log::info("Managing zephyr workspace");
    if rc.zephyr_url.is_some() && rc.zephyr_manifest.is_some() {
        return Err(KazeError::Msg(
            "Conflicting west workspace config: both manifest repo url and local manifest file \
             path are set."
                .to_owned(),
        ));
    }

    if let Some(ws) = rc.zephyr_ws.as_deref() {
        match *cmd {
            WsCommand::Init(ref i) => return cmd_ws_init(runner, ws, rc, i.force),
            WsCommand::Update => return cmd_ws_update(runner, ws),
            WsCommand::Export => return cmd_ws_export(runner, ws),
            WsCommand::Apply(ref a) => {
                return cmd_ws_apply(runner, ws, rc, a.noupdate, a.force);
            }
        }
    }

    Err(KazeError::Msg(
        "Zephyr workspace could not be resolved.".to_owned(),
    ))
}

/**
 * Returns true when zephyr workspace configured.
 *
 * # Arguments
 * - `ws`: Workspace path.
 *
 * # Returns
 * `true` if workspace configured, `false` otherwise.
 */
fn is_ws_initialized(ws: &Path) -> bool {
    if ws.join(".west").is_dir() {
        return true;
    }
    false
}

/**
 * Initializes zephyr workspace based on provided configuration.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `ws`: Workspace path.
 * - `rc`: Resolved configuration.
 * - `force`: Force re-initialize
 *
 * # Returns
 * `Ok(())` on success.
 *
 * # Errors
 * Returns `KazeError` on ws init failure.
 */
fn cmd_ws_init(
    runner: Runner,
    ws: &Path,
    rc: &ResolvedConfig,
    force: bool,
) -> Result<(), KazeError> {
    if is_ws_initialized(ws) {
        if !force {
            return Err(KazeError::Msg(
                "Zephyr workspace already initialized.".to_owned(),
            ));
        }

        clean_dir_safe(&ws.join(".west"), ws, runner.dry_run)?;
    }

    let mut cmd = Cmd::new("west").arg("init");
    if let Some(url) = rc.zephyr_url.as_deref() {
        cmd = cmd.arg("-m").arg(url).arg(ws);
    } else if let Some(manifest) = rc.zephyr_manifest.as_deref() {
        if let Some(path) = manifest.parent()
            && let Some(file) = manifest.file_name()
        {
            cmd = cmd.arg("-l").arg("--mf").arg(file).arg(path);
        } else {
            return Err(KazeError::Msg("Invalid manifest path".to_owned()));
        }
    } else {
        return Err(KazeError::Msg(
            "No manifest repo url or manifest file path specified".to_owned(),
        ));
    }

    runner.run_with_spinner(&cmd, "Initializing zephyr workspace")?;

    Ok(())
}

/**
 * Updates zephyr workspace
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `ws`: Workspace path.
 *
 * # Returns
 * `Ok(())` on success.
 *
 * # Errors
 * Returns `KazeError` on ws update failure.
 */
fn cmd_ws_update(runner: Runner, ws: &Path) -> Result<(), KazeError> {
    let cmd = Cmd::new("west").arg("update").cwd(ws);
    runner.run_with_spinner(&cmd, "Updating zephyr workspace")?;
    Ok(())
}

/**
 * Exports zephyr workspace cmake package.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `ws`: Workspace path.
 *
 * # Returns
 * `Ok(())` on success.
 *
 * # Errors
 * Returns `KazeError` on export failure.
 */
fn cmd_ws_export(runner: Runner, ws: &Path) -> Result<(), KazeError> {
    let cmd = Cmd::new("west").arg("zephyr-export").cwd(ws);
    runner.run_with_spinner(&cmd, "Exporting zephyr workspace")?;
    Ok(())
}

/**
 * Applies zephyr workspace by running ws init, update and export.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `ws`: Workspace path.
 * - `rc`: Resolved configuration.
 * - `noupdate`: Skips ws update.
 * - `force`: Force re-initialize
 *
 * # Returns
 * `Ok(())` on success.
 *
 * # Errors
 * Returns `KazeError` on init, update or export failure.
 */
fn cmd_ws_apply(
    runner: Runner,
    ws: &Path,
    rc: &ResolvedConfig,
    noupdate: bool,
    force: bool,
) -> Result<(), KazeError> {
    log::info("Applying zephyr workspace");
    if !is_ws_initialized(ws) {
        cmd_ws_init(runner, ws, rc, force)?;
    }

    if !noupdate {
        cmd_ws_update(runner, ws)?;
    }

    cmd_ws_export(runner, ws)?;
    Ok(())
}

/**
 * Returns true when a `CMake` cache is present in the build directory.
 *
 * # Arguments
 * - `build_dir`: Build directory to probe.
 *
 * # Returns
 * `true` when `CMakeCache.txt` exists, `false` otherwise.
 */
fn is_configured(build_dir: &Path) -> bool {
    build_dir.join("CMakeCache.txt").is_file() && build_dir.join("build.ninja").is_file()
}

/**
 * Reads a `CMake` cache entry from the build directory.
 *
 * # Arguments
 * - `build_dir`: Build directory containing `CMakeCache.txt`.
 * - `key`: Cache variable name to lookup.
 *
 * # Returns
 * `Ok(Some(value))` when the entry exists, `Ok(None)` when missing.
 *
 * # Errors
 * Returns `KazeError` if the cache file cannot be read.
 */
fn get_cache_var(build_dir: &Path, key: &str) -> Result<Option<String>, KazeError> {
    let path = build_dir.join("CMakeCache.txt");
    if !path.is_file() {
        return Ok(None);
    }

    let f = File::open(path)?;
    let r = BufReader::new(f);

    for line in r.lines() {
        let line = line?;
        let s = line.trim();

        if s.is_empty() || s.starts_with('#') || s.starts_with("//") {
            continue;
        }

        let Some((lhs, rhs)) = s.split_once('=') else {
            continue;
        };
        let Some((var, _ty)) = lhs.split_once(':') else {
            continue;
        };

        if var == key {
            return Ok(Some(rhs.into()));
        }
    }

    Ok(None)
}

/**
 * Removes a directory if it is inside the project root and not the root itself.
 *
 * # Arguments
 * - `dir`: Directory to remove.
 * - `project_dir`: Project root used as the safety boundary.
 * - `dry_run`: When true, only log the removal.
 *
 * # Returns
 * `Ok(())` once deletion is complete or skipped.
 *
 * # Errors
 * Returns `KazeError` when the delete target is outside the project root, is
 * the project root itself, or the filesystem operation fails.
 */
fn clean_dir_safe(dir: &Path, project_dir: &Path, dry_run: bool) -> Result<(), KazeError> {
    if !dir.exists() && !dry_run {
        return Ok(());
    }

    let proj = canonical_or(project_dir);
    let target = canonical_or(dir);

    if !target.starts_with(&proj) {
        return Err(KazeError::Msg(format!(
            "refusing to delete outside project dir: {}",
            target.display()
        )));
    }

    if proj == target {
        return Err(KazeError::Msg(format!(
            "refusing to delete project_root: {}",
            target.display()
        )));
    }

    if dry_run {
        log::info(format!("Cleaning {}", target.display()));
    } else {
        fs::remove_dir_all(&target)?;
        log::info(format!("removed {}", target.display()));
    }

    Ok(())
}

/**
 * Returns the canonical path or falls back to the input on error.
 *
 * # Arguments
 * - `p`: Path to canonicalize.
 *
 * # Returns
 * Canonicalized path when available, otherwise the original path.
 */
fn canonical_or(p: &Path) -> PathBuf {
    p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
}

/**
 * Converts a `Path` to an owned `OsString`.
 *
 * # Arguments
 * - `p`: Path to convert.
 *
 * # Returns
 * Owned `OsString` representing the provided path.
 */
fn os(p: &Path) -> OsString {
    p.as_os_str().to_os_string()
}

#[cfg(test)]
mod tests {
    use std::sync::atomic::{AtomicUsize, Ordering};

    use super::*;
    use crate::config::schema::{BuildCfg, ProfileCfg, ProjectCfg};
    use crate::core::cli::{Cli, Command, PhaseArgs, RunArgs, WsApplyArgs, WsInitArgs};
    use crate::core::config::ConfigCtx;

    fn make_temp_dir(label: &str) -> PathBuf {
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let mut dir = std::env::temp_dir();
        dir.push(format!(
            "nishikaze-cmd-test-{}-{}",
            label,
            COUNTER.fetch_add(1, Ordering::Relaxed)
        ));
        if dir.exists() {
            fs::remove_dir_all(&dir).expect("clean temp dir");
        }
        fs::create_dir_all(&dir).expect("create temp dir");
        dir
    }

    fn make_ctx(project_dir: &Path) -> ConfigCtx {
        ConfigCtx {
            project_dir: project_dir.to_path_buf(),
            cwd: project_dir.to_path_buf(),
            invoked_from_build: false,
        }
    }

    fn make_rc(project_dir: &Path, build_dir: PathBuf) -> ResolvedConfig {
        let build_root = project_dir.join("build");
        ResolvedConfig {
            ctx: make_ctx(project_dir),
            profile: None,
            board: "native_sim".into(),
            runner: None,
            build_root,
            build_dir,
            sysbuild: false,
            zephyr_ws: None,
            zephyr_base: None,
            zephyr_url: None,
            zephyr_manifest: None,
            bom_build: false,
            bom_v: "2".into(),
            args_conf: Vec::new(),
            args_build: Vec::new(),
            args_flash: Vec::new(),
            args_run: Vec::new(),
        }
    }

    fn make_file_cfg() -> FileConfig {
        FileConfig {
            project: ProjectCfg {
                default_profile: Some("dev".into()),
                ..ProjectCfg::default()
            },
            build: BuildCfg {
                root: "build".into(),
                link_compile_commands: Some(true),
            },
            ..FileConfig::default()
        }
    }

    fn write_kaze_toml(project_dir: &Path, body: &str) {
        let path = project_dir.join("kaze.toml");
        fs::write(&path, body).expect("write kaze.toml");
    }

    fn write_configured(build_dir: &Path, board: &str) {
        fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
        fs::write(
            build_dir.join("CMakeCache.txt"),
            format!("BOARD:STRING={board}\n"),
        )
        .expect("write cache");
    }

    #[test]
    fn cmd_init_creates_and_rejects_existing_config() {
        let dir = make_temp_dir("cmd-init");
        let mut rc = make_rc(&dir, dir.join("build"));
        rc.board = "native_sim".into();
        cmd_init(&rc, false).expect("init ok");
        assert!(dir.join("kaze.toml").is_file());

        let err = cmd_init(&rc, false).expect_err("should fail when exists");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("already exists")));
    }

    #[test]
    fn cmd_profiles_handles_empty_and_non_empty() {
        let empty = FileConfig::default();
        cmd_profiles(&empty);

        let mut cfg = FileConfig::default();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());
        cmd_profiles(&cfg);
    }

    #[test]
    fn cmd_workspace_errors_on_conflicting_manifest_config() {
        let dir = make_temp_dir("ws-conflict");
        let build_dir = dir.join("build");
        let mut rc = make_rc(&dir, build_dir);
        rc.zephyr_ws = Some(dir.join("ws"));
        rc.zephyr_url = Some("https://example.com/zephyr".into());
        rc.zephyr_manifest = Some(PathBuf::from("west.yml"));

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_workspace(runner, &rc, &WsCommand::Update).expect_err("should fail");
        assert!(
            matches!(err, KazeError::Msg(ref msg) if msg.contains("Conflicting west workspace config"))
        );
    }

    #[test]
    fn cmd_workspace_errors_when_workspace_missing() {
        let dir = make_temp_dir("ws-missing");
        let build_dir = dir.join("build");
        let rc = make_rc(&dir, build_dir);

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_workspace(runner, &rc, &WsCommand::Update).expect_err("should fail");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("could not be resolved")));
    }

    #[test]
    fn cmd_workspace_update_succeeds_with_dry_run() {
        let dir = make_temp_dir("ws-update");
        let ws = dir.join("ws");
        fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
        let build_dir = dir.join("build");
        let mut rc = make_rc(&dir, build_dir);
        rc.zephyr_ws = Some(ws);

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_workspace(runner, &rc, &WsCommand::Update).expect("update ok");
    }

    #[test]
    fn ws_initialized_detects_west_dir() {
        let dir = make_temp_dir("ws-init-check");
        let ws = dir.join("ws");
        fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
        assert!(is_ws_initialized(&ws));

        let ws_missing = dir.join("ws-missing");
        fs::create_dir_all(&ws_missing).expect("mkdir ws");
        assert!(!is_ws_initialized(&ws_missing));
    }

    #[test]
    fn cmd_ws_init_errors_when_initialized_without_force() {
        let dir = make_temp_dir("ws-init-no-force");
        let ws = dir.join("ws");
        let ws_init = ws.join(".west");
        fs::create_dir_all(&ws_init).expect("mkdir .west");
        let mut rc = make_rc(&dir, dir.join("build"));
        rc.zephyr_ws = Some(ws.clone());

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_ws_init(runner, &ws, &rc, false).expect_err("should fail");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("already initialized")));
    }

    #[test]
    fn cmd_ws_init_force_removes_west_dir() {
        let dir = make_temp_dir("ws-init-force");
        let ws = dir.join("ws");
        let ws_init = ws.join(".west");
        fs::create_dir_all(&ws_init).expect("mkdir .west");
        let mut rc = make_rc(&dir, dir.join("build"));
        rc.zephyr_ws = Some(ws.clone());
        rc.zephyr_url = Some("https://example.com/zephyr".into());

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_ws_init(runner, &ws, &rc, true).expect("init ok");
        assert!(ws_init.exists());
    }

    #[test]
    fn cmd_ws_export_and_apply_succeed_with_dry_run() {
        let dir = make_temp_dir("ws-export-apply");
        let ws = dir.join("ws");
        fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
        let mut rc = make_rc(&dir, dir.join("build"));
        rc.zephyr_ws = Some(ws.clone());

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_ws_export(runner, &ws).expect("export ok");
        cmd_ws_apply(runner, &ws, &rc, false, false).expect("apply ok");
    }

    #[test]
    fn cmd_workspace_branches_init_export_apply() {
        let dir = make_temp_dir("ws-branches");
        let ws = dir.join("ws");
        fs::create_dir_all(ws.join(".west")).expect("mkdir .west");
        let mut rc = make_rc(&dir, dir.join("build"));
        rc.zephyr_ws = Some(ws);
        rc.zephyr_url = Some("https://example.com/zephyr".into());

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };

        cmd_workspace(runner, &rc, &WsCommand::Init(WsInitArgs { force: true }))
            .expect("workspace init ok");
        cmd_workspace(runner, &rc, &WsCommand::Export).expect("workspace export ok");
        cmd_workspace(
            runner,
            &rc,
            &WsCommand::Apply(WsApplyArgs {
                force: false,
                noupdate: true,
            }),
        )
        .expect("workspace apply ok");
    }

    #[test]
    fn cmd_menuconfig_sysbuild_runs_with_image() {
        let dir = make_temp_dir("menuconfig-sysbuild");
        let build_dir = dir.join("build");
        let image_dir = build_dir.join("app");
        fs::create_dir_all(&image_dir).expect("create image dir");
        write_configured(&build_dir, "native_sim");
        let zephyr_base = dir.join("zephyr");
        fs::create_dir_all(zephyr_base.join("share").join("sysbuild"))
            .expect("create sysbuild dir");
        let mut rc = make_rc(&dir, build_dir);
        rc.sysbuild = true;
        rc.zephyr_base = Some(zephyr_base);

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_menuconfig(runner, &rc, false, Some("app".into())).expect("menuconfig ok");
    }

    #[test]
    fn cmd_bom_sysbuild_runs_with_image() {
        let dir = make_temp_dir("bom-sysbuild");
        let build_dir = dir.join("build");
        let image_dir = build_dir.join("app");
        fs::create_dir_all(&image_dir).expect("create image dir");
        write_configured(&build_dir, "native_sim");
        let zephyr_base = dir.join("zephyr");
        fs::create_dir_all(zephyr_base.join("share").join("sysbuild"))
            .expect("create sysbuild dir");
        let mut rc = make_rc(&dir, build_dir);
        rc.sysbuild = true;
        rc.bom_build = true;
        rc.zephyr_base = Some(zephyr_base);

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_bom(runner, &rc, false, Some("app".into())).expect("bom ok");
    }

    #[test]
    fn cmd_run_prefers_zephyr_binary_when_norebuild() {
        let dir = make_temp_dir("run-zephyr-direct");
        let build_dir = dir.join("build");
        let zephyr_dir = build_dir.join("zephyr");
        fs::create_dir_all(&zephyr_dir).expect("create zephyr dir");
        fs::write(zephyr_dir.join("zephyr"), "").expect("write binary");

        let rc = make_rc(&dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_run(runner, &rc, true, false, None, &Vec::new()).expect("run ok");
    }

    #[test]
    fn merge_args_appends_extra_after_config() {
        let config = vec!["--conf_arg_1".to_owned(), "--conf_arg_2".to_owned()];
        let extra = vec!["--extra_arg".to_owned()];
        let merged = merge_args(&config, &extra);

        assert_eq!(
            merged,
            vec![
                "--conf_arg_1".to_owned(),
                "--conf_arg_2".to_owned(),
                "--extra_arg".to_owned()
            ]
        );
    }

    #[test]
    fn merge_args_skips_empty_and_preserves_order() {
        let config = vec!["a".to_owned(), "   ".to_owned(), "b".to_owned()];
        let extra = vec![String::new(), "c".to_owned(), "\t".to_owned()];
        let merged = merge_args(&config, &extra);
        assert_eq!(merged, vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
    }

    #[test]
    fn push_filtered_args_skips_empty() {
        let mut cmd = Cmd::new("tool");
        let extra = vec![String::new(), "  ".to_owned(), "arg".to_owned()];
        push_filtered_args(&mut cmd, &extra);
        assert_eq!(cmd.args, vec![OsString::from("arg")]);
    }

    #[test]
    fn compile_commands_link_uses_default_profile() {
        let project_dir = make_temp_dir("compile-default");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = make_file_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());
        cfg.profiles.insert("prod".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");

        let link = rc.build_root.join("compile_commands.json");
        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let target = fs::read_link(&link).expect("read link");
        assert_eq!(
            target,
            rc.build_root.join("dev").join("compile_commands.json")
        );
    }

    #[test]
    fn compile_commands_link_falls_back_to_first_profile() {
        let project_dir = make_temp_dir("compile-first");
        let build_dir = project_dir.join("build").join("alpha");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = make_file_cfg();
        cfg.project.default_profile = None;
        cfg.profiles.insert("alpha".into(), ProfileCfg::default());
        cfg.profiles.insert("beta".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("alpha".into());

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");

        let link = rc.build_root.join("compile_commands.json");
        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let target = fs::read_link(&link).expect("read link");
        assert_eq!(
            target,
            rc.build_root.join("alpha").join("compile_commands.json")
        );
    }

    #[test]
    fn compile_commands_link_skips_without_profiles() {
        let project_dir = make_temp_dir("compile-no-profiles");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let cfg = FileConfig::default();
        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");
        let link = rc.build_root.join("compile_commands.json");
        assert!(!link.exists());
    }

    #[test]
    fn compile_commands_link_falls_back_when_default_missing() {
        let project_dir = make_temp_dir("compile-missing-default");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = make_file_cfg();
        cfg.project.default_profile = Some("missing".into());
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");
        let link = rc.build_root.join("compile_commands.json");
        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let target = fs::read_link(&link).expect("read link");
        assert_eq!(
            target,
            rc.build_root.join("dev").join("compile_commands.json")
        );
    }

    #[test]
    fn compile_commands_link_skips_when_disabled() {
        let project_dir = make_temp_dir("compile-disabled");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = make_file_cfg();
        cfg.build.link_compile_commands = Some(false);
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");

        let link = rc.build_root.join("compile_commands.json");
        assert!(!link.exists());
    }

    #[test]
    fn compile_commands_link_defaults_enabled_when_unset() {
        let project_dir = make_temp_dir("compile-unset");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = make_file_cfg();
        cfg.build.link_compile_commands = None;
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");

        let link = rc.build_root.join("compile_commands.json");
        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let target = fs::read_link(&link).expect("read link");
        assert_eq!(
            target,
            rc.build_root.join("dev").join("compile_commands.json")
        );
    }

    #[test]
    fn compile_commands_link_replaces_existing_file() {
        let project_dir = make_temp_dir("compile-replace");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = make_file_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        let link = rc.build_root.join("compile_commands.json");
        fs::create_dir_all(&rc.build_root).expect("create build root");
        fs::write(&link, "stale").expect("write file");

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");

        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let target = fs::read_link(&link).expect("read link");
        assert_eq!(
            target,
            rc.build_root.join("dev").join("compile_commands.json")
        );
    }

    #[test]
    fn compile_commands_link_skips_when_existing_symlink() {
        let project_dir = make_temp_dir("compile-symlink");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = make_file_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        let link = rc.build_root.join("compile_commands.json");
        fs::create_dir_all(&rc.build_root).expect("create build root");
        let target = rc.build_root.join("dev").join("compile_commands.json");
        fs::write(&target, "data").expect("write target");
        #[cfg(unix)]
        std::os::unix::fs::symlink(&target, &link).expect("create symlink");
        #[cfg(windows)]
        std::os::windows::fs::symlink_file(&target, &link).expect("create symlink");

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");
        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let current_target = fs::read_link(&link).expect("read link");
        assert_eq!(current_target, target);
    }

    #[test]
    fn compile_commands_link_skips_when_existing_dir() {
        let project_dir = make_temp_dir("compile-dir");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = make_file_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        let link = rc.build_root.join("compile_commands.json");
        fs::create_dir_all(&link).expect("create dir");

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");
        assert!(link.is_dir());
    }

    #[test]
    fn compile_commands_link_creates_build_root_when_missing() {
        let project_dir = make_temp_dir("compile-missing-root");
        let build_dir = project_dir.join("build").join("dev");

        let mut cfg = make_file_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");

        assert!(rc.build_root.is_dir());
        let link = rc.build_root.join("compile_commands.json");
        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let target = fs::read_link(&link).expect("read link");
        assert_eq!(
            target,
            rc.build_root.join("dev").join("compile_commands.json")
        );
    }

    #[test]
    fn compile_commands_link_uses_sysbuild_image() {
        let project_dir = make_temp_dir("compile-sysbuild");
        let image = project_dir
            .file_name()
            .expect("project dir name")
            .to_string_lossy()
            .into_owned();
        let build_dir = project_dir.join("build").join("dev");
        let image_dir = build_dir.join(&image);
        fs::create_dir_all(&image_dir).expect("create image dir");
        write_configured(&build_dir, "native_sim");

        let mut cfg = make_file_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());
        rc.sysbuild = true;

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");

        let link = rc.build_root.join("compile_commands.json");
        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let target = fs::read_link(&link).expect("read link");
        assert_eq!(
            target,
            rc.build_root
                .join("dev")
                .join(&image)
                .join("compile_commands.json")
        );
    }

    #[test]
    fn compile_commands_link_sysbuild_errors_without_matching_image() {
        let project_dir = make_temp_dir("compile-sysbuild-missing");
        let build_dir = project_dir.join("build").join("dev");
        fs::create_dir_all(build_dir.join("other")).expect("create image dir");
        write_configured(&build_dir, "native_sim");

        let mut cfg = make_file_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let mut rc = make_rc(&project_dir, build_dir);
        rc.profile = Some("dev".into());
        rc.sysbuild = true;

        let err = ensure_compile_commands_link(&rc, &cfg).expect_err("should error");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Unknown sysbuild image")));
    }

    #[test]
    fn compile_commands_link_sysbuild_without_profiles() {
        let project_dir = make_temp_dir("compile-sysbuild-noprofiles");
        let image = project_dir
            .file_name()
            .expect("project dir name")
            .to_string_lossy()
            .into_owned();
        let build_dir = project_dir.join("build");
        let image_dir = build_dir.join(&image);
        fs::create_dir_all(&image_dir).expect("create image dir");
        write_configured(&build_dir, "native_sim");

        let cfg = FileConfig::default();
        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;

        ensure_compile_commands_link(&rc, &cfg).expect("link ok");

        let link = rc.build_root.join("compile_commands.json");
        let meta = fs::symlink_metadata(&link).expect("link meta");
        assert!(meta.file_type().is_symlink());
        let target = fs::read_link(&link).expect("read link");
        assert_eq!(
            target,
            rc.build_root.join(&image).join("compile_commands.json")
        );
    }

    #[test]
    fn resolve_default_profile_handles_default_and_fallback() {
        let mut cfg = make_file_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());
        cfg.profiles.insert("prod".into(), ProfileCfg::default());

        assert_eq!(resolve_default_profile(&cfg), Some("dev".into()));

        cfg.project.default_profile = Some("missing".into());
        assert_eq!(resolve_default_profile(&cfg), Some("dev".into()));
    }

    #[test]
    fn kaze_error_from_conversions() {
        let io_err = std::io::Error::other("io");
        let kaze_err: KazeError = io_err.into();
        assert!(matches!(kaze_err, KazeError::Io(_)));

        let exec_err = ExecError::from(std::io::Error::other("exec"));
        let kaze_exec_err: KazeError = exec_err.into();
        assert!(matches!(kaze_exec_err, KazeError::Exec(_)));

        let load_err = LoadError::Io {
            path: PathBuf::from("kaze.toml"),
            source: std::io::Error::other("load"),
        };
        let kaze_load_err: KazeError = load_err.into();
        assert!(matches!(kaze_load_err, KazeError::Load(_)));

        let resolve_err = ResolveError::NoBoardResolved;
        let kaze_resolve_err: KazeError = resolve_err.into();
        assert!(matches!(kaze_resolve_err, KazeError::Resolve(_)));

        let yaml_err = serde_yaml::from_str::<serde_yaml::Value>("[").expect_err("invalid yaml");
        let kaze_yaml_err: KazeError = yaml_err.into();
        assert!(
            matches!(kaze_yaml_err, KazeError::Msg(ref msg) if msg.contains("failed to load yaml"))
        );
    }

    #[test]
    fn build_conf_cmd_sysbuild_uses_app_dir_and_sysbuild_source() {
        let project_dir = make_temp_dir("conf-sysbuild");
        let build_dir = project_dir.join("build");
        let zephyr_base = project_dir.join("zephyr");
        let sysbuild_dir = zephyr_base.join("share").join("sysbuild");
        fs::create_dir_all(&sysbuild_dir).expect("create sysbuild dir");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        rc.zephyr_base = Some(zephyr_base);

        let cmd = build_conf_cmd(&rc, &[]).expect("build cmd");
        let args: Vec<String> = cmd
            .args
            .iter()
            .map(|a| a.to_string_lossy().to_string())
            .collect();

        assert!(args.contains(&"-S".to_owned()));
        assert!(args.contains(&sysbuild_dir.display().to_string()));
        assert!(args.contains(&format!("-DAPP_DIR={}", project_dir.display())));
    }

    #[test]
    fn build_conf_cmd_non_sysbuild_uses_project_source() {
        let project_dir = make_temp_dir("conf-non-sysbuild");
        let build_dir = project_dir.join("build");

        let rc = make_rc(&project_dir, build_dir);
        let cmd = build_conf_cmd(&rc, &[]).expect("build cmd");
        let args: Vec<String> = cmd
            .args
            .iter()
            .map(|a| a.to_string_lossy().to_string())
            .collect();

        assert!(args.contains(&"-S".to_owned()));
        assert!(args.contains(&project_dir.display().to_string()));
        assert!(!args.iter().any(|a| a.starts_with("-DAPP_DIR=")));
    }

    #[test]
    fn clean_dir_safe_covers_error_and_dry_run_paths() {
        let project_dir = make_temp_dir("clean-root");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");

        clean_dir_safe(&build_dir, &project_dir, true).expect("dry run ok");
        assert!(build_dir.is_dir());

        let outside_dir = make_temp_dir("clean-outside");
        let err_outside =
            clean_dir_safe(&outside_dir, &project_dir, true).expect_err("outside root");
        assert!(
            matches!(err_outside, KazeError::Msg(ref msg) if msg.contains("outside project dir"))
        );

        let err_root = clean_dir_safe(&project_dir, &project_dir, true).expect_err("reject root");
        assert!(matches!(err_root, KazeError::Msg(ref msg) if msg.contains("project_root")));

        let missing_dir = project_dir.join("missing");
        clean_dir_safe(&missing_dir, &project_dir, false).expect("missing ok");
    }

    #[test]
    fn clean_dir_safe_removes_dir() {
        let project_dir = make_temp_dir("clean-remove");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");
        fs::write(build_dir.join("file.txt"), "data").expect("write file");

        clean_dir_safe(&build_dir, &project_dir, false).expect("remove ok");
        assert!(!build_dir.exists());
    }

    #[test]
    fn is_configured_and_get_cache_var_cover_cache_paths() {
        let build_dir = make_temp_dir("cmake-cache");
        assert!(!is_configured(&build_dir));
        assert!(matches!(get_cache_var(&build_dir, "BOARD"), Ok(None)));

        fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
        fs::write(
            build_dir.join("CMakeCache.txt"),
            "# comment\nFOO:STRING=bar\n// skip\nBOARD:STRING=native\n",
        )
        .expect("write cache");

        assert!(is_configured(&build_dir));
        assert_eq!(
            get_cache_var(&build_dir, "BOARD").expect("read cache"),
            Some("native".into())
        );
        assert_eq!(
            get_cache_var(&build_dir, "MISSING").expect("read cache"),
            None
        );
    }

    #[test]
    fn get_cache_var_skips_malformed_lines() {
        let build_dir = make_temp_dir("cmake-malformed");
        fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
        fs::write(
            build_dir.join("CMakeCache.txt"),
            "NOEQ\nNOCOLON=VALUE\nGOOD:STRING=ok\n",
        )
        .expect("write cache");

        assert_eq!(
            get_cache_var(&build_dir, "GOOD").expect("read cache"),
            Some("ok".into())
        );
        assert_eq!(
            get_cache_var(&build_dir, "MISSING").expect("read cache"),
            None
        );
    }

    #[test]
    fn ensure_configured_reconfigures_on_board_mismatch() {
        let project_dir = make_temp_dir("ensure-configured");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");
        fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
        fs::write(build_dir.join("CMakeCache.txt"), "BOARD:STRING=old_board\n")
            .expect("write cache");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.board = "new_board".into();

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        ensure_configured(runner, &rc).expect("ensure configured ok");
    }

    #[test]
    fn cmd_run_prefers_zephyr_binary_when_present() {
        let project_dir = make_temp_dir("cmd-run-bin");
        let build_dir = project_dir.join("build");
        let zephyr_dir = build_dir.join("zephyr");
        fs::create_dir_all(&zephyr_dir).expect("create zephyr dir");
        fs::write(zephyr_dir.join("zephyr"), "").expect("write binary");

        let rc = make_rc(&project_dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_run(runner, &rc, true, false, None, &[]).expect("run ok");
    }

    #[test]
    fn cmd_run_falls_back_to_ninja() {
        let project_dir = make_temp_dir("cmd-run-ninja");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let rc = make_rc(&project_dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_run(runner, &rc, true, false, None, &[]).expect("run ok");
    }

    #[test]
    fn cmd_flash_uses_runner_when_set() {
        let project_dir = make_temp_dir("cmd-flash-runner");
        let build_dir = project_dir.join("build");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.runner = Some("openocd".into());

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_flash(runner, &rc, false, None, &["--foo".into()]).expect("flash ok");
    }

    #[test]
    fn cmd_flash_allows_no_runner() {
        let project_dir = make_temp_dir("cmd-flash-no-runner");
        let build_dir = project_dir.join("build");

        let rc = make_rc(&project_dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_flash(runner, &rc, false, None, &[]).expect("flash ok");
    }

    #[test]
    fn cmd_flash_list_errors_for_non_sysbuild() {
        let project_dir = make_temp_dir("cmd-flash-list");
        let build_dir = project_dir.join("build");

        let rc = make_rc(&project_dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_flash(runner, &rc, true, None, &[]).expect_err("list should error");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
    }

    #[test]
    fn cmd_flash_list_sysbuild_succeeds() {
        let project_dir = make_temp_dir("cmd-flash-list-sysbuild");
        let build_dir = project_dir.join("build");
        let zephyr_base = project_dir.join("zephyr");
        fs::create_dir_all(zephyr_base.join("share").join("sysbuild"))
            .expect("create sysbuild dir");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        fs::create_dir_all(build_dir.join("net")).expect("create net dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        rc.zephyr_base = Some(zephyr_base);

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_flash(runner, &rc, true, None, &[]).expect("list ok");
    }

    #[test]
    fn cmd_bom_list_errors_for_non_sysbuild() {
        let project_dir = make_temp_dir("cmd-bom-list");
        let build_dir = project_dir.join("build");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.bom_build = true;
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_bom(runner, &rc, true, None).expect_err("list should error");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
    }

    #[test]
    fn cmd_bom_image_errors_for_non_sysbuild() {
        let project_dir = make_temp_dir("cmd-bom-image");
        let build_dir = project_dir.join("build");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.bom_build = true;
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_bom(runner, &rc, false, Some("app".into())).expect_err("image error");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
    }

    #[test]
    fn cmd_bom_list_sysbuild_succeeds() {
        let project_dir = make_temp_dir("cmd-bom-list-sysbuild");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        rc.bom_build = true;

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_bom(runner, &rc, true, None).expect("list ok");
    }

    #[test]
    fn cmd_menuconfig_list_errors_for_non_sysbuild() {
        let project_dir = make_temp_dir("cmd-menuconfig-list");
        let build_dir = project_dir.join("build");

        let rc = make_rc(&project_dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_menuconfig(runner, &rc, true, None).expect_err("list should error");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
    }

    #[test]
    fn cmd_menuconfig_list_sysbuild_succeeds() {
        let project_dir = make_temp_dir("cmd-menuconfig-list-sysbuild");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        rc.zephyr_base = Some(project_dir.join("zephyr"));

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_menuconfig(runner, &rc, true, None).expect("list ok");
    }

    #[test]
    fn cmd_menuconfig_sysbuild_accepts_explicit_image() {
        let project_dir = make_temp_dir("cmd-menuconfig-image");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        rc.zephyr_base = Some(project_dir.join("zephyr"));

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_menuconfig(runner, &rc, false, Some("app".into())).expect("menuconfig ok");
    }

    #[test]
    fn cmd_menuconfig_sysbuild_defaults_to_first_image() {
        let project_dir = make_temp_dir("cmd-menuconfig-default");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        fs::create_dir_all(build_dir.join("net")).expect("create net dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        rc.zephyr_base = Some(project_dir.join("zephyr"));

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_menuconfig(runner, &rc, false, None).expect("menuconfig ok");
    }

    #[test]
    fn ensure_bom_conf_runs_when_uninitialized() {
        let project_dir = make_temp_dir("bom-conf-missing");
        let build_dir = project_dir.join("build");

        let rc = make_rc(&project_dir, build_dir.clone());
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        ensure_bom_conf(runner, &rc, &build_dir).expect("ensure bom conf ok");
    }

    #[test]
    fn cmd_run_list_errors_for_non_sysbuild() {
        let project_dir = make_temp_dir("cmd-run-list");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let rc = make_rc(&project_dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_run(runner, &rc, true, true, None, &[]).expect_err("list should error");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
    }

    #[test]
    fn cmd_run_image_errors_for_non_sysbuild() {
        let project_dir = make_temp_dir("cmd-run-image");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let rc = make_rc(&project_dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err =
            cmd_run(runner, &rc, true, false, Some("app".into()), &[]).expect_err("image error");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
    }

    #[test]
    fn cmd_flash_image_errors_for_non_sysbuild() {
        let project_dir = make_temp_dir("cmd-flash-image");
        let build_dir = project_dir.join("build");

        let rc = make_rc(&project_dir, build_dir);
        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_flash(runner, &rc, false, Some("app".into()), &[]).expect_err("image error");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Non-sysbuild project")));
    }

    #[test]
    fn cmd_run_list_sysbuild_succeeds() {
        let project_dir = make_temp_dir("cmd-run-list-sysbuild");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_run(runner, &rc, true, true, None, &[]).expect("list ok");
    }

    #[test]
    fn cmd_run_sysbuild_requires_image() {
        let project_dir = make_temp_dir("cmd-run-sysbuild-no-image");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_run(runner, &rc, true, false, None, &[]).expect("run ok");
    }

    #[test]
    fn cmd_run_sysbuild_uses_image_dir() {
        let project_dir = make_temp_dir("cmd-run-sysbuild-image");
        let build_dir = project_dir.join("build");
        let image_dir = build_dir.join("app");
        let zephyr_dir = image_dir.join("zephyr");
        fs::create_dir_all(&zephyr_dir).expect("create zephyr dir");
        fs::write(zephyr_dir.join("zephyr"), "").expect("write binary");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_run(runner, &rc, true, false, Some("1".into()), &[]).expect("run ok");
    }

    #[test]
    fn cmd_run_sysbuild_errors_when_no_images() {
        let project_dir = make_temp_dir("cmd-run-sysbuild-empty");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err =
            cmd_run(runner, &rc, true, false, Some("app".into()), &[]).expect_err("no images");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("No sysbuild images")));
    }

    #[test]
    fn cmd_flash_sysbuild_uses_image_dir() {
        let project_dir = make_temp_dir("cmd-flash-sysbuild-image");
        let build_dir = project_dir.join("build");
        let image_dir = build_dir.join("app");
        fs::create_dir_all(&image_dir).expect("create image dir");
        fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
        fs::write(
            build_dir.join("CMakeCache.txt"),
            "BOARD:STRING=native_sim\n",
        )
        .expect("write cache");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        cmd_flash(runner, &rc, false, Some("app".into()), &[]).expect("flash ok");
    }

    #[test]
    fn cmd_flash_sysbuild_errors_when_no_images() {
        let project_dir = make_temp_dir("cmd-flash-sysbuild-empty");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");
        fs::write(build_dir.join("build.ninja"), "").expect("write build.ninja");
        fs::write(
            build_dir.join("CMakeCache.txt"),
            "BOARD:STRING=native_sim\n",
        )
        .expect("write cache");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;

        let runner = Runner {
            verbose: 0,
            dry_run: true,
        };
        let err = cmd_flash(runner, &rc, false, None, &[]).expect_err("no images");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("No sysbuild images")));
    }

    #[test]
    fn find_images_filters_non_image_dirs() {
        let project_dir = make_temp_dir("find-images");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        fs::create_dir_all(build_dir.join("net")).expect("create net dir");
        fs::create_dir_all(build_dir.join("zephyr")).expect("create zephyr dir");
        fs::create_dir_all(build_dir.join("_sysbuild")).expect("create sysbuild dir");
        fs::write(build_dir.join("README.txt"), "").expect("write file");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        let images = find_images(&rc).expect("find images");
        assert_eq!(images, vec!["app".to_owned(), "net".to_owned()]);
    }

    #[test]
    fn resolve_sysbuild_image_defaults_to_non_bootloader() {
        let project_dir = make_temp_dir("resolve-default");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("mcuboot")).expect("create mcuboot dir");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        let image = resolve_sysbuild_image(&rc, None).expect("resolve image");
        assert_eq!(image, "app");
    }

    #[test]
    fn resolve_sysbuild_image_accepts_index_and_name() {
        let project_dir = make_temp_dir("resolve-index-name");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        fs::create_dir_all(build_dir.join("net")).expect("create net dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        let image_index = resolve_sysbuild_image(&rc, Some("2".into())).expect("index ok");
        assert_eq!(image_index, "net");

        let image_name = resolve_sysbuild_image(&rc, Some("app".into())).expect("name ok");
        assert_eq!(image_name, "app");
    }

    #[test]
    fn resolve_sysbuild_image_rejects_invalid_selection() {
        let project_dir = make_temp_dir("resolve-invalid");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        let err_index = resolve_sysbuild_image(&rc, Some("3".into())).expect_err("bad index");
        assert!(
            matches!(err_index, KazeError::Msg(ref msg) if msg.contains("Invalid sysbuild image index"))
        );

        let err_name = resolve_sysbuild_image(&rc, Some("nope".into())).expect_err("bad name");
        assert!(
            matches!(err_name, KazeError::Msg(ref msg) if msg.contains("Unknown sysbuild image"))
        );
    }

    #[test]
    fn resolve_sysbuild_image_errors_when_empty() {
        let project_dir = make_temp_dir("resolve-empty");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        let err = resolve_sysbuild_image(&rc, None).expect_err("no images");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("No sysbuild images")));
    }

    #[test]
    fn resolve_sysbuild_image_rejects_zero_index() {
        let project_dir = make_temp_dir("resolve-zero");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("app")).expect("create app dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        let err = resolve_sysbuild_image(&rc, Some("0".into())).expect_err("bad index");
        assert!(
            matches!(err, KazeError::Msg(ref msg) if msg.contains("Invalid sysbuild image index"))
        );
    }

    #[test]
    fn resolve_sysbuild_image_defaults_to_mcuboot_when_only() {
        let project_dir = make_temp_dir("resolve-mcuboot");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(build_dir.join("mcuboot")).expect("create mcuboot dir");
        write_configured(&build_dir, "native_sim");

        let mut rc = make_rc(&project_dir, build_dir);
        rc.sysbuild = true;
        let image = resolve_sysbuild_image(&rc, None).expect("resolve image");
        assert_eq!(image, "mcuboot");
    }

    #[test]
    fn cmd_runners_errors_without_yaml() {
        let project_dir = make_temp_dir("cmd-runners-missing");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let rc = make_rc(&project_dir, build_dir);
        let err = cmd_runners(&rc).expect_err("missing runners yaml");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("Runner config not found")));
    }

    #[test]
    fn cmd_runners_reads_yaml() {
        let project_dir = make_temp_dir("cmd-runners-ok");
        let build_dir = project_dir.join("build");
        let zephyr_dir = build_dir.join("zephyr");
        fs::create_dir_all(&zephyr_dir).expect("create zephyr dir");
        fs::write(
            zephyr_dir.join("runners.yaml"),
            "runners:\n  - openocd\n  - jlink\nflash-runner: openocd\n",
        )
        .expect("write runners yaml");

        let rc = make_rc(&project_dir, build_dir);
        cmd_runners(&rc).expect("runners ok");
    }

    #[test]
    fn cmd_runners_formats_empty_and_flash_runner() {
        let output = format_runners_output("native_sim", &[], Some("openocd".to_owned()));
        assert!(output.contains("None."));
        assert!(output.contains("Configured flash runner: openocd"));
    }

    #[test]
    fn cmd_runners_formats_listed_runners() {
        let output = format_runners_output(
            "native_sim",
            &["openocd".to_owned(), "jlink".to_owned()],
            None,
        );
        assert!(output.contains(" 0: openocd"));
        assert!(output.contains(" 1: jlink"));
    }

    #[test]
    fn run_cmd_dispatches_across_commands() {
        let project_dir = make_temp_dir("run-cmd");
        let build_dir = project_dir.join("build");
        fs::create_dir_all(&build_dir).expect("create build dir");

        let mut cfg = FileConfig::default();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let rc = make_rc(&project_dir, build_dir.clone());

        run_cmd(&rc, 0, true, false, &Command::Profiles, false, &cfg).expect("profiles ok");

        let build_cmd = Command::Build(PhaseArgs::default());
        run_cmd(&rc, 0, true, true, &build_cmd, true, &cfg).expect("build ok");

        let conf_cmd = Command::Conf(PhaseArgs::default());
        run_cmd(&rc, 0, true, false, &conf_cmd, true, &cfg).expect("conf ok");

        let run_cmd_args = Command::Run(RunArgs::default());
        run_cmd(&rc, 0, true, false, &run_cmd_args, true, &cfg).expect("run ok");

        let flash_cmd = Command::Flash(crate::core::cli::FlashArgs::default());
        run_cmd(&rc, 0, true, false, &flash_cmd, true, &cfg).expect("flash ok");

        let clean_cmd = Command::Clean;
        run_cmd(&rc, 0, true, false, &clean_cmd, false, &cfg).expect("clean ok");

        let init_cmd = Command::Init(crate::core::cli::InitArgs::default());
        run_cmd(&rc, 0, true, false, &init_cmd, false, &cfg).expect("init ok");

        let boards_cmd = Command::Boards;
        run_cmd(&rc, 0, true, false, &boards_cmd, false, &cfg).expect("boards ok");

        let runners_dir = build_dir.join("zephyr");
        fs::create_dir_all(&runners_dir).expect("create runners dir");
        fs::write(
            runners_dir.join("runners.yaml"),
            "runners:\n  - openocd\nflash-runner: openocd\n",
        )
        .expect("write runners yaml");
        let runners_cmd = Command::Runners;
        run_cmd(&rc, 0, true, false, &runners_cmd, false, &cfg).expect("runners ok");
    }

    #[test]
    fn run_uses_config_and_profiles() {
        let project_dir = make_temp_dir("run-single");
        write_kaze_toml(
            &project_dir,
            r#"[project]
board = "native_sim"

[build]
root = "build"

[profile.dev]
runner = "openocd"
"#,
        );

        let cli = Cli {
            preclean: false,
            all: false,
            profile: None,
            board: None,
            runner: None,
            project: Some(project_dir),
            verbose: 0,
            dry_run: true,
            command: Command::Profiles,
        };

        run(&cli).expect("run ok");
    }

    #[test]
    fn run_all_profiles_reports_any_failed() {
        let project_dir = make_temp_dir("run-all-fail");
        write_kaze_toml(
            &project_dir,
            r#"[project]
board = "native_sim"

[build]
root = ".."

[profile.dev]
runner = "openocd"

[profile.prod]
runner = "jlink"
"#,
        );

        let build_root = project_dir.join("..");
        let build_dev = build_root.join("dev");
        let build_prod = build_root.join("prod");
        fs::create_dir_all(&build_dev).expect("create build dev");
        fs::create_dir_all(&build_prod).expect("create build prod");

        let cli = Cli {
            preclean: true,
            all: true,
            profile: None,
            board: None,
            runner: None,
            project: Some(project_dir),
            verbose: 0,
            dry_run: true,
            command: Command::Build(PhaseArgs::default()),
        };

        let err = run(&cli).expect_err("run should fail");
        assert!(matches!(err, KazeError::Msg(ref msg) if msg.contains("one or more profiles")));
    }

    #[test]
    fn run_all_profiles_succeeds_with_dry_run() {
        let project_dir = make_temp_dir("run-all-ok");
        write_kaze_toml(
            &project_dir,
            r#"[project]
board = "native_sim"

[build]
root = "build"

[profile.dev]
runner = "openocd"

[profile.prod]
runner = "jlink"
"#,
        );

        let cli = Cli {
            preclean: true,
            all: true,
            profile: None,
            board: None,
            runner: None,
            project: Some(project_dir),
            verbose: 0,
            dry_run: true,
            command: Command::Build(PhaseArgs::default()),
        };

        run(&cli).expect("run ok");
    }
}
