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
252pub fn check_overlays(run_dir: &Path) -> Vec<Check> {
253    if run_dir.join("docker-compose.packages.yml").is_file() {
254        return vec![Check::ok("overlays", "generated compose overlays present")];
255    }
256    vec![Check::fixable(
257        "overlays",
258        Status::Fail,
259        "missing: docker-compose.packages.yml - run `rst generate`".to_string(),
260    )]
261}
262
263pub fn check_docker() -> Vec<Check> {
264    if docker_available() {
265        vec![Check::ok("docker", "docker compose v2 available")]
266    } else {
267        vec![Check::warn("docker", "'docker compose' unavailable - is docker running?")]
268    }
269}
270
271pub fn run(workspace: &Workspace) -> Result<Vec<Check>> {
272    let env = Env::load(&workspace.env_path())?;
273    let containers = if docker_available() { running_metro_containers() } else { Vec::new() };
274
275    let mut checks = check_docker();
276    checks.extend(check_metro_poke(&env, &containers));
277    checks.extend(check_overlays(&workspace.run_dir));
278    checks.extend(check_root_apps(workspace, &env));
279    Ok(checks)
280}
281
282/// Set `key` in a .env file, preserving comments and the order of what is
283/// already there. Appends with a note when the key is absent.
284pub fn set_env_key(path: &Path, key: &str, value: &str, note: &str) -> Result<()> {
285    let text = fs::read_to_string(path).unwrap_or_default();
286    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
287
288    let existing = lines.iter().position(|line| {
289        let trimmed = line.trim().strip_prefix("export ").unwrap_or(line.trim());
290        trimmed
291            .split_once('=')
292            .map(|(name, _)| name.trim() == key)
293            .unwrap_or(false)
294    });
295
296    match existing {
297        Some(index) => lines[index] = format!("{key}={value}"),
298        None => {
299            if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
300                lines.push(String::new());
301            }
302            for line in note.lines() {
303                lines.push(format!("# {line}"));
304            }
305            lines.push(format!("{key}={value}"));
306        }
307    }
308
309    fs::write(path, format!("{}\n", lines.join("\n")))?;
310    Ok(())
311}
312
313const 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.";
314
315/// Regenerating the overlays is `rst generate`; doctor calls the same code so
316/// a missing overlay is repaired rather than merely reported.
317fn fix_overlays(workspace: &Workspace, dry_run: bool) -> Action {
318    let missing = check_overlays(&workspace.run_dir)
319        .into_iter()
320        .any(|check| check.status != Status::Ok);
321    if !missing {
322        return Action::skipped("overlays", "already generated");
323    }
324    if dry_run {
325        return Action::applied("overlays", "would regenerate the compose overlays");
326    }
327
328    let mut env = match Env::load(&workspace.env_path()) {
329        Ok(env) => env,
330        Err(error) => return Action::failed("overlays", error.to_string()),
331    };
332    env.derive(&workspace.root);
333
334    let package = match crate::compose::package_dir() {
335        Ok(dir) => dir,
336        Err(error) => return Action::failed("overlays", error.to_string()),
337    };
338    match crate::generate::all(&workspace.run_dir, &env, &package) {
339        Ok(()) => Action::applied("overlays", "regenerated the compose overlays"),
340        Err(error) => Action::failed("overlays", error.to_string()),
341    }
342}
343
344const 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.";
345
346/// Add apps found beside the frontend repo to ROOT_APPS.
347///
348/// The directory name becomes the entry; a name that differs from the
349/// directory is a `name:dir` pair the author writes by hand.
350fn fix_root_apps(workspace: &Workspace, dry_run: bool) -> Action {
351    let env = match Env::load(&workspace.env_path()) {
352        Ok(env) => env,
353        Err(error) => return Action::failed("root apps", error.to_string()),
354    };
355
356    let found = undeclared_root_apps(workspace, &env);
357    if found.is_empty() {
358        return Action::skipped("root apps", "nothing unconfigured beside the frontend repo");
359    }
360
361    let mut declared: Vec<String> = env
362        .get_or("ROOT_APPS", "")
363        .split_whitespace()
364        .map(str::to_string)
365        .collect();
366    declared.extend(found.iter().cloned());
367    let value = declared.join(" ");
368
369    if dry_run {
370        return Action::applied("root apps", format!("would add {} to ROOT_APPS", found.join(", ")));
371    }
372
373    match set_env_key(&workspace.env_path(), "ROOT_APPS", &value, ROOT_APPS_NOTE) {
374        Ok(()) => Action::applied(
375            "root apps",
376            format!("added {} to ROOT_APPS - run `rst up {}` to start", found.join(", "), found[0]),
377        ),
378        Err(error) => Action::failed("root apps", error.to_string()),
379    }
380}
381
382pub fn fix(workspace: &Workspace, dry_run: bool) -> Result<Vec<Action>> {
383    let env = Env::load(&workspace.env_path())?;
384    let mut actions = Vec::new();
385
386    if runs_mobile(&env) {
387        let configured = env.get(METRO_POKE);
388        let needs_fix = matches!(configured, Some(value) if !Env::truthy(value));
389        if needs_fix {
390            if dry_run {
391                actions.push(Action::applied("metro poke", format!("would set {METRO_POKE}=true")));
392            } else {
393                match set_env_key(&workspace.env_path(), METRO_POKE, "true", POKE_NOTE) {
394                    Ok(()) => actions.push(Action::applied(
395                        "metro poke",
396                        format!("set {METRO_POKE}=true - recreate the mobile container to apply it"),
397                    )),
398                    Err(error) => actions.push(Action::failed("metro poke", error.to_string())),
399                }
400            }
401        } else {
402            actions.push(Action::skipped("metro poke", "already enabled"));
403        }
404    } else {
405        actions.push(Action::skipped("metro poke", "workspace runs no mobile app"));
406    }
407
408    actions.push(fix_overlays(workspace, dry_run));
409    actions.push(fix_root_apps(workspace, dry_run));
410    Ok(actions)
411}
412
413pub fn format_checks(checks: &[Check]) -> String {
414    let rows: Vec<Vec<String>> = checks
415        .iter()
416        .map(|check| {
417            vec![
418                check.status.mark().to_string(),
419                check.name.clone(),
420                check.detail.clone(),
421            ]
422        })
423        .collect();
424
425    let failures = checks.iter().filter(|c| c.status == Status::Fail).count();
426    let warnings = checks.iter().filter(|c| c.status == Status::Warn).count();
427    let fixable = checks.iter().filter(|c| c.fixable).count();
428
429    let mut out = crate::table::render(&["", "CHECK", "DETAIL"], &rows);
430    out.push('\n');
431    out.push_str(&if failures == 0 && warnings == 0 {
432        "all checks passed".to_string()
433    } else {
434        format!("{failures} failure(s), {warnings} warning(s)")
435    });
436    if fixable > 0 {
437        out.push_str(&format!("\n{fixable} can be repaired: rst doctor --fix"));
438    }
439    out
440}
441
442pub fn format_actions(actions: &[Action], dry_run: bool) -> String {
443    let rows: Vec<Vec<String>> = actions
444        .iter()
445        .map(|action| {
446            vec![
447                action.outcome.mark().to_string(),
448                action.name.clone(),
449                action.detail.clone(),
450            ]
451        })
452        .collect();
453
454    let applied = actions.iter().filter(|a| a.outcome == Repair::Applied).count();
455    let failed = actions.iter().filter(|a| a.outcome == Repair::Failed).count();
456
457    let mut out = crate::table::render(&["", "REPAIR", "DETAIL"], &rows);
458    out.push('\n');
459    out.push_str(&if failed > 0 {
460        format!("{applied} fixed, {failed} could not be fixed")
461    } else if applied > 0 && dry_run {
462        format!("{applied} would be fixed (dry run)")
463    } else if applied > 0 {
464        format!("{applied} fixed")
465    } else {
466        "nothing to fix".to_string()
467    });
468    out
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    fn env_from(text: &str) -> Env {
476        let dir = tempfile::tempdir().unwrap();
477        let path = dir.path().join(".env");
478        fs::write(&path, text).unwrap();
479        Env::load(&path).unwrap()
480    }
481
482    #[test]
483    fn poke_disabled_is_a_fixable_failure() {
484        let env = env_from("RUN_MOBILE=true\nMETRO_POKE=false\n");
485        let checks = check_metro_poke(&env, &[]);
486
487        assert_eq!(checks[0].status, Status::Fail);
488        assert!(checks[0].fixable);
489        assert!(checks[0].detail.contains("cache clear"));
490    }
491
492    #[test]
493    fn poke_unset_defaults_to_enabled() {
494        let env = env_from("RUN_MOBILE=true\n");
495        let checks = check_metro_poke(&env, &[]);
496
497        assert_eq!(checks[0].status, Status::Ok);
498        assert!(!checks[0].fixable);
499    }
500
501    #[test]
502    fn poke_is_irrelevant_without_a_mobile_app() {
503        let env = env_from("RUN_MOBILE=false\nMETRO_POKE=false\n");
504        let checks = check_metro_poke(&env, &[]);
505
506        assert_eq!(checks.len(), 1);
507        assert_eq!(checks[0].status, Status::Ok);
508    }
509
510    #[test]
511    fn overlays_missing_is_fixable() {
512        let dir = tempfile::tempdir().unwrap();
513        let checks = check_overlays(dir.path());
514
515        assert_eq!(checks[0].status, Status::Fail);
516        assert!(checks[0].fixable);
517    }
518
519    #[test]
520    fn overlays_present_pass() {
521        let dir = tempfile::tempdir().unwrap();
522        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
523
524        assert_eq!(check_overlays(dir.path())[0].status, Status::Ok);
525    }
526
527    #[test]
528    fn a_workspace_with_no_extra_apps_is_not_a_failure() {
529        // docker-compose.extra.yml is written per extra app; demanding it would
530        // be a failure `--fix` could never clear.
531        let dir = tempfile::tempdir().unwrap();
532        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
533
534        let checks = check_overlays(dir.path());
535        assert_eq!(checks[0].status, Status::Ok);
536        assert!(!checks[0].fixable);
537    }
538
539    #[test]
540    fn set_env_key_replaces_in_place_and_keeps_comments() {
541        let dir = tempfile::tempdir().unwrap();
542        let path = dir.path().join(".env");
543        fs::write(&path, "# keep me\nRUN_MOBILE=true\nMETRO_POKE=false\nPORT=1\n").unwrap();
544
545        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
546
547        let text = fs::read_to_string(&path).unwrap();
548        assert!(text.contains("# keep me"));
549        assert!(text.contains("METRO_POKE=true"));
550        assert!(!text.contains("METRO_POKE=false"));
551        // Replaced where it stood, so surrounding settings keep their order.
552        assert!(text.find("RUN_MOBILE").unwrap() < text.find("METRO_POKE").unwrap());
553        assert!(text.find("METRO_POKE").unwrap() < text.find("PORT").unwrap());
554    }
555
556    #[test]
557    fn set_env_key_appends_with_the_reason_when_absent() {
558        let dir = tempfile::tempdir().unwrap();
559        let path = dir.path().join(".env");
560        fs::write(&path, "RUN_MOBILE=true\n").unwrap();
561
562        set_env_key(&path, METRO_POKE, "true", "first line\nsecond line").unwrap();
563
564        let text = fs::read_to_string(&path).unwrap();
565        assert!(text.contains("# first line"));
566        assert!(text.contains("# second line"));
567        assert!(text.contains("METRO_POKE=true"));
568        assert!(text.starts_with("RUN_MOBILE=true"));
569    }
570
571    #[test]
572    fn set_env_key_handles_an_exported_line() {
573        let dir = tempfile::tempdir().unwrap();
574        let path = dir.path().join(".env");
575        fs::write(&path, "export METRO_POKE=false\n").unwrap();
576
577        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
578
579        let text = fs::read_to_string(&path).unwrap();
580        assert!(text.contains("METRO_POKE=true"));
581        assert!(!text.contains("false"));
582    }
583
584    #[test]
585    fn a_similar_key_is_not_mistaken_for_the_real_one() {
586        let dir = tempfile::tempdir().unwrap();
587        let path = dir.path().join(".env");
588        fs::write(&path, "METRO_POKE_INTERVAL=1\n").unwrap();
589
590        set_env_key(&path, METRO_POKE, "true", "why").unwrap();
591
592        let text = fs::read_to_string(&path).unwrap();
593        assert!(text.contains("METRO_POKE_INTERVAL=1"));
594        assert!(text.contains("\nMETRO_POKE=true"));
595    }
596
597    #[test]
598    fn report_points_at_the_fix_when_something_is_repairable() {
599        let checks = check_metro_poke(&env_from("RUN_MOBILE=true\nMETRO_POKE=false\n"), &[]);
600        let report = format_checks(&checks);
601
602        assert!(report.contains("rst doctor --fix"));
603        assert!(report.contains("1 failure(s)"));
604    }
605
606    #[test]
607    fn report_is_quiet_when_all_is_well() {
608        let report = format_checks(&[Check::ok("docker", "fine")]);
609
610        assert!(report.contains("all checks passed"));
611        assert!(!report.contains("--fix"));
612    }
613
614    #[test]
615    fn actions_report_distinguishes_a_dry_run() {
616        let applied = vec![Action::applied("metro poke", "would set it")];
617
618        assert!(format_actions(&applied, true).contains("would be fixed"));
619        assert!(format_actions(&applied, false).contains("1 fixed"));
620        assert!(format_actions(&[Action::skipped("x", "y")], false).contains("nothing to fix"));
621    }
622}
623
624#[cfg(test)]
625mod root_app_detection_tests {
626    use super::*;
627
628    fn workspace_with(dirs: &[(&str, &str)], env_text: &str) -> (tempfile::TempDir, Workspace) {
629        let dir = tempfile::tempdir().unwrap();
630        let root = dir.path().to_path_buf();
631        fs::create_dir_all(root.join(".run")).unwrap();
632        fs::write(root.join(".run").join(".env"), env_text).unwrap();
633        for (name, manifest) in dirs {
634            fs::create_dir_all(root.join(name)).unwrap();
635            if !manifest.is_empty() {
636                fs::write(root.join(name).join("package.json"), manifest).unwrap();
637            }
638        }
639        let workspace = Workspace {
640            root: root.clone(),
641            run_dir: root.join(".run"),
642        };
643        (dir, workspace)
644    }
645
646    fn env_of(workspace: &Workspace) -> Env {
647        Env::load(&workspace.env_path()).unwrap()
648    }
649
650    #[test]
651    fn an_app_beside_the_repo_is_found() {
652        let (_guard, workspace) = workspace_with(
653            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
654            "FRONTEND_DIR=/w/platform\n",
655        );
656
657        assert_eq!(undeclared_root_apps(&workspace, &env_of(&workspace)), vec!["seeder"]);
658    }
659
660    #[test]
661    fn the_frontend_and_backend_repos_are_not_apps() {
662        let (_guard, workspace) = workspace_with(
663            &[
664                ("platform", r#"{"scripts":{"dev":"vite"}}"#),
665                ("api", r#"{"scripts":{"start":"node ."}}"#),
666            ],
667            "FRONTEND_DIR=/w/platform\nBACKEND_DIR=/w/api\n",
668        );
669
670        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
671    }
672
673    #[test]
674    fn an_already_declared_app_is_not_offered_twice() {
675        let (_guard, workspace) = workspace_with(
676            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
677            "ROOT_APPS=seeder\n",
678        );
679
680        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
681    }
682
683    #[test]
684    fn a_name_and_directory_pair_still_counts_as_declared() {
685        let (_guard, workspace) = workspace_with(
686            &[("althaqeel-seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
687            "ROOT_APPS=seeder:althaqeel-seeder\n",
688        );
689
690        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
691    }
692
693    #[test]
694    fn a_directory_with_no_manifest_is_not_an_app() {
695        let (_guard, workspace) = workspace_with(&[("docs", "")], "");
696
697        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
698    }
699
700    #[test]
701    fn a_library_with_nothing_to_run_is_not_an_app() {
702        let (_guard, workspace) = workspace_with(
703            &[("shared", r#"{"scripts":{"build":"tsc","test":"vitest"}}"#)],
704            "",
705        );
706
707        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
708    }
709
710    #[test]
711    fn node_modules_and_hidden_directories_are_ignored() {
712        let (_guard, workspace) = workspace_with(
713            &[
714                ("node_modules", r#"{"scripts":{"start":"x"}}"#),
715                (".cache", r#"{"scripts":{"start":"x"}}"#),
716            ],
717            "",
718        );
719
720        assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
721    }
722
723    #[test]
724    fn the_check_says_init_update_cannot_see_them() {
725        let (_guard, workspace) = workspace_with(
726            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
727            "",
728        );
729
730        let checks = check_root_apps(&workspace, &env_of(&workspace));
731
732        assert_eq!(checks[0].status, Status::Warn);
733        assert!(checks[0].fixable);
734        assert!(checks[0].detail.contains("seeder"));
735    }
736
737    #[test]
738    fn fixing_writes_the_app_into_root_apps() {
739        let (_guard, workspace) = workspace_with(
740            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
741            "FRONTEND_DIR=/w/platform\n",
742        );
743
744        let action = fix_root_apps(&workspace, false);
745
746        assert_eq!(action.outcome, Repair::Applied);
747        let text = fs::read_to_string(workspace.env_path()).unwrap();
748        assert!(text.contains("ROOT_APPS=seeder"));
749        assert!(text.contains("FRONTEND_DIR=/w/platform"));
750    }
751
752    #[test]
753    fn a_dry_run_writes_nothing() {
754        let (_guard, workspace) = workspace_with(
755            &[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
756            "",
757        );
758
759        fix_root_apps(&workspace, true);
760
761        assert!(!fs::read_to_string(workspace.env_path()).unwrap().contains("ROOT_APPS"));
762    }
763
764    #[test]
765    fn fixing_keeps_apps_that_are_already_declared() {
766        let (_guard, workspace) = workspace_with(
767            &[
768                ("seeder", r#"{"scripts":{"dev":"node server.js"}}"#),
769                ("tools", r#"{"scripts":{"start":"node ."}}"#),
770            ],
771            "ROOT_APPS=tools\n",
772        );
773
774        fix_root_apps(&workspace, false);
775
776        let text = fs::read_to_string(workspace.env_path()).unwrap();
777        assert!(text.contains("tools"));
778        assert!(text.contains("seeder"));
779    }
780}