Skip to main content

typub_engine/
adapters_impl.rs

1use crate::assets::AssetStrategy;
2use crate::content::Content;
3use crate::metadata::MetadataService;
4use crate::resolved_config::ResolvedConfig;
5use anyhow::Result;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use typub_config::{Config, PlatformConfig, StorageConfig};
10use typub_storage::StatusTracker;
11
12use typub_adapters_core::{ContentInfo, resolve_asset_strategy_with_policy};
13
14pub fn content_info_from(content: &Content) -> ContentInfo {
15    ContentInfo::new(
16        content.meta.title.clone(),
17        content.slug().to_string(),
18        content.path.clone(),
19        content.meta.tags.clone(),
20        content.meta.categories.clone(),
21        content.assets.clone(),
22    )
23}
24
25pub fn content_info_with_platform(
26    content: &Content,
27    platform_id: &str,
28    config: &Config,
29) -> ContentInfo {
30    // Collect all unique keys from both post-level and global-level platform config
31    let mut all_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
32
33    // Keys from post-level platform config
34    if let Some(post_cfg) = content.platform_config(platform_id) {
35        all_keys.extend(post_cfg.extra.keys().cloned());
36    }
37
38    // Keys from global platform config
39    if let Some(global_cfg) = config.platforms.get(platform_id) {
40        all_keys.extend(global_cfg.extra.keys().cloned());
41    }
42
43    // Resolve each key using proper 4-layer resolution
44    let platform_extra: std::collections::HashMap<String, String> = all_keys
45        .into_iter()
46        .filter_map(|key| {
47            crate::resolved_config::resolve_platform_field(content, platform_id, config, &key, None)
48                .map(|value| (key, value))
49        })
50        .collect();
51
52    ContentInfo::with_platform_extra(
53        content.meta.title.clone(),
54        content.slug().to_string(),
55        content.path.clone(),
56        content.meta.tags.clone(),
57        content.meta.categories.clone(),
58        content.assets.clone(),
59        platform_extra,
60    )
61}
62
63use typub_core::{
64    CapabilityGapBehavior, CapabilitySupport, DraftSupport, MathDelimiters, MathRendering,
65    NodePolicyAction,
66};
67
68use typub_adapters_core::{AdapterCapability, ImageStrategyPolicy, NodePolicy, TaxonomyCapability};
69
70use CapabilityGapBehavior as UnsupportedBehavior;
71
72pub static BUILTIN_ADAPTERS: &[AdapterCapability] = &[
73    typub_adapter_ghost::CAPABILITY,
74    typub_adapter_devto::CAPABILITY,
75    typub_adapter_wordpress::CAPABILITY,
76    typub_adapter_hashnode::CAPABILITY,
77    typub_adapter_confluence::CAPABILITY,
78    typub_adapter_astro::CAPABILITY,
79    typub_adapter_static::CAPABILITY,
80    typub_adapter_xiaohongshu::CAPABILITY,
81    typub_adapter_notion::CAPABILITY,
82];
83
84const COPYPASTE_DEFAULT_CAPABILITY: AdapterCapability = AdapterCapability {
85    id: "copypaste",
86    name: "Copy-Paste",
87    short_code: "cp",
88    local_output: true,
89    requires_config: false,
90    taxonomy: TaxonomyCapability::new(
91        CapabilitySupport::Unsupported(UnsupportedBehavior::WarnAndDegrade),
92        CapabilitySupport::Unsupported(UnsupportedBehavior::WarnAndDegrade),
93        CapabilitySupport::Supported,
94        DraftSupport::None,
95    ),
96    asset_strategies: &[AssetStrategy::Embed, AssetStrategy::External],
97    math_renderings: &[MathRendering::Svg, MathRendering::Png],
98    math_delimiters: &[MathDelimiters::Dollar, MathDelimiters::Brackets],
99    code_highlight: false,
100    notes: "Copy-paste format has no metadata sync. Internal links resolve to other published platforms.",
101    node_policy: NodePolicy {
102        raw: NodePolicyAction::Sanitize,
103        unknown: NodePolicyAction::Drop,
104    },
105};
106
107pub fn all_adapter_capabilities() -> &'static [AdapterCapability] {
108    BUILTIN_ADAPTERS
109}
110
111pub fn adapter_capability(id: &str) -> Option<&'static AdapterCapability> {
112    BUILTIN_ADAPTERS.iter().find(|c| c.id == id).or_else(|| {
113        // Any known copy-paste profile → default copy-paste caps.
114        if typub_adapter_copypaste::find_profile(id).is_some() {
115            Some(&COPYPASTE_DEFAULT_CAPABILITY)
116        } else {
117            None
118        }
119    })
120}
121
122pub fn resolve_math_rendering(platform_id: &str) -> MathRendering {
123    adapter_capability(platform_id)
124        .map(|cap| cap.default_math_rendering())
125        .unwrap_or(MathRendering::Svg)
126}
127
128pub fn resolve_math_delimiters(platform_id: &str) -> MathDelimiters {
129    adapter_capability(platform_id)
130        .map(|cap| cap.default_math_delimiter())
131        .unwrap_or(MathDelimiters::Dollar)
132}
133
134pub fn resolve_code_highlight(platform_id: &str) -> bool {
135    adapter_capability(platform_id)
136        .map(|cap| cap.code_highlight)
137        .unwrap_or(false)
138}
139
140pub fn is_local_output_platform(id: &str) -> bool {
141    // Check API adapters first (using local_output field from TOML)
142    if let Some(cap) = BUILTIN_ADAPTERS.iter().find(|c| c.id == id) {
143        return cap.local_output;
144    }
145
146    // All copy-paste profiles are local-output
147    typub_adapter_copypaste::find_profile(id).is_some()
148}
149
150pub fn is_copypaste_platform(id: &str) -> bool {
151    typub_adapter_copypaste::find_profile(id).is_some()
152}
153
154pub fn platform_short_code(id: &str) -> Option<&'static str> {
155    // Check API adapters first
156    if let Some(cap) = BUILTIN_ADAPTERS.iter().find(|c| c.id == id) {
157        return Some(cap.short_code);
158    }
159    // Check copypaste profiles
160    if let Some(profile) = typub_adapter_copypaste::find_profile(id) {
161        return Some(profile.short_code);
162    }
163    None
164}
165
166pub fn resolve_platform_asset_strategy(
167    platform_id: &str,
168    platform_config: Option<&PlatformConfig>,
169    default: AssetStrategy,
170) -> Result<AssetStrategy> {
171    ResolvedConfig::resolve_asset_strategy_from_platform_config(
172        platform_id,
173        platform_config,
174        default,
175    )
176}
177
178pub fn resolve_asset_strategy_from_capability(
179    platform_id: &str,
180    platform_config: Option<&PlatformConfig>,
181) -> Result<AssetStrategy> {
182    let cap = adapter_capability(platform_id)
183        .ok_or_else(|| anyhow::anyhow!("Unknown platform: {}", platform_id))?;
184
185    resolve_platform_asset_strategy_with_policy(
186        platform_id,
187        platform_config,
188        cap.default_asset_strategy(),
189        cap.asset_strategy_policy(),
190    )
191}
192
193pub fn resolve_platform_asset_strategy_with_policy(
194    platform_id: &str,
195    platform_config: Option<&PlatformConfig>,
196    default: AssetStrategy,
197    policy: ImageStrategyPolicy,
198) -> Result<AssetStrategy> {
199    // Delegates to shared helper per [[ADR-0005]]
200    resolve_asset_strategy_with_policy(platform_id, platform_config, default, policy.supported)
201}
202
203pub struct PublishContext {
204    pub status: StatusTracker,
205    pub metadata: Arc<dyn MetadataService>,
206    resolved: Option<crate::resolved_config::ResolvedConfig>,
207    content_info: ContentInfo,
208    /// Whether we're in dry-run mode (mock asset uploads to temp dir)
209    pub dry_run: bool,
210}
211
212impl PublishContext {
213    pub fn new(_config: &Config) -> Result<Self> {
214        let status = StatusTracker::load(std::path::Path::new("."))?;
215        let metadata = Arc::new(crate::metadata::DefaultMetadataService {});
216        Ok(Self {
217            status,
218            metadata,
219            resolved: None,
220            content_info: ContentInfo::minimal("", "", std::path::PathBuf::new()),
221            dry_run: false,
222        })
223    }
224
225    pub fn new_with_root(_config: &Config, project_root: &Path) -> Result<Self> {
226        let status = StatusTracker::load(project_root)?;
227        let metadata = Arc::new(crate::metadata::DefaultMetadataService {});
228        Ok(Self {
229            status,
230            metadata,
231            resolved: None,
232            content_info: ContentInfo::minimal("", "", std::path::PathBuf::new()),
233            dry_run: false,
234        })
235    }
236
237    /// Create a new PublishContext for dry-run mode.
238    /// In dry-run mode, asset uploads are mocked (copied to temp dir).
239    pub fn new_dry_run(_config: &Config, project_root: &Path) -> Result<Self> {
240        let status = StatusTracker::load(project_root)?;
241        let metadata = Arc::new(crate::metadata::DefaultMetadataService {});
242        Ok(Self {
243            status,
244            metadata,
245            resolved: None,
246            content_info: ContentInfo::minimal("", "", std::path::PathBuf::new()),
247            dry_run: true,
248        })
249    }
250
251    pub fn set_resolved(&mut self, resolved: crate::resolved_config::ResolvedConfig) {
252        self.resolved = Some(resolved);
253    }
254
255    pub fn resolved(&self) -> Option<&crate::resolved_config::ResolvedConfig> {
256        self.resolved.as_ref()
257    }
258
259    pub fn set_content_info(&mut self, content_info: ContentInfo) {
260        self.content_info = content_info;
261    }
262
263    pub fn content_info(&self) -> &ContentInfo {
264        &self.content_info
265    }
266}
267
268impl typub_adapters_core::AdapterContext for PublishContext {
269    fn get_platform_id(&self, slug: &str, platform: &str) -> Result<Option<String>> {
270        self.status.get_platform_id(slug, platform)
271    }
272
273    fn normalize_terms(&self, terms: &[String]) -> Vec<String> {
274        self.metadata.normalize_terms(terms)
275    }
276
277    fn published(&self) -> bool {
278        self.resolved.as_ref().map(|r| r.published).unwrap_or(false)
279    }
280
281    fn storage_config(&self) -> Option<&StorageConfig> {
282        self.resolved.as_ref().and_then(|r| r.storage.as_ref())
283    }
284
285    fn theme_id(&self) -> Option<&str> {
286        self.resolved.as_ref().and_then(|r| r.theme_id.as_deref())
287    }
288
289    fn content_info(&self) -> &ContentInfo {
290        &self.content_info
291    }
292
293    fn status_tracker(&self) -> Option<&StatusTracker> {
294        Some(&self.status)
295    }
296
297    fn is_dry_run(&self) -> bool {
298        self.dry_run
299    }
300}
301
302pub use crate::assets::ensure_no_unresolved_image_markers;
303
304pub fn write_preview_file(slug: &str, platform: &str, html: &str) -> Result<PathBuf> {
305    let dir = std::env::temp_dir().join("typub-preview");
306    std::fs::create_dir_all(&dir)?;
307    let path = dir.join(format!("{slug}-{platform}.html"));
308    std::fs::write(&path, html)?;
309    Ok(path)
310}
311
312use typub_adapters_core::PlatformAdapter;
313
314pub struct AdapterRegistry {
315    adapters: HashMap<String, Box<dyn PlatformAdapter>>,
316}
317
318impl AdapterRegistry {
319    pub fn new(config: &Config) -> Result<Self> {
320        type Factory = fn(&Config) -> Result<Box<dyn PlatformAdapter>>;
321
322        // Factories with requires_config metadata
323        let factories: Vec<(&str, Factory, bool)> = vec![
324            ("astro", typub_adapter_astro::create, false),
325            ("static", typub_adapter_static::create, false),
326            ("xiaohongshu", typub_adapter_xiaohongshu::create, false),
327            ("confluence", typub_adapter_confluence::create, true),
328            ("devto", typub_adapter_devto::create, true),
329            ("ghost", typub_adapter_ghost::create, true),
330            ("hashnode", typub_adapter_hashnode::create, true),
331            ("notion", typub_adapter_notion::create, true),
332            ("wordpress", typub_adapter_wordpress::create, true),
333        ];
334
335        let mut adapters: HashMap<String, Box<dyn PlatformAdapter>> = HashMap::new();
336
337        for (id, factory, requires_config) in &factories {
338            let platform_config = config.get_platform(id);
339            let should_register = if *requires_config {
340                // Requires config: only register if config exists and is enabled
341                platform_config.is_some_and(|p| p.enabled)
342            } else {
343                // No config required: register unless explicitly disabled
344                platform_config.is_none_or(|p| p.enabled)
345            };
346
347            if should_register {
348                adapters.insert(id.to_string(), factory(config)?);
349            }
350        }
351
352        for profile in typub_adapter_copypaste::all_profiles() {
353            let explicitly_disabled = config.get_platform(profile.id).is_some_and(|p| !p.enabled);
354            if !explicitly_disabled {
355                let adapter =
356                    typub_adapter_copypaste::CopyPasteAdapter::from_profile(profile, config)?;
357                adapters.insert(profile.id.to_string(), Box::new(adapter));
358            }
359        }
360
361        for (id, pcfg) in &config.platforms {
362            if !pcfg.enabled || adapters.contains_key(id) {
363                continue;
364            }
365            if pcfg.get_str("type").as_deref() == Some("manual") {
366                let id_static: &'static str = Box::leak(id.clone().into_boxed_str());
367                let adapter = typub_adapter_copypaste::CopyPasteAdapter::from_manual_config(
368                    id_static, pcfg, config,
369                )?;
370                adapters.insert(id.clone(), Box::new(adapter));
371            }
372        }
373
374        Ok(Self { adapters })
375    }
376
377    pub fn get(&self, id: &str) -> Result<&dyn PlatformAdapter> {
378        self.adapters.get(id).map(|a| a.as_ref()).ok_or_else(|| {
379            anyhow::anyhow!(
380                "Platform '{}' not found. It may be disabled (enabled = false) or not a known platform ID.",
381                id,
382            )
383        })
384    }
385
386    pub fn list(&self) -> Vec<&str> {
387        self.adapters.keys().map(|s| s.as_str()).collect()
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    #![allow(clippy::expect_used)]
394
395    use super::*;
396    use typub_adapters_core::{AdapterPayload, downcast_payload};
397    use typub_html::{document, document_with_assets, image_marker};
398
399    #[test]
400    fn test_resolve_platform_asset_strategy_uses_default_when_missing() {
401        let strategy = resolve_platform_asset_strategy("devto", None, AssetStrategy::Upload)
402            .expect("should resolve default strategy");
403        assert_eq!(strategy, AssetStrategy::Upload);
404    }
405
406    #[test]
407    fn test_resolve_platform_asset_strategy_parses_override() {
408        let cfg = PlatformConfig {
409            enabled: true,
410            asset_strategy: Some("embed".to_string()),
411            published: None,
412            theme: None,
413            internal_link_target: None,
414            math_rendering: None,
415            math_delimiters: None,
416            extra: HashMap::new(),
417        };
418        let strategy = resolve_platform_asset_strategy("wechat", Some(&cfg), AssetStrategy::Copy)
419            .expect("should parse strategy override");
420        assert_eq!(strategy, AssetStrategy::Embed);
421    }
422
423    #[test]
424    fn test_resolve_platform_asset_strategy_rejects_invalid_value() {
425        let cfg = PlatformConfig {
426            enabled: true,
427            asset_strategy: Some("invalid".to_string()),
428            published: None,
429            theme: None,
430            internal_link_target: None,
431            math_rendering: None,
432            math_delimiters: None,
433            extra: HashMap::new(),
434        };
435        let err = resolve_platform_asset_strategy("astro", Some(&cfg), AssetStrategy::Copy)
436            .expect_err("invalid strategy should error");
437        assert!(err.to_string().contains("Invalid asset strategy"));
438    }
439
440    #[test]
441    fn test_policy_rejects_unsupported_strategy() {
442        let cfg = PlatformConfig {
443            enabled: true,
444            asset_strategy: Some("upload".to_string()),
445            published: None,
446            theme: None,
447            internal_link_target: None,
448            math_rendering: None,
449            math_delimiters: None,
450            extra: HashMap::new(),
451        };
452        let err = resolve_platform_asset_strategy_with_policy(
453            "astro",
454            Some(&cfg),
455            AssetStrategy::Copy,
456            ImageStrategyPolicy {
457                supported: &[AssetStrategy::Copy, AssetStrategy::Embed],
458            },
459        )
460        .expect_err("unsupported strategy should error");
461        assert!(err.to_string().contains("is not supported"));
462    }
463
464    // -- Capability lookup --
465
466    #[test]
467    fn test_all_adapter_capabilities_count() {
468        let caps = all_adapter_capabilities();
469        assert_eq!(caps.len(), 9);
470    }
471
472    #[test]
473    fn test_adapter_capability_api_platforms() {
474        for id in &[
475            "astro",
476            "static",
477            "confluence",
478            "devto",
479            "ghost",
480            "hashnode",
481            "notion",
482            "wordpress",
483            "xiaohongshu",
484        ] {
485            let cap = adapter_capability(id);
486            assert!(cap.is_some(), "capability for '{id}' should exist");
487            assert_eq!(cap.expect("checked").id, *id);
488        }
489    }
490
491    #[test]
492    fn test_adapter_capability_has_node_policy() {
493        for id in &[
494            "astro",
495            "static",
496            "confluence",
497            "devto",
498            "ghost",
499            "hashnode",
500            "notion",
501            "wordpress",
502            "xiaohongshu",
503            "wechat",
504        ] {
505            let cap = adapter_capability(id).expect("capability should exist");
506            let _ = cap.node_policy();
507        }
508    }
509
510    #[test]
511    fn test_adapter_capability_copypaste_fallback() {
512        // wechat is a built-in copy-paste profile, should get default capability
513        let cap = adapter_capability("wechat");
514        assert!(cap.is_some());
515        assert_eq!(cap.expect("checked").id, "copypaste");
516    }
517
518    #[test]
519    fn test_adapter_capability_unknown_returns_none() {
520        assert!(adapter_capability("nonexistent").is_none());
521    }
522
523    #[test]
524    fn test_capability_support_gap_behavior() {
525        assert!(CapabilitySupport::Supported.gap_behavior().is_none());
526        assert_eq!(
527            CapabilitySupport::Unsupported(CapabilityGapBehavior::WarnAndDegrade).gap_behavior(),
528            Some(CapabilityGapBehavior::WarnAndDegrade)
529        );
530        assert_eq!(
531            CapabilitySupport::Unsupported(CapabilityGapBehavior::HardError).gap_behavior(),
532            Some(CapabilityGapBehavior::HardError)
533        );
534    }
535
536    #[test]
537    fn test_adapter_capability_gap_methods() {
538        let cap = adapter_capability("astro").expect("astro exists");
539        // Astro has unsupported tags and categories, but supports internal links
540        assert!(cap.tags_gap_behavior().is_none());
541        assert!(cap.categories_gap_behavior().is_none());
542        assert!(cap.internal_links_gap_behavior().is_none()); // internal_links is Supported
543
544        let cap = adapter_capability("wordpress").expect("wordpress exists");
545        // WordPress supports everything
546        assert!(cap.tags_gap_behavior().is_none());
547        assert!(cap.categories_gap_behavior().is_none());
548        assert!(cap.internal_links_gap_behavior().is_none());
549    }
550
551    #[test]
552    fn test_image_strategy_policy_allow_all() {
553        let policy = ImageStrategyPolicy::allow_all();
554        assert_eq!(policy.supported.len(), 4);
555        assert!(policy.supported.contains(&AssetStrategy::Copy));
556        assert!(policy.supported.contains(&AssetStrategy::Embed));
557        assert!(policy.supported.contains(&AssetStrategy::Upload));
558        assert!(policy.supported.contains(&AssetStrategy::External));
559    }
560
561    #[test]
562    fn test_downcast_payload_success() {
563        let payload = AdapterPayload::simple(42u32, "test-slug");
564        let value: u32 = downcast_payload(payload, "test").expect("downcast should succeed");
565        assert_eq!(value, 42);
566    }
567
568    #[test]
569    fn test_downcast_payload_wrong_type() {
570        let payload = AdapterPayload::simple(42u32, "test-slug");
571        let err = downcast_payload::<String>(payload, "test").expect_err("wrong type");
572        assert!(err.to_string().contains("Invalid test publish payload"));
573    }
574
575    #[test]
576    fn test_adapter_payload_with_assets() {
577        use crate::assets::{AssetStrategy, DeferredAssets, PendingAsset, PendingAssetList};
578        use std::path::PathBuf;
579
580        let pending = PendingAssetList {
581            assets: vec![PendingAsset {
582                index: 0,
583                local_path: PathBuf::from("/tmp/a.png"),
584                original_ref: "a.png".to_string(),
585            }],
586        };
587        let assets = DeferredAssets::new(pending, AssetStrategy::External);
588        let content_info = ContentInfo::minimal("Test", "test-slug", "/tmp");
589        let payload = AdapterPayload::new(42u32, content_info, assets, document(Vec::new()));
590
591        assert!(payload.assets.needs_materialize());
592        let value: u32 = payload.downcast("test").expect("downcast");
593        assert_eq!(value, 42);
594    }
595
596    #[test]
597    fn test_adapter_payload_map_inner() {
598        let payload = AdapterPayload::simple(42u32, "test-slug");
599        let mapped = payload
600            .map_inner::<u32, _>("test", |v| v * 2)
601            .expect("map inner");
602        let value: u32 = mapped.downcast("test").expect("downcast");
603        assert_eq!(value, 84);
604    }
605
606    #[test]
607    fn test_ensure_no_unresolved_image_markers_no_deferred_strategy() {
608        let (block, (asset_id, asset)) =
609            image_marker("asset-a", "assets/a.png", "").expect("build image marker fixture");
610        let document = document_with_assets(vec![block], [(asset_id, asset)]);
611        ensure_no_unresolved_image_markers("astro", AssetStrategy::Copy, &document)
612            .expect("copy strategy should not enforce marker guard");
613    }
614
615    #[test]
616    fn test_ensure_no_unresolved_image_markers_errors_for_deferred_strategy() {
617        let (block, (asset_id, asset)) =
618            image_marker("asset-a", "assets/a.png", "").expect("build image marker fixture");
619        let document = document_with_assets(vec![block], [(asset_id, asset)]);
620        let err =
621            ensure_no_unresolved_image_markers("confluence", AssetStrategy::Upload, &document)
622                .expect_err("deferred strategy should fail on unresolved local assets");
623        assert!(err.to_string().contains("unresolved local asset"));
624    }
625
626    #[test]
627    fn test_adapter_registry_with_default_config() {
628        // Default config has no platforms — built-in copy-paste profiles
629        // and requires_config=false adapters (astro, static, xiaohongshu)
630        // should still be registered.
631        let config = Config::default();
632        let registry = AdapterRegistry::new(&config).expect("create registry");
633        let list = registry.list();
634        // Should have all 24 built-in copy-paste profiles
635        assert!(list.contains(&"wechat"));
636        assert!(list.contains(&"zhihu"));
637        assert!(list.contains(&"csdn"));
638        // Should also have requires_config=false adapters
639        assert!(list.contains(&"astro"));
640        assert!(list.contains(&"static"));
641        assert!(list.contains(&"xiaohongshu"));
642        // Should NOT have requires_config=true adapters without config
643        assert!(!list.contains(&"devto"));
644        assert!(!list.contains(&"ghost"));
645    }
646
647    #[test]
648    fn test_adapter_registry_get_nonexistent() {
649        let config = Config::default();
650        let registry = AdapterRegistry::new(&config).expect("create registry");
651        match registry.get("nonexistent") {
652            Ok(_) => panic!("expected error for nonexistent platform"),
653            Err(e) => assert!(e.to_string().contains("not found")),
654        }
655    }
656
657    #[test]
658    fn test_adapter_registry_explicit_disable() {
659        let config: Config = toml::from_str(
660            r#"
661[platforms.wechat]
662enabled = false
663"#,
664        )
665        .expect("parse config");
666        let registry = AdapterRegistry::new(&config).expect("create registry");
667        // wechat should be excluded
668        assert!(!registry.list().contains(&"wechat"));
669        // But other built-in profiles should still be present
670        assert!(registry.list().contains(&"zhihu"));
671    }
672
673    /// Per [[ADR-0010]]: requires_config=false adapters can be explicitly disabled
674    #[test]
675    fn test_adapter_registry_requires_config_false_can_be_disabled() {
676        let config: Config = toml::from_str(
677            r#"
678[platforms.astro]
679enabled = false
680"#,
681        )
682        .expect("parse config");
683        let registry = AdapterRegistry::new(&config).expect("create registry");
684        // astro should be excluded when explicitly disabled
685        assert!(!registry.list().contains(&"astro"));
686        // Other requires_config=false adapters should still be present
687        assert!(registry.list().contains(&"static"));
688        assert!(registry.list().contains(&"xiaohongshu"));
689    }
690
691    /// Per [[ADR-0010]]: requires_config=true adapter is registered when config exists AND enabled
692    #[test]
693    fn test_adapter_registry_requires_config_true_registered_when_enabled() {
694        let config: Config = toml::from_str(
695            r#"
696[platforms.devto]
697enabled = true
698"#,
699        )
700        .expect("parse config");
701        let registry = AdapterRegistry::new(&config).expect("create registry");
702        // devto should be registered when config exists and is enabled
703        assert!(registry.list().contains(&"devto"));
704        // Other requires_config=true adapters without config should NOT be registered
705        assert!(!registry.list().contains(&"ghost"));
706        assert!(!registry.list().contains(&"wordpress"));
707    }
708
709    /// Per [[ADR-0010]]: requires_config=true adapter is NOT registered when config exists but disabled
710    #[test]
711    fn test_adapter_registry_requires_config_true_not_registered_when_disabled() {
712        let config: Config = toml::from_str(
713            r#"
714[platforms.ghost]
715enabled = false
716"#,
717        )
718        .expect("parse config");
719        let registry = AdapterRegistry::new(&config).expect("create registry");
720        // ghost should NOT be registered when explicitly disabled
721        assert!(!registry.list().contains(&"ghost"));
722    }
723
724    /// Per [[ADR-0010]]: verify capability requires_config values
725    #[test]
726    fn test_adapter_capability_requires_config_values() {
727        // requires_config=false
728        let astro = adapter_capability("astro").expect("astro exists");
729        assert!(!astro.requires_config);
730
731        let static_cap = adapter_capability("static").expect("static exists");
732        assert!(!static_cap.requires_config);
733
734        let xiaohongshu = adapter_capability("xiaohongshu").expect("xiaohongshu exists");
735        assert!(!xiaohongshu.requires_config);
736
737        // requires_config=true
738        let devto = adapter_capability("devto").expect("devto exists");
739        assert!(devto.requires_config);
740
741        let ghost = adapter_capability("ghost").expect("ghost exists");
742        assert!(ghost.requires_config);
743
744        let wordpress = adapter_capability("wordpress").expect("wordpress exists");
745        assert!(wordpress.requires_config);
746
747        let hashnode = adapter_capability("hashnode").expect("hashnode exists");
748        assert!(hashnode.requires_config);
749
750        let confluence = adapter_capability("confluence").expect("confluence exists");
751        assert!(confluence.requires_config);
752
753        let notion = adapter_capability("notion").expect("notion exists");
754        assert!(notion.requires_config);
755    }
756}