crw-cli 0.19.0

crw — Unified CLI for web scraping, crawling, search, and serving
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
//! Local setup flow for CRW.

use crate::commands::setup::browser::{self, BrowserEngine};
use crate::commands::setup::config_file::{
    self, ExtractionSection, LlmSection, SearchSection, UserConfig,
};
use crate::commands::setup::docker::{self, DockerStatus};
use crate::commands::setup::llm::{self, LlmSetupResult};
use crate::commands::setup::searxng;
use crate::commands::setup::shell::{self, Shell, ShellConfig};
use crate::commands::setup::ui::{self, SetupError, SummaryItem};
use dialoguer::Select;

/// Run the local setup flow.
pub async fn run() -> Result<(), SetupError> {
    ui::print_section_header("🏠", "LOCAL SETUP");

    println!("  I'll set up everything you need to run CRW locally.");
    println!("  This includes a browser engine for JavaScript rendering");
    println!("  and a search engine for web searches.");
    println!();

    // Step 1: Check requirements
    ui::print_step(1, 5, "Check Requirements");

    let shell = shell::detect_shell();
    let docker_status = docker::check_docker();
    let platform = std::env::consts::OS;
    let arch = std::env::consts::ARCH;

    ui::print_success(&format!("Platform: {} {}", platform, arch));
    ui::print_success(&format!("Shell: {}", shell));

    // Check Docker
    let docker_available = match &docker_status {
        DockerStatus::Running { version } => {
            ui::print_success(&format!("Docker: found ({})", extract_version(version)));
            if let Some(disk) = docker::get_available_disk_space() {
                ui::print_detail("Running: Yes");
                ui::print_detail(&format!("Disk space: {}GB available", disk));
            } else {
                ui::print_detail("Running: Yes");
            }
            true
        }
        DockerStatus::NotRunning { version } => {
            ui::print_error(&format!(
                "Docker: found but not running ({})",
                extract_version(version)
            ));
            handle_docker_not_running().await?
        }
        DockerStatus::NotFound => {
            ui::print_error("Docker: not found");
            handle_docker_not_found().await?
        }
    };

    println!();

    // Step 2: Browser engine
    ui::print_step(2, 5, "Browser Engine (for JS rendering)");

    println!("  To scrape JavaScript-heavy sites (SPAs, React, etc.),");
    println!("  CRW needs a browser engine.");
    println!();

    // Cache filesystem-scanning detections once; reuse for both the prompt and
    // the post-prompt status label.
    let browser_chrome_present = browser::detect_chrome().is_some();

    let browser_engine = prompt_browser_engine().await?;
    let browser_installed = match browser_engine {
        BrowserEngine::LightPanda => {
            ui::print_warning("LightPanda is experimental and may timeout on some sites.");
            ui::print_detail("If you experience issues with --js, try Chrome instead.");
            // Check if already installed first
            if browser::detect_lightpanda().is_some() {
                ui::print_success("LightPanda already installed");
                true
            } else {
                ui::print_info("Downloading LightPanda...");
                match browser::download_lightpanda().await {
                    Ok(_) => true,
                    Err(e) => {
                        ui::print_error(&format!("Download failed: {}", e));
                        handle_download_failure().await?
                    }
                }
            }
        }
        BrowserEngine::Chrome => {
            if let Some(path) = browser::detect_chrome() {
                ui::print_success(&format!("Using Chrome at {}", path.display()));
                true
            } else {
                ui::print_warning("Chrome not detected. You'll need to install it manually.");
                ui::print_detail("Download from: https://google.com/chrome");
                false
            }
        }
        BrowserEngine::None => {
            ui::print_info("Skipping browser engine (HTTP-only mode)");
            false
        }
    };

    println!();

    // Step 3: Search engine
    ui::print_step(3, 5, "Search Engine (for web search)");

    println!("  CRW's search feature uses SearXNG, a privacy-respecting");
    println!("  meta search engine that aggregates results from Google,");
    println!("  Bing, DuckDuckGo, and 70+ other sources.");
    println!();

    let searxng_url = if docker_available {
        prompt_searxng_setup().await?
    } else {
        ui::print_warning("Skipping SearXNG (Docker not available)");
        ui::print_detail("crw search command won't work without SearXNG");
        None
    };

    println!();

    // Step 4: LLM configuration (optional)
    ui::print_step(4, 5, "LLM Configuration (optional)");

    let llm_result = llm::run().await?;

    println!();

    // Always persist canonical state to ~/.config/crw/config.toml. The
    // shell rc write below is *additional* (env vars still take precedence
    // for CI/Docker users).
    let cfg_path = config_file::write_user_config(build_user_config(
        searxng_url.as_deref(),
        llm_result.as_ref(),
    ))?;
    ui::print_success(&format!("Saved {}", cfg_path.display()));
    println!();

    // Step 5: Shell configuration
    ui::print_step(5, 5, "Shell Configuration");

    let save_to_shell = prompt_shell_config()?;
    if save_to_shell {
        save_shell_config(
            shell,
            browser_installed,
            searxng_url.as_deref(),
            llm_result.as_ref(),
        )?;
    } else {
        show_manual_config(
            browser_installed,
            searxng_url.as_deref(),
            llm_result.as_ref(),
        );
    }

    // Print configuration summary. Reuse the chrome detection from
    // `prompt_browser_engine` rather than scanning the filesystem again.
    let chrome_present = browser_chrome_present;
    let (browser_status, browser_ok) =
        browser_status_label(browser_engine, browser_installed, chrome_present);

    let summary_items = vec![
        SummaryItem::new("Browser Engine", browser_status, browser_ok),
        SummaryItem::new(
            "Search Engine",
            searxng_url.as_deref().unwrap_or("Not configured"),
            searxng_url.is_some(),
        ),
        SummaryItem::new(
            "LLM Provider",
            llm_result
                .as_ref()
                .map(|l| l.provider.name())
                .unwrap_or("Not configured"),
            llm_result.is_some(),
        ),
    ];
    ui::print_summary("Configuration Summary", &summary_items);

    // Print completion banner
    let source_cmd = shell::source_command(shell);

    let mut quick_start = vec!["crw example.com              # Scrape (HTTP)"];

    if browser_installed {
        quick_start.push("crw example.com --js         # Scrape with JavaScript");
    }

    if searxng_url.is_some() {
        quick_start.push("crw search \"rust tutorials\"  # Web search");
    }

    quick_start.push("crw serve                    # Start API server");

    let mut extras = Vec::new();
    if searxng_url.is_some() {
        extras.push("SearXNG management:");
        extras.push("  docker start searxng         # Start search engine");
        extras.push("  docker stop searxng          # Stop search engine");
        extras.push("");
    }
    extras.push("Documentation: https://fastcrw.com/docs");

    let extras_refs: Vec<&str> = extras.iter().map(|s| s.as_ref()).collect();

    ui::print_completion_banner(source_cmd.as_deref(), &quick_start, &extras_refs);

    Ok(())
}

/// Extract version number from docker version string.
fn extract_version(full: &str) -> &str {
    // "Docker version 24.0.5, build ..." -> "24.0.5"
    full.split_whitespace()
        .nth(2)
        .map(|s| s.trim_end_matches(','))
        .unwrap_or(full)
}

/// Handle Docker not running scenario.
async fn handle_docker_not_running() -> Result<bool, SetupError> {
    println!();
    println!("  Please start Docker Desktop and try again.");
    println!();

    let choice = Select::with_theme(&ui::select_style())
        .with_prompt("  What would you like to do?")
        .items(&[
            "Retry (I just started Docker)",
            "Continue without search (skip SearXNG)",
            "Exit",
        ])
        .default(0)
        .interact_opt()
        .map_err(ui::handle_dialoguer_error)?
        .ok_or(SetupError::Cancelled)?;

    match choice {
        0 => {
            // Retry
            let status = docker::check_docker();
            if status.is_ready() {
                ui::print_success("Docker is now running");
                Ok(true)
            } else {
                ui::print_error("Docker still not running");
                Ok(false)
            }
        }
        1 => Ok(false),
        2 => Err(SetupError::Cancelled),
        _ => unreachable!(),
    }
}

/// Handle Docker not found scenario.
async fn handle_docker_not_found() -> Result<bool, SetupError> {
    let instructions = docker::docker_install_instructions();
    let mut lines = vec![
        "Docker is required for local search setup",
        "",
        "Docker runs SearXNG (search engine) in a container.",
        "Without it, you can still scrape but not search.",
        "",
        "Install Docker:",
    ];
    for inst in &instructions {
        lines.push(inst);
    }

    ui::print_info_box(&lines);

    let choice = Select::with_theme(&ui::select_style())
        .with_prompt("  What would you like to do?")
        .items(&[
            "Continue without Docker (skip SearXNG)",
            "Exit and install Docker first",
        ])
        .default(0)
        .interact_opt()
        .map_err(ui::handle_dialoguer_error)?
        .ok_or(SetupError::Cancelled)?;

    match choice {
        0 => Ok(false),
        1 => Err("Please install Docker and run 'crw setup' again.".into()),
        _ => unreachable!(),
    }
}

/// Return the (label, ok) pair shown for the Browser Engine summary row.
///
/// `installed` reflects whether the chosen engine was actually set up; `chrome_present`
/// reflects whether a Chrome binary exists on disk regardless of the user's choice.
/// Pure — no I/O — so the truth table is unit-testable.
fn browser_status_label(
    engine: BrowserEngine,
    installed: bool,
    chrome_present: bool,
) -> (&'static str, bool) {
    match (engine, installed, chrome_present) {
        (BrowserEngine::Chrome, true, _) => ("Chrome (configured)", true),
        (BrowserEngine::LightPanda, true, _) => ("LightPanda (experimental)", true),
        // User picked a browser but install/detection failed — report the failure
        // explicitly rather than silently advertising any other browser.
        (BrowserEngine::Chrome, false, _) => ("Chrome (install failed)", false),
        (BrowserEngine::LightPanda, false, _) => ("LightPanda (install failed)", false),
        // User declined a browser; surface Chrome as an available fallback only
        // when it's actually present on disk.
        (BrowserEngine::None, _, true) => ("Chrome (available)", true),
        (BrowserEngine::None, _, false) => ("Not configured (HTTP only)", false),
    }
}

/// Build the browser engine selection menu.
///
/// Returns `(items, engines, default_index)`. Pure (no I/O) so the option
/// matrix is unit-testable.
///
/// Ordering rules:
///   - Chrome first when detected (recommended path).
///   - LightPanda shown if either the platform can download it OR a binary
///     is already on disk (user may have side-loaded it on an unsupported
///     platform).
///   - Chrome shown last as "not installed" if not detected.
///   - "Skip" is always last.
///
/// Default selection prefers, in order: detected Chrome, detected LightPanda,
/// otherwise Skip — never silently downgrade a user with a working browser.
fn build_browser_options(
    chrome_path: Option<&std::path::Path>,
    lightpanda_available: bool,
    lightpanda_path: Option<&std::path::Path>,
) -> (Vec<String>, Vec<BrowserEngine>, usize) {
    let mut items = Vec::new();
    let mut engines = Vec::new();
    let lightpanda_installed = lightpanda_path.is_some();
    // Show LightPanda whenever it's available to download OR already on disk.
    let show_lightpanda = lightpanda_available || lightpanda_installed;

    if let Some(path) = chrome_path {
        items.push(format!(
            "Chrome/Chromium (recommended)\n      • Uses: {}\n      • Full CDP support, maximum compatibility\n      • Best for: All JavaScript-heavy sites",
            path.display()
        ));
        engines.push(BrowserEngine::Chrome);
    }

    if show_lightpanda {
        let label = if lightpanda_installed {
            "LightPanda (experimental, installed)"
        } else {
            "LightPanda (experimental)"
        };
        let size_line = if lightpanda_installed {
            "Lightweight: ~50MB"
        } else {
            "Lightweight: ~50MB download"
        };
        items.push(format!(
            "{}\n      • ⚠️  May timeout on some sites (CDP compatibility)\n{}\n      • Best for: Simple JS sites only",
            label, size_line
        ));
        engines.push(BrowserEngine::LightPanda);
    }

    if chrome_path.is_none() {
        items.push("Chrome/Chromium (not installed)\n      • Full CDP support, maximum compatibility\n      • Install from: google.com/chrome".to_string());
        engines.push(BrowserEngine::Chrome);
    }

    items.push("Skip (HTTP only)\n      • No JavaScript support\n      • Fastest, lowest resource usage\n      • Best for: Simple HTML sites, APIs".to_string());
    engines.push(BrowserEngine::None);

    // Default: prefer a working browser the user already has.
    let default_choice = if chrome_path.is_some() {
        0 // Chrome is always first when detected.
    } else if lightpanda_installed {
        engines
            .iter()
            .position(|e| *e == BrowserEngine::LightPanda)
            .unwrap_or(items.len() - 1)
    } else {
        items.len() - 1 // Skip.
    };

    (items, engines, default_choice)
}

/// Prompt for browser engine choice.
async fn prompt_browser_engine() -> Result<BrowserEngine, SetupError> {
    let lightpanda_available = browser::get_platform_info().is_some();
    let chrome_detected = browser::detect_chrome();
    let lightpanda_detected = browser::detect_lightpanda();

    let (items, engines, default_choice) = build_browser_options(
        chrome_detected.as_deref(),
        lightpanda_available,
        lightpanda_detected.as_deref(),
    );

    let choice = Select::with_theme(&ui::select_style())
        .with_prompt("  Which browser engine would you like?")
        .items(&items)
        .default(default_choice)
        .interact_opt()
        .map_err(ui::handle_dialoguer_error)?
        .ok_or(SetupError::Cancelled)?;

    Ok(engines[choice])
}

/// Handle download failure.
async fn handle_download_failure() -> Result<bool, SetupError> {
    let choice = Select::with_theme(&ui::select_style())
        .with_prompt("  What would you like to do?")
        .items(&[
            "Retry download",
            "Skip LightPanda (use Chrome if available)",
            "Continue without browser (HTTP only)",
        ])
        .default(0)
        .interact_opt()
        .map_err(ui::handle_dialoguer_error)?
        .ok_or(SetupError::Cancelled)?;

    match choice {
        0 => {
            // Retry
            match browser::download_lightpanda().await {
                Ok(_) => Ok(true),
                Err(e) => {
                    ui::print_error(&format!("Download failed again: {}", e));
                    Ok(false)
                }
            }
        }
        1 => {
            if browser::detect_chrome().is_some() {
                ui::print_info("Will use Chrome for JavaScript rendering");
                Ok(true)
            } else {
                ui::print_warning("Chrome not detected, continuing without JS rendering");
                Ok(false)
            }
        }
        2 => Ok(false),
        _ => unreachable!(),
    }
}

/// Prompt for SearXNG setup.
async fn prompt_searxng_setup() -> Result<Option<String>, SetupError> {
    let status = searxng::check_status();

    // If already running, just return the URL
    if let searxng::SearxngStatus::Running { url } = &status {
        ui::print_success(&format!("SearXNG already running at {}", url));
        return Ok(Some(url.clone()));
    }

    let items = vec![
        "Yes, using Docker (recommended)\n      • Auto-managed container\n      • ~500MB disk space\n      • Starts automatically when needed",
        "No, I'll set it up myself\n      • Manual setup required\n      • See: https://docs.searxng.org",
        "Skip (no search feature)\n      • crw search command won't work\n      • Scraping still works fine",
    ];

    let choice = Select::with_theme(&ui::select_style())
        .with_prompt("  Set up SearXNG for web search?")
        .items(&items)
        .default(0)
        .interact_opt()
        .map_err(ui::handle_dialoguer_error)?
        .ok_or(SetupError::Cancelled)?;

    match choice {
        0 => {
            // Install with Docker
            searxng::pull_image().await?;
            let url = searxng::start_container().await?;
            Ok(Some(url))
        }
        1 => {
            ui::print_info("You can set up SearXNG manually and configure CRW_SEARXNG_URL");
            Ok(None)
        }
        2 => {
            ui::print_info("Skipping SearXNG setup");
            Ok(None)
        }
        _ => unreachable!(),
    }
}

/// Prompt for shell configuration.
///
/// Note: `~/.config/crw/config.toml` is already the source of truth at this
/// point — shell exports are *only* needed if you want env vars to win over
/// the file (CI, Docker, scripts). Default is therefore No, to keep the
/// user's rc file clean.
fn prompt_shell_config() -> Result<bool, SetupError> {
    let choice = Select::with_theme(&ui::select_style())
        .with_prompt("  Also export to your shell rc? (optional)")
        .items(&[
            "No, config.toml is enough (recommended)",
            "Yes — also add `export CRW_*` lines (for CI/Docker/scripts)",
        ])
        .default(0)
        .interact_opt()
        .map_err(ui::handle_dialoguer_error)?
        .ok_or(SetupError::Cancelled)?;

    Ok(choice == 1)
}

/// Build the `UserConfig` for `~/.config/crw/config.toml`. Only fills in
/// sections setup actually touched; everything else stays `None` so
/// `merge_config` preserves prior values across re-runs.
fn build_user_config(searxng_url: Option<&str>, llm_result: Option<&LlmSetupResult>) -> UserConfig {
    UserConfig {
        client: None,
        search: searxng_url.map(|url| SearchSection {
            searxng_url: Some(url.to_string()),
        }),
        extraction: llm_result.map(|llm| ExtractionSection {
            llm: Some(LlmSection {
                provider: Some(llm.provider.config_value().to_string()),
                api_key: Some(llm.api_key.clone()),
                model: Some(llm.model.clone()),
                base_url: llm.base_url.clone(),
                azure_api_version: llm.azure_api_version.clone(),
            }),
        }),
    }
}

/// Save configuration to shell RC file.
fn save_shell_config(
    shell: Shell,
    browser_installed: bool,
    searxng_url: Option<&str>,
    llm_result: Option<&LlmSetupResult>,
) -> Result<(), String> {
    let mut config = ShellConfig::new();

    // Add ~/.local/bin to PATH if browser was installed
    if browser_installed {
        config.add_to_path("$HOME/.local/bin");
    }

    // Add SearXNG URL if configured
    if let Some(url) = searxng_url {
        config.export("CRW_SEARXNG_URL", url);
    }

    // Add LLM config if provided
    if let Some(llm) = llm_result {
        llm::add_to_shell_config(&mut config, llm);
    }

    // Only write if we have something to add
    if config.lines.is_empty() {
        ui::print_info("No configuration changes needed");
        return Ok(());
    }

    let rc_path = shell::append_to_rc(shell, &config)?;

    ui::print_success(&format!("Added to {}:", rc_path.display()));
    for line in &config.lines {
        println!("    {}", line);
    }
    println!();

    Ok(())
}

/// Show manual configuration instructions.
fn show_manual_config(
    browser_installed: bool,
    searxng_url: Option<&str>,
    llm_result: Option<&LlmSetupResult>,
) {
    println!();
    println!("  Add these to your shell configuration:");
    println!();

    if browser_installed {
        println!("    export PATH=\"$HOME/.local/bin:$PATH\"");
    }

    if let Some(url) = searxng_url {
        println!("    export CRW_SEARXNG_URL=\"{}\"", url);
    }

    if let Some(llm) = llm_result {
        llm::show_manual_config(llm);
    }

    if !browser_installed && searxng_url.is_none() && llm_result.is_none() {
        println!("    (no configuration needed)");
    }

    println!();
}

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

    // ---- build_browser_options ----------------------------------------------

    #[test]
    fn options_chrome_detected_lists_chrome_first_and_defaults_to_it() {
        let chrome = Path::new("/usr/bin/google-chrome");
        let (items, engines, default) = build_browser_options(Some(chrome), true, None);
        assert_eq!(engines[0], BrowserEngine::Chrome);
        assert_eq!(default, 0);
        assert!(items[0].contains("recommended"));
        assert!(items.iter().any(|i| i.contains("experimental")));
        assert_eq!(*engines.last().unwrap(), BrowserEngine::None);
    }

    #[test]
    fn options_no_chrome_with_lightpanda_installed_defaults_to_lightpanda() {
        // Regression: previously defaulted to Skip, silently downgrading existing
        // LightPanda users to HTTP-only.
        let lp = Path::new("/home/u/.local/bin/lightpanda");
        let (_, engines, default) = build_browser_options(None, true, Some(lp));
        assert_eq!(engines[default], BrowserEngine::LightPanda);
    }

    #[test]
    fn options_no_chrome_no_lightpanda_defaults_to_skip() {
        let (items, engines, default) = build_browser_options(None, false, None);
        assert_eq!(engines[default], BrowserEngine::None);
        assert!(items[default].contains("HTTP only"));
    }

    #[test]
    fn options_lightpanda_detected_but_platform_unsupported_still_shows_it() {
        // Regression: previously hidden when get_platform_info() returned None.
        let lp = Path::new("/opt/lp");
        let (items, engines, _) = build_browser_options(None, false, Some(lp));
        assert!(items.iter().any(|i| i.contains("experimental")));
        assert!(engines.contains(&BrowserEngine::LightPanda));
    }

    #[test]
    fn options_lightpanda_installed_label_says_installed() {
        let chrome = Path::new("/c");
        let lp = Path::new("/lp");
        let (items, _, _) = build_browser_options(Some(chrome), true, Some(lp));
        assert!(items.iter().any(|i| i.contains("installed")));
    }

    #[test]
    fn options_skip_is_always_last() {
        for &(chrome, lp_avail, lp_inst) in &[
            (true, true, true),
            (true, false, false),
            (false, true, true),
            (false, false, false),
        ] {
            let c = if chrome { Some(Path::new("/c")) } else { None };
            let l = if lp_inst { Some(Path::new("/l")) } else { None };
            let (_, engines, _) = build_browser_options(c, lp_avail, l);
            assert_eq!(
                *engines.last().unwrap(),
                BrowserEngine::None,
                "Skip must be last for chrome={} lp_avail={} lp_inst={}",
                chrome,
                lp_avail,
                lp_inst
            );
        }
    }

    // ---- browser_status_label -----------------------------------------------

    #[test]
    fn status_chrome_configured() {
        assert_eq!(
            browser_status_label(BrowserEngine::Chrome, true, true),
            ("Chrome (configured)", true)
        );
    }

    #[test]
    fn status_lightpanda_configured() {
        assert_eq!(
            browser_status_label(BrowserEngine::LightPanda, true, false),
            ("LightPanda (experimental)", true)
        );
    }

    #[test]
    fn status_chrome_install_failed_does_not_advertise_other_browser() {
        // Regression: previously masked install failure as "Chrome (available)".
        let (label, ok) = browser_status_label(BrowserEngine::Chrome, false, true);
        assert!(label.contains("install failed"));
        assert!(!ok);
    }

    #[test]
    fn status_lightpanda_install_failed_reports_failure() {
        let (label, ok) = browser_status_label(BrowserEngine::LightPanda, false, true);
        assert!(label.contains("install failed"));
        assert!(!ok);
    }

    #[test]
    fn status_skipped_with_chrome_on_disk_shows_chrome_available() {
        assert_eq!(
            browser_status_label(BrowserEngine::None, false, true),
            ("Chrome (available)", true)
        );
    }

    #[test]
    fn status_skipped_without_chrome_says_not_configured() {
        assert_eq!(
            browser_status_label(BrowserEngine::None, false, false),
            ("Not configured (HTTP only)", false)
        );
    }
}