Skip to main content

rec/doctor/
checks.rs

1//! Individual diagnostic check functions.
2//!
3//! Each function performs a single diagnostic check and returns a [`CheckResult`].
4
5use crate::cli::Shell;
6use crate::config::ConfigLoader;
7use crate::storage::Paths;
8use std::fs;
9use std::path::PathBuf;
10
11/// Status of a diagnostic check.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum CheckStatus {
14    /// Check passed successfully.
15    Pass,
16    /// Check passed with a non-critical warning.
17    Warn,
18    /// Check failed — action needed.
19    Fail,
20}
21
22impl CheckStatus {
23    /// Return the status as a lowercase string for JSON output.
24    #[must_use]
25    pub fn as_str(&self) -> &'static str {
26        match self {
27            CheckStatus::Pass => "pass",
28            CheckStatus::Warn => "warn",
29            CheckStatus::Fail => "fail",
30        }
31    }
32}
33
34/// Result of a single diagnostic check.
35#[derive(Debug, Clone)]
36pub struct CheckResult {
37    /// Human-readable name of the check.
38    pub name: &'static str,
39    /// Pass, warn, or fail.
40    pub status: CheckStatus,
41    /// Descriptive message about the check outcome.
42    pub message: String,
43    /// Actionable fix hint shown on failure.
44    pub fix_hint: Option<String>,
45}
46
47/// Report the rec binary version (always Pass, informational).
48#[must_use]
49pub fn check_rec_version() -> CheckResult {
50    let version = env!("CARGO_PKG_VERSION");
51    CheckResult {
52        name: "rec version",
53        status: CheckStatus::Pass,
54        message: version.to_string(),
55        fix_hint: None,
56    }
57}
58
59/// Verify the `rec` binary is in a standard PATH location.
60#[must_use]
61pub fn check_rec_in_path() -> CheckResult {
62    match std::env::current_exe() {
63        Ok(exe) => {
64            let exe_dir = exe.parent().map(std::path::Path::to_path_buf);
65            let in_path = exe_dir
66                .as_ref()
67                .and_then(|dir| {
68                    std::env::var("PATH")
69                        .ok()
70                        .map(|path_var| std::env::split_paths(&path_var).any(|p| p == *dir))
71                })
72                .unwrap_or(false);
73
74            if in_path {
75                CheckResult {
76                    name: "rec in PATH",
77                    status: CheckStatus::Pass,
78                    message: exe.display().to_string(),
79                    fix_hint: None,
80                }
81            } else {
82                let dir_display = exe_dir
83                    .as_ref()
84                    .map_or_else(|| "(unknown)".to_string(), |d| d.display().to_string());
85                CheckResult {
86                    name: "rec in PATH",
87                    status: CheckStatus::Warn,
88                    message: format!("{} is not in a PATH directory", exe.display()),
89                    fix_hint: Some(format!("Add {dir_display} to your PATH")),
90                }
91            }
92        }
93        Err(e) => CheckResult {
94            name: "rec in PATH",
95            status: CheckStatus::Fail,
96            message: format!("Could not determine binary location: {e}"),
97            fix_hint: Some("Ensure rec is installed correctly".to_string()),
98        },
99    }
100}
101
102/// Detect the current shell via `Shell::detect()`.
103#[must_use]
104pub fn check_shell_detected() -> CheckResult {
105    if let Some(shell) = Shell::detect() {
106        CheckResult {
107            name: "Shell detected",
108            status: CheckStatus::Pass,
109            message: shell.name().to_string(),
110            fix_hint: None,
111        }
112    } else {
113        let shell_var = std::env::var("SHELL").unwrap_or_default();
114        let message = if shell_var.is_empty() {
115            "$SHELL not set".to_string()
116        } else {
117            format!("Unknown shell: {shell_var}")
118        };
119        CheckResult {
120            name: "Shell detected",
121            status: CheckStatus::Warn,
122            message,
123            fix_hint: Some("Set $SHELL to bash, zsh, or fish".to_string()),
124        }
125    }
126}
127
128/// Check if shell hooks are installed by scanning RC files for `rec init`.
129#[must_use]
130pub fn check_shell_hooks_installed() -> CheckResult {
131    let Some(shell) = Shell::detect() else {
132        return CheckResult {
133            name: "Shell hooks",
134            status: CheckStatus::Warn,
135            message: "Cannot check hooks — shell not detected".to_string(),
136            fix_hint: Some("Detect your shell first (set $SHELL)".to_string()),
137        };
138    };
139
140    let home = match directories::BaseDirs::new() {
141        Some(base) => base.home_dir().to_path_buf(),
142        None => {
143            return CheckResult {
144                name: "Shell hooks",
145                status: CheckStatus::Fail,
146                message: "Cannot determine home directory".to_string(),
147                fix_hint: Some("Set $HOME environment variable".to_string()),
148            };
149        }
150    };
151
152    // Candidate RC files per shell
153    let rc_candidates: Vec<PathBuf> = match shell {
154        Shell::Bash => vec![
155            home.join(".bashrc"),
156            home.join(".bash_profile"),
157            home.join(".profile"),
158        ],
159        Shell::Zsh => vec![home.join(".zshrc"), home.join(".zprofile")],
160        Shell::Fish => vec![home.join(".config/fish/config.fish")],
161    };
162
163    for rc_path in &rc_candidates {
164        if let Ok(contents) = fs::read_to_string(rc_path) {
165            if contents.contains("rec init") {
166                return CheckResult {
167                    name: "Shell hooks",
168                    status: CheckStatus::Pass,
169                    message: format!("found in {}", rc_path.display()),
170                    fix_hint: None,
171                };
172            }
173        }
174    }
175
176    let rc_file = shell.rc_file();
177    CheckResult {
178        name: "Shell hooks",
179        status: CheckStatus::Fail,
180        message: format!("not found in {rc_file}"),
181        fix_hint: Some(format!(
182            "Add to {}: eval \"$(rec init {})\"",
183            rc_file,
184            shell.name()
185        )),
186    }
187}
188
189/// Validate the configuration file via `ConfigLoader`.
190#[must_use]
191pub fn check_config_valid() -> CheckResult {
192    let paths = Paths::new();
193    let loader = ConfigLoader::new(paths.clone());
194
195    if !paths.config_file.exists() {
196        return CheckResult {
197            name: "Config file",
198            status: CheckStatus::Pass,
199            message: "no config file (using defaults)".to_string(),
200            fix_hint: None,
201        };
202    }
203
204    match loader.load() {
205        Ok(_) => CheckResult {
206            name: "Config file",
207            status: CheckStatus::Pass,
208            message: "valid".to_string(),
209            fix_hint: None,
210        },
211        Err(e) => CheckResult {
212            name: "Config file",
213            status: CheckStatus::Fail,
214            message: format!("parse error: {e}"),
215            fix_hint: Some(format!(
216                "Fix {} or delete it to use defaults",
217                paths.config_file.display()
218            )),
219        },
220    }
221}
222
223/// Check if the data directory exists.
224#[must_use]
225pub fn check_storage_dir_exists() -> CheckResult {
226    let paths = Paths::new();
227
228    if paths.data_dir.exists() {
229        CheckResult {
230            name: "Storage directory",
231            status: CheckStatus::Pass,
232            message: paths.data_dir.display().to_string(),
233            fix_hint: None,
234        }
235    } else {
236        CheckResult {
237            name: "Storage directory",
238            status: CheckStatus::Warn,
239            message: format!("{} does not exist yet", paths.data_dir.display()),
240            fix_hint: Some(format!(
241                "Will be created on first recording. Or run: mkdir -p {}",
242                paths.data_dir.display()
243            )),
244        }
245    }
246}
247
248/// Check if the data directory is writable by creating and removing a test file.
249#[must_use]
250pub fn check_storage_writable() -> CheckResult {
251    let paths = Paths::new();
252
253    if !paths.data_dir.exists() {
254        // Try to create it
255        if let Err(e) = fs::create_dir_all(&paths.data_dir) {
256            return CheckResult {
257                name: "Storage writable",
258                status: CheckStatus::Fail,
259                message: format!("Cannot create {}: {}", paths.data_dir.display(), e),
260                fix_hint: Some(format!(
261                    "Check permissions on parent directory of {}",
262                    paths.data_dir.display()
263                )),
264            };
265        }
266    }
267
268    let test_file = paths.data_dir.join(".write-test");
269    match fs::write(&test_file, "test") {
270        Ok(()) => {
271            let _ = fs::remove_file(&test_file);
272            CheckResult {
273                name: "Storage writable",
274                status: CheckStatus::Pass,
275                message: "writable".to_string(),
276                fix_hint: None,
277            }
278        }
279        Err(e) => CheckResult {
280            name: "Storage writable",
281            status: CheckStatus::Fail,
282            message: format!("Cannot write to {}: {}", paths.data_dir.display(), e),
283            fix_hint: Some(format!(
284                "Fix permissions: chmod u+w {}",
285                paths.data_dir.display()
286            )),
287        },
288    }
289}
290
291/// Check if the shell RC file is writable.
292#[must_use]
293pub fn check_rc_file_writable() -> CheckResult {
294    let Some(shell) = Shell::detect() else {
295        return CheckResult {
296            name: "RC file writable",
297            status: CheckStatus::Warn,
298            message: "Cannot check — shell not detected".to_string(),
299            fix_hint: Some("Detect your shell first (set $SHELL)".to_string()),
300        };
301    };
302
303    let home = match directories::BaseDirs::new() {
304        Some(base) => base.home_dir().to_path_buf(),
305        None => {
306            return CheckResult {
307                name: "RC file writable",
308                status: CheckStatus::Warn,
309                message: "Cannot determine home directory".to_string(),
310                fix_hint: Some("Set $HOME environment variable".to_string()),
311            };
312        }
313    };
314
315    // Resolve ~ in rc_file path
316    let rc_path = match shell {
317        Shell::Bash => home.join(".bashrc"),
318        Shell::Zsh => home.join(".zshrc"),
319        Shell::Fish => home.join(".config/fish/config.fish"),
320    };
321
322    if !rc_path.exists() {
323        return CheckResult {
324            name: "RC file writable",
325            status: CheckStatus::Pass,
326            message: format!("{} does not exist (will be created)", rc_path.display()),
327            fix_hint: None,
328        };
329    }
330
331    // Check write permission via metadata
332    match fs::metadata(&rc_path) {
333        Ok(meta) => {
334            if meta.permissions().readonly() {
335                CheckResult {
336                    name: "RC file writable",
337                    status: CheckStatus::Warn,
338                    message: format!("{} is read-only", rc_path.display()),
339                    fix_hint: Some(format!("Fix permissions: chmod u+w {}", rc_path.display())),
340                }
341            } else {
342                CheckResult {
343                    name: "RC file writable",
344                    status: CheckStatus::Pass,
345                    message: format!("{}", rc_path.display()),
346                    fix_hint: None,
347                }
348            }
349        }
350        Err(e) => CheckResult {
351            name: "RC file writable",
352            status: CheckStatus::Warn,
353            message: format!("Cannot read metadata for {}: {}", rc_path.display(), e),
354            fix_hint: Some(format!("Check permissions on {}", rc_path.display())),
355        },
356    }
357}
358
359/// Verify the data directory has correct read+write permissions.
360#[must_use]
361pub fn check_data_dir_permissions() -> CheckResult {
362    let paths = Paths::new();
363
364    if !paths.data_dir.exists() {
365        return CheckResult {
366            name: "Data dir permissions",
367            status: CheckStatus::Pass,
368            message: "directory not yet created (OK)".to_string(),
369            fix_hint: None,
370        };
371    }
372
373    match fs::metadata(&paths.data_dir) {
374        Ok(meta) => {
375            if meta.permissions().readonly() {
376                CheckResult {
377                    name: "Data dir permissions",
378                    status: CheckStatus::Fail,
379                    message: format!("{} is read-only", paths.data_dir.display()),
380                    fix_hint: Some(format!(
381                        "Fix permissions: chmod u+rwx {}",
382                        paths.data_dir.display()
383                    )),
384                }
385            } else {
386                CheckResult {
387                    name: "Data dir permissions",
388                    status: CheckStatus::Pass,
389                    message: "read/write OK".to_string(),
390                    fix_hint: None,
391                }
392            }
393        }
394        Err(e) => CheckResult {
395            name: "Data dir permissions",
396            status: CheckStatus::Fail,
397            message: format!(
398                "Cannot read metadata for {}: {}",
399                paths.data_dir.display(),
400                e
401            ),
402            fix_hint: Some(format!("Check permissions on {}", paths.data_dir.display())),
403        },
404    }
405}