jan-cli 0.6.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
//! Traverse YAML spec files reachable via root and nested `include:` links (same bases as loaders).

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

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

#[derive(Debug, Deserialize)]
pub struct LooseDoc {
    #[serde(default)]
    include: Vec<String>,
    #[serde(default)]
    commands: BTreeMap<String, LooseNode>,
}

#[derive(Debug, Deserialize)]
pub struct LooseNode {
    #[serde(default)]
    include: Option<String>,
    #[serde(default)]
    commands: BTreeMap<String, LooseNode>,
}

fn resolve_under(base_dir: &Path, rel: &str) -> Result<PathBuf> {
    let p = Path::new(rel.trim());
    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()))
}

/// Parent directory of a YAML spec entry (typically the anchored bundle directory).
///
/// Intended for tooling and tests (`jan bundle` derives the anchor from `--spec-dir`/`--spec` identity instead).
#[allow(dead_code)]
pub fn canonical_spec_root(spec_file: &Path) -> Result<PathBuf> {
    let spec_file = spec_file
        .canonicalize()
        .with_context(|| format!("canonicalize {}", spec_file.display()))?;
    let parent = spec_file
        .parent()
        .ok_or_else(|| anyhow::anyhow!("spec file has no parent directory"))?;
    Ok(parent.to_path_buf())
}

/// All distinct YAML files needed to resolve `entry_yaml` relative includes, anchored under `bundle_root`,
/// sorted for stable archiving.
pub fn ordered_yaml_closure(entry_yaml: &Path, bundle_root: &Path) -> Result<Vec<PathBuf>> {
    let bundle_root = bundle_root
        .canonicalize()
        .with_context(|| format!("canonicalize bundle root {}", bundle_root.display()))?;
    if !bundle_root.is_dir() {
        bail!("bundle root is not a directory: {}", bundle_root.display());
    }
    let mut seen: HashSet<PathBuf> = HashSet::new();
    let mut ordered: Vec<PathBuf> = Vec::new();
    visit_yaml_file(entry_yaml, &bundle_root, &mut seen, &mut ordered)?;
    Ok(ordered)
}

fn enforce_under_anchor(path: &Path, anchor: &Path) -> Result<()> {
    let p = path
        .canonicalize()
        .with_context(|| format!("canonicalize {}", path.display()))?;
    if !(p.starts_with(anchor)) {
        bail!(
            "include escapes spec root anchor: {}\n(offending file: {}; anchor: {})",
            p.display(),
            path.display(),
            anchor.display()
        );
    }
    Ok(())
}

fn visit_yaml_file(
    path: &Path,
    anchor: &Path,
    seen: &mut HashSet<PathBuf>,
    ordered: &mut Vec<PathBuf>,
) -> Result<()> {
    enforce_under_anchor(path, anchor)?;
    let canon = path
        .canonicalize()
        .with_context(|| format!("canonicalize {}", path.display()))?;
    if !seen.insert(canon.clone()) {
        return Ok(());
    }
    ordered.push(canon.clone());

    let text =
        std::fs::read_to_string(&canon).with_context(|| format!("read {}", canon.display()))?;
    let doc: LooseDoc =
        serde_yaml::from_str(&text).with_context(|| format!("parse YAML {}", canon.display()))?;
    let base = canon
        .parent()
        .ok_or_else(|| anyhow::anyhow!("{} has no parent", canon.display()))?;

    for inc in &doc.include {
        let p = resolve_under(base, inc)?;
        visit_yaml_file(&p, anchor, seen, ordered)?;
    }
    for node in doc.commands.values() {
        walk_node(node, base, anchor, seen, ordered)?;
    }
    Ok(())
}

fn walk_node(
    node: &LooseNode,
    include_base: &Path,
    anchor: &Path,
    seen: &mut HashSet<PathBuf>,
    ordered: &mut Vec<PathBuf>,
) -> Result<()> {
    if let Some(inc) = &node.include {
        let p = resolve_under(include_base, inc)?;
        visit_yaml_file(&p, anchor, seen, ordered)?;
    }
    for child in node.commands.values() {
        walk_node(child, include_base, anchor, seen, ordered)?;
    }
    Ok(())
}

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

    #[test]
    fn closure_collects_root_and_nested_includes() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let sub = root.join("sub");
        std::fs::create_dir_all(&sub).unwrap();
        std::fs::write(
            sub.join("leaf.yaml"),
            r#"
about: leaf
exec:
  argv: ["echo", "x"]
"#,
        )
        .unwrap();
        let main = root.join("main.yaml");
        std::fs::write(
            &main,
            r#"
include:
  - sub/leaf.yaml
commands:
  outer:
    include: sub/leaf.yaml
"#,
        )
        .unwrap();

        let anchor = canonical_spec_root(&main).unwrap();
        let mut out = ordered_yaml_closure(&main, &anchor).unwrap();
        out.sort();
        let mut names: Vec<_> = out
            .iter()
            .map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
            .collect();
        names.sort();
        assert_eq!(names, vec!["leaf.yaml", "main.yaml"]);
    }

    #[test]
    fn closure_rejects_escape_outside_anchor() {
        let tmp = tempfile::tempdir().unwrap();
        let inside = tmp.path().join("in");
        let outside = tmp.path().join("out");
        std::fs::create_dir_all(&inside).unwrap();
        std::fs::create_dir_all(&outside).unwrap();
        let ext = outside.join("evil.yaml");
        std::fs::write(&ext, "commands:\n").unwrap();

        let main = inside.join("main.yaml");
        let mut tmpf = tempfile::NamedTempFile::new_in(&inside).unwrap();
        write!(
            tmpf,
            r#"
commands:
  x:
    include: {}
"#,
            ext.display()
        )
        .unwrap();
        std::fs::rename(tmpf.path(), &main).unwrap();

        let anchor = canonical_spec_root(&main).unwrap();
        assert!(ordered_yaml_closure(&main, &anchor).is_err());
    }
}