1use 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#[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#[derive(Debug, Clone)]
95pub struct ResolvedStatePaths {
96 pub config_file: PathBuf,
98 pub history_file: PathBuf,
100 pub plugins_dir: PathBuf,
102 pub plugin_registry_file: PathBuf,
104 pub memory_file: PathBuf,
106 pub compatibility_config_file: PathBuf,
108 pub compatibility_config_warning: Option<String>,
110}
111
112pub 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#[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#[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}