Skip to main content

coding_agent_search/pages/
config_input.rs

1//! Non-interactive configuration input for `cass pages` command.
2//!
3//! This module provides a JSON-based configuration schema for running the pages
4//! export workflow in robot/CI mode without interactive wizard prompts.
5//!
6//! # Example Configuration
7//!
8//! ```json
9//! {
10//!   "filters": {
11//!     "agents": ["claude-code", "codex"],
12//!     "since": "30 days ago",
13//!     "until": "2025-01-06",
14//!     "workspaces": ["/path/one", "/path/two"],
15//!     "path_mode": "relative"
16//!   },
17//!   "encryption": {
18//!     "password": "env:EXPORT_PASSWORD",
19//!     "generate_recovery": true,
20//!     "generate_qr": true,
21//!     "compression": "deflate",
22//!     "chunk_size": 8388608
23//!   },
24//!   "bundle": {
25//!     "title": "Team Archive",
26//!     "description": "Encrypted cass export",
27//!     "hide_metadata": false
28//!   },
29//!   "deployment": {
30//!     "target": "local",
31//!     "output_dir": "./dist",
32//!     "repo": "my-archive",
33//!     "branch": "gh-pages",
34//!     "account_id": "env:CLOUDFLARE_ACCOUNT_ID",
35//!     "api_token": "env:CLOUDFLARE_API_TOKEN"
36//!   }
37//! }
38//! ```
39
40use 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/// Errors that can occur when loading or validating pages configuration.
50#[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/// Configuration result for JSON output.
69#[derive(Debug, Serialize)]
70pub struct ConfigValidationResult {
71    /// Whether the configuration is valid.
72    pub valid: bool,
73    /// Validation errors, if any.
74    #[serde(skip_serializing_if = "Vec::is_empty")]
75    pub errors: Vec<String>,
76    /// Warnings that don't prevent export but should be reviewed.
77    #[serde(skip_serializing_if = "Vec::is_empty")]
78    pub warnings: Vec<String>,
79    /// Resolved configuration (with env vars expanded).
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub resolved: Option<ResolvedConfig>,
82}
83
84/// Resolved configuration with env vars expanded and defaults applied.
85#[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/// Resolved filter configuration.
94#[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/// Resolved encryption configuration.
104#[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/// Resolved bundle configuration.
115#[derive(Debug, Serialize)]
116pub struct ResolvedBundle {
117    pub title: String,
118    pub description: String,
119    pub hide_metadata: bool,
120}
121
122/// Resolved deployment configuration.
123#[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/// Root pages configuration.
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
135pub struct PagesConfig {
136    /// Filter configuration for content selection.
137    #[serde(default)]
138    pub filters: FilterConfig,
139
140    /// Encryption and security configuration.
141    #[serde(default)]
142    pub encryption: EncryptionConfig,
143
144    /// Bundle/site configuration.
145    #[serde(default)]
146    pub bundle: BundleConfig,
147
148    /// Deployment configuration.
149    #[serde(default)]
150    pub deployment: DeploymentConfig,
151}
152
153/// Filter configuration for content selection.
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct FilterConfig {
156    /// Filter by agent slugs (e.g., "claude-code", "codex").
157    #[serde(default)]
158    pub agents: Vec<String>,
159
160    /// Filter entries since this time (ISO date or relative like "30 days ago").
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub since: Option<String>,
163
164    /// Filter entries until this time (ISO date or relative).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub until: Option<String>,
167
168    /// Filter by workspace paths.
169    #[serde(default)]
170    pub workspaces: Vec<String>,
171
172    /// Path mode: relative (default), basename, full, hash.
173    #[serde(default)]
174    pub path_mode: Option<String>,
175}
176
177/// Encryption and security configuration.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct EncryptionConfig {
180    /// Password for encryption. Supports "env:VAR_NAME" syntax for env var resolution.
181    /// If None and no_encryption is false, will error.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub password: Option<String>,
184
185    /// Disable encryption entirely (DANGEROUS).
186    /// Requires explicit acknowledgment via `i_understand_risks: true`.
187    #[serde(default)]
188    pub no_encryption: bool,
189
190    /// Required acknowledgment for no_encryption mode.
191    #[serde(default)]
192    pub i_understand_risks: bool,
193
194    /// Generate recovery secret for password recovery.
195    #[serde(default = "default_true")]
196    pub generate_recovery: bool,
197
198    /// Generate QR code for password.
199    #[serde(default)]
200    pub generate_qr: bool,
201
202    /// Compression algorithm for encrypted payload chunks.
203    ///
204    /// The current encryption format supports deflate only.
205    #[serde(default)]
206    pub compression: Option<String>,
207
208    /// Chunk size for encryption in bytes. Default: 8MB.
209    #[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/// Bundle/site configuration.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct BundleConfig {
230    /// Site title.
231    #[serde(default = "default_title")]
232    pub title: String,
233
234    /// Site description.
235    #[serde(default = "default_description")]
236    pub description: String,
237
238    /// Hide workspace/agent metadata in UI.
239    #[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/// Deployment configuration.
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct DeploymentConfig {
256    /// Deployment target: local (default), github, cloudflare.
257    #[serde(default = "default_target")]
258    pub target: String,
259
260    /// Output directory for local exports.
261    #[serde(default = "default_output_dir")]
262    pub output_dir: String,
263
264    /// Repository/project name for GitHub or Cloudflare Pages deployment.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub repo: Option<String>,
267
268    /// Branch for GitHub Pages deployment (default: gh-pages).
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub branch: Option<String>,
271
272    /// Cloudflare account ID (for API-token auth).
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub account_id: Option<String>,
275
276    /// Cloudflare API token (for API-token auth).
277    #[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
294// Default value functions
295fn 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    /// Load configuration from a file path.
366    ///
367    /// If path is "-", reads from stdin.
368    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    /// Load configuration from a reader.
382    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    /// Resolve environment variables in configuration values.
388    ///
389    /// Values starting with "env:" are resolved to the corresponding
390    /// environment variable value.
391    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        // Resolve env vars in output_dir if prefixed
399        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    /// Validate the configuration and return any errors/warnings.
419    pub fn validate(&self) -> ConfigValidationResult {
420        let mut errors = Vec::new();
421        let mut warnings = Vec::new();
422
423        // Validate encryption config
424        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        // Validate path_mode if specified
442        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        // Validate deployment target
455        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        // Validate GitHub deployment config
467        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        // Validate time formats
492        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        // Warnings
530        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    /// Convert to resolved config (with defaults applied).
579    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    /// Convert to WizardState for execution.
613    pub fn to_wizard_state(&self, db_path: PathBuf) -> Result<WizardState, ConfigError> {
614        // Parse deploy target
615        let target = match self.normalized_target().as_str() {
616            "github" => DeployTarget::GitHubPages,
617            "cloudflare" => DeployTarget::CloudflarePages,
618            _ => DeployTarget::Local,
619        };
620
621        // Convert workspaces
622        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    /// Parse path mode from config.
659    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    /// Get since timestamp.
669    pub fn since_ts(&self) -> Option<i64> {
670        self.filters.since.as_deref().and_then(parse_time_input)
671    }
672
673    /// Get until timestamp.
674    pub fn until_ts(&self) -> Option<i64> {
675        self.filters.until.as_deref().and_then(parse_time_input)
676    }
677}
678
679/// Generate example configuration JSON.
680pub 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    // Tests for `include_attachments` config field removed: the flag was
742    // accepted but unimplemented and has been removed from the pages
743    // config surface (bead adyyt). Any future attachment-bundling work
744    // will re-add the field with end-to-end implementation + fresh tests.
745
746    #[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        // SAFETY: This test runs in isolation and the env var is cleaned up after use
836        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        // SAFETY: Cleanup of test env var
842        unsafe { std::env::remove_var("TEST_PASSWORD_VAR") };
843    }
844
845    #[test]
846    fn test_env_var_resolution_deployment_credentials() {
847        // SAFETY: This test runs in isolation and the env vars are cleaned up after use
848        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        // SAFETY: Cleanup of test env vars
862        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        // Parsing should be case-insensitive and whitespace-tolerant, matching validation.
927        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}