jan-cli 0.2.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
//! YAML spec loading with `include` links and host-OS filtering.

use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::borrow::Cow;

use crate::{CommandNode, ExecSpec, Metadata, RootSpec};

/// Which platform string to use when filtering `os:` lists on commands.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostPlatform {
    /// Normalized id: `linux`, `macos`, `windows`, or another `std::env::consts::OS` value.
    pub id: Cow<'static, str>,
}

impl HostPlatform {
    /// Resolve the process host, honoring `JAN_OS` when set (for tests and overrides).
    pub fn detect() -> Self {
        if let Ok(v) = std::env::var("JAN_OS") {
            let s = v.trim().to_ascii_lowercase();
            if !s.is_empty() {
                return Self::from_normalized(&s);
            }
        }
        Self::from_normalized(std::env::consts::OS)
    }

    fn from_normalized(os: &str) -> Self {
        let id = match os {
            "darwin" | "macos" => Cow::Borrowed("macos"),
            "linux" => Cow::Borrowed("linux"),
            "windows" => Cow::Borrowed("windows"),
            other => Cow::Owned(other.to_string()),
        };
        Self { id }
    }
}

fn normalize_os_token(tok: &str) -> String {
    match tok.trim().to_ascii_lowercase().as_str() {
        "darwin" => "macos".to_string(),
        s => s.to_string(),
    }
}

fn node_visible_for_platform(os_list: &[String], platform: &str) -> bool {
    if os_list.is_empty() {
        return true;
    }
    os_list.iter().any(|o| normalize_os_token(o) == platform)
}

#[derive(Debug, Deserialize)]
struct RawRootSpec {
    metadata: Option<Metadata>,
    #[serde(default)]
    include: Vec<String>,
    #[serde(default)]
    commands: BTreeMap<String, RawCommandNode>,
}

#[derive(Debug, Deserialize)]
struct RawCommandNode {
    #[serde(default)]
    os: Vec<String>,
    #[serde(default)]
    about: String,
    path: Option<String>,
    #[serde(default)]
    dependencies: Vec<String>,
    #[serde(default)]
    requires: Vec<String>,
    #[serde(default)]
    env: BTreeMap<String, String>,
    include: Option<String>,
    #[serde(default)]
    commands: BTreeMap<String, RawCommandNode>,
    exec: Option<ExecSpec>,
}

#[derive(Clone)]
struct LoadCtx {
    /// Directory used to resolve relative `include` paths (typically the YAML file's parent).
    include_base: PathBuf,
}

impl LoadCtx {
    fn read_include(&self, rel: &str) -> Result<String> {
        let rel = rel.trim();
        if rel.is_empty() {
            bail!("empty include path");
        }
        let path = resolve_under(&self.include_base, rel)?;
        std::fs::read_to_string(&path)
            .with_context(|| format!("read included spec {}", path.display()))
    }

    fn ctx_for_nested_include(&self, rel: &str) -> Result<LoadCtx> {
        let path = resolve_under(&self.include_base, rel)?;
        Ok(LoadCtx {
            include_base: path
                .parent()
                .unwrap_or_else(|| Path::new("."))
                .to_path_buf(),
        })
    }

    fn visit_token(&self, rel: &str) -> Result<String> {
        let p = resolve_under(&self.include_base, rel)?;
        Ok(p.to_string_lossy().to_string())
    }
}

fn resolve_under(base_dir: &Path, rel: &str) -> Result<PathBuf> {
    let p = Path::new(rel);
    let full = if p.is_absolute() {
        p.to_path_buf()
    } else {
        base_dir.join(p)
    };
    full.canonicalize()
        .with_context(|| format!("include path not found: {}", full.display()))
}

fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
    match (outer.is_empty(), inner.is_empty()) {
        (true, true) => Ok(vec![]),
        (true, false) => Ok(inner.to_vec()),
        (false, true) => Ok(outer.to_vec()),
        (false, false) => {
            let merged: Vec<String> = outer
                .iter()
                .filter(|o| {
                    let n = normalize_os_token(o);
                    inner.iter().any(|i| normalize_os_token(i) == n)
                })
                .cloned()
                .collect();
            if merged.is_empty() {
                bail!(
                    "conflicting `os:` filters between include wrapper and included file \
                     (no platform appears in both lists)"
                );
            }
            Ok(merged)
        }
    }
}

fn overlay_about(overlay: &str, base: String) -> String {
    let o = overlay.trim();
    if o.is_empty() {
        base
    } else {
        o.to_string()
    }
}

fn resolve_raw_command_node(
    raw: RawCommandNode,
    ctx: &LoadCtx,
    visited: &mut HashSet<String>,
) -> Result<CommandNode> {
    if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
        bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
    }

    let mut raw = raw;
    if let Some(rel) = raw.include.take() {
        let token = ctx.visit_token(&rel)?;
        if !visited.insert(token.clone()) {
            bail!("include cycle detected at `{token}`");
        }
        let text = ctx.read_include(&rel)?;
        let inner: RawCommandNode =
            serde_yaml::from_str(&text).with_context(|| format!("parse include `{rel}`"))?;
        let nested_ctx = ctx.ctx_for_nested_include(&rel)?;
        let mut node = resolve_raw_command_node(inner, &nested_ctx, visited)?;
        visited.remove(&token);
        node.os = merge_os_filters(&raw.os, &node.os)?;
        node.about = overlay_about(&raw.about, node.about);
        return Ok(node);
    }

    let mut commands = BTreeMap::new();
    for (name, child) in raw.commands {
        commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
    }

    Ok(CommandNode {
        os: raw.os,
        about: raw.about,
        path: raw.path,
        dependencies: raw.dependencies,
        requires: raw.requires,
        env: raw.env,
        commands,
        exec: raw.exec,
    })
}

fn merge_root_includes(mut root: RawRootSpec, include_base: &Path) -> Result<RawRootSpec> {
    let base_dir = include_base;
    let mut merged = BTreeMap::new();
    for inc in &root.include {
        let path = resolve_under(base_dir, inc)?;
        let text = std::fs::read_to_string(&path)
            .with_context(|| format!("read root include {}", path.display()))?;
        let fragment: RawRootSpec =
            serde_yaml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
        let frag_base = path
            .parent()
            .unwrap_or_else(|| Path::new("."))
            .to_path_buf();
        let mut expanded = merge_root_includes(fragment, &frag_base)?;
        merged.append(&mut expanded.commands);
    }
    merged.append(&mut root.commands);
    root.commands = merged;
    root.include.clear();
    Ok(root)
}

fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
    let mut visited = HashSet::new();
    let mut commands = BTreeMap::new();
    for (name, node) in raw.commands {
        commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
    }
    Ok(RootSpec {
        metadata: raw.metadata,
        commands,
    })
}

fn validate_root(spec: &RootSpec) -> Result<()> {
    for (name, node) in &spec.commands {
        node.validate(name)?;
    }
    Ok(())
}

pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
    let text = std::fs::read_to_string(spec_path)
        .with_context(|| format!("read spec file {}", spec_path.display()))?;
    let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
    let include_base = spec_path
        .parent()
        .unwrap_or_else(|| Path::new("."))
        .to_path_buf();
    let raw = merge_root_includes(raw, &include_base)?;
    let ctx = LoadCtx { include_base };
    let mut spec = materialize_root(raw, &ctx)?;
    // Validate structure before OS filtering: filtering can drop nested nodes (e.g. empty
    // placeholders) and would otherwise hide invalid `exec` + `commands` combinations.
    validate_root(&spec)?;
    filter_spec_for_platform(&mut spec, platform.id.as_ref());
    Ok(spec)
}

/// Parse an in-memory spec. Root-level `include` entries resolve relative to `include_base`.
/// Pass `None` only when the document has no root `include` keys.
pub fn load_spec_from_str(
    raw: &str,
    include_base: Option<&Path>,
    platform: HostPlatform,
) -> Result<RootSpec> {
    let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
    let raw = if let Some(base) = include_base {
        merge_root_includes(raw, base)?
    } else {
        if !raw.include.is_empty() {
            bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
        }
        raw
    };
    let ctx = LoadCtx {
        include_base: include_base
            .map(Path::to_path_buf)
            .unwrap_or_else(|| PathBuf::from(".")),
    };
    let mut spec = materialize_root(raw, &ctx)?;
    validate_root(&spec)?;
    filter_spec_for_platform(&mut spec, platform.id.as_ref());
    Ok(spec)
}

pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
    filter_command_map(&mut spec.commands, platform_id);
}

fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
    map.retain(|_, node| {
        if !node_visible_for_platform(&node.os, platform_id) {
            return false;
        }
        filter_command_map(&mut node.commands, platform_id);
        if node.exec.is_some() {
            return true;
        }
        !node.commands.is_empty()
    });
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ExecSpec;

    #[test]
    fn os_filter_drops_linux_only_branch() {
        let mut spec = RootSpec {
            metadata: None,
            commands: BTreeMap::from([(
                "sys".into(),
                CommandNode {
                    os: vec!["linux".into()],
                    about: "linux".into(),
                    commands: BTreeMap::from([(
                        "ports".into(),
                        CommandNode {
                            exec: Some(ExecSpec {
                                argv: vec!["echo".into(), "x".into()],
                                passthrough: false,
                            }),
                            ..Default::default()
                        },
                    )]),
                    ..Default::default()
                },
            )]),
        };
        filter_spec_for_platform(&mut spec, "macos");
        assert!(spec.commands.is_empty());
    }
}