Skip to main content

coding_agent_search/pages/
wizard.rs

1use anyhow::{Context, Result, bail};
2use console::{Term, style};
3use dialoguer::{Confirm, Input, MultiSelect, Password, Select, theme::ColorfulTheme};
4use indicatif::{ProgressBar, ProgressStyle};
5use std::io::Write;
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::sync::atomic::AtomicBool;
9use std::time::Duration;
10
11use crate::pages::bundle::{BundleBuilder, BundleConfig};
12use crate::pages::confirmation::{
13    ConfirmationConfig, ConfirmationFlow, ConfirmationStep, PasswordStrengthAction, StepValidation,
14    UNENCRYPTED_ACK_PHRASE, unencrypted_warning_lines, validate_unencrypted_ack,
15};
16use crate::pages::deploy_cloudflare::{CloudflareConfig, CloudflareDeployer};
17use crate::pages::deploy_github::GitHubDeployer;
18use crate::pages::docs::{DocConfig, DocumentationGenerator};
19use crate::pages::encrypt::EncryptionEngine;
20use crate::pages::export::{ExportEngine, ExportFilter, PathMode};
21use crate::pages::password::{PasswordStrength, format_strength_inline, validate_password};
22use crate::pages::secret_scan::{
23    SecretScanConfig, SecretScanFilters, print_human_report, wizard_secret_scan,
24};
25use crate::pages::size::{BundleVerifier, SizeEstimate, SizeLimitResult};
26use crate::pages::summary::{
27    ExclusionSet, PrePublishSummary, SummaryFilters, SummaryGenerator, format_size,
28};
29use crate::storage::sqlite::FrankenStorage;
30use frankensqlite::Connection;
31
32/// Deployment target for the export
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DeployTarget {
35    Local,
36    GitHubPages,
37    CloudflarePages,
38}
39
40impl std::fmt::Display for DeployTarget {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            DeployTarget::Local => write!(f, "Local export only"),
44            DeployTarget::GitHubPages => write!(f, "GitHub Pages"),
45            DeployTarget::CloudflarePages => write!(f, "Cloudflare Pages"),
46        }
47    }
48}
49
50/// Wizard state tracking all configuration
51#[derive(Clone)]
52pub struct WizardState {
53    // Content selection
54    pub agents: Vec<String>,
55    pub time_range: Option<String>,
56    pub workspaces: Option<Vec<PathBuf>>,
57
58    // Security configuration
59    pub password: Option<String>,
60    pub recovery_secret: Option<Vec<u8>>,
61    pub generate_recovery: bool,
62    pub generate_qr: bool,
63
64    // Site configuration
65    pub title: String,
66    pub description: String,
67    pub hide_metadata: bool,
68
69    // Deployment
70    pub target: DeployTarget,
71    pub output_dir: PathBuf,
72    pub repo_name: Option<String>,
73
74    // Database path
75    pub db_path: PathBuf,
76
77    // Pre-publish summary and exclusions
78    pub exclusions: ExclusionSet,
79    pub last_summary: Option<PrePublishSummary>,
80
81    // Secret scan results
82    pub secret_scan_has_findings: bool,
83    pub secret_scan_has_critical: bool,
84    pub secret_scan_count: usize,
85
86    // Password entropy
87    pub password_entropy_bits: f64,
88
89    // Unencrypted export mode (DANGEROUS)
90    pub no_encryption: bool,
91    pub unencrypted_confirmed: bool,
92
93    // Cloudflare Pages deployment
94    pub cloudflare_branch: Option<String>,
95    pub cloudflare_account_id: Option<String>,
96    pub cloudflare_api_token: Option<String>,
97
98    // Final output location (set after export)
99    pub final_site_dir: Option<PathBuf>,
100}
101
102impl std::fmt::Debug for WizardState {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("WizardState")
105            .field("agents", &self.agents)
106            .field("time_range", &self.time_range)
107            .field("workspaces", &self.workspaces)
108            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
109            .field(
110                "recovery_secret",
111                &self.recovery_secret.as_ref().map(|_| "[REDACTED]"),
112            )
113            .field("generate_recovery", &self.generate_recovery)
114            .field("generate_qr", &self.generate_qr)
115            .field("title", &self.title)
116            .field("description", &self.description)
117            .field("hide_metadata", &self.hide_metadata)
118            .field("target", &self.target)
119            .field("output_dir", &self.output_dir)
120            .field("repo_name", &self.repo_name)
121            .field("db_path", &self.db_path)
122            .field("exclusions", &self.exclusions)
123            .field("last_summary", &self.last_summary)
124            .field("secret_scan_has_findings", &self.secret_scan_has_findings)
125            .field("secret_scan_has_critical", &self.secret_scan_has_critical)
126            .field("secret_scan_count", &self.secret_scan_count)
127            .field("password_entropy_bits", &self.password_entropy_bits)
128            .field("no_encryption", &self.no_encryption)
129            .field("unencrypted_confirmed", &self.unencrypted_confirmed)
130            .field("cloudflare_branch", &self.cloudflare_branch)
131            .field("cloudflare_account_id", &self.cloudflare_account_id)
132            .field(
133                "cloudflare_api_token",
134                &self.cloudflare_api_token.as_ref().map(|_| "[REDACTED]"),
135            )
136            .field("final_site_dir", &self.final_site_dir)
137            .finish()
138    }
139}
140
141impl Default for WizardState {
142    fn default() -> Self {
143        let db_path = crate::default_db_path();
144
145        Self {
146            agents: Vec::new(),
147            time_range: None,
148            workspaces: None,
149            password: None,
150            recovery_secret: None,
151            generate_recovery: true,
152            generate_qr: false,
153            title: "cass Archive".to_string(),
154            description: "Encrypted archive of AI coding agent conversations".to_string(),
155            hide_metadata: false,
156            target: DeployTarget::Local,
157            output_dir: PathBuf::from("cass-export"),
158            repo_name: None,
159            db_path,
160            exclusions: ExclusionSet::new(),
161            last_summary: None,
162            secret_scan_has_findings: false,
163            secret_scan_has_critical: false,
164            secret_scan_count: 0,
165            password_entropy_bits: 0.0,
166            no_encryption: false,
167            unencrypted_confirmed: false,
168            cloudflare_branch: None,
169            cloudflare_account_id: None,
170            cloudflare_api_token: None,
171            final_site_dir: None,
172        }
173    }
174}
175
176fn truncate_sample_title(title: &str) -> String {
177    if title.len() > 30 {
178        format!("{}...", &title[..title.floor_char_boundary(27)])
179    } else {
180        title.to_string()
181    }
182}
183
184pub struct PagesWizard {
185    state: WizardState,
186    no_encryption_mode: bool,
187}
188
189impl Default for PagesWizard {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195impl PagesWizard {
196    pub fn new() -> Self {
197        Self {
198            state: WizardState::default(),
199            no_encryption_mode: false,
200        }
201    }
202
203    /// Override the database path used for agent/workspace discovery and export.
204    pub fn set_db_path(&mut self, db_path: PathBuf) {
205        self.state.db_path = db_path;
206    }
207
208    /// Set whether to skip encryption (DANGEROUS - requires explicit confirmation).
209    pub fn set_no_encryption(&mut self, no_encryption: bool) {
210        self.no_encryption_mode = no_encryption;
211        self.state.no_encryption = no_encryption;
212    }
213
214    /// Set the deployment target.
215    pub fn set_deploy_target(&mut self, target: DeployTarget) {
216        self.state.target = target;
217    }
218
219    /// Set the repository/project name for deployment.
220    pub fn set_repo_name(&mut self, name: String) {
221        self.state.repo_name = Some(name);
222    }
223
224    /// Set the Cloudflare Pages branch.
225    pub fn set_cloudflare_branch(&mut self, branch: String) {
226        self.state.cloudflare_branch = Some(branch);
227    }
228
229    /// Set the Cloudflare account ID.
230    pub fn set_cloudflare_account_id(&mut self, account_id: String) {
231        self.state.cloudflare_account_id = Some(account_id);
232    }
233
234    /// Set the Cloudflare API token.
235    pub fn set_cloudflare_api_token(&mut self, api_token: String) {
236        self.state.cloudflare_api_token = Some(api_token);
237    }
238
239    pub fn run(&mut self) -> Result<()> {
240        let mut term = Term::stdout();
241        let theme = ColorfulTheme::default();
242
243        term.clear_screen()?;
244        self.print_header(&mut term)?;
245
246        if self.no_encryption_mode && !self.step_unencrypted_warning(&mut term, &theme)? {
247            writeln!(term, "{}", style("Export cancelled.").yellow())?;
248            return Ok(());
249        }
250
251        // Step 1: Content Selection
252        self.step_content_selection(&mut term, &theme)?;
253
254        // Step 2: Secret Scan
255        self.step_secret_scan(&mut term, &theme)?;
256
257        // Step 3: Security Configuration
258        if !self.no_encryption_mode {
259            self.step_security_config(&mut term, &theme)?;
260        } else {
261            self.state.generate_recovery = false;
262            self.state.generate_qr = false;
263        }
264
265        // Step 4: Site Configuration
266        self.step_site_config(&mut term, &theme)?;
267
268        // Step 5: Deployment Target
269        self.step_deployment_target(&mut term, &theme)?;
270
271        // Step 6: Pre-Publish Summary
272        if !self.step_summary(&mut term, &theme)? {
273            writeln!(term, "{}", style("Export cancelled.").yellow())?;
274            return Ok(());
275        }
276
277        // Step 7: Safety Confirmation
278        if !self.no_encryption_mode && !self.step_confirmation(&mut term, &theme)? {
279            writeln!(term, "{}", style("Export cancelled.").yellow())?;
280            return Ok(());
281        }
282
283        // Step 8: Export Progress
284        self.step_export(&mut term)?;
285
286        // Step 9: Deploy (if not local)
287        self.step_deploy(&mut term)?;
288
289        Ok(())
290    }
291
292    /// Step for unencrypted export warning and confirmation.
293    fn step_unencrypted_warning(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<bool> {
294        writeln!(term)?;
295        writeln!(term, "{}", style("⚠️  SECURITY WARNING").red().bold())?;
296        writeln!(term, "{}", style("━".repeat(60)).red())?;
297        writeln!(term)?;
298
299        for line in unencrypted_warning_lines() {
300            if line.is_empty() {
301                writeln!(term)?;
302            } else {
303                writeln!(term, "  {}", line)?;
304            }
305        }
306
307        writeln!(term)?;
308        writeln!(term, "{}", style("━".repeat(60)).red())?;
309        writeln!(term)?;
310        writeln!(term, "To proceed with unencrypted export, type exactly:")?;
311        writeln!(term)?;
312        writeln!(term, "  {}", style(UNENCRYPTED_ACK_PHRASE).cyan().bold())?;
313        writeln!(term)?;
314
315        loop {
316            let input: String = Input::with_theme(theme)
317                .with_prompt("Your input (or \"cancel\" to abort)")
318                .interact_text()?;
319
320            if input.trim().to_lowercase() == "cancel" {
321                return Ok(false);
322            }
323
324            match validate_unencrypted_ack(&input) {
325                StepValidation::Passed => {
326                    // Additional y/N confirmation
327                    writeln!(term)?;
328                    let confirmed = Confirm::with_theme(theme)
329                        .with_prompt(
330                            "Are you ABSOLUTELY SURE you want to export WITHOUT encryption?",
331                        )
332                        .default(false)
333                        .interact()?;
334
335                    if !confirmed {
336                        writeln!(term)?;
337                        writeln!(
338                            term,
339                            "  {}",
340                            style("Good choice. Export cancelled.").green()
341                        )?;
342                        writeln!(
343                            term,
344                            "  Remove --no-encryption to export with encryption (recommended)."
345                        )?;
346                        return Ok(false);
347                    }
348
349                    self.state.unencrypted_confirmed = true;
350                    writeln!(term)?;
351                    writeln!(
352                        term,
353                        "  {} Unencrypted export acknowledged",
354                        style("⚠").yellow()
355                    )?;
356                    writeln!(
357                        term,
358                        "  {}",
359                        style("Proceeding without encryption...").dim()
360                    )?;
361                    return Ok(true);
362                }
363                StepValidation::Failed(msg) => {
364                    writeln!(term, "  {} {}", style("✗").red(), msg)?;
365                }
366            }
367        }
368    }
369
370    fn print_header(&self, term: &mut Term) -> Result<()> {
371        writeln!(
372            term,
373            "{}",
374            style("🔐 cass Pages Export Wizard").bold().cyan()
375        )?;
376        writeln!(
377            term,
378            "Create an encrypted, searchable web archive of your AI coding agent conversations."
379        )?;
380        writeln!(term)?;
381        Ok(())
382    }
383
384    fn step_content_selection(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
385        writeln!(term, "\n{}", style("Step 1 of 9: Content Selection").bold())?;
386        writeln!(term, "{}", style("─".repeat(40)).dim())?;
387
388        // Load agents dynamically from database
389        let storage = FrankenStorage::open_readonly(&self.state.db_path)
390            .context("Failed to open database. Run 'cass index' first.")?;
391        let db_agents = storage.list_agents()?;
392        let db_workspaces = storage.list_workspaces()?;
393        drop(storage);
394
395        if db_agents.is_empty() {
396            writeln!(
397                term,
398                "{}",
399                style("⚠ No agents found in database. Run 'cass index' first.").red()
400            )?;
401            bail!("No agents found in database");
402        }
403
404        // Build agent display list with conversation counts
405        let agent_items: Vec<String> = db_agents
406            .iter()
407            .map(|a| format!("{} ({})", a.name, a.slug))
408            .collect();
409
410        let selected_agents = MultiSelect::with_theme(theme)
411            .with_prompt("Which agents would you like to include?")
412            .items(&agent_items)
413            .defaults(&vec![true; agent_items.len()])
414            .interact()?;
415
416        self.state.agents = selected_agents
417            .iter()
418            .map(|&i| db_agents[i].slug.clone())
419            .collect();
420
421        if self.state.agents.is_empty() {
422            bail!("No agents selected. Export cancelled.");
423        }
424
425        writeln!(
426            term,
427            "  {} {} agents selected",
428            style("✓").green(),
429            self.state.agents.len()
430        )?;
431
432        // Workspace selection (optional)
433        self.state.workspaces = None;
434        if !db_workspaces.is_empty() {
435            let include_all = Confirm::with_theme(theme)
436                .with_prompt("Include all workspaces?")
437                .default(true)
438                .interact()?;
439
440            if !include_all {
441                let workspace_items: Vec<String> = db_workspaces
442                    .iter()
443                    .map(|w| {
444                        w.display_name
445                            .clone()
446                            .unwrap_or_else(|| w.path.to_string_lossy().to_string())
447                    })
448                    .collect();
449
450                let selected_ws = MultiSelect::with_theme(theme)
451                    .with_prompt("Select workspaces to include:")
452                    .items(&workspace_items)
453                    .interact()?;
454
455                self.state.workspaces = Some(
456                    selected_ws
457                        .iter()
458                        .map(|&i| db_workspaces[i].path.clone())
459                        .collect(),
460                );
461                writeln!(
462                    term,
463                    "  {} {} workspaces selected",
464                    style("✓").green(),
465                    selected_ws.len()
466                )?;
467                if selected_ws.is_empty() {
468                    writeln!(
469                        term,
470                        "  {} No workspaces selected. The export will contain no conversations.",
471                        style("ℹ").yellow()
472                    )?;
473                }
474            }
475        }
476
477        // Time Range
478        let time_options = vec![
479            "All time",
480            "Last 7 days",
481            "Last 30 days",
482            "Last 90 days",
483            "Last year",
484        ];
485        let time_selection = Select::with_theme(theme)
486            .with_prompt("Time range")
487            .default(0)
488            .items(&time_options)
489            .interact()?;
490
491        self.state.time_range = match time_selection {
492            1 => Some("-7d".to_string()),
493            2 => Some("-30d".to_string()),
494            3 => Some("-90d".to_string()),
495            4 => Some("-365d".to_string()),
496            _ => None,
497        };
498
499        writeln!(
500            term,
501            "  {} Time range: {}",
502            style("✓").green(),
503            time_options[time_selection]
504        )?;
505
506        Ok(())
507    }
508
509    fn step_secret_scan(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
510        writeln!(term, "\n{}", style("Step 2 of 9: Secret Scan").bold())?;
511        writeln!(term, "{}", style("─".repeat(40)).dim())?;
512
513        let since_ts = self
514            .state
515            .time_range
516            .as_deref()
517            .and_then(crate::ui::time_parser::parse_time_input);
518
519        let filters = SecretScanFilters {
520            agents: if self.state.agents.is_empty() {
521                None
522            } else {
523                Some(self.state.agents.clone())
524            },
525            workspaces: self.state.workspaces.clone(),
526            since_ts,
527            until_ts: None,
528        };
529
530        let config = SecretScanConfig::from_inputs(&[], &[])?;
531        if !config.allowlist_raw.is_empty() || !config.denylist_raw.is_empty() {
532            writeln!(
533                term,
534                "  {} Allowlist patterns: {} | Denylist patterns: {}",
535                style("ℹ").blue(),
536                config.allowlist_raw.len(),
537                config.denylist_raw.len()
538            )?;
539        }
540
541        let report = wizard_secret_scan(&self.state.db_path, &filters, &config)?;
542        print_human_report(term, &report, 3)?;
543
544        // Save secret scan results to state for confirmation flow
545        self.state.secret_scan_has_findings = report.summary.total > 0;
546        self.state.secret_scan_has_critical = report.summary.has_critical;
547        self.state.secret_scan_count = report.summary.total;
548
549        if report.summary.has_critical {
550            writeln!(
551                term,
552                "  {} Critical secrets detected. Export is blocked without acknowledgement.",
553                style("✗").red()
554            )?;
555            let ack: String = Input::with_theme(theme)
556                .with_prompt("Type \"I UNDERSTAND\" to proceed")
557                .interact_text()?;
558            if ack.trim() != "I UNDERSTAND" {
559                bail!("Export cancelled due to critical secrets");
560            }
561            writeln!(term, "  {} Acknowledged", style("✓").green())?;
562        }
563
564        Ok(())
565    }
566
567    fn step_security_config(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
568        writeln!(
569            term,
570            "\n{}",
571            style("Step 3 of 9: Security Configuration").bold()
572        )?;
573        writeln!(term, "{}", style("─".repeat(40)).dim())?;
574
575        // Password
576        let password = Password::with_theme(theme)
577            .with_prompt("Archive password (min 8 characters)")
578            .with_confirmation("Confirm password", "Passwords don't match")
579            .validate_with(|input: &String| -> Result<(), &str> {
580                if input.chars().count() >= 8 {
581                    Ok(())
582                } else {
583                    Err("Password must be at least 8 characters")
584                }
585            })
586            .interact()?;
587
588        self.state.password = Some(password.clone());
589        writeln!(term, "  {} Password set", style("✓").green())?;
590
591        // Validate password using new password strength module
592        let validation = validate_password(&password);
593
594        // Calculate and save password entropy for confirmation flow
595        self.state.password_entropy_bits = validation.entropy_bits;
596
597        // Show password strength indicator with visual bar
598        writeln!(
599            term,
600            "    Password strength: {}",
601            format_strength_inline(&validation)
602        )?;
603        writeln!(term, "    Entropy: {:.0} bits", validation.entropy_bits)?;
604
605        // Show improvement suggestions if not strong
606        if validation.strength != PasswordStrength::Strong && !validation.suggestions.is_empty() {
607            writeln!(term, "    {}", style("Suggestions:").dim())?;
608            for suggestion in &validation.suggestions {
609                writeln!(
610                    term,
611                    "      {} {}",
612                    style("•").dim(),
613                    style(suggestion).dim()
614                )?;
615            }
616        }
617
618        // Recovery secret
619        self.state.generate_recovery = Confirm::with_theme(theme)
620            .with_prompt("Generate recovery secret? (recommended)")
621            .default(true)
622            .interact()?;
623
624        if self.state.generate_recovery {
625            writeln!(
626                term,
627                "  {} Recovery secret will be generated",
628                style("✓").green()
629            )?;
630        }
631
632        // QR code
633        self.state.generate_qr = Confirm::with_theme(theme)
634            .with_prompt("Generate QR code for recovery? (for mobile access)")
635            .default(false)
636            .interact()?;
637
638        if self.state.generate_qr {
639            writeln!(term, "  {} QR code will be generated", style("✓").green())?;
640        }
641
642        Ok(())
643    }
644
645    fn step_site_config(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
646        writeln!(
647            term,
648            "\n{}",
649            style("Step 4 of 9: Site Configuration").bold()
650        )?;
651        writeln!(term, "{}", style("─".repeat(40)).dim())?;
652
653        // Title
654        self.state.title = Input::with_theme(theme)
655            .with_prompt("Archive title")
656            .default(self.state.title.clone())
657            .interact_text()?;
658
659        writeln!(term, "  {} Title: {}", style("✓").green(), self.state.title)?;
660
661        // Description
662        self.state.description = Input::with_theme(theme)
663            .with_prompt("Description (shown on unlock page)")
664            .default(self.state.description.clone())
665            .interact_text()?;
666
667        writeln!(term, "  {} Description set", style("✓").green())?;
668
669        // Metadata privacy
670        self.state.hide_metadata = Confirm::with_theme(theme)
671            .with_prompt("Hide workspace paths and file names? (for privacy)")
672            .default(false)
673            .interact()?;
674
675        if self.state.hide_metadata {
676            writeln!(term, "  {} Metadata will be obfuscated", style("✓").green())?;
677        }
678
679        Ok(())
680    }
681
682    fn step_deployment_target(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<()> {
683        writeln!(term, "\n{}", style("Step 5 of 9: Deployment Target").bold())?;
684        writeln!(term, "{}", style("─".repeat(40)).dim())?;
685
686        let targets = vec![
687            "Local export only (generate files)",
688            "GitHub Pages (requires gh CLI)",
689            "Cloudflare Pages (requires wrangler CLI)",
690        ];
691
692        let target_selection = Select::with_theme(theme)
693            .with_prompt("Where would you like to deploy?")
694            .default(0)
695            .items(&targets)
696            .interact()?;
697
698        self.state.target = match target_selection {
699            1 => DeployTarget::GitHubPages,
700            2 => DeployTarget::CloudflarePages,
701            _ => DeployTarget::Local,
702        };
703
704        writeln!(
705            term,
706            "  {} Target: {}",
707            style("✓").green(),
708            self.state.target
709        )?;
710
711        // Output directory
712        self.state.output_dir = PathBuf::from(
713            Input::<String>::with_theme(theme)
714                .with_prompt("Output directory")
715                .default("cass-export".to_string())
716                .interact_text()?,
717        );
718
719        writeln!(
720            term,
721            "  {} Output: {}",
722            style("✓").green(),
723            self.state.output_dir.display()
724        )?;
725
726        // Repository name for remote deployment
727        if self.state.target != DeployTarget::Local {
728            let default_repo = format!("cass-archive-{}", chrono::Utc::now().format("%Y%m%d"));
729            let repo_name = Input::<String>::with_theme(theme)
730                .with_prompt("Repository/project name")
731                .default(default_repo)
732                .interact_text()?;
733            self.state.repo_name = Some(repo_name.clone());
734
735            writeln!(term, "  {} Repo: {}", style("✓").green(), repo_name)?;
736        }
737
738        Ok(())
739    }
740
741    fn step_summary(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<bool> {
742        writeln!(
743            term,
744            "\n{}",
745            style("Step 6 of 9: Pre-Publish Summary").bold()
746        )?;
747        writeln!(term, "{}", style("─".repeat(40)).dim())?;
748
749        // Generate comprehensive summary from database
750        writeln!(term, "\n  Generating summary...")?;
751        let summary = self.generate_prepublish_summary()?;
752        self.state.last_summary = Some(summary.clone());
753
754        // Display content overview
755        writeln!(term, "\n{}", style("📊 CONTENT OVERVIEW").bold().cyan())?;
756        writeln!(term, "{}", style("─".repeat(40)).dim())?;
757        writeln!(
758            term,
759            "  Conversations: {}",
760            style(summary.total_conversations).green()
761        )?;
762        writeln!(
763            term,
764            "  Messages:      {}",
765            style(summary.total_messages).green()
766        )?;
767        writeln!(
768            term,
769            "  Characters:    {} (~{})",
770            summary.total_characters,
771            format_size(summary.total_characters)
772        )?;
773        writeln!(
774            term,
775            "  Archive Size:  ~{} (estimated, compressed + encrypted)",
776            style(format_size(summary.estimated_size_bytes)).yellow()
777        )?;
778
779        // Display date range
780        writeln!(term, "\n{}", style("📅 DATE RANGE").bold().cyan())?;
781        writeln!(term, "{}", style("─".repeat(40)).dim())?;
782        if let (Some(earliest), Some(latest)) =
783            (&summary.earliest_timestamp, &summary.latest_timestamp)
784        {
785            let days = (*latest - *earliest).num_days();
786            writeln!(
787                term,
788                "  From: {}  To: {}  ({} days)",
789                style(earliest.format("%Y-%m-%d")).white(),
790                style(latest.format("%Y-%m-%d")).white(),
791                days
792            )?;
793
794            // Show activity histogram (simplified sparkline)
795            if !summary.date_histogram.is_empty() {
796                let bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
797
798                // Group by month for display
799                let mut months: std::collections::BTreeMap<String, usize> =
800                    std::collections::BTreeMap::new();
801                for entry in &summary.date_histogram {
802                    if entry.date.len() >= 7 {
803                        let month = &entry.date[0..7];
804                        *months.entry(month.to_string()).or_insert(0) += entry.message_count;
805                    }
806                }
807
808                if !months.is_empty() {
809                    let month_max = months.values().max().copied().unwrap_or(1).max(1);
810                    let sparkline: String = months
811                        .values()
812                        .map(|&count| {
813                            let idx = (count * 7 / month_max).min(7);
814                            bars[idx]
815                        })
816                        .collect();
817                    writeln!(term, "  Activity: {}", style(sparkline).cyan())?;
818                }
819            }
820        } else {
821            writeln!(term, "  No date information available")?;
822        }
823
824        // Display workspaces
825        writeln!(
826            term,
827            "\n{} ({})",
828            style("📁 WORKSPACES").bold().cyan(),
829            summary.workspaces.len()
830        )?;
831        writeln!(term, "{}", style("─".repeat(40)).dim())?;
832        for (_idx, ws) in summary.workspaces.iter().enumerate().take(5) {
833            let included_marker =
834                if ws.included && !self.state.exclusions.is_workspace_excluded(&ws.path) {
835                    style("✓").green()
836                } else {
837                    style("✗").red()
838                };
839            writeln!(
840                term,
841                "  {} {} ({} conversations)",
842                included_marker,
843                style(&ws.display_name).white(),
844                ws.conversation_count
845            )?;
846            if !ws.sample_titles.is_empty() {
847                let titles: Vec<_> = ws
848                    .sample_titles
849                    .iter()
850                    .take(2)
851                    .map(|t| truncate_sample_title(t))
852                    .collect();
853                writeln!(
854                    term,
855                    "      {}",
856                    style(format!("\"{}\"", titles.join("\", \""))).dim()
857                )?;
858            }
859        }
860        if summary.workspaces.len() > 5 {
861            writeln!(
862                term,
863                "  {} and {} more...",
864                style("...").dim(),
865                summary.workspaces.len() - 5
866            )?;
867        }
868
869        // Display agents
870        writeln!(term, "\n{}", style("🤖 AGENTS").bold().cyan())?;
871        writeln!(term, "{}", style("─".repeat(40)).dim())?;
872        for agent in &summary.agents {
873            writeln!(
874                term,
875                "  • {}: {} conversations ({:.0}%)",
876                style(&agent.name).white(),
877                agent.conversation_count,
878                agent.percentage
879            )?;
880        }
881
882        // Display security status
883        writeln!(term, "\n{}", style("🔒 SECURITY").bold().cyan())?;
884        writeln!(term, "{}", style("─".repeat(40)).dim())?;
885        if let Some(enc) = &summary.encryption_config {
886            writeln!(term, "  Encryption: {}", enc.algorithm)?;
887            writeln!(term, "  Key Derivation: {}", enc.key_derivation)?;
888            writeln!(term, "  Key Slots: {}", enc.key_slot_count)?;
889        } else {
890            writeln!(term, "  Encryption: AES-256-GCM")?;
891            writeln!(term, "  Key Derivation: Argon2id")?;
892        }
893
894        // Secret scan status
895        let secret_status = if summary.secret_scan.total_findings == 0 {
896            style("✓ No secrets detected".to_string()).green()
897        } else if summary.secret_scan.has_critical {
898            style(format!(
899                "⚠️  {} issues (CRITICAL)",
900                summary.secret_scan.total_findings
901            ))
902            .red()
903        } else {
904            style(format!(
905                "⚠️  {} issues found",
906                summary.secret_scan.total_findings
907            ))
908            .yellow()
909        };
910        writeln!(term, "  Secret Scan: {}", secret_status)?;
911
912        // Configuration summary
913        writeln!(term, "\n{}", style("⚙️  CONFIGURATION").bold().cyan())?;
914        writeln!(term, "{}", style("─".repeat(40)).dim())?;
915        writeln!(term, "  Title: {}", self.state.title)?;
916        writeln!(term, "  Target: {}", self.state.target)?;
917        writeln!(term, "  Output: {}", self.state.output_dir.display())?;
918        writeln!(
919            term,
920            "  Recovery Key: {}",
921            if self.state.generate_recovery {
922                "Yes"
923            } else {
924                "No"
925            }
926        )?;
927        writeln!(
928            term,
929            "  QR Code: {}",
930            if self.state.generate_qr { "Yes" } else { "No" }
931        )?;
932
933        // Exclusion summary
934        let (ws_excluded, conv_excluded, pattern_excluded) =
935            self.state.exclusions.exclusion_counts();
936        if ws_excluded > 0 || conv_excluded > 0 || pattern_excluded > 0 {
937            writeln!(term, "\n{}", style("🚫 EXCLUSIONS").bold().yellow())?;
938            writeln!(term, "{}", style("─".repeat(40)).dim())?;
939            if ws_excluded > 0 {
940                writeln!(term, "  {} workspace(s) excluded", ws_excluded)?;
941            }
942            if conv_excluded > 0 {
943                writeln!(term, "  {} conversation(s) excluded", conv_excluded)?;
944            }
945            if pattern_excluded > 0 {
946                writeln!(term, "  {} pattern(s) active", pattern_excluded)?;
947            }
948        }
949
950        writeln!(term)?;
951
952        // Options menu
953        loop {
954            let options = vec![
955                "✓ Proceed with export",
956                "📁 View/Edit workspace exclusions",
957                "✗ Cancel export",
958            ];
959
960            let selection = Select::with_theme(theme)
961                .with_prompt("What would you like to do?")
962                .items(&options)
963                .default(0)
964                .interact()?;
965
966            match selection {
967                0 => return Ok(true), // Proceed
968                1 => {
969                    // Edit workspace exclusions
970                    self.edit_workspace_exclusions(term, theme, &summary)?;
971                }
972                2 => return Ok(false), // Cancel
973                _ => unreachable!(),
974            }
975        }
976    }
977
978    /// Generate the pre-publish summary from the database.
979    fn generate_prepublish_summary(&self) -> Result<PrePublishSummary> {
980        let conn = Connection::open(self.state.db_path.to_string_lossy().as_ref())
981            .context("Failed to open database for summary generation")?;
982
983        conn.execute_batch(
984            "PRAGMA busy_timeout = 5000;
985             PRAGMA journal_mode = WAL;",
986        )
987        .context("Failed to set PRAGMAs for summary generation")?;
988
989        let since_ts = self
990            .state
991            .time_range
992            .as_deref()
993            .and_then(crate::ui::time_parser::parse_time_input);
994
995        let filters = SummaryFilters {
996            agents: if self.state.agents.is_empty() {
997                None
998            } else {
999                Some(self.state.agents.clone())
1000            },
1001            workspaces: self
1002                .state
1003                .workspaces
1004                .as_ref()
1005                .map(|ws| ws.iter().map(|p| p.to_string_lossy().to_string()).collect()),
1006            since_ts,
1007            until_ts: None,
1008        };
1009
1010        let generator = SummaryGenerator::new(&conn);
1011        let summary = generator.generate_with_exclusions(Some(&filters), &self.state.exclusions)?;
1012
1013        Ok(summary)
1014    }
1015
1016    /// Interactive workspace exclusion editing.
1017    fn edit_workspace_exclusions(
1018        &mut self,
1019        term: &mut Term,
1020        theme: &ColorfulTheme,
1021        summary: &PrePublishSummary,
1022    ) -> Result<()> {
1023        writeln!(term, "\n{}", style("Workspace Exclusions").bold())?;
1024        writeln!(term, "{}", style("─".repeat(40)).dim())?;
1025
1026        if summary.workspaces.is_empty() {
1027            writeln!(term, "  No workspaces to configure.")?;
1028            return Ok(());
1029        }
1030
1031        // Build list of workspaces with current inclusion status
1032        let items: Vec<String> = summary
1033            .workspaces
1034            .iter()
1035            .map(|ws| {
1036                format!(
1037                    "{} ({} conversations)",
1038                    ws.display_name, ws.conversation_count
1039                )
1040            })
1041            .collect();
1042
1043        // Determine which are currently selected (included)
1044        let defaults: Vec<bool> = summary
1045            .workspaces
1046            .iter()
1047            .map(|ws| !self.state.exclusions.is_workspace_excluded(&ws.path))
1048            .collect();
1049
1050        let selections = MultiSelect::with_theme(theme)
1051            .with_prompt("Select workspaces to INCLUDE (unselected will be excluded)")
1052            .items(&items)
1053            .defaults(&defaults)
1054            .interact()?;
1055
1056        // Update exclusions based on selections
1057        for (idx, ws) in summary.workspaces.iter().enumerate() {
1058            if selections.contains(&idx) {
1059                // Include this workspace (remove from exclusions)
1060                self.state.exclusions.include_workspace(&ws.path);
1061            } else {
1062                // Exclude this workspace
1063                self.state.exclusions.exclude_workspace(&ws.path);
1064            }
1065        }
1066
1067        let (ws_excluded, _, _) = self.state.exclusions.exclusion_counts();
1068        writeln!(
1069            term,
1070            "  {} {} workspace(s) now excluded",
1071            style("✓").green(),
1072            ws_excluded
1073        )?;
1074
1075        Ok(())
1076    }
1077
1078    /// Multi-step safety confirmation flow.
1079    fn step_confirmation(&mut self, term: &mut Term, theme: &ColorfulTheme) -> Result<bool> {
1080        writeln!(
1081            term,
1082            "\n{}",
1083            style("Step 7 of 9: Safety Confirmation").bold()
1084        )?;
1085        writeln!(term, "{}", style("─".repeat(40)).dim())?;
1086
1087        // Build confirmation configuration
1088        let target_domain = if self.state.target != DeployTarget::Local {
1089            self.state
1090                .repo_name
1091                .as_ref()
1092                .map(|name| match self.state.target {
1093                    DeployTarget::GitHubPages => format!("{}.github.io", name),
1094                    DeployTarget::CloudflarePages => format!("{}.pages.dev", name),
1095                    DeployTarget::Local => String::new(),
1096                })
1097        } else {
1098            None
1099        };
1100
1101        let summary = if let Some(summary) = self.state.last_summary.clone() {
1102            summary
1103        } else {
1104            let generated = self
1105                .generate_prepublish_summary()
1106                .context("Failed to generate pre-publish summary for confirmation")?;
1107            self.state.last_summary = Some(generated.clone());
1108            generated
1109        };
1110
1111        let config = ConfirmationConfig {
1112            has_secrets: self.state.secret_scan_has_findings,
1113            has_critical_secrets: self.state.secret_scan_has_critical,
1114            secret_count: self.state.secret_scan_count,
1115            target_domain,
1116            is_remote_publish: self.state.target != DeployTarget::Local,
1117            password_entropy_bits: self.state.password_entropy_bits,
1118            has_recovery_key: self.state.generate_recovery,
1119            recovery_key_phrase: None, // Will be set after generation
1120            summary,
1121        };
1122
1123        let mut flow = ConfirmationFlow::new(config);
1124
1125        // Process each confirmation step
1126        loop {
1127            match flow.current_step() {
1128                ConfirmationStep::SecretScanAcknowledgment => {
1129                    if !self.confirm_secret_ack(term, theme, &flow)? {
1130                        return Ok(false);
1131                    }
1132                    flow.complete_current_step();
1133                }
1134                ConfirmationStep::ContentReview => {
1135                    if !self.confirm_content_review(term, theme, &flow)? {
1136                        return Ok(false);
1137                    }
1138                    flow.complete_current_step();
1139                }
1140                ConfirmationStep::PublicPublishingWarning => {
1141                    if !self.confirm_public_warning(term, theme, &flow)? {
1142                        return Ok(false);
1143                    }
1144                    flow.complete_current_step();
1145                }
1146                ConfirmationStep::PasswordStrengthWarning => {
1147                    match self.confirm_password_strength(term, theme, &mut flow)? {
1148                        PasswordStrengthAction::SetStronger => {
1149                            // User wants to set a stronger password - go back
1150                            writeln!(
1151                                term,
1152                                "\n  {} Returning to security configuration...",
1153                                style("←").cyan()
1154                            )?;
1155                            return Ok(false);
1156                        }
1157                        PasswordStrengthAction::ProceedAnyway => {
1158                            flow.complete_current_step();
1159                        }
1160                        PasswordStrengthAction::Abort => {
1161                            return Ok(false);
1162                        }
1163                    }
1164                }
1165                ConfirmationStep::RecoveryKeyBackup => {
1166                    if !self.confirm_recovery_key(term, theme, &flow)? {
1167                        return Ok(false);
1168                    }
1169                    flow.complete_current_step();
1170                }
1171                ConfirmationStep::FinalConfirmation => {
1172                    if !self.confirm_final(term, theme, &mut flow)? {
1173                        return Ok(false);
1174                    }
1175                    flow.complete_current_step();
1176                    break;
1177                }
1178            }
1179        }
1180
1181        writeln!(
1182            term,
1183            "\n  {} All safety checks completed",
1184            style("✓").green()
1185        )?;
1186        Ok(true)
1187    }
1188
1189    /// Confirm acknowledgment of detected secrets.
1190    fn confirm_secret_ack(
1191        &self,
1192        term: &mut Term,
1193        theme: &ColorfulTheme,
1194        flow: &ConfirmationFlow,
1195    ) -> Result<bool> {
1196        writeln!(
1197            term,
1198            "\n  {}",
1199            style("⚠️  SECRETS DETECTED").yellow().bold()
1200        )?;
1201        writeln!(term)?;
1202        writeln!(
1203            term,
1204            "  The secret scan found {} potential sensitive data item(s).",
1205            flow.config().secret_count
1206        )?;
1207        writeln!(term)?;
1208        writeln!(
1209            term,
1210            "  Even though the export will be encrypted, publishing content"
1211        )?;
1212        writeln!(term, "  containing secrets carries additional risk:")?;
1213        writeln!(term)?;
1214        writeln!(
1215            term,
1216            "  {} If your password is weak, secrets could be exposed",
1217            style("⚠").yellow()
1218        )?;
1219        writeln!(
1220            term,
1221            "  {} Secrets may remain valid if encryption is ever compromised",
1222            style("⚠").yellow()
1223        )?;
1224        writeln!(term)?;
1225
1226        loop {
1227            let input: String = Input::with_theme(theme)
1228                .with_prompt("Type \"I understand the risks\" to proceed (or \"abort\" to cancel)")
1229                .interact_text()?;
1230
1231            if input.trim().to_lowercase() == "abort" {
1232                return Ok(false);
1233            }
1234
1235            match flow.validate_secret_ack(&input) {
1236                StepValidation::Passed => {
1237                    writeln!(term, "  {} Secrets acknowledged", style("✓").green())?;
1238                    return Ok(true);
1239                }
1240                StepValidation::Failed(msg) => {
1241                    writeln!(term, "  {} {}", style("✗").red(), msg)?;
1242                }
1243            }
1244        }
1245    }
1246
1247    /// Confirm review of content summary.
1248    fn confirm_content_review(
1249        &self,
1250        term: &mut Term,
1251        theme: &ColorfulTheme,
1252        flow: &ConfirmationFlow,
1253    ) -> Result<bool> {
1254        writeln!(term, "\n  {}", style("📋 CONTENT REVIEW").cyan().bold())?;
1255        writeln!(term)?;
1256        writeln!(term, "  You are about to export:")?;
1257        writeln!(term)?;
1258        writeln!(
1259            term,
1260            "  • {} conversations from {} workspaces",
1261            flow.config().summary.total_conversations,
1262            flow.config().summary.workspaces.len()
1263        )?;
1264        writeln!(
1265            term,
1266            "  • {} messages",
1267            flow.config().summary.total_messages
1268        )?;
1269        writeln!(
1270            term,
1271            "  • Content from: {}",
1272            flow.config()
1273                .summary
1274                .agents
1275                .iter()
1276                .map(|a| a.name.as_str())
1277                .collect::<Vec<_>>()
1278                .join(", ")
1279        )?;
1280        writeln!(term)?;
1281
1282        let confirmed = Confirm::with_theme(theme)
1283            .with_prompt("Have you reviewed the content summary?")
1284            .default(false)
1285            .interact()?;
1286
1287        if confirmed {
1288            writeln!(term, "  {} Content reviewed", style("✓").green())?;
1289        }
1290        Ok(confirmed)
1291    }
1292
1293    /// Confirm public publishing warning.
1294    fn confirm_public_warning(
1295        &self,
1296        term: &mut Term,
1297        theme: &ColorfulTheme,
1298        flow: &ConfirmationFlow,
1299    ) -> Result<bool> {
1300        let domain = flow
1301            .config()
1302            .target_domain
1303            .as_deref()
1304            .unwrap_or("your-site");
1305
1306        writeln!(
1307            term,
1308            "\n  {}",
1309            style("🌐 PUBLIC PUBLISHING WARNING").yellow().bold()
1310        )?;
1311        writeln!(term)?;
1312        writeln!(term, "  You are about to publish to:")?;
1313        writeln!(term, "    {}", style(format!("https://{}/", domain)).cyan())?;
1314        writeln!(term)?;
1315        writeln!(
1316            term,
1317            "  {} This URL will be publicly accessible on the internet",
1318            style("⚠").yellow()
1319        )?;
1320        writeln!(
1321            term,
1322            "  {} Anyone with the URL can download the encrypted archive",
1323            style("⚠").yellow()
1324        )?;
1325        writeln!(
1326            term,
1327            "  {} The security depends entirely on your password strength",
1328            style("⚠").yellow()
1329        )?;
1330        writeln!(term)?;
1331
1332        loop {
1333            let input: String = Input::with_theme(theme)
1334                .with_prompt(format!(
1335                    "Type \"publish to {}\" to confirm (or \"abort\" to cancel)",
1336                    domain
1337                ))
1338                .interact_text()?;
1339
1340            if input.trim().to_lowercase() == "abort" {
1341                return Ok(false);
1342            }
1343
1344            match flow.validate_public_warning(&input) {
1345                StepValidation::Passed => {
1346                    writeln!(term, "  {} Public URL confirmed", style("✓").green())?;
1347                    return Ok(true);
1348                }
1349                StepValidation::Failed(msg) => {
1350                    writeln!(term, "  {} {}", style("✗").red(), msg)?;
1351                }
1352            }
1353        }
1354    }
1355
1356    /// Handle password strength warning.
1357    fn confirm_password_strength(
1358        &self,
1359        term: &mut Term,
1360        theme: &ColorfulTheme,
1361        flow: &mut ConfirmationFlow,
1362    ) -> Result<PasswordStrengthAction> {
1363        writeln!(
1364            term,
1365            "\n  {}",
1366            style("🔐 PASSWORD STRENGTH WARNING").yellow().bold()
1367        )?;
1368        writeln!(term)?;
1369        writeln!(
1370            term,
1371            "  Your password has estimated entropy of {:.0} bits.",
1372            self.state.password_entropy_bits
1373        )?;
1374        writeln!(term)?;
1375        writeln!(term, "  Recommended minimum: 60 bits")?;
1376        writeln!(term)?;
1377        writeln!(
1378            term,
1379            "  A password with low entropy could potentially be cracked"
1380        )?;
1381        writeln!(
1382            term,
1383            "  by a determined attacker with sufficient resources."
1384        )?;
1385        writeln!(term)?;
1386        writeln!(term, "  For long-term security, consider:")?;
1387        writeln!(term, "  • Using a longer password (16+ characters)")?;
1388        writeln!(term, "  • Including numbers, symbols, and mixed case")?;
1389        writeln!(term, "  • Using a passphrase of 5+ random words")?;
1390        writeln!(term)?;
1391
1392        let options = vec![
1393            "[S] Set a stronger password",
1394            "[P] Proceed with current password (not recommended)",
1395            "[A] Abort export",
1396        ];
1397
1398        let selection = Select::with_theme(theme)
1399            .with_prompt("What would you like to do?")
1400            .items(&options)
1401            .default(0)
1402            .interact()?;
1403
1404        let action = match selection {
1405            0 => PasswordStrengthAction::SetStronger,
1406            1 => {
1407                writeln!(
1408                    term,
1409                    "  {} Password warning acknowledged",
1410                    style("⚠").yellow()
1411                )?;
1412                PasswordStrengthAction::ProceedAnyway
1413            }
1414            _ => PasswordStrengthAction::Abort,
1415        };
1416
1417        flow.set_password_action(action);
1418        Ok(action)
1419    }
1420
1421    /// Confirm recovery key backup.
1422    fn confirm_recovery_key(
1423        &self,
1424        term: &mut Term,
1425        theme: &ColorfulTheme,
1426        _flow: &ConfirmationFlow,
1427    ) -> Result<bool> {
1428        writeln!(
1429            term,
1430            "\n  {}",
1431            style("💾 RECOVERY KEY BACKUP").cyan().bold()
1432        )?;
1433        writeln!(term)?;
1434        writeln!(
1435            term,
1436            "  A recovery key will be generated. This is the ONLY way"
1437        )?;
1438        writeln!(term, "  to recover your data if you forget your password.")?;
1439        writeln!(term)?;
1440        writeln!(
1441            term,
1442            "  {} If you lose both your password AND the recovery key,",
1443            style("⚠").yellow()
1444        )?;
1445        writeln!(term, "     your data will be permanently inaccessible.")?;
1446        writeln!(term)?;
1447
1448        let confirmed = Confirm::with_theme(theme)
1449            .with_prompt("I understand that I must save the recovery key securely")
1450            .default(false)
1451            .interact()?;
1452
1453        if confirmed {
1454            writeln!(
1455                term,
1456                "  {} Recovery key backup confirmed",
1457                style("✓").green()
1458            )?;
1459        }
1460        Ok(confirmed)
1461    }
1462
1463    /// Final double-enter confirmation.
1464    fn confirm_final(
1465        &self,
1466        term: &mut Term,
1467        theme: &ColorfulTheme,
1468        flow: &mut ConfirmationFlow,
1469    ) -> Result<bool> {
1470        writeln!(term, "\n  {}", style("✓ FINAL CONFIRMATION").green().bold())?;
1471        writeln!(term)?;
1472        writeln!(term, "  Ready to publish:")?;
1473        writeln!(term)?;
1474
1475        // Show completed steps
1476        for (_, label) in flow.completed_steps_summary() {
1477            writeln!(term, "  {} {}", style("✓").green(), label)?;
1478        }
1479        writeln!(term)?;
1480
1481        // Show target info
1482        if self.state.target != DeployTarget::Local {
1483            if let Some(domain) = &flow.config().target_domain {
1484                writeln!(term, "  Target: https://{}/", domain)?;
1485            }
1486        } else {
1487            writeln!(
1488                term,
1489                "  Target: {} (local)",
1490                self.state.output_dir.display()
1491            )?;
1492        }
1493
1494        if let Some(summary) = &self.state.last_summary {
1495            writeln!(
1496                term,
1497                "  Size: ~{}",
1498                format_size(summary.estimated_size_bytes)
1499            )?;
1500        }
1501        writeln!(term)?;
1502
1503        writeln!(
1504            term,
1505            "  {}",
1506            style("Press Enter TWICE to confirm and begin export").dim()
1507        )?;
1508        writeln!(term)?;
1509
1510        // First Enter
1511        let _: String = Input::with_theme(theme)
1512            .with_prompt("[First confirmation - press Enter]")
1513            .allow_empty(true)
1514            .interact_text()?;
1515
1516        flow.process_final_enter();
1517        writeln!(term, "  {} First confirmation received", style("•").cyan())?;
1518
1519        // Second Enter
1520        let _: String = Input::with_theme(theme)
1521            .with_prompt("[Second confirmation - press Enter to proceed]")
1522            .allow_empty(true)
1523            .interact_text()?;
1524
1525        flow.process_final_enter();
1526        writeln!(
1527            term,
1528            "  {} Second confirmation received",
1529            style("✓").green()
1530        )?;
1531
1532        Ok(true)
1533    }
1534
1535    fn step_export(&mut self, term: &mut Term) -> Result<()> {
1536        writeln!(term, "\n{}", style("Step 8 of 9: Export Progress").bold())?;
1537        writeln!(term, "{}", style("─".repeat(40)).dim())?;
1538
1539        // Phase 0: Size estimation and limit checking
1540        writeln!(term, "\n  Estimating export size...")?;
1541
1542        let since_ts = self
1543            .state
1544            .time_range
1545            .as_deref()
1546            .and_then(crate::ui::time_parser::parse_time_input);
1547
1548        let agents: Vec<String> = self.state.agents.to_vec();
1549        let estimate = SizeEstimate::from_database(
1550            &self.state.db_path,
1551            if agents.is_empty() {
1552                None
1553            } else {
1554                Some(&agents)
1555            },
1556            since_ts,
1557            None,
1558        )?;
1559
1560        // Display estimate
1561        writeln!(term)?;
1562        for line in estimate.format_display().lines() {
1563            writeln!(term, "  {}", line)?;
1564        }
1565        writeln!(term)?;
1566
1567        // Check limits
1568        match estimate.check_limits() {
1569            SizeLimitResult::Ok => {
1570                writeln!(term, "  {} Size within limits", style("✓").green())?;
1571            }
1572            SizeLimitResult::Warning(warning) => {
1573                writeln!(term, "  {} {}", style("⚠").yellow(), warning)?;
1574                writeln!(term)?;
1575
1576                let theme = ColorfulTheme::default();
1577                if !Confirm::with_theme(&theme)
1578                    .with_prompt("Continue with export?")
1579                    .default(true)
1580                    .interact()?
1581                {
1582                    bail!("Export cancelled due to size warning");
1583                }
1584            }
1585            SizeLimitResult::ExceedsLimit(error) => {
1586                writeln!(term)?;
1587                writeln!(term, "  {} {}", style("✗").red(), error)?;
1588                writeln!(term)?;
1589                bail!("Export blocked: {}", error);
1590            }
1591        }
1592
1593        writeln!(term)?;
1594
1595        // Stage export/encryption artifacts in a temp directory so the final
1596        // bundle root only contains deployable output (site/ + private/).
1597        let staging_dir = tempfile::tempdir()?;
1598        let export_db_path = staging_dir.path().join("export.db");
1599        let encrypted_dir = staging_dir.path().join("encrypted");
1600        std::fs::create_dir_all(&encrypted_dir)?;
1601
1602        // Phase 1: Database Export with progress
1603        let pb = ProgressBar::new_spinner();
1604        let spinner_style = ProgressStyle::default_spinner()
1605            .template("{spinner:.cyan} {msg}")
1606            .context("build progress spinner style for export phase")?;
1607        pb.set_style(spinner_style);
1608        pb.enable_steady_tick(Duration::from_millis(100));
1609        pb.set_message("Filtering and exporting conversations...");
1610
1611        // Build export filter with workspaces
1612        let workspaces = self.state.workspaces.clone();
1613        let since_dt = self.state.time_range.as_deref().and_then(|s| {
1614            crate::ui::time_parser::parse_time_input(s)
1615                .and_then(chrono::DateTime::from_timestamp_millis)
1616        });
1617
1618        let filter = ExportFilter {
1619            agents: Some(self.state.agents.clone()),
1620            workspaces,
1621            since: since_dt,
1622            until: None,
1623            path_mode: if self.state.hide_metadata {
1624                PathMode::Hash
1625            } else {
1626                PathMode::Relative
1627            },
1628        };
1629
1630        let engine = ExportEngine::new(&self.state.db_path, &export_db_path, filter);
1631        let running = Arc::new(AtomicBool::new(true));
1632
1633        let stats = engine.execute(
1634            |current, total| {
1635                if total > 0 {
1636                    pb.set_message(format!("Exporting... {}/{} conversations", current, total));
1637                }
1638            },
1639            Some(running),
1640        )?;
1641
1642        pb.finish_with_message(format!(
1643            "✓ Exported {} conversations, {} messages",
1644            stats.conversations_processed, stats.messages_processed
1645        ));
1646
1647        // Phase 2: Encryption (skip if no_encryption mode)
1648        if self.no_encryption_mode {
1649            writeln!(term)?;
1650            writeln!(
1651                term,
1652                "  {} Skipping encryption (unencrypted mode)",
1653                style("⚠").yellow()
1654            )?;
1655            writeln!(
1656                term,
1657                "  {}",
1658                style("WARNING: All content will be publicly readable!").red()
1659            )?;
1660
1661            // For unencrypted mode, just copy the export.db to payload directory
1662            let payload_dir = encrypted_dir.join("payload");
1663            std::fs::create_dir_all(&payload_dir)?;
1664            let dest_db = payload_dir.join("data.db");
1665            std::fs::copy(&export_db_path, &dest_db)?;
1666
1667            // Write minimal config.json for unencrypted bundle
1668            let db_size = std::fs::metadata(&dest_db).map(|m| m.len()).unwrap_or(0);
1669            let config = unencrypted_bundle_config(db_size);
1670            let config_path = encrypted_dir.join("config.json");
1671            crate::pages::write_file_durably(
1672                &config_path,
1673                serde_json::to_string_pretty(&config)?.as_bytes(),
1674            )?;
1675        } else {
1676            let pb2 = ProgressBar::new_spinner();
1677            let spinner_style = ProgressStyle::default_spinner()
1678                .template("{spinner:.cyan} {msg}")
1679                .context("build progress spinner style for encryption phase")?;
1680            pb2.set_style(spinner_style);
1681            pb2.enable_steady_tick(Duration::from_millis(100));
1682            pb2.set_message("Encrypting archive...");
1683
1684            // Initialize encryption engine
1685            let mut enc_engine = EncryptionEngine::default();
1686
1687            // Add password slot
1688            if let Some(password) = &self.state.password {
1689                enc_engine.add_password_slot(password)?;
1690            }
1691
1692            // Generate and add recovery secret if requested
1693            if self.state.generate_recovery {
1694                let mut recovery_bytes = [0u8; 32];
1695                use rand::Rng;
1696                let mut rng = rand::rng();
1697                rng.fill_bytes(&mut recovery_bytes);
1698                enc_engine.add_recovery_slot(&recovery_bytes)?;
1699                self.state.recovery_secret = Some(recovery_bytes.to_vec());
1700            }
1701
1702            // Guard: refuse to produce an archive with zero key slots
1703            if enc_engine.key_slot_count() == 0 {
1704                bail!(
1705                    "No encryption key slots configured — archive would be permanently undecryptable"
1706                );
1707            }
1708
1709            // Encrypt the database
1710            enc_engine.encrypt_file(&export_db_path, &encrypted_dir, |_, _| {})?;
1711
1712            pb2.finish_with_message("✓ Encryption complete");
1713        }
1714
1715        // Phase 3: Build static site bundle
1716        let pb3 = ProgressBar::new_spinner();
1717        let spinner_style = ProgressStyle::default_spinner()
1718            .template("{spinner:.cyan} {msg}")
1719            .context("build progress spinner style for bundle phase")?;
1720        pb3.set_style(spinner_style);
1721        pb3.enable_steady_tick(Duration::from_millis(100));
1722        pb3.set_message("Building static site bundle...");
1723
1724        // Generate documentation
1725        let generated_docs = if let Some(ref summary) = self.state.last_summary {
1726            // Determine target URL based on deployment target
1727            // Note: GitHub Pages URL requires the username which isn't known until deployment,
1728            // so we omit the URL for that target. The actual URL will be shown after deployment.
1729            let target_url = match self.state.target {
1730                DeployTarget::GitHubPages => None, // Username unknown at this stage
1731                DeployTarget::CloudflarePages => self
1732                    .state
1733                    .repo_name
1734                    .as_ref()
1735                    .map(|name| format!("https://{}.pages.dev", name)),
1736                DeployTarget::Local => None,
1737            };
1738
1739            let doc_config = if let Some(url) = target_url {
1740                DocConfig::new().with_url(url)
1741            } else {
1742                DocConfig::new()
1743            };
1744
1745            let doc_generator = DocumentationGenerator::new(doc_config, summary.clone());
1746            doc_generator.generate_all()
1747        } else {
1748            Vec::new()
1749        };
1750
1751        // Create bundle configuration
1752        let bundle_config = BundleConfig {
1753            title: self.state.title.clone(),
1754            description: self.state.description.clone(),
1755            hide_metadata: self.state.hide_metadata,
1756            recovery_secret: self.state.recovery_secret.clone(),
1757            generate_qr: self.state.generate_qr,
1758            generated_docs,
1759        };
1760
1761        let builder = BundleBuilder::with_config(bundle_config);
1762        let bundle_result =
1763            builder.build(&encrypted_dir, &self.state.output_dir, |phase, msg| {
1764                pb3.set_message(format!("{}: {}", phase, msg));
1765            })?;
1766        self.state.final_site_dir = Some(bundle_result.site_dir.clone());
1767
1768        pb3.finish_with_message(format!(
1769            "✓ Bundle complete: {} files, fingerprint {}",
1770            bundle_result.total_files,
1771            bundle_result
1772                .fingerprint
1773                .get(..8)
1774                .unwrap_or(&bundle_result.fingerprint)
1775        ));
1776
1777        // Phase 4: Post-export verification
1778        let warnings = BundleVerifier::verify(&bundle_result.site_dir)?;
1779        if !warnings.is_empty() {
1780            writeln!(term)?;
1781            writeln!(term, "  {} Size warnings:", style("⚠").yellow())?;
1782            for warning in &warnings {
1783                writeln!(term, "    {}", warning)?;
1784            }
1785        }
1786
1787        writeln!(term)?;
1788        writeln!(
1789            term,
1790            "  {} Site directory (deploy this): {}",
1791            style("✓").green(),
1792            style(bundle_result.site_dir.display()).cyan()
1793        )?;
1794        writeln!(
1795            term,
1796            "  {} Private directory (keep secure): {}",
1797            style("✓").green(),
1798            style(bundle_result.private_dir.display()).cyan()
1799        )?;
1800        writeln!(
1801            term,
1802            "  {} Integrity fingerprint: {}",
1803            style("✓").green(),
1804            style(&bundle_result.fingerprint).cyan()
1805        )?;
1806
1807        // Display recovery secret location if generated
1808        if self.state.recovery_secret.is_some() {
1809            writeln!(term)?;
1810            writeln!(
1811                term,
1812                "  {} Recovery secret saved to: {}",
1813                style("⚠").yellow().bold(),
1814                style(
1815                    bundle_result
1816                        .private_dir
1817                        .join("recovery-secret.txt")
1818                        .display()
1819                )
1820                .cyan()
1821            )?;
1822            writeln!(
1823                term,
1824                "  {}",
1825                style("Store this file securely - it can unlock your archive if you forget the password.").dim()
1826            )?;
1827        }
1828
1829        if self.state.generate_qr {
1830            writeln!(
1831                term,
1832                "  {} QR codes saved to private directory",
1833                style("✓").green()
1834            )?;
1835        }
1836
1837        Ok(())
1838    }
1839
1840    fn deploy_site_dir(&self) -> PathBuf {
1841        self.state
1842            .final_site_dir
1843            .as_ref()
1844            .cloned()
1845            .unwrap_or_else(|| self.state.output_dir.join("site"))
1846    }
1847
1848    fn deploy_project_name(&self) -> String {
1849        self.state
1850            .repo_name
1851            .clone()
1852            .unwrap_or_else(|| "cass-archive".to_string())
1853    }
1854
1855    fn step_deploy(&self, term: &mut Term) -> Result<()> {
1856        writeln!(term, "\n{}", style("Step 9 of 9: Deployment").bold())?;
1857        writeln!(term, "{}", style("─".repeat(40)).dim())?;
1858
1859        match self.state.target {
1860            DeployTarget::Local => {
1861                let site_dir = self.deploy_site_dir();
1862                writeln!(term)?;
1863                writeln!(term, "{}", style("✓ Export complete!").green().bold())?;
1864                writeln!(term)?;
1865                writeln!(
1866                    term,
1867                    "Your archive bundle has been exported to: {}",
1868                    style(self.state.output_dir.display()).cyan()
1869                )?;
1870                writeln!(term)?;
1871                writeln!(
1872                    term,
1873                    "Deployable site directory: {}",
1874                    style(site_dir.display()).cyan()
1875                )?;
1876                writeln!(term)?;
1877                writeln!(term, "To preview locally, run:")?;
1878                writeln!(
1879                    term,
1880                    "  {}",
1881                    style(format!(
1882                        "cass pages --preview {} --no-open",
1883                        site_dir.display()
1884                    ))
1885                    .dim()
1886                )?;
1887                writeln!(term)?;
1888                writeln!(
1889                    term,
1890                    "Then open {} in your browser.",
1891                    style("http://localhost:8080").cyan()
1892                )?;
1893            }
1894            DeployTarget::GitHubPages => {
1895                writeln!(term, "  {} GitHub Pages deployment...", style("→").cyan())?;
1896                let site_dir = self.deploy_site_dir();
1897
1898                // Determine repository name
1899                let repo_name = self.deploy_project_name();
1900
1901                // Configure the deployer
1902                let deployer = GitHubDeployer::new(repo_name.clone());
1903
1904                // Check prerequisites first
1905                match deployer.check_prerequisites() {
1906                    Ok(prereqs) if prereqs.is_ready() => {
1907                        // Deploy with progress output
1908                        match deployer.deploy(&site_dir, |_phase, msg| {
1909                            let _ = writeln!(term, "    {} {}", style("•").dim(), msg);
1910                        }) {
1911                            Ok(result) => {
1912                                writeln!(term)?;
1913                                writeln!(
1914                                    term,
1915                                    "  {} Deployed to GitHub Pages!",
1916                                    style("✓").green().bold()
1917                                )?;
1918                                writeln!(term)?;
1919                                writeln!(term, "  Repository: {}", style(&result.repo_url).cyan())?;
1920                                writeln!(
1921                                    term,
1922                                    "  Your archive is available at: {}",
1923                                    style(&result.pages_url).cyan().bold()
1924                                )?;
1925                            }
1926                            Err(e) => {
1927                                writeln!(term)?;
1928                                writeln!(term, "  {} Deployment failed: {}", style("✗").red(), e)?;
1929                                writeln!(term)?;
1930                                writeln!(
1931                                    term,
1932                                    "To deploy manually, push the {} directory to a gh-pages branch.",
1933                                    site_dir.display()
1934                                )?;
1935                            }
1936                        }
1937                    }
1938                    Ok(prereqs) => {
1939                        let missing = prereqs.missing();
1940                        writeln!(term)?;
1941                        writeln!(term, "  {} Prerequisites not met:", style("⚠").yellow())?;
1942                        for item in &missing {
1943                            writeln!(term, "    {} {}", style("•").dim(), item)?;
1944                        }
1945                        writeln!(term)?;
1946                        writeln!(
1947                            term,
1948                            "Please install/configure the missing tools and try again."
1949                        )?;
1950                        writeln!(
1951                            term,
1952                            "To deploy manually after fixing prerequisites, push the {} directory to a gh-pages branch.",
1953                            site_dir.display()
1954                        )?;
1955                    }
1956                    Err(e) => {
1957                        writeln!(term)?;
1958                        writeln!(
1959                            term,
1960                            "  {} Could not check prerequisites: {}",
1961                            style("⚠").yellow(),
1962                            e
1963                        )?;
1964                        writeln!(term)?;
1965                        writeln!(
1966                            term,
1967                            "To deploy manually, push the {} directory to a gh-pages branch.",
1968                            site_dir.display()
1969                        )?;
1970                    }
1971                }
1972            }
1973            DeployTarget::CloudflarePages => {
1974                writeln!(
1975                    term,
1976                    "  {} Cloudflare Pages deployment...",
1977                    style("→").cyan()
1978                )?;
1979                let site_dir = self.deploy_site_dir();
1980
1981                // Determine project name from repo_name or use default
1982                let project_name = self.deploy_project_name();
1983
1984                // Configure the deployer
1985                let deployer = CloudflareDeployer::new(CloudflareConfig {
1986                    project_name: project_name.clone(),
1987                    custom_domain: None,
1988                    create_if_missing: true,
1989                    branch: "main".to_string(),
1990                    account_id: dotenvy::var("CLOUDFLARE_ACCOUNT_ID").ok(),
1991                    api_token: dotenvy::var("CLOUDFLARE_API_TOKEN").ok(),
1992                });
1993
1994                // Check prerequisites first
1995                match deployer.check_prerequisites() {
1996                    Ok(prereqs) if prereqs.is_ready() => {
1997                        // Deploy with progress output
1998                        match deployer.deploy(&site_dir, |_phase, msg| {
1999                            let _ = writeln!(term, "    {} {}", style("•").dim(), msg);
2000                        }) {
2001                            Ok(result) => {
2002                                writeln!(term)?;
2003                                writeln!(
2004                                    term,
2005                                    "  {} Deployed to Cloudflare Pages!",
2006                                    style("✓").green().bold()
2007                                )?;
2008                                writeln!(term)?;
2009                                writeln!(
2010                                    term,
2011                                    "  Your archive is available at: {}",
2012                                    style(&result.pages_url).cyan().bold()
2013                                )?;
2014                                if let Some(ref domain) = result.custom_domain {
2015                                    writeln!(term, "  Custom domain: {}", style(domain).cyan())?;
2016                                }
2017                            }
2018                            Err(e) => {
2019                                writeln!(term)?;
2020                                writeln!(term, "  {} Deployment failed: {}", style("✗").red(), e)?;
2021                                writeln!(term)?;
2022                                writeln!(
2023                                    term,
2024                                    "To deploy manually, use wrangler to deploy the {} directory:",
2025                                    site_dir.display()
2026                                )?;
2027                                writeln!(
2028                                    term,
2029                                    "  {}",
2030                                    style(format!(
2031                                        "wrangler pages deploy {} --project-name {}",
2032                                        site_dir.display(),
2033                                        project_name
2034                                    ))
2035                                    .dim()
2036                                )?;
2037                            }
2038                        }
2039                    }
2040                    Ok(prereqs) => {
2041                        let missing = prereqs.missing();
2042                        writeln!(term)?;
2043                        writeln!(term, "  {} Prerequisites not met:", style("⚠").yellow())?;
2044                        for item in &missing {
2045                            writeln!(term, "    {} {}", style("•").dim(), item)?;
2046                        }
2047                        writeln!(term)?;
2048                        writeln!(term, "To deploy manually after meeting prerequisites:")?;
2049                        writeln!(
2050                            term,
2051                            "  {}",
2052                            style(format!(
2053                                "wrangler pages deploy {} --project-name {}",
2054                                site_dir.display(),
2055                                project_name
2056                            ))
2057                            .dim()
2058                        )?;
2059                    }
2060                    Err(e) => {
2061                        writeln!(term)?;
2062                        writeln!(
2063                            term,
2064                            "  {} Could not check prerequisites: {}",
2065                            style("⚠").yellow(),
2066                            e
2067                        )?;
2068                        writeln!(term)?;
2069                        writeln!(
2070                            term,
2071                            "To deploy manually, use wrangler to deploy the {} directory:",
2072                            site_dir.display()
2073                        )?;
2074                        writeln!(
2075                            term,
2076                            "  {}",
2077                            style(format!(
2078                                "wrangler pages deploy {} --project-name {}",
2079                                site_dir.display(),
2080                                project_name
2081                            ))
2082                            .dim()
2083                        )?;
2084                    }
2085                }
2086            }
2087        }
2088
2089        writeln!(term)?;
2090        Ok(())
2091    }
2092}
2093
2094fn unencrypted_bundle_config(db_size: u64) -> serde_json::Value {
2095    serde_json::json!({
2096        "encrypted": false,
2097        "version": "1.0.0",
2098        "payload": {
2099            "path": "payload/data.db",
2100            "format": "sqlite",
2101            "size_bytes": db_size
2102        },
2103        "warning": "UNENCRYPTED - All content is publicly readable"
2104    })
2105}
2106
2107#[cfg(test)]
2108mod tests {
2109    use super::*;
2110
2111    // =========================
2112    // DeployTarget Tests
2113    // =========================
2114
2115    #[test]
2116    fn deploy_target_display() {
2117        assert_eq!(DeployTarget::Local.to_string(), "Local export only");
2118        assert_eq!(DeployTarget::GitHubPages.to_string(), "GitHub Pages");
2119        assert_eq!(
2120            DeployTarget::CloudflarePages.to_string(),
2121            "Cloudflare Pages"
2122        );
2123    }
2124
2125    #[test]
2126    fn deploy_target_equality() {
2127        assert_eq!(DeployTarget::Local, DeployTarget::Local);
2128        assert_eq!(DeployTarget::GitHubPages, DeployTarget::GitHubPages);
2129        assert_eq!(DeployTarget::CloudflarePages, DeployTarget::CloudflarePages);
2130        assert_ne!(DeployTarget::Local, DeployTarget::GitHubPages);
2131        assert_ne!(DeployTarget::GitHubPages, DeployTarget::CloudflarePages);
2132    }
2133
2134    #[test]
2135    fn deploy_target_clone() {
2136        let target = DeployTarget::CloudflarePages;
2137        let cloned = target;
2138        assert_eq!(target, cloned);
2139    }
2140
2141    #[test]
2142    fn unencrypted_bundle_config_shape() {
2143        let config = unencrypted_bundle_config(1234);
2144
2145        assert_eq!(
2146            config,
2147            serde_json::json!({
2148                "encrypted": false,
2149                "version": "1.0.0",
2150                "payload": {
2151                    "path": "payload/data.db",
2152                    "format": "sqlite",
2153                    "size_bytes": 1234
2154                },
2155                "warning": "UNENCRYPTED - All content is publicly readable"
2156            })
2157        );
2158    }
2159
2160    // =========================
2161    // WizardState Tests
2162    // =========================
2163
2164    #[test]
2165    fn wizard_state_default_values() {
2166        let state = WizardState::default();
2167
2168        // Content selection defaults
2169        assert!(state.agents.is_empty());
2170        assert!(state.time_range.is_none());
2171        assert!(state.workspaces.is_none());
2172
2173        // Security defaults
2174        assert!(state.password.is_none());
2175        assert!(state.recovery_secret.is_none());
2176        assert!(state.generate_recovery); // Should default to true
2177        assert!(!state.generate_qr); // Should default to false
2178
2179        // Site configuration defaults
2180        assert_eq!(state.title, "cass Archive");
2181        assert_eq!(
2182            state.description,
2183            "Encrypted archive of AI coding agent conversations"
2184        );
2185        assert!(!state.hide_metadata);
2186
2187        // Deployment defaults
2188        assert_eq!(state.target, DeployTarget::Local);
2189        assert_eq!(state.output_dir, PathBuf::from("cass-export"));
2190        assert!(state.repo_name.is_none());
2191
2192        // Exclusions default
2193        assert_eq!(state.exclusions.exclusion_counts(), (0, 0, 0));
2194
2195        // Summary default
2196        assert!(state.last_summary.is_none());
2197
2198        // Secret scan defaults
2199        assert!(!state.secret_scan_has_findings);
2200        assert!(!state.secret_scan_has_critical);
2201        assert_eq!(state.secret_scan_count, 0);
2202
2203        // Password entropy default
2204        assert_eq!(state.password_entropy_bits, 0.0);
2205
2206        // Unencrypted mode defaults
2207        assert!(!state.no_encryption);
2208        assert!(!state.unencrypted_confirmed);
2209    }
2210
2211    #[test]
2212    fn wizard_state_db_path_is_set() {
2213        let state = WizardState::default();
2214        // db_path should be set to a valid path containing the expected filename
2215        assert!(state.db_path.to_string_lossy().contains("agent_search.db"));
2216    }
2217
2218    #[test]
2219    fn wizard_state_clone() {
2220        let state = WizardState {
2221            title: "Custom Title".to_string(),
2222            agents: vec!["claude".to_string(), "codex".to_string()],
2223            no_encryption: true,
2224            ..Default::default()
2225        };
2226
2227        let cloned = state.clone();
2228        assert_eq!(cloned.title, "Custom Title");
2229        assert_eq!(
2230            cloned.agents,
2231            vec!["claude".to_string(), "codex".to_string()]
2232        );
2233        assert!(cloned.no_encryption);
2234    }
2235
2236    // =========================
2237    // PagesWizard Tests
2238    // =========================
2239
2240    #[test]
2241    fn pages_wizard_new_initializes_default_state() {
2242        let wizard = PagesWizard::new();
2243        // Access state through the no_encryption_mode field which is false by default
2244        assert!(!wizard.no_encryption_mode);
2245    }
2246
2247    #[test]
2248    fn pages_wizard_default_impl() {
2249        let wizard1 = PagesWizard::new();
2250        let wizard2 = PagesWizard::default();
2251        // Both should have same default state
2252        assert_eq!(wizard1.no_encryption_mode, wizard2.no_encryption_mode);
2253    }
2254
2255    #[test]
2256    fn pages_wizard_set_no_encryption() {
2257        let mut wizard = PagesWizard::new();
2258        assert!(!wizard.no_encryption_mode);
2259        assert!(!wizard.state.no_encryption);
2260
2261        wizard.set_no_encryption(true);
2262        assert!(wizard.no_encryption_mode);
2263        assert!(wizard.state.no_encryption);
2264
2265        wizard.set_no_encryption(false);
2266        assert!(!wizard.no_encryption_mode);
2267        assert!(!wizard.state.no_encryption);
2268    }
2269
2270    // Test for pages_wizard_set_include_attachments removed: flag removed
2271    // from WizardState per bead adyyt.
2272
2273    // =========================
2274    // Time Range Mapping Tests
2275    // =========================
2276
2277    #[test]
2278    fn time_range_selection_mapping() {
2279        // Test the time range mapping logic from step_content_selection
2280        // This is the mapping: 1 => -7d, 2 => -30d, 3 => -90d, 4 => -365d, 0/_ => None
2281
2282        fn map_time_selection(selection: usize) -> Option<String> {
2283            match selection {
2284                1 => Some("-7d".to_string()),
2285                2 => Some("-30d".to_string()),
2286                3 => Some("-90d".to_string()),
2287                4 => Some("-365d".to_string()),
2288                _ => None,
2289            }
2290        }
2291
2292        assert_eq!(map_time_selection(0), None);
2293        assert_eq!(map_time_selection(1), Some("-7d".to_string()));
2294        assert_eq!(map_time_selection(2), Some("-30d".to_string()));
2295        assert_eq!(map_time_selection(3), Some("-90d".to_string()));
2296        assert_eq!(map_time_selection(4), Some("-365d".to_string()));
2297        assert_eq!(map_time_selection(5), None);
2298    }
2299
2300    // =========================
2301    // Deploy Target Selection Mapping Tests
2302    // =========================
2303
2304    #[test]
2305    fn deploy_target_selection_mapping() {
2306        // Test the target selection mapping from step_deployment_target
2307        fn map_target_selection(selection: usize) -> DeployTarget {
2308            match selection {
2309                1 => DeployTarget::GitHubPages,
2310                2 => DeployTarget::CloudflarePages,
2311                _ => DeployTarget::Local,
2312            }
2313        }
2314
2315        assert_eq!(map_target_selection(0), DeployTarget::Local);
2316        assert_eq!(map_target_selection(1), DeployTarget::GitHubPages);
2317        assert_eq!(map_target_selection(2), DeployTarget::CloudflarePages);
2318        assert_eq!(map_target_selection(3), DeployTarget::Local);
2319    }
2320
2321    // =========================
2322    // State Modification Tests
2323    // =========================
2324
2325    #[test]
2326    fn wizard_state_agents_modification() {
2327        let mut state = WizardState::default();
2328        assert!(state.agents.is_empty());
2329
2330        state.agents = vec!["claude".to_string()];
2331        assert_eq!(state.agents.len(), 1);
2332
2333        state.agents.push("codex".to_string());
2334        assert_eq!(state.agents.len(), 2);
2335        assert_eq!(
2336            state.agents,
2337            vec!["claude".to_string(), "codex".to_string()]
2338        );
2339    }
2340
2341    #[test]
2342    fn wizard_state_workspaces_modification() {
2343        let mut state = WizardState::default();
2344        assert!(state.workspaces.is_none());
2345
2346        state.workspaces = Some(vec![PathBuf::from("/project1")]);
2347        assert_eq!(state.workspaces.as_ref().unwrap().len(), 1);
2348
2349        state
2350            .workspaces
2351            .as_mut()
2352            .unwrap()
2353            .push(PathBuf::from("/project2"));
2354        assert_eq!(state.workspaces.as_ref().unwrap().len(), 2);
2355    }
2356
2357    #[test]
2358    fn wizard_state_security_configuration() {
2359        let state = WizardState {
2360            password: Some("test_password".to_string()),
2361            recovery_secret: Some(vec![1, 2, 3, 4]),
2362            generate_recovery: false,
2363            generate_qr: true,
2364            ..Default::default()
2365        };
2366
2367        assert_eq!(state.password, Some("test_password".to_string()));
2368        assert_eq!(state.recovery_secret, Some(vec![1, 2, 3, 4]));
2369        assert!(!state.generate_recovery);
2370        assert!(state.generate_qr);
2371    }
2372
2373    #[test]
2374    fn wizard_state_debug_redacts_sensitive_fields() {
2375        let state = WizardState {
2376            password: Some("test_password".to_string()),
2377            recovery_secret: Some(vec![1, 2, 3, 4]),
2378            cloudflare_api_token: Some("cf-secret-token".to_string()),
2379            ..Default::default()
2380        };
2381
2382        let debug = format!("{state:?}");
2383        assert!(debug.contains("password"));
2384        assert!(debug.contains("recovery_secret"));
2385        assert!(debug.contains("cloudflare_api_token"));
2386        assert!(debug.contains("[REDACTED]"));
2387        assert!(!debug.contains("test_password"));
2388        assert!(!debug.contains("cf-secret-token"));
2389        assert!(!debug.contains("[1, 2, 3, 4]"));
2390    }
2391
2392    #[test]
2393    fn sample_title_truncation_is_utf8_boundary_safe() {
2394        let ascii = "abcdefghijklmnopqrstuvwxyz0123456789";
2395        assert_eq!(
2396            truncate_sample_title(ascii),
2397            "abcdefghijklmnopqrstuvwxyz0..."
2398        );
2399
2400        let unicode = format!("{}{}", "日本語".repeat(12), "suffix");
2401        let truncated = truncate_sample_title(&unicode);
2402        assert!(truncated.ends_with("..."));
2403        assert!(truncated.is_char_boundary(truncated.len()));
2404        assert!(truncated.len() <= 30);
2405    }
2406
2407    #[test]
2408    fn wizard_state_password_entropy() {
2409        let mut state = WizardState::default();
2410        assert_eq!(state.password_entropy_bits, 0.0);
2411
2412        state.password_entropy_bits = 64.5;
2413        assert!((state.password_entropy_bits - 64.5).abs() < f64::EPSILON);
2414    }
2415
2416    #[test]
2417    fn wizard_state_secret_scan_results() {
2418        let state = WizardState {
2419            secret_scan_has_findings: true,
2420            secret_scan_has_critical: true,
2421            secret_scan_count: 5,
2422            ..Default::default()
2423        };
2424
2425        assert!(state.secret_scan_has_findings);
2426        assert!(state.secret_scan_has_critical);
2427        assert_eq!(state.secret_scan_count, 5);
2428    }
2429
2430    #[test]
2431    fn wizard_state_output_configuration() {
2432        let state = WizardState {
2433            output_dir: PathBuf::from("/custom/output"),
2434            repo_name: Some("my-archive".to_string()),
2435            ..Default::default()
2436        };
2437
2438        assert_eq!(state.output_dir, PathBuf::from("/custom/output"));
2439        assert_eq!(state.repo_name, Some("my-archive".to_string()));
2440    }
2441
2442    // =========================
2443    // Edge Cases
2444    // =========================
2445
2446    #[test]
2447    fn wizard_state_with_unicode_values() {
2448        let state = WizardState {
2449            title: "日本語タイトル".to_string(),
2450            description: "説明文 with émojis 🎉".to_string(),
2451            agents: vec!["クローード".to_string()],
2452            ..Default::default()
2453        };
2454
2455        assert_eq!(state.title, "日本語タイトル");
2456        assert_eq!(state.description, "説明文 with émojis 🎉");
2457        assert_eq!(state.agents[0], "クローード");
2458    }
2459
2460    #[test]
2461    fn wizard_state_empty_strings() {
2462        let state = WizardState {
2463            title: "".to_string(),
2464            description: "".to_string(),
2465            ..Default::default()
2466        };
2467
2468        assert!(state.title.is_empty());
2469        assert!(state.description.is_empty());
2470    }
2471}