1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub jan_dir: Option<String>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub spec_root: Option<String>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub jan_dir_source_url: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub jan_dir_sha256: Option<String>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub computer_id: Option<String>,
28}
29
30pub 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
76pub 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
93pub 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
112pub 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
117pub 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
158pub 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
167pub 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 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}