Skip to main content

typub_config/
lib.rs

1//! Configuration types for typub.
2//!
3//! This crate provides the core configuration structures used by typub:
4//! - `Config` — main configuration from `typub.toml`
5//! - `PlatformConfig` — per-platform configuration
6//! - `StorageConfig` — S3-compatible storage configuration per [[RFC-0004]]
7//!
8//! Extracted per [[RFC-0007:C-SHARED-TYPES]] to enable adapter subcrates
9//! to depend on configuration without circular dependencies.
10
11pub mod project;
12
13use anyhow::{Context, Result};
14use serde::Deserialize;
15use sha2::{Digest, Sha256};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use url::Url;
19
20// Re-export ThemeId from typub-core
21pub use typub_core::ThemeId;
22
23/// Expand environment variables in a string using shell-like syntax.
24///
25/// Supports:
26/// - `$VAR` or `${VAR}` — substitute from environment
27/// - `${VAR:default}` — use default if VAR is unset
28///
29/// If expansion fails, returns the original string unchanged.
30fn expand_env_vars(s: &str) -> String {
31    subst::substitute(s, &subst::Env).unwrap_or_else(|_| s.to_string())
32}
33
34/// Main configuration structure
35#[derive(Debug, Clone, Deserialize)]
36pub struct Config {
37    #[serde(default = "default_content_dir")]
38    pub content_dir: PathBuf,
39    #[serde(default = "default_output_dir")]
40    pub output_dir: PathBuf,
41    /// Global storage configuration for External asset strategy
42    /// Per [[RFC-0004:C-STORAGE-CONFIG]]
43    #[serde(default)]
44    pub storage: Option<StorageConfig>,
45    /// Global default for published state (layer 4 per [[RFC-0005:C-RESOLUTION-ORDER]])
46    #[serde(default)]
47    pub published: Option<bool>,
48    /// Global default theme (layer 4 in theme resolution chain)
49    #[serde(default)]
50    pub theme: Option<ThemeId>,
51    /// Global default platform for internal link resolution in copypaste adapters.
52    /// Per-post `internal_link_target` in meta.toml overrides this.
53    /// If neither is set, auto-selects first published platform alphabetically.
54    #[serde(default)]
55    pub internal_link_target: Option<String>,
56    /// Global Typst render preamble override (layer 4 per [[RFC-0005:C-RESOLUTION-ORDER]]).
57    #[serde(default)]
58    pub preamble: Option<String>,
59    #[serde(default)]
60    pub platforms: HashMap<String, PlatformConfig>,
61}
62
63fn default_content_dir() -> PathBuf {
64    PathBuf::from("posts")
65}
66
67fn default_output_dir() -> PathBuf {
68    PathBuf::from("output")
69}
70
71#[derive(Debug, Clone, Deserialize)]
72pub struct PlatformConfig {
73    #[serde(default = "default_true")]
74    pub enabled: bool,
75    #[serde(default)]
76    pub asset_strategy: Option<String>,
77    /// Platform-specific published setting (layer 3 per [[RFC-0005:C-RESOLUTION-ORDER]])
78    #[serde(default)]
79    pub published: Option<bool>,
80    /// Platform-specific theme (layer 3 in theme resolution chain)
81    #[serde(default)]
82    pub theme: Option<ThemeId>,
83    /// Platform-specific internal link target (layer 3 in resolution chain)
84    #[serde(default)]
85    pub internal_link_target: Option<String>,
86    /// Math rendering strategy override (layer 3 per [[RFC-0005:C-RESOLUTION-ORDER]])
87    /// Per [[WI-2026-02-17-002]].
88    #[serde(default)]
89    pub math_rendering: Option<String>,
90    /// Math delimiter syntax override (layer 3 per [[RFC-0005:C-RESOLUTION-ORDER]])
91    /// Per [[WI-2026-02-17-002]].
92    #[serde(default)]
93    pub math_delimiters: Option<String>,
94    #[serde(flatten)]
95    pub extra: HashMap<String, toml::Value>,
96}
97
98fn default_true() -> bool {
99    true
100}
101
102/// Result of loading configuration.
103pub enum ConfigLoadResult {
104    /// Configuration loaded successfully.
105    Loaded(Config),
106    /// Configuration file not found, using defaults.
107    /// The String contains the path that was not found.
108    DefaultsUsed(Config, String),
109}
110
111impl Config {
112    /// Load configuration from a file.
113    ///
114    /// Returns `ConfigLoadResult::DefaultsUsed` if the file does not exist,
115    /// allowing the caller to decide how to handle the warning.
116    pub fn load(path: &Path) -> Result<ConfigLoadResult> {
117        if !path.exists() {
118            return Ok(ConfigLoadResult::DefaultsUsed(
119                Self::default(),
120                path.display().to_string(),
121            ));
122        }
123
124        let content = std::fs::read_to_string(path)
125            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
126
127        let config: Config = toml::from_str(&content)
128            .with_context(|| format!("Failed to parse config file: {}", path.display()))?;
129
130        Ok(ConfigLoadResult::Loaded(config))
131    }
132
133    /// Load configuration, returning defaults if file not found.
134    ///
135    /// This is a convenience method that discards the "defaults used" information.
136    /// Use `load()` if you need to know whether defaults were used.
137    pub fn load_or_default(path: &Path) -> Result<Config> {
138        match Self::load(path)? {
139            ConfigLoadResult::Loaded(config) => Ok(config),
140            ConfigLoadResult::DefaultsUsed(config, _) => Ok(config),
141        }
142    }
143
144    /// Get platform configuration by ID
145    pub fn get_platform(&self, id: &str) -> Option<&PlatformConfig> {
146        self.platforms.get(id)
147    }
148
149    /// Get platforms that should be targeted by default (present and not disabled).
150    pub fn default_platforms(&self) -> Vec<(&str, &PlatformConfig)> {
151        self.platforms
152            .iter()
153            .filter(|(_, c)| c.enabled)
154            .map(|(k, v)| (k.as_str(), v))
155            .collect()
156    }
157}
158
159impl Default for Config {
160    fn default() -> Self {
161        Self {
162            content_dir: default_content_dir(),
163            output_dir: default_output_dir(),
164            storage: None,
165            published: None,
166            theme: None,
167            internal_link_target: None,
168            preamble: None,
169            platforms: HashMap::new(),
170        }
171    }
172}
173
174impl PlatformConfig {
175    /// Get a string value from extra config, expanding environment variables.
176    ///
177    /// Supports shell-like variable substitution via the `subst` crate:
178    /// - `$VAR` or `${VAR}` — substitute from environment
179    /// - `${VAR:default}` — use default if VAR is unset
180    ///
181    /// If expansion fails (e.g., undefined variable without default),
182    /// returns the original unexpanded string.
183    pub fn get_str(&self, key: &str) -> Option<String> {
184        self.extra
185            .get(key)
186            .and_then(|v| v.as_str())
187            .map(expand_env_vars)
188    }
189
190    /// Get a raw string value without environment variable expansion.
191    pub fn get_str_raw(&self, key: &str) -> Option<&str> {
192        self.extra.get(key).and_then(|v| v.as_str())
193    }
194
195    /// Get an integer value from extra config
196    pub fn get_int(&self, key: &str) -> Option<i64> {
197        self.extra.get(key).and_then(|v| v.as_integer())
198    }
199
200    /// Get a boolean value from extra config
201    pub fn get_bool(&self, key: &str) -> Option<bool> {
202        self.extra.get(key).and_then(|v| v.as_bool())
203    }
204
205    /// Get platform-specific storage config override
206    pub fn get_storage(&self) -> Option<StorageConfig> {
207        self.extra.get("storage").and_then(|v| {
208            let table = v.as_table()?;
209            let toml_str = toml::to_string(table).ok()?;
210            toml::from_str(&toml_str).ok()
211        })
212    }
213}
214
215/// Storage configuration for external asset storage
216/// Per [[RFC-0004:C-STORAGE-CONFIG]]
217#[derive(Debug, Clone, Deserialize, Default)]
218pub struct StorageConfig {
219    /// Storage type (e.g., "s3")
220    #[serde(rename = "type", default)]
221    pub storage_type: Option<String>,
222    /// S3-compatible endpoint URL (optional)
223    #[serde(default)]
224    pub endpoint: Option<String>,
225    /// Bucket name
226    #[serde(default)]
227    pub bucket: Option<String>,
228    /// Region (e.g., "us-east-1", "auto" for R2)
229    #[serde(default)]
230    pub region: Option<String>,
231    /// URL prefix for constructing public asset URLs
232    #[serde(default)]
233    pub url_prefix: Option<String>,
234    /// Access key ID (or use S3_ACCESS_KEY_ID env var)
235    #[serde(default)]
236    pub access_key_id: Option<String>,
237    /// Secret access key (or use S3_SECRET_ACCESS_KEY env var)
238    #[serde(default)]
239    pub secret_access_key: Option<String>,
240}
241
242impl StorageConfig {
243    /// Resolve a field value using RFC-0004 precedence ladder:
244    /// 1. Platform-specific env var (e.g., HASHNODE_S3_BUCKET)
245    /// 2. Platform-specific config value
246    /// 3. Global env var (e.g., S3_BUCKET)
247    /// 4. Global config value
248    fn resolve_field(
249        platform_id: Option<&str>,
250        platform_value: Option<&str>,
251        global_value: Option<&str>,
252        env_suffix: &str,
253    ) -> Option<String> {
254        // 1. Platform-specific env var
255        if let Some(pid) = platform_id {
256            let env_key = format!("{}_{}", pid.to_uppercase(), env_suffix);
257            if let Ok(val) = std::env::var(&env_key)
258                && !val.is_empty()
259            {
260                return Some(val);
261            }
262        }
263
264        // 2. Platform-specific config value
265        if let Some(val) = platform_value
266            && !val.is_empty()
267        {
268            return Some(val.to_string());
269        }
270
271        // 3. Global env var
272        if let Ok(val) = std::env::var(env_suffix)
273            && !val.is_empty()
274        {
275            return Some(val);
276        }
277
278        // 4. Global config value
279        global_value
280            .filter(|v| !v.is_empty())
281            .map(|v| v.to_string())
282    }
283
284    /// Merge global and platform-specific config with env var precedence
285    /// Per [[RFC-0004:C-STORAGE-CONFIG]] precedence ladder
286    pub fn resolve(
287        global: Option<&StorageConfig>,
288        platform: Option<&StorageConfig>,
289        platform_id: &str,
290    ) -> StorageConfig {
291        let g = global.cloned().unwrap_or_default();
292        let p = platform.cloned().unwrap_or_default();
293
294        StorageConfig {
295            storage_type: Self::resolve_field(
296                Some(platform_id),
297                p.storage_type.as_deref(),
298                g.storage_type.as_deref(),
299                "S3_TYPE",
300            ),
301            endpoint: Self::resolve_field(
302                Some(platform_id),
303                p.endpoint.as_deref(),
304                g.endpoint.as_deref(),
305                "S3_ENDPOINT",
306            ),
307            bucket: Self::resolve_field(
308                Some(platform_id),
309                p.bucket.as_deref(),
310                g.bucket.as_deref(),
311                "S3_BUCKET",
312            ),
313            region: Self::resolve_field(
314                Some(platform_id),
315                p.region.as_deref(),
316                g.region.as_deref(),
317                "S3_REGION",
318            ),
319            url_prefix: Self::resolve_field(
320                Some(platform_id),
321                p.url_prefix.as_deref(),
322                g.url_prefix.as_deref(),
323                "S3_URL_PREFIX",
324            ),
325            access_key_id: Self::resolve_field(
326                Some(platform_id),
327                p.access_key_id.as_deref(),
328                g.access_key_id.as_deref(),
329                "S3_ACCESS_KEY_ID",
330            ),
331            secret_access_key: Self::resolve_field(
332                Some(platform_id),
333                p.secret_access_key.as_deref(),
334                g.secret_access_key.as_deref(),
335                "S3_SECRET_ACCESS_KEY",
336            ),
337        }
338    }
339
340    /// Validate required fields are present
341    /// Per [[RFC-0004:C-STORAGE-CONFIG]]
342    pub fn validate(&self) -> Result<()> {
343        if self.storage_type.is_none() {
344            anyhow::bail!(
345                "Storage configuration missing 'type' field. \
346                Set S3_TYPE env var or add type = \"s3\" to [storage] config."
347            );
348        }
349        if self.bucket.is_none() {
350            anyhow::bail!(
351                "Storage configuration missing 'bucket' field. \
352                Set S3_BUCKET env var or add bucket = \"your-bucket\" to [storage] config."
353            );
354        }
355        if self.url_prefix.is_none() {
356            anyhow::bail!(
357                "Storage configuration missing 'url_prefix' field. \
358                Set S3_URL_PREFIX env var or add url_prefix to [storage] config."
359            );
360        }
361        Ok(())
362    }
363
364    /// Compute storage configuration identifier (64-char SHA-256 hex)
365    /// Per [[RFC-0004:C-STORAGE-CONFIG]]
366    /// Excludes credentials, includes all other fields with normalization.
367    pub fn config_id(&self) -> String {
368        let storage_type = self
369            .storage_type
370            .as_deref()
371            .unwrap_or("")
372            .to_lowercase()
373            .trim()
374            .to_string();
375
376        let endpoint = self
377            .endpoint
378            .as_deref()
379            .map(Self::normalize_url)
380            .unwrap_or_default();
381
382        let bucket = self.bucket.as_deref().unwrap_or("").to_string();
383
384        let region = self
385            .region
386            .as_deref()
387            .unwrap_or("")
388            .to_lowercase()
389            .trim()
390            .to_string();
391
392        let url_prefix = self
393            .url_prefix
394            .as_deref()
395            .map(Self::normalize_url)
396            .unwrap_or_default();
397
398        let concatenated = format!(
399            "{}|{}|{}|{}|{}",
400            storage_type, endpoint, bucket, region, url_prefix
401        );
402
403        let mut hasher = Sha256::new();
404        hasher.update(concatenated.as_bytes());
405        let result = hasher.finalize();
406        hex::encode(result)
407    }
408
409    /// Normalize URL: lowercase scheme+host, preserve path case, remove trailing slash and default ports
410    fn normalize_url(url_str: &str) -> String {
411        let trimmed = url_str.trim();
412        if trimmed.is_empty() {
413            return String::new();
414        }
415
416        // Try to parse as URL
417        if let Ok(mut url) = Url::parse(trimmed) {
418            // Lowercase scheme (already done by Url)
419            // Lowercase host
420            if let Some(host) = url.host_str() {
421                let lower_host = host.to_lowercase();
422                // Reconstruct URL with lowercase host
423                let _ = url.set_host(Some(&lower_host));
424            }
425
426            // Remove default ports
427            if let Some(port) = url.port() {
428                let scheme = url.scheme();
429                if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
430                    let _ = url.set_port(None);
431                }
432            }
433
434            // Get the string and remove trailing slash from path
435            let mut result = url.to_string();
436            while result.ends_with('/') {
437                result.pop();
438            }
439            result
440        } else {
441            // Not a valid URL, just trim and remove trailing slashes
442            let mut result = trimmed.to_string();
443            while result.ends_with('/') {
444                result.pop();
445            }
446            result
447        }
448    }
449
450    /// Get normalized URL prefix (trailing slashes removed)
451    pub fn normalized_url_prefix(&self) -> Option<String> {
452        self.url_prefix.as_deref().map(Self::normalize_url)
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    #![allow(clippy::expect_used)]
459
460    use super::*;
461    use std::io::Write;
462
463    #[test]
464    fn test_default_config() {
465        let config = Config::default();
466        assert_eq!(config.content_dir, PathBuf::from("posts"));
467        assert_eq!(config.output_dir, PathBuf::from("output"));
468        assert!(config.platforms.is_empty());
469    }
470
471    #[test]
472    fn test_load_nonexistent_returns_default() {
473        let result =
474            Config::load(Path::new("/tmp/does-not-exist-config.toml")).expect("load config");
475        match result {
476            ConfigLoadResult::DefaultsUsed(config, path) => {
477                assert_eq!(config.content_dir, PathBuf::from("posts"));
478                assert!(path.contains("does-not-exist"));
479            }
480            ConfigLoadResult::Loaded(_) => panic!("expected DefaultsUsed"),
481        }
482    }
483
484    #[test]
485    fn test_load_full_config() {
486        let toml = r#"
487content_dir = "articles"
488output_dir = "build"
489
490[platforms.astro]
491enabled = true
492output_dir = "/var/www"
493
494[platforms.notion]
495enabled = false
496data_source_id = "ds-123"
497
498[platforms.confluence]
499enabled = true
500space_key = "TEAM"
501"#;
502        let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
503        tmp.write_all(toml.as_bytes()).expect("write config");
504
505        let result = Config::load(tmp.path()).expect("load config");
506        let config = match result {
507            ConfigLoadResult::Loaded(c) => c,
508            ConfigLoadResult::DefaultsUsed(_, _) => panic!("expected Loaded"),
509        };
510
511        assert_eq!(config.content_dir, PathBuf::from("articles"));
512        assert_eq!(config.output_dir, PathBuf::from("build"));
513        assert_eq!(config.platforms.len(), 3);
514    }
515
516    #[test]
517    fn test_load_global_preamble() {
518        let toml = r##"
519preamble = "#set text(fill: red)"
520"##;
521        let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
522        tmp.write_all(toml.as_bytes()).expect("write config");
523
524        let result = Config::load(tmp.path()).expect("load config");
525        let config = match result {
526            ConfigLoadResult::Loaded(c) => c,
527            ConfigLoadResult::DefaultsUsed(_, _) => panic!("expected Loaded"),
528        };
529
530        assert_eq!(config.preamble.as_deref(), Some("#set text(fill: red)"));
531    }
532
533    #[test]
534    fn test_load_invalid_toml() {
535        let mut tmp = tempfile::NamedTempFile::new().expect("create temp file");
536        tmp.write_all(b"this is not { valid toml")
537            .expect("write config");
538        assert!(Config::load(tmp.path()).is_err());
539    }
540
541    #[test]
542    fn test_get_platform() {
543        let config: Config = toml::from_str(
544            r#"
545[platforms.astro]
546output_dir = "/var/www"
547"#,
548        )
549        .expect("parse TOML");
550
551        assert!(config.get_platform("astro").is_some());
552        assert!(config.get_platform("notion").is_none());
553    }
554
555    #[test]
556    fn test_default_platforms() {
557        let config: Config = toml::from_str(
558            r#"
559[platforms.astro]
560enabled = true
561[platforms.notion]
562enabled = false
563[platforms.wechat]
564"#,
565        )
566        .expect("parse TOML");
567
568        let defaults = config.default_platforms();
569        let names: Vec<&str> = defaults.iter().map(|(k, _)| *k).collect();
570        assert!(names.contains(&"astro"));
571        assert!(names.contains(&"wechat")); // default is true
572        assert!(!names.contains(&"notion"));
573    }
574
575    #[test]
576    fn test_platform_config_accessors() {
577        let config: Config = toml::from_str(
578            r#"
579[platforms.test]
580output_dir = "/tmp/out"
581max_retries = 3
582dry_run = true
583"#,
584        )
585        .expect("parse TOML");
586
587        let pc = config.get_platform("test").expect("platform should exist");
588        assert_eq!(pc.get_str("output_dir"), Some("/tmp/out".to_string()));
589        assert_eq!(pc.get_int("max_retries"), Some(3));
590        assert_eq!(pc.get_bool("dry_run"), Some(true));
591
592        // Missing keys
593        assert_eq!(pc.get_str("nonexistent"), None);
594        assert_eq!(pc.get_int("output_dir"), None); // wrong type
595        assert_eq!(pc.get_bool("output_dir"), None); // wrong type
596    }
597
598    #[test]
599    fn test_minimal_config_uses_defaults() {
600        let config: Config = toml::from_str("").expect("parse TOML");
601        assert_eq!(config.content_dir, PathBuf::from("posts"));
602        assert_eq!(config.output_dir, PathBuf::from("output"));
603        assert!(config.platforms.is_empty());
604    }
605
606    // --- Environment variable substitution tests ---
607
608    #[test]
609    fn test_get_str_expands_env_var() {
610        // SAFETY: Test runs in single thread; no concurrent access to this env var
611        unsafe {
612            std::env::set_var("TYPUB_TEST_API_KEY", "secret123");
613        }
614        let config: Config = toml::from_str(
615            r#"
616[platforms.test]
617api_key = "$TYPUB_TEST_API_KEY"
618"#,
619        )
620        .expect("parse TOML");
621
622        let pc = config.get_platform("test").expect("platform should exist");
623        assert_eq!(pc.get_str("api_key"), Some("secret123".to_string()));
624        // SAFETY: Test cleanup
625        unsafe {
626            std::env::remove_var("TYPUB_TEST_API_KEY");
627        }
628    }
629
630    #[test]
631    fn test_get_str_expands_env_var_long_format() {
632        // SAFETY: Test runs in single thread; no concurrent access to this env var
633        unsafe {
634            std::env::set_var("TYPUB_TEST_TOKEN", "token456");
635        }
636        let config: Config = toml::from_str(
637            r#"
638[platforms.test]
639token = "${TYPUB_TEST_TOKEN}"
640"#,
641        )
642        .expect("parse TOML");
643
644        let pc = config.get_platform("test").expect("platform should exist");
645        assert_eq!(pc.get_str("token"), Some("token456".to_string()));
646        // SAFETY: Test cleanup
647        unsafe {
648            std::env::remove_var("TYPUB_TEST_TOKEN");
649        }
650    }
651
652    #[test]
653    fn test_get_str_expands_env_var_with_default() {
654        // Ensure the var is NOT set
655        // SAFETY: Test runs in single thread; no concurrent access to this env var
656        unsafe {
657            std::env::remove_var("TYPUB_UNDEFINED_VAR");
658        }
659        let config: Config = toml::from_str(
660            r#"
661[platforms.test]
662value = "${TYPUB_UNDEFINED_VAR:fallback_value}"
663"#,
664        )
665        .expect("parse TOML");
666
667        let pc = config.get_platform("test").expect("platform should exist");
668        assert_eq!(pc.get_str("value"), Some("fallback_value".to_string()));
669    }
670
671    #[test]
672    fn test_get_str_returns_original_on_undefined_var() {
673        // When a var is undefined and no default is provided, subst returns an error
674        // and we fall back to the original string
675        // SAFETY: Test runs in single thread; no concurrent access to this env var
676        unsafe {
677            std::env::remove_var("TYPUB_NONEXISTENT_VAR");
678        }
679        let config: Config = toml::from_str(
680            r#"
681[platforms.test]
682value = "${TYPUB_NONEXISTENT_VAR}"
683"#,
684        )
685        .expect("parse TOML");
686
687        let pc = config.get_platform("test").expect("platform should exist");
688        // Should return original unexpanded string on error
689        assert_eq!(
690            pc.get_str("value"),
691            Some("${TYPUB_NONEXISTENT_VAR}".to_string())
692        );
693    }
694
695    #[test]
696    fn test_get_str_no_expansion_for_plain_string() {
697        let config: Config = toml::from_str(
698            r#"
699[platforms.test]
700plain = "no variables here"
701"#,
702        )
703        .expect("parse TOML");
704
705        let pc = config.get_platform("test").expect("platform should exist");
706        assert_eq!(pc.get_str("plain"), Some("no variables here".to_string()));
707    }
708
709    #[test]
710    fn test_get_str_raw_does_not_expand() {
711        // SAFETY: Test runs in single thread; no concurrent access to this env var
712        unsafe {
713            std::env::set_var("TYPUB_TEST_RAW", "expanded");
714        }
715        let config: Config = toml::from_str(
716            r#"
717[platforms.test]
718raw_value = "$TYPUB_TEST_RAW"
719"#,
720        )
721        .expect("parse TOML");
722
723        let pc = config.get_platform("test").expect("platform should exist");
724        // get_str_raw should NOT expand
725        assert_eq!(pc.get_str_raw("raw_value"), Some("$TYPUB_TEST_RAW"));
726        // get_str SHOULD expand
727        assert_eq!(pc.get_str("raw_value"), Some("expanded".to_string()));
728        // SAFETY: Test cleanup
729        unsafe {
730            std::env::remove_var("TYPUB_TEST_RAW");
731        }
732    }
733
734    // --- StorageConfig tests per [[RFC-0004]] ---
735
736    #[test]
737    fn test_storage_config_parse() {
738        let config: Config = toml::from_str(
739            r#"
740[storage]
741type = "s3"
742endpoint = "https://xxx.r2.cloudflarestorage.com"
743bucket = "my-assets"
744region = "auto"
745url_prefix = "https://cdn.example.com/assets"
746"#,
747        )
748        .expect("parse TOML");
749
750        let storage = config.storage.expect("storage should be present");
751        assert_eq!(storage.storage_type, Some("s3".to_string()));
752        assert_eq!(
753            storage.endpoint,
754            Some("https://xxx.r2.cloudflarestorage.com".to_string())
755        );
756        assert_eq!(storage.bucket, Some("my-assets".to_string()));
757        assert_eq!(storage.region, Some("auto".to_string()));
758        assert_eq!(
759            storage.url_prefix,
760            Some("https://cdn.example.com/assets".to_string())
761        );
762    }
763
764    #[test]
765    fn test_storage_config_id_deterministic() {
766        let config1 = StorageConfig {
767            storage_type: Some("s3".to_string()),
768            endpoint: Some("https://xxx.r2.cloudflarestorage.com".to_string()),
769            bucket: Some("my-assets".to_string()),
770            region: Some("auto".to_string()),
771            url_prefix: Some("https://cdn.example.com/assets".to_string()),
772            access_key_id: Some("key1".to_string()),
773            secret_access_key: Some("secret1".to_string()),
774        };
775        let config2 = StorageConfig {
776            storage_type: Some("s3".to_string()),
777            endpoint: Some("https://xxx.r2.cloudflarestorage.com".to_string()),
778            bucket: Some("my-assets".to_string()),
779            region: Some("auto".to_string()),
780            url_prefix: Some("https://cdn.example.com/assets".to_string()),
781            access_key_id: Some("different_key".to_string()),
782            secret_access_key: Some("different_secret".to_string()),
783        };
784
785        // Config ID should be identical since credentials are excluded
786        assert_eq!(config1.config_id(), config2.config_id());
787        assert_eq!(config1.config_id().len(), 64); // SHA-256 = 64 hex chars
788    }
789
790    #[test]
791    fn test_storage_config_id_differs_by_bucket() {
792        let config1 = StorageConfig {
793            storage_type: Some("s3".to_string()),
794            bucket: Some("bucket-a".to_string()),
795            ..Default::default()
796        };
797        let config2 = StorageConfig {
798            storage_type: Some("s3".to_string()),
799            bucket: Some("bucket-b".to_string()),
800            ..Default::default()
801        };
802
803        assert_ne!(config1.config_id(), config2.config_id());
804    }
805
806    #[test]
807    fn test_storage_config_normalize_url_trailing_slash() {
808        let config = StorageConfig {
809            url_prefix: Some("https://cdn.example.com/assets/".to_string()),
810            ..Default::default()
811        };
812        let normalized = config.normalized_url_prefix().expect("prefix");
813        assert!(!normalized.ends_with('/'));
814        assert_eq!(normalized, "https://cdn.example.com/assets");
815    }
816
817    #[test]
818    fn test_storage_config_normalize_url_lowercase_host() {
819        let config = StorageConfig {
820            endpoint: Some("https://S3.US-EAST-1.AMAZONAWS.COM".to_string()),
821            ..Default::default()
822        };
823        let _id = config.config_id();
824        // The ID should include the normalized URL, different case should produce same ID
825        let config2 = StorageConfig {
826            endpoint: Some("https://s3.us-east-1.amazonaws.com".to_string()),
827            ..Default::default()
828        };
829        assert_eq!(config.config_id(), config2.config_id());
830    }
831
832    #[test]
833    fn test_storage_config_validate_missing_type() {
834        let config = StorageConfig {
835            bucket: Some("my-bucket".to_string()),
836            url_prefix: Some("https://cdn.example.com".to_string()),
837            ..Default::default()
838        };
839        let err = config.validate().expect_err("should fail");
840        assert!(err.to_string().contains("type"));
841    }
842
843    #[test]
844    fn test_storage_config_validate_missing_bucket() {
845        let config = StorageConfig {
846            storage_type: Some("s3".to_string()),
847            url_prefix: Some("https://cdn.example.com".to_string()),
848            ..Default::default()
849        };
850        let err = config.validate().expect_err("should fail");
851        assert!(err.to_string().contains("bucket"));
852    }
853
854    #[test]
855    fn test_storage_config_validate_missing_url_prefix() {
856        let config = StorageConfig {
857            storage_type: Some("s3".to_string()),
858            bucket: Some("my-bucket".to_string()),
859            ..Default::default()
860        };
861        let err = config.validate().expect_err("should fail");
862        assert!(err.to_string().contains("url_prefix"));
863    }
864
865    #[test]
866    fn test_storage_config_validate_ok() {
867        let config = StorageConfig {
868            storage_type: Some("s3".to_string()),
869            bucket: Some("my-bucket".to_string()),
870            url_prefix: Some("https://cdn.example.com".to_string()),
871            ..Default::default()
872        };
873        config.validate().expect("should pass");
874    }
875}
876
877// ============================================================================
878// Config Resolution Functions
879// ============================================================================
880
881/// Resolve `published` using RFC-0005:C-RESOLUTION-ORDER 5-level chain:
882/// 1. meta.toml[platforms.X].published — per-content platform-specific
883/// 2. meta.toml.published — per-content default
884/// 3. typub.toml[platforms.X].published — global platform-specific
885/// 4. typub.toml.published — global default
886/// 5. Adapter default (true)
887///
888/// Implements [[RFC-0005:C-RESOLUTION-ORDER]].
889pub fn resolve_published(
890    content_meta: &typub_core::ContentMeta,
891    platform_id: &str,
892    global_config: &Config,
893) -> bool {
894    // Layer 1: per-content platform-specific
895    content_meta
896        .platforms
897        .get(platform_id)
898        .and_then(|p| p.published)
899        // Layer 2: per-content default
900        .or(content_meta.published)
901        // Layer 3: global platform-specific
902        .or(global_config
903            .platforms
904            .get(platform_id)
905            .and_then(|p| p.published))
906        // Layer 4: global default
907        .or(global_config.published)
908        // Layer 5: adapter default
909        .unwrap_or(true)
910}
911
912#[cfg(test)]
913mod resolution_tests {
914    #![allow(clippy::expect_used)]
915
916    use super::*;
917    use typub_core::{ContentMeta, PostPlatformConfig};
918
919    fn make_content_meta(published: Option<bool>, platform_published: Option<bool>) -> ContentMeta {
920        let mut platforms = std::collections::HashMap::new();
921        if platform_published.is_some() {
922            platforms.insert(
923                "hashnode".to_string(),
924                PostPlatformConfig {
925                    published: platform_published,
926                    internal_link_target: None,
927                    extra: std::collections::HashMap::new(),
928                },
929            );
930        }
931        ContentMeta {
932            title: "Test".to_string(),
933            created: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"),
934            updated: None,
935            tags: vec![],
936            categories: vec![],
937            published,
938            theme: None,
939            internal_link_target: None,
940            preamble: None,
941            platforms,
942        }
943    }
944
945    fn make_global_config(published: Option<bool>, platform_published: Option<bool>) -> Config {
946        let mut platforms = std::collections::HashMap::new();
947        if platform_published.is_some() {
948            platforms.insert(
949                "hashnode".to_string(),
950                PlatformConfig {
951                    enabled: true,
952                    asset_strategy: None,
953                    published: platform_published,
954                    theme: None,
955                    internal_link_target: None,
956                    math_rendering: None,
957                    math_delimiters: None,
958                    extra: std::collections::HashMap::new(),
959                },
960            );
961        }
962        Config {
963            content_dir: std::path::PathBuf::from("posts"),
964            output_dir: std::path::PathBuf::from("output"),
965            storage: None,
966            published,
967            theme: None,
968            internal_link_target: None,
969            preamble: None,
970            platforms,
971        }
972    }
973
974    #[test]
975    fn test_resolve_published_layer_1_per_content_platform_specific() {
976        let meta = make_content_meta(Some(true), Some(false));
977        let config = make_global_config(Some(true), Some(true));
978        assert!(!resolve_published(&meta, "hashnode", &config));
979    }
980
981    #[test]
982    fn test_resolve_published_layer_2_per_content_default() {
983        let meta = make_content_meta(Some(false), None);
984        let config = make_global_config(Some(true), Some(true));
985        assert!(!resolve_published(&meta, "hashnode", &config));
986    }
987
988    #[test]
989    fn test_resolve_published_layer_3_global_platform_specific() {
990        let meta = make_content_meta(None, None);
991        let config = make_global_config(Some(true), Some(false));
992        assert!(!resolve_published(&meta, "hashnode", &config));
993    }
994
995    #[test]
996    fn test_resolve_published_layer_4_global_default() {
997        let meta = make_content_meta(None, None);
998        let config = make_global_config(Some(false), None);
999        assert!(!resolve_published(&meta, "hashnode", &config));
1000    }
1001
1002    #[test]
1003    fn test_resolve_published_layer_5_adapter_default() {
1004        let meta = make_content_meta(None, None);
1005        let config = make_global_config(None, None);
1006        assert!(resolve_published(&meta, "hashnode", &config));
1007    }
1008
1009    #[test]
1010    fn test_resolve_published_different_platform_uses_correct_layer() {
1011        let mut meta = make_content_meta(None, None);
1012        meta.published = Some(false);
1013        let config = make_global_config(Some(true), Some(true));
1014        assert!(!resolve_published(&meta, "hashnode", &config));
1015        assert!(!resolve_published(&meta, "devto", &config));
1016    }
1017}