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 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
453pub 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
475fn 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
506fn 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 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 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 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 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}