run-stack 0.5.3

One command to boot a full local stack in Docker: API, Vite apps, Expo mobile, desktop renderer, database, mail and a status dashboard.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
//! Checks for the faults that cost real debugging time, and repairs for the
//! ones whose correct value is discoverable.
//!
//! The motivating case: Metro runs inside the container, but file events do not
//! cross the bind mount from the host, so its watcher never sees an edit. The
//! entrypoint's poker exists to bridge that, and a stale `METRO_POKE=false`
//! baked into a container turns it off silently - every change then needs a
//! cache clear, with nothing to say why.

use std::fs;
use std::path::Path;
use std::process::Command;

use anyhow::Result;

use crate::env::Env;
use crate::workspace::Workspace;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
    Ok,
    Warn,
    Fail,
}

impl Status {
    pub fn mark(self) -> &'static str {
        match self {
            Status::Ok => "",
            Status::Warn => "!",
            Status::Fail => "",
        }
    }
}

#[derive(Debug, Clone)]
pub struct Check {
    pub name: String,
    pub status: Status,
    pub detail: String,
    /// Set when `--fix` can repair this without guessing.
    pub fixable: bool,
}

impl Check {
    fn ok(name: &str, detail: impl Into<String>) -> Self {
        Self { name: name.into(), status: Status::Ok, detail: detail.into(), fixable: false }
    }

    fn warn(name: &str, detail: impl Into<String>) -> Self {
        Self { name: name.into(), status: Status::Warn, detail: detail.into(), fixable: false }
    }

    fn fixable(name: &str, status: Status, detail: impl Into<String>) -> Self {
        Self { name: name.into(), status, detail: detail.into(), fixable: true }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Repair {
    Applied,
    Skipped,
    Failed,
}

impl Repair {
    fn mark(self) -> &'static str {
        match self {
            Repair::Applied => "",
            Repair::Skipped => "·",
            Repair::Failed => "",
        }
    }
}

#[derive(Debug, Clone)]
pub struct Action {
    pub name: String,
    pub outcome: Repair,
    pub detail: String,
}

impl Action {
    fn applied(name: &str, detail: impl Into<String>) -> Self {
        Self { name: name.into(), outcome: Repair::Applied, detail: detail.into() }
    }

    fn skipped(name: &str, detail: impl Into<String>) -> Self {
        Self { name: name.into(), outcome: Repair::Skipped, detail: detail.into() }
    }

    fn failed(name: &str, detail: impl Into<String>) -> Self {
        Self { name: name.into(), outcome: Repair::Failed, detail: detail.into() }
    }
}

pub const METRO_POKE: &str = "METRO_POKE";

/// Does this workspace run a mobile app whose Metro lives in a container?
pub fn runs_mobile(env: &Env) -> bool {
    match env.get("RUN_MOBILE") {
        Some(value) => Env::truthy(value),
        None => false,
    }
}

pub fn docker_available() -> bool {
    Command::new("docker")
        .args(["compose", "version"])
        .output()
        .map(|output| output.status.success())
        .unwrap_or(false)
}

/// `METRO_POKE` as the named container actually has it, which is not always
/// what the env file says: a container keeps the environment it was created
/// with until it is recreated.
pub fn container_metro_poke(container: &str) -> Option<String> {
    let output = Command::new("docker")
        .args(["inspect", container, "--format", "{{range .Config.Env}}{{println .}}{{end}}"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&output.stdout);
    text.lines()
        .find_map(|line| line.strip_prefix(&format!("{METRO_POKE}=")))
        .map(|value| value.trim().to_string())
}

pub fn running_metro_containers() -> Vec<String> {
    let output = match Command::new("docker")
        .args(["ps", "--format", "{{.Names}}"])
        .output()
    {
        Ok(output) if output.status.success() => output,
        _ => return Vec::new(),
    };
    String::from_utf8_lossy(&output.stdout)
        .lines()
        .filter(|name| name.contains("mobile"))
        .map(str::to_string)
        .collect()
}

pub fn check_metro_poke(env: &Env, containers: &[String]) -> Vec<Check> {
    if !runs_mobile(env) {
        return vec![Check::ok("metro poke", "workspace runs no mobile app")];
    }

    let mut checks = Vec::new();
    let configured = env.get(METRO_POKE);
    match configured {
        Some(value) if !Env::truthy(value) => checks.push(Check::fixable(
            "metro poke",
            Status::Fail,
            format!("{METRO_POKE}={value} - Metro cannot see host edits, so every change needs a cache clear"),
        )),
        Some(_) => checks.push(Check::ok("metro poke", format!("{METRO_POKE} enabled"))),
        None => checks.push(Check::ok("metro poke", "unset - defaults to enabled")),
    }

    // A container keeps its creation-time environment, so the file can be right
    // while the thing actually running is still wrong.
    for container in containers {
        match container_metro_poke(container) {
            Some(value) if !Env::truthy(&value) => checks.push(Check::warn(
                "metro poke (running)",
                format!("{container} was created with {METRO_POKE}={value} - recreate it to pick up the fix"),
            )),
            Some(_) => checks.push(Check::ok("metro poke (running)", format!("{container} has it enabled"))),
            None => {}
        }
    }
    checks
}

/// Only the overlay `generate` always writes. docker-compose.extra.yml is
/// written per extra app, so a workspace with none legitimately has no such
/// file - demanding it would be a failure no repair could ever clear.
pub fn check_overlays(run_dir: &Path) -> Vec<Check> {
    if run_dir.join("docker-compose.packages.yml").is_file() {
        return vec![Check::ok("overlays", "generated compose overlays present")];
    }
    vec![Check::fixable(
        "overlays",
        Status::Fail,
        "missing: docker-compose.packages.yml - run `rst generate`".to_string(),
    )]
}

pub fn check_docker() -> Vec<Check> {
    if docker_available() {
        vec![Check::ok("docker", "docker compose v2 available")]
    } else {
        vec![Check::warn("docker", "'docker compose' unavailable - is docker running?")]
    }
}

pub fn run(workspace: &Workspace) -> Result<Vec<Check>> {
    let env = Env::load(&workspace.env_path())?;
    let containers = if docker_available() { running_metro_containers() } else { Vec::new() };

    let mut checks = check_docker();
    checks.extend(check_metro_poke(&env, &containers));
    checks.extend(check_overlays(&workspace.run_dir));
    Ok(checks)
}

/// Set `key` in a .env file, preserving comments and the order of what is
/// already there. Appends with a note when the key is absent.
pub fn set_env_key(path: &Path, key: &str, value: &str, note: &str) -> Result<()> {
    let text = fs::read_to_string(path).unwrap_or_default();
    let mut lines: Vec<String> = text.lines().map(str::to_string).collect();

    let existing = lines.iter().position(|line| {
        let trimmed = line.trim().strip_prefix("export ").unwrap_or(line.trim());
        trimmed
            .split_once('=')
            .map(|(name, _)| name.trim() == key)
            .unwrap_or(false)
    });

    match existing {
        Some(index) => lines[index] = format!("{key}={value}"),
        None => {
            if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
                lines.push(String::new());
            }
            for line in note.lines() {
                lines.push(format!("# {line}"));
            }
            lines.push(format!("{key}={value}"));
        }
    }

    fs::write(path, format!("{}\n", lines.join("\n")))?;
    Ok(())
}

const 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.";

/// Regenerating the overlays is `rst generate`; doctor calls the same code so
/// a missing overlay is repaired rather than merely reported.
fn fix_overlays(workspace: &Workspace, dry_run: bool) -> Action {
    let missing = check_overlays(&workspace.run_dir)
        .into_iter()
        .any(|check| check.status != Status::Ok);
    if !missing {
        return Action::skipped("overlays", "already generated");
    }
    if dry_run {
        return Action::applied("overlays", "would regenerate the compose overlays");
    }

    let mut env = match Env::load(&workspace.env_path()) {
        Ok(env) => env,
        Err(error) => return Action::failed("overlays", error.to_string()),
    };
    env.derive(&workspace.root);

    let package = match crate::compose::package_dir() {
        Ok(dir) => dir,
        Err(error) => return Action::failed("overlays", error.to_string()),
    };
    match crate::generate::all(&workspace.run_dir, &env, &package) {
        Ok(()) => Action::applied("overlays", "regenerated the compose overlays"),
        Err(error) => Action::failed("overlays", error.to_string()),
    }
}

pub fn fix(workspace: &Workspace, dry_run: bool) -> Result<Vec<Action>> {
    let env = Env::load(&workspace.env_path())?;
    let mut actions = Vec::new();

    if runs_mobile(&env) {
        let configured = env.get(METRO_POKE);
        let needs_fix = matches!(configured, Some(value) if !Env::truthy(value));
        if needs_fix {
            if dry_run {
                actions.push(Action::applied("metro poke", format!("would set {METRO_POKE}=true")));
            } else {
                match set_env_key(&workspace.env_path(), METRO_POKE, "true", POKE_NOTE) {
                    Ok(()) => actions.push(Action::applied(
                        "metro poke",
                        format!("set {METRO_POKE}=true - recreate the mobile container to apply it"),
                    )),
                    Err(error) => actions.push(Action::failed("metro poke", error.to_string())),
                }
            }
        } else {
            actions.push(Action::skipped("metro poke", "already enabled"));
        }
    } else {
        actions.push(Action::skipped("metro poke", "workspace runs no mobile app"));
    }

    actions.push(fix_overlays(workspace, dry_run));
    Ok(actions)
}

pub fn format_checks(checks: &[Check]) -> String {
    let rows: Vec<Vec<String>> = checks
        .iter()
        .map(|check| {
            vec![
                check.status.mark().to_string(),
                check.name.clone(),
                check.detail.clone(),
            ]
        })
        .collect();

    let failures = checks.iter().filter(|c| c.status == Status::Fail).count();
    let warnings = checks.iter().filter(|c| c.status == Status::Warn).count();
    let fixable = checks.iter().filter(|c| c.fixable).count();

    let mut out = crate::table::render(&["", "CHECK", "DETAIL"], &rows);
    out.push('\n');
    out.push_str(&if failures == 0 && warnings == 0 {
        "all checks passed".to_string()
    } else {
        format!("{failures} failure(s), {warnings} warning(s)")
    });
    if fixable > 0 {
        out.push_str(&format!("\n{fixable} can be repaired: rst doctor --fix"));
    }
    out
}

pub fn format_actions(actions: &[Action], dry_run: bool) -> String {
    let rows: Vec<Vec<String>> = actions
        .iter()
        .map(|action| {
            vec![
                action.outcome.mark().to_string(),
                action.name.clone(),
                action.detail.clone(),
            ]
        })
        .collect();

    let applied = actions.iter().filter(|a| a.outcome == Repair::Applied).count();
    let failed = actions.iter().filter(|a| a.outcome == Repair::Failed).count();

    let mut out = crate::table::render(&["", "REPAIR", "DETAIL"], &rows);
    out.push('\n');
    out.push_str(&if failed > 0 {
        format!("{applied} fixed, {failed} could not be fixed")
    } else if applied > 0 && dry_run {
        format!("{applied} would be fixed (dry run)")
    } else if applied > 0 {
        format!("{applied} fixed")
    } else {
        "nothing to fix".to_string()
    });
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn env_from(text: &str) -> Env {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".env");
        fs::write(&path, text).unwrap();
        Env::load(&path).unwrap()
    }

    #[test]
    fn poke_disabled_is_a_fixable_failure() {
        let env = env_from("RUN_MOBILE=true\nMETRO_POKE=false\n");
        let checks = check_metro_poke(&env, &[]);

        assert_eq!(checks[0].status, Status::Fail);
        assert!(checks[0].fixable);
        assert!(checks[0].detail.contains("cache clear"));
    }

    #[test]
    fn poke_unset_defaults_to_enabled() {
        let env = env_from("RUN_MOBILE=true\n");
        let checks = check_metro_poke(&env, &[]);

        assert_eq!(checks[0].status, Status::Ok);
        assert!(!checks[0].fixable);
    }

    #[test]
    fn poke_is_irrelevant_without_a_mobile_app() {
        let env = env_from("RUN_MOBILE=false\nMETRO_POKE=false\n");
        let checks = check_metro_poke(&env, &[]);

        assert_eq!(checks.len(), 1);
        assert_eq!(checks[0].status, Status::Ok);
    }

    #[test]
    fn overlays_missing_is_fixable() {
        let dir = tempfile::tempdir().unwrap();
        let checks = check_overlays(dir.path());

        assert_eq!(checks[0].status, Status::Fail);
        assert!(checks[0].fixable);
    }

    #[test]
    fn overlays_present_pass() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();

        assert_eq!(check_overlays(dir.path())[0].status, Status::Ok);
    }

    #[test]
    fn a_workspace_with_no_extra_apps_is_not_a_failure() {
        // docker-compose.extra.yml is written per extra app; demanding it would
        // be a failure `--fix` could never clear.
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();

        let checks = check_overlays(dir.path());
        assert_eq!(checks[0].status, Status::Ok);
        assert!(!checks[0].fixable);
    }

    #[test]
    fn set_env_key_replaces_in_place_and_keeps_comments() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".env");
        fs::write(&path, "# keep me\nRUN_MOBILE=true\nMETRO_POKE=false\nPORT=1\n").unwrap();

        set_env_key(&path, METRO_POKE, "true", "why").unwrap();

        let text = fs::read_to_string(&path).unwrap();
        assert!(text.contains("# keep me"));
        assert!(text.contains("METRO_POKE=true"));
        assert!(!text.contains("METRO_POKE=false"));
        // Replaced where it stood, so surrounding settings keep their order.
        assert!(text.find("RUN_MOBILE").unwrap() < text.find("METRO_POKE").unwrap());
        assert!(text.find("METRO_POKE").unwrap() < text.find("PORT").unwrap());
    }

    #[test]
    fn set_env_key_appends_with_the_reason_when_absent() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".env");
        fs::write(&path, "RUN_MOBILE=true\n").unwrap();

        set_env_key(&path, METRO_POKE, "true", "first line\nsecond line").unwrap();

        let text = fs::read_to_string(&path).unwrap();
        assert!(text.contains("# first line"));
        assert!(text.contains("# second line"));
        assert!(text.contains("METRO_POKE=true"));
        assert!(text.starts_with("RUN_MOBILE=true"));
    }

    #[test]
    fn set_env_key_handles_an_exported_line() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".env");
        fs::write(&path, "export METRO_POKE=false\n").unwrap();

        set_env_key(&path, METRO_POKE, "true", "why").unwrap();

        let text = fs::read_to_string(&path).unwrap();
        assert!(text.contains("METRO_POKE=true"));
        assert!(!text.contains("false"));
    }

    #[test]
    fn a_similar_key_is_not_mistaken_for_the_real_one() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join(".env");
        fs::write(&path, "METRO_POKE_INTERVAL=1\n").unwrap();

        set_env_key(&path, METRO_POKE, "true", "why").unwrap();

        let text = fs::read_to_string(&path).unwrap();
        assert!(text.contains("METRO_POKE_INTERVAL=1"));
        assert!(text.contains("\nMETRO_POKE=true"));
    }

    #[test]
    fn report_points_at_the_fix_when_something_is_repairable() {
        let checks = check_metro_poke(&env_from("RUN_MOBILE=true\nMETRO_POKE=false\n"), &[]);
        let report = format_checks(&checks);

        assert!(report.contains("rst doctor --fix"));
        assert!(report.contains("1 failure(s)"));
    }

    #[test]
    fn report_is_quiet_when_all_is_well() {
        let report = format_checks(&[Check::ok("docker", "fine")]);

        assert!(report.contains("all checks passed"));
        assert!(!report.contains("--fix"));
    }

    #[test]
    fn actions_report_distinguishes_a_dry_run() {
        let applied = vec![Action::applied("metro poke", "would set it")];

        assert!(format_actions(&applied, true).contains("would be fixed"));
        assert!(format_actions(&applied, false).contains("1 fixed"));
        assert!(format_actions(&[Action::skipped("x", "y")], false).contains("nothing to fix"));
    }
}