bijux_cli/features/install/
compatibility.rs1#![forbid(unsafe_code)]
2use std::collections::BTreeMap;
5use std::hash::BuildHasher;
6use std::path::{Path, PathBuf};
7use std::{fs, io};
8
9use super::io::atomic_write_text;
10
11pub const ENV_CONFIG_PATH: &str = "BIJUXCLI_CONFIG";
13pub const ENV_HISTORY_PATH: &str = "BIJUXCLI_HISTORY_FILE";
15pub const ENV_PLUGINS_PATH: &str = "BIJUXCLI_PLUGINS_DIR";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CompatibilityPaths {
21 pub config_file: PathBuf,
23 pub history_file: PathBuf,
25 pub plugins_dir: PathBuf,
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct PathOverrides {
32 pub config_file: Option<PathBuf>,
34 pub history_file: Option<PathBuf>,
36 pub plugins_dir: Option<PathBuf>,
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
42pub struct CompatibilityConfig {
43 pub config_file: Option<PathBuf>,
45 pub history_file: Option<PathBuf>,
47 pub plugins_dir: Option<PathBuf>,
49}
50
51#[derive(Debug, thiserror::Error)]
53pub enum CompatibilityError {
54 #[error("home directory is required for compatibility path discovery")]
56 MissingHome,
57 #[error("unsupported config key: {0}")]
59 UnsupportedConfigKey(String),
60 #[error("malformed config line {line}: {content}")]
62 MalformedConfigLine {
63 line: usize,
65 content: String,
67 },
68 #[error("duplicate config key `{key}` at line {line}")]
70 DuplicateConfigKey {
71 key: String,
73 line: usize,
75 },
76 #[error("empty config value for `{key}` at line {line}")]
78 EmptyConfigValue {
79 key: String,
81 line: usize,
83 },
84 #[error("state lock is already held at {0}")]
86 LockHeld(PathBuf),
87 #[error(transparent)]
89 Io(#[from] io::Error),
90}
91
92pub fn discover_compatibility_paths(
95 home_dir: Option<&Path>,
96 cli_overrides: &PathOverrides,
97 env_map: &std::collections::HashMap<String, String, impl BuildHasher>,
98 file_config: &CompatibilityConfig,
99) -> Result<CompatibilityPaths, CompatibilityError> {
100 let defaults = home_dir.map(default_compatibility_paths);
101
102 let config_file = select_path(
103 cli_overrides.config_file.as_ref(),
104 env_map.get(ENV_CONFIG_PATH),
105 file_config.config_file.as_ref(),
106 defaults.as_ref().map(|paths| paths.config_file.as_path()),
107 home_dir,
108 )?;
109 let history_file = select_path(
110 cli_overrides.history_file.as_ref(),
111 env_map.get(ENV_HISTORY_PATH),
112 file_config.history_file.as_ref(),
113 defaults.as_ref().map(|paths| paths.history_file.as_path()),
114 home_dir,
115 )?;
116 let plugins_dir = select_path(
117 cli_overrides.plugins_dir.as_ref(),
118 env_map.get(ENV_PLUGINS_PATH),
119 file_config.plugins_dir.as_ref(),
120 defaults.as_ref().map(|paths| paths.plugins_dir.as_path()),
121 home_dir,
122 )?;
123
124 Ok(CompatibilityPaths { config_file, history_file, plugins_dir })
125}
126
127#[must_use]
129pub fn default_compatibility_paths(home_dir: &Path) -> CompatibilityPaths {
130 let base = home_dir.join(".bijux");
131 CompatibilityPaths {
132 config_file: base.join(".env"),
133 history_file: base.join(".history"),
134 plugins_dir: base.join(".plugins"),
135 }
136}
137
138pub fn parse_compatibility_config(text: &str) -> Result<CompatibilityConfig, CompatibilityError> {
140 let mut values = BTreeMap::<String, String>::new();
141
142 for (index, raw_line) in text.lines().enumerate() {
143 let line_no = index + 1;
144 let line = raw_line.trim();
145 if line.is_empty() || line.starts_with('#') {
146 continue;
147 }
148
149 let Some((key, value)) = line.split_once('=') else {
150 return Err(CompatibilityError::MalformedConfigLine {
151 line: line_no,
152 content: raw_line.to_string(),
153 });
154 };
155
156 let trimmed_key = key.trim();
157 let trimmed_value = value.trim();
158 match trimmed_key {
159 ENV_CONFIG_PATH | ENV_HISTORY_PATH | ENV_PLUGINS_PATH => {
160 if trimmed_value.is_empty() {
161 return Err(CompatibilityError::EmptyConfigValue {
162 key: trimmed_key.to_string(),
163 line: line_no,
164 });
165 }
166 if values.contains_key(trimmed_key) {
167 return Err(CompatibilityError::DuplicateConfigKey {
168 key: trimmed_key.to_string(),
169 line: line_no,
170 });
171 }
172 values.insert(trimmed_key.to_string(), trimmed_value.to_string());
173 }
174 _ => {
175 return Err(CompatibilityError::UnsupportedConfigKey(trimmed_key.to_string()));
176 }
177 }
178 }
179
180 Ok(CompatibilityConfig {
181 config_file: values.get(ENV_CONFIG_PATH).map(PathBuf::from),
182 history_file: values.get(ENV_HISTORY_PATH).map(PathBuf::from),
183 plugins_dir: values.get(ENV_PLUGINS_PATH).map(PathBuf::from),
184 })
185}
186
187pub fn load_compatibility_config(path: &Path) -> Result<CompatibilityConfig, CompatibilityError> {
189 if !path.exists() {
190 return Ok(CompatibilityConfig::default());
191 }
192
193 let text = fs::read_to_string(path)?;
194 parse_compatibility_config(&text)
195}
196
197pub fn write_compatibility_config(
199 path: &Path,
200 config: &CompatibilityConfig,
201) -> Result<(), CompatibilityError> {
202 let mut lines = Vec::new();
203 if let Some(value) = &config.config_file {
204 lines.push(format!("{ENV_CONFIG_PATH}={}", value.display()));
205 }
206 if let Some(value) = &config.history_file {
207 lines.push(format!("{ENV_HISTORY_PATH}={}", value.display()));
208 }
209 if let Some(value) = &config.plugins_dir {
210 lines.push(format!("{ENV_PLUGINS_PATH}={}", value.display()));
211 }
212 lines.sort();
213
214 let rendered = if lines.is_empty() {
215 String::new()
216 } else {
217 let mut buf = lines.join("\n");
218 buf.push('\n');
219 buf
220 };
221
222 atomic_write_text(path, &rendered)
223}
224
225fn select_path(
226 cli_value: Option<&PathBuf>,
227 env_value: Option<&String>,
228 config_value: Option<&PathBuf>,
229 default_value: Option<&Path>,
230 home_dir: Option<&Path>,
231) -> Result<PathBuf, CompatibilityError> {
232 let candidate = cli_value
233 .filter(|value| !path_is_empty(value))
234 .cloned()
235 .or_else(|| env_value.filter(|value| !value.trim().is_empty()).map(PathBuf::from))
236 .or_else(|| config_value.filter(|value| !path_is_empty(value)).cloned())
237 .or_else(|| default_value.map(Path::to_path_buf))
238 .ok_or(CompatibilityError::MissingHome)?;
239
240 normalize_path(&candidate, home_dir)
241}
242
243fn path_is_empty(path: &Path) -> bool {
244 path.to_str().is_some_and(|value| value.trim().is_empty())
245}
246
247fn normalize_path(path: &Path, home_dir: Option<&Path>) -> Result<PathBuf, CompatibilityError> {
248 let Some(raw) = path.to_str() else {
249 return Ok(path.to_path_buf());
250 };
251
252 if raw == "~" {
253 return home_dir.map(Path::to_path_buf).ok_or(CompatibilityError::MissingHome);
254 }
255 if let Some(tail) = raw.strip_prefix("~/") {
256 let home = home_dir.ok_or(CompatibilityError::MissingHome)?;
257 return Ok(home.join(tail));
258 }
259 if path.is_absolute() {
260 return Ok(path.to_path_buf());
261 }
262
263 let home = home_dir.ok_or(CompatibilityError::MissingHome)?;
264 Ok(home.join(path))
265}
266
267#[cfg(test)]
268mod tests {
269 use std::collections::HashMap;
270 use std::path::PathBuf;
271
272 use super::{
273 discover_compatibility_paths, parse_compatibility_config, CompatibilityConfig,
274 CompatibilityError, PathOverrides, ENV_CONFIG_PATH, ENV_HISTORY_PATH, ENV_PLUGINS_PATH,
275 };
276
277 #[test]
278 fn parser_rejects_duplicate_keys() {
279 let source = format!(
280 "{ENV_CONFIG_PATH}=a.env\n{ENV_HISTORY_PATH}=a.history\n{ENV_CONFIG_PATH}=b.env\n"
281 );
282
283 let err = parse_compatibility_config(&source).expect_err("duplicate key should fail");
284 assert!(matches!(
285 err,
286 CompatibilityError::DuplicateConfigKey { key, line }
287 if key == ENV_CONFIG_PATH && line == 3
288 ));
289 }
290
291 #[test]
292 fn parser_rejects_unknown_keys() {
293 let source = "UNKNOWN=/tmp/path\n";
294 let err = parse_compatibility_config(source).expect_err("unknown key should fail");
295 assert!(matches!(err, CompatibilityError::UnsupportedConfigKey(key) if key == "UNKNOWN"));
296 }
297
298 #[test]
299 fn parser_accepts_known_keys_once() {
300 let source = format!(
301 "{ENV_CONFIG_PATH}=cfg.env\n{ENV_HISTORY_PATH}=history.log\n{ENV_PLUGINS_PATH}=plugins\n"
302 );
303 let parsed = parse_compatibility_config(&source).expect("parse should pass");
304 assert_eq!(parsed.config_file.as_deref(), Some(std::path::Path::new("cfg.env")));
305 assert_eq!(parsed.history_file.as_deref(), Some(std::path::Path::new("history.log")));
306 assert_eq!(parsed.plugins_dir.as_deref(), Some(std::path::Path::new("plugins")));
307 }
308
309 #[test]
310 fn parser_rejects_empty_values() {
311 let source = format!("{ENV_HISTORY_PATH}=\n");
312 let err = parse_compatibility_config(&source).expect_err("empty value should fail");
313 assert!(matches!(
314 err,
315 CompatibilityError::EmptyConfigValue { key, line } if key == ENV_HISTORY_PATH && line == 1
316 ));
317 }
318
319 #[test]
320 fn discover_paths_ignores_empty_overrides_and_uses_defaults() {
321 let home = PathBuf::from("/tmp/bijux-compat-home");
322 let overrides = PathOverrides {
323 config_file: Some(PathBuf::from("")),
324 history_file: Some(PathBuf::from(" ")),
325 plugins_dir: None,
326 };
327 let mut env_map = HashMap::new();
328 env_map.insert(ENV_CONFIG_PATH.to_string(), " ".to_string());
329 env_map.insert(ENV_HISTORY_PATH.to_string(), "".to_string());
330 env_map.insert(ENV_PLUGINS_PATH.to_string(), "\t".to_string());
331
332 let resolved = discover_compatibility_paths(
333 Some(home.as_path()),
334 &overrides,
335 &env_map,
336 &CompatibilityConfig::default(),
337 )
338 .expect("resolve");
339
340 assert_eq!(resolved.config_file, home.join(".bijux/.env"));
341 assert_eq!(resolved.history_file, home.join(".bijux/.history"));
342 assert_eq!(resolved.plugins_dir, home.join(".bijux/.plugins"));
343 }
344
345 #[test]
346 fn discover_paths_without_home_supports_absolute_overrides() {
347 let overrides = PathOverrides {
348 config_file: Some(PathBuf::from("/tmp/bijux/config.env")),
349 history_file: Some(PathBuf::from("/tmp/bijux/history.log")),
350 plugins_dir: Some(PathBuf::from("/tmp/bijux/plugins")),
351 };
352
353 let resolved = discover_compatibility_paths(
354 None,
355 &overrides,
356 &HashMap::new(),
357 &CompatibilityConfig::default(),
358 )
359 .expect("absolute overrides should not require home");
360
361 assert_eq!(resolved.config_file, PathBuf::from("/tmp/bijux/config.env"));
362 assert_eq!(resolved.history_file, PathBuf::from("/tmp/bijux/history.log"));
363 assert_eq!(resolved.plugins_dir, PathBuf::from("/tmp/bijux/plugins"));
364 }
365
366 #[test]
367 fn discover_paths_without_home_rejects_defaults() {
368 let error = discover_compatibility_paths(
369 None,
370 &PathOverrides::default(),
371 &HashMap::new(),
372 &CompatibilityConfig::default(),
373 )
374 .expect_err("missing home should fail when defaults are required");
375 assert!(matches!(error, CompatibilityError::MissingHome));
376 }
377
378 #[test]
379 fn discover_paths_without_home_rejects_relative_overrides() {
380 let overrides = PathOverrides {
381 config_file: Some(PathBuf::from("config.env")),
382 history_file: Some(PathBuf::from("/tmp/bijux/history.log")),
383 plugins_dir: Some(PathBuf::from("/tmp/bijux/plugins")),
384 };
385
386 let error = discover_compatibility_paths(
387 None,
388 &overrides,
389 &HashMap::new(),
390 &CompatibilityConfig::default(),
391 )
392 .expect_err("relative overrides still need home to normalize");
393 assert!(matches!(error, CompatibilityError::MissingHome));
394 }
395}