rosace-cli 0.1.0

rsc: the ROSACE CLI for scaffolding, running, building, and analyzing apps
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
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
//! `rsc run [--target macos|windows|linux|web|ios]` (or `--mac`/`--win`/
//! `--lnx` shorthand) — build and run the current app on a platform, hiding
//! every manual step (wasm-bindgen + serve for web; bundle + codesign +
//! simctl for iOS). Reads `rsc.toml` for the app name / bundle id.
//!
//! macOS/Windows/Linux are explicit, separate targets (not one "desktop"
//! bucket) for the same reason `rosace-cli/src/commands/new.rs`'s
//! `Platform` enum is — each has its own toolchain requirements, checked by
//! `preflight` before anything is attempted.

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

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Target {
    MacOs,
    Windows,
    Linux,
    Web,
    Ios,
    Android,
}

pub struct RunOptions {
    pub target: Target,
    pub port: u16,
    /// A device id/name/serial (`rsc devices`' ID column — an iOS
    /// simulator UDID or name, an Android adb serial). Empty means "let
    /// the target pick a sensible default" — iOS defaults to "iPhone 15
    /// Pro", Android auto-detects whatever's connected. Deliberately not
    /// defaulted here: an iOS-shaped default would be actively wrong for
    /// Android (there's no "iPhone 15 Pro" to auto-detect against).
    pub device: String,
}

impl RunOptions {
    pub fn from_args(args: &[String]) -> Result<Self, String> {
        if args.iter().any(|a| a == "--help" || a == "-h") {
            print_help();
            std::process::exit(0);
        }

        let mut target = None;
        let mut port = 8080u16;
        let mut device = String::new();

        let mut i = 0;
        while i < args.len() {
            match args[i].as_str() {
                "--target" | "-t" => {
                    i += 1;
                    target = Some(parse_target(args.get(i).map(String::as_str))?);
                }
                "--mac" => target = Some(Target::MacOs),
                "--win" => target = Some(Target::Windows),
                "--lnx" => target = Some(Target::Linux),
                "--port" => {
                    i += 1;
                    port = args.get(i).and_then(|s| s.parse().ok())
                        .ok_or_else(|| "--port requires a number".to_string())?;
                }
                "--device" => {
                    i += 1;
                    device = args.get(i).cloned()
                        .ok_or_else(|| "--device requires a value".to_string())?;
                }
                other if other.starts_with("--target=") => {
                    target = Some(parse_target(Some(other.trim_start_matches("--target=")))?);
                }
                other if other.starts_with("--port=") => {
                    port = other.trim_start_matches("--port=").parse()
                        .map_err(|_| "invalid --port".to_string())?;
                }
                _ => {}
            }
            i += 1;
        }
        // Default to the host OS — the one platform this run can actually
        // build AND execute locally, without cross-toolchain gymnastics.
        let target = target.unwrap_or_else(host_target);
        Ok(Self { target, port, device })
    }
}

/// The `Target` matching whichever OS `rsc` itself is running on.
fn host_target() -> Target {
    if cfg!(target_os = "macos") { Target::MacOs }
    else if cfg!(target_os = "windows") { Target::Windows }
    else { Target::Linux }
}

fn parse_target(s: Option<&str>) -> Result<Target, String> {
    match s {
        Some("macos") => Ok(Target::MacOs),
        Some("windows") => Ok(Target::Windows),
        Some("linux") => Ok(Target::Linux),
        Some("web") => Ok(Target::Web),
        Some("ios") => Ok(Target::Ios),
        Some("android") => Ok(Target::Android),
        Some(other) => Err(format!("unknown target '{}'. Use: macos, windows, linux, web, ios, android", other)),
        None => Err("--target requires a value (macos, windows, linux, web, ios, android)".to_string()),
    }
}

pub fn print_help() {
    println!("rsc run — build + run the app on a platform");
    println!();
    println!("USAGE:");
    println!("  rsc run [OPTIONS]");
    println!();
    println!("OPTIONS:");
    println!("  --target <t>        macos | windows | linux | web | ios | android (default: host OS)");
    println!("  --mac / --win / --lnx   shorthand for --target macos|windows|linux");
    println!("  --port <n>          Web dev server port (default: 8080)");
    println!("  --device <id>       iOS simulator (name or UDID, default: \"iPhone 15 Pro\") or");
    println!("                      Android device/emulator (adb serial) — run `rsc devices` to list");
    println!("  -h, --help          Print this message");
    println!();
    println!("Before building, a preflight check confirms the tools each target needs");
    println!("are actually installed (codesign for macOS; a rustup cross target for");
    println!("Windows/Linux) and fails fast with install instructions if not — cross-");
    println!("building Windows/Linux from macOS only produces a binary, it can't run it.");
    println!();
    println!("EXAMPLES:");
    println!("  rsc run");
    println!("  rsc run --mac");
    println!("  rsc run --target web --port 3000");
    println!("  rsc run --target ios --device \"iPhone 15\"");
}

pub fn run(opts: RunOptions) -> Result<(), String> {
    preflight(opts.target)?;
    let app = App::read()?;
    match opts.target {
        Target::MacOs => run_macos(&app),
        Target::Windows => run_windows_cross_build(&app),
        Target::Linux => run_linux_cross_build(&app),
        Target::Web => run_web(&app, opts.port),
        Target::Ios => run_ios(&app, &opts.device),
        Target::Android => run_android(&app, &opts.device),
    }
}

// ── Preflight: fail fast with an actionable message, not a raw tool error ──

fn preflight(target: Target) -> Result<(), String> {
    match target {
        Target::MacOs => preflight_macos(),
        Target::Windows => preflight_cross_target("x86_64-pc-windows-gnu", "Windows", Some("mingw-w64")),
        Target::Linux => preflight_cross_target("x86_64-unknown-linux-gnu", "Linux", None),
        Target::Web => Ok(()), // existing inline checks in run_web cover this
        Target::Ios => preflight_ios(),
        Target::Android => preflight_android(),
    }
}

/// `xcodebuild` requires actual Xcode (not just the Command Line Tools) to
/// be selected — `xcode-select -p` alone succeeding isn't enough to prove
/// that, so this actually invokes `xcodebuild -version` and checks it runs.
/// Only gates the real `run_ios_xcodeproj` path; the Phase 20-22 legacy
/// harness (`run_ios_legacy`) only needs `codesign`, already covered by
/// the same check `preflight_macos` runs, so it's not duplicated here.
fn preflight_ios() -> Result<(), String> {
    if !Path::new("ios/App.xcodeproj").exists() {
        return Ok(()); // legacy harness path — its own tools are covered by preflight_macos
    }
    let ok = Command::new("xcodebuild")
        .arg("-version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if ok {
        Ok(())
    } else {
        Err(
            "xcodebuild not found or not runnable. Install Xcode from the App Store, then run:\n    \
             sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer"
                .to_string(),
        )
    }
}

/// Checks the tools `run_android` needs before touching Gradle: `adb`
/// (installs/launches — soft-checked, since building without a device
/// connected is still useful) and `android/gradlew` (hard requirement —
/// `rsc new --platforms android` generates this via `gradle wrapper`; its
/// absence means either an old project or that step failed, both needing
/// the same fix).
fn preflight_android() -> Result<(), String> {
    if !Path::new("android/gradlew").exists() {
        return Err(
            "android/gradlew not found. Either this project predates Android support \
             (recreate with `rsc new --platforms android`), or `gradle wrapper` failed \
             when it was created — run `gradle wrapper` inside android/ yourself \
             (requires Gradle installed: https://gradle.org/install)."
                .to_string(),
        );
    }
    let adb_ok = Command::new("adb").arg("version").output().is_ok();
    if !adb_ok {
        println!("  Warning: adb not found — will build the APK but can't install/launch it.");
        println!("  Install Android platform-tools (via Android Studio or `brew install android-platform-tools`).");
    }
    Ok(())
}

fn preflight_macos() -> Result<(), String> {
    let ok = Command::new("xcrun")
        .args(["-f", "codesign"])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if ok {
        Ok(())
    } else {
        Err("codesign not found. Install Xcode Command Line Tools: xcode-select --install".to_string())
    }
}

/// Windows/Linux from macOS: this can only ever cross-BUILD, never run —
/// there's no emulator wired in. Checks the rustup target is installed and
/// (for Windows) warns if the cross-linker looks missing; doesn't hard-fail
/// on the linker check since detecting it reliably across package managers
/// is best-effort, not something to block on incorrectly.
fn preflight_cross_target(triple: &str, label: &str, linker_hint: Option<&str>) -> Result<(), String> {
    let output = Command::new("rustup")
        .args(["target", "list", "--installed"])
        .output()
        .map_err(|e| format!("failed to run rustup: {}", e))?;
    let installed = String::from_utf8_lossy(&output.stdout);
    if !installed.contains(triple) {
        let mut msg = format!(
            "{} target not installed. Run:\n    rustup target add {}\n",
            label, triple
        );
        if let Some(hint) = linker_hint {
            msg.push_str(&format!(
                "  You'll also need a cross-linker. On macOS:\n    brew install {}\n",
                hint
            ));
        }
        msg.push_str(&format!(
            "  Note: this only lets you BUILD a {} binary from this host, not run it.",
            label
        ));
        return Err(msg);
    }
    Ok(())
}

// ── Manifest ───────────────────────────────────────────────────────────────

struct App {
    name: String,
    crate_name: String,
    bundle_id: String,
}

impl App {
    /// Read `rsc.toml` (falling back to `Cargo.toml`'s package name).
    fn read() -> Result<Self, String> {
        if !Path::new("Cargo.toml").exists() {
            return Err("no Cargo.toml here — run `rsc run` from an app directory".to_string());
        }
        let mut name = None;
        let mut bundle = None;
        if let Ok(s) = fs::read_to_string("rsc.toml") {
            for line in s.lines() {
                if let Some((k, v)) = line.split_once('=') {
                    let val = v.trim().trim_matches('"').to_string();
                    match k.trim() {
                        "name" => name = Some(val),
                        "bundle_id" => bundle = Some(val),
                        _ => {}
                    }
                }
            }
        }
        let name = name.or_else(cargo_pkg_name).ok_or_else(|| {
            "could not determine app name (no rsc.toml name / Cargo.toml package)".to_string()
        })?;
        let crate_name = name.replace('-', "_");
        let bundle_id = bundle.unwrap_or_else(|| format!("dev.rosace.{}", crate_name));
        Ok(Self { name, crate_name, bundle_id })
    }
}

fn cargo_pkg_name() -> Option<String> {
    let s = fs::read_to_string("Cargo.toml").ok()?;
    let mut in_pkg = false;
    for line in s.lines() {
        let t = line.trim();
        if t == "[package]" { in_pkg = true; continue; }
        if in_pkg && t.starts_with('[') { break; }
        if in_pkg {
            if let Some((k, v)) = t.split_once('=') {
                if k.trim() == "name" {
                    return Some(v.trim().trim_matches('"').to_string());
                }
            }
        }
    }
    None
}

// ── Desktop ────────────────────────────────────────────────────────────────

fn run_macos(app: &App) -> Result<(), String> {
    if !cfg!(target_os = "macos") {
        return Err(
            "rsc run --mac requires running rsc on macOS itself — cross-running \
             (build on one OS, execute on another) isn't supported."
                .to_string(),
        );
    }
    println!("Running '{}' on macOS...", app.name);
    let status = Command::new("cargo")
        .args(["build", "--bin", &app.crate_name])
        .status()
        .map_err(|e| format!("failed to invoke cargo: {}", e))?;
    if !status.success() {
        return Err("cargo build failed".to_string());
    }

    // Wrap the debug binary in a real `.app` bundle (same assembly `rsc
    // package` uses) before launching it — a bare binary run as a plain
    // Unix process has no bundle for `NSBundle.mainBundle` to resolve, so
    // AppKit shows a generic Dock icon regardless of `macos/icon.icns`.
    // Skipped gracefully (falls back to the old bare-binary run) when
    // `macos/Info.plist` doesn't exist — a project scaffolded before this
    // file existed, or one that dropped macOS support after the fact.
    if Path::new("macos/Info.plist").exists() {
        let bin_src = format!("target/debug/{}", app.crate_name);
        if let Some(result) = assemble_and_launch_mac_bundle(app, &bin_src) {
            return result;
        }
    }

    let status = Command::new("cargo")
        .args(["run", "--bin", &app.crate_name])
        .status()
        .map_err(|e| format!("failed to invoke cargo: {}", e))?;
    if status.success() { Ok(()) } else { Err("app exited with an error".to_string()) }
}

/// `assemble_macos_app` (in `package.rs`) is `#[cfg(target_os = "macos")]`
/// — it shells out to macOS-only tooling. `run_macos` above only reaches
/// this at runtime when actually on macOS (see its own early `cfg!` guard),
/// but Rust still type-checks this file on every host OS, so the call
/// itself needs the same cfg gate. Returns `None` to fall through to the
/// bare-binary run, exactly like the "couldn't assemble a bundle" case.
#[cfg(target_os = "macos")]
fn assemble_and_launch_mac_bundle(app: &App, bin_src: &str) -> Option<Result<(), String>> {
    match crate::commands::package::assemble_macos_app(
        &app.name, &app.crate_name, "target/rsc-run", Path::new(bin_src), None,
    ) {
        Ok(app_dir) => {
            let exe = format!("{}/Contents/MacOS/{}", app_dir, app.crate_name);
            Some(match Command::new(&exe).status() {
                Ok(status) if status.success() => Ok(()),
                Ok(_) => Err("app exited with an error".to_string()),
                Err(e) => Err(format!("failed to launch {}: {}", exe, e)),
            })
        }
        Err(e) => {
            println!("  Note: couldn't assemble a .app bundle ({e}) — running the bare binary instead");
            None
        }
    }
}

#[cfg(not(target_os = "macos"))]
fn assemble_and_launch_mac_bundle(_app: &App, _bin_src: &str) -> Option<Result<(), String>> {
    None
}

/// Cross-compiles for Windows; never attempts to run the result (no Windows
/// execution environment on a non-Windows host). See the Known Issues note
/// in `.steering/CRATE_CONTRACTS.md` — this path is generated/preflighted
/// but not build-verified end-to-end (no Windows toolchain available on the
/// machines this was developed on).
fn run_windows_cross_build(app: &App) -> Result<(), String> {
    const TRIPLE: &str = "x86_64-pc-windows-gnu";
    println!("Building '{}' for Windows ({})...", app.name, TRIPLE);
    let ok = Command::new("cargo")
        .args(["build", "--bin", &app.name, "--target", TRIPLE])
        .status()
        .map_err(|e| format!("cargo: {}", e))?
        .success();
    if !ok {
        return Err(format!("Windows cross-build failed (target/{}/debug/{}.exe)", TRIPLE, app.crate_name));
    }
    println!("  Built target/{}/debug/{}.exe", TRIPLE, app.crate_name);
    if !cfg!(target_os = "windows") {
        println!("  This host can't run a Windows binary — copy it to a Windows machine to launch it.");
    }
    Ok(())
}

/// Cross-compiles for Linux; never attempts to run the result on a
/// non-Linux host, same reasoning as `run_windows_cross_build`.
fn run_linux_cross_build(app: &App) -> Result<(), String> {
    const TRIPLE: &str = "x86_64-unknown-linux-gnu";
    println!("Building '{}' for Linux ({})...", app.name, TRIPLE);
    let ok = Command::new("cargo")
        .args(["build", "--bin", &app.name, "--target", TRIPLE])
        .status()
        .map_err(|e| format!("cargo: {}", e))?
        .success();
    if !ok {
        return Err(format!("Linux cross-build failed (target/{}/debug/{})", TRIPLE, app.crate_name));
    }
    println!("  Built target/{}/debug/{}", TRIPLE, app.crate_name);
    if !cfg!(target_os = "linux") {
        println!("  This host can't run a Linux binary — copy it to a Linux machine to launch it.");
    }
    Ok(())
}

// ── Web ────────────────────────────────────────────────────────────────────

fn run_web(app: &App, port: u16) -> Result<(), String> {
    println!("Building '{}' for web (wasm)...", app.name);

    // 1. Build the cdylib for wasm.
    let ok = Command::new("cargo")
        .args(["build", "--lib", "--target", "wasm32-unknown-unknown"])
        .status()
        .map_err(|e| format!("cargo: {}", e))?
        .success();
    if !ok {
        return Err("wasm build failed (run: rustup target add wasm32-unknown-unknown)".into());
    }

    // 2. wasm-bindgen → dist/ (generates <crate>.js + <crate>_bg.wasm).
    let wasm = format!("target/wasm32-unknown-unknown/debug/{}.wasm", app.crate_name);
    if !Path::new(&wasm).exists() {
        return Err(format!("expected wasm artifact not found: {}", wasm));
    }
    fs::create_dir_all("dist").map_err(|e| format!("cannot create dist/: {}", e))?;
    let bindgen = wasm_bindgen_bin()?;
    println!("  Generating JS glue (wasm-bindgen)...");
    let ok = Command::new(&bindgen)
        .args([&wasm, "--out-dir", "dist", "--target", "web", "--out-name", &app.crate_name])
        .status()
        .map_err(|e| format!("wasm-bindgen: {}", e))?
        .success();
    if !ok {
        return Err("wasm-bindgen failed".into());
    }

    // 2b. Copy assets into dist/ (A6): the web build fetches them from
    // `/assets/<name>` at the same relative path the typed handles encode.
    crate::commands::package::copy_assets_into(
        Path::new("assets"),
        std::path::PathBuf::from("dist").join("assets"),
    )?;

    // 3. Host page: use the app's web/index.html if present, else a default.
    let index_src = Path::new("web/index.html");
    if index_src.exists() {
        fs::copy(index_src, "dist/index.html").map_err(|e| format!("copy index.html: {}", e))?;
    } else {
        fs::write("dist/index.html", default_index_html(&app.crate_name))
            .map_err(|e| format!("write index.html: {}", e))?;
    }

    // 4. Serve.
    println!("  Open http://localhost:{}/", port);
    crate::commands::dev::serve_dist(port)
}

/// Locate `wasm-bindgen` (PATH, then ~/.cargo/bin).
fn wasm_bindgen_bin() -> Result<String, String> {
    if Command::new("wasm-bindgen").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) {
        return Ok("wasm-bindgen".to_string());
    }
    if let Ok(home) = std::env::var("HOME") {
        let p = format!("{}/.cargo/bin/wasm-bindgen", home);
        if Path::new(&p).exists() {
            return Ok(p);
        }
    }
    Err("wasm-bindgen not found. Install it: cargo install wasm-bindgen-cli".into())
}

fn default_index_html(crate_name: &str) -> String {
    format!(
        "<!doctype html><html><head><meta charset=\"utf-8\">\
         <style>html,body{{margin:0;background:#14141a}}</style></head><body>\
         <script type=\"module\">import init from './{crate_name}.js'; init();</script>\
         </body></html>\n"
    )
}

// ── iOS (simulator) ──────────────────────────────────────────────────────────

/// Prefers the real `.xcodeproj` (D106 Phase 24 Step 2/4) — drives actual
/// `xcodebuild`, so the Cargo build script phase, real code signing, and
/// the exact project a user might also have open in Xcode are all the same
/// path, not a second parallel pipeline. Falls back to the Phase 20-22
/// hand-rolled raw-binary+Info.plist+adhoc-codesign harness only for a
/// project that predates Step 2 (no `ios/App.xcodeproj`) — per the
/// Migration Rule, that harness is superseded, not deleted.
fn run_ios(app: &App, device: &str) -> Result<(), String> {
    let device = if device.is_empty() { "iPhone 15 Pro" } else { device };
    if Path::new("ios/App.xcodeproj").exists() {
        run_ios_xcodeproj(app, device)
    } else {
        run_ios_legacy(app, device)
    }
}

/// `-derivedDataPath` pins the build output to a predictable location
/// instead of DerivedData's hashed-per-checkout default (confirmed hashed
/// during Step 5's verification — `App-<random-hash>`) — otherwise there's
/// no reliable path to hand `simctl install`. The project/target/product
/// are always literally named "App" regardless of the app's own name (see
/// `rsc new`'s `ios_pbxproj` template), so `Build/Products/.../App.app` is
/// stable across every generated project.
fn run_ios_xcodeproj(app: &App, device: &str) -> Result<(), String> {
    println!("Building '{}' for the iOS simulator (xcodebuild)...", app.name);

    // A name-based destination ("platform=iOS Simulator,name=<device>") is
    // ambiguous whenever both arm64 and x86_64 runtime variants exist for
    // the same device (common on this kind of Xcode install — confirmed:
    // `xcodebuild -destination "platform=iOS Simulator,name=iPhone 15 Pro"`
    // failed with "Unable to find a device matching the provided
    // destination specifier" even though `xcrun simctl list devices`
    // plainly shows exactly one such device). Resolving to a concrete UDID
    // first sidesteps the ambiguity entirely — same form Step 5's
    // verification already used successfully.
    let udid = resolve_simulator_udid(device)?;
    let derived_data = "target/ios-build";
    let ok = Command::new("xcodebuild")
        .args([
            "-project", "ios/App.xcodeproj",
            "-scheme", "App",
            "-destination", &format!("id={}", udid),
            "-derivedDataPath", derived_data,
            "build",
        ])
        .status()
        .map_err(|e| format!("xcodebuild: {}", e))?
        .success();
    if !ok {
        return Err(
            "xcodebuild failed. Common cause: Xcode Command Line Tools not selected \
             (xcode-select --install).".to_string(),
        );
    }
    let bundle = format!("{}/Build/Products/Debug-iphonesimulator/App.app", derived_data);
    if !Path::new(&bundle).exists() {
        return Err(format!("xcodebuild reported success but {} wasn't produced — unexpected", bundle));
    }

    // Copy the app's assets into the .app (A6). iOS bundles are flat, and the
    // Rust runtime resolves `current_exe()` (→ App.app/App) → `App.app/assets`,
    // so this is the location `Image::asset(...)` reads from on device.
    crate::commands::package::copy_assets_into(Path::new("assets"), Path::new(&bundle).join("assets"))?;

    let _ = Command::new("xcrun").args(["simctl", "boot", &udid]).status();
    let _ = Command::new("open").args(["-a", "Simulator"]).status();

    println!("  Installing on '{}'...", device);
    run_checked("xcrun", &["simctl", "install", &udid, &bundle], "simctl install")?;
    println!("  Launching {}...", app.bundle_id);
    run_checked("xcrun", &["simctl", "launch", "--console", &udid, &app.bundle_id], "simctl launch")
}

/// Resolves `device` to a simulator UDID. Three cases: empty (no `--device`
/// given) defaults to "iPhone 15 Pro"; already UDID-shaped (e.g. pasted
/// straight from `rsc devices`' ID column) is trusted as-is, no lookup
/// needed; otherwise matched by exact name via `crate::commands::devices`'
/// shared listing — same data `rsc devices` prints, so the two commands
/// can't drift apart on what a given name resolves to.
fn resolve_simulator_udid(device: &str) -> Result<String, String> {
    let device = if device.is_empty() { "iPhone 15 Pro" } else { device };
    if crate::commands::devices::find_uuid(device).map(|(u, s)| s == 0 && u.len() == device.len()).unwrap_or(false) {
        return Ok(device.to_string());
    }
    crate::commands::devices::list_devices()
        .into_iter()
        .find(|d| d.platform == "ios" && d.name == device)
        .map(|d| d.id)
        .ok_or_else(|| format!(
            "no simulator named '{}' found. Run `rsc devices` to see real names/ids \
             (pass either via --device).",
            device
        ))
}

/// Phase 20-22 hand-rolled harness: raw binary + physical Info.plist +
/// ad-hoc codesign, no real Xcode project involved. Only reached for a
/// project scaffolded before Step 2 added `ios/App.xcodeproj` generation.
fn run_ios_legacy(app: &App, device: &str) -> Result<(), String> {
    println!("Building '{}' for the iOS simulator (legacy harness — no ios/App.xcodeproj found)...", app.name);

    // 1. Build the executable for the simulator target. `RSC_HOT=1` adds the
    //    hot-reload feature so the app opens its reload socket (the iOS
    //    simulator shares the host's localhost, so `rsc dev --target ios`
    //    pushes edits straight to it — no port forward needed).
    let mut build = Command::new("cargo");
    build.args(["build", "--bin", &app.name, "--target", "aarch64-apple-ios-sim"]);
    if std::env::var("RSC_HOT").as_deref() == Ok("1") {
        build.args(["--features", "rosace/rsc-hot"]);
        println!("  (hot reload: building with rosace/rsc-hot)");
    }
    let ok = build.status().map_err(|e| format!("cargo: {}", e))?.success();
    if !ok {
        return Err("iOS build failed (run: rustup target add aarch64-apple-ios-sim)".into());
    }
    let bin = format!("target/aarch64-apple-ios-sim/debug/{}", app.name);

    // 2. Assemble the .app bundle (executable named after the crate + Info.plist).
    let bundle = format!("target/{}.app", app.name);
    let _ = fs::remove_dir_all(&bundle);
    fs::create_dir_all(&bundle).map_err(|e| format!("mkdir bundle: {}", e))?;
    fs::copy(&bin, format!("{}/{}", bundle, app.crate_name))
        .map_err(|e| format!("copy executable: {}", e))?;
    let plist_src = Path::new("ios/Info.plist");
    if !plist_src.exists() {
        return Err("ios/Info.plist not found — scaffold with `rsc new --platforms ios`".into());
    }
    fs::copy(plist_src, format!("{}/Info.plist", bundle))
        .map_err(|e| format!("copy Info.plist: {}", e))?;

    // 3. Ad-hoc code-sign (required even for the simulator).
    run_checked("codesign", &["--force", "--sign", "-", &bundle], "codesign")?;

    // 4. Boot the simulator (ignore "already booted") + open the Simulator UI.
    let _ = Command::new("xcrun").args(["simctl", "boot", device]).status();
    let _ = Command::new("open").args(["-a", "Simulator"]).status();

    // 5. Install + launch (stream the app's stdout/stderr so panics are visible).
    println!("  Installing on '{}'...", device);
    run_checked("xcrun", &["simctl", "install", "booted", &bundle], "simctl install")?;
    println!("  Launching {}...", app.bundle_id);
    run_checked("xcrun", &["simctl", "launch", "--console", "booted", &app.bundle_id], "simctl launch")
}

/// Builds via the generated Gradle project (which cross-compiles the Rust
/// cdylib through its own `cargoBuildAndroid` task, then packages the APK —
/// see `rsc new`'s `android_app_build_gradle` template), then installs +
/// launches on a connected device/emulator if `adb` sees one. No device
/// connected still ends in success (a built APK, not a crash) — the same
/// "build if you can't run" honesty `run_windows_cross_build`/
/// `run_linux_cross_build` already use for a cross-target with no local
/// execution environment.
/// `device` is an adb serial (`rsc devices`' ID column for `android`
/// entries) — empty means "let adb pick", which only works when exactly
/// one device/emulator is connected; adb itself errors clearly
/// ("more than one device/emulator") when that's ambiguous, so no extra
/// disambiguation logic is needed here.
fn run_android(app: &App, device: &str) -> Result<(), String> {
    if !Path::new("android").exists() {
        return Err("android/ not found — scaffold with `rsc new --platforms android`".into());
    }
    ensure_android_local_properties()?;
    // Copy assets into the Gradle module so they're packaged into the APK (A6).
    // NOTE: APK assets live inside the zip and can't be `fs::read` directly —
    // the runtime extracts them to filesDir at first launch (see the scaffold's
    // Android launch glue) and points the asset root there.
    crate::commands::package::copy_assets_into(
        Path::new("assets"),
        Path::new("android/app/src/main/assets").to_path_buf(),
    )?;
    println!("Building '{}' for Android (Gradle assembleDebug)...", app.name);
    let ok = Command::new("./gradlew")
        .args(["assembleDebug"])
        .current_dir("android")
        .status()
        .map_err(|e| format!("gradlew: {}", e))?
        .success();
    if !ok {
        return Err("Gradle build failed".into());
    }
    // AGP names the APK after the Gradle module ("app"), not the crate/app
    // name — confirmed against a real `assembleDebug` output.
    let apk = "android/app/build/outputs/apk/debug/app-debug.apk".to_string();
    println!("  Built {}", apk);

    let connected = crate::commands::devices::list_devices()
        .into_iter()
        .filter(|d| d.platform == "android" && d.status == "device")
        .count();
    if connected == 0 {
        println!("  No device/emulator connected (adb devices) — built the APK, not installed.");
        println!("  Install manually: adb install {}", apk);
        return Ok(());
    }
    if !device.is_empty() && connected > 1 {
        // Confirm the requested serial is actually one of the connected
        // devices before handing it to adb -s, so a typo gets a clear
        // "not found" here instead of an opaque adb failure downstream.
        let known = crate::commands::devices::list_devices()
            .into_iter()
            .any(|d| d.platform == "android" && d.id == device);
        if !known {
            return Err(format!("no Android device with id '{}' connected. Run `rsc devices` to see real ids.", device));
        }
    }

    let adb_target: Vec<&str> = if device.is_empty() { vec![] } else { vec!["-s", device] };

    println!("  Installing on device...");
    let mut install_args = adb_target.clone();
    install_args.extend(["install", "-r", &apk]);
    run_checked("adb", &install_args, "adb install")?;

    let activity = format!("{}/.MainActivity", app.bundle_id);
    println!("  Launching {}...", app.bundle_id);
    let mut launch_args = adb_target;
    launch_args.extend(["shell", "am", "start", "-n", &activity]);
    run_checked("adb", &launch_args, "adb shell am start")
}

/// Gradle needs to know where the Android SDK lives. It reads that from
/// `android/local.properties` (`sdk.dir=...`) or the `ANDROID_HOME` /
/// `ANDROID_SDK_ROOT` env vars. `local.properties` is machine-specific (it
/// holds an absolute path) so it's never committed / scaffolded by `rsc new`
/// — we generate it on demand here from whatever SDK we can detect, so a
/// fresh clone builds without the user hand-writing it. If it already exists
/// we leave it alone (the user may have pointed it somewhere deliberately).
fn ensure_android_local_properties() -> Result<(), String> {
    let lp = Path::new("android/local.properties");
    if lp.exists() {
        return Ok(());
    }
    let sdk = std::env::var("ANDROID_HOME")
        .ok()
        .filter(|s| !s.is_empty())
        .or_else(|| std::env::var("ANDROID_SDK_ROOT").ok().filter(|s| !s.is_empty()))
        .or_else(|| {
            // Conventional default install locations, so a machine with
            // Android Studio's defaults works even with no env vars set.
            let home = std::env::var("HOME").ok()?;
            [
                format!("{home}/Library/Android/sdk"), // macOS
                format!("{home}/Android/Sdk"),         // Linux
            ]
            .into_iter()
            .find(|cand| Path::new(cand).exists())
        })
        .ok_or_else(|| {
            "Android SDK not found — set ANDROID_HOME (or ANDROID_SDK_ROOT) to your SDK path, \
             or create android/local.properties with `sdk.dir=/path/to/sdk`."
                .to_string()
        })?;
    std::fs::write(lp, format!("sdk.dir={sdk}\n"))
        .map_err(|e| format!("writing android/local.properties: {e}"))?;
    println!("  Wrote android/local.properties (sdk.dir={sdk})");
    Ok(())
}

fn run_checked(cmd: &str, args: &[&str], what: &str) -> Result<(), String> {
    let ok = Command::new(cmd)
        .args(args)
        .status()
        .map_err(|e| format!("{}: {}", what, e))?
        .success();
    if ok { Ok(()) } else { Err(format!("{} failed", what)) }
}

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

    #[test]
    fn parse_target_accepts_explicit_os_names() {
        assert_eq!(parse_target(Some("macos")).unwrap(), Target::MacOs);
        assert_eq!(parse_target(Some("windows")).unwrap(), Target::Windows);
        assert_eq!(parse_target(Some("linux")).unwrap(), Target::Linux);
        assert_eq!(parse_target(Some("web")).unwrap(), Target::Web);
        assert_eq!(parse_target(Some("ios")).unwrap(), Target::Ios);
        assert_eq!(parse_target(Some("android")).unwrap(), Target::Android);
    }

    #[test]
    fn parse_target_rejects_old_desktop_keyword() {
        // "desktop" is intentionally no longer accepted — macOS/Windows/
        // Linux are explicit, separate targets now (see module doc).
        let err = parse_target(Some("desktop")).unwrap_err();
        assert!(err.contains("macos"), "error should list the real options: {err}");
    }

    #[test]
    fn mac_win_lnx_flags_set_the_right_target() {
        let opts = RunOptions::from_args(&["--mac".to_string()]).unwrap();
        assert_eq!(opts.target, Target::MacOs);
        let opts = RunOptions::from_args(&["--win".to_string()]).unwrap();
        assert_eq!(opts.target, Target::Windows);
        let opts = RunOptions::from_args(&["--lnx".to_string()]).unwrap();
        assert_eq!(opts.target, Target::Linux);
    }

    #[test]
    fn no_target_flag_defaults_to_host_os() {
        let opts = RunOptions::from_args(&[]).unwrap();
        assert_eq!(opts.target, host_target());
    }

    #[test]
    fn target_flag_still_works() {
        let opts = RunOptions::from_args(&["--target".to_string(), "web".to_string()]).unwrap();
        assert_eq!(opts.target, Target::Web);
        let opts = RunOptions::from_args(&["--target=ios".to_string()]).unwrap();
        assert_eq!(opts.target, Target::Ios);
    }

    #[test]
    fn resolve_simulator_udid_rejects_prefix_collision() {
        // "iPhone 15 Pro" must not match "iPhone 15 Pro Max" — a real
        // collision this device list actually has (both start with the
        // same prefix). A nonsense name proves the not-found path is
        // reached without a real simulator having to exist on the machine
        // running this test.
        let err = resolve_simulator_udid("definitely not a real simulator name").unwrap_err();
        assert!(err.contains("no simulator named"), "{err}");
    }

    #[test]
    fn resolve_simulator_udid_trusts_an_already_uuid_shaped_device() {
        // A UDID pasted straight from `rsc devices`' ID column should work
        // directly, no lookup/network-of-simctl-calls needed — this must
        // not depend on that exact UDID actually existing on the machine
        // running the test.
        let udid = resolve_simulator_udid("DA884712-56EF-4605-A4FD-C00865FCC084").unwrap();
        assert_eq!(udid, "DA884712-56EF-4605-A4FD-C00865FCC084");
    }

    #[test]
    fn preflight_cross_target_reports_missing_target_actionably() {
        // A triple that will never be installed — proves the error message
        // is specific and actionable, not a raw tool failure.
        let err = preflight_cross_target("bogus-target-triple", "Bogus", Some("bogus-linker")).unwrap_err();
        assert!(err.contains("rustup target add bogus-target-triple"), "{err}");
        assert!(err.contains("bogus-linker"), "{err}");
        assert!(err.contains("BUILD"), "should clarify build-only: {err}");
    }
}