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#[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#[derive(Clone)]
52pub struct WizardState {
53 pub agents: Vec<String>,
55 pub time_range: Option<String>,
56 pub workspaces: Option<Vec<PathBuf>>,
57
58 pub password: Option<String>,
60 pub recovery_secret: Option<Vec<u8>>,
61 pub generate_recovery: bool,
62 pub generate_qr: bool,
63
64 pub title: String,
66 pub description: String,
67 pub hide_metadata: bool,
68
69 pub target: DeployTarget,
71 pub output_dir: PathBuf,
72 pub repo_name: Option<String>,
73
74 pub db_path: PathBuf,
76
77 pub exclusions: ExclusionSet,
79 pub last_summary: Option<PrePublishSummary>,
80
81 pub secret_scan_has_findings: bool,
83 pub secret_scan_has_critical: bool,
84 pub secret_scan_count: usize,
85
86 pub password_entropy_bits: f64,
88
89 pub no_encryption: bool,
91 pub unencrypted_confirmed: bool,
92
93 pub cloudflare_branch: Option<String>,
95 pub cloudflare_account_id: Option<String>,
96 pub cloudflare_api_token: Option<String>,
97
98 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 pub fn set_db_path(&mut self, db_path: PathBuf) {
205 self.state.db_path = db_path;
206 }
207
208 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 pub fn set_deploy_target(&mut self, target: DeployTarget) {
216 self.state.target = target;
217 }
218
219 pub fn set_repo_name(&mut self, name: String) {
221 self.state.repo_name = Some(name);
222 }
223
224 pub fn set_cloudflare_branch(&mut self, branch: String) {
226 self.state.cloudflare_branch = Some(branch);
227 }
228
229 pub fn set_cloudflare_account_id(&mut self, account_id: String) {
231 self.state.cloudflare_account_id = Some(account_id);
232 }
233
234 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 self.step_content_selection(&mut term, &theme)?;
253
254 self.step_secret_scan(&mut term, &theme)?;
256
257 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 self.step_site_config(&mut term, &theme)?;
267
268 self.step_deployment_target(&mut term, &theme)?;
270
271 if !self.step_summary(&mut term, &theme)? {
273 writeln!(term, "{}", style("Export cancelled.").yellow())?;
274 return Ok(());
275 }
276
277 if !self.no_encryption_mode && !self.step_confirmation(&mut term, &theme)? {
279 writeln!(term, "{}", style("Export cancelled.").yellow())?;
280 return Ok(());
281 }
282
283 self.step_export(&mut term)?;
285
286 self.step_deploy(&mut term)?;
288
289 Ok(())
290 }
291
292 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 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 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 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 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 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 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 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 let validation = validate_password(&password);
593
594 self.state.password_entropy_bits = validation.entropy_bits;
596
597 writeln!(
599 term,
600 " Password strength: {}",
601 format_strength_inline(&validation)
602 )?;
603 writeln!(term, " Entropy: {:.0} bits", validation.entropy_bits)?;
604
605 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 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 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 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 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 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 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 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 writeln!(term, "\n Generating summary...")?;
751 let summary = self.generate_prepublish_summary()?;
752 self.state.last_summary = Some(summary.clone());
753
754 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 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 if !summary.date_histogram.is_empty() {
796 let bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
797
798 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 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 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 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 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 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 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 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), 1 => {
969 self.edit_workspace_exclusions(term, theme, &summary)?;
971 }
972 2 => return Ok(false), _ => unreachable!(),
974 }
975 }
976 }
977
978 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 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 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 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 for (idx, ws) in summary.workspaces.iter().enumerate() {
1058 if selections.contains(&idx) {
1059 self.state.exclusions.include_workspace(&ws.path);
1061 } else {
1062 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 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 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, summary,
1121 };
1122
1123 let mut flow = ConfirmationFlow::new(config);
1124
1125 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 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 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 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 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 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 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 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 for (_, label) in flow.completed_steps_summary() {
1477 writeln!(term, " {} {}", style("✓").green(), label)?;
1478 }
1479 writeln!(term)?;
1480
1481 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 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 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 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 writeln!(term)?;
1562 for line in estimate.format_display().lines() {
1563 writeln!(term, " {}", line)?;
1564 }
1565 writeln!(term)?;
1566
1567 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 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 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 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 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 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 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 let mut enc_engine = EncryptionEngine::default();
1686
1687 if let Some(password) = &self.state.password {
1689 enc_engine.add_password_slot(password)?;
1690 }
1691
1692 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 if enc_engine.key_slot_count() == 0 {
1704 bail!(
1705 "No encryption key slots configured — archive would be permanently undecryptable"
1706 );
1707 }
1708
1709 enc_engine.encrypt_file(&export_db_path, &encrypted_dir, |_, _| {})?;
1711
1712 pb2.finish_with_message("✓ Encryption complete");
1713 }
1714
1715 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 let generated_docs = if let Some(ref summary) = self.state.last_summary {
1726 let target_url = match self.state.target {
1730 DeployTarget::GitHubPages => None, 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 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 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 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 let repo_name = self.deploy_project_name();
1900
1901 let deployer = GitHubDeployer::new(repo_name.clone());
1903
1904 match deployer.check_prerequisites() {
1906 Ok(prereqs) if prereqs.is_ready() => {
1907 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 let project_name = self.deploy_project_name();
1983
1984 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 match deployer.check_prerequisites() {
1996 Ok(prereqs) if prereqs.is_ready() => {
1997 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 #[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 #[test]
2165 fn wizard_state_default_values() {
2166 let state = WizardState::default();
2167
2168 assert!(state.agents.is_empty());
2170 assert!(state.time_range.is_none());
2171 assert!(state.workspaces.is_none());
2172
2173 assert!(state.password.is_none());
2175 assert!(state.recovery_secret.is_none());
2176 assert!(state.generate_recovery); assert!(!state.generate_qr); 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 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 assert_eq!(state.exclusions.exclusion_counts(), (0, 0, 0));
2194
2195 assert!(state.last_summary.is_none());
2197
2198 assert!(!state.secret_scan_has_findings);
2200 assert!(!state.secret_scan_has_critical);
2201 assert_eq!(state.secret_scan_count, 0);
2202
2203 assert_eq!(state.password_entropy_bits, 0.0);
2205
2206 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 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 #[test]
2241 fn pages_wizard_new_initializes_default_state() {
2242 let wizard = PagesWizard::new();
2243 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 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]
2278 fn time_range_selection_mapping() {
2279 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 #[test]
2305 fn deploy_target_selection_mapping() {
2306 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 #[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 #[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}