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
319fn option_has_non_empty_value(value: &Option<String>) -> bool {
320 value
321 .as_deref()
322 .is_some_and(|value| !value.trim().is_empty())
323}
324
325fn option_has_empty_value(value: &Option<String>) -> bool {
326 value
327 .as_deref()
328 .is_some_and(|value| value.trim().is_empty())
329}
330
331impl PagesConfig {
332 fn normalized_path_mode(&self) -> Option<String> {
333 self.filters
334 .path_mode
335 .as_deref()
336 .map(str::trim)
337 .filter(|mode| !mode.is_empty())
338 .map(str::to_ascii_lowercase)
339 }
340
341 fn normalized_target(&self) -> String {
342 self.deployment.target.trim().to_ascii_lowercase()
343 }
344
345 fn resolved_path_mode(&self) -> String {
346 self.normalized_path_mode()
347 .unwrap_or_else(|| DEFAULT_PATH_MODE.to_string())
348 }
349
350 fn resolved_compression(&self) -> String {
351 self.normalized_compression()
352 .unwrap_or_else(|| DEFAULT_COMPRESSION.to_string())
353 }
354
355 fn normalized_compression(&self) -> Option<String> {
356 self.encryption
357 .compression
358 .as_deref()
359 .map(str::trim)
360 .filter(|compression| !compression.is_empty())
361 .map(str::to_ascii_lowercase)
362 }
363
364 fn resolved_chunk_size(&self) -> u64 {
365 self.encryption.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE)
366 }
367
368 fn resolved_time_range(&self) -> Option<String> {
369 match (&self.filters.since, &self.filters.until) {
370 (Some(since), Some(until)) => Some(format!("{} to {}", since, until)),
371 (Some(since), None) => Some(format!("since {}", since)),
372 (None, Some(until)) => Some(format!("until {}", until)),
373 (None, None) => None,
374 }
375 }
376
377 pub fn load(path: &str) -> Result<Self, ConfigError> {
381 let content = if path == "-" {
382 let mut buf = String::new();
383 std::io::stdin().read_to_string(&mut buf)?;
384 buf
385 } else {
386 std::fs::read_to_string(path)?
387 };
388
389 let config: PagesConfig = serde_json::from_str(&content)?;
390 Ok(config)
391 }
392
393 pub fn from_reader<R: Read>(reader: R) -> Result<Self, ConfigError> {
395 let config: PagesConfig = serde_json::from_reader(reader)?;
396 Ok(config)
397 }
398
399 pub fn resolve_env_vars(&mut self) -> Result<(), ConfigError> {
404 if let Some(ref password) = self.encryption.password
405 && let Some(env_var) = password.strip_prefix("env:")
406 {
407 self.encryption.password = Some(resolve_env_var(env_var)?);
408 }
409
410 if let Some(env_var) = self.deployment.output_dir.strip_prefix("env:") {
412 self.deployment.output_dir = resolve_env_var(env_var)?;
413 }
414
415 if let Some(ref account_id) = self.deployment.account_id
416 && let Some(env_var) = account_id.strip_prefix("env:")
417 {
418 self.deployment.account_id = Some(resolve_env_var(env_var)?);
419 }
420
421 if let Some(ref api_token) = self.deployment.api_token
422 && let Some(env_var) = api_token.strip_prefix("env:")
423 {
424 self.deployment.api_token = Some(resolve_env_var(env_var)?);
425 }
426
427 Ok(())
428 }
429
430 pub fn validate(&self) -> ConfigValidationResult {
432 let mut errors = Vec::new();
433 let mut warnings = Vec::new();
434
435 if !self.encryption.no_encryption {
437 match self.encryption.password.as_deref().map(str::trim) {
438 Some(password) if !password.is_empty() => {}
439 Some(_) => errors.push(
440 "encryption.password must not be empty when encryption is enabled.".to_string(),
441 ),
442 None => errors.push(
443 "encryption.password is required when encryption is enabled. \
444 Use \"env:VAR_NAME\" syntax to read from environment variable, \
445 or set encryption.no_encryption: true (requires i_understand_risks: true)."
446 .to_string(),
447 ),
448 }
449 }
450
451 if self.encryption.no_encryption && !self.encryption.i_understand_risks {
452 errors.push(
453 "encryption.i_understand_risks must be true when no_encryption is enabled. \
454 This confirms you understand the security implications of unencrypted exports."
455 .to_string(),
456 );
457 }
458
459 if let Some(mode) = self.normalized_path_mode() {
461 match mode.as_str() {
462 "relative" | "basename" | "full" | "hash" => {}
463 _ => {
464 errors.push(format!(
465 "Invalid filters.path_mode: '{}'. Must be one of: relative, basename, full, hash",
466 self.filters.path_mode.as_deref().unwrap_or_default()
467 ));
468 }
469 }
470 }
471
472 let target = self.normalized_target();
474 if self.deployment.output_dir.trim().is_empty() {
475 errors.push("deployment.output_dir must not be empty.".to_string());
476 }
477 match target.as_str() {
478 "local" | "github" | "cloudflare" => {}
479 _ => {
480 errors.push(format!(
481 "Invalid deployment.target: '{}'. Must be one of: local, github, cloudflare",
482 self.deployment.target
483 ));
484 }
485 }
486
487 if target == "github" && !option_has_non_empty_value(&self.deployment.repo) {
489 errors.push(
490 "deployment.repo is required and must not be empty when target is 'github'. \
491 Specify the repository name for GitHub Pages deployment."
492 .to_string(),
493 );
494 }
495
496 if target == "cloudflare" {
497 if option_has_empty_value(&self.deployment.account_id) {
498 errors.push("deployment.account_id must not be empty when set.".to_string());
499 }
500 if option_has_empty_value(&self.deployment.api_token) {
501 errors.push("deployment.api_token must not be empty when set.".to_string());
502 }
503
504 let account_id_set = option_has_non_empty_value(&self.deployment.account_id);
505 let api_token_set = option_has_non_empty_value(&self.deployment.api_token);
506 if account_id_set ^ api_token_set {
507 errors.push(
508 "deployment.account_id and deployment.api_token must both be set for Cloudflare API-token auth (use env:VAR syntax if needed)."
509 .to_string(),
510 );
511 }
512 } else if option_has_non_empty_value(&self.deployment.account_id)
513 || option_has_non_empty_value(&self.deployment.api_token)
514 {
515 warnings.push(
516 "deployment.account_id/api_token are set but deployment.target is not cloudflare; these values will be ignored."
517 .to_string(),
518 );
519 }
520
521 if let Some(ref since) = self.filters.since
523 && parse_time_input(since).is_none()
524 {
525 errors.push(format!(
526 "Invalid filters.since time format: '{}'. \
527 Use ISO 8601 (2025-01-06), relative (30 days ago), or keywords (today, yesterday).",
528 since
529 ));
530 }
531
532 if let Some(ref until) = self.filters.until
533 && parse_time_input(until).is_none()
534 {
535 errors.push(format!(
536 "Invalid filters.until time format: '{}'. \
537 Use ISO 8601 (2025-01-06), relative (30 days ago), or keywords (today, yesterday).",
538 until
539 ));
540 }
541
542 match self.encryption.chunk_size {
543 Some(0) => errors.push("encryption.chunk_size must be greater than 0 bytes.".into()),
544 Some(chunk_size) if chunk_size > crate::pages::encrypt::MAX_CHUNK_SIZE as u64 => errors.push(format!(
545 "encryption.chunk_size ({chunk_size}) exceeds the maximum supported size of {} bytes.",
546 crate::pages::encrypt::MAX_CHUNK_SIZE
547 )),
548 _ => {}
549 }
550 if let Some(compression) = self.normalized_compression()
551 && compression != DEFAULT_COMPRESSION
552 {
553 errors.push(format!(
554 "Invalid encryption.compression: '{}'. The current encrypted pages format supports only deflate.",
555 self.encryption.compression.as_deref().unwrap_or_default()
556 ));
557 }
558
559 if self
561 .encryption
562 .password
563 .as_ref()
564 .is_some_and(|p| p.chars().count() < 12)
565 {
566 warnings.push(
567 "Password is less than 12 characters. Consider using a stronger password."
568 .to_string(),
569 );
570 }
571
572 if self.encryption.no_encryption {
573 warnings.push(
574 "no_encryption is enabled. Content will be publicly readable without a password."
575 .to_string(),
576 );
577 }
578
579 if self.encryption.generate_qr && !self.encryption.generate_recovery {
580 warnings.push(
581 "generate_qr is enabled but generate_recovery is false. QR codes are generated for recovery secrets only."
582 .to_string(),
583 );
584 }
585
586 if target == "github" && self.deployment.branch.is_some() {
587 warnings.push(
588 "deployment.branch is set for GitHub Pages, but cass always deploys to gh-pages. The value will be ignored."
589 .to_string(),
590 );
591 }
592
593 let valid = errors.is_empty();
594 let resolved = if valid {
595 Some(self.to_resolved())
596 } else {
597 None
598 };
599
600 ConfigValidationResult {
601 valid,
602 errors,
603 warnings,
604 resolved,
605 }
606 }
607
608 fn to_resolved(&self) -> ResolvedConfig {
610 ResolvedConfig {
611 filters: ResolvedFilters {
612 agents: self.filters.agents.clone(),
613 workspaces: self.filters.workspaces.iter().map(PathBuf::from).collect(),
614 since_ts: self.filters.since.as_deref().and_then(parse_time_input),
615 until_ts: self.filters.until.as_deref().and_then(parse_time_input),
616 path_mode: self.resolved_path_mode(),
617 },
618 encryption: ResolvedEncryption {
619 enabled: !self.encryption.no_encryption,
620 password_set: self.encryption.password.is_some(),
621 generate_recovery: self.encryption.generate_recovery,
622 generate_qr: self.encryption.generate_qr,
623 compression: self.resolved_compression(),
624 chunk_size: self.resolved_chunk_size(),
625 },
626 bundle: ResolvedBundle {
627 title: self.bundle.title.clone(),
628 description: self.bundle.description.clone(),
629 hide_metadata: self.bundle.hide_metadata,
630 },
631 deployment: ResolvedDeployment {
632 target: self.normalized_target(),
633 output_dir: PathBuf::from(&self.deployment.output_dir),
634 repo: self.deployment.repo.clone(),
635 branch: self.deployment.branch.clone(),
636 account_id: self.deployment.account_id.clone(),
637 api_token_set: self.deployment.api_token.is_some(),
638 },
639 }
640 }
641
642 pub fn to_wizard_state(&self, db_path: PathBuf) -> Result<WizardState, ConfigError> {
644 let target = match self.normalized_target().as_str() {
646 "github" => DeployTarget::GitHubPages,
647 "cloudflare" => DeployTarget::CloudflarePages,
648 _ => DeployTarget::Local,
649 };
650
651 let workspaces = if self.filters.workspaces.is_empty() {
653 None
654 } else {
655 Some(self.filters.workspaces.iter().map(PathBuf::from).collect())
656 };
657
658 Ok(WizardState {
659 agents: self.filters.agents.clone(),
660 time_range: self.resolved_time_range(),
661 workspaces,
662 password: self.encryption.password.clone(),
663 recovery_secret: None,
664 generate_recovery: self.encryption.generate_recovery,
665 generate_qr: self.encryption.generate_qr,
666 title: self.bundle.title.clone(),
667 description: self.bundle.description.clone(),
668 hide_metadata: self.bundle.hide_metadata,
669 target,
670 output_dir: PathBuf::from(&self.deployment.output_dir),
671 repo_name: self.deployment.repo.clone(),
672 db_path,
673 exclusions: Default::default(),
674 last_summary: None,
675 secret_scan_has_findings: false,
676 secret_scan_has_critical: false,
677 secret_scan_count: 0,
678 password_entropy_bits: 0.0,
679 no_encryption: self.encryption.no_encryption,
680 unencrypted_confirmed: self.encryption.i_understand_risks,
681 cloudflare_branch: self.deployment.branch.clone(),
682 cloudflare_account_id: self.deployment.account_id.clone(),
683 cloudflare_api_token: self.deployment.api_token.clone(),
684 final_site_dir: None,
685 })
686 }
687
688 pub fn path_mode(&self) -> PathMode {
690 match self.normalized_path_mode().as_deref() {
691 Some("basename") => PathMode::Basename,
692 Some("full") => PathMode::Full,
693 Some("hash") => PathMode::Hash,
694 _ => PathMode::Relative,
695 }
696 }
697
698 pub fn since_ts(&self) -> Option<i64> {
700 self.filters.since.as_deref().and_then(parse_time_input)
701 }
702
703 pub fn until_ts(&self) -> Option<i64> {
705 self.filters.until.as_deref().and_then(parse_time_input)
706 }
707}
708
709pub fn example_config() -> &'static str {
711 r#"{
712 "filters": {
713 "agents": ["claude-code", "codex"],
714 "since": "30 days ago",
715 "until": null,
716 "workspaces": [],
717 "path_mode": "relative"
718 },
719 "encryption": {
720 "password": "env:CASS_EXPORT_PASSWORD",
721 "no_encryption": false,
722 "i_understand_risks": false,
723 "generate_recovery": true,
724 "generate_qr": false,
725 "compression": "deflate",
726 "chunk_size": 8388608
727 },
728 "bundle": {
729 "title": "My Archive",
730 "description": "Encrypted cass export",
731 "hide_metadata": false
732 },
733 "deployment": {
734 "target": "local",
735 "output_dir": "./cass-export",
736 "repo": null,
737 "branch": null,
738 "account_id": null,
739 "api_token": null
740 }
741}"#
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 fn config_with_password() -> PagesConfig {
749 let mut config = PagesConfig::default();
750 config.encryption.password = Some("test123".to_string());
751 config
752 }
753
754 #[test]
755 fn test_parse_minimal_config() {
756 let json = r#"{"encryption": {"password": "test123"}}"#;
757 let config: PagesConfig = serde_json::from_str(json).unwrap();
758 assert_eq!(config.encryption.password, Some("test123".to_string()));
759 assert!(!config.encryption.no_encryption);
760 }
761
762 #[test]
763 fn test_parse_full_config() {
764 let json = example_config();
765 let config: PagesConfig = serde_json::from_str(json).unwrap();
766 assert_eq!(config.filters.agents, vec!["claude-code", "codex"]);
767 assert_eq!(config.bundle.title, "My Archive");
768 assert_eq!(config.deployment.target, "local");
769 }
770
771 #[test]
777 fn test_validate_missing_password() {
778 let config = PagesConfig::default();
779 let result = config.validate();
780 assert!(!result.valid);
781 assert!(result.errors.iter().any(|e| e.contains("password")));
782 }
783
784 #[test]
785 fn test_validate_empty_password() {
786 let mut config = PagesConfig::default();
787 config.encryption.password = Some(" ".to_string());
788
789 let result = config.validate();
790 assert!(!result.valid);
791 assert!(
792 result
793 .errors
794 .iter()
795 .any(|e| e.contains("password") && e.contains("empty"))
796 );
797 }
798
799 #[test]
800 fn test_validate_no_encryption_without_ack() {
801 let mut config = PagesConfig::default();
802 config.encryption.no_encryption = true;
803 config.encryption.i_understand_risks = false;
804 let result = config.validate();
805 assert!(!result.valid);
806 assert!(
807 result
808 .errors
809 .iter()
810 .any(|e| e.contains("i_understand_risks"))
811 );
812 }
813
814 #[test]
815 fn test_validate_no_encryption_with_ack() {
816 let mut config = PagesConfig::default();
817 config.encryption.no_encryption = true;
818 config.encryption.i_understand_risks = true;
819 let result = config.validate();
820 assert!(result.valid);
821 }
822
823 #[test]
824 fn test_validate_github_without_repo() {
825 let mut config = config_with_password();
826 config.deployment.target = "github".to_string();
827 let result = config.validate();
828 assert!(!result.valid);
829 assert!(result.errors.iter().any(|e| e.contains("repo")));
830 }
831
832 #[test]
833 fn test_validate_github_with_blank_repo() {
834 let mut config = config_with_password();
835 config.deployment.target = "github".to_string();
836 config.deployment.repo = Some(" ".to_string());
837
838 let result = config.validate();
839 assert!(!result.valid);
840 assert!(
841 result
842 .errors
843 .iter()
844 .any(|e| e.contains("repo") && e.contains("empty"))
845 );
846 }
847
848 #[test]
849 fn test_validate_blank_output_dir() {
850 let mut config = config_with_password();
851 config.deployment.output_dir = " ".to_string();
852
853 let result = config.validate();
854 assert!(!result.valid);
855 assert!(
856 result
857 .errors
858 .iter()
859 .any(|e| e.contains("output_dir") && e.contains("empty"))
860 );
861 }
862
863 #[test]
864 fn test_validate_zero_chunk_size() {
865 let mut config = config_with_password();
866 config.encryption.chunk_size = Some(0);
867
868 let result = config.validate();
869 assert!(!result.valid);
870 assert!(result.errors.iter().any(|e| e.contains("chunk_size")));
871 }
872
873 #[test]
874 fn test_validate_oversized_chunk_size() {
875 let mut config = config_with_password();
876 config.encryption.chunk_size = Some(crate::pages::encrypt::MAX_CHUNK_SIZE as u64 + 1);
877
878 let result = config.validate();
879 assert!(!result.valid);
880 assert!(result.errors.iter().any(|e| e.contains("chunk_size")));
881 }
882
883 #[test]
884 fn test_validate_rejects_unsupported_compression() {
885 let mut config = config_with_password();
886 config.encryption.compression = Some("gzip".to_string());
887
888 let result = config.validate();
889 assert!(!result.valid);
890 assert!(
891 result
892 .errors
893 .iter()
894 .any(|e| e.contains("compression") && e.contains("deflate"))
895 );
896 }
897
898 #[test]
899 fn test_validate_compression_trims_and_normalizes() {
900 let mut config = config_with_password();
901 config.encryption.compression = Some(" Deflate ".to_string());
902
903 let result = config.validate();
904 assert!(result.valid, "{:?}", result.errors);
905 let resolved = result.resolved.expect("resolved config should exist");
906 assert_eq!(resolved.encryption.compression, DEFAULT_COMPRESSION);
907 }
908
909 #[test]
910 fn test_env_var_resolution() {
911 unsafe { std::env::set_var("TEST_PASSWORD_VAR", "secret123") };
913 let mut config = PagesConfig::default();
914 config.encryption.password = Some("env:TEST_PASSWORD_VAR".to_string());
915 config.resolve_env_vars().unwrap();
916 assert_eq!(config.encryption.password, Some("secret123".to_string()));
917 unsafe { std::env::remove_var("TEST_PASSWORD_VAR") };
919 }
920
921 #[test]
922 fn test_env_var_resolution_deployment_credentials() {
923 unsafe {
925 std::env::set_var("TEST_CF_ACCOUNT_ID", "acc123");
926 std::env::set_var("TEST_CF_API_TOKEN", "token456");
927 }
928
929 let mut config = PagesConfig::default();
930 config.deployment.account_id = Some("env:TEST_CF_ACCOUNT_ID".to_string());
931 config.deployment.api_token = Some("env:TEST_CF_API_TOKEN".to_string());
932 config.resolve_env_vars().unwrap();
933
934 assert_eq!(config.deployment.account_id, Some("acc123".to_string()));
935 assert_eq!(config.deployment.api_token, Some("token456".to_string()));
936
937 unsafe {
939 std::env::remove_var("TEST_CF_ACCOUNT_ID");
940 std::env::remove_var("TEST_CF_API_TOKEN");
941 }
942 }
943
944 #[test]
945 fn test_env_var_not_found() {
946 let mut config = PagesConfig::default();
947 config.encryption.password = Some("env:NONEXISTENT_VAR_12345".to_string());
948 let result = config.resolve_env_vars();
949 assert!(result.is_err());
950 }
951
952 #[test]
953 fn test_invalid_path_mode() {
954 let mut config = config_with_password();
955 config.filters.path_mode = Some("invalid".to_string());
956 let result = config.validate();
957 assert!(!result.valid);
958 assert!(result.errors.iter().any(|e| e.contains("path_mode")));
959 }
960
961 #[test]
962 fn test_invalid_deploy_target() {
963 let mut config = config_with_password();
964 config.deployment.target = "invalid".to_string();
965 let result = config.validate();
966 assert!(!result.valid);
967 assert!(result.errors.iter().any(|e| e.contains("target")));
968 }
969
970 #[test]
971 fn test_validate_partial_cloudflare_credentials() {
972 let mut config = config_with_password();
973 config.deployment.target = "cloudflare".to_string();
974 config.deployment.account_id = Some("acc-only".to_string());
975
976 let result = config.validate();
977 assert!(!result.valid);
978 assert!(
979 result
980 .errors
981 .iter()
982 .any(|e| e.contains("account_id") && e.contains("api_token"))
983 );
984 }
985
986 #[test]
987 fn test_validate_cloudflare_rejects_blank_credentials() {
988 let mut config = config_with_password();
989 config.deployment.target = "cloudflare".to_string();
990 config.deployment.account_id = Some(" ".to_string());
991 config.deployment.api_token = Some("token456".to_string());
992
993 let result = config.validate();
994 assert!(!result.valid);
995 assert!(
996 result
997 .errors
998 .iter()
999 .any(|e| e.contains("account_id") && e.contains("empty"))
1000 );
1001 }
1002
1003 #[test]
1004 fn test_path_mode_parsing() {
1005 let mut config = PagesConfig::default();
1006
1007 config.filters.path_mode = None;
1008 assert!(matches!(config.path_mode(), PathMode::Relative));
1009
1010 config.filters.path_mode = Some("basename".to_string());
1011 assert!(matches!(config.path_mode(), PathMode::Basename));
1012
1013 config.filters.path_mode = Some("full".to_string());
1014 assert!(matches!(config.path_mode(), PathMode::Full));
1015
1016 config.filters.path_mode = Some("hash".to_string());
1017 assert!(matches!(config.path_mode(), PathMode::Hash));
1018
1019 config.filters.path_mode = Some("Basename".to_string());
1021 assert!(matches!(config.path_mode(), PathMode::Basename));
1022
1023 config.filters.path_mode = Some(" FULL ".to_string());
1024 assert!(matches!(config.path_mode(), PathMode::Full));
1025
1026 config.filters.path_mode = Some(" ".to_string());
1027 assert!(matches!(config.path_mode(), PathMode::Relative));
1028 }
1029
1030 #[test]
1031 fn test_validate_path_mode_trims_whitespace() {
1032 let mut config = config_with_password();
1033 config.filters.path_mode = Some(" FULL ".to_string());
1034
1035 let result = config.validate();
1036 assert!(result.valid, "{:?}", result.errors);
1037
1038 let resolved = result.resolved.expect("resolved config should exist");
1039 assert_eq!(resolved.filters.path_mode, "full");
1040 }
1041
1042 #[test]
1043 fn test_resolved_config_applies_export_defaults() {
1044 let config = config_with_password();
1045
1046 let result = config.validate();
1047 assert!(result.valid, "{:?}", result.errors);
1048
1049 let resolved = result.resolved.expect("resolved config should exist");
1050 assert_eq!(resolved.filters.path_mode, DEFAULT_PATH_MODE);
1051 assert_eq!(resolved.encryption.compression, DEFAULT_COMPRESSION);
1052 assert_eq!(resolved.encryption.chunk_size, DEFAULT_CHUNK_SIZE);
1053 }
1054
1055 #[test]
1056 fn test_validate_target_trims_whitespace() {
1057 let mut config = config_with_password();
1058 config.deployment.target = " GitHub ".to_string();
1059 config.deployment.repo = Some("example-repo".to_string());
1060
1061 let result = config.validate();
1062 assert!(result.valid, "{:?}", result.errors);
1063
1064 let resolved = result.resolved.expect("resolved config should exist");
1065 assert_eq!(resolved.deployment.target, "github");
1066 }
1067
1068 #[test]
1069 fn test_to_wizard_state_target_trims_whitespace() {
1070 let mut config = config_with_password();
1071 config.deployment.target = " cloudflare ".to_string();
1072
1073 let state = config
1074 .to_wizard_state(PathBuf::from("/tmp/test.db"))
1075 .expect("wizard state should parse");
1076
1077 assert!(matches!(state.target, DeployTarget::CloudflarePages));
1078 }
1079
1080 #[test]
1081 fn test_resolved_time_range_priority() {
1082 let mut config = PagesConfig::default();
1083
1084 assert_eq!(config.resolved_time_range(), None);
1085
1086 config.filters.since = Some("30 days ago".to_string());
1087 assert_eq!(
1088 config.resolved_time_range(),
1089 Some("since 30 days ago".to_string())
1090 );
1091
1092 config.filters.until = Some("today".to_string());
1093 assert_eq!(
1094 config.resolved_time_range(),
1095 Some("30 days ago to today".to_string())
1096 );
1097
1098 config.filters.since = None;
1099 assert_eq!(
1100 config.resolved_time_range(),
1101 Some("until today".to_string())
1102 );
1103 }
1104}