zeroclaw_runtime/integrations/
registry.rs1use 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
36fn 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 _ => IntegrationCategory::ToolsAutomation,
48 }
49}
50
51fn 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
70pub 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 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 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 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 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 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 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 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}