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
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    /// Load configuration from a file path.
378    ///
379    /// If path is "-", reads from stdin.
380    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    /// Load configuration from a reader.
394    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    /// Resolve environment variables in configuration values.
400    ///
401    /// Values starting with "env:" are resolved to the corresponding
402    /// environment variable value.
403    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        // Resolve env vars in output_dir if prefixed
411        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    /// Validate the configuration and return any errors/warnings.
431    pub fn validate(&self) -> ConfigValidationResult {
432        let mut errors = Vec::new();
433        let mut warnings = Vec::new();
434
435        // Validate encryption config
436        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        // Validate path_mode if specified
460        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        // Validate deployment target
473        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        // Validate GitHub deployment config
488        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        // Validate time formats
522        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        // Warnings
560        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    /// Convert to resolved config (with defaults applied).
609    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    /// Convert to WizardState for execution.
643    pub fn to_wizard_state(&self, db_path: PathBuf) -> Result<WizardState, ConfigError> {
644        // Parse deploy target
645        let target = match self.normalized_target().as_str() {
646            "github" => DeployTarget::GitHubPages,
647            "cloudflare" => DeployTarget::CloudflarePages,
648            _ => DeployTarget::Local,
649        };
650
651        // Convert workspaces
652        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    /// Parse path mode from config.
689    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    /// Get since timestamp.
699    pub fn since_ts(&self) -> Option<i64> {
700        self.filters.since.as_deref().and_then(parse_time_input)
701    }
702
703    /// Get until timestamp.
704    pub fn until_ts(&self) -> Option<i64> {
705        self.filters.until.as_deref().and_then(parse_time_input)
706    }
707}
708
709/// Generate example configuration JSON.
710pub 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    // Tests for `include_attachments` config field removed: the flag was
772    // accepted but unimplemented and has been removed from the pages
773    // config surface (bead adyyt). Any future attachment-bundling work
774    // will re-add the field with end-to-end implementation + fresh tests.
775
776    #[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        // SAFETY: This test runs in isolation and the env var is cleaned up after use
912        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        // SAFETY: Cleanup of test env var
918        unsafe { std::env::remove_var("TEST_PASSWORD_VAR") };
919    }
920
921    #[test]
922    fn test_env_var_resolution_deployment_credentials() {
923        // SAFETY: This test runs in isolation and the env vars are cleaned up after use
924        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        // SAFETY: Cleanup of test env vars
938        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        // Parsing should be case-insensitive and whitespace-tolerant, matching validation.
1020        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}