Skip to main content

jan_cli/
config.rs

1//! User preferences under the XDG config directory (`$XDG_CONFIG_HOME/jan-cli/`).
2
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{bail, Context, Result};
7use serde::{Deserialize, Serialize};
8
9const CONFIG_FILE: &str = "config.json";
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
12pub struct UserConfig {
13    /// Preferred directory containing a jan YAML tree (absolute path when saved).
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub jan_dir: Option<String>,
16    /// Entry YAML file name inside `jan_dir` (e.g. `scripts.spec.yaml`).
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub spec_root: Option<String>,
19}
20
21/// `$XDG_CONFIG_HOME/jan-cli` (or `~/.config/jan-cli`), overridable via `JAN_CONFIG_DIR`.
22pub fn config_dir() -> PathBuf {
23    if let Ok(p) = std::env::var("JAN_CONFIG_DIR") {
24        let p = p.trim();
25        if !p.is_empty() {
26            return PathBuf::from(p);
27        }
28    }
29    dirs::config_dir()
30        .unwrap_or_else(|| PathBuf::from("."))
31        .join("jan-cli")
32}
33
34pub fn config_path() -> PathBuf {
35    config_dir().join(CONFIG_FILE)
36}
37
38pub fn load_user_config() -> Result<UserConfig> {
39    let path = config_path();
40    if !path.is_file() {
41        return Ok(UserConfig::default());
42    }
43    let text = fs::read_to_string(&path)
44        .with_context(|| format!("read user config {}", path.display()))?;
45    let cfg: UserConfig = serde_json::from_str(&text)
46        .with_context(|| format!("parse user config {}", path.display()))?;
47    Ok(cfg)
48}
49
50pub fn save_user_config(cfg: &UserConfig) -> Result<()> {
51    let dir = config_dir();
52    fs::create_dir_all(&dir).with_context(|| format!("create config dir {}", dir.display()))?;
53    let path = config_path();
54    let text = serde_json::to_string_pretty(cfg).context("serialize user config")?;
55    fs::write(&path, format!("{text}\n")).with_context(|| format!("write {}", path.display()))?;
56    Ok(())
57}
58
59pub fn clear_user_config() -> Result<()> {
60    let path = config_path();
61    if path.is_file() {
62        fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
63    }
64    Ok(())
65}
66
67/// Candidate entry file names inside a jan directory, in preference order.
68pub fn entry_candidates(explicit_root: Option<&str>) -> Vec<String> {
69    let mut out = Vec::new();
70    if let Some(r) = explicit_root {
71        let r = r.trim();
72        if !r.is_empty() {
73            out.push(r.to_string());
74        }
75    }
76    for name in ["scripts.spec.yaml", "jan.spec.yaml", "jan.yaml"] {
77        if !out.iter().any(|x| x == name) {
78            out.push(name.to_string());
79        }
80    }
81    out
82}
83
84/// Find an entry YAML under `dir`, or error with a clear message.
85pub fn detect_entry_file(dir: &Path, explicit_root: Option<&str>) -> Result<String> {
86    if !dir.is_dir() {
87        bail!("not a directory: {}", dir.display());
88    }
89    let candidates = entry_candidates(explicit_root);
90    for name in &candidates {
91        let path = dir.join(name);
92        if path.is_file() {
93            return Ok(name.clone());
94        }
95    }
96    bail!(
97        "no jan entry YAML in {} (tried: {})",
98        dir.display(),
99        candidates.join(", ")
100    )
101}
102
103/// Canonicalize `dir`, detect entry file, and persist as the preferred jan directory.
104pub fn set_preferred_jan_dir(dir: &Path, explicit_root: Option<&str>) -> Result<UserConfig> {
105    let abs = if dir.is_absolute() {
106        dir.to_path_buf()
107    } else {
108        std::env::current_dir().context("current_dir")?.join(dir)
109    };
110    let abs = abs
111        .canonicalize()
112        .with_context(|| format!("canonicalize {}", abs.display()))?;
113    let root = detect_entry_file(&abs, explicit_root)?;
114    let cfg = UserConfig {
115        jan_dir: Some(abs.to_string_lossy().into_owned()),
116        spec_root: Some(root),
117    };
118    save_user_config(&cfg)?;
119    Ok(cfg)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use std::sync::Mutex;
126
127    // Serialize tests that touch env / config path isolation via temp dirs.
128    static LOCK: Mutex<()> = Mutex::new(());
129
130    #[test]
131    fn detect_entry_prefers_scripts_spec() {
132        let _g = LOCK.lock().unwrap();
133        let tmp = tempfile::tempdir().unwrap();
134        fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
135        fs::write(tmp.path().join("scripts.spec.yaml"), "commands: {}\n").unwrap();
136        assert_eq!(
137            detect_entry_file(tmp.path(), None).unwrap(),
138            "scripts.spec.yaml"
139        );
140    }
141
142    #[test]
143    fn detect_entry_honors_explicit_root() {
144        let _g = LOCK.lock().unwrap();
145        let tmp = tempfile::tempdir().unwrap();
146        fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
147        fs::write(tmp.path().join("custom.yaml"), "commands: {}\n").unwrap();
148        assert_eq!(
149            detect_entry_file(tmp.path(), Some("custom.yaml")).unwrap(),
150            "custom.yaml"
151        );
152    }
153}