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