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