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 check_overlays(run_dir: &Path) -> Vec<Check> {
183 if run_dir.join("docker-compose.packages.yml").is_file() {
184 return vec![Check::ok("overlays", "generated compose overlays present")];
185 }
186 vec![Check::fixable(
187 "overlays",
188 Status::Fail,
189 "missing: docker-compose.packages.yml — run `rst generate`".to_string(),
190 )]
191}
192
193pub fn check_docker() -> Vec<Check> {
194 if docker_available() {
195 vec![Check::ok("docker", "docker compose v2 available")]
196 } else {
197 vec![Check::warn("docker", "'docker compose' unavailable — is docker running?")]
198 }
199}
200
201pub fn run(workspace: &Workspace) -> Result<Vec<Check>> {
202 let env = Env::load(&workspace.env_path())?;
203 let containers = if docker_available() { running_metro_containers() } else { Vec::new() };
204
205 let mut checks = check_docker();
206 checks.extend(check_metro_poke(&env, &containers));
207 checks.extend(check_overlays(&workspace.run_dir));
208 Ok(checks)
209}
210
211pub fn set_env_key(path: &Path, key: &str, value: &str, note: &str) -> Result<()> {
214 let text = fs::read_to_string(path).unwrap_or_default();
215 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
216
217 let existing = lines.iter().position(|line| {
218 let trimmed = line.trim().strip_prefix("export ").unwrap_or(line.trim());
219 trimmed
220 .split_once('=')
221 .map(|(name, _)| name.trim() == key)
222 .unwrap_or(false)
223 });
224
225 match existing {
226 Some(index) => lines[index] = format!("{key}={value}"),
227 None => {
228 if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
229 lines.push(String::new());
230 }
231 for line in note.lines() {
232 lines.push(format!("# {line}"));
233 }
234 lines.push(format!("{key}={value}"));
235 }
236 }
237
238 fs::write(path, format!("{}\n", lines.join("\n")))?;
239 Ok(())
240}
241
242const 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.";
243
244fn fix_overlays(workspace: &Workspace, dry_run: bool) -> Action {
247 let missing = check_overlays(&workspace.run_dir)
248 .into_iter()
249 .any(|check| check.status != Status::Ok);
250 if !missing {
251 return Action::skipped("overlays", "already generated");
252 }
253 if dry_run {
254 return Action::applied("overlays", "would regenerate the compose overlays");
255 }
256
257 let mut env = match Env::load(&workspace.env_path()) {
258 Ok(env) => env,
259 Err(error) => return Action::failed("overlays", error.to_string()),
260 };
261 env.derive(&workspace.root);
262
263 let package = match crate::compose::package_dir() {
264 Ok(dir) => dir,
265 Err(error) => return Action::failed("overlays", error.to_string()),
266 };
267 match crate::generate::all(&workspace.run_dir, &env, &package) {
268 Ok(()) => Action::applied("overlays", "regenerated the compose overlays"),
269 Err(error) => Action::failed("overlays", error.to_string()),
270 }
271}
272
273pub fn fix(workspace: &Workspace, dry_run: bool) -> Result<Vec<Action>> {
274 let env = Env::load(&workspace.env_path())?;
275 let mut actions = Vec::new();
276
277 if runs_mobile(&env) {
278 let configured = env.get(METRO_POKE);
279 let needs_fix = matches!(configured, Some(value) if !Env::truthy(value));
280 if needs_fix {
281 if dry_run {
282 actions.push(Action::applied("metro poke", format!("would set {METRO_POKE}=true")));
283 } else {
284 match set_env_key(&workspace.env_path(), METRO_POKE, "true", POKE_NOTE) {
285 Ok(()) => actions.push(Action::applied(
286 "metro poke",
287 format!("set {METRO_POKE}=true — recreate the mobile container to apply it"),
288 )),
289 Err(error) => actions.push(Action::failed("metro poke", error.to_string())),
290 }
291 }
292 } else {
293 actions.push(Action::skipped("metro poke", "already enabled"));
294 }
295 } else {
296 actions.push(Action::skipped("metro poke", "workspace runs no mobile app"));
297 }
298
299 actions.push(fix_overlays(workspace, dry_run));
300 Ok(actions)
301}
302
303pub fn format_checks(checks: &[Check]) -> String {
304 let mut lines: Vec<String> = checks
305 .iter()
306 .map(|check| format!(" {} {}: {}", check.status.mark(), check.name, check.detail))
307 .collect();
308
309 let failures = checks.iter().filter(|c| c.status == Status::Fail).count();
310 let warnings = checks.iter().filter(|c| c.status == Status::Warn).count();
311 let fixable = checks.iter().filter(|c| c.fixable).count();
312
313 lines.push(String::new());
314 lines.push(if failures == 0 && warnings == 0 {
315 "all checks passed".to_string()
316 } else {
317 format!("{failures} failure(s), {warnings} warning(s)")
318 });
319 if fixable > 0 {
320 lines.push(format!("{fixable} can be repaired: rst doctor --fix"));
321 }
322 lines.join("\n")
323}
324
325pub fn format_actions(actions: &[Action], dry_run: bool) -> String {
326 let mut lines: Vec<String> = actions
327 .iter()
328 .map(|action| format!(" {} {}: {}", action.outcome.mark(), action.name, action.detail))
329 .collect();
330
331 let applied = actions.iter().filter(|a| a.outcome == Repair::Applied).count();
332 let failed = actions.iter().filter(|a| a.outcome == Repair::Failed).count();
333
334 lines.push(String::new());
335 lines.push(if failed > 0 {
336 format!("{applied} fixed, {failed} could not be fixed")
337 } else if applied > 0 && dry_run {
338 format!("{applied} would be fixed (dry run)")
339 } else if applied > 0 {
340 format!("{applied} fixed")
341 } else {
342 "nothing to fix".to_string()
343 });
344 lines.join("\n")
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn env_from(text: &str) -> Env {
352 let dir = tempfile::tempdir().unwrap();
353 let path = dir.path().join(".env");
354 fs::write(&path, text).unwrap();
355 Env::load(&path).unwrap()
356 }
357
358 #[test]
359 fn poke_disabled_is_a_fixable_failure() {
360 let env = env_from("RUN_MOBILE=true\nMETRO_POKE=false\n");
361 let checks = check_metro_poke(&env, &[]);
362
363 assert_eq!(checks[0].status, Status::Fail);
364 assert!(checks[0].fixable);
365 assert!(checks[0].detail.contains("cache clear"));
366 }
367
368 #[test]
369 fn poke_unset_defaults_to_enabled() {
370 let env = env_from("RUN_MOBILE=true\n");
371 let checks = check_metro_poke(&env, &[]);
372
373 assert_eq!(checks[0].status, Status::Ok);
374 assert!(!checks[0].fixable);
375 }
376
377 #[test]
378 fn poke_is_irrelevant_without_a_mobile_app() {
379 let env = env_from("RUN_MOBILE=false\nMETRO_POKE=false\n");
380 let checks = check_metro_poke(&env, &[]);
381
382 assert_eq!(checks.len(), 1);
383 assert_eq!(checks[0].status, Status::Ok);
384 }
385
386 #[test]
387 fn overlays_missing_is_fixable() {
388 let dir = tempfile::tempdir().unwrap();
389 let checks = check_overlays(dir.path());
390
391 assert_eq!(checks[0].status, Status::Fail);
392 assert!(checks[0].fixable);
393 }
394
395 #[test]
396 fn overlays_present_pass() {
397 let dir = tempfile::tempdir().unwrap();
398 fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
399
400 assert_eq!(check_overlays(dir.path())[0].status, Status::Ok);
401 }
402
403 #[test]
404 fn a_workspace_with_no_extra_apps_is_not_a_failure() {
405 let dir = tempfile::tempdir().unwrap();
408 fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
409
410 let checks = check_overlays(dir.path());
411 assert_eq!(checks[0].status, Status::Ok);
412 assert!(!checks[0].fixable);
413 }
414
415 #[test]
416 fn set_env_key_replaces_in_place_and_keeps_comments() {
417 let dir = tempfile::tempdir().unwrap();
418 let path = dir.path().join(".env");
419 fs::write(&path, "# keep me\nRUN_MOBILE=true\nMETRO_POKE=false\nPORT=1\n").unwrap();
420
421 set_env_key(&path, METRO_POKE, "true", "why").unwrap();
422
423 let text = fs::read_to_string(&path).unwrap();
424 assert!(text.contains("# keep me"));
425 assert!(text.contains("METRO_POKE=true"));
426 assert!(!text.contains("METRO_POKE=false"));
427 assert!(text.find("RUN_MOBILE").unwrap() < text.find("METRO_POKE").unwrap());
429 assert!(text.find("METRO_POKE").unwrap() < text.find("PORT").unwrap());
430 }
431
432 #[test]
433 fn set_env_key_appends_with_the_reason_when_absent() {
434 let dir = tempfile::tempdir().unwrap();
435 let path = dir.path().join(".env");
436 fs::write(&path, "RUN_MOBILE=true\n").unwrap();
437
438 set_env_key(&path, METRO_POKE, "true", "first line\nsecond line").unwrap();
439
440 let text = fs::read_to_string(&path).unwrap();
441 assert!(text.contains("# first line"));
442 assert!(text.contains("# second line"));
443 assert!(text.contains("METRO_POKE=true"));
444 assert!(text.starts_with("RUN_MOBILE=true"));
445 }
446
447 #[test]
448 fn set_env_key_handles_an_exported_line() {
449 let dir = tempfile::tempdir().unwrap();
450 let path = dir.path().join(".env");
451 fs::write(&path, "export METRO_POKE=false\n").unwrap();
452
453 set_env_key(&path, METRO_POKE, "true", "why").unwrap();
454
455 let text = fs::read_to_string(&path).unwrap();
456 assert!(text.contains("METRO_POKE=true"));
457 assert!(!text.contains("false"));
458 }
459
460 #[test]
461 fn a_similar_key_is_not_mistaken_for_the_real_one() {
462 let dir = tempfile::tempdir().unwrap();
463 let path = dir.path().join(".env");
464 fs::write(&path, "METRO_POKE_INTERVAL=1\n").unwrap();
465
466 set_env_key(&path, METRO_POKE, "true", "why").unwrap();
467
468 let text = fs::read_to_string(&path).unwrap();
469 assert!(text.contains("METRO_POKE_INTERVAL=1"));
470 assert!(text.contains("\nMETRO_POKE=true"));
471 }
472
473 #[test]
474 fn report_points_at_the_fix_when_something_is_repairable() {
475 let checks = check_metro_poke(&env_from("RUN_MOBILE=true\nMETRO_POKE=false\n"), &[]);
476 let report = format_checks(&checks);
477
478 assert!(report.contains("rst doctor --fix"));
479 assert!(report.contains("1 failure(s)"));
480 }
481
482 #[test]
483 fn report_is_quiet_when_all_is_well() {
484 let report = format_checks(&[Check::ok("docker", "fine")]);
485
486 assert!(report.contains("all checks passed"));
487 assert!(!report.contains("--fix"));
488 }
489
490 #[test]
491 fn actions_report_distinguishes_a_dry_run() {
492 let applied = vec![Action::applied("metro poke", "would set it")];
493
494 assert!(format_actions(&applied, true).contains("would be fixed"));
495 assert!(format_actions(&applied, false).contains("1 fixed"));
496 assert!(format_actions(&[Action::skipped("x", "y")], false).contains("nothing to fix"));
497 }
498}