Skip to main content

bvr/
pages_wizard.rs

1//! Pages wizard state model, config persistence, and validation.
2//!
3//! This module defines the state machine for the interactive `--pages` wizard,
4//! including deploy target configuration, validation, and saved config support.
5//! The actual interactive prompts are wired up in `main.rs`; this module is the
6//! testable core that the interactive layer drives.
7
8use std::fmt;
9use std::fs;
10use std::io::{BufRead, Write as IoWrite};
11use std::path::{Path, PathBuf};
12use std::time::Instant;
13
14use serde::{Deserialize, Serialize};
15
16use crate::{BvrError, Result};
17
18// ── Deploy target ──────────────────────────────────────────────────
19
20/// Supported static-hosting deployment targets.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum DeployTarget {
24    Github,
25    Cloudflare,
26    Local,
27}
28
29impl DeployTarget {
30    pub const ALL: [Self; 3] = [Self::Github, Self::Cloudflare, Self::Local];
31
32    /// Human label for display in prompts.
33    pub const fn label(self) -> &'static str {
34        match self {
35            Self::Github => "GitHub Pages",
36            Self::Cloudflare => "Cloudflare Pages",
37            Self::Local => "Local / custom static host",
38        }
39    }
40
41    /// CLI tools required before deployment (empty for local).
42    pub const fn required_tools(self) -> &'static [&'static str] {
43        match self {
44            Self::Github => &["gh"],
45            Self::Cloudflare => &["wrangler"],
46            Self::Local => &[],
47        }
48    }
49}
50
51impl fmt::Display for DeployTarget {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(self.label())
54    }
55}
56
57// ── Wizard step ────────────────────────────────────────────────────
58
59/// Steps in the wizard state machine.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum WizardStep {
62    /// Offer to load saved config from disk.
63    LoadSaved = 0,
64    /// Collect export options (closed, history, title).
65    ExportOptions = 1,
66    /// Choose deployment target.
67    DeployTarget = 2,
68    /// Collect target-specific settings.
69    TargetConfig = 3,
70    /// Verify prerequisites (CLI tools, auth).
71    Prerequisites = 4,
72    /// Perform the export.
73    Export = 5,
74    /// Offer local preview before deploy.
75    Preview = 6,
76    /// Deploy to target.
77    Deploy = 7,
78    /// Show success summary.
79    Done = 8,
80}
81
82impl WizardStep {
83    /// All steps in order.
84    pub const ALL: [Self; 9] = [
85        Self::LoadSaved,
86        Self::ExportOptions,
87        Self::DeployTarget,
88        Self::TargetConfig,
89        Self::Prerequisites,
90        Self::Export,
91        Self::Preview,
92        Self::Deploy,
93        Self::Done,
94    ];
95
96    /// Advance to the next step.
97    pub fn next(self) -> Option<Self> {
98        let idx = self as usize;
99        Self::ALL.get(idx + 1).copied()
100    }
101
102    /// Go back to the previous user-configurable step.
103    /// Export/Preview/Deploy/Done cannot be backed out of.
104    pub fn back(self) -> Option<Self> {
105        match self {
106            Self::LoadSaved => None,
107            Self::ExportOptions => Some(Self::LoadSaved),
108            Self::DeployTarget => Some(Self::ExportOptions),
109            Self::TargetConfig => Some(Self::DeployTarget),
110            Self::Prerequisites => Some(Self::TargetConfig),
111            // Cannot back out of execution steps
112            Self::Export | Self::Preview | Self::Deploy | Self::Done => None,
113        }
114    }
115
116    /// Whether this step can be cancelled (returns to caller).
117    pub fn is_cancellable(self) -> bool {
118        matches!(
119            self,
120            Self::LoadSaved
121                | Self::ExportOptions
122                | Self::DeployTarget
123                | Self::TargetConfig
124                | Self::Prerequisites
125        )
126    }
127
128    /// Human label for progress display.
129    pub const fn label(self) -> &'static str {
130        match self {
131            Self::LoadSaved => "Load saved config",
132            Self::ExportOptions => "Export options",
133            Self::DeployTarget => "Deploy target",
134            Self::TargetConfig => "Target settings",
135            Self::Prerequisites => "Prerequisites",
136            Self::Export => "Export",
137            Self::Preview => "Preview",
138            Self::Deploy => "Deploy",
139            Self::Done => "Done",
140        }
141    }
142
143    /// Step number for display (1-indexed).
144    pub const fn display_number(self) -> usize {
145        (self as usize) + 1
146    }
147
148    /// Total number of steps.
149    pub const fn total() -> usize {
150        Self::ALL.len()
151    }
152}
153
154// ── Wizard config ──────────────────────────────────────────────────
155
156/// Persistent wizard configuration, saved between runs.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct WizardConfig {
159    /// Include closed issues in export.
160    #[serde(default = "default_true")]
161    pub include_closed: bool,
162    /// Include git history for time-travel views.
163    #[serde(default = "default_true")]
164    pub include_history: bool,
165    /// Custom title for the exported site.
166    #[serde(default)]
167    pub title: Option<String>,
168    /// Subtitle for the exported site.
169    #[serde(default)]
170    pub subtitle: Option<String>,
171    /// Deployment target.
172    #[serde(default)]
173    pub deploy_target: Option<DeployTarget>,
174    /// Output directory for the export bundle.
175    #[serde(default)]
176    pub output_path: Option<PathBuf>,
177
178    // GitHub-specific
179    /// GitHub repo name (owner/repo format).
180    #[serde(default)]
181    pub github_repo: Option<String>,
182    /// Whether to create a private GitHub repo.
183    #[serde(default)]
184    pub github_private: bool,
185    /// GitHub repo description.
186    #[serde(default)]
187    pub github_description: Option<String>,
188
189    // Cloudflare-specific
190    /// Cloudflare Pages project name.
191    #[serde(default)]
192    pub cloudflare_project: Option<String>,
193    /// Cloudflare Pages branch.
194    #[serde(default)]
195    pub cloudflare_branch: Option<String>,
196}
197
198fn default_true() -> bool {
199    true
200}
201
202impl Default for WizardConfig {
203    fn default() -> Self {
204        Self {
205            include_closed: true,
206            include_history: true,
207            title: None,
208            subtitle: None,
209            deploy_target: None,
210            output_path: None,
211            github_repo: None,
212            github_private: false,
213            github_description: None,
214            cloudflare_project: None,
215            cloudflare_branch: None,
216        }
217    }
218}
219
220impl WizardConfig {
221    fn has_valid_github_repo(&self) -> bool {
222        self.github_repo.as_deref().is_some_and(|repo| {
223            let repo = repo.trim();
224            let mut parts = repo.split('/');
225            let Some(owner) = parts.next() else {
226                return false;
227            };
228            let Some(name) = parts.next() else {
229                return false;
230            };
231
232            !owner.is_empty()
233                && !name.is_empty()
234                && parts.next().is_none()
235                && !owner.contains(char::is_whitespace)
236                && !name.contains(char::is_whitespace)
237        })
238    }
239
240    fn has_output_path(&self) -> bool {
241        self.output_path.as_ref().is_some_and(|path| {
242            !path.as_os_str().is_empty() && !path.to_string_lossy().trim().is_empty()
243        })
244    }
245
246    /// Validate the config for completeness before export.
247    pub fn validate_for_export(&self) -> Result<()> {
248        if !self.has_output_path() {
249            return Err(BvrError::InvalidArgument(
250                "output path is required for export".into(),
251            ));
252        }
253        Ok(())
254    }
255
256    /// Validate the config for completeness before deployment.
257    pub fn validate_for_deploy(&self) -> Result<()> {
258        self.validate_for_export()?;
259        let target = self
260            .deploy_target
261            .ok_or_else(|| BvrError::InvalidArgument("deploy target is required".into()))?;
262        match target {
263            DeployTarget::Github => {
264                if !self.has_valid_github_repo() {
265                    return Err(BvrError::InvalidArgument(
266                        "GitHub repo name is required (owner/repo format)".into(),
267                    ));
268                }
269            }
270            DeployTarget::Cloudflare => {
271                if self
272                    .cloudflare_project
273                    .as_deref()
274                    .map(str::trim)
275                    .is_none_or(str::is_empty)
276                {
277                    return Err(BvrError::InvalidArgument(
278                        "Cloudflare project name is required".into(),
279                    ));
280                }
281            }
282            DeployTarget::Local => {}
283        }
284        Ok(())
285    }
286
287    /// Clear target-specific fields when switching deploy target.
288    pub fn clear_target_config(&mut self) {
289        self.github_repo = None;
290        self.github_private = false;
291        self.github_description = None;
292        self.cloudflare_project = None;
293        self.cloudflare_branch = None;
294    }
295}
296
297fn repair_step_for_saved_config(config: &WizardConfig) -> WizardStep {
298    if !config.has_output_path() {
299        WizardStep::ExportOptions
300    } else if config.deploy_target.is_none() {
301        WizardStep::DeployTarget
302    } else {
303        WizardStep::TargetConfig
304    }
305}
306
307// ── Config persistence ─────────────────────────────────────────────
308
309/// Default config directory: `~/.config/bvr/`.
310fn config_dir() -> Option<PathBuf> {
311    dirs_path().map(|d| d.join("bvr"))
312}
313
314/// Cross-platform config base path.
315fn dirs_path() -> Option<PathBuf> {
316    std::env::var_os("XDG_CONFIG_HOME")
317        .map(PathBuf::from)
318        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
319}
320
321const WIZARD_CONFIG_FILENAME: &str = "pages-wizard.json";
322
323/// Path to the saved wizard config file.
324pub fn wizard_config_path() -> Option<PathBuf> {
325    config_dir().map(|d| d.join(WIZARD_CONFIG_FILENAME))
326}
327
328/// Load a previously saved wizard config from disk.
329pub fn load_wizard_config() -> Result<Option<WizardConfig>> {
330    let path = match wizard_config_path() {
331        Some(p) => p,
332        None => return Ok(None),
333    };
334    if !path.is_file() {
335        return Ok(None);
336    }
337    let contents = fs::read_to_string(&path)
338        .map_err(|e| BvrError::InvalidArgument(format!("failed to read wizard config: {e}")))?;
339    let config: WizardConfig = serde_json::from_str(&contents)
340        .map_err(|e| BvrError::InvalidArgument(format!("failed to parse wizard config: {e}")))?;
341    Ok(Some(config))
342}
343
344/// Save wizard config to disk for future reuse.
345pub fn save_wizard_config(config: &WizardConfig) -> Result<()> {
346    let path = match wizard_config_path() {
347        Some(p) => p,
348        None => {
349            return Err(BvrError::InvalidArgument(
350                "cannot determine config directory".into(),
351            ));
352        }
353    };
354    if let Some(parent) = path.parent() {
355        fs::create_dir_all(parent).map_err(|e| {
356            BvrError::InvalidArgument(format!(
357                "failed to create config directory {}: {e}",
358                parent.display()
359            ))
360        })?;
361    }
362    let json = serde_json::to_string_pretty(config).map_err(|e| {
363        BvrError::InvalidArgument(format!("failed to serialize wizard config: {e}"))
364    })?;
365    fs::write(&path, json)
366        .map_err(|e| BvrError::InvalidArgument(format!("failed to write wizard config: {e}")))?;
367    Ok(())
368}
369
370/// Save wizard config to a specific path (for testing).
371pub fn save_wizard_config_to(config: &WizardConfig, path: &Path) -> Result<()> {
372    if let Some(parent) = path.parent() {
373        fs::create_dir_all(parent)
374            .map_err(|e| BvrError::InvalidArgument(format!("mkdir {}: {e}", parent.display())))?;
375    }
376    let json = serde_json::to_string_pretty(config)
377        .map_err(|e| BvrError::InvalidArgument(format!("serialize: {e}")))?;
378    fs::write(path, json)
379        .map_err(|e| BvrError::InvalidArgument(format!("write {}: {e}", path.display())))?;
380    Ok(())
381}
382
383/// Load wizard config from a specific path (for testing).
384pub fn load_wizard_config_from(path: &Path) -> Result<Option<WizardConfig>> {
385    if !path.is_file() {
386        return Ok(None);
387    }
388    let contents = fs::read_to_string(path)
389        .map_err(|e| BvrError::InvalidArgument(format!("read {}: {e}", path.display())))?;
390    let config: WizardConfig = serde_json::from_str(&contents)
391        .map_err(|e| BvrError::InvalidArgument(format!("parse {}: {e}", path.display())))?;
392    Ok(Some(config))
393}
394
395// ── Prerequisite checking ──────────────────────────────────────────
396
397/// Result of checking prerequisites for a deploy target.
398#[derive(Debug, Clone)]
399pub struct PrereqResult {
400    pub target: DeployTarget,
401    pub missing_tools: Vec<String>,
402    pub passed: bool,
403}
404
405/// Check whether required CLI tools are available on PATH.
406pub fn check_prerequisites(target: DeployTarget) -> PrereqResult {
407    let mut missing = Vec::new();
408    for tool in target.required_tools() {
409        if !is_tool_available(tool) {
410            missing.push((*tool).to_string());
411        }
412    }
413    PrereqResult {
414        target,
415        passed: missing.is_empty(),
416        missing_tools: missing,
417    }
418}
419
420fn is_tool_available(name: &str) -> bool {
421    #[cfg(test)]
422    {
423        let _ = name;
424        return true;
425    }
426    #[cfg(not(test))]
427    {
428        std::process::Command::new("which")
429            .arg(name)
430            .stdout(std::process::Stdio::null())
431            .stderr(std::process::Stdio::null())
432            .status()
433            .map(|s| s.success())
434            .unwrap_or(false)
435    }
436}
437
438// ── Wizard transcript (diagnostic trace) ──────────────────────────
439
440/// A single entry in the wizard transcript.
441#[derive(Debug, Clone)]
442pub struct TranscriptEntry {
443    pub step: WizardStep,
444    pub action: String,
445    pub elapsed_ms: u64,
446}
447
448/// Debug transcript recording wizard step transitions and outcomes.
449#[derive(Debug, Clone, Default)]
450pub struct WizardTranscript {
451    entries: Vec<TranscriptEntry>,
452    start: Option<Instant>,
453}
454
455impl WizardTranscript {
456    fn new() -> Self {
457        Self {
458            entries: Vec::new(),
459            start: Some(Instant::now()),
460        }
461    }
462
463    fn record(&mut self, step: WizardStep, action: &str) {
464        let elapsed = self
465            .start
466            .map(|s| u64::try_from(s.elapsed().as_millis()).unwrap_or(u64::MAX))
467            .unwrap_or(0);
468        self.entries.push(TranscriptEntry {
469            step,
470            action: action.to_string(),
471            elapsed_ms: elapsed,
472        });
473    }
474
475    /// Format the transcript as a diagnostic summary.
476    pub fn summary(&self) -> String {
477        let mut out = String::from("wizard transcript:\n");
478        for entry in &self.entries {
479            out.push_str(&format!(
480                "  [{:>6}ms] {:?}: {}\n",
481                entry.elapsed_ms, entry.step, entry.action
482            ));
483        }
484        out
485    }
486
487    pub fn entries(&self) -> &[TranscriptEntry] {
488        &self.entries
489    }
490}
491
492// ── Wizard state machine ───────────────────────────────────────────
493
494/// Result of a wizard step interaction.
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum StepResult {
497    /// Advance to the next step.
498    Next,
499    /// Go back to the previous step.
500    Back,
501    /// User cancelled the wizard.
502    Cancel,
503}
504
505/// The wizard state machine.
506pub struct Wizard {
507    pub config: WizardConfig,
508    pub step: WizardStep,
509    pub is_update: bool,
510    pub transcript: WizardTranscript,
511    beads_path: Option<PathBuf>,
512}
513
514impl Wizard {
515    /// Create a new wizard with default config.
516    pub fn new(beads_path: Option<PathBuf>) -> Self {
517        Self {
518            config: WizardConfig::default(),
519            step: WizardStep::LoadSaved,
520            is_update: false,
521            transcript: WizardTranscript::new(),
522            beads_path,
523        }
524    }
525
526    /// Create a wizard with a pre-loaded saved config.
527    pub fn with_saved_config(config: WizardConfig, beads_path: Option<PathBuf>) -> Self {
528        Self {
529            config,
530            step: WizardStep::Prerequisites,
531            is_update: true,
532            transcript: WizardTranscript::new(),
533            beads_path,
534        }
535    }
536
537    /// Beads path, if provided.
538    pub fn beads_path(&self) -> Option<&Path> {
539        self.beads_path.as_deref()
540    }
541
542    /// Advance to the next step, returning None when done.
543    pub fn advance(&mut self) -> Option<WizardStep> {
544        if let Some(next) = self.step.next() {
545            self.step = next;
546            Some(next)
547        } else {
548            None
549        }
550    }
551
552    /// Go back one step.
553    pub fn go_back(&mut self) -> Option<WizardStep> {
554        if let Some(prev) = self.step.back() {
555            self.step = prev;
556            Some(prev)
557        } else {
558            None
559        }
560    }
561
562    /// Whether the current step can be cancelled.
563    pub fn can_cancel(&self) -> bool {
564        self.step.is_cancellable()
565    }
566
567    /// Whether the wizard has reached the done state.
568    pub fn is_done(&self) -> bool {
569        self.step == WizardStep::Done
570    }
571
572    /// Apply a step result to the wizard state.
573    pub fn apply_result(&mut self, result: StepResult) -> WizardTransition {
574        match result {
575            StepResult::Next => {
576                if let Some(next) = self.advance() {
577                    WizardTransition::GoTo(next)
578                } else {
579                    WizardTransition::Finished
580                }
581            }
582            StepResult::Back => {
583                if let Some(prev) = self.go_back() {
584                    WizardTransition::GoTo(prev)
585                } else {
586                    WizardTransition::StayOnCurrent
587                }
588            }
589            StepResult::Cancel => WizardTransition::Cancelled,
590        }
591    }
592}
593
594/// Result of applying a step result to the wizard.
595#[derive(Debug, Clone, PartialEq, Eq)]
596pub enum WizardTransition {
597    /// Move to a specific step.
598    GoTo(WizardStep),
599    /// Stay on the current step (back from first step).
600    StayOnCurrent,
601    /// Wizard completed successfully.
602    Finished,
603    /// User cancelled.
604    Cancelled,
605}
606
607// ── Config summary (action preview) ────────────────────────────────
608
609/// Write a human-readable config summary showing what the wizard will do.
610fn write_config_preview<W: IoWrite>(writer: &mut W, config: &WizardConfig) {
611    writeln!(writer, "  ┌─ Configuration summary ───────────────────").ok();
612    if let Some(ref path) = config.output_path {
613        writeln!(writer, "  │ Output:    {}", path.display()).ok();
614    }
615    if let Some(ref title) = config.title {
616        writeln!(writer, "  │ Title:     {title}").ok();
617    }
618    if let Some(ref sub) = config.subtitle {
619        writeln!(writer, "  │ Subtitle:  {sub}").ok();
620    }
621    writeln!(
622        writer,
623        "  │ Closed:    {}",
624        if config.include_closed { "yes" } else { "no" }
625    )
626    .ok();
627    writeln!(
628        writer,
629        "  │ History:   {}",
630        if config.include_history { "yes" } else { "no" }
631    )
632    .ok();
633    if let Some(target) = config.deploy_target {
634        writeln!(writer, "  │ Target:    {target}").ok();
635        match target {
636            DeployTarget::Github => {
637                if let Some(ref repo) = config.github_repo {
638                    writeln!(writer, "  │ Repo:      {repo}").ok();
639                }
640            }
641            DeployTarget::Cloudflare => {
642                if let Some(ref proj) = config.cloudflare_project {
643                    writeln!(writer, "  │ Project:   {proj}").ok();
644                }
645            }
646            DeployTarget::Local => {}
647        }
648    }
649    writeln!(writer, "  └──────────────────────────────────────────").ok();
650    writeln!(writer).ok();
651    writeln!(writer, "  [auto]   Export generates the static HTML bundle").ok();
652    writeln!(
653        writer,
654        "  [auto]   Preview starts a local server (if chosen)"
655    )
656    .ok();
657    writeln!(
658        writer,
659        "  [manual] Deploy commands are printed — you run them"
660    )
661    .ok();
662}
663
664fn shell_quote(value: &str) -> String {
665    if value.is_empty() {
666        return "''".to_string();
667    }
668
669    let escaped = value.replace('\'', "'\"'\"'");
670    format!("'{escaped}'")
671}
672
673// ── Interactive wizard runner ───────────────────────────────────────
674
675/// Run the interactive pages wizard with the given I/O streams.
676///
677/// Uses `reader` for user input and `writer` for prompts/output.
678/// The `beads_path` is the path to the beads file to export from.
679/// The `export_fn` callback performs the actual export given the config.
680/// The `preview_fn` callback starts a preview server for the given path.
681///
682/// Returns `Ok(Some(config))` on success, `Ok(None)` on cancel,
683/// or `Err` on I/O or validation failure.
684pub fn run_wizard_interactive<R, W, E, P>(
685    reader: &mut R,
686    writer: &mut W,
687    beads_path: Option<PathBuf>,
688    saved_config: Option<WizardConfig>,
689    export_fn: E,
690    preview_fn: P,
691) -> Result<Option<WizardConfig>>
692where
693    R: BufRead,
694    W: IoWrite,
695    E: FnOnce(&WizardConfig) -> Result<()>,
696    P: Fn(&Path) -> Result<()>,
697{
698    let mut export_fn = Some(export_fn);
699
700    writeln!(writer, "╭──────────────────────────────────────╮").ok();
701    writeln!(writer, "│  bvr pages wizard                    │").ok();
702    writeln!(writer, "╰──────────────────────────────────────╯").ok();
703    writeln!(writer).ok();
704
705    // Step 1: Check for saved config
706    let mut wizard = match saved_config {
707        Some(saved) => {
708            writeln!(writer, "Found saved configuration.").ok();
709            write!(writer, "Use saved config? [Y/n] ").ok();
710            writer.flush().ok();
711            let answer = read_line_trimmed(reader);
712            if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
713                writeln!(writer, "  → Using saved config").ok();
714                Wizard::with_saved_config(saved, beads_path)
715            } else {
716                Wizard::new(beads_path)
717            }
718        }
719        None => Wizard::new(beads_path),
720    };
721
722    // If starting fresh, advance past LoadSaved
723    if wizard.step == WizardStep::LoadSaved {
724        wizard.advance();
725    }
726
727    loop {
728        match wizard.step {
729            WizardStep::LoadSaved => {
730                // Already handled above
731                wizard.advance();
732            }
733            WizardStep::ExportOptions => {
734                wizard.transcript.record(wizard.step, "begin");
735                writeln!(writer).ok();
736                writeln!(
737                    writer,
738                    "Step {}/{}: {}",
739                    wizard.step.display_number(),
740                    WizardStep::total(),
741                    wizard.step.label()
742                )
743                .ok();
744
745                wizard.config.include_closed =
746                    prompt_yes_no(reader, writer, "Include closed issues?", true);
747                wizard.config.include_history =
748                    prompt_yes_no(reader, writer, "Include git history?", true);
749                wizard.config.title =
750                    prompt_optional(reader, writer, "Custom title (empty = default)");
751                wizard.config.subtitle =
752                    prompt_optional(reader, writer, "Custom subtitle (empty = none)");
753
754                wizard.transcript.record(wizard.step, "complete");
755                wizard.advance();
756            }
757            WizardStep::DeployTarget => {
758                wizard.transcript.record(wizard.step, "begin");
759                writeln!(writer).ok();
760                writeln!(
761                    writer,
762                    "Step {}/{}: {}",
763                    wizard.step.display_number(),
764                    WizardStep::total(),
765                    wizard.step.label()
766                )
767                .ok();
768
769                writeln!(writer, "Where will you deploy?").ok();
770                for (i, target) in DeployTarget::ALL.iter().enumerate() {
771                    writeln!(writer, "  {}) {}", i + 1, target.label()).ok();
772                }
773                write!(writer, "Choice [1-3, or 'b' to go back]: ").ok();
774                writer.flush().ok();
775                let answer = read_line_trimmed(reader);
776
777                if answer == "b" || answer == "B" {
778                    wizard.go_back();
779                    continue;
780                }
781
782                let choice = answer.parse::<usize>().unwrap_or(0);
783                if choice >= 1 && choice <= 3 {
784                    let target = DeployTarget::ALL[choice - 1];
785                    if wizard.config.deploy_target != Some(target) {
786                        wizard.config.clear_target_config();
787                    }
788                    wizard.config.deploy_target = Some(target);
789                    wizard
790                        .transcript
791                        .record(wizard.step, &format!("selected {target}"));
792                    wizard.advance();
793                } else {
794                    writeln!(writer, "Invalid choice, please enter 1, 2, or 3.").ok();
795                    // Stay on current step
796                }
797            }
798            WizardStep::TargetConfig => {
799                wizard.transcript.record(wizard.step, "begin");
800                writeln!(writer).ok();
801                writeln!(
802                    writer,
803                    "Step {}/{}: {}",
804                    wizard.step.display_number(),
805                    WizardStep::total(),
806                    wizard.step.label()
807                )
808                .ok();
809
810                match wizard.config.deploy_target {
811                    Some(DeployTarget::Github) => {
812                        wizard.config.github_repo =
813                            prompt_required(reader, writer, "GitHub repo (owner/repo)");
814                        if wizard.config.github_repo.is_none() {
815                            wizard.go_back();
816                            continue;
817                        }
818                        wizard.config.github_private =
819                            prompt_yes_no(reader, writer, "Private repo?", false);
820                        wizard.config.github_description =
821                            prompt_optional(reader, writer, "Repo description (optional)");
822                    }
823                    Some(DeployTarget::Cloudflare) => {
824                        wizard.config.cloudflare_project =
825                            prompt_required(reader, writer, "Cloudflare project name");
826                        if wizard.config.cloudflare_project.is_none() {
827                            wizard.go_back();
828                            continue;
829                        }
830                        wizard.config.cloudflare_branch =
831                            prompt_optional(reader, writer, "Branch name (default: production)");
832                    }
833                    Some(DeployTarget::Local) | None => {
834                        // Local needs output path
835                    }
836                }
837
838                // Output path (all targets)
839                write!(writer, "Output directory [./bv-pages]: ").ok();
840                writer.flush().ok();
841                let path = read_line_trimmed(reader);
842                wizard.config.output_path = Some(PathBuf::from(if path.is_empty() {
843                    "./bv-pages".to_string()
844                } else {
845                    path
846                }));
847
848                wizard.transcript.record(wizard.step, "complete");
849                wizard.advance();
850            }
851            WizardStep::Prerequisites => {
852                wizard.transcript.record(wizard.step, "begin");
853                writeln!(writer).ok();
854                writeln!(
855                    writer,
856                    "Step {}/{}: {}",
857                    wizard.step.display_number(),
858                    WizardStep::total(),
859                    wizard.step.label()
860                )
861                .ok();
862
863                if let Some(target) = wizard.config.deploy_target {
864                    let result = check_prerequisites(target);
865                    if result.passed {
866                        writeln!(writer, "  ✓ All prerequisites met for {target}").ok();
867                    } else {
868                        writeln!(
869                            writer,
870                            "  ✗ Missing tools: {}",
871                            result.missing_tools.join(", ")
872                        )
873                        .ok();
874                        writeln!(writer, "  Install the missing tools and retry, or go back to choose a different target.").ok();
875                        write!(writer, "  [r]etry / [b]ack / [c]ancel: ").ok();
876                        writer.flush().ok();
877                        let answer = read_line_trimmed(reader);
878                        match answer.as_str() {
879                            "b" | "B" => {
880                                wizard.go_back();
881                                continue;
882                            }
883                            "c" | "C" => return Ok(None),
884                            _ => continue, // retry
885                        }
886                    }
887                }
888
889                // Saved configs can jump straight here, so validate the full
890                // deploy target payload before we start exporting.
891                match wizard.config.validate_for_deploy() {
892                    Ok(()) => {
893                        writeln!(writer).ok();
894                        write_config_preview(writer, &wizard.config);
895                        wizard.transcript.record(wizard.step, "prereqs passed");
896                        wizard.advance();
897                    }
898                    Err(e) => {
899                        writeln!(writer, "  Config validation failed: {e}").ok();
900                        wizard
901                            .transcript
902                            .record(wizard.step, &format!("validation failed: {e}"));
903                        wizard.step = repair_step_for_saved_config(&wizard.config);
904                        continue;
905                    }
906                }
907            }
908            WizardStep::Export => {
909                wizard.transcript.record(wizard.step, "begin [auto]");
910                writeln!(writer).ok();
911                writeln!(
912                    writer,
913                    "Step {}/{}: [auto] Exporting bundle...",
914                    wizard.step.display_number(),
915                    WizardStep::total(),
916                )
917                .ok();
918
919                let Some(do_export) = export_fn.take() else {
920                    writeln!(writer, "  ✗ Export already executed").ok();
921                    wizard
922                        .transcript
923                        .record(wizard.step, "export skipped: already executed");
924                    wizard.advance();
925                    continue;
926                };
927                match do_export(&wizard.config) {
928                    Ok(()) => {
929                        writeln!(
930                            writer,
931                            "  ✓ Export complete: {}",
932                            wizard
933                                .config
934                                .output_path
935                                .as_deref()
936                                .unwrap_or(Path::new("?"))
937                                .display()
938                        )
939                        .ok();
940                        wizard.transcript.record(wizard.step, "export succeeded");
941                        wizard.advance();
942                    }
943                    Err(e) => {
944                        writeln!(writer, "  ✗ Export failed: {e}").ok();
945                        wizard
946                            .transcript
947                            .record(wizard.step, &format!("export FAILED: {e}"));
948                        writeln!(writer, "\n  -- debug transcript --").ok();
949                        write!(writer, "{}", wizard.transcript.summary()).ok();
950                        return Err(e);
951                    }
952                }
953            }
954            WizardStep::Preview => {
955                wizard.transcript.record(wizard.step, "begin [auto]");
956                writeln!(writer).ok();
957                write!(writer, "[auto] Preview the export locally? [Y/n] ").ok();
958                writer.flush().ok();
959                let answer = read_line_trimmed(reader);
960                if answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y') {
961                    if let Some(path) = wizard.config.output_path.as_deref() {
962                        if let Err(e) = preview_fn(path) {
963                            writeln!(writer, "  Preview error: {e}").ok();
964                            wizard
965                                .transcript
966                                .record(wizard.step, &format!("preview error: {e}"));
967                        }
968                    }
969                    wizard.transcript.record(wizard.step, "previewed");
970                } else {
971                    writeln!(writer, "  Skipping preview.").ok();
972                    wizard.transcript.record(wizard.step, "skipped");
973                }
974                wizard.advance();
975            }
976            WizardStep::Deploy => {
977                wizard
978                    .transcript
979                    .record(wizard.step, "begin [manual handoff]");
980                writeln!(writer).ok();
981                writeln!(
982                    writer,
983                    "Step {}/{}: [manual] Deploy instructions",
984                    wizard.step.display_number(),
985                    WizardStep::total(),
986                )
987                .ok();
988
989                let target = wizard
990                    .config
991                    .deploy_target
992                    .map_or("local".to_string(), |t| t.label().to_string());
993                let output = wizard
994                    .config
995                    .output_path
996                    .as_deref()
997                    .unwrap_or(Path::new("./bv-pages"));
998
999                writeln!(writer, "  Target: {target}").ok();
1000                writeln!(writer, "  Bundle: {}", output.display()).ok();
1001
1002                match wizard.config.deploy_target {
1003                    Some(DeployTarget::Local) | None => {
1004                        writeln!(writer, "  Your bundle is ready at: {}", output.display()).ok();
1005                        writeln!(
1006                            writer,
1007                            "  Deploy it to any static host (Netlify, Vercel, S3, etc.)"
1008                        )
1009                        .ok();
1010                    }
1011                    Some(DeployTarget::Github) => {
1012                        let repo = wizard.config.github_repo.as_deref().unwrap_or("?");
1013                        let visibility_flag = if wizard.config.github_private {
1014                            "--private"
1015                        } else {
1016                            "--public"
1017                        };
1018                        writeln!(writer, "  Deploy to GitHub Pages: {repo}").ok();
1019                        let mut command =
1020                            format!("gh repo create {} {visibility_flag}", shell_quote(repo));
1021                        if let Some(description) = wizard
1022                            .config
1023                            .github_description
1024                            .as_deref()
1025                            .map(str::trim)
1026                            .filter(|value| !value.is_empty())
1027                        {
1028                            command.push_str(" --description ");
1029                            command.push_str(&shell_quote(description));
1030                        }
1031                        writeln!(writer, "  Run: {command}").ok();
1032                        writeln!(
1033                            writer,
1034                            "  Then publish {} to your gh-pages branch with your preferred git workflow.",
1035                            shell_quote(&output.display().to_string())
1036                        )
1037                        .ok();
1038                    }
1039                    Some(DeployTarget::Cloudflare) => {
1040                        let project = wizard.config.cloudflare_project.as_deref().unwrap_or("?");
1041                        writeln!(writer, "  Deploy to Cloudflare Pages: {project}").ok();
1042                        let mut command = format!(
1043                            "wrangler pages deploy {} --project-name={}",
1044                            shell_quote(&output.display().to_string()),
1045                            shell_quote(project)
1046                        );
1047                        if let Some(branch) = wizard
1048                            .config
1049                            .cloudflare_branch
1050                            .as_deref()
1051                            .map(str::trim)
1052                            .filter(|value| !value.is_empty())
1053                        {
1054                            command.push_str(" --branch=");
1055                            command.push_str(&shell_quote(branch));
1056                        }
1057                        writeln!(writer, "  Run: {command}").ok();
1058                    }
1059                }
1060                wizard.transcript.record(wizard.step, "instructions shown");
1061                wizard.advance();
1062            }
1063            WizardStep::Done => {
1064                wizard.transcript.record(wizard.step, "complete");
1065                writeln!(writer).ok();
1066                writeln!(writer, "✓ Pages wizard complete!").ok();
1067
1068                // Save config for reuse
1069                if let Err(e) = save_wizard_config(&wizard.config) {
1070                    writeln!(writer, "  (could not save config for reuse: {e})").ok();
1071                } else {
1072                    writeln!(writer, "  Config saved for next run.").ok();
1073                }
1074
1075                // Emit transcript in verbose/debug mode
1076                if std::env::var("BVR_WIZARD_DEBUG").is_ok() {
1077                    writeln!(writer, "\n  -- debug transcript --").ok();
1078                    write!(writer, "{}", wizard.transcript.summary()).ok();
1079                }
1080
1081                return Ok(Some(wizard.config));
1082            }
1083        }
1084    }
1085}
1086
1087fn read_line_trimmed<R: BufRead>(reader: &mut R) -> String {
1088    let mut line = String::new();
1089    let _ = reader.read_line(&mut line);
1090    line.trim().to_string()
1091}
1092
1093fn prompt_yes_no<R: BufRead, W: IoWrite>(
1094    reader: &mut R,
1095    writer: &mut W,
1096    prompt: &str,
1097    default: bool,
1098) -> bool {
1099    let hint = if default { "[Y/n]" } else { "[y/N]" };
1100    write!(writer, "  {prompt} {hint} ").ok();
1101    writer.flush().ok();
1102    let answer = read_line_trimmed(reader);
1103    if answer.is_empty() {
1104        default
1105    } else {
1106        answer.starts_with('y') || answer.starts_with('Y')
1107    }
1108}
1109
1110fn prompt_optional<R: BufRead, W: IoWrite>(
1111    reader: &mut R,
1112    writer: &mut W,
1113    prompt: &str,
1114) -> Option<String> {
1115    write!(writer, "  {prompt}: ").ok();
1116    writer.flush().ok();
1117    let answer = read_line_trimmed(reader);
1118    if answer.is_empty() {
1119        None
1120    } else {
1121        Some(answer)
1122    }
1123}
1124
1125fn prompt_required<R: BufRead, W: IoWrite>(
1126    reader: &mut R,
1127    writer: &mut W,
1128    prompt: &str,
1129) -> Option<String> {
1130    write!(writer, "  {prompt}: ").ok();
1131    writer.flush().ok();
1132    let answer = read_line_trimmed(reader);
1133    if answer.is_empty() {
1134        writeln!(writer, "  (required, going back)").ok();
1135        None
1136    } else {
1137        Some(answer)
1138    }
1139}
1140
1141// ── Tests ──────────────────────────────────────────────────────────
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146    use tempfile::tempdir;
1147
1148    // ── DeployTarget ───────────────────────────────────────────────
1149
1150    #[test]
1151    fn deploy_target_all_has_three_variants() {
1152        assert_eq!(DeployTarget::ALL.len(), 3);
1153    }
1154
1155    #[test]
1156    fn deploy_target_labels_are_non_empty() {
1157        for target in DeployTarget::ALL {
1158            assert!(!target.label().is_empty());
1159        }
1160    }
1161
1162    #[test]
1163    fn deploy_target_github_requires_gh_tool() {
1164        assert_eq!(DeployTarget::Github.required_tools(), &["gh"]);
1165    }
1166
1167    #[test]
1168    fn deploy_target_cloudflare_requires_wrangler() {
1169        assert_eq!(DeployTarget::Cloudflare.required_tools(), &["wrangler"]);
1170    }
1171
1172    #[test]
1173    fn deploy_target_local_requires_no_tools() {
1174        assert!(DeployTarget::Local.required_tools().is_empty());
1175    }
1176
1177    #[test]
1178    fn deploy_target_display_matches_label() {
1179        for target in DeployTarget::ALL {
1180            assert_eq!(format!("{target}"), target.label());
1181        }
1182    }
1183
1184    #[test]
1185    fn deploy_target_serde_roundtrip() {
1186        for target in DeployTarget::ALL {
1187            let json = serde_json::to_string(&target).unwrap();
1188            let back: DeployTarget = serde_json::from_str(&json).unwrap();
1189            assert_eq!(target, back);
1190        }
1191    }
1192
1193    // ── WizardStep ─────────────────────────────────────────────────
1194
1195    #[test]
1196    fn wizard_step_ordering_is_sequential() {
1197        for (i, step) in WizardStep::ALL.iter().enumerate() {
1198            assert_eq!(*step as usize, i);
1199        }
1200    }
1201
1202    #[test]
1203    fn wizard_step_next_advances_through_all() {
1204        let mut step = WizardStep::LoadSaved;
1205        let mut count = 1;
1206        while let Some(next) = step.next() {
1207            step = next;
1208            count += 1;
1209        }
1210        assert_eq!(count, WizardStep::total());
1211        assert_eq!(step, WizardStep::Done);
1212    }
1213
1214    #[test]
1215    fn wizard_step_done_has_no_next() {
1216        assert_eq!(WizardStep::Done.next(), None);
1217    }
1218
1219    #[test]
1220    fn wizard_step_back_from_first_is_none() {
1221        assert_eq!(WizardStep::LoadSaved.back(), None);
1222    }
1223
1224    #[test]
1225    fn wizard_step_back_from_export_options_goes_to_load_saved() {
1226        assert_eq!(
1227            WizardStep::ExportOptions.back(),
1228            Some(WizardStep::LoadSaved)
1229        );
1230    }
1231
1232    #[test]
1233    fn wizard_step_back_from_prerequisites_goes_to_target_config() {
1234        assert_eq!(
1235            WizardStep::Prerequisites.back(),
1236            Some(WizardStep::TargetConfig)
1237        );
1238    }
1239
1240    #[test]
1241    fn wizard_step_execution_steps_cannot_go_back() {
1242        assert_eq!(WizardStep::Export.back(), None);
1243        assert_eq!(WizardStep::Preview.back(), None);
1244        assert_eq!(WizardStep::Deploy.back(), None);
1245        assert_eq!(WizardStep::Done.back(), None);
1246    }
1247
1248    #[test]
1249    fn wizard_step_config_steps_are_cancellable() {
1250        assert!(WizardStep::LoadSaved.is_cancellable());
1251        assert!(WizardStep::ExportOptions.is_cancellable());
1252        assert!(WizardStep::DeployTarget.is_cancellable());
1253        assert!(WizardStep::TargetConfig.is_cancellable());
1254        assert!(WizardStep::Prerequisites.is_cancellable());
1255    }
1256
1257    #[test]
1258    fn wizard_step_execution_steps_not_cancellable() {
1259        assert!(!WizardStep::Export.is_cancellable());
1260        assert!(!WizardStep::Preview.is_cancellable());
1261        assert!(!WizardStep::Deploy.is_cancellable());
1262        assert!(!WizardStep::Done.is_cancellable());
1263    }
1264
1265    #[test]
1266    fn wizard_step_labels_are_non_empty() {
1267        for step in WizardStep::ALL {
1268            assert!(!step.label().is_empty(), "step {:?} has empty label", step);
1269        }
1270    }
1271
1272    #[test]
1273    fn wizard_step_display_numbers_are_1_indexed() {
1274        for (i, step) in WizardStep::ALL.iter().enumerate() {
1275            assert_eq!(step.display_number(), i + 1);
1276        }
1277    }
1278
1279    // ── WizardConfig defaults ──────────────────────────────────────
1280
1281    #[test]
1282    fn wizard_config_default_includes_closed_and_history() {
1283        let config = WizardConfig::default();
1284        assert!(config.include_closed);
1285        assert!(config.include_history);
1286    }
1287
1288    #[test]
1289    fn wizard_config_default_has_no_title() {
1290        let config = WizardConfig::default();
1291        assert!(config.title.is_none());
1292    }
1293
1294    #[test]
1295    fn wizard_config_default_has_no_deploy_target() {
1296        let config = WizardConfig::default();
1297        assert!(config.deploy_target.is_none());
1298    }
1299
1300    // ── WizardConfig validation ────────────────────────────────────
1301
1302    #[test]
1303    fn validate_for_export_requires_output_path() {
1304        let config = WizardConfig::default();
1305        assert!(config.validate_for_export().is_err());
1306    }
1307
1308    #[test]
1309    fn validate_for_export_passes_with_output_path() {
1310        let mut config = WizardConfig::default();
1311        config.output_path = Some(PathBuf::from("./pages"));
1312        assert!(config.validate_for_export().is_ok());
1313    }
1314
1315    #[test]
1316    fn validate_for_export_rejects_whitespace_only_output_path() {
1317        let mut config = WizardConfig::default();
1318        config.output_path = Some(PathBuf::from("   "));
1319        assert!(config.validate_for_export().is_err());
1320    }
1321
1322    #[test]
1323    fn validate_for_deploy_requires_deploy_target() {
1324        let mut config = WizardConfig::default();
1325        config.output_path = Some(PathBuf::from("./pages"));
1326        assert!(config.validate_for_deploy().is_err());
1327    }
1328
1329    #[test]
1330    fn validate_for_deploy_local_needs_only_output_path() {
1331        let mut config = WizardConfig::default();
1332        config.output_path = Some(PathBuf::from("./pages"));
1333        config.deploy_target = Some(DeployTarget::Local);
1334        assert!(config.validate_for_deploy().is_ok());
1335    }
1336
1337    #[test]
1338    fn validate_for_deploy_rejects_whitespace_only_output_path() {
1339        let mut config = WizardConfig::default();
1340        config.output_path = Some(PathBuf::from("   "));
1341        config.deploy_target = Some(DeployTarget::Local);
1342        assert!(config.validate_for_deploy().is_err());
1343    }
1344
1345    #[test]
1346    fn validate_for_deploy_github_requires_repo_name() {
1347        let mut config = WizardConfig::default();
1348        config.output_path = Some(PathBuf::from("./pages"));
1349        config.deploy_target = Some(DeployTarget::Github);
1350        assert!(config.validate_for_deploy().is_err());
1351
1352        config.github_repo = Some("owner/repo".into());
1353        assert!(config.validate_for_deploy().is_ok());
1354    }
1355
1356    #[test]
1357    fn validate_for_deploy_github_rejects_empty_repo() {
1358        let mut config = WizardConfig::default();
1359        config.output_path = Some(PathBuf::from("./pages"));
1360        config.deploy_target = Some(DeployTarget::Github);
1361        config.github_repo = Some(String::new());
1362        assert!(config.validate_for_deploy().is_err());
1363    }
1364
1365    #[test]
1366    fn validate_for_deploy_github_rejects_whitespace_only_repo() {
1367        let mut config = WizardConfig::default();
1368        config.output_path = Some(PathBuf::from("./pages"));
1369        config.deploy_target = Some(DeployTarget::Github);
1370        config.github_repo = Some("   ".into());
1371        assert!(config.validate_for_deploy().is_err());
1372    }
1373
1374    #[test]
1375    fn validate_for_deploy_github_rejects_repo_without_owner() {
1376        let mut config = WizardConfig::default();
1377        config.output_path = Some(PathBuf::from("./pages"));
1378        config.deploy_target = Some(DeployTarget::Github);
1379        config.github_repo = Some("repo-only".into());
1380        assert!(config.validate_for_deploy().is_err());
1381    }
1382
1383    #[test]
1384    fn validate_for_deploy_github_rejects_repo_with_extra_segments() {
1385        let mut config = WizardConfig::default();
1386        config.output_path = Some(PathBuf::from("./pages"));
1387        config.deploy_target = Some(DeployTarget::Github);
1388        config.github_repo = Some("owner/repo/extra".into());
1389        assert!(config.validate_for_deploy().is_err());
1390    }
1391
1392    #[test]
1393    fn validate_for_deploy_github_rejects_repo_with_whitespace_in_segment() {
1394        let mut config = WizardConfig::default();
1395        config.output_path = Some(PathBuf::from("./pages"));
1396        config.deploy_target = Some(DeployTarget::Github);
1397        config.github_repo = Some("owner name/repo".into());
1398        assert!(config.validate_for_deploy().is_err());
1399    }
1400
1401    #[test]
1402    fn validate_for_deploy_cloudflare_requires_project_name() {
1403        let mut config = WizardConfig::default();
1404        config.output_path = Some(PathBuf::from("./pages"));
1405        config.deploy_target = Some(DeployTarget::Cloudflare);
1406        assert!(config.validate_for_deploy().is_err());
1407
1408        config.cloudflare_project = Some("my-project".into());
1409        assert!(config.validate_for_deploy().is_ok());
1410    }
1411
1412    #[test]
1413    fn validate_for_deploy_cloudflare_rejects_whitespace_only_project_name() {
1414        let mut config = WizardConfig::default();
1415        config.output_path = Some(PathBuf::from("./pages"));
1416        config.deploy_target = Some(DeployTarget::Cloudflare);
1417        config.cloudflare_project = Some("   ".into());
1418        assert!(config.validate_for_deploy().is_err());
1419    }
1420
1421    #[test]
1422    fn clear_target_config_resets_all_target_fields() {
1423        let mut config = WizardConfig::default();
1424        config.github_repo = Some("owner/repo".into());
1425        config.github_private = true;
1426        config.github_description = Some("desc".into());
1427        config.cloudflare_project = Some("proj".into());
1428        config.cloudflare_branch = Some("main".into());
1429
1430        config.clear_target_config();
1431        assert!(config.github_repo.is_none());
1432        assert!(!config.github_private);
1433        assert!(config.github_description.is_none());
1434        assert!(config.cloudflare_project.is_none());
1435        assert!(config.cloudflare_branch.is_none());
1436    }
1437
1438    // ── Config persistence ─────────────────────────────────────────
1439
1440    #[test]
1441    fn config_serde_roundtrip() {
1442        let mut config = WizardConfig::default();
1443        config.title = Some("My Dashboard".into());
1444        config.deploy_target = Some(DeployTarget::Github);
1445        config.github_repo = Some("user/pages".into());
1446        config.output_path = Some(PathBuf::from("./out"));
1447
1448        let json = serde_json::to_string_pretty(&config).unwrap();
1449        let back: WizardConfig = serde_json::from_str(&json).unwrap();
1450        assert_eq!(back.title.as_deref(), Some("My Dashboard"));
1451        assert_eq!(back.deploy_target, Some(DeployTarget::Github));
1452        assert_eq!(back.github_repo.as_deref(), Some("user/pages"));
1453    }
1454
1455    #[test]
1456    fn config_deserialize_with_missing_fields_uses_defaults() {
1457        let json = r#"{"title": "Minimal"}"#;
1458        let config: WizardConfig = serde_json::from_str(json).unwrap();
1459        assert!(config.include_closed);
1460        assert!(config.include_history);
1461        assert!(config.deploy_target.is_none());
1462    }
1463
1464    #[test]
1465    fn save_and_load_config_file_roundtrip() {
1466        let tmp = tempdir().unwrap();
1467        let path = tmp.path().join("bvr/pages-wizard.json");
1468
1469        let mut config = WizardConfig::default();
1470        config.title = Some("Test".into());
1471        config.deploy_target = Some(DeployTarget::Local);
1472        config.output_path = Some(PathBuf::from("./pages"));
1473
1474        save_wizard_config_to(&config, &path).unwrap();
1475        let loaded = load_wizard_config_from(&path).unwrap().unwrap();
1476        assert_eq!(loaded.title.as_deref(), Some("Test"));
1477        assert_eq!(loaded.deploy_target, Some(DeployTarget::Local));
1478    }
1479
1480    #[test]
1481    fn load_config_from_nonexistent_returns_none() {
1482        let tmp = tempdir().unwrap();
1483        let path = tmp.path().join("does_not_exist.json");
1484        let result = load_wizard_config_from(&path).unwrap();
1485        assert!(result.is_none());
1486    }
1487
1488    #[test]
1489    fn load_config_from_invalid_json_returns_error() {
1490        let tmp = tempdir().unwrap();
1491        let path = tmp.path().join("bad.json");
1492        fs::write(&path, "not json").unwrap();
1493        assert!(load_wizard_config_from(&path).is_err());
1494    }
1495
1496    // ── Wizard state machine ───────────────────────────────────────
1497
1498    #[test]
1499    fn wizard_starts_at_load_saved() {
1500        let w = Wizard::new(None);
1501        assert_eq!(w.step, WizardStep::LoadSaved);
1502        assert!(!w.is_update);
1503    }
1504
1505    #[test]
1506    fn wizard_with_saved_config_starts_at_prerequisites() {
1507        let w = Wizard::with_saved_config(WizardConfig::default(), None);
1508        assert_eq!(w.step, WizardStep::Prerequisites);
1509        assert!(w.is_update);
1510    }
1511
1512    #[test]
1513    fn wizard_advance_walks_through_all_steps() {
1514        let mut w = Wizard::new(None);
1515        let mut visited = vec![w.step];
1516        while let Some(next) = w.advance() {
1517            visited.push(next);
1518        }
1519        assert_eq!(visited.len(), WizardStep::total());
1520        assert!(w.is_done());
1521    }
1522
1523    #[test]
1524    fn wizard_go_back_from_deploy_target_to_export_options() {
1525        let mut w = Wizard::new(None);
1526        w.step = WizardStep::DeployTarget;
1527        let prev = w.go_back();
1528        assert_eq!(prev, Some(WizardStep::ExportOptions));
1529        assert_eq!(w.step, WizardStep::ExportOptions);
1530    }
1531
1532    #[test]
1533    fn wizard_go_back_from_first_step_stays() {
1534        let mut w = Wizard::new(None);
1535        let prev = w.go_back();
1536        assert_eq!(prev, None);
1537        assert_eq!(w.step, WizardStep::LoadSaved);
1538    }
1539
1540    #[test]
1541    fn wizard_cancel_from_config_step_returns_cancelled() {
1542        let mut w = Wizard::new(None);
1543        w.step = WizardStep::ExportOptions;
1544        assert!(w.can_cancel());
1545        let transition = w.apply_result(StepResult::Cancel);
1546        assert_eq!(transition, WizardTransition::Cancelled);
1547    }
1548
1549    #[test]
1550    fn wizard_next_from_done_returns_finished() {
1551        let mut w = Wizard::new(None);
1552        w.step = WizardStep::Done;
1553        let transition = w.apply_result(StepResult::Next);
1554        assert_eq!(transition, WizardTransition::Finished);
1555    }
1556
1557    #[test]
1558    fn wizard_back_from_first_stays_on_current() {
1559        let mut w = Wizard::new(None);
1560        let transition = w.apply_result(StepResult::Back);
1561        assert_eq!(transition, WizardTransition::StayOnCurrent);
1562    }
1563
1564    #[test]
1565    fn wizard_next_advances_to_next_step() {
1566        let mut w = Wizard::new(None);
1567        let transition = w.apply_result(StepResult::Next);
1568        assert_eq!(
1569            transition,
1570            WizardTransition::GoTo(WizardStep::ExportOptions)
1571        );
1572        assert_eq!(w.step, WizardStep::ExportOptions);
1573    }
1574
1575    #[test]
1576    fn wizard_full_forward_journey() {
1577        let mut w = Wizard::new(None);
1578        let mut steps = vec![];
1579        loop {
1580            steps.push(w.step);
1581            match w.apply_result(StepResult::Next) {
1582                WizardTransition::GoTo(_) => {}
1583                WizardTransition::Finished => break,
1584                other => panic!("unexpected transition: {other:?}"),
1585            }
1586        }
1587        assert_eq!(steps.len(), WizardStep::total());
1588    }
1589
1590    #[test]
1591    fn wizard_back_and_forward_cycle() {
1592        let mut w = Wizard::new(None);
1593        // Go to DeployTarget
1594        w.apply_result(StepResult::Next); // LoadSaved -> ExportOptions
1595        w.apply_result(StepResult::Next); // ExportOptions -> DeployTarget
1596        assert_eq!(w.step, WizardStep::DeployTarget);
1597
1598        // Go back
1599        w.apply_result(StepResult::Back); // DeployTarget -> ExportOptions
1600        assert_eq!(w.step, WizardStep::ExportOptions);
1601
1602        // Go forward again
1603        w.apply_result(StepResult::Next); // ExportOptions -> DeployTarget
1604        assert_eq!(w.step, WizardStep::DeployTarget);
1605    }
1606
1607    #[test]
1608    fn wizard_beads_path_stored() {
1609        let w = Wizard::new(Some(PathBuf::from("/test/beads")));
1610        assert_eq!(w.beads_path(), Some(Path::new("/test/beads")));
1611    }
1612
1613    #[test]
1614    fn wizard_beads_path_none() {
1615        let w = Wizard::new(None);
1616        assert!(w.beads_path().is_none());
1617    }
1618
1619    // ── Prerequisite checking ──────────────────────────────────────
1620
1621    #[test]
1622    fn prereq_local_always_passes() {
1623        let result = check_prerequisites(DeployTarget::Local);
1624        assert!(result.passed);
1625        assert!(result.missing_tools.is_empty());
1626    }
1627
1628    #[test]
1629    fn prereq_result_has_correct_target() {
1630        for target in DeployTarget::ALL {
1631            let result = check_prerequisites(target);
1632            assert_eq!(result.target, target);
1633        }
1634    }
1635
1636    // ── Interactive wizard tests ───────────────────────────────────
1637
1638    /// Helper to run wizard with canned input and capture output.
1639    fn run_wizard_with_input(
1640        input: &str,
1641    ) -> (
1642        String,
1643        std::result::Result<Option<WizardConfig>, crate::BvrError>,
1644    ) {
1645        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
1646        let mut output = Vec::new();
1647        let export_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1648        let ec = export_called.clone();
1649        let result = run_wizard_interactive(
1650            &mut reader,
1651            &mut output,
1652            None,
1653            None, // No saved config in tests
1654            move |_config| {
1655                ec.store(true, std::sync::atomic::Ordering::SeqCst);
1656                Ok(())
1657            },
1658            |_path| Ok(()),
1659        );
1660        let text = String::from_utf8_lossy(&output).to_string();
1661        (text, result)
1662    }
1663
1664    // Input flow for no-saved-config wizard:
1665    // ExportOptions: include_closed(Y/n), include_history(Y/n), title, subtitle
1666    // DeployTarget: choice(1-3)
1667    // TargetConfig: target-specific fields + output_path
1668    // Prerequisites: auto
1669    // Export: auto
1670    // Preview: Y/n
1671    // Deploy: auto
1672    // Done: auto
1673
1674    #[test]
1675    fn wizard_interactive_local_flow_completes() {
1676        // Accept default closed+history, no title/subtitle, local(3), output path, preview yes
1677        let input = "y\ny\n\n\n3\n./test-out\ny\n";
1678        let (output, result) = run_wizard_with_input(input);
1679        assert!(
1680            result.is_ok(),
1681            "wizard should succeed, got: {result:?}\noutput: {output}"
1682        );
1683        let config = result.unwrap();
1684        assert!(config.is_some(), "wizard should return config");
1685        let config = config.unwrap();
1686        assert_eq!(config.deploy_target, Some(DeployTarget::Local));
1687        assert!(output.contains("Pages wizard complete"));
1688    }
1689
1690    #[test]
1691    fn wizard_interactive_github_flow_collects_repo() {
1692        // Accept defaults, GitHub(1), repo name, not private, no description, output path, preview
1693        let input = "y\ny\n\n\n1\nuser/my-pages\nn\n\n./gh-out\ny\n";
1694        let (output, result) = run_wizard_with_input(input);
1695        assert!(result.is_ok(), "output: {output}");
1696        let config = result.unwrap().unwrap();
1697        assert_eq!(config.deploy_target, Some(DeployTarget::Github));
1698        assert_eq!(config.github_repo.as_deref(), Some("user/my-pages"));
1699        assert!(!config.github_private);
1700    }
1701
1702    #[test]
1703    fn wizard_interactive_cloudflare_flow_collects_project() {
1704        // Accept defaults, Cloudflare(2), project name, branch, output path, preview
1705        let input = "y\ny\n\n\n2\nmy-cf-project\nmain\n./cf-out\ny\n";
1706        let (output, result) = run_wizard_with_input(input);
1707        assert!(result.is_ok(), "output: {output}");
1708        let config = result.unwrap().unwrap();
1709        assert_eq!(config.deploy_target, Some(DeployTarget::Cloudflare));
1710        assert_eq!(config.cloudflare_project.as_deref(), Some("my-cf-project"));
1711    }
1712
1713    #[test]
1714    fn wizard_interactive_shows_step_numbers() {
1715        let input = "y\ny\n\n\n3\n./out\ny\n";
1716        let (output, _) = run_wizard_with_input(input);
1717        assert!(
1718            output.contains("Step 2/9"),
1719            "expected step numbering: {output}"
1720        );
1721    }
1722
1723    #[test]
1724    fn wizard_interactive_skip_preview() {
1725        let input = "y\ny\n\n\n3\n./out\nn\n";
1726        let (output, result) = run_wizard_with_input(input);
1727        assert!(result.is_ok());
1728        assert!(
1729            output.contains("Skipping preview"),
1730            "expected skip msg: {output}"
1731        );
1732    }
1733
1734    #[test]
1735    fn wizard_interactive_default_output_path() {
1736        // Leave output path empty to get default ./bv-pages
1737        let input = "y\ny\n\n\n3\n\ny\n";
1738        let (_, result) = run_wizard_with_input(input);
1739        let config = result.unwrap().unwrap();
1740        assert_eq!(config.output_path, Some(PathBuf::from("./bv-pages")));
1741    }
1742
1743    #[test]
1744    fn wizard_interactive_custom_title() {
1745        // include_closed=y, include_history=y, title="My Dashboard", subtitle=(empty)
1746        let input = "y\ny\nMy Dashboard\n\n3\n./out\ny\n";
1747        let (_, result) = run_wizard_with_input(input);
1748        let config = result.unwrap().unwrap();
1749        assert_eq!(config.title.as_deref(), Some("My Dashboard"));
1750    }
1751
1752    #[test]
1753    fn wizard_interactive_shows_deploy_instructions_github() {
1754        let input = "y\ny\n\n\n1\nowner/repo\nn\n\n./out\ny\n";
1755        let (output, _) = run_wizard_with_input(input);
1756        assert!(
1757            output.contains("gh repo create"),
1758            "expected gh instructions: {output}"
1759        );
1760    }
1761
1762    #[test]
1763    fn wizard_interactive_shows_deploy_instructions_cloudflare() {
1764        let input = "y\ny\n\n\n2\nmy-proj\n\n./out\ny\n";
1765        let (output, _) = run_wizard_with_input(input);
1766        assert!(
1767            output.contains("wrangler pages deploy"),
1768            "expected wrangler instructions: {output}"
1769        );
1770    }
1771
1772    #[test]
1773    fn wizard_interactive_shows_banner() {
1774        let input = "y\ny\n\n\n3\n./out\ny\n";
1775        let (output, _) = run_wizard_with_input(input);
1776        assert!(
1777            output.contains("bvr pages wizard"),
1778            "expected banner: {output}"
1779        );
1780    }
1781
1782    #[test]
1783    fn wizard_interactive_shows_config_preview() {
1784        let input = "y\ny\n\n\n3\n./out\ny\n";
1785        let (output, _) = run_wizard_with_input(input);
1786        assert!(
1787            output.contains("Configuration summary"),
1788            "expected config preview: {output}"
1789        );
1790        assert!(
1791            output.contains("Output:"),
1792            "expected output path in preview: {output}"
1793        );
1794    }
1795
1796    #[test]
1797    fn wizard_interactive_shows_automation_boundaries() {
1798        let input = "y\ny\n\n\n3\n./out\ny\n";
1799        let (output, _) = run_wizard_with_input(input);
1800        assert!(
1801            output.contains("[auto]"),
1802            "expected [auto] marker: {output}"
1803        );
1804        assert!(
1805            output.contains("[manual]"),
1806            "expected [manual] marker: {output}"
1807        );
1808    }
1809
1810    #[test]
1811    fn wizard_interactive_preview_shows_closed_history_flags() {
1812        let input = "y\nn\n\n\n3\n./out\ny\n";
1813        let (output, _) = run_wizard_with_input(input);
1814        assert!(
1815            output.contains("Closed:    yes"),
1816            "expected closed=yes: {output}"
1817        );
1818        assert!(
1819            output.contains("History:   no"),
1820            "expected history=no: {output}"
1821        );
1822    }
1823
1824    #[test]
1825    fn wizard_transcript_records_steps() {
1826        let transcript = {
1827            let input = "y\ny\n\n\n3\n./out\ny\n";
1828            let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
1829            let mut output = Vec::new();
1830            // We can't access the transcript from run_wizard_interactive directly,
1831            // so we test the transcript type in isolation.
1832            let _ = run_wizard_interactive(
1833                &mut reader,
1834                &mut output,
1835                None,
1836                None,
1837                |_| Ok(()),
1838                |_| Ok(()),
1839            );
1840            // Transcript is internal to the wizard; test the type directly.
1841            let mut t = WizardTranscript::new();
1842            t.record(WizardStep::ExportOptions, "begin");
1843            t.record(WizardStep::Export, "export succeeded");
1844            t.record(WizardStep::Done, "complete");
1845            t
1846        };
1847        assert_eq!(transcript.entries().len(), 3);
1848        assert_eq!(transcript.entries()[0].step, WizardStep::ExportOptions);
1849        let summary = transcript.summary();
1850        assert!(summary.contains("wizard transcript:"));
1851        assert!(summary.contains("ExportOptions: begin"));
1852        assert!(summary.contains("Export: export succeeded"));
1853    }
1854
1855    // ── Wizard edge-case and failure-path tests ──────────────────
1856
1857    #[test]
1858    fn wizard_interactive_invalid_deploy_choice_reprompts() {
1859        // '9' is invalid, then '3' is Local
1860        let input = "y\ny\n\n\n9\n3\n./out\ny\n";
1861        let (output, result) = run_wizard_with_input(input);
1862        assert!(result.is_ok(), "output: {output}");
1863        assert!(
1864            output.contains("Invalid choice"),
1865            "expected reprompt: {output}"
1866        );
1867        let config = result.unwrap().unwrap();
1868        assert_eq!(config.deploy_target, Some(DeployTarget::Local));
1869    }
1870
1871    #[test]
1872    fn wizard_interactive_back_from_deploy_returns_to_export_options() {
1873        // Back from deploy target, then re-enter with Local(3)
1874        let input = "y\ny\n\n\nb\ny\ny\n\n\n3\n./out\ny\n";
1875        let (output, result) = run_wizard_with_input(input);
1876        assert!(result.is_ok(), "output: {output}");
1877        // ExportOptions prompt appears twice (original + after back)
1878        let count = output.matches("Include closed issues?").count();
1879        assert!(
1880            count >= 2,
1881            "expected ExportOptions prompt twice: {count} in: {output}"
1882        );
1883    }
1884
1885    #[test]
1886    fn wizard_interactive_empty_required_field_goes_back() {
1887        // For GitHub: empty repo name should go back to DeployTarget
1888        // Then pick Local(3) instead
1889        let input = "y\ny\n\n\n1\n\n3\n./out\ny\n";
1890        let (output, result) = run_wizard_with_input(input);
1891        assert!(result.is_ok(), "output: {output}");
1892        let config = result.unwrap().unwrap();
1893        assert_eq!(config.deploy_target, Some(DeployTarget::Local));
1894    }
1895
1896    #[test]
1897    fn wizard_interactive_saved_config_fast_path() {
1898        let saved = WizardConfig {
1899            deploy_target: Some(DeployTarget::Local),
1900            output_path: Some(PathBuf::from("./saved-out")),
1901            include_closed: true,
1902            include_history: false,
1903            ..WizardConfig::default()
1904        };
1905        // "y" = use saved, then prereqs pass, export auto, preview=n
1906        let input = "y\nn\n";
1907        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
1908        let mut output = Vec::new();
1909        let result = run_wizard_interactive(
1910            &mut reader,
1911            &mut output,
1912            None,
1913            Some(saved),
1914            |_| Ok(()),
1915            |_| Ok(()),
1916        );
1917        assert!(result.is_ok());
1918        let config = result.unwrap().unwrap();
1919        assert_eq!(config.output_path, Some(PathBuf::from("./saved-out")));
1920        assert!(!config.include_history);
1921    }
1922
1923    #[test]
1924    fn wizard_interactive_saved_github_config_missing_repo_reprompts_target_settings() {
1925        let saved = WizardConfig {
1926            deploy_target: Some(DeployTarget::Github),
1927            output_path: Some(PathBuf::from("./saved-out")),
1928            include_closed: true,
1929            include_history: true,
1930            ..WizardConfig::default()
1931        };
1932        let input = "y\nowner/repo\nn\n\n./saved-out\ny\n";
1933        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
1934        let mut output = Vec::new();
1935        let result = run_wizard_interactive(
1936            &mut reader,
1937            &mut output,
1938            None,
1939            Some(saved),
1940            |_| Ok(()),
1941            |_| Ok(()),
1942        );
1943        assert!(
1944            result.is_ok(),
1945            "output: {}",
1946            String::from_utf8_lossy(&output)
1947        );
1948        let config = result.unwrap().unwrap();
1949        assert_eq!(config.deploy_target, Some(DeployTarget::Github));
1950        assert_eq!(config.github_repo.as_deref(), Some("owner/repo"));
1951        let text = String::from_utf8_lossy(&output);
1952        assert!(
1953            text.contains("Config validation failed"),
1954            "expected validation failure before repair: {text}"
1955        );
1956    }
1957
1958    #[test]
1959    fn wizard_interactive_saved_cloudflare_config_missing_project_reprompts_target_settings() {
1960        let saved = WizardConfig {
1961            deploy_target: Some(DeployTarget::Cloudflare),
1962            output_path: Some(PathBuf::from("./saved-out")),
1963            include_closed: true,
1964            include_history: true,
1965            ..WizardConfig::default()
1966        };
1967        let input = "y\nmy-pages\nproduction\n./saved-out\ny\n";
1968        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
1969        let mut output = Vec::new();
1970        let result = run_wizard_interactive(
1971            &mut reader,
1972            &mut output,
1973            None,
1974            Some(saved),
1975            |_| Ok(()),
1976            |_| Ok(()),
1977        );
1978        assert!(
1979            result.is_ok(),
1980            "output: {}",
1981            String::from_utf8_lossy(&output)
1982        );
1983        let config = result.unwrap().unwrap();
1984        assert_eq!(config.deploy_target, Some(DeployTarget::Cloudflare));
1985        assert_eq!(config.cloudflare_project.as_deref(), Some("my-pages"));
1986        let text = String::from_utf8_lossy(&output);
1987        assert!(
1988            text.contains("Config validation failed"),
1989            "expected validation failure before repair: {text}"
1990        );
1991    }
1992
1993    #[test]
1994    fn wizard_interactive_saved_local_config_missing_output_reprompts_export_options() {
1995        let saved = WizardConfig {
1996            deploy_target: Some(DeployTarget::Local),
1997            include_closed: true,
1998            include_history: false,
1999            ..WizardConfig::default()
2000        };
2001        let input = "y\n\nn\n\n\n3\n./saved-out\nn\n";
2002        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
2003        let mut output = Vec::new();
2004        let result = run_wizard_interactive(
2005            &mut reader,
2006            &mut output,
2007            None,
2008            Some(saved),
2009            |_| Ok(()),
2010            |_| Ok(()),
2011        );
2012        assert!(
2013            result.is_ok(),
2014            "output: {}",
2015            String::from_utf8_lossy(&output)
2016        );
2017        let config = result.unwrap().unwrap();
2018        assert_eq!(config.deploy_target, Some(DeployTarget::Local));
2019        assert_eq!(config.output_path, Some(PathBuf::from("./saved-out")));
2020        let text = String::from_utf8_lossy(&output);
2021        assert!(
2022            text.contains("Config validation failed"),
2023            "expected validation failure before repair: {text}"
2024        );
2025        assert!(
2026            text.contains("Step 2/9: Export options"),
2027            "expected repair to return to export options: {text}"
2028        );
2029    }
2030
2031    #[test]
2032    fn wizard_interactive_saved_local_config_empty_output_reprompts_export_options() {
2033        let saved = WizardConfig {
2034            deploy_target: Some(DeployTarget::Local),
2035            output_path: Some(PathBuf::new()),
2036            include_closed: true,
2037            include_history: false,
2038            ..WizardConfig::default()
2039        };
2040        let input = "y\n\nn\n\n\n3\n./saved-out\nn\n";
2041        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
2042        let mut output = Vec::new();
2043        let result = run_wizard_interactive(
2044            &mut reader,
2045            &mut output,
2046            None,
2047            Some(saved),
2048            |_| Ok(()),
2049            |_| Ok(()),
2050        );
2051        assert!(
2052            result.is_ok(),
2053            "output: {}",
2054            String::from_utf8_lossy(&output)
2055        );
2056        let config = result.unwrap().unwrap();
2057        assert_eq!(config.deploy_target, Some(DeployTarget::Local));
2058        assert_eq!(config.output_path, Some(PathBuf::from("./saved-out")));
2059        let text = String::from_utf8_lossy(&output);
2060        assert!(
2061            text.contains("Config validation failed"),
2062            "expected validation failure before repair: {text}"
2063        );
2064        assert!(
2065            text.contains("Step 2/9: Export options"),
2066            "expected repair to return to export options: {text}"
2067        );
2068    }
2069
2070    #[test]
2071    fn wizard_interactive_decline_saved_config_starts_fresh() {
2072        let saved = WizardConfig {
2073            deploy_target: Some(DeployTarget::Github),
2074            github_repo: Some("old/repo".to_string()),
2075            output_path: Some(PathBuf::from("./old")),
2076            ..WizardConfig::default()
2077        };
2078        // "n" = don't use saved, then fill fresh: local(3), output, preview
2079        let input = "n\ny\ny\n\n\n3\n./fresh-out\ny\n";
2080        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
2081        let mut output = Vec::new();
2082        let result = run_wizard_interactive(
2083            &mut reader,
2084            &mut output,
2085            None,
2086            Some(saved),
2087            |_| Ok(()),
2088            |_| Ok(()),
2089        );
2090        assert!(result.is_ok());
2091        let config = result.unwrap().unwrap();
2092        assert_eq!(config.deploy_target, Some(DeployTarget::Local));
2093        assert_eq!(config.output_path, Some(PathBuf::from("./fresh-out")));
2094        assert!(config.github_repo.is_none());
2095    }
2096
2097    #[test]
2098    fn wizard_interactive_export_failure_returns_error() {
2099        let input = "y\ny\n\n\n3\n./out\ny\n";
2100        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
2101        let mut output = Vec::new();
2102        let result = run_wizard_interactive(
2103            &mut reader,
2104            &mut output,
2105            None,
2106            None,
2107            |_| Err(crate::BvrError::InvalidArgument("export broke".into())),
2108            |_| Ok(()),
2109        );
2110        assert!(result.is_err());
2111        let text = String::from_utf8_lossy(&output).to_string();
2112        assert!(
2113            text.contains("Export failed"),
2114            "expected failure message: {text}"
2115        );
2116        assert!(
2117            text.contains("debug transcript"),
2118            "expected transcript on failure: {text}"
2119        );
2120    }
2121
2122    #[test]
2123    fn wizard_interactive_preview_error_does_not_abort() {
2124        let input = "y\ny\n\n\n3\n./out\ny\n";
2125        let mut reader = std::io::Cursor::new(input.as_bytes().to_vec());
2126        let mut output = Vec::new();
2127        let result = run_wizard_interactive(
2128            &mut reader,
2129            &mut output,
2130            None,
2131            None,
2132            |_| Ok(()),
2133            |_| Err(crate::BvrError::InvalidArgument("preview broke".into())),
2134        );
2135        assert!(result.is_ok(), "preview error should not abort wizard");
2136        let text = String::from_utf8_lossy(&output).to_string();
2137        assert!(
2138            text.contains("Preview error"),
2139            "expected preview error msg: {text}"
2140        );
2141        assert!(
2142            text.contains("Pages wizard complete"),
2143            "wizard should still complete: {text}"
2144        );
2145    }
2146
2147    #[test]
2148    fn wizard_interactive_github_private_repo_and_description() {
2149        let input = "y\ny\n\n\n1\norg/private-pages\ny\nMy project dashboard\n./gh-out\ny\n";
2150        let (output, result) = run_wizard_with_input(input);
2151        let config = result.unwrap().unwrap();
2152        assert!(config.github_private);
2153        assert_eq!(
2154            config.github_description.as_deref(),
2155            Some("My project dashboard")
2156        );
2157        assert!(
2158            output.contains("--private"),
2159            "expected private flag in deploy instructions: {output}"
2160        );
2161        assert!(
2162            output.contains("--description 'My project dashboard'"),
2163            "expected description in deploy instructions: {output}"
2164        );
2165        assert!(
2166            output.contains("gh-pages branch"),
2167            "expected bundle publish guidance: {output}"
2168        );
2169    }
2170
2171    #[test]
2172    fn wizard_interactive_quotes_github_deploy_command_arguments() {
2173        // Repo name must be valid owner/repo format (no spaces); quoting is
2174        // tested via the description and output path which may contain spaces.
2175        let input = "y\ny\n\n\n1\norg/pages-repo\nn\nProject dashboard's home\n./out dir\ny\n";
2176        let (output, result) = run_wizard_with_input(input);
2177        assert!(result.is_ok(), "output: {output}");
2178        assert!(
2179            output.contains("gh repo create 'org/pages-repo' --public"),
2180            "expected quoted repo in deploy instructions: {output}"
2181        );
2182        assert!(
2183            output.contains("--description 'Project dashboard'\"'\"'s home'"),
2184            "expected quoted description in deploy instructions: {output}"
2185        );
2186        assert!(
2187            output.contains("Then publish './out dir' to your gh-pages branch"),
2188            "expected quoted bundle path in publish guidance: {output}"
2189        );
2190    }
2191
2192    #[test]
2193    fn wizard_interactive_quotes_cloudflare_deploy_command_arguments() {
2194        let input = "y\ny\n\n\n2\nteam dashboard\nrelease branch\n./cf out\ny\n";
2195        let (output, result) = run_wizard_with_input(input);
2196        assert!(result.is_ok(), "output: {output}");
2197        assert!(
2198            output.contains(
2199                "wrangler pages deploy './cf out' --project-name='team dashboard' --branch='release branch'"
2200            ),
2201            "expected quoted cloudflare command args: {output}"
2202        );
2203    }
2204
2205    #[test]
2206    fn wizard_validate_for_export_rejects_missing_output_path() {
2207        let config = WizardConfig {
2208            deploy_target: Some(DeployTarget::Local),
2209            output_path: None,
2210            ..WizardConfig::default()
2211        };
2212        assert!(config.validate_for_export().is_err());
2213    }
2214
2215    #[test]
2216    fn wizard_validate_for_export_rejects_empty_output_path() {
2217        let config = WizardConfig {
2218            deploy_target: Some(DeployTarget::Local),
2219            output_path: Some(PathBuf::new()),
2220            ..WizardConfig::default()
2221        };
2222        assert!(config.validate_for_export().is_err());
2223    }
2224
2225    #[test]
2226    fn wizard_validate_for_deploy_rejects_missing_target() {
2227        let config = WizardConfig {
2228            deploy_target: None,
2229            output_path: Some(PathBuf::from("./out")),
2230            ..WizardConfig::default()
2231        };
2232        assert!(config.validate_for_deploy().is_err());
2233    }
2234
2235    #[test]
2236    fn wizard_clear_target_config_on_target_change() {
2237        let mut config = WizardConfig {
2238            deploy_target: Some(DeployTarget::Github),
2239            github_repo: Some("old/repo".into()),
2240            output_path: Some(PathBuf::from("./out")),
2241            ..WizardConfig::default()
2242        };
2243        config.clear_target_config();
2244        assert!(config.github_repo.is_none());
2245        // output_path preserved
2246        assert!(config.output_path.is_some());
2247    }
2248
2249    #[test]
2250    fn wizard_config_roundtrip_with_all_fields() {
2251        let config = WizardConfig {
2252            include_closed: false,
2253            include_history: false,
2254            title: Some("Test".into()),
2255            subtitle: Some("Sub".into()),
2256            deploy_target: Some(DeployTarget::Cloudflare),
2257            cloudflare_project: Some("my-proj".into()),
2258            cloudflare_branch: Some("staging".into()),
2259            output_path: Some(PathBuf::from("/tmp/bundle")),
2260            ..WizardConfig::default()
2261        };
2262        let json = serde_json::to_string(&config).unwrap();
2263        let back: WizardConfig = serde_json::from_str(&json).unwrap();
2264        assert_eq!(back.title, config.title);
2265        assert_eq!(back.cloudflare_project, config.cloudflare_project);
2266        assert_eq!(back.cloudflare_branch, config.cloudflare_branch);
2267        assert!(!back.include_closed);
2268        assert!(!back.include_history);
2269    }
2270}