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.
182/// Directories beside the frontend repo that look like runnable apps.
183///
184/// `init --update` only scans FRONTEND_DIR/apps, so an app kept next to that
185/// repo is invisible to it: not in the bind mount, not in the pnpm workspace,
186/// never offered. These are the ones ROOT_APPS exists for.
187pub fn undeclared_root_apps(workspace: &Workspace, env: &Env) -> Vec<String> {
188    let declared: Vec<String> = crate::generate::root_apps(env)
189        .into_iter()
190        .map(|app| app.dir)
191        .collect();
192
193    let skip: Vec<String> = ["FRONTEND_DIR", "BACKEND_DIR"]
194        .iter()
195        .filter_map(|key| env.get(key))
196        .filter_map(|dir| {
197            Path::new(dir)
198                .file_name()
199                .map(|name| name.to_string_lossy().to_string())
200        })
201        .collect();
202
203    let entries = match std::fs::read_dir(&workspace.root) {
204        Ok(entries) => entries,
205        Err(_) => return Vec::new(),
206    };
207
208    let mut found: Vec<String> = entries
209        .flatten()
210        .filter(|entry| entry.path().is_dir())
211        .map(|entry| entry.file_name().to_string_lossy().to_string())
212        .filter(|name| !name.starts_with('.') && name != "node_modules")
213        .filter(|name| !skip.contains(name) && !declared.contains(name))
214        .filter(|name| runnable_app(&workspace.root.join(name)))
215        .collect();
216
217    found.sort();
218    found
219}
220
221/// A package.json with something to run. A library beside the repo is not an
222/// app, and offering it would be noise.
223fn runnable_app(dir: &Path) -> bool {
224    let manifest = dir.join("package.json");
225    let Ok(text) = std::fs::read_to_string(&manifest) else {
226        return false;
227    };
228    // Scripts are matched textually rather than with a JSON parser: this runs
229    // on every doctor call, and the shape being looked for is unambiguous.
230    let Some(scripts) = text.split("\"scripts\"").nth(1) else {
231        return false;
232    };
233    let block = scripts.split('}').next().unwrap_or("");
234    block.contains("\"dev\"") || block.contains("\"start\"")
235}
236
237pub fn check_root_apps(workspace: &Workspace, env: &Env) -> Vec<Check> {
238    let found = undeclared_root_apps(workspace, env);
239    if found.is_empty() {
240        return vec![Check::ok("root apps", "no unconfigured apps beside the frontend repo")];
241    }
242    vec![Check::fixable(
243        "root apps",
244        Status::Warn,
245        format!(
246            "not configured: {} - init --update only scans the frontend repo's apps/",
247            found.join(", ")
248        ),
249    )]
250}
251
252/// A .env line whose value holds unquoted spaces.
253///
254/// The shell implementation sources this file, so `FOO=a b` is not an
255/// assignment at all: bash reads `FOO=a` as a prefix and then *runs* `b`.
256/// The failure surfaces far from its cause - a command you never typed,
257/// reported against the directory you happened to be in.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct UnquotedLine {
260    pub number: usize,
261    pub key: String,
262    pub value: String,
263}
264
265pub fn unquoted_values(text: &str) -> Vec<UnquotedLine> {
266    text.lines()
267        .enumerate()
268        .filter_map(|(index, raw)| {
269            let line = raw.trim();
270            if line.is_empty() || line.starts_with('#') {
271                return None;
272            }
273            let line = line.strip_prefix("export ").unwrap_or(line);
274            let (key, value) = line.split_once('=')?;
275            let key = key.trim();
276            if key.is_empty() || !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
277                return None;
278            }
279
280            let value = value.trim();
281            // Already quoted, or nothing that can split into words.
282            if value.is_empty() || !value.contains(char::is_whitespace) {
283                return None;
284            }
285            if (value.starts_with('"') && value.ends_with('"') && value.len() > 1)
286                || (value.starts_with('\'') && value.ends_with('\'') && value.len() > 1)
287            {
288                return None;
289            }
290
291            Some(UnquotedLine {
292                number: index + 1,
293                key: key.to_string(),
294                value: value.to_string(),
295            })
296        })
297        .collect()
298}
299
300pub fn check_env_quoting(workspace: &Workspace) -> Vec<Check> {
301    let Ok(text) = fs::read_to_string(workspace.env_path()) else {
302        return vec![Check::ok("env quoting", "no .env to read")];
303    };
304
305    let loose = unquoted_values(&text);
306    if loose.is_empty() {
307        return vec![Check::ok("env quoting", "every value is a value, not a command")];
308    }
309
310    let names: Vec<String> = loose
311        .iter()
312        .map(|line| format!("{} (line {})", line.key, line.number))
313        .collect();
314    vec![Check::fixable(
315        "env quoting",
316        Status::Fail,
317        format!(
318            "unquoted spaces run as commands when .env is sourced: {}",
319            names.join(", ")
320        ),
321    )]
322}
323
324/// Wrap loose values in double quotes, leaving everything else byte for byte.
325fn fix_env_quoting(workspace: &Workspace, dry_run: bool) -> Action {
326    let path = workspace.env_path();
327    let Ok(text) = fs::read_to_string(&path) else {
328        return Action::skipped("env quoting", "no .env to read");
329    };
330
331    let loose = unquoted_values(&text);
332    if loose.is_empty() {
333        return Action::skipped("env quoting", "nothing to quote");
334    }
335    // A value already carrying a quote could be half-quoted in a way no
336    // mechanical rewrite can settle; leave those to a person.
337    let (safe, risky): (Vec<_>, Vec<_>) = loose
338        .iter()
339        .partition(|line| !line.value.contains('"') && !line.value.contains('\''));
340
341    if safe.is_empty() {
342        return Action::failed(
343            "env quoting",
344            format!("{} line(s) mix quotes - quote them by hand", risky.len()),
345        );
346    }
347    if dry_run {
348        return Action::applied(
349            "env quoting",
350            format!("would quote {}", safe.iter().map(|l| l.key.as_str()).collect::<Vec<_>>().join(", ")),
351        );
352    }
353
354    let numbers: Vec<usize> = safe.iter().map(|line| line.number).collect();
355    let rewritten: Vec<String> = text
356        .lines()
357        .enumerate()
358        .map(|(index, raw)| {
359            if !numbers.contains(&(index + 1)) {
360                return raw.to_string();
361            }
362            match raw.split_once('=') {
363                Some((key, value)) => format!("{key}=\"{}\"", value.trim()),
364                None => raw.to_string(),
365            }
366        })
367        .collect();
368
369    if let Err(error) = fs::write(&path, format!("{}\n", rewritten.join("\n"))) {
370        return Action::failed("env quoting", error.to_string());
371    }
372
373    let mut detail = format!(
374        "quoted {}",
375        safe.iter().map(|l| l.key.as_str()).collect::<Vec<_>>().join(", ")
376    );
377    if !risky.is_empty() {
378        detail.push_str(&format!("; {} mixing quotes left alone", risky.len()));
379    }
380    Action::applied("env quoting", detail)
381}
382
383pub fn check_overlays(run_dir: &Path) -> Vec<Check> {
384    if run_dir.join("docker-compose.packages.yml").is_file() {
385        return vec![Check::ok("overlays", "generated compose overlays present")];
386    }
387    vec![Check::fixable(
388        "overlays",
389        Status::Fail,
390        "missing: docker-compose.packages.yml - run `rst generate`".to_string(),
391    )]
392}
393
394pub fn check_docker() -> Vec<Check> {
395    if docker_available() {
396        vec![Check::ok("docker", "docker compose v2 available")]
397    } else {
398        vec![Check::warn("docker", "'docker compose' unavailable - is docker running?")]
399    }
400}
401
402pub fn run(workspace: &Workspace) -> Result<Vec<Check>> {
403    let env = Env::load(&workspace.env_path())?;
404    let containers = if docker_available() { running_metro_containers() } else { Vec::new() };
405
406    let mut checks = check_docker();
407    checks.extend(check_metro_poke(&env, &containers));
408    checks.extend(check_overlays(&workspace.run_dir));
409    checks.extend(check_root_apps(workspace, &env));
410    checks.extend(check_env_quoting(workspace));
411    Ok(checks)
412}
413
414/// Set `key` in a .env file, preserving comments and the order of what is
415/// already there. Appends with a note when the key is absent.
416pub fn set_env_key(path: &Path, key: &str, value: &str, note: &str) -> Result<()> {
417    let text = fs::read_to_string(path).unwrap_or_default();
418    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
419
420    let existing = lines.iter().position(|line| {
421        let trimmed = line.trim().strip_prefix("export ").unwrap_or(line.trim());
422        trimmed
423            .split_once('=')
424            .map(|(name, _)| name.trim() == key)
425            .unwrap_or(false)
426    });
427
428    // A value with spaces must be quoted or sourcing the file runs the rest of
429    // it as a command - the same fault `check_env_quoting` reports.
430    let value = if value.contains(char::is_whitespace) && !value.starts_with('"') {
431        format!("\"{value}\"")
432    } else {
433        value.to_string()
434    };
435
436    match existing {
437        Some(index) => lines[index] = format!("{key}={value}"),
438        None => {
439            if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
440                lines.push(String::new());
441            }
442            for line in note.lines() {
443                lines.push(format!("# {line}"));
444            }
445            lines.push(format!("{key}={value}"));
446        }
447    }
448
449    fs::write(path, format!("{}\n", lines.join("\n")))?;
450    Ok(())
451}
452
453const 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.";
454
455/// Regenerating the overlays is `rst generate`; doctor calls the same code so
456/// a missing overlay is repaired rather than merely reported.
457fn fix_overlays(workspace: &Workspace, dry_run: bool) -> Action {
458    let missing = check_overlays(&workspace.run_dir)
459        .into_iter()
460        .any(|check| check.status != Status::Ok);
461    if !missing {
462        return Action::skipped("overlays", "already generated");
463    }
464    if dry_run {
465        return Action::applied("overlays", "would regenerate the compose overlays");
466    }
467
468    let mut env = match Env::load(&workspace.env_path()) {
469        Ok(env) => env,
470        Err(error) => return Action::failed("overlays", error.to_string()),
471    };
472    env.derive(&workspace.root);
473
474    let package = match crate::compose::package_dir() {
475        Ok(dir) => dir,
476        Err(error) => return Action::failed("overlays", error.to_string()),
477    };
478    match crate::generate::all(&workspace.run_dir, &env, &package) {
479        Ok(()) => Action::applied("overlays", "regenerated the compose overlays"),
480        Err(error) => Action::failed("overlays", error.to_string()),
481    }
482}
483
484const ROOT_APPS_NOTE: &str = "Apps beside the frontend repo. They are outside its bind mount and its pnpm\nworkspace, so each gets its own mount and runs its own command.";
485
486/// Add apps found beside the frontend repo to ROOT_APPS.
487///
488/// The directory name becomes the entry; a name that differs from the
489/// directory is a `name:dir` pair the author writes by hand.
490fn fix_root_apps(workspace: &Workspace, dry_run: bool) -> Action {
491    let env = match Env::load(&workspace.env_path()) {
492        Ok(env) => env,
493        Err(error) => return Action::failed("root apps", error.to_string()),
494    };
495
496    let found = undeclared_root_apps(workspace, &env);
497    if found.is_empty() {
498        return Action::skipped("root apps", "nothing unconfigured beside the frontend repo");
499    }
500
501    let mut declared: Vec<String> = env
502        .get_or("ROOT_APPS", "")
503        .split_whitespace()
504        .map(str::to_string)
505        .collect();
506    declared.extend(found.iter().cloned());
507    let value = declared.join(" ");
508
509    if dry_run {
510        return Action::applied("root apps", format!("would add {} to ROOT_APPS", found.join(", ")));
511    }
512
513    match set_env_key(&workspace.env_path(), "ROOT_APPS", &value, ROOT_APPS_NOTE) {
514        Ok(()) => Action::applied(
515            "root apps",
516            format!("added {} to ROOT_APPS - run `rst up {}` to start", found.join(", "), found[0]),
517        ),
518        Err(error) => Action::failed("root apps", error.to_string()),
519    }
520}
521
522pub fn fix(workspace: &Workspace, dry_run: bool) -> Result<Vec<Action>> {
523    let env = Env::load(&workspace.env_path())?;
524    let mut actions = Vec::new();
525
526    if runs_mobile(&env) {
527        let configured = env.get(METRO_POKE);
528        let needs_fix = matches!(configured, Some(value) if !Env::truthy(value));
529        if needs_fix {
530            if dry_run {
531                actions.push(Action::applied("metro poke", format!("would set {METRO_POKE}=true")));
532            } else {
533                match set_env_key(&workspace.env_path(), METRO_POKE, "true", POKE_NOTE) {
534                    Ok(()) => actions.push(Action::applied(
535                        "metro poke",
536                        format!("set {METRO_POKE}=true - recreate the mobile container to apply it"),
537                    )),
538                    Err(error) => actions.push(Action::failed("metro poke", error.to_string())),
539                }
540            }
541        } else {
542            actions.push(Action::skipped("metro poke", "already enabled"));
543        }
544    } else {
545        actions.push(Action::skipped("metro poke", "workspace runs no mobile app"));
546    }
547
548    actions.push(fix_overlays(workspace, dry_run));
549    actions.push(fix_root_apps(workspace, dry_run));
550    actions.push(fix_env_quoting(workspace, dry_run));
551    Ok(actions)
552}
553
554pub fn format_checks(checks: &[Check]) -> String {
555    let rows: Vec<Vec<String>> = checks
556        .iter()
557        .map(|check| {
558            vec![
559                check.status.mark().to_string(),
560                check.name.clone(),
561                check.detail.clone(),
562            ]
563        })
564        .collect();
565
566    let failures = checks.iter().filter(|c| c.status == Status::Fail).count();
567    let warnings = checks.iter().filter(|c| c.status == Status::Warn).count();
568    let fixable = checks.iter().filter(|c| c.fixable).count();
569
570    let mut out = crate::table::render(&["", "CHECK", "DETAIL"], &rows);
571    out.push('\n');
572    out.push_str(&if failures == 0 && warnings == 0 {
573        "all checks passed".to_string()
574    } else {
575        format!("{failures} failure(s), {warnings} warning(s)")
576    });
577    if fixable > 0 {
578        out.push_str(&format!("\n{fixable} can be repaired: rst doctor --fix"));
579    }
580    out
581}
582
583pub fn format_actions(actions: &[Action], dry_run: bool) -> String {
584    let rows: Vec<Vec<String>> = actions
585        .iter()
586        .map(|action| {
587            vec![
588                action.outcome.mark().to_string(),
589                action.name.clone(),
590                action.detail.clone(),
591            ]
592        })
593        .collect();
594
595    let applied = actions.iter().filter(|a| a.outcome == Repair::Applied).count();
596    let failed = actions.iter().filter(|a| a.outcome == Repair::Failed).count();
597
598    let mut out = crate::table::render(&["", "REPAIR", "DETAIL"], &rows);
599    out.push('\n');
600    out.push_str(&if failed > 0 {
601        format!("{applied} fixed, {failed} could not be fixed")
602    } else if applied > 0 && dry_run {
603        format!("{applied} would be fixed (dry run)")
604    } else if applied > 0 {
605        format!("{applied} fixed")
606    } else {
607        "nothing to fix".to_string()
608    });
609    out
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615
616    fn env_from(text: &str) -> Env {
617        let dir = tempfile::tempdir().unwrap();
618        let path = dir.path().join(".env");
619        fs::write(&path, text).unwrap();
620        Env::load(&path).unwrap()
621    }
622
623    #[test]
624    fn poke_disabled_is_a_fixable_failure() {
625        let env = env_from("RUN_MOBILE=true\nMETRO_POKE=false\n");
626        let checks = check_metro_poke(&env, &[]);
627
628        assert_eq!(checks[0].status, Status::Fail);
629        assert!(checks[0].fixable);
630        assert!(checks[0].detail.contains("cache clear"));
631    }
632
633    #[test]
634    fn poke_unset_defaults_to_enabled() {
635        let env = env_from("RUN_MOBILE=true\n");
636        let checks = check_metro_poke(&env, &[]);
637
638        assert_eq!(checks[0].status, Status::Ok);
639        assert!(!checks[0].fixable);
640    }
641
642    #[test]
643    fn poke_is_irrelevant_without_a_mobile_app() {
644        let env = env_from("RUN_MOBILE=false\nMETRO_POKE=false\n");
645        let checks = check_metro_poke(&env, &[]);
646
647        assert_eq!(checks.len(), 1);
648        assert_eq!(checks[0].status, Status::Ok);
649    }
650
651    #[test]
652    fn overlays_missing_is_fixable() {
653        let dir = tempfile::tempdir().unwrap();
654        let checks = check_overlays(dir.path());
655
656        assert_eq!(checks[0].status, Status::Fail);
657        assert!(checks[0].fixable);
658    }
659
660    #[test]
661    fn overlays_present_pass() {
662        let dir = tempfile::tempdir().unwrap();
663        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
664
665        assert_eq!(check_overlays(dir.path())[0].status, Status::Ok);
666    }
667
668    #[test]
669    fn a_workspace_with_no_extra_apps_is_not_a_failure() {
670        // docker-compose.extra.yml is written per extra app; demanding it would
671        // be a failure `--fix` could never clear.
672        let dir = tempfile::tempdir().unwrap();
673        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
674
675        let checks = check_overlays(dir.path());
676        assert_eq!(checks[0].status, Status::Ok);
677        assert!(!checks[0].fixable);
678    }
679
680    #[test]
681    fn set_env_key_replaces_in_place_and_keeps_comments() {
682        let dir = tempfile::tempdir().unwrap();
683        let path = dir.path().join(".env");
684        fs::write(&path, "# keep me\nRUN_MOBILE=true\nMETRO_POKE=false\nPORT=1\n").unwrap();
685
686        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
687
688        let text = fs::read_to_string(&path).unwrap();
689        assert!(text.contains("# keep me"));
690        assert!(text.contains("METRO_POKE=true"));
691        assert!(!text.contains("METRO_POKE=false"));
692        // Replaced where it stood, so surrounding settings keep their order.
693        assert!(text.find("RUN_MOBILE").unwrap() < text.find("METRO_POKE").unwrap());
694        assert!(text.find("METRO_POKE").unwrap() < text.find("PORT").unwrap());
695    }
696
697    #[test]
698    fn set_env_key_appends_with_the_reason_when_absent() {
699        let dir = tempfile::tempdir().unwrap();
700        let path = dir.path().join(".env");
701        fs::write(&path, "RUN_MOBILE=true\n").unwrap();
702
703        set_env_key(&path, METRO_POKE, "true", "first line\nsecond line").unwrap();
704
705        let text = fs::read_to_string(&path).unwrap();
706        assert!(text.contains("# first line"));
707        assert!(text.contains("# second line"));
708        assert!(text.contains("METRO_POKE=true"));
709        assert!(text.starts_with("RUN_MOBILE=true"));
710    }
711
712    #[test]
713    fn set_env_key_handles_an_exported_line() {
714        let dir = tempfile::tempdir().unwrap();
715        let path = dir.path().join(".env");
716        fs::write(&path, "export METRO_POKE=false\n").unwrap();
717
718        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
719
720        let text = fs::read_to_string(&path).unwrap();
721        assert!(text.contains("METRO_POKE=true"));
722        assert!(!text.contains("false"));
723    }
724
725    #[test]
726    fn a_similar_key_is_not_mistaken_for_the_real_one() {
727        let dir = tempfile::tempdir().unwrap();
728        let path = dir.path().join(".env");
729        fs::write(&path, "METRO_POKE_INTERVAL=1\n").unwrap();
730
731        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
732
733        let text = fs::read_to_string(&path).unwrap();
734        assert!(text.contains("METRO_POKE_INTERVAL=1"));
735        assert!(text.contains("\nMETRO_POKE=true"));
736    }
737
738    #[test]
739    fn report_points_at_the_fix_when_something_is_repairable() {
740        let checks = check_metro_poke(&env_from("RUN_MOBILE=true\nMETRO_POKE=false\n"), &[]);
741        let report = format_checks(&checks);
742
743        assert!(report.contains("rst doctor --fix"));
744        assert!(report.contains("1 failure(s)"));
745    }
746
747    #[test]
748    fn report_is_quiet_when_all_is_well() {
749        let report = format_checks(&[Check::ok("docker", "fine")]);
750
751        assert!(report.contains("all checks passed"));
752        assert!(!report.contains("--fix"));
753    }
754
755    #[test]
756    fn actions_report_distinguishes_a_dry_run() {
757        let applied = vec![Action::applied("metro poke", "would set it")];
758
759        assert!(format_actions(&applied, true).contains("would be fixed"));
760        assert!(format_actions(&applied, false).contains("1 fixed"));
761        assert!(format_actions(&[Action::skipped("x", "y")], false).contains("nothing to fix"));
762    }
763}
764
765#[cfg(test)]
766mod root_app_detection_tests {
767    use super::*;
768
769    fn workspace_with(dirs: &[(&str, &str)], env_text: &str) -> (tempfile::TempDir, Workspace) {
770        let dir = tempfile::tempdir().unwrap();
771        let root = dir.path().to_path_buf();
772        fs::create_dir_all(root.join(".run")).unwrap();
773        fs::write(root.join(".run").join(".env"), env_text).unwrap();
774        for (name, manifest) in dirs {
775            fs::create_dir_all(root.join(name)).unwrap();
776            if !manifest.is_empty() {
777                fs::write(root.join(name).join("package.json"), manifest).unwrap();
778            }
779        }
780        let workspace = Workspace {
781            root: root.clone(),
782            run_dir: root.join(".run"),
783        };
784        (dir, workspace)
785    }
786
787    fn env_of(workspace: &Workspace) -> Env {
788        Env::load(&workspace.env_path()).unwrap()
789    }
790
791    #[test]
792    fn an_app_beside_the_repo_is_found() {
793        let (_guard, workspace) = workspace_with(
794            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
795            "FRONTEND_DIR=/w/platform\n",
796        );
797
798        assert_eq!(undeclared_root_apps(&workspace, &env_of(&workspace)), vec!["seeder"]);
799    }
800
801    #[test]
802    fn the_frontend_and_backend_repos_are_not_apps() {
803        let (_guard, workspace) = workspace_with(
804            &[
805                ("platform", r#"{"scripts":{"dev":"vite"}}"#),
806                ("api", r#"{"scripts":{"start":"node ."}}"#),
807            ],
808            "FRONTEND_DIR=/w/platform\nBACKEND_DIR=/w/api\n",
809        );
810
811        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
812    }
813
814    #[test]
815    fn an_already_declared_app_is_not_offered_twice() {
816        let (_guard, workspace) = workspace_with(
817            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
818            "ROOT_APPS=seeder\n",
819        );
820
821        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
822    }
823
824    #[test]
825    fn a_name_and_directory_pair_still_counts_as_declared() {
826        let (_guard, workspace) = workspace_with(
827            &[("althaqeel-seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
828            "ROOT_APPS=seeder:althaqeel-seeder\n",
829        );
830
831        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
832    }
833
834    #[test]
835    fn a_directory_with_no_manifest_is_not_an_app() {
836        let (_guard, workspace) = workspace_with(&[("docs", "")], "");
837
838        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
839    }
840
841    #[test]
842    fn a_library_with_nothing_to_run_is_not_an_app() {
843        let (_guard, workspace) = workspace_with(
844            &[("shared", r#"{"scripts":{"build":"tsc","test":"vitest"}}"#)],
845            "",
846        );
847
848        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
849    }
850
851    #[test]
852    fn node_modules_and_hidden_directories_are_ignored() {
853        let (_guard, workspace) = workspace_with(
854            &[
855                ("node_modules", r#"{"scripts":{"start":"x"}}"#),
856                (".cache", r#"{"scripts":{"start":"x"}}"#),
857            ],
858            "",
859        );
860
861        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
862    }
863
864    #[test]
865    fn the_check_says_init_update_cannot_see_them() {
866        let (_guard, workspace) = workspace_with(
867            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
868            "",
869        );
870
871        let checks = check_root_apps(&workspace, &env_of(&workspace));
872
873        assert_eq!(checks[0].status, Status::Warn);
874        assert!(checks[0].fixable);
875        assert!(checks[0].detail.contains("seeder"));
876    }
877
878    #[test]
879    fn fixing_writes_the_app_into_root_apps() {
880        let (_guard, workspace) = workspace_with(
881            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
882            "FRONTEND_DIR=/w/platform\n",
883        );
884
885        let action = fix_root_apps(&workspace, false);
886
887        assert_eq!(action.outcome, Repair::Applied);
888        let text = fs::read_to_string(workspace.env_path()).unwrap();
889        assert!(text.contains("ROOT_APPS=seeder"));
890        assert!(text.contains("FRONTEND_DIR=/w/platform"));
891    }
892
893    #[test]
894    fn a_dry_run_writes_nothing() {
895        let (_guard, workspace) = workspace_with(
896            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
897            "",
898        );
899
900        fix_root_apps(&workspace, true);
901
902        assert!(!fs::read_to_string(workspace.env_path()).unwrap().contains("ROOT_APPS"));
903    }
904
905    #[test]
906    fn fixing_keeps_apps_that_are_already_declared() {
907        let (_guard, workspace) = workspace_with(
908            &[
909                ("seeder", r#"{"scripts":{"dev":"node server.js"}}"#),
910                ("tools", r#"{"scripts":{"start":"node ."}}"#),
911            ],
912            "ROOT_APPS=tools\n",
913        );
914
915        fix_root_apps(&workspace, false);
916
917        let text = fs::read_to_string(workspace.env_path()).unwrap();
918        assert!(text.contains("tools"));
919        assert!(text.contains("seeder"));
920    }
921}
922
923#[cfg(test)]
924mod env_quoting_tests {
925    use super::*;
926
927    fn lines(text: &str) -> Vec<String> {
928        unquoted_values(text).into_iter().map(|l| l.key).collect()
929    }
930
931    #[test]
932    fn a_bare_command_value_is_caught() {
933        // The exact shape that ran `npm run dev` in the workspace root.
934        assert_eq!(
935            lines("SEEDER_CMD=NO_OPEN=1 HOST=0.0.0.0 npm run dev\n"),
936            vec!["SEEDER_CMD"]
937        );
938    }
939
940    #[test]
941    fn quoted_values_are_fine() {
942        let text = "A=\"one two\"\nB='three four'\n";
943
944        assert!(unquoted_values(text).is_empty());
945    }
946
947    #[test]
948    fn single_word_values_are_fine() {
949        assert!(unquoted_values("PORT=4500\nNAME=seeder\n").is_empty());
950    }
951
952    #[test]
953    fn comments_and_blanks_are_skipped() {
954        assert!(unquoted_values("# a note with spaces\n\n   \n").is_empty());
955    }
956
957    #[test]
958    fn an_exported_line_is_still_checked() {
959        assert_eq!(lines("export CMD=npm run dev\n"), vec!["CMD"]);
960    }
961
962    #[test]
963    fn the_reported_line_number_is_one_based() {
964        let found = unquoted_values("A=1\nB=npm run dev\n");
965
966        assert_eq!(found[0].number, 2);
967    }
968
969    #[test]
970    fn prose_after_a_hash_is_not_a_setting() {
971        assert!(unquoted_values("#   EXTRA_APPS=reports partner portal\n").is_empty());
972    }
973}
974
975#[cfg(test)]
976mod env_quoting_fix_tests {
977    use super::*;
978
979    fn workspace_with(env_text: &str) -> (tempfile::TempDir, Workspace) {
980        let dir = tempfile::tempdir().unwrap();
981        let root = dir.path().to_path_buf();
982        fs::create_dir_all(root.join(".run")).unwrap();
983        fs::write(root.join(".run").join(".env"), env_text).unwrap();
984        let workspace = Workspace {
985            root: root.clone(),
986            run_dir: root.join(".run"),
987        };
988        (dir, workspace)
989    }
990
991    #[test]
992    fn fixing_quotes_the_value_and_leaves_the_rest_alone() {
993        let (_guard, workspace) =
994            workspace_with("# note\nPORT=4500\nCMD=npm run dev\nOTHER=\"a b\"\n");
995
996        let action = fix_env_quoting(&workspace, false);
997
998        assert_eq!(action.outcome, Repair::Applied);
999        let text = fs::read_to_string(workspace.env_path()).unwrap();
1000        assert!(text.contains("CMD=\"npm run dev\""));
1001        assert!(text.contains("PORT=4500"));
1002        assert!(text.contains("# note"));
1003        assert!(text.contains("OTHER=\"a b\""));
1004    }
1005
1006    #[test]
1007    fn a_fixed_file_is_clean_on_the_next_pass() {
1008        let (_guard, workspace) = workspace_with("CMD=npm run dev\n");
1009
1010        fix_env_quoting(&workspace, false);
1011
1012        let text = fs::read_to_string(workspace.env_path()).unwrap();
1013        assert!(unquoted_values(&text).is_empty());
1014    }
1015
1016    #[test]
1017    fn a_dry_run_writes_nothing() {
1018        let (_guard, workspace) = workspace_with("CMD=npm run dev\n");
1019
1020        fix_env_quoting(&workspace, true);
1021
1022        assert!(fs::read_to_string(workspace.env_path()).unwrap().contains("CMD=npm run dev\n"));
1023    }
1024
1025    #[test]
1026    fn a_half_quoted_value_is_left_to_a_person() {
1027        // Nothing mechanical can tell which half was meant.
1028        let (_guard, workspace) = workspace_with("CMD=say \"hello world\n");
1029
1030        let action = fix_env_quoting(&workspace, false);
1031
1032        assert_eq!(action.outcome, Repair::Failed);
1033        assert!(fs::read_to_string(workspace.env_path()).unwrap().contains("CMD=say \"hello world"));
1034    }
1035
1036    #[test]
1037    fn the_check_reports_the_key_and_line() {
1038        let (_guard, workspace) = workspace_with("A=1\nCMD=npm run dev\n");
1039
1040        let checks = check_env_quoting(&workspace);
1041
1042        assert_eq!(checks[0].status, Status::Fail);
1043        assert!(checks[0].detail.contains("CMD (line 2)"));
1044    }
1045}