Skip to main content

typub_engine/
resolved_config.rs

1//! Unified configuration resolution
2//!
3//! Implements [[RFC-0005:C-RESOLUTION-ORDER]] with a consistent 4-layer resolution
4//! chain for all configurable fields.
5//!
6//! Resolution order (highest to lowest priority):
7//! 1. `meta.toml[platforms.<platform>].{field}` — per-content platform-specific
8//! 2. `meta.toml.{field}` — per-content default
9//! 3. `typub.toml[platforms.<platform>].{field}` — global platform-specific
10//! 4. `typub.toml.{field}` — global default
11//! 5. Caller-provided default (optional)
12
13use 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// Re-export from typub_adapters_core for backward compatibility
21
22/// Fully resolved configuration for a (content, platform) pair.
23///
24/// This struct centralizes all configuration resolution, implementing the
25/// 4-layer resolution chain defined in [[RFC-0005:C-RESOLUTION-ORDER]].
26///
27/// Compute once per (content, platform), then pass throughout the pipeline.
28#[derive(Debug, Clone)]
29pub struct ResolvedConfig {
30    /// Whether content should be published to this platform
31    pub published: bool,
32    /// Resolved theme ID (not the Theme object itself)
33    pub theme_id: Option<ThemeId>,
34    /// Preferred platform for internal link resolution (for copypaste adapters)
35    pub internal_link_target: Option<String>,
36    /// Asset handling strategy for this platform
37    pub asset_strategy: AssetStrategy,
38    /// Optional user-provided Typst preamble resolved from config layers.
39    pub render_preamble: Option<String>,
40    /// Resolved storage configuration (merged global + platform)
41    pub storage: Option<StorageConfig>,
42    /// Optional node policy overrides from config layers.
43    pub node_policy_override: Option<NodePolicyOverride>,
44}
45
46/// Partial node policy override from configuration.
47#[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    /// Resolve all configuration fields for a (content, platform) pair.
55    ///
56    /// Implements [[RFC-0005:C-RESOLUTION-ORDER]]:
57    /// 1. `meta.toml[platforms.<platform>].{field}`
58    /// 2. `meta.toml.{field}`
59    /// 3. `typub.toml[platforms.<platform>].{field}`
60    /// 4. `typub.toml.{field}`
61    /// 5. Default from `defaults` parameter
62    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    /// Resolve `published` using 4-layer chain + default.
114    ///
115    /// Implements [[RFC-0005:C-RESOLUTION-ORDER]].
116    fn resolve_published(
117        content: &Content,
118        platform: &str,
119        config: &Config,
120        default: bool,
121    ) -> bool {
122        // Layer 1: meta.toml[platforms.<platform>].published
123        content
124            .meta
125            .platforms
126            .get(platform)
127            .and_then(|p| p.published)
128            // Layer 2: meta.toml.published
129            .or(content.meta.published)
130            // Layer 3: typub.toml[platforms.<platform>].published
131            .or(config.platforms.get(platform).and_then(|p| p.published))
132            // Layer 4: typub.toml.published
133            .or(config.published)
134            // Layer 5: default
135            .unwrap_or(default)
136    }
137
138    /// Resolve `theme` ID using 4-layer chain + default.
139    ///
140    /// Returns the theme ID string, not the Theme object. Caller should
141    /// load the actual Theme from ThemeRegistry using this ID.
142    fn resolve_theme_id(
143        content: &Content,
144        platform: &str,
145        config: &Config,
146        default: Option<ThemeId>,
147    ) -> Option<ThemeId> {
148        // Layer 1: meta.toml[platforms.<platform>].theme (via extra)
149        content
150            .platform_config(platform)
151            .and_then(|c| c.get_str("theme"))
152            .map(ThemeId::from)
153            // Layer 2: meta.toml.theme
154            .or_else(|| content.meta.theme.clone())
155            // Layer 3: typub.toml[platforms.<platform>].theme
156            .or_else(|| config.platforms.get(platform).and_then(|p| p.theme.clone()))
157            // Layer 4: typub.toml.theme
158            .or_else(|| config.theme.clone())
159            // Layer 5: default
160            .or(default)
161    }
162
163    /// Resolve `internal_link_target` using 4-layer chain.
164    ///
165    /// Returns `None` if no explicit preference is set (caller should auto-select).
166    fn resolve_internal_link_target(
167        content: &Content,
168        platform: &str,
169        config: &Config,
170    ) -> Option<String> {
171        // Layer 1: meta.toml[platforms.<platform>].internal_link_target
172        content
173            .meta
174            .platforms
175            .get(platform)
176            .and_then(|p| p.internal_link_target.clone())
177            // Layer 2: meta.toml.internal_link_target
178            .or_else(|| content.meta.internal_link_target.clone())
179            // Layer 3: typub.toml[platforms.<platform>].internal_link_target
180            .or_else(|| {
181                config
182                    .platforms
183                    .get(platform)
184                    .and_then(|p| p.internal_link_target.clone())
185            })
186            // Layer 4: typub.toml.internal_link_target
187            .or_else(|| config.internal_link_target.clone())
188        // No default — None means auto-select
189    }
190
191    /// Resolve `asset_strategy` using 4-layer chain + default.
192    ///
193    /// Note: Currently `meta.toml` doesn't have explicit asset_strategy fields,
194    /// so layers 1-2 use the `extra` map. We check for the string value and parse it.
195    fn resolve_asset_strategy(
196        content: &Content,
197        platform: &str,
198        config: &Config,
199        default: AssetStrategy,
200    ) -> Result<AssetStrategy> {
201        // Layer 1: meta.toml[platforms.<platform>].asset_strategy (via extra)
202        let strategy_str = content
203            .platform_config(platform)
204            .and_then(|c| c.get_str("asset_strategy"))
205            // Layer 2: not applicable (no global asset_strategy in meta.toml)
206            // Layer 3: typub.toml[platforms.<platform>].asset_strategy
207            .or_else(|| {
208                config
209                    .platforms
210                    .get(platform)
211                    .and_then(|p| p.asset_strategy.clone())
212            });
213        // Layer 4: not applicable (no global asset_strategy in typub.toml root)
214
215        match strategy_str {
216            Some(s) => parse_asset_strategy(platform, &s),
217            None => Ok(default),
218        }
219    }
220
221    /// Resolve Typst render preamble using 5-layer chain.
222    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    /// Resolve storage configuration by merging global and platform-specific config.
241    ///
242    /// Uses [[RFC-0004:C-STORAGE-CONFIG]] precedence ladder for each field:
243    /// 1. Platform-specific env var (e.g., `HASHNODE_S3_BUCKET`)
244    /// 2. Platform-specific config value
245    /// 3. Global env var (e.g., `S3_BUCKET`)
246    /// 4. Global config value
247    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        // Only return Some if there's any storage config at any level
252        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); // Layer 1 wins (false)
419    }
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); // Uses default (false)
428
429        let result = ResolvedConfig::resolve_published(&content, "wechat", &config, true);
430        assert!(result); // Uses default (true)
431    }
432
433    #[test]
434    fn test_resolve_theme_id_layer_precedence() {
435        // Layer 2 set, others None
436        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"))); // Layer 2 wins
446    }
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())); // Layer 4
459    }
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}