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}
26
27pub 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
73pub 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
90pub 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
109pub 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
114pub 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 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}