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
//! User preferences under the XDG config directory (`$XDG_CONFIG_HOME/jan-cli/`).

use std::fs;
use std::path::{Path, PathBuf};

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

const CONFIG_FILE: &str = "config.json";

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct UserConfig {
    /// Preferred directory containing a jan YAML tree (absolute path when saved).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub jan_dir: Option<String>,
    /// Entry YAML file name inside `jan_dir` (e.g. `scripts.spec.yaml`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub spec_root: Option<String>,
}

/// `$XDG_CONFIG_HOME/jan-cli` (or `~/.config/jan-cli`), overridable via `JAN_CONFIG_DIR`.
pub fn config_dir() -> PathBuf {
    if let Ok(p) = std::env::var("JAN_CONFIG_DIR") {
        let p = p.trim();
        if !p.is_empty() {
            return PathBuf::from(p);
        }
    }
    dirs::config_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join("jan-cli")
}

pub fn config_path() -> PathBuf {
    config_dir().join(CONFIG_FILE)
}

pub fn load_user_config() -> Result<UserConfig> {
    let path = config_path();
    if !path.is_file() {
        return Ok(UserConfig::default());
    }
    let text = fs::read_to_string(&path)
        .with_context(|| format!("read user config {}", path.display()))?;
    let cfg: UserConfig = serde_json::from_str(&text)
        .with_context(|| format!("parse user config {}", path.display()))?;
    Ok(cfg)
}

pub fn save_user_config(cfg: &UserConfig) -> Result<()> {
    let dir = config_dir();
    fs::create_dir_all(&dir).with_context(|| format!("create config dir {}", dir.display()))?;
    let path = config_path();
    let text = serde_json::to_string_pretty(cfg).context("serialize user config")?;
    fs::write(&path, format!("{text}\n")).with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

pub fn clear_user_config() -> Result<()> {
    let path = config_path();
    if path.is_file() {
        fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
    }
    Ok(())
}

/// Candidate entry file names inside a jan directory, in preference order.
pub fn entry_candidates(explicit_root: Option<&str>) -> Vec<String> {
    let mut out = Vec::new();
    if let Some(r) = explicit_root {
        let r = r.trim();
        if !r.is_empty() {
            out.push(r.to_string());
        }
    }
    if let Ok(env) = std::env::var("JAN_SPEC_ROOT") {
        let r = env.trim().to_string();
        if !r.is_empty() && !out.iter().any(|x| x == &r) {
            out.push(r);
        }
    }
    for name in ["scripts.spec.yaml", "jan.spec.yaml", "jan.yaml"] {
        if !out.iter().any(|x| x == name) {
            out.push(name.to_string());
        }
    }
    out
}

/// Find an entry YAML under `dir`, or error with a clear message.
pub fn detect_entry_file(dir: &Path, explicit_root: Option<&str>) -> Result<String> {
    if !dir.is_dir() {
        bail!("not a directory: {}", dir.display());
    }
    let candidates = entry_candidates(explicit_root);
    for name in &candidates {
        let path = dir.join(name);
        if path.is_file() {
            return Ok(name.clone());
        }
    }
    bail!(
        "no jan entry YAML in {} (tried: {})",
        dir.display(),
        candidates.join(", ")
    )
}

/// Canonicalize `dir`, detect entry file, and persist as the preferred jan directory.
pub fn set_preferred_jan_dir(dir: &Path, explicit_root: Option<&str>) -> Result<UserConfig> {
    let abs = if dir.is_absolute() {
        dir.to_path_buf()
    } else {
        std::env::current_dir()
            .context("current_dir")?
            .join(dir)
    };
    let abs = abs
        .canonicalize()
        .with_context(|| format!("canonicalize {}", abs.display()))?;
    let root = detect_entry_file(&abs, explicit_root)?;
    let cfg = UserConfig {
        jan_dir: Some(abs.to_string_lossy().into_owned()),
        spec_root: Some(root),
    };
    save_user_config(&cfg)?;
    Ok(cfg)
}

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

    // Serialize tests that touch env / config path isolation via temp dirs.
    static LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn detect_entry_prefers_scripts_spec() {
        let _g = LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
        fs::write(tmp.path().join("scripts.spec.yaml"), "commands: {}\n").unwrap();
        assert_eq!(
            detect_entry_file(tmp.path(), None).unwrap(),
            "scripts.spec.yaml"
        );
    }

    #[test]
    fn detect_entry_honors_explicit_root() {
        let _g = LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
        fs::write(tmp.path().join("custom.yaml"), "commands: {}\n").unwrap();
        assert_eq!(
            detect_entry_file(tmp.path(), Some("custom.yaml")).unwrap(),
            "custom.yaml"
        );
    }
}