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                reply_min_interval_secs: 0,
240                reply_queue_depth_max: 0,
241            },
242        );
243        let entries = all_integrations(&config);
244        let display_name = <TelegramConfig as ChannelConfig>::name();
245        let tg = entries.iter().find(|e| e.name == display_name).unwrap();
246        assert!(matches!(tg.status, IntegrationStatus::Active));
247    }
248
249    #[test]
250    fn telegram_available_when_not_configured() {
251        let config = Config::default();
252        let entries = all_integrations(&config);
253        let display_name = <TelegramConfig as ChannelConfig>::name();
254        let tg = entries.iter().find(|e| e.name == display_name).unwrap();
255        assert!(matches!(tg.status, IntegrationStatus::Available));
256    }
257
258    #[test]
259    fn imessage_active_when_configured() {
260        let mut config = Config::default();
261        config.channels.imessage.insert(
262            "default".to_string(),
263            IMessageConfig {
264                enabled: true,
265                excluded_tools: vec![],
266                reply_min_interval_secs: 0,
267                reply_queue_depth_max: 0,
268            },
269        );
270        let entries = all_integrations(&config);
271        let display_name = <IMessageConfig as ChannelConfig>::name();
272        let im = entries.iter().find(|e| e.name == display_name).unwrap();
273        assert!(matches!(im.status, IntegrationStatus::Active));
274    }
275
276    #[test]
277    fn imessage_available_when_not_configured() {
278        let config = Config::default();
279        let entries = all_integrations(&config);
280        let display_name = <IMessageConfig as ChannelConfig>::name();
281        let im = entries.iter().find(|e| e.name == display_name).unwrap();
282        assert!(matches!(im.status, IntegrationStatus::Available));
283    }
284
285    #[test]
286    fn matrix_active_when_configured() {
287        let mut config = Config::default();
288        config.channels.matrix.insert(
289            "default".to_string(),
290            MatrixConfig {
291                enabled: true,
292                homeserver: "https://m.org".into(),
293                access_token: Some("tok".into()),
294                user_id: None,
295                device_id: None,
296                allowed_rooms: vec!["!r:m".into()],
297                interrupt_on_new_message: false,
298                stream_mode: zeroclaw_config::schema::StreamMode::default(),
299                draft_update_interval_ms: 1500,
300                multi_message_delay_ms: 800,
301                recovery_key: None,
302                password: None,
303                mention_only: false,
304                approval_timeout_secs: 300,
305                reply_in_thread: true,
306                ack_reactions: Some(true),
307                excluded_tools: vec![],
308                reply_min_interval_secs: 0,
309                reply_queue_depth_max: 0,
310            },
311        );
312        let entries = all_integrations(&config);
313        let display_name = <MatrixConfig as ChannelConfig>::name();
314        let mx = entries.iter().find(|e| e.name == display_name).unwrap();
315        assert!(matches!(mx.status, IntegrationStatus::Active));
316    }
317
318    /// Look up a toggle integration's status by its descriptor display
319    /// name. Each call to `Config::integration_descriptors()` is the
320    /// schema-side source of truth, so the helper resolves the entry
321    /// dynamically rather than hardcoding the display string.
322    fn toggle_status(config: &Config, field_filter: impl Fn(&str) -> bool) -> IntegrationStatus {
323        let descriptor = config
324            .integration_descriptors()
325            .into_iter()
326            .find(|d| field_filter(d.display_name))
327            .unwrap_or_else(|| panic!("expected toggle integration descriptor not present"));
328        let entries = all_integrations(config);
329        let entry = entries
330            .iter()
331            .find(|e| e.name == descriptor.display_name)
332            .unwrap_or_else(|| {
333                panic!(
334                    "registry missing toggle integration entry for {:?}",
335                    descriptor.display_name,
336                )
337            });
338        entry.status
339    }
340
341    #[test]
342    fn browser_active_in_default_config() {
343        // BrowserConfig::default() has enabled=true, so the toggle
344        // should be Active in the unconfigured registry.
345        let config = Config::default();
346        assert!(matches!(
347            toggle_status(&config, |n| n == "Browser"),
348            IntegrationStatus::Active
349        ));
350    }
351
352    #[test]
353    fn browser_available_when_disabled() {
354        let mut config = Config::default();
355        config.browser.enabled = false;
356        assert!(matches!(
357            toggle_status(&config, |n| n == "Browser"),
358            IntegrationStatus::Available
359        ));
360    }
361
362    #[test]
363    fn google_workspace_available_in_default_config() {
364        // GoogleWorkspaceConfig defaults to enabled=false.
365        let config = Config::default();
366        assert!(matches!(
367            toggle_status(&config, |n| n == "Google Workspace"),
368            IntegrationStatus::Available
369        ));
370    }
371
372    #[test]
373    fn google_workspace_active_when_enabled() {
374        let mut config = Config::default();
375        config.google_workspace.enabled = true;
376        assert!(matches!(
377            toggle_status(&config, |n| n == "Google Workspace"),
378            IntegrationStatus::Active
379        ));
380    }
381
382    #[test]
383    fn cron_available_when_no_jobs_configured() {
384        let config = Config::default();
385        assert!(matches!(
386            toggle_status(&config, |n| n == "Cron"),
387            IntegrationStatus::Available
388        ));
389    }
390
391    #[test]
392    fn cron_active_when_any_job_configured() {
393        // Cron is HashMap<String, CronJobDecl>; the descriptor's
394        // `active` reflects `!cron.is_empty()`, so a single entry
395        // (default-constructed) flips the toggle to Active.
396        let mut config = Config::default();
397        config.cron.insert(
398            "daily-digest".to_string(),
399            zeroclaw_config::schema::CronJobDecl::default(),
400        );
401        assert!(matches!(
402            toggle_status(&config, |n| n == "Cron"),
403            IntegrationStatus::Active
404        ));
405    }
406
407    #[test]
408    fn builtin_tool_integrations_always_active() {
409        // Drift detector: every row in BUILTIN_TOOL_INTEGRATIONS must
410        // surface as an Active entry. Adding / removing a built-in is
411        // the single edit point.
412        let config = Config::default();
413        let entries = all_integrations(&config);
414        for (name, _desc) in BUILTIN_TOOL_INTEGRATIONS {
415            let entry = entries
416                .iter()
417                .find(|e| e.name == *name)
418                .unwrap_or_else(|| panic!("built-in {name:?} missing from registry"));
419            assert!(
420                matches!(entry.status, IntegrationStatus::Active),
421                "{name} should always be Active",
422            );
423        }
424    }
425
426    #[test]
427    fn platforms_match_compile_time_constants() {
428        let config = Config::default();
429        let entries = all_integrations(&config);
430        for (name, available) in PLATFORMS {
431            let entry = entries
432                .iter()
433                .find(|e| e.name == *name)
434                .unwrap_or_else(|| panic!("platform {name:?} missing from registry"));
435            let expected = bool_to_status(*available);
436            assert_eq!(
437                entry.status, expected,
438                "platform {name:?} status disagrees with PLATFORMS const",
439            );
440        }
441    }
442
443    #[test]
444    fn populated_typed_slot_activates_corresponding_ai_integration() {
445        // PR-branch typed-family layout: regional variants are folded
446        // onto the parent canonical slot (e.g. minimax-cn → minimax with
447        // a typed `endpoint` enum on the alias entry). Activation is
448        // therefore "any alias under the canonical slot" — a one-call
449        // `contains_model_provider_type` check that drops the V2-era
450        // `FallbackKeyMatches` predicate scaffolding.
451        //
452        // Drives every entry of `list_model_providers()` so adding a
453        // new family later (one row in `for_each_model_provider_slot!`
454        // + one display_name row here) is automatically covered.
455        for info in zeroclaw_providers::list_model_providers() {
456            let mut config = Config::default();
457            assert!(
458                config
459                    .providers
460                    .models
461                    .ensure(info.name, "default")
462                    .is_some(),
463                "ModelProviderInfo {:?} must correspond to a typed slot \
464                 (drift: name not in `for_each_model_provider_slot!`)",
465                info.name,
466            );
467            let entries = all_integrations(&config);
468            let integration = entries
469                .iter()
470                .find(|e| e.name == info.display_name)
471                .unwrap_or_else(|| {
472                    panic!(
473                        "integration entry for {:?} (display {:?}) must exist",
474                        info.name, info.display_name,
475                    )
476                });
477            assert!(
478                matches!(integration.status, IntegrationStatus::Active),
479                "configuring slot {:?} must activate {:?} integration",
480                info.name,
481                info.display_name,
482            );
483        }
484    }
485}