kata 0.12.0

Multi-project template applier with AI-delegated merge
Documentation
//! One file per subcommand. The dispatch table itself lives in
//! `cli.rs` (calling `cmd::<name>::run`).

pub mod add;
pub mod apply;
pub mod doctor;
pub mod init;
pub mod list;
pub mod register;
pub mod remove;
pub mod self_update;
pub mod status;
pub mod unregister;
pub mod update;

use std::collections::BTreeMap;
use std::env;

use camino::{Utf8Path, Utf8PathBuf};

use crate::error::{Error, Result};
use crate::render::parse_cli_var;

/// Resolve `--at <dir>` to an absolute path, defaulting to the
/// current working directory.
pub(crate) fn resolve_pj_root(at: Option<Utf8PathBuf>) -> Result<Utf8PathBuf> {
    let raw = match at {
        Some(p) => p,
        None => Utf8PathBuf::from_path_buf(
            env::current_dir()
                .map_err(|e| Error::io_at(env::current_dir().ok().unwrap_or_default(), e))?,
        )
        .map_err(|p| Error::Config(format!("cwd is not valid UTF-8: {}", p.display())))?,
    };
    if raw.is_absolute() {
        return Ok(raw);
    }
    let cwd = env::current_dir().map_err(|e| Error::io_at(Utf8PathBuf::new().as_std_path(), e))?;
    let abs = Utf8PathBuf::from_path_buf(cwd.join(raw.as_std_path()))
        .map_err(|p| Error::Config(format!("path is not valid UTF-8: {}", p.display())))?;
    Ok(abs)
}

/// Parse `--var name=val` into a typed table. Errors out on the first
/// invalid entry.
pub(crate) fn parse_cli_vars(items: Vec<String>) -> Result<BTreeMap<String, toml::Value>> {
    let mut out = BTreeMap::new();
    for it in items {
        let (k, v) = parse_cli_var(&it)?;
        out.insert(k, v);
    }
    Ok(out)
}

/// Make `<root>/.kata/` if missing (so `applied.toml` writes succeed
/// later). Idempotent.
pub(crate) fn ensure_state_dir(root: &Utf8Path) -> Result<()> {
    let dir = root.join(crate::paths::PJ_STATE_DIR);
    std::fs::create_dir_all(&dir).map_err(|e| Error::io_at(dir.as_std_path(), e))?;
    Ok(())
}

/// Hard-coded floor for `defaults.ai_concurrency`. Mirrors
/// `config::default_ai_concurrency`. Kept here so the cmd layer
/// has a single place to reach for it without depending on
/// internals of `config`.
const DEFAULT_AI_CONCURRENCY: usize = 4;

/// Resolve the AI concurrency cap for one apply run: CLI override
/// wins, otherwise read `defaults.ai_concurrency` from the global
/// config, otherwise fall back to `DEFAULT_AI_CONCURRENCY`. A
/// hard-edited config that returns `Err` (missing / malformed)
/// silently falls through to the default — Phase 1 already
/// surfaces config problems through other paths, no need to fail
/// the apply for an unrelated read error.
pub(crate) fn resolve_ai_concurrency(cli_override: Option<usize>) -> usize {
    cli_override.unwrap_or_else(|| {
        crate::config::GlobalConfig::load()
            .map(|c| c.defaults.ai_concurrency)
            .unwrap_or(DEFAULT_AI_CONCURRENCY)
    })
}

/// Pick the project name kata reports back to the renderer.
/// Prefers the upstream repo basename via
/// `git config --get remote.origin.url`, falling back to the
/// directory's leaf only when the project has no upstream. This
/// keeps `project.name` stable across worktrees: running
/// `kata apply` from `~/wt/<repo>/<branch>/` reports `<repo>`,
/// not `<branch>`.
pub(crate) async fn resolve_project_name(pj_root: &Utf8Path) -> String {
    if let Some(name) = crate::git::repo_name_from_remote(pj_root).await {
        return name;
    }
    pj_root.file_name().unwrap_or("kata-project").to_string()
}

/// Hard-coded floor for `defaults.pj_concurrency`. Used when the
/// global config doesn't specify one and the CLI doesn't override.
const DEFAULT_PJ_CONCURRENCY: usize = 4;

/// Resolve the per-PJ concurrency cap for multi-PJ fan-out
/// (`apply --all` / `update --all`). Same precedence chain as
/// `resolve_ai_concurrency`: CLI override → global config →
/// hard-coded floor.
pub(crate) fn resolve_pj_concurrency(cli_override: Option<usize>) -> usize {
    cli_override.unwrap_or_else(|| {
        crate::config::GlobalConfig::load()
            .ok()
            .and_then(|c| c.defaults.pj_concurrency)
            .unwrap_or(DEFAULT_PJ_CONCURRENCY)
    })
}

/// Pick the registered PJs that should be visited by
/// `apply --all` / `update --all`. With no `tag_filter`, every
/// registered PJ is returned. With tags, a PJ is included iff its
/// `tags` set contains *all* of the requested tags (intersection;
/// `--tag rust --tag cli` matches PJs that are both rust and cli).
pub(crate) fn select_registered_projects(
    config: &crate::config::GlobalConfig,
    tag_filter: &[String],
) -> Vec<crate::config::ProjectEntry> {
    config
        .projects
        .iter()
        .filter(|p| {
            tag_filter
                .iter()
                .all(|wanted| p.tags.iter().any(|t| t == wanted))
        })
        .cloned()
        .collect()
}

pub mod doctor_helpers {
    use std::process::Command;

    /// True if `cmd --version` (or just `cmd` for `which` cases) runs
    /// successfully. Used by `kata doctor` to detect tooling.
    pub fn detect(cmd: &str, args: &[&str]) -> bool {
        Command::new(cmd)
            .args(args)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map(|s| s.success())
            .unwrap_or(false)
    }
}