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    match existing {
429        Some(index) => lines[index] = format!("{key}={value}"),
430        None => {
431            if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
432                lines.push(String::new());
433            }
434            for line in note.lines() {
435                lines.push(format!("# {line}"));
436            }
437            lines.push(format!("{key}={value}"));
438        }
439    }
440
441    fs::write(path, format!("{}\n", lines.join("\n")))?;
442    Ok(())
443}
444
445const 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.";
446
447/// Regenerating the overlays is `rst generate`; doctor calls the same code so
448/// a missing overlay is repaired rather than merely reported.
449fn fix_overlays(workspace: &Workspace, dry_run: bool) -> Action {
450    let missing = check_overlays(&workspace.run_dir)
451        .into_iter()
452        .any(|check| check.status != Status::Ok);
453    if !missing {
454        return Action::skipped("overlays", "already generated");
455    }
456    if dry_run {
457        return Action::applied("overlays", "would regenerate the compose overlays");
458    }
459
460    let mut env = match Env::load(&workspace.env_path()) {
461        Ok(env) => env,
462        Err(error) => return Action::failed("overlays", error.to_string()),
463    };
464    env.derive(&workspace.root);
465
466    let package = match crate::compose::package_dir() {
467        Ok(dir) => dir,
468        Err(error) => return Action::failed("overlays", error.to_string()),
469    };
470    match crate::generate::all(&workspace.run_dir, &env, &package) {
471        Ok(()) => Action::applied("overlays", "regenerated the compose overlays"),
472        Err(error) => Action::failed("overlays", error.to_string()),
473    }
474}
475
476const 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.";
477
478/// Add apps found beside the frontend repo to ROOT_APPS.
479///
480/// The directory name becomes the entry; a name that differs from the
481/// directory is a `name:dir` pair the author writes by hand.
482fn fix_root_apps(workspace: &Workspace, dry_run: bool) -> Action {
483    let env = match Env::load(&workspace.env_path()) {
484        Ok(env) => env,
485        Err(error) => return Action::failed("root apps", error.to_string()),
486    };
487
488    let found = undeclared_root_apps(workspace, &env);
489    if found.is_empty() {
490        return Action::skipped("root apps", "nothing unconfigured beside the frontend repo");
491    }
492
493    let mut declared: Vec<String> = env
494        .get_or("ROOT_APPS", "")
495        .split_whitespace()
496        .map(str::to_string)
497        .collect();
498    declared.extend(found.iter().cloned());
499    let value = declared.join(" ");
500
501    if dry_run {
502        return Action::applied("root apps", format!("would add {} to ROOT_APPS", found.join(", ")));
503    }
504
505    match set_env_key(&workspace.env_path(), "ROOT_APPS", &value, ROOT_APPS_NOTE) {
506        Ok(()) => Action::applied(
507            "root apps",
508            format!("added {} to ROOT_APPS - run `rst up {}` to start", found.join(", "), found[0]),
509        ),
510        Err(error) => Action::failed("root apps", error.to_string()),
511    }
512}
513
514pub fn fix(workspace: &Workspace, dry_run: bool) -> Result<Vec<Action>> {
515    let env = Env::load(&workspace.env_path())?;
516    let mut actions = Vec::new();
517
518    if runs_mobile(&env) {
519        let configured = env.get(METRO_POKE);
520        let needs_fix = matches!(configured, Some(value) if !Env::truthy(value));
521        if needs_fix {
522            if dry_run {
523                actions.push(Action::applied("metro poke", format!("would set {METRO_POKE}=true")));
524            } else {
525                match set_env_key(&workspace.env_path(), METRO_POKE, "true", POKE_NOTE) {
526                    Ok(()) => actions.push(Action::applied(
527                        "metro poke",
528                        format!("set {METRO_POKE}=true - recreate the mobile container to apply it"),
529                    )),
530                    Err(error) => actions.push(Action::failed("metro poke", error.to_string())),
531                }
532            }
533        } else {
534            actions.push(Action::skipped("metro poke", "already enabled"));
535        }
536    } else {
537        actions.push(Action::skipped("metro poke", "workspace runs no mobile app"));
538    }
539
540    actions.push(fix_overlays(workspace, dry_run));
541    actions.push(fix_root_apps(workspace, dry_run));
542    actions.push(fix_env_quoting(workspace, dry_run));
543    Ok(actions)
544}
545
546pub fn format_checks(checks: &[Check]) -> String {
547    let rows: Vec<Vec<String>> = checks
548        .iter()
549        .map(|check| {
550            vec![
551                check.status.mark().to_string(),
552                check.name.clone(),
553                check.detail.clone(),
554            ]
555        })
556        .collect();
557
558    let failures = checks.iter().filter(|c| c.status == Status::Fail).count();
559    let warnings = checks.iter().filter(|c| c.status == Status::Warn).count();
560    let fixable = checks.iter().filter(|c| c.fixable).count();
561
562    let mut out = crate::table::render(&["", "CHECK", "DETAIL"], &rows);
563    out.push('\n');
564    out.push_str(&if failures == 0 && warnings == 0 {
565        "all checks passed".to_string()
566    } else {
567        format!("{failures} failure(s), {warnings} warning(s)")
568    });
569    if fixable > 0 {
570        out.push_str(&format!("\n{fixable} can be repaired: rst doctor --fix"));
571    }
572    out
573}
574
575pub fn format_actions(actions: &[Action], dry_run: bool) -> String {
576    let rows: Vec<Vec<String>> = actions
577        .iter()
578        .map(|action| {
579            vec![
580                action.outcome.mark().to_string(),
581                action.name.clone(),
582                action.detail.clone(),
583            ]
584        })
585        .collect();
586
587    let applied = actions.iter().filter(|a| a.outcome == Repair::Applied).count();
588    let failed = actions.iter().filter(|a| a.outcome == Repair::Failed).count();
589
590    let mut out = crate::table::render(&["", "REPAIR", "DETAIL"], &rows);
591    out.push('\n');
592    out.push_str(&if failed > 0 {
593        format!("{applied} fixed, {failed} could not be fixed")
594    } else if applied > 0 && dry_run {
595        format!("{applied} would be fixed (dry run)")
596    } else if applied > 0 {
597        format!("{applied} fixed")
598    } else {
599        "nothing to fix".to_string()
600    });
601    out
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    fn env_from(text: &str) -> Env {
609        let dir = tempfile::tempdir().unwrap();
610        let path = dir.path().join(".env");
611        fs::write(&path, text).unwrap();
612        Env::load(&path).unwrap()
613    }
614
615    #[test]
616    fn poke_disabled_is_a_fixable_failure() {
617        let env = env_from("RUN_MOBILE=true\nMETRO_POKE=false\n");
618        let checks = check_metro_poke(&env, &[]);
619
620        assert_eq!(checks[0].status, Status::Fail);
621        assert!(checks[0].fixable);
622        assert!(checks[0].detail.contains("cache clear"));
623    }
624
625    #[test]
626    fn poke_unset_defaults_to_enabled() {
627        let env = env_from("RUN_MOBILE=true\n");
628        let checks = check_metro_poke(&env, &[]);
629
630        assert_eq!(checks[0].status, Status::Ok);
631        assert!(!checks[0].fixable);
632    }
633
634    #[test]
635    fn poke_is_irrelevant_without_a_mobile_app() {
636        let env = env_from("RUN_MOBILE=false\nMETRO_POKE=false\n");
637        let checks = check_metro_poke(&env, &[]);
638
639        assert_eq!(checks.len(), 1);
640        assert_eq!(checks[0].status, Status::Ok);
641    }
642
643    #[test]
644    fn overlays_missing_is_fixable() {
645        let dir = tempfile::tempdir().unwrap();
646        let checks = check_overlays(dir.path());
647
648        assert_eq!(checks[0].status, Status::Fail);
649        assert!(checks[0].fixable);
650    }
651
652    #[test]
653    fn overlays_present_pass() {
654        let dir = tempfile::tempdir().unwrap();
655        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
656
657        assert_eq!(check_overlays(dir.path())[0].status, Status::Ok);
658    }
659
660    #[test]
661    fn a_workspace_with_no_extra_apps_is_not_a_failure() {
662        // docker-compose.extra.yml is written per extra app; demanding it would
663        // be a failure `--fix` could never clear.
664        let dir = tempfile::tempdir().unwrap();
665        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
666
667        let checks = check_overlays(dir.path());
668        assert_eq!(checks[0].status, Status::Ok);
669        assert!(!checks[0].fixable);
670    }
671
672    #[test]
673    fn set_env_key_replaces_in_place_and_keeps_comments() {
674        let dir = tempfile::tempdir().unwrap();
675        let path = dir.path().join(".env");
676        fs::write(&path, "# keep me\nRUN_MOBILE=true\nMETRO_POKE=false\nPORT=1\n").unwrap();
677
678        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
679
680        let text = fs::read_to_string(&path).unwrap();
681        assert!(text.contains("# keep me"));
682        assert!(text.contains("METRO_POKE=true"));
683        assert!(!text.contains("METRO_POKE=false"));
684        // Replaced where it stood, so surrounding settings keep their order.
685        assert!(text.find("RUN_MOBILE").unwrap() < text.find("METRO_POKE").unwrap());
686        assert!(text.find("METRO_POKE").unwrap() < text.find("PORT").unwrap());
687    }
688
689    #[test]
690    fn set_env_key_appends_with_the_reason_when_absent() {
691        let dir = tempfile::tempdir().unwrap();
692        let path = dir.path().join(".env");
693        fs::write(&path, "RUN_MOBILE=true\n").unwrap();
694
695        set_env_key(&path, METRO_POKE, "true", "first line\nsecond line").unwrap();
696
697        let text = fs::read_to_string(&path).unwrap();
698        assert!(text.contains("# first line"));
699        assert!(text.contains("# second line"));
700        assert!(text.contains("METRO_POKE=true"));
701        assert!(text.starts_with("RUN_MOBILE=true"));
702    }
703
704    #[test]
705    fn set_env_key_handles_an_exported_line() {
706        let dir = tempfile::tempdir().unwrap();
707        let path = dir.path().join(".env");
708        fs::write(&path, "export METRO_POKE=false\n").unwrap();
709
710        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
711
712        let text = fs::read_to_string(&path).unwrap();
713        assert!(text.contains("METRO_POKE=true"));
714        assert!(!text.contains("false"));
715    }
716
717    #[test]
718    fn a_similar_key_is_not_mistaken_for_the_real_one() {
719        let dir = tempfile::tempdir().unwrap();
720        let path = dir.path().join(".env");
721        fs::write(&path, "METRO_POKE_INTERVAL=1\n").unwrap();
722
723        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
724
725        let text = fs::read_to_string(&path).unwrap();
726        assert!(text.contains("METRO_POKE_INTERVAL=1"));
727        assert!(text.contains("\nMETRO_POKE=true"));
728    }
729
730    #[test]
731    fn report_points_at_the_fix_when_something_is_repairable() {
732        let checks = check_metro_poke(&env_from("RUN_MOBILE=true\nMETRO_POKE=false\n"), &[]);
733        let report = format_checks(&checks);
734
735        assert!(report.contains("rst doctor --fix"));
736        assert!(report.contains("1 failure(s)"));
737    }
738
739    #[test]
740    fn report_is_quiet_when_all_is_well() {
741        let report = format_checks(&[Check::ok("docker", "fine")]);
742
743        assert!(report.contains("all checks passed"));
744        assert!(!report.contains("--fix"));
745    }
746
747    #[test]
748    fn actions_report_distinguishes_a_dry_run() {
749        let applied = vec![Action::applied("metro poke", "would set it")];
750
751        assert!(format_actions(&applied, true).contains("would be fixed"));
752        assert!(format_actions(&applied, false).contains("1 fixed"));
753        assert!(format_actions(&[Action::skipped("x", "y")], false).contains("nothing to fix"));
754    }
755}
756
757#[cfg(test)]
758mod root_app_detection_tests {
759    use super::*;
760
761    fn workspace_with(dirs: &[(&str, &str)], env_text: &str) -> (tempfile::TempDir, Workspace) {
762        let dir = tempfile::tempdir().unwrap();
763        let root = dir.path().to_path_buf();
764        fs::create_dir_all(root.join(".run")).unwrap();
765        fs::write(root.join(".run").join(".env"), env_text).unwrap();
766        for (name, manifest) in dirs {
767            fs::create_dir_all(root.join(name)).unwrap();
768            if !manifest.is_empty() {
769                fs::write(root.join(name).join("package.json"), manifest).unwrap();
770            }
771        }
772        let workspace = Workspace {
773            root: root.clone(),
774            run_dir: root.join(".run"),
775        };
776        (dir, workspace)
777    }
778
779    fn env_of(workspace: &Workspace) -> Env {
780        Env::load(&workspace.env_path()).unwrap()
781    }
782
783    #[test]
784    fn an_app_beside_the_repo_is_found() {
785        let (_guard, workspace) = workspace_with(
786            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
787            "FRONTEND_DIR=/w/platform\n",
788        );
789
790        assert_eq!(undeclared_root_apps(&workspace, &env_of(&workspace)), vec!["seeder"]);
791    }
792
793    #[test]
794    fn the_frontend_and_backend_repos_are_not_apps() {
795        let (_guard, workspace) = workspace_with(
796            &[
797                ("platform", r#"{"scripts":{"dev":"vite"}}"#),
798                ("api", r#"{"scripts":{"start":"node ."}}"#),
799            ],
800            "FRONTEND_DIR=/w/platform\nBACKEND_DIR=/w/api\n",
801        );
802
803        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
804    }
805
806    #[test]
807    fn an_already_declared_app_is_not_offered_twice() {
808        let (_guard, workspace) = workspace_with(
809            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
810            "ROOT_APPS=seeder\n",
811        );
812
813        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
814    }
815
816    #[test]
817    fn a_name_and_directory_pair_still_counts_as_declared() {
818        let (_guard, workspace) = workspace_with(
819            &[("althaqeel-seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
820            "ROOT_APPS=seeder:althaqeel-seeder\n",
821        );
822
823        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
824    }
825
826    #[test]
827    fn a_directory_with_no_manifest_is_not_an_app() {
828        let (_guard, workspace) = workspace_with(&[("docs", "")], "");
829
830        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
831    }
832
833    #[test]
834    fn a_library_with_nothing_to_run_is_not_an_app() {
835        let (_guard, workspace) = workspace_with(
836            &[("shared", r#"{"scripts":{"build":"tsc","test":"vitest"}}"#)],
837            "",
838        );
839
840        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
841    }
842
843    #[test]
844    fn node_modules_and_hidden_directories_are_ignored() {
845        let (_guard, workspace) = workspace_with(
846            &[
847                ("node_modules", r#"{"scripts":{"start":"x"}}"#),
848                (".cache", r#"{"scripts":{"start":"x"}}"#),
849            ],
850            "",
851        );
852
853        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
854    }
855
856    #[test]
857    fn the_check_says_init_update_cannot_see_them() {
858        let (_guard, workspace) = workspace_with(
859            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
860            "",
861        );
862
863        let checks = check_root_apps(&workspace, &env_of(&workspace));
864
865        assert_eq!(checks[0].status, Status::Warn);
866        assert!(checks[0].fixable);
867        assert!(checks[0].detail.contains("seeder"));
868    }
869
870    #[test]
871    fn fixing_writes_the_app_into_root_apps() {
872        let (_guard, workspace) = workspace_with(
873            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
874            "FRONTEND_DIR=/w/platform\n",
875        );
876
877        let action = fix_root_apps(&workspace, false);
878
879        assert_eq!(action.outcome, Repair::Applied);
880        let text = fs::read_to_string(workspace.env_path()).unwrap();
881        assert!(text.contains("ROOT_APPS=seeder"));
882        assert!(text.contains("FRONTEND_DIR=/w/platform"));
883    }
884
885    #[test]
886    fn a_dry_run_writes_nothing() {
887        let (_guard, workspace) = workspace_with(
888            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
889            "",
890        );
891
892        fix_root_apps(&workspace, true);
893
894        assert!(!fs::read_to_string(workspace.env_path()).unwrap().contains("ROOT_APPS"));
895    }
896
897    #[test]
898    fn fixing_keeps_apps_that_are_already_declared() {
899        let (_guard, workspace) = workspace_with(
900            &[
901                ("seeder", r#"{"scripts":{"dev":"node server.js"}}"#),
902                ("tools", r#"{"scripts":{"start":"node ."}}"#),
903            ],
904            "ROOT_APPS=tools\n",
905        );
906
907        fix_root_apps(&workspace, false);
908
909        let text = fs::read_to_string(workspace.env_path()).unwrap();
910        assert!(text.contains("tools"));
911        assert!(text.contains("seeder"));
912    }
913}
914
915#[cfg(test)]
916mod env_quoting_tests {
917    use super::*;
918
919    fn lines(text: &str) -> Vec<String> {
920        unquoted_values(text).into_iter().map(|l| l.key).collect()
921    }
922
923    #[test]
924    fn a_bare_command_value_is_caught() {
925        // The exact shape that ran `npm run dev` in the workspace root.
926        assert_eq!(
927            lines("SEEDER_CMD=NO_OPEN=1 HOST=0.0.0.0 npm run dev\n"),
928            vec!["SEEDER_CMD"]
929        );
930    }
931
932    #[test]
933    fn quoted_values_are_fine() {
934        let text = "A=\"one two\"\nB='three four'\n";
935
936        assert!(unquoted_values(text).is_empty());
937    }
938
939    #[test]
940    fn single_word_values_are_fine() {
941        assert!(unquoted_values("PORT=4500\nNAME=seeder\n").is_empty());
942    }
943
944    #[test]
945    fn comments_and_blanks_are_skipped() {
946        assert!(unquoted_values("# a note with spaces\n\n   \n").is_empty());
947    }
948
949    #[test]
950    fn an_exported_line_is_still_checked() {
951        assert_eq!(lines("export CMD=npm run dev\n"), vec!["CMD"]);
952    }
953
954    #[test]
955    fn the_reported_line_number_is_one_based() {
956        let found = unquoted_values("A=1\nB=npm run dev\n");
957
958        assert_eq!(found[0].number, 2);
959    }
960
961    #[test]
962    fn prose_after_a_hash_is_not_a_setting() {
963        assert!(unquoted_values("#   EXTRA_APPS=reports partner portal\n").is_empty());
964    }
965}
966
967#[cfg(test)]
968mod env_quoting_fix_tests {
969    use super::*;
970
971    fn workspace_with(env_text: &str) -> (tempfile::TempDir, Workspace) {
972        let dir = tempfile::tempdir().unwrap();
973        let root = dir.path().to_path_buf();
974        fs::create_dir_all(root.join(".run")).unwrap();
975        fs::write(root.join(".run").join(".env"), env_text).unwrap();
976        let workspace = Workspace {
977            root: root.clone(),
978            run_dir: root.join(".run"),
979        };
980        (dir, workspace)
981    }
982
983    #[test]
984    fn fixing_quotes_the_value_and_leaves_the_rest_alone() {
985        let (_guard, workspace) =
986            workspace_with("# note\nPORT=4500\nCMD=npm run dev\nOTHER=\"a b\"\n");
987
988        let action = fix_env_quoting(&workspace, false);
989
990        assert_eq!(action.outcome, Repair::Applied);
991        let text = fs::read_to_string(workspace.env_path()).unwrap();
992        assert!(text.contains("CMD=\"npm run dev\""));
993        assert!(text.contains("PORT=4500"));
994        assert!(text.contains("# note"));
995        assert!(text.contains("OTHER=\"a b\""));
996    }
997
998    #[test]
999    fn a_fixed_file_is_clean_on_the_next_pass() {
1000        let (_guard, workspace) = workspace_with("CMD=npm run dev\n");
1001
1002        fix_env_quoting(&workspace, false);
1003
1004        let text = fs::read_to_string(workspace.env_path()).unwrap();
1005        assert!(unquoted_values(&text).is_empty());
1006    }
1007
1008    #[test]
1009    fn a_dry_run_writes_nothing() {
1010        let (_guard, workspace) = workspace_with("CMD=npm run dev\n");
1011
1012        fix_env_quoting(&workspace, true);
1013
1014        assert!(fs::read_to_string(workspace.env_path()).unwrap().contains("CMD=npm run dev\n"));
1015    }
1016
1017    #[test]
1018    fn a_half_quoted_value_is_left_to_a_person() {
1019        // Nothing mechanical can tell which half was meant.
1020        let (_guard, workspace) = workspace_with("CMD=say \"hello world\n");
1021
1022        let action = fix_env_quoting(&workspace, false);
1023
1024        assert_eq!(action.outcome, Repair::Failed);
1025        assert!(fs::read_to_string(workspace.env_path()).unwrap().contains("CMD=say \"hello world"));
1026    }
1027
1028    #[test]
1029    fn the_check_reports_the_key_and_line() {
1030        let (_guard, workspace) = workspace_with("A=1\nCMD=npm run dev\n");
1031
1032        let checks = check_env_quoting(&workspace);
1033
1034        assert_eq!(checks[0].status, Status::Fail);
1035        assert!(checks[0].detail.contains("CMD (line 2)"));
1036    }
1037}