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
306/// Resolve a platform-specific string field using 4-layer resolution.
307///
308/// Implements [[RFC-0005:C-RESOLUTION-ORDER]]:
309/// 1. `meta.toml[platforms.<platform>].{field}` — per-content platform-specific
310/// 2. `meta.toml.{field}` — per-content default (not applicable for most platform-specific fields)
311/// 3. `typub.toml[platforms.<platform>].{field}` — global platform-specific
312/// 4. `typub.toml.{field}` — global default (not applicable for most platform-specific fields)
313/// 5. `default` — caller-provided default
314///
315/// This function is used by adapters to resolve fields like `space`, `parent_id`,
316/// `slug`, `subtitle`, etc. that are specific to a platform but may be overridden
317/// at the post level.
318pub fn resolve_platform_field(
319    content: &Content,
320    platform: &str,
321    config: &Config,
322    field: &str,
323    default: Option<String>,
324) -> Option<String> {
325    // Layer 1: meta.toml[platforms.<platform>].{field} (via extra)
326    content
327        .platform_config(platform)
328        .and_then(|c| c.get_str(field))
329        // Layer 2: not applicable (no per-content default for platform-specific fields)
330        // Layer 3: typub.toml[platforms.<platform>].{field}
331        .or_else(|| {
332            config
333                .platforms
334                .get(platform)
335                .and_then(|p| p.get_str(field))
336        })
337        // Layer 4: not applicable (no global default for platform-specific fields)
338        // Layer 5: caller-provided default
339        .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); // Layer 1 wins (false)
455    }
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); // Uses default (false)
464
465        let result = ResolvedConfig::resolve_published(&content, "wechat", &config, true);
466        assert!(result); // Uses default (true)
467    }
468
469    #[test]
470    fn test_resolve_theme_id_layer_precedence() {
471        // Layer 2 set, others None
472        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"))); // Layer 2 wins
482    }
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())); // Layer 4
495    }
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    // --- resolve_platform_field tests ---
823
824    #[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        // Layer 1 should win
863        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        // Layer 3 should be used when Layer 1 is not set
896        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        // Layer 5 (caller default) should be used when no config is found
906        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        // Should return None when field is not found at any layer and no default
922        let result = resolve_platform_field(&content, "confluence", &config, "space", None);
923        assert_eq!(result, None);
924    }
925}