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}
20
21pub 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
67pub 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 if let Ok(env) = std::env::var("JAN_SPEC_ROOT") {
77 let r = env.trim().to_string();
78 if !r.is_empty() && !out.iter().any(|x| x == &r) {
79 out.push(r);
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 let abs = if dir.is_absolute() {
112 dir.to_path_buf()
113 } else {
114 std::env::current_dir()
115 .context("current_dir")?
116 .join(dir)
117 };
118 let abs = abs
119 .canonicalize()
120 .with_context(|| format!("canonicalize {}", abs.display()))?;
121 let root = detect_entry_file(&abs, explicit_root)?;
122 let cfg = UserConfig {
123 jan_dir: Some(abs.to_string_lossy().into_owned()),
124 spec_root: Some(root),
125 };
126 save_user_config(&cfg)?;
127 Ok(cfg)
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use std::sync::Mutex;
134
135 static LOCK: Mutex<()> = Mutex::new(());
137
138 #[test]
139 fn detect_entry_prefers_scripts_spec() {
140 let _g = LOCK.lock().unwrap();
141 let tmp = tempfile::tempdir().unwrap();
142 fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
143 fs::write(tmp.path().join("scripts.spec.yaml"), "commands: {}\n").unwrap();
144 assert_eq!(
145 detect_entry_file(tmp.path(), None).unwrap(),
146 "scripts.spec.yaml"
147 );
148 }
149
150 #[test]
151 fn detect_entry_honors_explicit_root() {
152 let _g = LOCK.lock().unwrap();
153 let tmp = tempfile::tempdir().unwrap();
154 fs::write(tmp.path().join("jan.spec.yaml"), "commands: {}\n").unwrap();
155 fs::write(tmp.path().join("custom.yaml"), "commands: {}\n").unwrap();
156 assert_eq!(
157 detect_entry_file(tmp.path(), Some("custom.yaml")).unwrap(),
158 "custom.yaml"
159 );
160 }
161}