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    /// When the preferred tree came from a remote bundle, the source HTTPS URL.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub jan_dir_source_url: Option<String>,
22    /// SHA256 of the remote bundle zip used to populate `jan_dir`.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub jan_dir_sha256: Option<String>,
25}
26
27/// `$XDG_CONFIG_HOME/jan-cli` (or `~/.config/jan-cli`), overridable via `JAN_CONFIG_DIR`.
28pub fn config_dir() -> PathBuf {
29    if let Ok(p) = std::env::var("JAN_CONFIG_DIR") {
30        let p = p.trim();
31        if !p.is_empty() {
32            return PathBuf::from(p);
33        }
34    }
35    dirs::config_dir()
36        .unwrap_or_else(|| PathBuf::from("."))
37        .join("jan-cli")
38}
39
40pub fn config_path() -> PathBuf {
41    config_dir().join(CONFIG_FILE)
42}
43
44pub fn load_user_config() -> Result<UserConfig> {
45    let path = config_path();
46    if !path.is_file() {
47        return Ok(UserConfig::default());
48    }
49    let text = fs::read_to_string(&path)
50        .with_context(|| format!("read user config {}", path.display()))?;
51    let cfg: UserConfig = serde_json::from_str(&text)
52        .with_context(|| format!("parse user config {}", path.display()))?;
53    Ok(cfg)
54}
55
56pub fn save_user_config(cfg: &UserConfig) -> Result<()> {
57    let dir = config_dir();
58    fs::create_dir_all(&dir).with_context(|| format!("create config dir {}", dir.display()))?;
59    let path = config_path();
60    let text = serde_json::to_string_pretty(cfg).context("serialize user config")?;
61    fs::write(&path, format!("{text}\n")).with_context(|| format!("write {}", path.display()))?;
62    Ok(())
63}
64
65pub fn clear_user_config() -> Result<()> {
66    let path = config_path();
67    if path.is_file() {
68        fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
69    }
70    Ok(())
71}
72
73/// Candidate entry file names inside a jan directory, in preference order.
74pub fn entry_candidates(explicit_root: Option<&str>) -> Vec<String> {
75    let mut out = Vec::new();
76    if let Some(r) = explicit_root {
77        let r = r.trim();
78        if !r.is_empty() {
79            out.push(r.to_string());
80        }
81    }
82    for name in ["scripts.spec.yaml", "jan.spec.yaml", "jan.yaml"] {
83        if !out.iter().any(|x| x == name) {
84            out.push(name.to_string());
85        }
86    }
87    out
88}
89
90/// Find an entry YAML under `dir`, or error with a clear message.
91pub fn detect_entry_file(dir: &Path, explicit_root: Option<&str>) -> Result<String> {
92    if !dir.is_dir() {
93        bail!("not a directory: {}", dir.display());
94    }
95    let candidates = entry_candidates(explicit_root);
96    for name in &candidates {
97        let path = dir.join(name);
98        if path.is_file() {
99            return Ok(name.clone());
100        }
101    }
102    bail!(
103        "no jan entry YAML in {} (tried: {})",
104        dir.display(),
105        candidates.join(", ")
106    )
107}
108
109/// Canonicalize `dir`, detect entry file, and persist as the preferred jan directory.
110pub fn set_preferred_jan_dir(dir: &Path, explicit_root: Option<&str>) -> Result<UserConfig> {
111    set_preferred_jan_dir_remote(dir, explicit_root, None, None)
112}
113
114/// Like [`set_preferred_jan_dir`], optionally recording a remote bundle source.
115pub fn set_preferred_jan_dir_remote(
116    dir: &Path,
117    explicit_root: Option<&str>,
118    source_url: Option<&str>,
119    source_sha256: Option<&str>,
120) -> Result<UserConfig> {
121    let abs = if dir.is_absolute() {
122        dir.to_path_buf()
123    } else {
124        std::env::current_dir().context("current_dir")?.join(dir)
125    };
126    let abs = abs
127        .canonicalize()
128        .with_context(|| format!("canonicalize {}", abs.display()))?;
129    let root = detect_entry_file(&abs, explicit_root)?;
130    let cfg = UserConfig {
131        jan_dir: Some(abs.to_string_lossy().into_owned()),
132        spec_root: Some(root),
133        jan_dir_source_url: source_url.map(|s| s.to_string()),
134        jan_dir_sha256: source_sha256.map(|s| s.to_ascii_lowercase()),
135    };
136    save_user_config(&cfg)?;
137    Ok(cfg)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use std::sync::Mutex;
144
145    // Serialize tests that touch env / config path isolation via temp dirs.
146    static LOCK: Mutex<()> = Mutex::new(());
147
148    #[test]
149    fn detect_entry_prefers_scripts_spec() {
150        let _g = LOCK.lock().unwrap();
151        let tmp = tempfile::tempdir().unwrap();
152        fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
153        fs::write(tmp.path().join("scripts.spec.yaml"), "commands: {}\n").unwrap();
154        assert_eq!(
155            detect_entry_file(tmp.path(), None).unwrap(),
156            "scripts.spec.yaml"
157        );
158    }
159
160    #[test]
161    fn detect_entry_honors_explicit_root() {
162        let _g = LOCK.lock().unwrap();
163        let tmp = tempfile::tempdir().unwrap();
164        fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
165        fs::write(tmp.path().join("custom.yaml"), "commands: {}\n").unwrap();
166        assert_eq!(
167            detect_entry_file(tmp.path(), Some("custom.yaml")).unwrap(),
168            "custom.yaml"
169        );
170    }
171}