1pub 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
20pub use typub_core::ThemeId;
22
23fn expand_env_vars(s: &str) -> String {
31 subst::substitute(s, &subst::Env).unwrap_or_else(|_| s.to_string())
32}
33
34#[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 #[serde(default)]
44 pub storage: Option<StorageConfig>,
45 #[serde(default)]
47 pub published: Option<bool>,
48 #[serde(default)]
50 pub theme: Option<ThemeId>,
51 #[serde(default)]
55 pub internal_link_target: Option<String>,
56 #[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 #[serde(default)]
79 pub published: Option<bool>,
80 #[serde(default)]
82 pub theme: Option<ThemeId>,
83 #[serde(default)]
85 pub internal_link_target: Option<String>,
86 #[serde(default)]
89 pub math_rendering: Option<String>,
90 #[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
102pub enum ConfigLoadResult {
104 Loaded(Config),
106 DefaultsUsed(Config, String),
109}
110
111impl Config {
112 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 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 pub fn get_platform(&self, id: &str) -> Option<&PlatformConfig> {
146 self.platforms.get(id)
147 }
148
149 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 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 pub fn get_str_raw(&self, key: &str) -> Option<&str> {
192 self.extra.get(key).and_then(|v| v.as_str())
193 }
194
195 pub fn get_int(&self, key: &str) -> Option<i64> {
197 self.extra.get(key).and_then(|v| v.as_integer())
198 }
199
200 pub fn get_bool(&self, key: &str) -> Option<bool> {
202 self.extra.get(key).and_then(|v| v.as_bool())
203 }
204
205 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#[derive(Debug, Clone, Deserialize, Default)]
218pub struct StorageConfig {
219 #[serde(rename = "type", default)]
221 pub storage_type: Option<String>,
222 #[serde(default)]
224 pub endpoint: Option<String>,
225 #[serde(default)]
227 pub bucket: Option<String>,
228 #[serde(default)]
230 pub region: Option<String>,
231 #[serde(default)]
233 pub url_prefix: Option<String>,
234 #[serde(default)]
236 pub access_key_id: Option<String>,
237 #[serde(default)]
239 pub secret_access_key: Option<String>,
240}
241
242impl StorageConfig {
243 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 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 if let Some(val) = platform_value
266 && !val.is_empty()
267 {
268 return Some(val.to_string());
269 }
270
271 if let Ok(val) = std::env::var(env_suffix)
273 && !val.is_empty()
274 {
275 return Some(val);
276 }
277
278 global_value
280 .filter(|v| !v.is_empty())
281 .map(|v| v.to_string())
282 }
283
284 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 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 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 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 if let Ok(mut url) = Url::parse(trimmed) {
418 if let Some(host) = url.host_str() {
421 let lower_host = host.to_lowercase();
422 let _ = url.set_host(Some(&lower_host));
424 }
425
426 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 let mut result = url.to_string();
436 while result.ends_with('/') {
437 result.pop();
438 }
439 result
440 } else {
441 let mut result = trimmed.to_string();
443 while result.ends_with('/') {
444 result.pop();
445 }
446 result
447 }
448 }
449
450 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")); 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 assert_eq!(pc.get_str("nonexistent"), None);
594 assert_eq!(pc.get_int("output_dir"), None); assert_eq!(pc.get_bool("output_dir"), None); }
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 #[test]
609 fn test_get_str_expands_env_var() {
610 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 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 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 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 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 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 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 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 assert_eq!(pc.get_str_raw("raw_value"), Some("$TYPUB_TEST_RAW"));
726 assert_eq!(pc.get_str("raw_value"), Some("expanded".to_string()));
728 unsafe {
730 std::env::remove_var("TYPUB_TEST_RAW");
731 }
732 }
733
734 #[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 assert_eq!(config1.config_id(), config2.config_id());
787 assert_eq!(config1.config_id().len(), 64); }
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 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
877pub fn resolve_published(
890 content_meta: &typub_core::ContentMeta,
891 platform_id: &str,
892 global_config: &Config,
893) -> bool {
894 content_meta
896 .platforms
897 .get(platform_id)
898 .and_then(|p| p.published)
899 .or(content_meta.published)
901 .or(global_config
903 .platforms
904 .get(platform_id)
905 .and_then(|p| p.published))
906 .or(global_config.published)
908 .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}