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