1use 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#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
61pub enum WizardStep {
62 LoadSaved = 0,
64 ExportOptions = 1,
66 DeployTarget = 2,
68 TargetConfig = 3,
70 Prerequisites = 4,
72 Export = 5,
74 Preview = 6,
76 Deploy = 7,
78 Done = 8,
80}
81
82impl WizardStep {
83 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 pub fn next(self) -> Option<Self> {
98 let idx = self as usize;
99 Self::ALL.get(idx + 1).copied()
100 }
101
102 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 Self::Export | Self::Preview | Self::Deploy | Self::Done => None,
113 }
114 }
115
116 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 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 pub const fn display_number(self) -> usize {
145 (self as usize) + 1
146 }
147
148 pub const fn total() -> usize {
150 Self::ALL.len()
151 }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct WizardConfig {
159 #[serde(default = "default_true")]
161 pub include_closed: bool,
162 #[serde(default = "default_true")]
164 pub include_history: bool,
165 #[serde(default)]
167 pub title: Option<String>,
168 #[serde(default)]
170 pub subtitle: Option<String>,
171 #[serde(default)]
173 pub deploy_target: Option<DeployTarget>,
174 #[serde(default)]
176 pub output_path: Option<PathBuf>,
177
178 #[serde(default)]
181 pub github_repo: Option<String>,
182 #[serde(default)]
184 pub github_private: bool,
185 #[serde(default)]
187 pub github_description: Option<String>,
188
189 #[serde(default)]
192 pub cloudflare_project: Option<String>,
193 #[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 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 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 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
307fn config_dir() -> Option<PathBuf> {
311 dirs_path().map(|d| d.join("bvr"))
312}
313
314fn 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
323pub fn wizard_config_path() -> Option<PathBuf> {
325 config_dir().map(|d| d.join(WIZARD_CONFIG_FILENAME))
326}
327
328pub 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
344pub 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
370pub 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
383pub 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#[derive(Debug, Clone)]
399pub struct PrereqResult {
400 pub target: DeployTarget,
401 pub missing_tools: Vec<String>,
402 pub passed: bool,
403}
404
405pub 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#[derive(Debug, Clone)]
442pub struct TranscriptEntry {
443 pub step: WizardStep,
444 pub action: String,
445 pub elapsed_ms: u64,
446}
447
448#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum StepResult {
497 Next,
499 Back,
501 Cancel,
503}
504
505pub 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 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 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 pub fn beads_path(&self) -> Option<&Path> {
539 self.beads_path.as_deref()
540 }
541
542 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 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 pub fn can_cancel(&self) -> bool {
564 self.step.is_cancellable()
565 }
566
567 pub fn is_done(&self) -> bool {
569 self.step == WizardStep::Done
570 }
571
572 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#[derive(Debug, Clone, PartialEq, Eq)]
596pub enum WizardTransition {
597 GoTo(WizardStep),
599 StayOnCurrent,
601 Finished,
603 Cancelled,
605}
606
607fn 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
673pub 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 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 wizard.step == WizardStep::LoadSaved {
724 wizard.advance();
725 }
726
727 loop {
728 match wizard.step {
729 WizardStep::LoadSaved => {
730 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 }
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 }
836 }
837
838 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, }
886 }
887 }
888
889 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 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 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#[cfg(test)]
1144mod tests {
1145 use super::*;
1146 use tempfile::tempdir;
1147
1148 #[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 #[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 #[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 #[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 #[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 #[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 w.apply_result(StepResult::Next); w.apply_result(StepResult::Next); assert_eq!(w.step, WizardStep::DeployTarget);
1597
1598 w.apply_result(StepResult::Back); assert_eq!(w.step, WizardStep::ExportOptions);
1601
1602 w.apply_result(StepResult::Next); 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 #[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 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, 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 #[test]
1675 fn wizard_interactive_local_flow_completes() {
1676 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 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 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 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 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 let _ = run_wizard_interactive(
1833 &mut reader,
1834 &mut output,
1835 None,
1836 None,
1837 |_| Ok(()),
1838 |_| Ok(()),
1839 );
1840 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 #[test]
1858 fn wizard_interactive_invalid_deploy_choice_reprompts() {
1859 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 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 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 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 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 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 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 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}