1use crate::assets::AssetStrategy;
14use crate::content::Content;
15use anyhow::Result;
16use typub_adapters_core::ResolvedConfigDefaults;
17use typub_config::{Config, PlatformConfig, StorageConfig};
18use typub_core::{NodePolicyAction, ThemeId};
19
20#[derive(Debug, Clone)]
29pub struct ResolvedConfig {
30 pub published: bool,
32 pub theme_id: Option<ThemeId>,
34 pub internal_link_target: Option<String>,
36 pub asset_strategy: AssetStrategy,
38 pub render_preamble: Option<String>,
40 pub storage: Option<StorageConfig>,
42 pub node_policy_override: Option<NodePolicyOverride>,
44}
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
48pub struct NodePolicyOverride {
49 pub raw: Option<NodePolicyAction>,
50 pub unknown: Option<NodePolicyAction>,
51}
52
53impl ResolvedConfig {
54 pub fn resolve(
63 content: &Content,
64 platform: &str,
65 config: &Config,
66 defaults: ResolvedConfigDefaults,
67 ) -> Result<Self> {
68 Ok(Self {
69 published: Self::resolve_published(content, platform, config, defaults.published),
70 theme_id: Self::resolve_theme_id(content, platform, config, defaults.theme),
71 internal_link_target: Self::resolve_internal_link_target(content, platform, config),
72 asset_strategy: Self::resolve_asset_strategy(
73 content,
74 platform,
75 config,
76 defaults.asset_strategy,
77 )?,
78 render_preamble: Self::resolve_render_preamble(content, platform, config),
79 storage: Self::resolve_storage(platform, config),
80 node_policy_override: Self::resolve_node_policy_override(content, platform, config)?,
81 })
82 }
83
84 pub fn resolve_internal_link_target_for(
85 content: &Content,
86 platform: &str,
87 config: &Config,
88 ) -> Option<String> {
89 Self::resolve_internal_link_target(content, platform, config)
90 }
91
92 pub fn resolve_asset_strategy_for(
93 content: &Content,
94 platform: &str,
95 config: &Config,
96 default: AssetStrategy,
97 ) -> Result<AssetStrategy> {
98 Self::resolve_asset_strategy(content, platform, config, default)
99 }
100
101 pub fn resolve_asset_strategy_from_platform_config(
102 platform: &str,
103 platform_config: Option<&PlatformConfig>,
104 default: AssetStrategy,
105 ) -> Result<AssetStrategy> {
106 let Some(strategy_str) = platform_config.and_then(|c| c.asset_strategy.as_deref()) else {
107 return Ok(default);
108 };
109
110 parse_asset_strategy(platform, strategy_str)
111 }
112
113 fn resolve_published(
117 content: &Content,
118 platform: &str,
119 config: &Config,
120 default: bool,
121 ) -> bool {
122 content
124 .meta
125 .platforms
126 .get(platform)
127 .and_then(|p| p.published)
128 .or(content.meta.published)
130 .or(config.platforms.get(platform).and_then(|p| p.published))
132 .or(config.published)
134 .unwrap_or(default)
136 }
137
138 fn resolve_theme_id(
143 content: &Content,
144 platform: &str,
145 config: &Config,
146 default: Option<ThemeId>,
147 ) -> Option<ThemeId> {
148 content
150 .platform_config(platform)
151 .and_then(|c| c.get_str("theme"))
152 .map(ThemeId::from)
153 .or_else(|| content.meta.theme.clone())
155 .or_else(|| config.platforms.get(platform).and_then(|p| p.theme.clone()))
157 .or_else(|| config.theme.clone())
159 .or(default)
161 }
162
163 fn resolve_internal_link_target(
167 content: &Content,
168 platform: &str,
169 config: &Config,
170 ) -> Option<String> {
171 content
173 .meta
174 .platforms
175 .get(platform)
176 .and_then(|p| p.internal_link_target.clone())
177 .or_else(|| content.meta.internal_link_target.clone())
179 .or_else(|| {
181 config
182 .platforms
183 .get(platform)
184 .and_then(|p| p.internal_link_target.clone())
185 })
186 .or_else(|| config.internal_link_target.clone())
188 }
190
191 fn resolve_asset_strategy(
196 content: &Content,
197 platform: &str,
198 config: &Config,
199 default: AssetStrategy,
200 ) -> Result<AssetStrategy> {
201 let strategy_str = content
203 .platform_config(platform)
204 .and_then(|c| c.get_str("asset_strategy"))
205 .or_else(|| {
208 config
209 .platforms
210 .get(platform)
211 .and_then(|p| p.asset_strategy.clone())
212 });
213 match strategy_str {
216 Some(s) => parse_asset_strategy(platform, &s),
217 None => Ok(default),
218 }
219 }
220
221 fn resolve_render_preamble(
223 content: &Content,
224 platform: &str,
225 config: &Config,
226 ) -> Option<String> {
227 content
228 .platform_config(platform)
229 .and_then(|c| c.get_str("preamble"))
230 .or_else(|| content.meta.preamble.clone())
231 .or_else(|| {
232 config
233 .platforms
234 .get(platform)
235 .and_then(|p| p.get_str("preamble"))
236 })
237 .or_else(|| config.preamble.clone())
238 }
239
240 fn resolve_storage(platform: &str, config: &Config) -> Option<StorageConfig> {
248 let global = config.storage.as_ref();
249 let platform_storage = config.platforms.get(platform).and_then(|p| p.get_storage());
250
251 if global.is_none() && platform_storage.is_none() {
253 return None;
254 }
255
256 Some(StorageConfig::resolve(
257 global,
258 platform_storage.as_ref(),
259 platform,
260 ))
261 }
262
263 fn resolve_node_policy_override(
264 content: &Content,
265 platform: &str,
266 config: &Config,
267 ) -> Result<Option<NodePolicyOverride>> {
268 let post_override = content
269 .platform_config(platform)
270 .and_then(|p| p.extra.get("node_policy"))
271 .map(|v| {
272 parse_node_policy_override(
273 v,
274 &format!("meta.toml platforms.{platform}.node_policy"),
275 )
276 })
277 .transpose()?;
278
279 let config_override = config
280 .platforms
281 .get(platform)
282 .and_then(|p| p.extra.get("node_policy"))
283 .map(|v| {
284 parse_node_policy_override(
285 v,
286 &format!("typub.toml platforms.{platform}.node_policy"),
287 )
288 })
289 .transpose()?;
290
291 let raw = post_override
292 .and_then(|p| p.raw)
293 .or(config_override.and_then(|p| p.raw));
294 let unknown = post_override
295 .and_then(|p| p.unknown)
296 .or(config_override.and_then(|p| p.unknown));
297
298 if raw.is_none() && unknown.is_none() {
299 Ok(None)
300 } else {
301 Ok(Some(NodePolicyOverride { raw, unknown }))
302 }
303 }
304}
305
306pub fn resolve_platform_field(
319 content: &Content,
320 platform: &str,
321 config: &Config,
322 field: &str,
323 default: Option<String>,
324) -> Option<String> {
325 content
327 .platform_config(platform)
328 .and_then(|c| c.get_str(field))
329 .or_else(|| {
332 config
333 .platforms
334 .get(platform)
335 .and_then(|p| p.get_str(field))
336 })
337 .or(default)
340}
341
342fn parse_asset_strategy(platform: &str, strategy: &str) -> Result<AssetStrategy> {
343 AssetStrategy::parse(strategy).ok_or_else(|| {
344 anyhow::anyhow!(
345 "Invalid asset strategy '{}' for platform '{}'. \
346 Expected one of: copy, embed, upload, external.",
347 strategy,
348 platform
349 )
350 })
351}
352
353fn parse_node_policy_override(value: &toml::Value, context: &str) -> Result<NodePolicyOverride> {
354 let table = value
355 .as_table()
356 .ok_or_else(|| anyhow::anyhow!("{context} must be a table with raw/unknown keys"))?;
357
358 let raw = table
359 .get("raw")
360 .map(|v| parse_node_policy_action(v, context, "raw"))
361 .transpose()?;
362 let unknown = table
363 .get("unknown")
364 .map(|v| parse_node_policy_action(v, context, "unknown"))
365 .transpose()?;
366
367 Ok(NodePolicyOverride { raw, unknown })
368}
369
370fn parse_node_policy_action(
371 value: &toml::Value,
372 context: &str,
373 key: &str,
374) -> Result<NodePolicyAction> {
375 let Some(raw) = value.as_str() else {
376 anyhow::bail!("{context}.{key} must be a string");
377 };
378 match raw {
379 "pass" => Ok(NodePolicyAction::Pass),
380 "sanitize" => Ok(NodePolicyAction::Sanitize),
381 "drop" => Ok(NodePolicyAction::Drop),
382 "error" => Ok(NodePolicyAction::Error),
383 _ => anyhow::bail!(
384 "{context}.{key} has invalid value '{}'; expected one of: pass, sanitize, drop, error",
385 raw
386 ),
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use crate::content::{ContentFormat, ContentMeta, PostPlatformConfig};
394 use anyhow::Result;
395 use chrono::NaiveDate;
396 use std::collections::HashMap;
397 use std::path::PathBuf;
398
399 fn make_content(
400 published: Option<bool>,
401 theme: Option<ThemeId>,
402 internal_link_target: Option<String>,
403 platforms: HashMap<String, PostPlatformConfig>,
404 ) -> Content {
405 Content {
406 path: PathBuf::from("/tmp/test-post"),
407 meta: ContentMeta {
408 title: "Test".to_string(),
409 created: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap_or_default(),
410 updated: None,
411 tags: vec![],
412 categories: vec![],
413 published,
414 theme,
415 internal_link_target,
416 preamble: None,
417 platforms,
418 },
419 content_file: PathBuf::from("/tmp/test-post/content.typ"),
420 source_format: ContentFormat::Typst,
421 slides_file: None,
422 assets: vec![],
423 }
424 }
425
426 fn make_post_platform_config(
427 published: Option<bool>,
428 internal_link_target: Option<String>,
429 extra: HashMap<String, toml::Value>,
430 ) -> PostPlatformConfig {
431 PostPlatformConfig {
432 published,
433 internal_link_target,
434 extra,
435 }
436 }
437
438 #[test]
439 fn test_resolve_published_layer_1_wins() {
440 let mut platforms = HashMap::new();
441 platforms.insert(
442 "wechat".to_string(),
443 make_post_platform_config(Some(false), None, HashMap::new()),
444 );
445
446 let content = make_content(Some(true), None, None, platforms);
447
448 let config = Config {
449 published: Some(true),
450 ..Default::default()
451 };
452
453 let result = ResolvedConfig::resolve_published(&content, "wechat", &config, true);
454 assert!(!result); }
456
457 #[test]
458 fn test_resolve_published_uses_default_when_all_none() {
459 let content = make_content(None, None, None, HashMap::new());
460 let config = Config::default();
461
462 let result = ResolvedConfig::resolve_published(&content, "wechat", &config, false);
463 assert!(!result); let result = ResolvedConfig::resolve_published(&content, "wechat", &config, true);
466 assert!(result); }
468
469 #[test]
470 fn test_resolve_theme_id_layer_precedence() {
471 let content = make_content(None, Some(ThemeId::new("elegant")), None, HashMap::new());
473 let config = Config::default();
474
475 let result = ResolvedConfig::resolve_theme_id(
476 &content,
477 "wechat",
478 &config,
479 Some(ThemeId::new("minimal")),
480 );
481 assert_eq!(result, Some(ThemeId::new("elegant"))); }
483
484 #[test]
485 fn test_resolve_internal_link_target_4_layer() {
486 let content = make_content(None, None, None, HashMap::new());
487
488 let config = Config {
489 internal_link_target: Some("ghost".to_string()),
490 ..Default::default()
491 };
492
493 let result = ResolvedConfig::resolve_internal_link_target(&content, "wechat", &config);
494 assert_eq!(result, Some("ghost".to_string())); }
496
497 #[test]
498 fn test_resolve_internal_link_target_returns_none_when_unset() {
499 let content = make_content(None, None, None, HashMap::new());
500 let config = Config::default();
501
502 let result = ResolvedConfig::resolve_internal_link_target(&content, "wechat", &config);
503 assert!(result.is_none());
504 }
505
506 #[test]
507 fn test_resolve_asset_strategy_uses_default() -> Result<()> {
508 let content = make_content(None, None, None, HashMap::new());
509 let config = Config::default();
510
511 let result = ResolvedConfig::resolve_asset_strategy(
512 &content,
513 "wechat",
514 &config,
515 AssetStrategy::Copy,
516 )?;
517 assert_eq!(result, AssetStrategy::Copy);
518 Ok(())
519 }
520
521 #[test]
522 fn test_resolve_asset_strategy_platform_override() -> Result<()> {
523 let content = make_content(None, None, None, HashMap::new());
524
525 let mut platforms = HashMap::new();
526 platforms.insert(
527 "wechat".to_string(),
528 PlatformConfig {
529 enabled: true,
530 asset_strategy: Some("embed".to_string()),
531 published: None,
532 theme: None,
533 internal_link_target: None,
534 math_rendering: None,
535 math_delimiters: None,
536 extra: HashMap::new(),
537 },
538 );
539 let config = Config {
540 platforms,
541 ..Default::default()
542 };
543
544 let result = ResolvedConfig::resolve_asset_strategy(
545 &content,
546 "wechat",
547 &config,
548 AssetStrategy::Copy,
549 )?;
550 assert_eq!(result, AssetStrategy::Embed);
551 Ok(())
552 }
553
554 #[test]
555 fn test_resolve_full_config() -> Result<()> {
556 let content = make_content(
557 Some(true),
558 Some(ThemeId::new("elegant")),
559 Some("ghost".to_string()),
560 HashMap::new(),
561 );
562 let config = Config::default();
563
564 let defaults =
565 ResolvedConfigDefaults::new(false, Some(ThemeId::new("minimal")), AssetStrategy::Copy);
566 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
567
568 assert!(resolved.published);
569 assert_eq!(resolved.theme_id, Some(ThemeId::new("elegant")));
570 assert_eq!(resolved.internal_link_target, Some("ghost".to_string()));
571 assert_eq!(resolved.asset_strategy, AssetStrategy::Copy);
572 assert!(resolved.render_preamble.is_none());
573 assert!(resolved.storage.is_none());
574 assert!(resolved.node_policy_override.is_none());
575 Ok(())
576 }
577
578 #[test]
579 fn test_resolve_render_preamble_layer_1_wins() -> Result<()> {
580 let mut post_extra = HashMap::new();
581 post_extra.insert(
582 "preamble".to_string(),
583 toml::Value::String("#set text(fill: red)".to_string()),
584 );
585 let mut post_platforms = HashMap::new();
586 post_platforms.insert(
587 "wechat".to_string(),
588 make_post_platform_config(None, None, post_extra),
589 );
590 let mut content = make_content(None, None, None, post_platforms);
591 content.meta.preamble = Some("#set text(fill: blue)".to_string());
592
593 let mut cfg_extra = HashMap::new();
594 cfg_extra.insert(
595 "preamble".to_string(),
596 toml::Value::String("#set text(fill: green)".to_string()),
597 );
598 let mut platforms = HashMap::new();
599 platforms.insert(
600 "wechat".to_string(),
601 PlatformConfig {
602 enabled: true,
603 asset_strategy: None,
604 published: None,
605 theme: None,
606 internal_link_target: None,
607 math_rendering: None,
608 math_delimiters: None,
609 extra: cfg_extra,
610 },
611 );
612 let config = Config {
613 preamble: Some("#set text(fill: purple)".to_string()),
614 platforms,
615 ..Default::default()
616 };
617
618 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
619 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
620 assert_eq!(
621 resolved.render_preamble.as_deref(),
622 Some("#set text(fill: red)")
623 );
624 Ok(())
625 }
626
627 #[test]
628 fn test_resolve_render_preamble_falls_through_layers() -> Result<()> {
629 let mut content = make_content(None, None, None, HashMap::new());
630 content.meta.preamble = Some("#set text(size: 11pt)".to_string());
631
632 let config = Config {
633 preamble: Some("#set text(size: 9pt)".to_string()),
634 ..Default::default()
635 };
636 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
637
638 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
639 assert_eq!(
640 resolved.render_preamble.as_deref(),
641 Some("#set text(size: 11pt)")
642 );
643 Ok(())
644 }
645
646 #[test]
647 fn test_resolve_node_policy_override_layer_1_overrides_layer_3() -> Result<()> {
648 let mut post_extra = HashMap::new();
649 post_extra.insert(
650 "node_policy".to_string(),
651 toml::Value::Table(
652 [
653 ("raw".to_string(), toml::Value::String("drop".to_string())),
654 (
655 "unknown".to_string(),
656 toml::Value::String("error".to_string()),
657 ),
658 ]
659 .into_iter()
660 .collect(),
661 ),
662 );
663 let mut post_platforms = HashMap::new();
664 post_platforms.insert(
665 "wechat".to_string(),
666 make_post_platform_config(None, None, post_extra),
667 );
668 let content = make_content(None, None, None, post_platforms);
669
670 let mut cfg_extra = HashMap::new();
671 cfg_extra.insert(
672 "node_policy".to_string(),
673 toml::Value::Table(
674 [
675 (
676 "raw".to_string(),
677 toml::Value::String("sanitize".to_string()),
678 ),
679 (
680 "unknown".to_string(),
681 toml::Value::String("drop".to_string()),
682 ),
683 ]
684 .into_iter()
685 .collect(),
686 ),
687 );
688 let mut platforms = HashMap::new();
689 platforms.insert(
690 "wechat".to_string(),
691 PlatformConfig {
692 enabled: true,
693 asset_strategy: None,
694 published: None,
695 theme: None,
696 internal_link_target: None,
697 math_rendering: None,
698 math_delimiters: None,
699 extra: cfg_extra,
700 },
701 );
702 let config = Config {
703 platforms,
704 ..Default::default()
705 };
706
707 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
708 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
709 assert_eq!(
710 resolved.node_policy_override,
711 Some(NodePolicyOverride {
712 raw: Some(NodePolicyAction::Drop),
713 unknown: Some(NodePolicyAction::Error)
714 })
715 );
716 Ok(())
717 }
718
719 #[test]
720 fn test_resolve_node_policy_override_partial_fallback() -> Result<()> {
721 let mut post_extra = HashMap::new();
722 post_extra.insert(
723 "node_policy".to_string(),
724 toml::Value::Table(
725 [("raw".to_string(), toml::Value::String("error".to_string()))]
726 .into_iter()
727 .collect(),
728 ),
729 );
730 let mut post_platforms = HashMap::new();
731 post_platforms.insert(
732 "wechat".to_string(),
733 make_post_platform_config(None, None, post_extra),
734 );
735 let content = make_content(None, None, None, post_platforms);
736
737 let mut cfg_extra = HashMap::new();
738 cfg_extra.insert(
739 "node_policy".to_string(),
740 toml::Value::Table(
741 [(
742 "unknown".to_string(),
743 toml::Value::String("sanitize".to_string()),
744 )]
745 .into_iter()
746 .collect(),
747 ),
748 );
749 let mut platforms = HashMap::new();
750 platforms.insert(
751 "wechat".to_string(),
752 PlatformConfig {
753 enabled: true,
754 asset_strategy: None,
755 published: None,
756 theme: None,
757 internal_link_target: None,
758 math_rendering: None,
759 math_delimiters: None,
760 extra: cfg_extra,
761 },
762 );
763 let config = Config {
764 platforms,
765 ..Default::default()
766 };
767
768 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
769 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
770 assert_eq!(
771 resolved.node_policy_override,
772 Some(NodePolicyOverride {
773 raw: Some(NodePolicyAction::Error),
774 unknown: Some(NodePolicyAction::Sanitize)
775 })
776 );
777 Ok(())
778 }
779
780 #[test]
781 #[allow(clippy::expect_used)]
782 fn test_resolve_node_policy_override_invalid_value_errors() {
783 let content = make_content(None, None, None, HashMap::new());
784
785 let mut extra = HashMap::new();
786 extra.insert(
787 "node_policy".to_string(),
788 toml::Value::Table(
789 [(
790 "raw".to_string(),
791 toml::Value::String("invalid".to_string()),
792 )]
793 .into_iter()
794 .collect(),
795 ),
796 );
797 let mut platforms = HashMap::new();
798 platforms.insert(
799 "wechat".to_string(),
800 PlatformConfig {
801 enabled: true,
802 asset_strategy: None,
803 published: None,
804 theme: None,
805 internal_link_target: None,
806 math_rendering: None,
807 math_delimiters: None,
808 extra,
809 },
810 );
811 let config = Config {
812 platforms,
813 ..Default::default()
814 };
815
816 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
817 let err = ResolvedConfig::resolve(&content, "wechat", &config, defaults)
818 .expect_err("invalid node_policy should error");
819 assert!(err.to_string().contains("invalid value"));
820 }
821
822 #[test]
825 fn test_resolve_platform_field_layer_1_post_platform_specific() {
826 let mut post_extra = HashMap::new();
827 post_extra.insert(
828 "space".to_string(),
829 toml::Value::String("POSTSPACE".to_string()),
830 );
831 let mut post_platforms = HashMap::new();
832 post_platforms.insert(
833 "confluence".to_string(),
834 make_post_platform_config(None, None, post_extra),
835 );
836 let content = make_content(None, None, None, post_platforms);
837
838 let mut global_extra = HashMap::new();
839 global_extra.insert(
840 "space".to_string(),
841 toml::Value::String("GLOBALSPACE".to_string()),
842 );
843 let mut global_platforms = HashMap::new();
844 global_platforms.insert(
845 "confluence".to_string(),
846 PlatformConfig {
847 enabled: true,
848 asset_strategy: None,
849 published: None,
850 theme: None,
851 internal_link_target: None,
852 math_rendering: None,
853 math_delimiters: None,
854 extra: global_extra,
855 },
856 );
857 let config = Config {
858 platforms: global_platforms,
859 ..Default::default()
860 };
861
862 let result = resolve_platform_field(&content, "confluence", &config, "space", None);
864 assert_eq!(result, Some("POSTSPACE".to_string()));
865 }
866
867 #[test]
868 fn test_resolve_platform_field_layer_3_global_platform_specific() {
869 let content = make_content(None, None, None, HashMap::new());
870
871 let mut global_extra = HashMap::new();
872 global_extra.insert(
873 "space".to_string(),
874 toml::Value::String("GLOBALSPACE".to_string()),
875 );
876 let mut global_platforms = HashMap::new();
877 global_platforms.insert(
878 "confluence".to_string(),
879 PlatformConfig {
880 enabled: true,
881 asset_strategy: None,
882 published: None,
883 theme: None,
884 internal_link_target: None,
885 math_rendering: None,
886 math_delimiters: None,
887 extra: global_extra,
888 },
889 );
890 let config = Config {
891 platforms: global_platforms,
892 ..Default::default()
893 };
894
895 let result = resolve_platform_field(&content, "confluence", &config, "space", None);
897 assert_eq!(result, Some("GLOBALSPACE".to_string()));
898 }
899
900 #[test]
901 fn test_resolve_platform_field_layer_5_default() {
902 let content = make_content(None, None, None, HashMap::new());
903 let config = Config::default();
904
905 let result = resolve_platform_field(
907 &content,
908 "confluence",
909 &config,
910 "space",
911 Some("DEFAULT".to_string()),
912 );
913 assert_eq!(result, Some("DEFAULT".to_string()));
914 }
915
916 #[test]
917 fn test_resolve_platform_field_none_when_not_found() {
918 let content = make_content(None, None, None, HashMap::new());
919 let config = Config::default();
920
921 let result = resolve_platform_field(&content, "confluence", &config, "space", None);
923 assert_eq!(result, None);
924 }
925}