1use serde::{Deserialize, Serialize};
41use std::io::Read;
42use std::path::PathBuf;
43use thiserror::Error;
44
45use super::export::PathMode;
46use super::wizard::{DeployTarget, WizardState};
47use crate::ui::time_parser::parse_time_input;
48
49#[derive(Error, Debug)]
51pub enum ConfigError {
52 #[error("Failed to read config file: {0}")]
53 ReadFile(#[from] std::io::Error),
54
55 #[error("Failed to parse config JSON: {0}")]
56 ParseJson(#[from] serde_json::Error),
57
58 #[error("Validation error: {0}")]
59 Validation(String),
60
61 #[error("Environment variable not found: {0}")]
62 EnvVarNotFound(String),
63
64 #[error("Invalid time format: {0}")]
65 InvalidTime(String),
66}
67
68#[derive(Debug, Serialize)]
70pub struct ConfigValidationResult {
71 pub valid: bool,
73 #[serde(skip_serializing_if = "Vec::is_empty")]
75 pub errors: Vec<String>,
76 #[serde(skip_serializing_if = "Vec::is_empty")]
78 pub warnings: Vec<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub resolved: Option<ResolvedConfig>,
82}
83
84#[derive(Debug, Serialize)]
86pub struct ResolvedConfig {
87 pub filters: ResolvedFilters,
88 pub encryption: ResolvedEncryption,
89 pub bundle: ResolvedBundle,
90 pub deployment: ResolvedDeployment,
91}
92
93#[derive(Debug, Serialize)]
95pub struct ResolvedFilters {
96 pub agents: Vec<String>,
97 pub workspaces: Vec<PathBuf>,
98 pub since_ts: Option<i64>,
99 pub until_ts: Option<i64>,
100 pub path_mode: String,
101}
102
103#[derive(Debug, Serialize)]
105pub struct ResolvedEncryption {
106 pub enabled: bool,
107 pub password_set: bool,
108 pub generate_recovery: bool,
109 pub generate_qr: bool,
110 pub compression: String,
111 pub chunk_size: u64,
112}
113
114#[derive(Debug, Serialize)]
116pub struct ResolvedBundle {
117 pub title: String,
118 pub description: String,
119 pub hide_metadata: bool,
120}
121
122#[derive(Debug, Serialize)]
124pub struct ResolvedDeployment {
125 pub target: String,
126 pub output_dir: PathBuf,
127 pub repo: Option<String>,
128 pub branch: Option<String>,
129 pub account_id: Option<String>,
130 pub api_token_set: bool,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct PagesConfig {
136 #[serde(default)]
138 pub filters: FilterConfig,
139
140 #[serde(default)]
142 pub encryption: EncryptionConfig,
143
144 #[serde(default)]
146 pub bundle: BundleConfig,
147
148 #[serde(default)]
150 pub deployment: DeploymentConfig,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct FilterConfig {
156 #[serde(default)]
158 pub agents: Vec<String>,
159
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub since: Option<String>,
163
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub until: Option<String>,
167
168 #[serde(default)]
170 pub workspaces: Vec<String>,
171
172 #[serde(default)]
174 pub path_mode: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct EncryptionConfig {
180 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub password: Option<String>,
184
185 #[serde(default)]
188 pub no_encryption: bool,
189
190 #[serde(default)]
192 pub i_understand_risks: bool,
193
194 #[serde(default = "default_true")]
196 pub generate_recovery: bool,
197
198 #[serde(default)]
200 pub generate_qr: bool,
201
202 #[serde(default)]
206 pub compression: Option<String>,
207
208 #[serde(default)]
210 pub chunk_size: Option<u64>,
211}
212
213impl Default for EncryptionConfig {
214 fn default() -> Self {
215 Self {
216 password: None,
217 no_encryption: false,
218 i_understand_risks: false,
219 generate_recovery: true,
220 generate_qr: false,
221 compression: None,
222 chunk_size: None,
223 }
224 }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct BundleConfig {
230 #[serde(default = "default_title")]
232 pub title: String,
233
234 #[serde(default = "default_description")]
236 pub description: String,
237
238 #[serde(default)]
240 pub hide_metadata: bool,
241}
242
243impl Default for BundleConfig {
244 fn default() -> Self {
245 Self {
246 title: default_title(),
247 description: default_description(),
248 hide_metadata: false,
249 }
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct DeploymentConfig {
256 #[serde(default = "default_target")]
258 pub target: String,
259
260 #[serde(default = "default_output_dir")]
262 pub output_dir: String,
263
264 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub repo: Option<String>,
267
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub branch: Option<String>,
271
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub account_id: Option<String>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub api_token: Option<String>,
279}
280
281impl Default for DeploymentConfig {
282 fn default() -> Self {
283 Self {
284 target: default_target(),
285 output_dir: default_output_dir(),
286 repo: None,
287 branch: None,
288 account_id: None,
289 api_token: None,
290 }
291 }
292}
293
294fn default_true() -> bool {
296 true
297}
298fn default_title() -> String {
299 "cass Archive".to_string()
300}
301fn default_description() -> String {
302 "Encrypted archive of AI coding agent conversations".to_string()
303}
304fn default_target() -> String {
305 "local".to_string()
306}
307fn default_output_dir() -> String {
308 "cass-export".to_string()
309}
310
311const DEFAULT_PATH_MODE: &str = "relative";
312const DEFAULT_COMPRESSION: &str = "deflate";
313const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
314
315fn resolve_env_var(env_var: &str) -> Result<String, ConfigError> {
316 dotenvy::var(env_var).map_err(|_| ConfigError::EnvVarNotFound(env_var.to_string()))
317}
318
319impl PagesConfig {
320 fn normalized_path_mode(&self) -> Option<String> {
321 self.filters
322 .path_mode
323 .as_deref()
324 .map(str::trim)
325 .filter(|mode| !mode.is_empty())
326 .map(str::to_ascii_lowercase)
327 }
328
329 fn normalized_target(&self) -> String {
330 self.deployment.target.trim().to_ascii_lowercase()
331 }
332
333 fn resolved_path_mode(&self) -> String {
334 self.normalized_path_mode()
335 .unwrap_or_else(|| DEFAULT_PATH_MODE.to_string())
336 }
337
338 fn resolved_compression(&self) -> String {
339 self.normalized_compression()
340 .unwrap_or_else(|| DEFAULT_COMPRESSION.to_string())
341 }
342
343 fn normalized_compression(&self) -> Option<String> {
344 self.encryption
345 .compression
346 .as_deref()
347 .map(str::trim)
348 .filter(|compression| !compression.is_empty())
349 .map(str::to_ascii_lowercase)
350 }
351
352 fn resolved_chunk_size(&self) -> u64 {
353 self.encryption.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE)
354 }
355
356 fn resolved_time_range(&self) -> Option<String> {
357 match (&self.filters.since, &self.filters.until) {
358 (Some(since), Some(until)) => Some(format!("{} to {}", since, until)),
359 (Some(since), None) => Some(format!("since {}", since)),
360 (None, Some(until)) => Some(format!("until {}", until)),
361 (None, None) => None,
362 }
363 }
364
365 pub fn load(path: &str) -> Result<Self, ConfigError> {
369 let content = if path == "-" {
370 let mut buf = String::new();
371 std::io::stdin().read_to_string(&mut buf)?;
372 buf
373 } else {
374 std::fs::read_to_string(path)?
375 };
376
377 let config: PagesConfig = serde_json::from_str(&content)?;
378 Ok(config)
379 }
380
381 pub fn from_reader<R: Read>(reader: R) -> Result<Self, ConfigError> {
383 let config: PagesConfig = serde_json::from_reader(reader)?;
384 Ok(config)
385 }
386
387 pub fn resolve_env_vars(&mut self) -> Result<(), ConfigError> {
392 if let Some(ref password) = self.encryption.password
393 && let Some(env_var) = password.strip_prefix("env:")
394 {
395 self.encryption.password = Some(resolve_env_var(env_var)?);
396 }
397
398 if let Some(env_var) = self.deployment.output_dir.strip_prefix("env:") {
400 self.deployment.output_dir = resolve_env_var(env_var)?;
401 }
402
403 if let Some(ref account_id) = self.deployment.account_id
404 && let Some(env_var) = account_id.strip_prefix("env:")
405 {
406 self.deployment.account_id = Some(resolve_env_var(env_var)?);
407 }
408
409 if let Some(ref api_token) = self.deployment.api_token
410 && let Some(env_var) = api_token.strip_prefix("env:")
411 {
412 self.deployment.api_token = Some(resolve_env_var(env_var)?);
413 }
414
415 Ok(())
416 }
417
418 pub fn validate(&self) -> ConfigValidationResult {
420 let mut errors = Vec::new();
421 let mut warnings = Vec::new();
422
423 if !self.encryption.no_encryption && self.encryption.password.is_none() {
425 errors.push(
426 "encryption.password is required when encryption is enabled. \
427 Use \"env:VAR_NAME\" syntax to read from environment variable, \
428 or set encryption.no_encryption: true (requires i_understand_risks: true)."
429 .to_string(),
430 );
431 }
432
433 if self.encryption.no_encryption && !self.encryption.i_understand_risks {
434 errors.push(
435 "encryption.i_understand_risks must be true when no_encryption is enabled. \
436 This confirms you understand the security implications of unencrypted exports."
437 .to_string(),
438 );
439 }
440
441 if let Some(mode) = self.normalized_path_mode() {
443 match mode.as_str() {
444 "relative" | "basename" | "full" | "hash" => {}
445 _ => {
446 errors.push(format!(
447 "Invalid filters.path_mode: '{}'. Must be one of: relative, basename, full, hash",
448 self.filters.path_mode.as_deref().unwrap_or_default()
449 ));
450 }
451 }
452 }
453
454 let target = self.normalized_target();
456 match target.as_str() {
457 "local" | "github" | "cloudflare" => {}
458 _ => {
459 errors.push(format!(
460 "Invalid deployment.target: '{}'. Must be one of: local, github, cloudflare",
461 self.deployment.target
462 ));
463 }
464 }
465
466 if target == "github" && self.deployment.repo.is_none() {
468 errors.push(
469 "deployment.repo is required when target is 'github'. \
470 Specify the repository name for GitHub Pages deployment."
471 .to_string(),
472 );
473 }
474
475 if target == "cloudflare" {
476 let account_id_set = self.deployment.account_id.is_some();
477 let api_token_set = self.deployment.api_token.is_some();
478 if account_id_set ^ api_token_set {
479 errors.push(
480 "deployment.account_id and deployment.api_token must both be set for Cloudflare API-token auth (use env:VAR syntax if needed)."
481 .to_string(),
482 );
483 }
484 } else if self.deployment.account_id.is_some() || self.deployment.api_token.is_some() {
485 warnings.push(
486 "deployment.account_id/api_token are set but deployment.target is not cloudflare; these values will be ignored."
487 .to_string(),
488 );
489 }
490
491 if let Some(ref since) = self.filters.since
493 && parse_time_input(since).is_none()
494 {
495 errors.push(format!(
496 "Invalid filters.since time format: '{}'. \
497 Use ISO 8601 (2025-01-06), relative (30 days ago), or keywords (today, yesterday).",
498 since
499 ));
500 }
501
502 if let Some(ref until) = self.filters.until
503 && parse_time_input(until).is_none()
504 {
505 errors.push(format!(
506 "Invalid filters.until time format: '{}'. \
507 Use ISO 8601 (2025-01-06), relative (30 days ago), or keywords (today, yesterday).",
508 until
509 ));
510 }
511
512 match self.encryption.chunk_size {
513 Some(0) => errors.push("encryption.chunk_size must be greater than 0 bytes.".into()),
514 Some(chunk_size) if chunk_size > crate::pages::encrypt::MAX_CHUNK_SIZE as u64 => errors.push(format!(
515 "encryption.chunk_size ({chunk_size}) exceeds the maximum supported size of {} bytes.",
516 crate::pages::encrypt::MAX_CHUNK_SIZE
517 )),
518 _ => {}
519 }
520 if let Some(compression) = self.normalized_compression()
521 && compression != DEFAULT_COMPRESSION
522 {
523 errors.push(format!(
524 "Invalid encryption.compression: '{}'. The current encrypted pages format supports only deflate.",
525 self.encryption.compression.as_deref().unwrap_or_default()
526 ));
527 }
528
529 if self
531 .encryption
532 .password
533 .as_ref()
534 .is_some_and(|p| p.chars().count() < 12)
535 {
536 warnings.push(
537 "Password is less than 12 characters. Consider using a stronger password."
538 .to_string(),
539 );
540 }
541
542 if self.encryption.no_encryption {
543 warnings.push(
544 "no_encryption is enabled. Content will be publicly readable without a password."
545 .to_string(),
546 );
547 }
548
549 if self.encryption.generate_qr && !self.encryption.generate_recovery {
550 warnings.push(
551 "generate_qr is enabled but generate_recovery is false. QR codes are generated for recovery secrets only."
552 .to_string(),
553 );
554 }
555
556 if target == "github" && self.deployment.branch.is_some() {
557 warnings.push(
558 "deployment.branch is set for GitHub Pages, but cass always deploys to gh-pages. The value will be ignored."
559 .to_string(),
560 );
561 }
562
563 let valid = errors.is_empty();
564 let resolved = if valid {
565 Some(self.to_resolved())
566 } else {
567 None
568 };
569
570 ConfigValidationResult {
571 valid,
572 errors,
573 warnings,
574 resolved,
575 }
576 }
577
578 fn to_resolved(&self) -> ResolvedConfig {
580 ResolvedConfig {
581 filters: ResolvedFilters {
582 agents: self.filters.agents.clone(),
583 workspaces: self.filters.workspaces.iter().map(PathBuf::from).collect(),
584 since_ts: self.filters.since.as_deref().and_then(parse_time_input),
585 until_ts: self.filters.until.as_deref().and_then(parse_time_input),
586 path_mode: self.resolved_path_mode(),
587 },
588 encryption: ResolvedEncryption {
589 enabled: !self.encryption.no_encryption,
590 password_set: self.encryption.password.is_some(),
591 generate_recovery: self.encryption.generate_recovery,
592 generate_qr: self.encryption.generate_qr,
593 compression: self.resolved_compression(),
594 chunk_size: self.resolved_chunk_size(),
595 },
596 bundle: ResolvedBundle {
597 title: self.bundle.title.clone(),
598 description: self.bundle.description.clone(),
599 hide_metadata: self.bundle.hide_metadata,
600 },
601 deployment: ResolvedDeployment {
602 target: self.normalized_target(),
603 output_dir: PathBuf::from(&self.deployment.output_dir),
604 repo: self.deployment.repo.clone(),
605 branch: self.deployment.branch.clone(),
606 account_id: self.deployment.account_id.clone(),
607 api_token_set: self.deployment.api_token.is_some(),
608 },
609 }
610 }
611
612 pub fn to_wizard_state(&self, db_path: PathBuf) -> Result<WizardState, ConfigError> {
614 let target = match self.normalized_target().as_str() {
616 "github" => DeployTarget::GitHubPages,
617 "cloudflare" => DeployTarget::CloudflarePages,
618 _ => DeployTarget::Local,
619 };
620
621 let workspaces = if self.filters.workspaces.is_empty() {
623 None
624 } else {
625 Some(self.filters.workspaces.iter().map(PathBuf::from).collect())
626 };
627
628 Ok(WizardState {
629 agents: self.filters.agents.clone(),
630 time_range: self.resolved_time_range(),
631 workspaces,
632 password: self.encryption.password.clone(),
633 recovery_secret: None,
634 generate_recovery: self.encryption.generate_recovery,
635 generate_qr: self.encryption.generate_qr,
636 title: self.bundle.title.clone(),
637 description: self.bundle.description.clone(),
638 hide_metadata: self.bundle.hide_metadata,
639 target,
640 output_dir: PathBuf::from(&self.deployment.output_dir),
641 repo_name: self.deployment.repo.clone(),
642 db_path,
643 exclusions: Default::default(),
644 last_summary: None,
645 secret_scan_has_findings: false,
646 secret_scan_has_critical: false,
647 secret_scan_count: 0,
648 password_entropy_bits: 0.0,
649 no_encryption: self.encryption.no_encryption,
650 unencrypted_confirmed: self.encryption.i_understand_risks,
651 cloudflare_branch: self.deployment.branch.clone(),
652 cloudflare_account_id: self.deployment.account_id.clone(),
653 cloudflare_api_token: self.deployment.api_token.clone(),
654 final_site_dir: None,
655 })
656 }
657
658 pub fn path_mode(&self) -> PathMode {
660 match self.normalized_path_mode().as_deref() {
661 Some("basename") => PathMode::Basename,
662 Some("full") => PathMode::Full,
663 Some("hash") => PathMode::Hash,
664 _ => PathMode::Relative,
665 }
666 }
667
668 pub fn since_ts(&self) -> Option<i64> {
670 self.filters.since.as_deref().and_then(parse_time_input)
671 }
672
673 pub fn until_ts(&self) -> Option<i64> {
675 self.filters.until.as_deref().and_then(parse_time_input)
676 }
677}
678
679pub fn example_config() -> &'static str {
681 r#"{
682 "filters": {
683 "agents": ["claude-code", "codex"],
684 "since": "30 days ago",
685 "until": null,
686 "workspaces": [],
687 "path_mode": "relative"
688 },
689 "encryption": {
690 "password": "env:CASS_EXPORT_PASSWORD",
691 "no_encryption": false,
692 "i_understand_risks": false,
693 "generate_recovery": true,
694 "generate_qr": false,
695 "compression": "deflate",
696 "chunk_size": 8388608
697 },
698 "bundle": {
699 "title": "My Archive",
700 "description": "Encrypted cass export",
701 "hide_metadata": false
702 },
703 "deployment": {
704 "target": "local",
705 "output_dir": "./cass-export",
706 "repo": null,
707 "branch": null,
708 "account_id": null,
709 "api_token": null
710 }
711}"#
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 fn config_with_password() -> PagesConfig {
719 let mut config = PagesConfig::default();
720 config.encryption.password = Some("test123".to_string());
721 config
722 }
723
724 #[test]
725 fn test_parse_minimal_config() {
726 let json = r#"{"encryption": {"password": "test123"}}"#;
727 let config: PagesConfig = serde_json::from_str(json).unwrap();
728 assert_eq!(config.encryption.password, Some("test123".to_string()));
729 assert!(!config.encryption.no_encryption);
730 }
731
732 #[test]
733 fn test_parse_full_config() {
734 let json = example_config();
735 let config: PagesConfig = serde_json::from_str(json).unwrap();
736 assert_eq!(config.filters.agents, vec!["claude-code", "codex"]);
737 assert_eq!(config.bundle.title, "My Archive");
738 assert_eq!(config.deployment.target, "local");
739 }
740
741 #[test]
747 fn test_validate_missing_password() {
748 let config = PagesConfig::default();
749 let result = config.validate();
750 assert!(!result.valid);
751 assert!(result.errors.iter().any(|e| e.contains("password")));
752 }
753
754 #[test]
755 fn test_validate_no_encryption_without_ack() {
756 let mut config = PagesConfig::default();
757 config.encryption.no_encryption = true;
758 config.encryption.i_understand_risks = false;
759 let result = config.validate();
760 assert!(!result.valid);
761 assert!(
762 result
763 .errors
764 .iter()
765 .any(|e| e.contains("i_understand_risks"))
766 );
767 }
768
769 #[test]
770 fn test_validate_no_encryption_with_ack() {
771 let mut config = PagesConfig::default();
772 config.encryption.no_encryption = true;
773 config.encryption.i_understand_risks = true;
774 let result = config.validate();
775 assert!(result.valid);
776 }
777
778 #[test]
779 fn test_validate_github_without_repo() {
780 let mut config = config_with_password();
781 config.deployment.target = "github".to_string();
782 let result = config.validate();
783 assert!(!result.valid);
784 assert!(result.errors.iter().any(|e| e.contains("repo")));
785 }
786
787 #[test]
788 fn test_validate_zero_chunk_size() {
789 let mut config = config_with_password();
790 config.encryption.chunk_size = Some(0);
791
792 let result = config.validate();
793 assert!(!result.valid);
794 assert!(result.errors.iter().any(|e| e.contains("chunk_size")));
795 }
796
797 #[test]
798 fn test_validate_oversized_chunk_size() {
799 let mut config = config_with_password();
800 config.encryption.chunk_size = Some(crate::pages::encrypt::MAX_CHUNK_SIZE as u64 + 1);
801
802 let result = config.validate();
803 assert!(!result.valid);
804 assert!(result.errors.iter().any(|e| e.contains("chunk_size")));
805 }
806
807 #[test]
808 fn test_validate_rejects_unsupported_compression() {
809 let mut config = config_with_password();
810 config.encryption.compression = Some("gzip".to_string());
811
812 let result = config.validate();
813 assert!(!result.valid);
814 assert!(
815 result
816 .errors
817 .iter()
818 .any(|e| e.contains("compression") && e.contains("deflate"))
819 );
820 }
821
822 #[test]
823 fn test_validate_compression_trims_and_normalizes() {
824 let mut config = config_with_password();
825 config.encryption.compression = Some(" Deflate ".to_string());
826
827 let result = config.validate();
828 assert!(result.valid, "{:?}", result.errors);
829 let resolved = result.resolved.expect("resolved config should exist");
830 assert_eq!(resolved.encryption.compression, DEFAULT_COMPRESSION);
831 }
832
833 #[test]
834 fn test_env_var_resolution() {
835 unsafe { std::env::set_var("TEST_PASSWORD_VAR", "secret123") };
837 let mut config = PagesConfig::default();
838 config.encryption.password = Some("env:TEST_PASSWORD_VAR".to_string());
839 config.resolve_env_vars().unwrap();
840 assert_eq!(config.encryption.password, Some("secret123".to_string()));
841 unsafe { std::env::remove_var("TEST_PASSWORD_VAR") };
843 }
844
845 #[test]
846 fn test_env_var_resolution_deployment_credentials() {
847 unsafe {
849 std::env::set_var("TEST_CF_ACCOUNT_ID", "acc123");
850 std::env::set_var("TEST_CF_API_TOKEN", "token456");
851 }
852
853 let mut config = PagesConfig::default();
854 config.deployment.account_id = Some("env:TEST_CF_ACCOUNT_ID".to_string());
855 config.deployment.api_token = Some("env:TEST_CF_API_TOKEN".to_string());
856 config.resolve_env_vars().unwrap();
857
858 assert_eq!(config.deployment.account_id, Some("acc123".to_string()));
859 assert_eq!(config.deployment.api_token, Some("token456".to_string()));
860
861 unsafe {
863 std::env::remove_var("TEST_CF_ACCOUNT_ID");
864 std::env::remove_var("TEST_CF_API_TOKEN");
865 }
866 }
867
868 #[test]
869 fn test_env_var_not_found() {
870 let mut config = PagesConfig::default();
871 config.encryption.password = Some("env:NONEXISTENT_VAR_12345".to_string());
872 let result = config.resolve_env_vars();
873 assert!(result.is_err());
874 }
875
876 #[test]
877 fn test_invalid_path_mode() {
878 let mut config = config_with_password();
879 config.filters.path_mode = Some("invalid".to_string());
880 let result = config.validate();
881 assert!(!result.valid);
882 assert!(result.errors.iter().any(|e| e.contains("path_mode")));
883 }
884
885 #[test]
886 fn test_invalid_deploy_target() {
887 let mut config = config_with_password();
888 config.deployment.target = "invalid".to_string();
889 let result = config.validate();
890 assert!(!result.valid);
891 assert!(result.errors.iter().any(|e| e.contains("target")));
892 }
893
894 #[test]
895 fn test_validate_partial_cloudflare_credentials() {
896 let mut config = config_with_password();
897 config.deployment.target = "cloudflare".to_string();
898 config.deployment.account_id = Some("acc-only".to_string());
899
900 let result = config.validate();
901 assert!(!result.valid);
902 assert!(
903 result
904 .errors
905 .iter()
906 .any(|e| e.contains("account_id") && e.contains("api_token"))
907 );
908 }
909
910 #[test]
911 fn test_path_mode_parsing() {
912 let mut config = PagesConfig::default();
913
914 config.filters.path_mode = None;
915 assert!(matches!(config.path_mode(), PathMode::Relative));
916
917 config.filters.path_mode = Some("basename".to_string());
918 assert!(matches!(config.path_mode(), PathMode::Basename));
919
920 config.filters.path_mode = Some("full".to_string());
921 assert!(matches!(config.path_mode(), PathMode::Full));
922
923 config.filters.path_mode = Some("hash".to_string());
924 assert!(matches!(config.path_mode(), PathMode::Hash));
925
926 config.filters.path_mode = Some("Basename".to_string());
928 assert!(matches!(config.path_mode(), PathMode::Basename));
929
930 config.filters.path_mode = Some(" FULL ".to_string());
931 assert!(matches!(config.path_mode(), PathMode::Full));
932
933 config.filters.path_mode = Some(" ".to_string());
934 assert!(matches!(config.path_mode(), PathMode::Relative));
935 }
936
937 #[test]
938 fn test_validate_path_mode_trims_whitespace() {
939 let mut config = config_with_password();
940 config.filters.path_mode = Some(" FULL ".to_string());
941
942 let result = config.validate();
943 assert!(result.valid, "{:?}", result.errors);
944
945 let resolved = result.resolved.expect("resolved config should exist");
946 assert_eq!(resolved.filters.path_mode, "full");
947 }
948
949 #[test]
950 fn test_resolved_config_applies_export_defaults() {
951 let config = config_with_password();
952
953 let result = config.validate();
954 assert!(result.valid, "{:?}", result.errors);
955
956 let resolved = result.resolved.expect("resolved config should exist");
957 assert_eq!(resolved.filters.path_mode, DEFAULT_PATH_MODE);
958 assert_eq!(resolved.encryption.compression, DEFAULT_COMPRESSION);
959 assert_eq!(resolved.encryption.chunk_size, DEFAULT_CHUNK_SIZE);
960 }
961
962 #[test]
963 fn test_validate_target_trims_whitespace() {
964 let mut config = config_with_password();
965 config.deployment.target = " GitHub ".to_string();
966 config.deployment.repo = Some("example-repo".to_string());
967
968 let result = config.validate();
969 assert!(result.valid, "{:?}", result.errors);
970
971 let resolved = result.resolved.expect("resolved config should exist");
972 assert_eq!(resolved.deployment.target, "github");
973 }
974
975 #[test]
976 fn test_to_wizard_state_target_trims_whitespace() {
977 let mut config = config_with_password();
978 config.deployment.target = " cloudflare ".to_string();
979
980 let state = config
981 .to_wizard_state(PathBuf::from("/tmp/test.db"))
982 .expect("wizard state should parse");
983
984 assert!(matches!(state.target, DeployTarget::CloudflarePages));
985 }
986
987 #[test]
988 fn test_resolved_time_range_priority() {
989 let mut config = PagesConfig::default();
990
991 assert_eq!(config.resolved_time_range(), None);
992
993 config.filters.since = Some("30 days ago".to_string());
994 assert_eq!(
995 config.resolved_time_range(),
996 Some("since 30 days ago".to_string())
997 );
998
999 config.filters.until = Some("today".to_string());
1000 assert_eq!(
1001 config.resolved_time_range(),
1002 Some("30 days ago to today".to_string())
1003 );
1004
1005 config.filters.since = None;
1006 assert_eq!(
1007 config.resolved_time_range(),
1008 Some("until today".to_string())
1009 );
1010 }
1011}