Skip to main content

bijux_cli/features/install/
compatibility.rs

1#![forbid(unsafe_code)]
2//! Compatibility config and path behavior shared by rust and python entrypoints.
3
4use std::collections::BTreeMap;
5use std::hash::BuildHasher;
6use std::path::{Path, PathBuf};
7use std::{fs, io};
8
9use super::io::atomic_write_text;
10
11/// Environment variable used for explicit config file path.
12pub const ENV_CONFIG_PATH: &str = "BIJUXCLI_CONFIG";
13/// Environment variable used for explicit history file path.
14pub const ENV_HISTORY_PATH: &str = "BIJUXCLI_HISTORY_FILE";
15/// Environment variable used for explicit plugin directory path.
16pub const ENV_PLUGINS_PATH: &str = "BIJUXCLI_PLUGINS_DIR";
17
18/// Compatibility paths consumed by Python and Rust implementations.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CompatibilityPaths {
21    /// Path to `config.env`.
22    pub config_file: PathBuf,
23    /// Path to history store.
24    pub history_file: PathBuf,
25    /// Path to plugins directory.
26    pub plugins_dir: PathBuf,
27}
28
29/// Key-based path overrides from command-line flags.
30#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct PathOverrides {
32    /// Optional override for config file path.
33    pub config_file: Option<PathBuf>,
34    /// Optional override for history file path.
35    pub history_file: Option<PathBuf>,
36    /// Optional override for plugins directory path.
37    pub plugins_dir: Option<PathBuf>,
38}
39
40/// Parsed file-backed compatibility configuration.
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
42pub struct CompatibilityConfig {
43    /// Optional path from config file for config path recursion-safe representation.
44    pub config_file: Option<PathBuf>,
45    /// Optional path from config file for history file.
46    pub history_file: Option<PathBuf>,
47    /// Optional path from config file for plugins directory.
48    pub plugins_dir: Option<PathBuf>,
49}
50
51/// Error type for compatibility discovery and file operations.
52#[derive(Debug, thiserror::Error)]
53pub enum CompatibilityError {
54    /// Home directory not provided.
55    #[error("home directory is required for compatibility path discovery")]
56    MissingHome,
57    /// Config file contained an unknown key.
58    #[error("unsupported config key: {0}")]
59    UnsupportedConfigKey(String),
60    /// Config file contains malformed line.
61    #[error("malformed config line {line}: {content}")]
62    MalformedConfigLine {
63        /// 1-based line number.
64        line: usize,
65        /// Original line content.
66        content: String,
67    },
68    /// Config file contains duplicate keys.
69    #[error("duplicate config key `{key}` at line {line}")]
70    DuplicateConfigKey {
71        /// Duplicate key.
72        key: String,
73        /// 1-based line number where duplicate was detected.
74        line: usize,
75    },
76    /// Config file contains an empty path value.
77    #[error("empty config value for `{key}` at line {line}")]
78    EmptyConfigValue {
79        /// Config key.
80        key: String,
81        /// 1-based line number where empty value was detected.
82        line: usize,
83    },
84    /// Lock file already exists for mutable state operation.
85    #[error("state lock is already held at {0}")]
86    LockHeld(PathBuf),
87    /// Underlying I/O failure.
88    #[error(transparent)]
89    Io(#[from] io::Error),
90}
91
92/// Resolve effective compatibility paths with strict precedence:
93/// CLI flag overrides -> environment variables -> config file -> defaults.
94pub 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/// Default compatibility paths anchored in the user home directory.
128#[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
138/// Parse `.env`-style configuration file.
139pub 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
187/// Read and parse compatibility config file if it exists.
188pub 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
197/// Persist compatibility config atomically.
198pub 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}