Skip to main content

run_stack/
doctor.rs

1//! Checks for the faults that cost real debugging time, and repairs for the
2//! ones whose correct value is discoverable.
3//!
4//! The motivating case: Metro runs inside the container, but file events do not
5//! cross the bind mount from the host, so its watcher never sees an edit. The
6//! entrypoint's poker exists to bridge that, and a stale `METRO_POKE=false`
7//! baked into a container turns it off silently — every change then needs a
8//! cache clear, with nothing to say why.
9
10use std::fs;
11use std::path::Path;
12use std::process::Command;
13
14use anyhow::Result;
15
16use crate::env::Env;
17use crate::workspace::Workspace;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Status {
21    Ok,
22    Warn,
23    Fail,
24}
25
26impl Status {
27    pub fn mark(self) -> &'static str {
28        match self {
29            Status::Ok => "✓",
30            Status::Warn => "!",
31            Status::Fail => "✗",
32        }
33    }
34}
35
36#[derive(Debug, Clone)]
37pub struct Check {
38    pub name: String,
39    pub status: Status,
40    pub detail: String,
41    /// Set when `--fix` can repair this without guessing.
42    pub fixable: bool,
43}
44
45impl Check {
46    fn ok(name: &str, detail: impl Into<String>) -> Self {
47        Self { name: name.into(), status: Status::Ok, detail: detail.into(), fixable: false }
48    }
49
50    fn warn(name: &str, detail: impl Into<String>) -> Self {
51        Self { name: name.into(), status: Status::Warn, detail: detail.into(), fixable: false }
52    }
53
54    fn fixable(name: &str, status: Status, detail: impl Into<String>) -> Self {
55        Self { name: name.into(), status, detail: detail.into(), fixable: true }
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Repair {
61    Applied,
62    Skipped,
63    Failed,
64}
65
66impl Repair {
67    fn mark(self) -> &'static str {
68        match self {
69            Repair::Applied => "✓",
70            Repair::Skipped => "·",
71            Repair::Failed => "✗",
72        }
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct Action {
78    pub name: String,
79    pub outcome: Repair,
80    pub detail: String,
81}
82
83impl Action {
84    fn applied(name: &str, detail: impl Into<String>) -> Self {
85        Self { name: name.into(), outcome: Repair::Applied, detail: detail.into() }
86    }
87
88    fn skipped(name: &str, detail: impl Into<String>) -> Self {
89        Self { name: name.into(), outcome: Repair::Skipped, detail: detail.into() }
90    }
91
92    fn failed(name: &str, detail: impl Into<String>) -> Self {
93        Self { name: name.into(), outcome: Repair::Failed, detail: detail.into() }
94    }
95}
96
97pub const METRO_POKE: &str = "METRO_POKE";
98
99/// Does this workspace run a mobile app whose Metro lives in a container?
100pub fn runs_mobile(env: &Env) -> bool {
101    match env.get("RUN_MOBILE") {
102        Some(value) => Env::truthy(value),
103        None => false,
104    }
105}
106
107pub fn docker_available() -> bool {
108    Command::new("docker")
109        .args(["compose", "version"])
110        .output()
111        .map(|output| output.status.success())
112        .unwrap_or(false)
113}
114
115/// `METRO_POKE` as the named container actually has it, which is not always
116/// what the env file says: a container keeps the environment it was created
117/// with until it is recreated.
118pub fn container_metro_poke(container: &str) -> Option<String> {
119    let output = Command::new("docker")
120        .args(["inspect", container, "--format", "{{range .Config.Env}}{{println .}}{{end}}"])
121        .output()
122        .ok()?;
123    if !output.status.success() {
124        return None;
125    }
126    let text = String::from_utf8_lossy(&output.stdout);
127    text.lines()
128        .find_map(|line| line.strip_prefix(&format!("{METRO_POKE}=")))
129        .map(|value| value.trim().to_string())
130}
131
132pub fn running_metro_containers() -> Vec<String> {
133    let output = match Command::new("docker")
134        .args(["ps", "--format", "{{.Names}}"])
135        .output()
136    {
137        Ok(output) if output.status.success() => output,
138        _ => return Vec::new(),
139    };
140    String::from_utf8_lossy(&output.stdout)
141        .lines()
142        .filter(|name| name.contains("mobile"))
143        .map(str::to_string)
144        .collect()
145}
146
147pub fn check_metro_poke(env: &Env, containers: &[String]) -> Vec<Check> {
148    if !runs_mobile(env) {
149        return vec![Check::ok("metro poke", "workspace runs no mobile app")];
150    }
151
152    let mut checks = Vec::new();
153    let configured = env.get(METRO_POKE);
154    match configured {
155        Some(value) if !Env::truthy(value) => checks.push(Check::fixable(
156            "metro poke",
157            Status::Fail,
158            format!("{METRO_POKE}={value} — Metro cannot see host edits, so every change needs a cache clear"),
159        )),
160        Some(_) => checks.push(Check::ok("metro poke", format!("{METRO_POKE} enabled"))),
161        None => checks.push(Check::ok("metro poke", "unset — defaults to enabled")),
162    }
163
164    // A container keeps its creation-time environment, so the file can be right
165    // while the thing actually running is still wrong.
166    for container in containers {
167        match container_metro_poke(container) {
168            Some(value) if !Env::truthy(&value) => checks.push(Check::warn(
169                "metro poke (running)",
170                format!("{container} was created with {METRO_POKE}={value} — recreate it to pick up the fix"),
171            )),
172            Some(_) => checks.push(Check::ok("metro poke (running)", format!("{container} has it enabled"))),
173            None => {}
174        }
175    }
176    checks
177}
178
179/// Only the overlay `generate` always writes. docker-compose.extra.yml is
180/// written per extra app, so a workspace with none legitimately has no such
181/// file — demanding it would be a failure no repair could ever clear.
182pub fn check_overlays(run_dir: &Path) -> Vec<Check> {
183    if run_dir.join("docker-compose.packages.yml").is_file() {
184        return vec![Check::ok("overlays", "generated compose overlays present")];
185    }
186    vec![Check::fixable(
187        "overlays",
188        Status::Fail,
189        "missing: docker-compose.packages.yml — run `rst generate`".to_string(),
190    )]
191}
192
193pub fn check_docker() -> Vec<Check> {
194    if docker_available() {
195        vec![Check::ok("docker", "docker compose v2 available")]
196    } else {
197        vec![Check::warn("docker", "'docker compose' unavailable — is docker running?")]
198    }
199}
200
201pub fn run(workspace: &Workspace) -> Result<Vec<Check>> {
202    let env = Env::load(&workspace.env_path())?;
203    let containers = if docker_available() { running_metro_containers() } else { Vec::new() };
204
205    let mut checks = check_docker();
206    checks.extend(check_metro_poke(&env, &containers));
207    checks.extend(check_overlays(&workspace.run_dir));
208    Ok(checks)
209}
210
211/// Set `key` in a .env file, preserving comments and the order of what is
212/// already there. Appends with a note when the key is absent.
213pub fn set_env_key(path: &Path, key: &str, value: &str, note: &str) -> Result<()> {
214    let text = fs::read_to_string(path).unwrap_or_default();
215    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
216
217    let existing = lines.iter().position(|line| {
218        let trimmed = line.trim().strip_prefix("export ").unwrap_or(line.trim());
219        trimmed
220            .split_once('=')
221            .map(|(name, _)| name.trim() == key)
222            .unwrap_or(false)
223    });
224
225    match existing {
226        Some(index) => lines[index] = format!("{key}={value}"),
227        None => {
228            if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
229                lines.push(String::new());
230            }
231            for line in note.lines() {
232                lines.push(format!("# {line}"));
233            }
234            lines.push(format!("{key}={value}"));
235        }
236    }
237
238    fs::write(path, format!("{}\n", lines.join("\n")))?;
239    Ok(())
240}
241
242const POKE_NOTE: &str = "Metro runs in the container, and file events do not cross the bind mount,\nso its watcher never sees host edits. The poker re-touches changed files from\ninside the container, which does raise a real event.";
243
244/// Regenerating the overlays is `rst generate`; doctor calls the same code so
245/// a missing overlay is repaired rather than merely reported.
246fn fix_overlays(workspace: &Workspace, dry_run: bool) -> Action {
247    let missing = check_overlays(&workspace.run_dir)
248        .into_iter()
249        .any(|check| check.status != Status::Ok);
250    if !missing {
251        return Action::skipped("overlays", "already generated");
252    }
253    if dry_run {
254        return Action::applied("overlays", "would regenerate the compose overlays");
255    }
256
257    let mut env = match Env::load(&workspace.env_path()) {
258        Ok(env) => env,
259        Err(error) => return Action::failed("overlays", error.to_string()),
260    };
261    env.derive(&workspace.root);
262
263    let package = match crate::compose::package_dir() {
264        Ok(dir) => dir,
265        Err(error) => return Action::failed("overlays", error.to_string()),
266    };
267    match crate::generate::all(&workspace.run_dir, &env, &package) {
268        Ok(()) => Action::applied("overlays", "regenerated the compose overlays"),
269        Err(error) => Action::failed("overlays", error.to_string()),
270    }
271}
272
273pub fn fix(workspace: &Workspace, dry_run: bool) -> Result<Vec<Action>> {
274    let env = Env::load(&workspace.env_path())?;
275    let mut actions = Vec::new();
276
277    if runs_mobile(&env) {
278        let configured = env.get(METRO_POKE);
279        let needs_fix = matches!(configured, Some(value) if !Env::truthy(value));
280        if needs_fix {
281            if dry_run {
282                actions.push(Action::applied("metro poke", format!("would set {METRO_POKE}=true")));
283            } else {
284                match set_env_key(&workspace.env_path(), METRO_POKE, "true", POKE_NOTE) {
285                    Ok(()) => actions.push(Action::applied(
286                        "metro poke",
287                        format!("set {METRO_POKE}=true — recreate the mobile container to apply it"),
288                    )),
289                    Err(error) => actions.push(Action::failed("metro poke", error.to_string())),
290                }
291            }
292        } else {
293            actions.push(Action::skipped("metro poke", "already enabled"));
294        }
295    } else {
296        actions.push(Action::skipped("metro poke", "workspace runs no mobile app"));
297    }
298
299    actions.push(fix_overlays(workspace, dry_run));
300    Ok(actions)
301}
302
303pub fn format_checks(checks: &[Check]) -> String {
304    let mut lines: Vec<String> = checks
305        .iter()
306        .map(|check| format!("  {} {}: {}", check.status.mark(), check.name, check.detail))
307        .collect();
308
309    let failures = checks.iter().filter(|c| c.status == Status::Fail).count();
310    let warnings = checks.iter().filter(|c| c.status == Status::Warn).count();
311    let fixable = checks.iter().filter(|c| c.fixable).count();
312
313    lines.push(String::new());
314    lines.push(if failures == 0 && warnings == 0 {
315        "all checks passed".to_string()
316    } else {
317        format!("{failures} failure(s), {warnings} warning(s)")
318    });
319    if fixable > 0 {
320        lines.push(format!("{fixable} can be repaired: rst doctor --fix"));
321    }
322    lines.join("\n")
323}
324
325pub fn format_actions(actions: &[Action], dry_run: bool) -> String {
326    let mut lines: Vec<String> = actions
327        .iter()
328        .map(|action| format!("  {} {}: {}", action.outcome.mark(), action.name, action.detail))
329        .collect();
330
331    let applied = actions.iter().filter(|a| a.outcome == Repair::Applied).count();
332    let failed = actions.iter().filter(|a| a.outcome == Repair::Failed).count();
333
334    lines.push(String::new());
335    lines.push(if failed > 0 {
336        format!("{applied} fixed, {failed} could not be fixed")
337    } else if applied > 0 && dry_run {
338        format!("{applied} would be fixed (dry run)")
339    } else if applied > 0 {
340        format!("{applied} fixed")
341    } else {
342        "nothing to fix".to_string()
343    });
344    lines.join("\n")
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn env_from(text: &str) -> Env {
352        let dir = tempfile::tempdir().unwrap();
353        let path = dir.path().join(".env");
354        fs::write(&path, text).unwrap();
355        Env::load(&path).unwrap()
356    }
357
358    #[test]
359    fn poke_disabled_is_a_fixable_failure() {
360        let env = env_from("RUN_MOBILE=true\nMETRO_POKE=false\n");
361        let checks = check_metro_poke(&env, &[]);
362
363        assert_eq!(checks[0].status, Status::Fail);
364        assert!(checks[0].fixable);
365        assert!(checks[0].detail.contains("cache clear"));
366    }
367
368    #[test]
369    fn poke_unset_defaults_to_enabled() {
370        let env = env_from("RUN_MOBILE=true\n");
371        let checks = check_metro_poke(&env, &[]);
372
373        assert_eq!(checks[0].status, Status::Ok);
374        assert!(!checks[0].fixable);
375    }
376
377    #[test]
378    fn poke_is_irrelevant_without_a_mobile_app() {
379        let env = env_from("RUN_MOBILE=false\nMETRO_POKE=false\n");
380        let checks = check_metro_poke(&env, &[]);
381
382        assert_eq!(checks.len(), 1);
383        assert_eq!(checks[0].status, Status::Ok);
384    }
385
386    #[test]
387    fn overlays_missing_is_fixable() {
388        let dir = tempfile::tempdir().unwrap();
389        let checks = check_overlays(dir.path());
390
391        assert_eq!(checks[0].status, Status::Fail);
392        assert!(checks[0].fixable);
393    }
394
395    #[test]
396    fn overlays_present_pass() {
397        let dir = tempfile::tempdir().unwrap();
398        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
399
400        assert_eq!(check_overlays(dir.path())[0].status, Status::Ok);
401    }
402
403    #[test]
404    fn a_workspace_with_no_extra_apps_is_not_a_failure() {
405        // docker-compose.extra.yml is written per extra app; demanding it would
406        // be a failure `--fix` could never clear.
407        let dir = tempfile::tempdir().unwrap();
408        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
409
410        let checks = check_overlays(dir.path());
411        assert_eq!(checks[0].status, Status::Ok);
412        assert!(!checks[0].fixable);
413    }
414
415    #[test]
416    fn set_env_key_replaces_in_place_and_keeps_comments() {
417        let dir = tempfile::tempdir().unwrap();
418        let path = dir.path().join(".env");
419        fs::write(&path, "# keep me\nRUN_MOBILE=true\nMETRO_POKE=false\nPORT=1\n").unwrap();
420
421        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
422
423        let text = fs::read_to_string(&path).unwrap();
424        assert!(text.contains("# keep me"));
425        assert!(text.contains("METRO_POKE=true"));
426        assert!(!text.contains("METRO_POKE=false"));
427        // Replaced where it stood, so surrounding settings keep their order.
428        assert!(text.find("RUN_MOBILE").unwrap() < text.find("METRO_POKE").unwrap());
429        assert!(text.find("METRO_POKE").unwrap() < text.find("PORT").unwrap());
430    }
431
432    #[test]
433    fn set_env_key_appends_with_the_reason_when_absent() {
434        let dir = tempfile::tempdir().unwrap();
435        let path = dir.path().join(".env");
436        fs::write(&path, "RUN_MOBILE=true\n").unwrap();
437
438        set_env_key(&path, METRO_POKE, "true", "first line\nsecond line").unwrap();
439
440        let text = fs::read_to_string(&path).unwrap();
441        assert!(text.contains("# first line"));
442        assert!(text.contains("# second line"));
443        assert!(text.contains("METRO_POKE=true"));
444        assert!(text.starts_with("RUN_MOBILE=true"));
445    }
446
447    #[test]
448    fn set_env_key_handles_an_exported_line() {
449        let dir = tempfile::tempdir().unwrap();
450        let path = dir.path().join(".env");
451        fs::write(&path, "export METRO_POKE=false\n").unwrap();
452
453        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
454
455        let text = fs::read_to_string(&path).unwrap();
456        assert!(text.contains("METRO_POKE=true"));
457        assert!(!text.contains("false"));
458    }
459
460    #[test]
461    fn a_similar_key_is_not_mistaken_for_the_real_one() {
462        let dir = tempfile::tempdir().unwrap();
463        let path = dir.path().join(".env");
464        fs::write(&path, "METRO_POKE_INTERVAL=1\n").unwrap();
465
466        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
467
468        let text = fs::read_to_string(&path).unwrap();
469        assert!(text.contains("METRO_POKE_INTERVAL=1"));
470        assert!(text.contains("\nMETRO_POKE=true"));
471    }
472
473    #[test]
474    fn report_points_at_the_fix_when_something_is_repairable() {
475        let checks = check_metro_poke(&env_from("RUN_MOBILE=true\nMETRO_POKE=false\n"), &[]);
476        let report = format_checks(&checks);
477
478        assert!(report.contains("rst doctor --fix"));
479        assert!(report.contains("1 failure(s)"));
480    }
481
482    #[test]
483    fn report_is_quiet_when_all_is_well() {
484        let report = format_checks(&[Check::ok("docker", "fine")]);
485
486        assert!(report.contains("all checks passed"));
487        assert!(!report.contains("--fix"));
488    }
489
490    #[test]
491    fn actions_report_distinguishes_a_dry_run() {
492        let applied = vec![Action::applied("metro poke", "would set it")];
493
494        assert!(format_actions(&applied, true).contains("would be fixed"));
495        assert!(format_actions(&applied, false).contains("1 fixed"));
496        assert!(format_actions(&[Action::skipped("x", "y")], false).contains("nothing to fix"));
497    }
498}