Skip to main content

bijux_cli/features/diagnostics/
state_paths.rs

1//! Runtime state path resolution and diagnostics helpers.
2
3use std::collections::HashMap;
4use std::env;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use anyhow::Result;
9use serde_json::{json, Value};
10
11use super::StatePathStatus;
12use crate::features::config::storage::{ConfigRepository, FileConfigRepository};
13use crate::features::install::{
14    default_compatibility_paths, discover_compatibility_paths, load_compatibility_config,
15    CompatibilityConfig, CompatibilityError, PathOverrides, ENV_CONFIG_PATH, ENV_HISTORY_PATH,
16    ENV_PLUGINS_PATH,
17};
18use crate::features::plugins::{
19    plugin_doctor, prune_registry_backup, registry_path_from_plugins_dir, self_repair_registry,
20    PluginError,
21};
22use crate::infrastructure::state_store::{read_history_report, read_memory_map};
23use crate::routing::parser::ParsedGlobalFlags;
24
25fn non_empty_env_value(name: &str) -> Option<String> {
26    env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
27}
28
29fn home_dir_from_env(
30    home: Option<&str>,
31    user_profile: Option<&str>,
32    home_drive: Option<&str>,
33    home_path: Option<&str>,
34    fallback_current_dir: PathBuf,
35) -> (PathBuf, Option<String>) {
36    if let Some(value) = home {
37        return (PathBuf::from(value), None);
38    }
39    if let Some(value) = user_profile {
40        return (
41            PathBuf::from(value),
42            Some(format!("HOME is unset; resolved state paths from USERPROFILE ({value})")),
43        );
44    }
45    if let (Some(drive), Some(path)) = (home_drive, home_path) {
46        let resolved = PathBuf::from(format!("{drive}{path}"));
47        return (
48            resolved.clone(),
49            Some(format!(
50                "HOME and USERPROFILE are unset; resolved state paths from HOMEDRIVE/HOMEPATH ({})",
51                resolved.display()
52            )),
53        );
54    }
55    (
56        fallback_current_dir.clone(),
57        Some(format!(
58            "HOME, USERPROFILE, and HOMEDRIVE/HOMEPATH are unset; resolved state paths from current directory ({})",
59            fallback_current_dir.display()
60        )),
61    )
62}
63
64fn resolved_home_dir() -> (PathBuf, Option<String>) {
65    let fallback_current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
66    home_dir_from_env(
67        non_empty_env_value("HOME").as_deref(),
68        non_empty_env_value("USERPROFILE").as_deref(),
69        non_empty_env_value("HOMEDRIVE").as_deref(),
70        non_empty_env_value("HOMEPATH").as_deref(),
71        fallback_current_dir,
72    )
73}
74
75fn merge_warnings(primary: Option<String>, secondary: Option<String>) -> Option<String> {
76    match (primary, secondary) {
77        (Some(first), Some(second)) => Some(format!("{first}; {second}")),
78        (Some(first), None) => Some(first),
79        (None, Some(second)) => Some(second),
80        (None, None) => None,
81    }
82}
83
84/// Collect runtime path override environment variables.
85#[must_use]
86pub fn env_map() -> HashMap<String, String> {
87    [ENV_CONFIG_PATH, ENV_HISTORY_PATH, ENV_PLUGINS_PATH]
88        .iter()
89        .filter_map(|key| non_empty_env_value(key).map(|value| ((*key).to_string(), value)))
90        .collect()
91}
92
93/// Runtime state path set resolved from defaults, compatibility, env, and flags.
94#[derive(Debug, Clone)]
95pub struct ResolvedStatePaths {
96    /// Resolved config file path.
97    pub config_file: PathBuf,
98    /// Resolved history file path.
99    pub history_file: PathBuf,
100    /// Resolved plugins directory path.
101    pub plugins_dir: PathBuf,
102    /// Resolved plugin registry file path.
103    pub plugin_registry_file: PathBuf,
104    /// Resolved memory file path.
105    pub memory_file: PathBuf,
106    /// Compatibility config file path used during discovery.
107    pub compatibility_config_file: PathBuf,
108    /// Compatibility override parse warning (if fallback defaults were used).
109    pub compatibility_config_warning: Option<String>,
110}
111
112/// Resolve runtime state file paths from defaults, compatibility config, env, and flags.
113pub fn resolve_state_paths(flags: &ParsedGlobalFlags) -> Result<ResolvedStatePaths> {
114    let (effective_home, home_resolution_warning) = resolved_home_dir();
115    let defaults = default_compatibility_paths(&effective_home);
116
117    let compatibility_config_file = defaults.config_file.clone();
118    let (config, compatibility_parse_warning) =
119        match load_compatibility_config(&compatibility_config_file) {
120            Ok(config) => (config, None),
121            Err(error @ CompatibilityError::UnsupportedConfigKey(_))
122            | Err(error @ CompatibilityError::MalformedConfigLine { .. })
123            | Err(error @ CompatibilityError::DuplicateConfigKey { .. })
124            | Err(error @ CompatibilityError::EmptyConfigValue { .. }) => (
125                CompatibilityConfig::default(),
126                Some(format!(
127                    "compatibility override parsing failed for {}: {error}",
128                    compatibility_config_file.display()
129                )),
130            ),
131            Err(error) => return Err(error.into()),
132        };
133    let compatibility_config_warning =
134        merge_warnings(home_resolution_warning, compatibility_parse_warning);
135    let mut overrides = PathOverrides::default();
136    if let Some(path) = &flags.config_path {
137        overrides.config_file = Some(path.into());
138    }
139
140    let resolved = discover_compatibility_paths(
141        Some(effective_home.as_path()),
142        &overrides,
143        &env_map(),
144        &config,
145    )?;
146    let plugin_registry_file = registry_path_from_plugins_dir(&resolved.plugins_dir);
147    let memory_file = resolved
148        .config_file
149        .parent()
150        .map(|dir| dir.join(".memory.json"))
151        .unwrap_or_else(|| Path::new(".").join(".bijux").join(".memory.json"));
152
153    Ok(ResolvedStatePaths {
154        config_file: resolved.config_file,
155        history_file: resolved.history_file,
156        plugins_dir: resolved.plugins_dir,
157        plugin_registry_file,
158        memory_file,
159        compatibility_config_file,
160        compatibility_config_warning,
161    })
162}
163
164/// Convert path status into JSON payload shape used by maintainer reports.
165#[must_use]
166pub fn state_path_status_value(status: &StatePathStatus) -> Value {
167    json!({
168        "path": status.path,
169        "exists": status.exists,
170        "is_file": status.is_file,
171        "is_dir": status.is_dir,
172        "size_bytes": status.size_bytes,
173        "readable": status.readable,
174        "writable": status.writable,
175    })
176}
177
178/// Build state diagnostics and repair actions from resolved runtime state paths.
179#[must_use]
180pub fn state_diagnostics(paths: &ResolvedStatePaths) -> Value {
181    let mut issues = Vec::<Value>::new();
182    let mut repairs = Vec::<Value>::new();
183
184    if let Some(message) = &paths.compatibility_config_warning {
185        issues.push(json!({
186            "area": "paths",
187            "severity": "warning",
188            "message": message,
189            "path": paths.compatibility_config_file,
190        }));
191    }
192
193    let repository = FileConfigRepository;
194    if let Err(err) = repository.load(&paths.config_file) {
195        issues.push(json!({
196            "area": "config",
197            "severity": "error",
198            "message": err.to_string(),
199            "path": paths.config_file,
200        }));
201    }
202    if let Ok(text) = fs::read_to_string(&paths.config_file) {
203        let mut seen = std::collections::BTreeMap::<String, usize>::new();
204        for line in
205            text.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with('#'))
206        {
207            if let Some((left, _)) = line.split_once('=') {
208                *seen.entry(left.trim().to_string()).or_insert(0) += 1;
209            }
210        }
211        let duplicates: Vec<String> =
212            seen.into_iter().filter_map(|(key, count)| (count > 1).then_some(key)).collect();
213        if !duplicates.is_empty() {
214            issues.push(json!({
215                "area": "config",
216                "severity": "error",
217                "message": "duplicate config keys found",
218                "keys": duplicates,
219                "path": paths.config_file,
220            }));
221        }
222    }
223
224    let config_tmp = paths.config_file.with_extension("tmp");
225    if config_tmp.exists() {
226        issues.push(json!({
227            "area": "config",
228            "severity": "warning",
229            "message": "partial-write rollback artifact detected",
230            "path": config_tmp,
231        }));
232    }
233
234    match read_history_report(&paths.history_file, 20) {
235        Ok(history_report) => {
236            if history_report.dropped_invalid_entries > 0 {
237                issues.push(json!({
238                    "area": "history",
239                    "severity": "warning",
240                    "message": "history file contains invalid entries that were ignored",
241                    "dropped_invalid_entries": history_report.dropped_invalid_entries,
242                    "accepted_entries": history_report.total_entries,
243                    "observed_entries": history_report.observed_entries,
244                    "path": paths.history_file,
245                }));
246            }
247            if history_report.truncated_command_entries > 0 {
248                issues.push(json!({
249                    "area": "history",
250                    "severity": "warning",
251                    "message": "history file contains commands that exceeded the command size budget",
252                    "truncated_command_entries": history_report.truncated_command_entries,
253                    "accepted_entries": history_report.total_entries,
254                    "observed_entries": history_report.observed_entries,
255                    "path": paths.history_file,
256                }));
257            }
258            if matches!(history_report.source_format, "legacy-lines" | "legacy-json-lines") {
259                issues.push(json!({
260                    "area": "history",
261                    "severity": "warning",
262                    "message": "history file uses legacy layout; rewrite as a JSON array for deterministic behavior",
263                    "source_format": history_report.source_format,
264                    "accepted_entries": history_report.total_entries,
265                    "observed_entries": history_report.observed_entries,
266                    "path": paths.history_file,
267                }));
268            }
269        }
270        Err(err) => {
271            issues.push(json!({
272                "area": "history",
273                "severity": "error",
274                "message": err.to_string(),
275                "path": paths.history_file,
276            }));
277        }
278    }
279
280    match read_memory_map(&paths.memory_file) {
281        Ok(memory) => {
282            let wrong_type_keys: Vec<String> = memory
283                .iter()
284                .filter_map(|(key, value)| {
285                    (!(value.is_string() || value.is_object())).then_some(key.clone())
286                })
287                .collect();
288            if !wrong_type_keys.is_empty() {
289                issues.push(json!({
290                    "area": "memory",
291                    "severity": "warning",
292                    "message": "memory entries with wrong-type values detected",
293                    "keys": wrong_type_keys,
294                    "path": paths.memory_file,
295                }));
296            }
297        }
298        Err(err) => {
299            issues.push(json!({
300                "area": "memory",
301                "severity": "error",
302                "message": err.to_string(),
303                "path": paths.memory_file,
304            }));
305        }
306    }
307
308    let mut repaired_corrupted_registry = false;
309    if let Err(err) = plugin_doctor(&paths.plugin_registry_file) {
310        repaired_corrupted_registry = matches!(err, PluginError::RegistryCorrupted);
311        issues.push(json!({
312            "area": "plugins",
313            "severity": "error",
314            "message": err.to_string(),
315            "path": paths.plugin_registry_file,
316        }));
317    }
318
319    if self_repair_registry(&paths.plugin_registry_file).is_ok() {
320        if repaired_corrupted_registry {
321            repairs.push(json!({
322                "area": "plugins",
323                "action": "repaired-corrupted-registry",
324                "path": paths.plugin_registry_file,
325            }));
326        }
327        if let Ok(true) = prune_registry_backup(&paths.plugin_registry_file) {
328            repairs.push(json!({
329                "area": "plugins",
330                "action": "removed-stale-backup",
331                "path": paths.plugin_registry_file.with_extension("bak"),
332            }));
333        }
334    }
335
336    json!({
337        "status": if issues.is_empty() { "healthy" } else { "degraded" },
338        "issues": issues,
339        "repairs": repairs,
340    })
341}
342
343#[cfg(test)]
344mod tests {
345    use std::path::PathBuf;
346
347    use super::home_dir_from_env;
348
349    #[test]
350    fn home_resolution_prefers_home_without_warning() {
351        let (path, warning) = home_dir_from_env(
352            Some("/tmp/home"),
353            Some("/tmp/profile"),
354            Some("C:"),
355            Some("\\Users\\profile"),
356            PathBuf::from("/tmp/fallback"),
357        );
358        assert_eq!(path, PathBuf::from("/tmp/home"));
359        assert!(warning.is_none());
360    }
361
362    #[test]
363    fn home_resolution_uses_userprofile_when_home_is_missing() {
364        let (path, warning) = home_dir_from_env(
365            None,
366            Some(r"C:\Users\profile"),
367            Some("C:"),
368            Some("\\Users\\profile"),
369            PathBuf::from("."),
370        );
371        assert_eq!(path, PathBuf::from(r"C:\Users\profile"));
372        assert!(warning.as_deref().is_some_and(|value| value.contains("USERPROFILE")));
373    }
374
375    #[test]
376    fn home_resolution_uses_homedrive_and_homepath_when_others_are_missing() {
377        let (path, warning) =
378            home_dir_from_env(None, None, Some("D:"), Some("\\Work\\User"), PathBuf::from("."));
379        assert_eq!(path, PathBuf::from(r"D:\Work\User"));
380        assert!(warning.as_deref().is_some_and(|value| value.contains("HOMEDRIVE/HOMEPATH")));
381    }
382
383    #[test]
384    fn home_resolution_falls_back_to_current_directory_with_warning() {
385        let fallback = PathBuf::from("/tmp/fallback");
386        let (path, warning) = home_dir_from_env(None, None, None, None, fallback.clone());
387        assert_eq!(path, fallback);
388        assert!(warning.as_deref().is_some_and(|value| value.contains("current directory")));
389    }
390}