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