Skip to main content

zeroclaw_runtime/integrations/
registry.rs

1//! Integration catalog — schema-driven, single-loop.
2//!
3//! Every entry comes from a schema-side source:
4//! - Channels: `ChannelsConfig::channels()` (each multi-instance V3
5//!   channel field surfaces as one `ChannelInfo` entry; name and desc
6//!   strings live in `channels()` itself, not in this file).
7//! - Toggle integrations: `Config::integration_descriptors()` (per-struct
8//!   `#[integration(...)]` attribute on `BrowserConfig` /
9//!   `GoogleWorkspaceConfig`, plus an inline descriptor for `cron` whose
10//!   `active` reflects whether any job is configured — cron is now a
11//!   `HashMap<String, CronJobDecl>` with no enable toggle struct).
12//! - AI providers: `zeroclaw_providers::list_providers()` (each
13//!   `ProviderInfo` row carries `display_name`, `description`, and a
14//!   `ProviderActivation` strategy).
15//! - Always-on built-in tools: `crate::tools::BUILTIN_TOOL_INTEGRATIONS`.
16//! - Platforms: `super::platform::PLATFORMS` (compile-time `cfg!` facts).
17//!
18//! No string literal naming a channel, vendor, tool, or platform appears
19//! in this file's production path. Adding a new integration of any kind
20//! is one row in the corresponding schema source — the registry picks
21//! it up automatically.
22
23use super::platform::PLATFORMS;
24use super::{IntegrationCategory, IntegrationEntry, IntegrationStatus};
25use crate::tools::BUILTIN_TOOL_INTEGRATIONS;
26use zeroclaw_config::schema::Config;
27
28fn bool_to_status(active: bool) -> IntegrationStatus {
29    if active {
30        IntegrationStatus::Active
31    } else {
32        IntegrationStatus::Available
33    }
34}
35
36/// Map the schema-side `#[integration(category = "...")]` label to the
37/// runtime enum. The schema crate intentionally keeps the label as a
38/// string to avoid taking a dependency on this crate's enum.
39fn parse_category(label: &str) -> IntegrationCategory {
40    match label {
41        "Chat" => IntegrationCategory::Chat,
42        "AiModel" => IntegrationCategory::AiModel,
43        "ToolsAutomation" => IntegrationCategory::ToolsAutomation,
44        "Platform" => IntegrationCategory::Platform,
45        // Defensive default; the schema's `#[integration(category = ...)]`
46        // attribute is the source of truth for valid labels.
47        _ => IntegrationCategory::ToolsAutomation,
48    }
49}
50
51/// Compute an AI-model integration's status from typed-family slot
52/// occupancy. The registry never branches on a provider name — the
53/// canonical slot list (`for_each_model_provider_slot!`) is the single
54/// source of truth, and a slot is "active" iff at least one alias is
55/// configured under it. Regional variants and OAuth modes that used to
56/// drive richer activation predicates are now folded onto the parent
57/// typed slot, so per-row activation enums are unnecessary.
58fn evaluate_model_provider_activation(
59    config: &Config,
60    info: &zeroclaw_providers::ModelProviderInfo,
61) -> IntegrationStatus {
62    bool_to_status(
63        config
64            .providers
65            .models
66            .contains_model_provider_type(info.name),
67    )
68}
69
70/// Returns the integration catalog computed against `config`.
71///
72/// Single-loop, schema-driven. Every per-row decision lives on the
73/// schema-side source; this function just concatenates the iterators.
74///
75/// Channel discovery walks `ChannelsConfig::channels()` which always
76/// returns all known channel types; each `ChannelInfo` carries name,
77/// desc, and a configured flag.  Multi-instance V3 channels are
78/// reported active when any alias is configured.
79pub fn all_integrations(config: &Config) -> Vec<IntegrationEntry> {
80    let channels = config
81        .channels
82        .channels()
83        .into_iter()
84        .map(|info| IntegrationEntry {
85            name: info.name.to_string(),
86            description: info.desc.to_string(),
87            category: IntegrationCategory::Chat,
88            status: bool_to_status(info.configured),
89        });
90
91    let toggles = config
92        .integration_descriptors()
93        .into_iter()
94        .map(|d| IntegrationEntry {
95            name: d.display_name.to_string(),
96            description: d.description.to_string(),
97            category: parse_category(d.category),
98            status: bool_to_status(d.active),
99        });
100
101    let providers = zeroclaw_providers::list_model_providers()
102        .into_iter()
103        .map(|info| {
104            let status = evaluate_model_provider_activation(config, &info);
105            IntegrationEntry {
106                name: info.display_name.to_string(),
107                description: String::new(),
108                category: IntegrationCategory::AiModel,
109                status,
110            }
111        });
112
113    let builtins = BUILTIN_TOOL_INTEGRATIONS
114        .iter()
115        .map(|(name, desc)| IntegrationEntry {
116            name: (*name).to_string(),
117            description: (*desc).to_string(),
118            category: IntegrationCategory::ToolsAutomation,
119            status: IntegrationStatus::Active,
120        });
121
122    let platforms = PLATFORMS.iter().map(|(name, available)| IntegrationEntry {
123        name: (*name).to_string(),
124        description: String::new(),
125        category: IntegrationCategory::Platform,
126        status: bool_to_status(*available),
127    });
128
129    channels
130        .chain(toggles)
131        .chain(providers)
132        .chain(builtins)
133        .chain(platforms)
134        .collect()
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use zeroclaw_config::schema::Config;
141    use zeroclaw_config::schema::{IMessageConfig, MatrixConfig, StreamMode, TelegramConfig};
142    use zeroclaw_config::traits::ChannelConfig;
143
144    #[test]
145    fn registry_has_entries() {
146        let config = Config::default();
147        let entries = all_integrations(&config);
148        assert!(
149            entries.len() >= 30,
150            "Expected 30+ integrations, got {}",
151            entries.len()
152        );
153    }
154
155    #[test]
156    fn all_categories_represented() {
157        let config = Config::default();
158        let entries = all_integrations(&config);
159        for cat in IntegrationCategory::all() {
160            let count = entries.iter().filter(|e| e.category == *cat).count();
161            assert!(count > 0, "Category {cat:?} has no entries");
162        }
163    }
164
165    #[test]
166    fn no_duplicate_names() {
167        let config = Config::default();
168        let entries = all_integrations(&config);
169        let mut seen = std::collections::HashSet::new();
170        for entry in &entries {
171            assert!(
172                seen.insert(entry.name.clone()),
173                "Duplicate integration name: {}",
174                entry.name
175            );
176        }
177    }
178
179    #[test]
180    fn channel_entries_carry_per_field_metadata_from_schema() {
181        // Schema-driven contract: every channel registered through
182        // `ChannelsConfig::channels()` surfaces as a Chat entry whose
183        // display_name and description come from the channel's
184        // `ChannelConfig::name()` / `desc()` methods — no override
185        // table lives here. V3 channels are HashMap<alias, XConfig>
186        // (one entry per channel type at the registry level), so the
187        // count must equal the number of (handle, _) pairs returned.
188        let config = Config::default();
189        let entries = all_integrations(&config);
190        let channel_count = entries
191            .iter()
192            .filter(|e| e.category == IntegrationCategory::Chat)
193            .count();
194        let channel_infos = config.channels.channels();
195        assert_eq!(
196            channel_count,
197            channel_infos.len(),
198            "every ChannelsConfig::channels() entry should produce exactly one Chat entry",
199        );
200        for info in &channel_infos {
201            let entry = entries
202                .iter()
203                .find(|e| e.name == info.name)
204                .unwrap_or_else(|| {
205                    panic!(
206                        "channel {:?} ({:?}) missing from registry",
207                        info.name, info.desc,
208                    )
209                });
210            assert!(
211                !entry.name.is_empty(),
212                "channel {:?} produced empty display name",
213                info.name,
214            );
215            assert!(
216                !entry.description.is_empty(),
217                "channel {:?} missing description text",
218                info.name,
219            );
220        }
221    }
222
223    #[test]
224    fn telegram_active_when_configured() {
225        let mut config = Config::default();
226        config.channels.telegram.insert(
227            "default".to_string(),
228            TelegramConfig {
229                enabled: true,
230                bot_token: "123:ABC".into(),
231                stream_mode: StreamMode::default(),
232                draft_update_interval_ms: 1000,
233                interrupt_on_new_message: false,
234                mention_only: false,
235                ack_reactions: None,
236                proxy_url: None,
237                approval_timeout_secs: 120,
238                excluded_tools: vec![],
239                default_target: None,
240            },
241        );
242        let entries = all_integrations(&config);
243        let display_name = <TelegramConfig as ChannelConfig>::name();
244        let tg = entries.iter().find(|e| e.name == display_name).unwrap();
245        assert!(matches!(tg.status, IntegrationStatus::Active));
246    }
247
248    #[test]
249    fn telegram_available_when_not_configured() {
250        let config = Config::default();
251        let entries = all_integrations(&config);
252        let display_name = <TelegramConfig as ChannelConfig>::name();
253        let tg = entries.iter().find(|e| e.name == display_name).unwrap();
254        assert!(matches!(tg.status, IntegrationStatus::Available));
255    }
256
257    #[test]
258    fn imessage_active_when_configured() {
259        let mut config = Config::default();
260        config.channels.imessage.insert(
261            "default".to_string(),
262            IMessageConfig {
263                enabled: true,
264                excluded_tools: vec![],
265            },
266        );
267        let entries = all_integrations(&config);
268        let display_name = <IMessageConfig as ChannelConfig>::name();
269        let im = entries.iter().find(|e| e.name == display_name).unwrap();
270        assert!(matches!(im.status, IntegrationStatus::Active));
271    }
272
273    #[test]
274    fn imessage_available_when_not_configured() {
275        let config = Config::default();
276        let entries = all_integrations(&config);
277        let display_name = <IMessageConfig as ChannelConfig>::name();
278        let im = entries.iter().find(|e| e.name == display_name).unwrap();
279        assert!(matches!(im.status, IntegrationStatus::Available));
280    }
281
282    #[test]
283    fn matrix_active_when_configured() {
284        let mut config = Config::default();
285        config.channels.matrix.insert(
286            "default".to_string(),
287            MatrixConfig {
288                enabled: true,
289                homeserver: "https://m.org".into(),
290                access_token: Some("tok".into()),
291                user_id: None,
292                device_id: None,
293                allowed_rooms: vec!["!r:m".into()],
294                interrupt_on_new_message: false,
295                stream_mode: zeroclaw_config::schema::StreamMode::default(),
296                draft_update_interval_ms: 1500,
297                multi_message_delay_ms: 800,
298                recovery_key: None,
299                password: None,
300                mention_only: false,
301                approval_timeout_secs: 300,
302                reply_in_thread: true,
303                ack_reactions: Some(true),
304                excluded_tools: vec![],
305                default_target: None,
306            },
307        );
308        let entries = all_integrations(&config);
309        let display_name = <MatrixConfig as ChannelConfig>::name();
310        let mx = entries.iter().find(|e| e.name == display_name).unwrap();
311        assert!(matches!(mx.status, IntegrationStatus::Active));
312    }
313
314    /// Look up a toggle integration's status by its descriptor display
315    /// name. Each call to `Config::integration_descriptors()` is the
316    /// schema-side source of truth, so the helper resolves the entry
317    /// dynamically rather than hardcoding the display string.
318    fn toggle_status(config: &Config, field_filter: impl Fn(&str) -> bool) -> IntegrationStatus {
319        let descriptor = config
320            .integration_descriptors()
321            .into_iter()
322            .find(|d| field_filter(d.display_name))
323            .unwrap_or_else(|| panic!("expected toggle integration descriptor not present"));
324        let entries = all_integrations(config);
325        let entry = entries
326            .iter()
327            .find(|e| e.name == descriptor.display_name)
328            .unwrap_or_else(|| {
329                panic!(
330                    "registry missing toggle integration entry for {:?}",
331                    descriptor.display_name,
332                )
333            });
334        entry.status
335    }
336
337    #[test]
338    fn browser_active_in_default_config() {
339        // BrowserConfig::default() has enabled=true, so the toggle
340        // should be Active in the unconfigured registry.
341        let config = Config::default();
342        assert!(matches!(
343            toggle_status(&config, |n| n == "Browser"),
344            IntegrationStatus::Active
345        ));
346    }
347
348    #[test]
349    fn browser_available_when_disabled() {
350        let mut config = Config::default();
351        config.browser.enabled = false;
352        assert!(matches!(
353            toggle_status(&config, |n| n == "Browser"),
354            IntegrationStatus::Available
355        ));
356    }
357
358    #[test]
359    fn google_workspace_available_in_default_config() {
360        // GoogleWorkspaceConfig defaults to enabled=false.
361        let config = Config::default();
362        assert!(matches!(
363            toggle_status(&config, |n| n == "Google Workspace"),
364            IntegrationStatus::Available
365        ));
366    }
367
368    #[test]
369    fn google_workspace_active_when_enabled() {
370        let mut config = Config::default();
371        config.google_workspace.enabled = true;
372        assert!(matches!(
373            toggle_status(&config, |n| n == "Google Workspace"),
374            IntegrationStatus::Active
375        ));
376    }
377
378    #[test]
379    fn cron_available_when_no_jobs_configured() {
380        let config = Config::default();
381        assert!(matches!(
382            toggle_status(&config, |n| n == "Cron"),
383            IntegrationStatus::Available
384        ));
385    }
386
387    #[test]
388    fn cron_active_when_any_job_configured() {
389        // Cron is HashMap<String, CronJobDecl>; the descriptor's
390        // `active` reflects `!cron.is_empty()`, so a single entry
391        // (default-constructed) flips the toggle to Active.
392        let mut config = Config::default();
393        config.cron.insert(
394            "daily-digest".to_string(),
395            zeroclaw_config::schema::CronJobDecl::default(),
396        );
397        assert!(matches!(
398            toggle_status(&config, |n| n == "Cron"),
399            IntegrationStatus::Active
400        ));
401    }
402
403    #[test]
404    fn builtin_tool_integrations_always_active() {
405        // Drift detector: every row in BUILTIN_TOOL_INTEGRATIONS must
406        // surface as an Active entry. Adding / removing a built-in is
407        // the single edit point.
408        let config = Config::default();
409        let entries = all_integrations(&config);
410        for (name, _desc) in BUILTIN_TOOL_INTEGRATIONS {
411            let entry = entries
412                .iter()
413                .find(|e| e.name == *name)
414                .unwrap_or_else(|| panic!("built-in {name:?} missing from registry"));
415            assert!(
416                matches!(entry.status, IntegrationStatus::Active),
417                "{name} should always be Active",
418            );
419        }
420    }
421
422    #[test]
423    fn platforms_match_compile_time_constants() {
424        let config = Config::default();
425        let entries = all_integrations(&config);
426        for (name, available) in PLATFORMS {
427            let entry = entries
428                .iter()
429                .find(|e| e.name == *name)
430                .unwrap_or_else(|| panic!("platform {name:?} missing from registry"));
431            let expected = bool_to_status(*available);
432            assert_eq!(
433                entry.status, expected,
434                "platform {name:?} status disagrees with PLATFORMS const",
435            );
436        }
437    }
438
439    #[test]
440    fn populated_typed_slot_activates_corresponding_ai_integration() {
441        // PR-branch typed-family layout: regional variants are folded
442        // onto the parent canonical slot (e.g. minimax-cn → minimax with
443        // a typed `endpoint` enum on the alias entry). Activation is
444        // therefore "any alias under the canonical slot" — a one-call
445        // `contains_model_provider_type` check that drops the V2-era
446        // `FallbackKeyMatches` predicate scaffolding.
447        //
448        // Drives every entry of `list_model_providers()` so adding a
449        // new family later (one row in `for_each_model_provider_slot!`
450        // + one display_name row here) is automatically covered.
451        for info in zeroclaw_providers::list_model_providers() {
452            let mut config = Config::default();
453            assert!(
454                config
455                    .providers
456                    .models
457                    .ensure(info.name, "default")
458                    .is_some(),
459                "ModelProviderInfo {:?} must correspond to a typed slot \
460                 (drift: name not in `for_each_model_provider_slot!`)",
461                info.name,
462            );
463            let entries = all_integrations(&config);
464            let integration = entries
465                .iter()
466                .find(|e| e.name == info.display_name)
467                .unwrap_or_else(|| {
468                    panic!(
469                        "integration entry for {:?} (display {:?}) must exist",
470                        info.name, info.display_name,
471                    )
472                });
473            assert!(
474                matches!(integration.status, IntegrationStatus::Active),
475                "configuring slot {:?} must activate {:?} integration",
476                info.name,
477                info.display_name,
478            );
479        }
480    }
481}