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
252#[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 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
324fn 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 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
414pub 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
447fn 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
478fn 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 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 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 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 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}