jan-cli 0.27.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 and linked script includes reachable via `include:` links.

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

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

use crate::{IncludeRef, LocalInclude};

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

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

fn resolve_under(use_root: &Path, rel: &str) -> Result<PathBuf> {
    let p = Path::new(rel.trim());
    if p.as_os_str().is_empty() {
        bail!("empty include path");
    }
    if p.is_absolute() {
        bail!(
            "include path must be relative to the jan use root: {}",
            p.display()
        );
    }
    if p.components()
        .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        bail!("include path must not contain `..`: {}", p.display());
    }
    let full = use_root.join(p);
    let resolved = full
        .canonicalize()
        .with_context(|| format!("include path not found: {}", full.display()))?;
    enforce_under_anchor(&resolved, use_root)?;
    Ok(resolved)
}

/// Parent directory of a YAML spec entry (typically the anchored bundle directory).
///
/// Intended for tooling and tests; `jan bundle` uses the preferred `jan use`
/// directory as its anchor.
#[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 files needed to resolve `entry_yaml` relative includes (YAML and
/// script links), anchored under `bundle_root`, sorted for stable archiving.
///
/// Fails if any remote `include:` is present — bundles must stay self-contained.
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 reject_remote(inc: &IncludeRef) -> Result<()> {
    if let IncludeRef::Remote(r) = inc {
        bail!(
            "jan bundle cannot pack remote include `{}` — vendor the YAML locally first",
            r.url
        );
    }
    Ok(())
}

fn local_include(inc: &IncludeRef) -> Result<&LocalInclude> {
    reject_remote(inc)?;
    match inc {
        IncludeRef::Local(l) => Ok(l),
        IncludeRef::Remote(_) => unreachable!("reject_remote"),
    }
}

fn visit_include_target(
    local: &LocalInclude,
    anchor: &Path,
    seen: &mut HashSet<PathBuf>,
    ordered: &mut Vec<PathBuf>,
) -> Result<()> {
    let p = resolve_under(anchor, &local.path)?;
    if local.is_yaml() {
        visit_yaml_file(&p, anchor, seen, ordered)
    } else {
        enforce_under_anchor(&p, anchor)?;
        let canon = p
            .canonicalize()
            .with_context(|| format!("canonicalize {}", p.display()))?;
        if seen.insert(canon.clone()) {
            ordered.push(canon);
        }
        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()))?;
    for inc in &doc.include {
        let local = local_include(inc)?;
        visit_include_target(local, anchor, seen, ordered)?;
    }
    for node in doc.commands.values() {
        walk_node(node, anchor, seen, ordered)?;
    }
    Ok(())
}

fn walk_node(
    node: &LooseNode,
    anchor: &Path,
    seen: &mut HashSet<PathBuf>,
    ordered: &mut Vec<PathBuf>,
) -> Result<()> {
    if let Some(inc) = &node.include {
        let local = local_include(inc)?;
        visit_include_target(local, anchor, seen, ordered)?;
    }
    for child in node.commands.values() {
        walk_node(child, 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();
        std::fs::write(
            root.join("scripts.spec.yaml"),
            r#"
metadata:
  name: demo
include:
  - sub/leaf.yaml
commands:
  wrapped:
    include: sub/leaf.yaml
"#,
        )
        .unwrap();

        let files = ordered_yaml_closure(&root.join("scripts.spec.yaml"), root).unwrap();
        assert_eq!(files.len(), 2);
        assert!(files.iter().any(|p| p.ends_with("scripts.spec.yaml")));
        assert!(files.iter().any(|p| p.ends_with("leaf.yaml")));
    }

    #[test]
    fn closure_packs_script_includes() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        std::fs::create_dir_all(root.join("scripts")).unwrap();
        std::fs::write(root.join("scripts/hi.sh"), "#!/bin/sh\necho hi\n").unwrap();
        std::fs::write(
            root.join("scripts.spec.yaml"),
            r#"
commands:
  hi:
    include: scripts/hi.sh
"#,
        )
        .unwrap();
        let files = ordered_yaml_closure(&root.join("scripts.spec.yaml"), root).unwrap();
        assert!(files.iter().any(|p| p.ends_with("hi.sh")));
    }

    #[test]
    fn closure_rejects_remote_include() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let mut f = std::fs::File::create(root.join("scripts.spec.yaml")).unwrap();
        write!(
            f,
            r#"
commands:
  remote:
    include:
      url: https://example.com/a.yaml
      sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"#
        )
        .unwrap();
        let err = ordered_yaml_closure(&root.join("scripts.spec.yaml"), root).unwrap_err();
        assert!(
            err.to_string().contains("cannot pack remote include"),
            "{err:#}"
        );
    }
}