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 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 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 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 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 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 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 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}