jan-cli 0.12.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
//! Typed script inputs (`inputs:`) — CLI flags and `${{ inputs.name }}` interpolation.

use std::collections::BTreeMap;
use std::ffi::OsString;

use anyhow::{bail, Result};
use serde::Deserialize;

use crate::RootSpec;

/// Declaration of one named input on a command node.
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
pub struct InputDef {
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub required: bool,
    pub default: Option<String>,
}

impl InputDef {
    pub fn validate_name(name: &str) -> Result<()> {
        let mut chars = name.chars();
        let Some(first) = chars.next() else {
            bail!("input name must not be empty");
        };
        if !(first.is_ascii_alphabetic() || first == '_') {
            bail!("input `{name}` must start with a letter or `_`");
        }
        if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
            bail!("input `{name}` may only contain letters, digits, `_`, and `-`");
        }
        Ok(())
    }
}

/// Merge `inputs` maps along `chain`; later segments override earlier defs for the same name.
pub fn collect_chain_inputs(chain: &[String], spec: &RootSpec) -> BTreeMap<String, InputDef> {
    let mut out = BTreeMap::new();
    let mut map = &spec.commands;
    for seg in chain {
        let Some(node) = map.get(seg) else { break };
        for (name, def) in &node.inputs {
            out.insert(name.clone(), def.clone());
        }
        map = &node.commands;
    }
    out
}

/// Resolve CLI trailing args into input values + leftover (for `passthrough`).
pub fn resolve_inputs(
    defs: &BTreeMap<String, InputDef>,
    trailing: &[OsString],
) -> Result<(BTreeMap<String, String>, Vec<OsString>)> {
    for name in defs.keys() {
        InputDef::validate_name(name)?;
    }

    if defs.is_empty() {
        return Ok((BTreeMap::new(), trailing.to_vec()));
    }

    let mut provided: BTreeMap<String, String> = BTreeMap::new();
    let mut rest: Vec<OsString> = Vec::new();
    let mut i = 0usize;
    while i < trailing.len() {
        let raw = trailing[i].to_string_lossy();
        if raw == "--" {
            rest.extend_from_slice(&trailing[i + 1..]);
            break;
        }
        if let Some(body) = raw.strip_prefix("--") {
            if body.is_empty() {
                rest.push(trailing[i].clone());
                i += 1;
                continue;
            }
            let (name, value_opt) = if let Some((n, v)) = body.split_once('=') {
                (n.to_string(), Some(v.to_string()))
            } else {
                (body.to_string(), None)
            };
            if !defs.contains_key(&name) {
                // Unknown long flag: leave for passthrough / later error.
                rest.push(trailing[i].clone());
                i += 1;
                continue;
            }
            let value = match value_opt {
                Some(v) => v,
                None => {
                    i += 1;
                    let Some(next) = trailing.get(i) else {
                        bail!("missing value for input flag `--{name}`");
                    };
                    let next_s = next.to_string_lossy();
                    if next_s.starts_with('-') && next_s != "-" {
                        bail!("missing value for input flag `--{name}`");
                    }
                    next_s.into_owned()
                }
            };
            provided.insert(name, value);
            i += 1;
            continue;
        }
        rest.push(trailing[i].clone());
        i += 1;
    }

    let mut resolved = BTreeMap::new();
    let mut missing = Vec::new();
    for (name, def) in defs {
        if let Some(v) = provided.get(name) {
            resolved.insert(name.clone(), v.clone());
            continue;
        }
        if let Some(default) = &def.default {
            resolved.insert(name.clone(), default.clone());
            continue;
        }
        if def.required {
            missing.push(format!("--{name}"));
        }
    }
    if !missing.is_empty() {
        bail!(
            "missing required input(s): {} (see `--help` on this command)",
            missing.join(", ")
        );
    }
    Ok((resolved, rest))
}

/// Replace `${{ inputs.name }}` (optional inner whitespace) using resolved values.
pub fn interpolate(template: &str, inputs: &BTreeMap<String, String>) -> Result<String> {
    let mut out = String::with_capacity(template.len());
    let mut rest = template;
    while !rest.is_empty() {
        if let Some(start) = rest.find("${{") {
            out.push_str(&rest[..start]);
            let after = &rest[start + 3..];
            let Some(end) = after.find("}}") else {
                bail!("unclosed `${{{{` expression in `{template}`");
            };
            let inner = after[..end].trim();
            out.push_str(&eval_expr(inner, inputs)?);
            rest = &after[end + 2..];
        } else {
            out.push_str(rest);
            break;
        }
    }
    Ok(out)
}

fn eval_expr(inner: &str, inputs: &BTreeMap<String, String>) -> Result<String> {
    let Some(name) = inner.strip_prefix("inputs.") else {
        bail!(
            "unsupported expression `${{{{ {inner} }}}}` \
             (only `inputs.<name>` is supported)"
        );
    };
    let name = name.trim();
    InputDef::validate_name(name)?;
    inputs.get(name).cloned().ok_or_else(|| {
        anyhow::anyhow!(
            "expression refers to unknown or unset input `{name}` \
             (declare it under `inputs:` and pass `--{name}` or set a default)"
        )
    })
}

/// Format help lines for declared inputs.
pub fn format_inputs_help(defs: &BTreeMap<String, InputDef>) -> String {
    if defs.is_empty() {
        return String::new();
    }
    let mut out = String::from("Inputs:\n");
    for (name, def) in defs {
        let mut line = format!("  --{name} <VALUE>");
        let mut notes = Vec::new();
        if def.required && def.default.is_none() {
            notes.push("required".to_string());
        }
        if let Some(d) = &def.default {
            notes.push(format!("default: {d}"));
        }
        if !def.description.trim().is_empty() {
            if notes.is_empty() {
                line.push_str(&format!("   {}", def.description.trim()));
            } else {
                line.push_str(&format!(
                    "   {} ({})",
                    def.description.trim(),
                    notes.join("; ")
                ));
            }
        } else if !notes.is_empty() {
            line.push_str(&format!("   ({})", notes.join("; ")));
        }
        out.push_str(&line);
        out.push('\n');
    }
    out.push('\n');
    out
}

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

    #[test]
    fn resolve_required_and_default() {
        let defs = BTreeMap::from([
            (
                "path".into(),
                InputDef {
                    description: "src".into(),
                    required: true,
                    default: None,
                },
            ),
            (
                "dest".into(),
                InputDef {
                    description: String::new(),
                    required: false,
                    default: Some("~/Backups".into()),
                },
            ),
        ]);
        let trailing = vec![OsString::from("--path"), OsString::from("/tmp/a")];
        let (vals, rest) = resolve_inputs(&defs, &trailing).unwrap();
        assert!(rest.is_empty());
        assert_eq!(vals.get("path").map(String::as_str), Some("/tmp/a"));
        assert_eq!(vals.get("dest").map(String::as_str), Some("~/Backups"));
    }

    #[test]
    fn resolve_equals_and_passthrough_rest() {
        let defs = BTreeMap::from([(
            "path".into(),
            InputDef {
                required: true,
                ..Default::default()
            },
        )]);
        let trailing = vec![
            OsString::from("--path=/tmp"),
            OsString::from("--"),
            OsString::from("--verbose"),
        ];
        let (vals, rest) = resolve_inputs(&defs, &trailing).unwrap();
        assert_eq!(vals["path"], "/tmp");
        assert_eq!(rest, vec![OsString::from("--verbose")]);
    }

    #[test]
    fn interpolate_inputs() {
        let inputs = BTreeMap::from([("path".into(), "/data".into())]);
        assert_eq!(
            interpolate("rsync ${{ inputs.path }}/", &inputs).unwrap(),
            "rsync /data/"
        );
    }

    #[test]
    fn missing_required_errors() {
        let defs = BTreeMap::from([(
            "path".into(),
            InputDef {
                required: true,
                ..Default::default()
            },
        )]);
        let err = resolve_inputs(&defs, &[]).unwrap_err();
        assert!(err.to_string().contains("missing required input"));
    }
}