Skip to main content

devboy_executor/
factory.rs

1use devboy_core::{
2    Error, KnowledgeBaseProvider, MeetingNotesProvider, MessengerProvider, Provider, Result,
3    ToolEnricher,
4};
5
6use crate::context::{
7    ClickUpScope, ConfluenceAuthConfig, ConfluenceScope, GitHubScope, GitLabScope, JiraScope,
8    ProviderConfig, ProviderMetadata, ProxyConfig, SlackScope,
9};
10
11/// Create a provider instance from a typed `ProviderConfig`.
12///
13/// Provider is created on the stack — cheap and stateless.
14/// The scope determines which project/repo/list is targeted.
15///
16/// # Unsupported scopes
17///
18/// Group, Organization, and Global scopes are not yet implemented.
19/// They will be added when cross-project queries are needed.
20pub fn create_provider(
21    config: &ProviderConfig,
22    proxy: Option<&ProxyConfig>,
23) -> Result<Box<dyn Provider>> {
24    match config {
25        ProviderConfig::GitLab {
26            base_url,
27            access_token,
28            scope,
29            ..
30        } => match scope {
31            GitLabScope::Project { id } => {
32                let client = if let Some(proxy) = proxy {
33                    devboy_gitlab::GitLabClient::with_base_url(
34                        &proxy.url,
35                        id,
36                        access_token.clone(),
37                    )
38                    .with_proxy(proxy.headers.clone())
39                } else {
40                    devboy_gitlab::GitLabClient::with_base_url(
41                        base_url,
42                        id,
43                        access_token.clone(),
44                    )
45                };
46                Ok(Box::new(client))
47            }
48            GitLabScope::Group { id } => Err(Error::ProviderUnsupported {
49                provider: "gitlab".into(),
50                operation: format!("group scope (group_id: {id}) not yet implemented"),
51            }),
52            GitLabScope::Global => Err(Error::ProviderUnsupported {
53                provider: "gitlab".into(),
54                operation: "global scope not yet implemented".into(),
55            }),
56        },
57
58        ProviderConfig::GitHub {
59            base_url,
60            access_token,
61            scope,
62            ..
63        } => match scope {
64            GitHubScope::Repository { owner, repo } => {
65                Ok(Box::new(devboy_github::GitHubClient::with_base_url(
66                    base_url,
67                    owner,
68                    repo,
69                    access_token.clone(),
70                )))
71            }
72            GitHubScope::Organization { name } => Err(Error::ProviderUnsupported {
73                provider: "github".into(),
74                operation: format!("organization scope (org: {name}) not yet implemented"),
75            }),
76            GitHubScope::Global => Err(Error::ProviderUnsupported {
77                provider: "github".into(),
78                operation: "global scope not yet implemented".into(),
79            }),
80        },
81
82        ProviderConfig::ClickUp {
83            access_token,
84            scope,
85            ..
86        } => match scope {
87            ClickUpScope::List { id, team_id } => {
88                let mut client = devboy_clickup::ClickUpClient::new(id, access_token.clone());
89                if let Some(tid) = team_id {
90                    client = client.with_team_id(tid);
91                }
92                Ok(Box::new(client))
93            }
94        },
95
96        ProviderConfig::Jira {
97            base_url,
98            access_token,
99            email,
100            scope,
101            flavor,
102            ..
103        } => match scope {
104            JiraScope::Project { key } => {
105                let mut client = if let Some(proxy) = proxy {
106                    devboy_jira::JiraClient::new(
107                        &proxy.url,
108                        key,
109                        email,
110                        access_token.clone(),
111                    )
112                    .with_proxy(proxy.headers.clone())
113                    .with_instance_url(base_url)
114                } else {
115                    devboy_jira::JiraClient::new(base_url, key, email, access_token.clone())
116                };
117                if let Some(f) = flavor {
118                    client = client.with_flavor(*f);
119                }
120                Ok(Box::new(client))
121            }
122            JiraScope::MultiProject { keys } => Err(Error::ProviderUnsupported {
123                provider: "jira".into(),
124                operation: format!(
125                    "multi-project scope ({}) not yet implemented",
126                    keys.join(", ")
127                ),
128            }),
129        },
130
131        ProviderConfig::Confluence { .. } => Err(Error::ProviderUnsupported {
132            provider: "confluence".into(),
133            operation: "Confluence is a KnowledgeBaseProvider, not a Provider. Use create_knowledge_base_provider() instead.".into(),
134        }),
135
136        ProviderConfig::Fireflies { .. } => Err(Error::ProviderUnsupported {
137            provider: "fireflies".into(),
138            operation: "Fireflies is a MeetingNotesProvider, not a Provider. Use create_meeting_notes_provider() instead.".into(),
139        }),
140
141        ProviderConfig::Slack { .. } => Err(Error::ProviderUnsupported {
142            provider: "slack".into(),
143            operation: "Slack is a MessengerProvider, not a Provider. Use create_messenger_provider() instead.".into(),
144        }),
145
146        ProviderConfig::Custom { name, .. } => Err(Error::ProviderNotFound(format!(
147            "custom provider '{name}' not yet supported"
148        ))),
149    }
150}
151
152pub fn create_knowledge_base_provider(
153    config: &ProviderConfig,
154    proxy: Option<&ProxyConfig>,
155) -> Result<Box<dyn KnowledgeBaseProvider>> {
156    match config {
157        ProviderConfig::Confluence {
158            base_url,
159            auth,
160            api_version,
161            scope: ConfluenceScope::Space { .. },
162            ..
163        } => {
164            let client = if let Some(proxy) = proxy {
165                devboy_confluence::ConfluenceClient::new(
166                    &proxy.url,
167                    devboy_confluence::ConfluenceAuth::None,
168                )
169                .with_api_version(api_version.as_deref())
170                .with_proxy(proxy.headers.clone())
171                // Browse links in `_links.webui` / `/pages/<id>` must
172                // point at the real Confluence host, not the proxy.
173                .with_instance_url(base_url)
174            } else {
175                devboy_confluence::ConfluenceClient::new(base_url, confluence_auth(auth))
176                    .with_api_version(api_version.as_deref())
177            };
178            Ok(Box::new(client))
179        }
180        other => Err(Error::ProviderUnsupported {
181            provider: other.provider_name().into(),
182            operation: "not a knowledge base provider".into(),
183        }),
184    }
185}
186
187/// Create the matching knowledge base enricher for a provider.
188///
189/// Knowledge-base-capable providers use a separate path from the regular
190/// issue/git repository enrichers because they implement
191/// `KnowledgeBaseProvider`, not `Provider`.
192pub fn create_knowledge_base_enricher(config: &ProviderConfig) -> Option<Box<dyn ToolEnricher>> {
193    match config {
194        ProviderConfig::Confluence { .. } => {
195            Some(Box::new(devboy_confluence::ConfluenceSchemaEnricher::new()))
196        }
197        _ => None,
198    }
199}
200
201/// Create a meeting notes provider from config.
202///
203/// Separate from `create_provider()` because meeting notes providers
204/// implement `MeetingNotesProvider`, not `Provider`.
205pub fn create_meeting_notes_provider(
206    config: &ProviderConfig,
207) -> Result<Box<dyn MeetingNotesProvider>> {
208    match config {
209        ProviderConfig::Fireflies { api_key, .. } => Ok(Box::new(
210            devboy_fireflies::FirefliesClient::new(api_key.clone()),
211        )),
212        other => Err(Error::ProviderUnsupported {
213            provider: other.provider_name().into(),
214            operation: "not a meeting notes provider".into(),
215        }),
216    }
217}
218
219pub fn create_messenger_provider(config: &ProviderConfig) -> Result<Box<dyn MessengerProvider>> {
220    match config {
221        ProviderConfig::Slack {
222            base_url,
223            access_token,
224            scope: SlackScope::Workspace { .. },
225            required_scopes,
226            ..
227        } => Ok(Box::new(
228            devboy_slack::SlackClient::new(access_token.clone())
229                .with_base_url(base_url)
230                .with_required_scopes(required_scopes.clone()),
231        )),
232        other => Err(Error::ProviderUnsupported {
233            provider: other.provider_name().into(),
234            operation: "not a messenger provider".into(),
235        }),
236    }
237}
238
239/// Create the matching enricher for a provider.
240///
241/// Static providers (GitLab, GitHub) always return an enricher.
242/// Dynamic providers (ClickUp, Jira) require metadata — returns None if missing.
243///
244/// The metadata `data` field is deserialized to provider-specific types.
245pub fn create_enricher(
246    config: &ProviderConfig,
247    metadata: Option<&ProviderMetadata>,
248) -> Option<Box<dyn ToolEnricher>> {
249    match config {
250        ProviderConfig::GitLab { .. } => Some(Box::new(devboy_gitlab::GitLabSchemaEnricher)),
251        ProviderConfig::GitHub { .. } => Some(Box::new(devboy_github::GitHubSchemaEnricher)),
252        ProviderConfig::ClickUp { .. } => {
253            let meta = metadata?;
254            let clickup_meta: devboy_clickup::ClickUpMetadata =
255                serde_json::from_value(meta.data.clone()).ok()?;
256            Some(Box::new(devboy_clickup::ClickUpSchemaEnricher::new(
257                clickup_meta,
258            )))
259        }
260        ProviderConfig::Jira { .. } => {
261            let meta = metadata?;
262            let jira_meta: devboy_jira::JiraMetadata =
263                serde_json::from_value(meta.data.clone()).ok()?;
264            Some(Box::new(devboy_jira::JiraSchemaEnricher::new(jira_meta)))
265        }
266        ProviderConfig::Confluence { .. } => None,
267        ProviderConfig::Fireflies { .. } => {
268            Some(Box::new(devboy_fireflies::FirefliesSchemaEnricher))
269        }
270        ProviderConfig::Slack { .. } => None,
271        ProviderConfig::Custom { .. } => None,
272    }
273}
274
275fn confluence_auth(auth: &ConfluenceAuthConfig) -> devboy_confluence::ConfluenceAuth {
276    match auth {
277        ConfluenceAuthConfig::BearerToken { token } => {
278            devboy_confluence::ConfluenceAuth::BearerToken(token.clone())
279        }
280        ConfluenceAuthConfig::Basic { username, password } => {
281            devboy_confluence::ConfluenceAuth::Basic {
282                username: username.clone(),
283                password: password.clone(),
284            }
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::context::*;
293    use devboy_core::{IssueProvider, KnowledgeBaseProvider};
294    use httpmock::Method::GET;
295    use httpmock::MockServer;
296    use std::collections::HashMap;
297
298    #[test]
299    fn test_create_gitlab_project_provider() {
300        let config = ProviderConfig::GitLab {
301            base_url: "https://gitlab.com".into(),
302            access_token: "test-token".into(),
303            scope: GitLabScope::Project { id: "12345".into() },
304            extra: HashMap::new(),
305        };
306        let provider = create_provider(&config, None);
307        assert!(provider.is_ok());
308        assert_eq!(
309            IssueProvider::provider_name(provider.unwrap().as_ref()),
310            "gitlab"
311        );
312    }
313
314    #[test]
315    fn test_create_github_repo_provider() {
316        let config = ProviderConfig::GitHub {
317            base_url: "https://api.github.com".into(),
318            access_token: "ghp_test".into(),
319            scope: GitHubScope::Repository {
320                owner: "meteora-pro".into(),
321                repo: "devboy-tools".into(),
322            },
323            extra: HashMap::new(),
324        };
325        let provider = create_provider(&config, None);
326        assert!(provider.is_ok());
327        assert_eq!(
328            IssueProvider::provider_name(provider.unwrap().as_ref()),
329            "github"
330        );
331    }
332
333    #[test]
334    fn test_create_clickup_provider() {
335        let config = ProviderConfig::ClickUp {
336            access_token: "pk_test".into(),
337            scope: ClickUpScope::List {
338                id: "list123".into(),
339                team_id: Some("team456".into()),
340            },
341            extra: HashMap::new(),
342        };
343        let provider = create_provider(&config, None);
344        assert!(provider.is_ok());
345        assert_eq!(
346            IssueProvider::provider_name(provider.unwrap().as_ref()),
347            "clickup"
348        );
349    }
350
351    #[test]
352    fn test_create_jira_provider() {
353        let config = ProviderConfig::Jira {
354            base_url: "https://myorg.atlassian.net".into(),
355            access_token: "jira-token".into(),
356            email: "user@example.com".into(),
357            scope: JiraScope::Project { key: "PROJ".into() },
358            flavor: None,
359            extra: HashMap::new(),
360        };
361        let provider = create_provider(&config, None);
362        assert!(provider.is_ok());
363        assert_eq!(
364            IssueProvider::provider_name(provider.unwrap().as_ref()),
365            "jira"
366        );
367    }
368
369    #[test]
370    fn test_create_confluence_knowledge_base_provider() {
371        let config = ProviderConfig::Confluence {
372            base_url: "https://wiki.example.com".into(),
373            auth: ConfluenceAuthConfig::BearerToken {
374                token: "test-token".into(),
375            },
376            scope: ConfluenceScope::Space {
377                key: Some("ENG".into()),
378            },
379            api_version: Some("v1".into()),
380            extra: HashMap::new(),
381        };
382        let provider = create_knowledge_base_provider(&config, None);
383        assert!(provider.is_ok());
384        assert_eq!(
385            KnowledgeBaseProvider::provider_name(provider.unwrap().as_ref()),
386            "confluence"
387        );
388    }
389
390    #[test]
391    fn test_create_confluence_knowledge_base_enricher() {
392        let config = ProviderConfig::Confluence {
393            base_url: "https://wiki.example.com".into(),
394            auth: ConfluenceAuthConfig::BearerToken {
395                token: "test-token".into(),
396            },
397            scope: ConfluenceScope::Space {
398                key: Some("ENG".into()),
399            },
400            api_version: Some("v1".into()),
401            extra: HashMap::new(),
402        };
403        let enricher = create_knowledge_base_enricher(&config);
404        assert!(enricher.is_some());
405        assert_eq!(
406            enricher.unwrap().supported_categories(),
407            &[devboy_core::ToolCategory::KnowledgeBase]
408        );
409    }
410
411    #[test]
412    fn test_confluence_is_not_regular_provider() {
413        let config = ProviderConfig::Confluence {
414            base_url: "https://wiki.example.com".into(),
415            auth: ConfluenceAuthConfig::BearerToken {
416                token: "test-token".into(),
417            },
418            scope: ConfluenceScope::Space { key: None },
419            api_version: None,
420            extra: HashMap::new(),
421        };
422
423        let result = create_provider(&config, None);
424        assert!(matches!(
425            result,
426            Err(Error::ProviderUnsupported { provider, .. }) if provider == "confluence"
427        ));
428    }
429
430    #[tokio::test]
431    async fn test_create_confluence_knowledge_base_provider_honors_api_version() {
432        let server = MockServer::start();
433        let mock = server.mock(|when, then| {
434            when.method(GET)
435                .path("/api/v2/space")
436                .query_param("limit", "100")
437                .query_param("type", "global,personal");
438            then.status(200)
439                .header("content-type", "application/json")
440                .body(r#"{"results":[],"start":0,"limit":100,"size":0,"_links":{}}"#);
441        });
442
443        let config = ProviderConfig::Confluence {
444            base_url: server.base_url(),
445            auth: ConfluenceAuthConfig::BearerToken {
446                token: "test-token".into(),
447            },
448            scope: ConfluenceScope::Space { key: None },
449            api_version: Some("v2".into()),
450            extra: HashMap::new(),
451        };
452
453        let provider = create_knowledge_base_provider(&config, None).unwrap();
454        let _ = provider.get_spaces().await.unwrap();
455
456        mock.assert();
457    }
458
459    #[test]
460    fn test_create_custom_provider_unsupported() {
461        let config = ProviderConfig::Custom {
462            name: "my-plugin".into(),
463            config: HashMap::new(),
464        };
465        let result = create_provider(&config, None);
466        assert!(result.is_err());
467    }
468
469    #[test]
470    fn test_gitlab_group_scope_unsupported() {
471        let config = ProviderConfig::GitLab {
472            base_url: "https://gitlab.com".into(),
473            access_token: "token".into(),
474            scope: GitLabScope::Group {
475                id: "group1".into(),
476            },
477            extra: HashMap::new(),
478        };
479        let result = create_provider(&config, None);
480        assert!(result.is_err());
481    }
482
483    #[test]
484    fn test_create_enricher_gitlab_static() {
485        let config = ProviderConfig::GitLab {
486            base_url: "https://gitlab.com".into(),
487            access_token: "token".into(),
488            scope: GitLabScope::Project { id: "123".into() },
489            extra: HashMap::new(),
490        };
491        let enricher = create_enricher(&config, None);
492        assert!(enricher.is_some());
493    }
494
495    #[test]
496    fn test_create_enricher_github_static() {
497        let config = ProviderConfig::GitHub {
498            base_url: "https://api.github.com".into(),
499            access_token: "token".into(),
500            scope: GitHubScope::Repository {
501                owner: "test".into(),
502                repo: "test".into(),
503            },
504            extra: HashMap::new(),
505        };
506        let enricher = create_enricher(&config, None);
507        assert!(enricher.is_some());
508    }
509
510    #[test]
511    fn test_create_enricher_clickup_needs_metadata() {
512        let config = ProviderConfig::ClickUp {
513            access_token: "token".into(),
514            scope: ClickUpScope::List {
515                id: "list1".into(),
516                team_id: None,
517            },
518            extra: HashMap::new(),
519        };
520        // No metadata → None
521        assert!(create_enricher(&config, None).is_none());
522
523        // With metadata → Some
524        let meta = ProviderMetadata::new(serde_json::json!({
525            "statuses": [{ "name": "To Do" }],
526            "custom_fields": []
527        }));
528        assert!(create_enricher(&config, Some(&meta)).is_some());
529    }
530
531    #[test]
532    fn test_create_enricher_jira_needs_metadata() {
533        let config = ProviderConfig::Jira {
534            base_url: "https://test.atlassian.net".into(),
535            access_token: "token".into(),
536            email: "test@test.com".into(),
537            scope: JiraScope::Project { key: "PROJ".into() },
538            flavor: None,
539            extra: HashMap::new(),
540        };
541        // No metadata → None
542        assert!(create_enricher(&config, None).is_none());
543
544        // With metadata → Some
545        let meta = ProviderMetadata::new(serde_json::json!({
546            "flavor": "cloud",
547            "projects": {
548                "PROJ": {
549                    "issue_types": [],
550                    "priorities": [],
551                    "components": [],
552                    "link_types": [],
553                    "custom_fields": []
554                }
555            }
556        }));
557        assert!(create_enricher(&config, Some(&meta)).is_some());
558    }
559
560    // --- Proxy tests ---
561
562    #[test]
563    fn test_create_gitlab_provider_with_proxy() {
564        let config = ProviderConfig::GitLab {
565            base_url: "https://gitlab.internal.com".into(),
566            access_token: "test-token".into(),
567            scope: GitLabScope::Project { id: "99".into() },
568            extra: HashMap::new(),
569        };
570        let mut headers = HashMap::new();
571        headers.insert("X-Proxy-Auth".into(), "proxy-secret".into());
572        let proxy = ProxyConfig {
573            url: "https://proxy.example.com/gitlab".into(),
574            headers,
575        };
576        let provider = create_provider(&config, Some(&proxy));
577        assert!(provider.is_ok());
578        assert_eq!(
579            IssueProvider::provider_name(provider.unwrap().as_ref()),
580            "gitlab"
581        );
582    }
583
584    #[test]
585    fn test_create_jira_provider_with_proxy() {
586        let config = ProviderConfig::Jira {
587            base_url: "https://jira.mycompany.com".into(),
588            access_token: "jira-token".into(),
589            email: "dev@mycompany.com".into(),
590            scope: JiraScope::Project { key: "DEV".into() },
591            flavor: None,
592            extra: HashMap::new(),
593        };
594        let mut headers = HashMap::new();
595        headers.insert("X-Proxy-Auth".into(), "secret".into());
596        headers.insert("X-Route".into(), "jira-dc".into());
597        let proxy = ProxyConfig {
598            url: "https://proxy.internal/jira".into(),
599            headers,
600        };
601        let provider = create_provider(&config, Some(&proxy));
602        assert!(provider.is_ok());
603        assert_eq!(
604            IssueProvider::provider_name(provider.unwrap().as_ref()),
605            "jira"
606        );
607    }
608
609    #[test]
610    fn test_create_jira_provider_with_flavor_cloud() {
611        let config = ProviderConfig::Jira {
612            base_url: "https://myorg.atlassian.net".into(),
613            access_token: "tok".into(),
614            email: "a@b.com".into(),
615            scope: JiraScope::Project { key: "CLD".into() },
616            flavor: Some(devboy_jira::JiraFlavor::Cloud),
617            extra: HashMap::new(),
618        };
619        let provider = create_provider(&config, None);
620        assert!(provider.is_ok());
621    }
622
623    #[test]
624    fn test_create_jira_provider_with_flavor_self_hosted() {
625        let config = ProviderConfig::Jira {
626            base_url: "https://jira.local".into(),
627            access_token: "tok".into(),
628            email: "a@b.com".into(),
629            scope: JiraScope::Project { key: "SH".into() },
630            flavor: Some(devboy_jira::JiraFlavor::SelfHosted),
631            extra: HashMap::new(),
632        };
633        let provider = create_provider(&config, None);
634        assert!(provider.is_ok());
635    }
636
637    #[test]
638    fn test_create_jira_provider_with_proxy_and_flavor_override() {
639        let config = ProviderConfig::Jira {
640            base_url: "https://jira.dc.mycompany.com".into(),
641            access_token: "tok".into(),
642            email: "a@b.com".into(),
643            scope: JiraScope::Project { key: "DC".into() },
644            flavor: Some(devboy_jira::JiraFlavor::SelfHosted),
645            extra: HashMap::new(),
646        };
647        let proxy = ProxyConfig {
648            url: "https://proxy.internal/jira".into(),
649            headers: HashMap::new(),
650        };
651        let provider = create_provider(&config, Some(&proxy));
652        assert!(provider.is_ok());
653    }
654
655    // --- Unsupported scope tests ---
656
657    #[test]
658    fn test_gitlab_global_scope_unsupported() {
659        let config = ProviderConfig::GitLab {
660            base_url: "https://gitlab.com".into(),
661            access_token: "tok".into(),
662            scope: GitLabScope::Global,
663            extra: HashMap::new(),
664        };
665        let result = create_provider(&config, None);
666        match result {
667            Err(e) => assert!(e.to_string().contains("global scope")),
668            Ok(_) => panic!("expected error for global scope"),
669        }
670    }
671
672    #[test]
673    fn test_github_organization_scope_unsupported() {
674        let config = ProviderConfig::GitHub {
675            base_url: "https://api.github.com".into(),
676            access_token: "tok".into(),
677            scope: GitHubScope::Organization {
678                name: "myorg".into(),
679            },
680            extra: HashMap::new(),
681        };
682        let result = create_provider(&config, None);
683        match result {
684            Err(e) => assert!(e.to_string().contains("organization scope")),
685            Ok(_) => panic!("expected error for organization scope"),
686        }
687    }
688
689    #[test]
690    fn test_github_global_scope_unsupported() {
691        let config = ProviderConfig::GitHub {
692            base_url: "https://api.github.com".into(),
693            access_token: "tok".into(),
694            scope: GitHubScope::Global,
695            extra: HashMap::new(),
696        };
697        let result = create_provider(&config, None);
698        assert!(result.is_err());
699    }
700
701    #[test]
702    fn test_jira_multi_project_scope_unsupported() {
703        let config = ProviderConfig::Jira {
704            base_url: "https://test.atlassian.net".into(),
705            access_token: "tok".into(),
706            email: "a@b.com".into(),
707            scope: JiraScope::MultiProject {
708                keys: vec!["A".into(), "B".into()],
709            },
710            flavor: None,
711            extra: HashMap::new(),
712        };
713        let result = create_provider(&config, None);
714        match result {
715            Err(e) => assert!(e.to_string().contains("multi-project")),
716            Ok(_) => panic!("expected error for multi-project scope"),
717        }
718    }
719
720    // --- ClickUp without team_id ---
721
722    #[test]
723    fn test_create_clickup_provider_without_team_id() {
724        let config = ProviderConfig::ClickUp {
725            access_token: "pk_test".into(),
726            scope: ClickUpScope::List {
727                id: "list999".into(),
728                team_id: None,
729            },
730            extra: HashMap::new(),
731        };
732        let provider = create_provider(&config, None);
733        assert!(provider.is_ok());
734        assert_eq!(
735            IssueProvider::provider_name(provider.unwrap().as_ref()),
736            "clickup"
737        );
738    }
739
740    // --- Enricher for Custom is None ---
741
742    #[test]
743    fn test_create_enricher_custom_returns_none() {
744        let config = ProviderConfig::Custom {
745            name: "custom-plugin".into(),
746            config: HashMap::new(),
747        };
748        assert!(create_enricher(&config, None).is_none());
749    }
750
751    #[test]
752    fn test_create_knowledge_base_enricher_non_kb_returns_none() {
753        let config = ProviderConfig::GitHub {
754            base_url: "https://api.github.com".into(),
755            access_token: "tok".into(),
756            scope: GitHubScope::Repository {
757                owner: "test".into(),
758                repo: "test".into(),
759            },
760            extra: HashMap::new(),
761        };
762        assert!(create_knowledge_base_enricher(&config).is_none());
763    }
764
765    // --- Enricher with invalid metadata ---
766
767    #[test]
768    fn test_create_enricher_clickup_invalid_metadata_returns_none() {
769        let config = ProviderConfig::ClickUp {
770            access_token: "token".into(),
771            scope: ClickUpScope::List {
772                id: "list1".into(),
773                team_id: None,
774            },
775            extra: HashMap::new(),
776        };
777        let meta = ProviderMetadata::new(serde_json::json!("invalid_data"));
778        assert!(create_enricher(&config, Some(&meta)).is_none());
779    }
780
781    #[test]
782    fn test_create_enricher_jira_invalid_metadata_returns_none() {
783        let config = ProviderConfig::Jira {
784            base_url: "https://test.atlassian.net".into(),
785            access_token: "token".into(),
786            email: "a@b.com".into(),
787            scope: JiraScope::Project { key: "X".into() },
788            flavor: None,
789            extra: HashMap::new(),
790        };
791        let meta = ProviderMetadata::new(serde_json::json!("not_valid"));
792        assert!(create_enricher(&config, Some(&meta)).is_none());
793    }
794}