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
306fn parse_asset_strategy(platform: &str, strategy: &str) -> Result<AssetStrategy> {
307 AssetStrategy::parse(strategy).ok_or_else(|| {
308 anyhow::anyhow!(
309 "Invalid asset strategy '{}' for platform '{}'. \
310 Expected one of: copy, embed, upload, external.",
311 strategy,
312 platform
313 )
314 })
315}
316
317fn parse_node_policy_override(value: &toml::Value, context: &str) -> Result<NodePolicyOverride> {
318 let table = value
319 .as_table()
320 .ok_or_else(|| anyhow::anyhow!("{context} must be a table with raw/unknown keys"))?;
321
322 let raw = table
323 .get("raw")
324 .map(|v| parse_node_policy_action(v, context, "raw"))
325 .transpose()?;
326 let unknown = table
327 .get("unknown")
328 .map(|v| parse_node_policy_action(v, context, "unknown"))
329 .transpose()?;
330
331 Ok(NodePolicyOverride { raw, unknown })
332}
333
334fn parse_node_policy_action(
335 value: &toml::Value,
336 context: &str,
337 key: &str,
338) -> Result<NodePolicyAction> {
339 let Some(raw) = value.as_str() else {
340 anyhow::bail!("{context}.{key} must be a string");
341 };
342 match raw {
343 "pass" => Ok(NodePolicyAction::Pass),
344 "sanitize" => Ok(NodePolicyAction::Sanitize),
345 "drop" => Ok(NodePolicyAction::Drop),
346 "error" => Ok(NodePolicyAction::Error),
347 _ => anyhow::bail!(
348 "{context}.{key} has invalid value '{}'; expected one of: pass, sanitize, drop, error",
349 raw
350 ),
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::content::{ContentFormat, ContentMeta, PostPlatformConfig};
358 use anyhow::Result;
359 use chrono::NaiveDate;
360 use std::collections::HashMap;
361 use std::path::PathBuf;
362
363 fn make_content(
364 published: Option<bool>,
365 theme: Option<ThemeId>,
366 internal_link_target: Option<String>,
367 platforms: HashMap<String, PostPlatformConfig>,
368 ) -> Content {
369 Content {
370 path: PathBuf::from("/tmp/test-post"),
371 meta: ContentMeta {
372 title: "Test".to_string(),
373 created: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap_or_default(),
374 updated: None,
375 tags: vec![],
376 categories: vec![],
377 published,
378 theme,
379 internal_link_target,
380 preamble: None,
381 platforms,
382 },
383 content_file: PathBuf::from("/tmp/test-post/content.typ"),
384 source_format: ContentFormat::Typst,
385 slides_file: None,
386 assets: vec![],
387 }
388 }
389
390 fn make_post_platform_config(
391 published: Option<bool>,
392 internal_link_target: Option<String>,
393 extra: HashMap<String, toml::Value>,
394 ) -> PostPlatformConfig {
395 PostPlatformConfig {
396 published,
397 internal_link_target,
398 extra,
399 }
400 }
401
402 #[test]
403 fn test_resolve_published_layer_1_wins() {
404 let mut platforms = HashMap::new();
405 platforms.insert(
406 "wechat".to_string(),
407 make_post_platform_config(Some(false), None, HashMap::new()),
408 );
409
410 let content = make_content(Some(true), None, None, platforms);
411
412 let config = Config {
413 published: Some(true),
414 ..Default::default()
415 };
416
417 let result = ResolvedConfig::resolve_published(&content, "wechat", &config, true);
418 assert!(!result); }
420
421 #[test]
422 fn test_resolve_published_uses_default_when_all_none() {
423 let content = make_content(None, None, None, HashMap::new());
424 let config = Config::default();
425
426 let result = ResolvedConfig::resolve_published(&content, "wechat", &config, false);
427 assert!(!result); let result = ResolvedConfig::resolve_published(&content, "wechat", &config, true);
430 assert!(result); }
432
433 #[test]
434 fn test_resolve_theme_id_layer_precedence() {
435 let content = make_content(None, Some(ThemeId::new("elegant")), None, HashMap::new());
437 let config = Config::default();
438
439 let result = ResolvedConfig::resolve_theme_id(
440 &content,
441 "wechat",
442 &config,
443 Some(ThemeId::new("minimal")),
444 );
445 assert_eq!(result, Some(ThemeId::new("elegant"))); }
447
448 #[test]
449 fn test_resolve_internal_link_target_4_layer() {
450 let content = make_content(None, None, None, HashMap::new());
451
452 let config = Config {
453 internal_link_target: Some("ghost".to_string()),
454 ..Default::default()
455 };
456
457 let result = ResolvedConfig::resolve_internal_link_target(&content, "wechat", &config);
458 assert_eq!(result, Some("ghost".to_string())); }
460
461 #[test]
462 fn test_resolve_internal_link_target_returns_none_when_unset() {
463 let content = make_content(None, None, None, HashMap::new());
464 let config = Config::default();
465
466 let result = ResolvedConfig::resolve_internal_link_target(&content, "wechat", &config);
467 assert!(result.is_none());
468 }
469
470 #[test]
471 fn test_resolve_asset_strategy_uses_default() -> Result<()> {
472 let content = make_content(None, None, None, HashMap::new());
473 let config = Config::default();
474
475 let result = ResolvedConfig::resolve_asset_strategy(
476 &content,
477 "wechat",
478 &config,
479 AssetStrategy::Copy,
480 )?;
481 assert_eq!(result, AssetStrategy::Copy);
482 Ok(())
483 }
484
485 #[test]
486 fn test_resolve_asset_strategy_platform_override() -> Result<()> {
487 let content = make_content(None, None, None, HashMap::new());
488
489 let mut platforms = HashMap::new();
490 platforms.insert(
491 "wechat".to_string(),
492 PlatformConfig {
493 enabled: true,
494 asset_strategy: Some("embed".to_string()),
495 published: None,
496 theme: None,
497 internal_link_target: None,
498 math_rendering: None,
499 math_delimiters: None,
500 extra: HashMap::new(),
501 },
502 );
503 let config = Config {
504 platforms,
505 ..Default::default()
506 };
507
508 let result = ResolvedConfig::resolve_asset_strategy(
509 &content,
510 "wechat",
511 &config,
512 AssetStrategy::Copy,
513 )?;
514 assert_eq!(result, AssetStrategy::Embed);
515 Ok(())
516 }
517
518 #[test]
519 fn test_resolve_full_config() -> Result<()> {
520 let content = make_content(
521 Some(true),
522 Some(ThemeId::new("elegant")),
523 Some("ghost".to_string()),
524 HashMap::new(),
525 );
526 let config = Config::default();
527
528 let defaults =
529 ResolvedConfigDefaults::new(false, Some(ThemeId::new("minimal")), AssetStrategy::Copy);
530 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
531
532 assert!(resolved.published);
533 assert_eq!(resolved.theme_id, Some(ThemeId::new("elegant")));
534 assert_eq!(resolved.internal_link_target, Some("ghost".to_string()));
535 assert_eq!(resolved.asset_strategy, AssetStrategy::Copy);
536 assert!(resolved.render_preamble.is_none());
537 assert!(resolved.storage.is_none());
538 assert!(resolved.node_policy_override.is_none());
539 Ok(())
540 }
541
542 #[test]
543 fn test_resolve_render_preamble_layer_1_wins() -> Result<()> {
544 let mut post_extra = HashMap::new();
545 post_extra.insert(
546 "preamble".to_string(),
547 toml::Value::String("#set text(fill: red)".to_string()),
548 );
549 let mut post_platforms = HashMap::new();
550 post_platforms.insert(
551 "wechat".to_string(),
552 make_post_platform_config(None, None, post_extra),
553 );
554 let mut content = make_content(None, None, None, post_platforms);
555 content.meta.preamble = Some("#set text(fill: blue)".to_string());
556
557 let mut cfg_extra = HashMap::new();
558 cfg_extra.insert(
559 "preamble".to_string(),
560 toml::Value::String("#set text(fill: green)".to_string()),
561 );
562 let mut platforms = HashMap::new();
563 platforms.insert(
564 "wechat".to_string(),
565 PlatformConfig {
566 enabled: true,
567 asset_strategy: None,
568 published: None,
569 theme: None,
570 internal_link_target: None,
571 math_rendering: None,
572 math_delimiters: None,
573 extra: cfg_extra,
574 },
575 );
576 let config = Config {
577 preamble: Some("#set text(fill: purple)".to_string()),
578 platforms,
579 ..Default::default()
580 };
581
582 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
583 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
584 assert_eq!(
585 resolved.render_preamble.as_deref(),
586 Some("#set text(fill: red)")
587 );
588 Ok(())
589 }
590
591 #[test]
592 fn test_resolve_render_preamble_falls_through_layers() -> Result<()> {
593 let mut content = make_content(None, None, None, HashMap::new());
594 content.meta.preamble = Some("#set text(size: 11pt)".to_string());
595
596 let config = Config {
597 preamble: Some("#set text(size: 9pt)".to_string()),
598 ..Default::default()
599 };
600 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
601
602 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
603 assert_eq!(
604 resolved.render_preamble.as_deref(),
605 Some("#set text(size: 11pt)")
606 );
607 Ok(())
608 }
609
610 #[test]
611 fn test_resolve_node_policy_override_layer_1_overrides_layer_3() -> Result<()> {
612 let mut post_extra = HashMap::new();
613 post_extra.insert(
614 "node_policy".to_string(),
615 toml::Value::Table(
616 [
617 ("raw".to_string(), toml::Value::String("drop".to_string())),
618 (
619 "unknown".to_string(),
620 toml::Value::String("error".to_string()),
621 ),
622 ]
623 .into_iter()
624 .collect(),
625 ),
626 );
627 let mut post_platforms = HashMap::new();
628 post_platforms.insert(
629 "wechat".to_string(),
630 make_post_platform_config(None, None, post_extra),
631 );
632 let content = make_content(None, None, None, post_platforms);
633
634 let mut cfg_extra = HashMap::new();
635 cfg_extra.insert(
636 "node_policy".to_string(),
637 toml::Value::Table(
638 [
639 (
640 "raw".to_string(),
641 toml::Value::String("sanitize".to_string()),
642 ),
643 (
644 "unknown".to_string(),
645 toml::Value::String("drop".to_string()),
646 ),
647 ]
648 .into_iter()
649 .collect(),
650 ),
651 );
652 let mut platforms = HashMap::new();
653 platforms.insert(
654 "wechat".to_string(),
655 PlatformConfig {
656 enabled: true,
657 asset_strategy: None,
658 published: None,
659 theme: None,
660 internal_link_target: None,
661 math_rendering: None,
662 math_delimiters: None,
663 extra: cfg_extra,
664 },
665 );
666 let config = Config {
667 platforms,
668 ..Default::default()
669 };
670
671 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
672 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
673 assert_eq!(
674 resolved.node_policy_override,
675 Some(NodePolicyOverride {
676 raw: Some(NodePolicyAction::Drop),
677 unknown: Some(NodePolicyAction::Error)
678 })
679 );
680 Ok(())
681 }
682
683 #[test]
684 fn test_resolve_node_policy_override_partial_fallback() -> Result<()> {
685 let mut post_extra = HashMap::new();
686 post_extra.insert(
687 "node_policy".to_string(),
688 toml::Value::Table(
689 [("raw".to_string(), toml::Value::String("error".to_string()))]
690 .into_iter()
691 .collect(),
692 ),
693 );
694 let mut post_platforms = HashMap::new();
695 post_platforms.insert(
696 "wechat".to_string(),
697 make_post_platform_config(None, None, post_extra),
698 );
699 let content = make_content(None, None, None, post_platforms);
700
701 let mut cfg_extra = HashMap::new();
702 cfg_extra.insert(
703 "node_policy".to_string(),
704 toml::Value::Table(
705 [(
706 "unknown".to_string(),
707 toml::Value::String("sanitize".to_string()),
708 )]
709 .into_iter()
710 .collect(),
711 ),
712 );
713 let mut platforms = HashMap::new();
714 platforms.insert(
715 "wechat".to_string(),
716 PlatformConfig {
717 enabled: true,
718 asset_strategy: None,
719 published: None,
720 theme: None,
721 internal_link_target: None,
722 math_rendering: None,
723 math_delimiters: None,
724 extra: cfg_extra,
725 },
726 );
727 let config = Config {
728 platforms,
729 ..Default::default()
730 };
731
732 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
733 let resolved = ResolvedConfig::resolve(&content, "wechat", &config, defaults)?;
734 assert_eq!(
735 resolved.node_policy_override,
736 Some(NodePolicyOverride {
737 raw: Some(NodePolicyAction::Error),
738 unknown: Some(NodePolicyAction::Sanitize)
739 })
740 );
741 Ok(())
742 }
743
744 #[test]
745 #[allow(clippy::expect_used)]
746 fn test_resolve_node_policy_override_invalid_value_errors() {
747 let content = make_content(None, None, None, HashMap::new());
748
749 let mut extra = HashMap::new();
750 extra.insert(
751 "node_policy".to_string(),
752 toml::Value::Table(
753 [(
754 "raw".to_string(),
755 toml::Value::String("invalid".to_string()),
756 )]
757 .into_iter()
758 .collect(),
759 ),
760 );
761 let mut platforms = HashMap::new();
762 platforms.insert(
763 "wechat".to_string(),
764 PlatformConfig {
765 enabled: true,
766 asset_strategy: None,
767 published: None,
768 theme: None,
769 internal_link_target: None,
770 math_rendering: None,
771 math_delimiters: None,
772 extra,
773 },
774 );
775 let config = Config {
776 platforms,
777 ..Default::default()
778 };
779
780 let defaults = ResolvedConfigDefaults::new(true, None, AssetStrategy::Embed);
781 let err = ResolvedConfig::resolve(&content, "wechat", &config, defaults)
782 .expect_err("invalid node_policy should error");
783 assert!(err.to_string().contains("invalid value"));
784 }
785}