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