1use crate::cli::Shell;
6use crate::config::ConfigLoader;
7use crate::storage::Paths;
8use std::fs;
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum CheckStatus {
14 Pass,
16 Warn,
18 Fail,
20}
21
22impl CheckStatus {
23 #[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#[derive(Debug, Clone)]
36pub struct CheckResult {
37 pub name: &'static str,
39 pub status: CheckStatus,
41 pub message: String,
43 pub fix_hint: Option<String>,
45}
46
47#[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#[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#[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#[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 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#[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#[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#[must_use]
250pub fn check_storage_writable() -> CheckResult {
251 let paths = Paths::new();
252
253 if !paths.data_dir.exists() {
254 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#[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 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 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#[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}