1use 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 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
99pub 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
115pub 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 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
179pub 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
221fn 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 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
282pub 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
315fn 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
346fn 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 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 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}