Coverage Report

Created: 2026-03-25 23:22

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/tenet/tenet/tenet-api/src/routes/config.rs
Line
Count
Source
1
//! Config routes
2
//!
3
//! Provides the dynamic configuration endpoint that MCP servers
4
//! can call to retrieve organisation rules and project information.
5
//!
6
//! Routes:
7
//! - GET /config?org_id={org_id}[&project_id={project_id}] - Get config for MCP servers
8
9
use axum::{extract::State, response::Response, routing::get};
10
use serde::Deserialize;
11
use serde_json::json;
12
use std::time::Instant;
13
use tenet_aws::models::{organisation::OrganisationId, project::ProjectId};
14
use tenet_aws::observability::with_db_call_count;
15
use tenet_aws::services::context_read::ContextProjection;
16
17
#[cfg(test)]
18
use std::collections::{HashMap, HashSet};
19
#[cfg(test)]
20
use tenet_aws::models::{
21
    ProjectArchitectureConfig,
22
    component::Component,
23
    connection::Connection,
24
    rule::Rule,
25
    skill::{SkillId, SkillSummaryView, SkillTargetLinkView},
26
};
27
#[cfg(test)]
28
use tenet_aws::services::context_match::{
29
    resolve_best_component_by_path, rule_matches_component as shared_rule_matches_component,
30
};
31
32
use crate::http::{AppState, JsonResponse, ScopedQueryAccess, ScopedQueryRequest};
33
34
/// Create the config router
35
2
pub fn router() -> axum::Router<AppState> {
36
2
    axum::Router::new().route("/config", get(get_config))
37
2
}
38
39
/// Query parameters for the config endpoint
40
#[derive(Debug, Deserialize)]
41
pub struct ConfigQuery {
42
    /// Organisation ID (required)
43
    pub org_id: OrganisationId,
44
    /// Optional project ID filter
45
    pub project_id: Option<ProjectId>,
46
    /// Optional source path for working-context-like filtering
47
    pub path: Option<String>,
48
    /// Optional component name for working-context-like filtering
49
    pub component_name: Option<String>,
50
}
51
52
impl ScopedQueryAccess for ConfigQuery {
53
0
    fn org_id(&self) -> &OrganisationId {
54
0
        &self.org_id
55
0
    }
56
57
0
    fn project_id(&self) -> Option<&ProjectId> {
58
0
        self.project_id.as_ref()
59
0
    }
60
}
61
62
#[derive(Debug)]
63
struct ConfigRequestMetrics {
64
    start: Instant,
65
    db_calls: u32,
66
    root_skill_detail_fetches: u32,
67
    sub_skill_detail_fetches: u32,
68
}
69
70
impl ConfigRequestMetrics {
71
2
    fn new() -> Self {
72
2
        Self {
73
2
            start: Instant::now(),
74
2
            db_calls: 0,
75
2
            root_skill_detail_fetches: 0,
76
2
            sub_skill_detail_fetches: 0,
77
2
        }
78
2
    }
79
}
80
81
/// GET /config - Get configuration for MCP servers
82
///
83
/// This endpoint requires JWT authentication.
84
/// It's intended to be called by MCP servers or clients with a valid
85
/// user token to retrieve organisation rules and project information.
86
0
async fn get_config(
87
0
    State(state): State<AppState>,
88
0
    req: ScopedQueryRequest<ConfigQuery>,
89
0
) -> Response {
90
0
    let ScopedQueryRequest { user, query } = req;
91
92
0
    if let Some(project_id) = query.project_id.as_ref() {
93
0
        let mut metrics = ConfigRequestMetrics::new();
94
0
        let (result, db_calls) = with_db_call_count(async {
95
0
            let snapshot = state
96
0
                .context_read
97
0
                .load_snapshot(
98
0
                    &query.org_id,
99
0
                    project_id,
100
0
                    &user.id,
101
0
                    ContextProjection::Config,
102
0
                )
103
0
                .await?;
104
0
            let view = state.context_read.build_project_context_view(
105
0
                snapshot,
106
0
                query.component_name.as_deref(),
107
0
                query.path.as_deref(),
108
            );
109
0
            Ok::<_, tenet_aws::ApiError>(view)
110
0
        })
111
0
        .await;
112
113
0
        metrics.db_calls = db_calls;
114
115
0
        match result {
116
0
            Ok(view) => {
117
0
                let project = view.project;
118
0
                let filtered_rules = view.rules;
119
0
                let filtered_skills = view.skills;
120
0
                let filtered_specs = view.active_specs;
121
0
                let filtered_connections = view.connections;
122
123
0
                tracing::info!(
124
                    route = "/config",
125
                    method = "GET",
126
                    mode = "project_context",
127
                    project_id = %project_id,
128
0
                    component_name = query.component_name.as_deref().unwrap_or(""),
129
0
                    path = query.path.as_deref().unwrap_or(""),
130
                    db_calls = metrics.db_calls,
131
                    root_skill_detail_fetches = metrics.root_skill_detail_fetches,
132
                    sub_skill_detail_fetches = metrics.sub_skill_detail_fetches,
133
0
                    duration_ms = metrics.start.elapsed().as_millis(),
134
                    "config_working_context_metrics"
135
                );
136
137
0
                JsonResponse::ok(json!({
138
0
                    "rules": filtered_rules,
139
0
                    "projects": vec![project],
140
0
                    "skills": filtered_skills,
141
0
                    "active_specs": filtered_specs,
142
0
                    "connections": filtered_connections,
143
0
                    "hints": view.hints
144
                }))
145
            }
146
0
            Err(e) => {
147
0
                tracing::error!(error = %e, "Failed to get project config");
148
0
                JsonResponse::error(e)
149
            }
150
        }
151
    } else {
152
0
        JsonResponse::ok_result_with_context(
153
0
            state.config.get_config(&query.org_id).await,
154
            "Failed to get config",
155
        )
156
    }
157
0
}
158
159
#[cfg(test)]
160
2
fn filter_skills_for_context(
161
2
    query: &ConfigQuery,
162
2
    project: &ProjectArchitectureConfig,
163
2
    skills: Vec<SkillSummaryView>,
164
2
    children_by_parent: &HashMap<SkillId, Vec<SkillSummaryView>>,
165
2
    links_by_skill: &HashMap<SkillId, Vec<SkillTargetLinkView>>,
166
2
    metrics: &mut ConfigRequestMetrics,
167
2
) -> Vec<SkillSummaryView> {
168
2
    if query.path.is_none() && query.component_name.is_none() {
169
1
        return skills;
170
1
    }
171
172
1
    let Some(component) = resolve_context_component(project, query) else {
173
0
        return vec![];
174
    };
175
176
1
    let mut target_ids = HashSet::new();
177
1
    target_ids.insert(component.id.to_string());
178
1
    if let Some(
parent_id0
) = component.parent_id.as_ref() {
179
0
        target_ids.insert(parent_id.to_string());
180
1
    }
181
182
1
    let mut filtered = Vec::new();
183
1
    for skill in skills {
184
1
        metrics.root_skill_detail_fetches += 1;
185
1
        let mut applies = links_by_skill
186
1
            .get(&skill.id)
187
1
            .into_iter()
188
1
            .flat_map(|links| 
links0
.
iter0
())
189
1
            .any(|link| 
target_ids0
.
contains0
(
&link.target_id0
));
190
191
1
        if !applies {
192
1
            for sub in children_by_parent.get(&skill.id).into_iter().flatten() {
193
1
                metrics.sub_skill_detail_fetches += 1;
194
1
                if links_by_skill
195
1
                    .get(&sub.id)
196
1
                    .into_iter()
197
1
                    .flat_map(|links| links.iter())
198
1
                    .any(|link| target_ids.contains(&link.target_id))
199
                {
200
1
                    applies = true;
201
1
                    break;
202
0
                }
203
            }
204
0
        }
205
206
1
        if applies {
207
1
            filtered.push(skill);
208
1
        
}0
209
    }
210
211
1
    filtered
212
2
}
213
214
#[cfg(test)]
215
3
fn filter_rules_for_context(
216
3
    query: &ConfigQuery,
217
3
    project: &ProjectArchitectureConfig,
218
3
    rules: Vec<Rule>,
219
3
) -> Vec<Rule> {
220
3
    if query.path.is_none() && query.component_name.is_none() {
221
1
        return rules;
222
2
    }
223
224
2
    let Some(component) = resolve_context_component(project, query) else {
225
0
        return vec![];
226
    };
227
228
2
    let parent = component.parent_id.as_ref().and_then(|parent_id| 
{1
229
1
        project
230
1
            .containers
231
1
            .iter()
232
1
            .flat_map(|container| {
233
1
                std::iter::once(&container.container).chain(container.components.iter())
234
1
            })
235
1
            .find(|candidate| &candidate.id == parent_id)
236
1
    });
237
238
2
    let parent_chain = parent.into_iter().cloned().collect::<Vec<_>>();
239
240
2
    rules
241
2
        .into_iter()
242
7
        .
filter2
(|rule| shared_rule_matches_component(rule, &component, &parent_chain))
243
2
        .collect()
244
3
}
245
246
#[cfg(test)]
247
2
fn filter_connections_for_context(
248
2
    query: &ConfigQuery,
249
2
    project: &ProjectArchitectureConfig,
250
2
    connections: Vec<Connection>,
251
2
) -> Vec<Connection> {
252
2
    if query.path.is_none() && query.component_name.is_none() {
253
1
        return connections;
254
1
    }
255
256
1
    let Some(component) = resolve_context_component(project, query) else {
257
0
        return vec![];
258
    };
259
1
    let component_id = component.id.to_string();
260
1
    connections
261
1
        .into_iter()
262
2
        .
filter1
(|connection| {
263
2
            connection.source_id == component_id || connection.target_id == component_id
264
2
        })
265
1
        .collect()
266
2
}
267
268
#[cfg(test)]
269
5
fn resolve_context_component(
270
5
    project: &ProjectArchitectureConfig,
271
5
    query: &ConfigQuery,
272
5
) -> Option<Component> {
273
5
    if let Some(
path1
) = query.path.as_deref() {
274
1
        let mut all = Vec::new();
275
1
        for container in &project.containers {
276
1
            all.push(container.container.clone());
277
1
            all.extend(container.components.clone());
278
1
        }
279
1
        return resolve_best_component_by_path(path, &all);
280
4
    }
281
282
4
    if let Some(name) = query.component_name.as_deref() {
283
4
        for container in &project.containers {
284
4
            if container.container.name.eq_ignore_ascii_case(name) {
285
2
                return Some(container.container.clone());
286
2
            }
287
2
            for child in &container.components {
288
2
                if child.name.eq_ignore_ascii_case(name) {
289
2
                    return Some(child.clone());
290
0
                }
291
            }
292
        }
293
0
    }
294
295
0
    None
296
5
}
297
298
#[cfg(test)]
299
mod tests {
300
    use super::*;
301
    use std::collections::HashMap;
302
    use tenet_aws::models::{
303
        ProjectArchitectureConfig,
304
        component::{
305
            ArchitectureComponentKind, ArchitectureComponentMetadata, Component, ContainerKind,
306
            ContainerMetadata,
307
        },
308
        connection::{Connection, ConnectionSourceKind, ConnectionTargetKind},
309
        organisation::OrganisationId,
310
        project::{Project, ProjectType},
311
        rule::Rule,
312
        skill::{SkillId, SkillSummaryView, SkillTargetLinkView},
313
    };
314
    use tenet_aws::services::context_match::path_matches_glob;
315
316
    #[allure_rs::allure_parent_suite("tenet-api")]
317
    #[allure_rs::allure_test]
318
    #[tokio::test]
319
    async fn test_config_routes_exist() {
320
        // Verify the router can be created without panicking
321
        let _router = router();
322
    }
323
324
    #[allure_rs::allure_parent_suite("tenet-api")]
325
    #[allure_rs::allure_test]
326
    #[test]
327
    fn path_matching_prefers_component_over_container() {
328
        let org_id = OrganisationId::try_from("550e8400-e29b-41d4-a716-446655440000".to_string())
329
            .expect("org id");
330
        let project = Project::new(
331
            org_id.clone(),
332
            "TENET".to_string(),
333
            None,
334
            ProjectType::Monorepo,
335
            None,
336
        );
337
        let project_id = project.id.clone();
338
339
        let container = Component::new_container(
340
            project_id.clone(),
341
            org_id.clone(),
342
            "tenet-infra".to_string(),
343
            None,
344
            "tenet-infra/**".to_string(),
345
            &ContainerMetadata {
346
                container_kind: ContainerKind::Library,
347
                technology: Some("Terraform".to_string()),
348
                framework: Some("OpenTofu".to_string()),
349
                runtime: None,
350
                deployment: None,
351
            },
352
        )
353
        .expect("container");
354
355
        let child = Component::new_architecture_component(
356
            container.id.clone(),
357
            project_id,
358
            org_id,
359
            "modules".to_string(),
360
            None,
361
            "tenet-infra/modules/**".to_string(),
362
            &ArchitectureComponentMetadata {
363
                component_kind: ArchitectureComponentKind::Module,
364
            },
365
        )
366
        .expect("child");
367
368
        let config = ProjectArchitectureConfig {
369
            project,
370
            containers: vec![tenet_aws::models::ContainerConfig::new(
371
                container.clone(),
372
                vec![child.clone()],
373
            )],
374
        };
375
376
        let query = ConfigQuery {
377
            org_id: container.organisation_id.clone(),
378
            project_id: Some(container.project_id.clone()),
379
            path: Some("tenet-infra/modules/api/main.tf".to_string()),
380
            component_name: None,
381
        };
382
383
        let resolved = resolve_context_component(&config, &query).expect("resolved component");
384
        assert_eq!(resolved.id, child.id);
385
    }
386
387
    #[allure_rs::allure_parent_suite("tenet-api")]
388
    #[allure_rs::allure_test]
389
    #[test]
390
    fn glob_match_handles_double_star_prefix() {
391
        assert!(path_matches_glob("tenet-infra/main.tf", "tenet-infra/**"));
392
        assert!(path_matches_glob(
393
            "tenet-infra/modules/api/main.tf",
394
            "tenet-infra/modules/**"
395
        ));
396
        assert!(!path_matches_glob(
397
            "tenet-web/src/main.tsx",
398
            "tenet-infra/**"
399
        ));
400
    }
401
402
    #[allure_rs::allure_parent_suite("tenet-api")]
403
    #[allure_rs::allure_test]
404
    #[test]
405
    fn rules_filter_by_component_technology_and_framework() {
406
        let org_id = OrganisationId::try_from("550e8400-e29b-41d4-a716-446655440000".to_string())
407
            .expect("org id");
408
        let project = Project::new(
409
            org_id.clone(),
410
            "TENET".to_string(),
411
            None,
412
            ProjectType::Monorepo,
413
            None,
414
        );
415
        let project_id = project.id.clone();
416
417
        let web_container = Component::new_container(
418
            project_id.clone(),
419
            org_id.clone(),
420
            "tenet-web".to_string(),
421
            None,
422
            "tenet-web/**".to_string(),
423
            &ContainerMetadata {
424
                container_kind: ContainerKind::App,
425
                technology: Some("TypeScript".to_string()),
426
                framework: Some("React".to_string()),
427
                runtime: None,
428
                deployment: None,
429
            },
430
        )
431
        .expect("web container");
432
433
        let config = ProjectArchitectureConfig {
434
            project,
435
            containers: vec![tenet_aws::models::ContainerConfig::new(
436
                web_container.clone(),
437
                vec![],
438
            )],
439
        };
440
441
        let query = ConfigQuery {
442
            org_id: org_id.clone(),
443
            project_id: Some(project_id.clone()),
444
            path: None,
445
            component_name: Some("tenet-web".to_string()),
446
        };
447
448
        let rust_rule = Rule::new_with_options(
449
            org_id.clone(),
450
            Some(project_id.clone()),
451
            "Rust Rule".to_string(),
452
            "rust-only".to_string(),
453
            Some("Rust".to_string()),
454
            "code".to_string(),
455
            true,
456
            100,
457
        );
458
        let ts_rule = Rule::new_with_options(
459
            org_id.clone(),
460
            Some(project_id.clone()),
461
            "TS Rule".to_string(),
462
            "ts/react".to_string(),
463
            Some("TypeScript, React".to_string()),
464
            "code".to_string(),
465
            true,
466
            100,
467
        );
468
        let global_rule = Rule::new_with_options(
469
            org_id.clone(),
470
            Some(project_id.clone()),
471
            "Global Rule".to_string(),
472
            "global".to_string(),
473
            Some("git".to_string()),
474
            "workflow".to_string(),
475
            true,
476
            100,
477
        );
478
        let no_applies_to_rule = Rule::new_with_options(
479
            org_id,
480
            Some(project_id),
481
            "No Scope Rule".to_string(),
482
            "everywhere".to_string(),
483
            None,
484
            "code".to_string(),
485
            true,
486
            100,
487
        );
488
489
        let filtered = filter_rules_for_context(
490
            &query,
491
            &config,
492
            vec![rust_rule, ts_rule, global_rule, no_applies_to_rule],
493
        );
494
        let names = filtered
495
            .into_iter()
496
            .map(|rule| rule.name)
497
            .collect::<Vec<_>>();
498
499
        assert!(!names.contains(&"Rust Rule".to_string()));
500
        assert!(names.contains(&"TS Rule".to_string()));
501
        assert!(names.contains(&"Global Rule".to_string()));
502
        assert!(names.contains(&"No Scope Rule".to_string()));
503
    }
504
505
    #[allure_rs::allure_parent_suite("tenet-api")]
506
    #[allure_rs::allure_test]
507
    #[test]
508
    fn rules_filter_uses_parent_container_technology_for_modules() {
509
        let org_id = OrganisationId::try_from("550e8400-e29b-41d4-a716-446655440000".to_string())
510
            .expect("org id");
511
        let project = Project::new(
512
            org_id.clone(),
513
            "TENET".to_string(),
514
            None,
515
            ProjectType::Monorepo,
516
            None,
517
        );
518
        let project_id = project.id.clone();
519
520
        let container = Component::new_container(
521
            project_id.clone(),
522
            org_id.clone(),
523
            "tenet-aws".to_string(),
524
            None,
525
            "tenet-aws/**".to_string(),
526
            &ContainerMetadata {
527
                container_kind: ContainerKind::Library,
528
                technology: Some("Rust".to_string()),
529
                framework: None,
530
                runtime: None,
531
                deployment: None,
532
            },
533
        )
534
        .expect("container");
535
536
        let db_component = Component::new_architecture_component(
537
            container.id.clone(),
538
            project_id.clone(),
539
            org_id.clone(),
540
            "db".to_string(),
541
            None,
542
            "tenet-aws/src/db/**".to_string(),
543
            &ArchitectureComponentMetadata {
544
                component_kind: ArchitectureComponentKind::Module,
545
            },
546
        )
547
        .expect("db component");
548
549
        let config = ProjectArchitectureConfig {
550
            project,
551
            containers: vec![tenet_aws::models::ContainerConfig::new(
552
                container,
553
                vec![db_component],
554
            )],
555
        };
556
557
        let query = ConfigQuery {
558
            org_id: org_id.clone(),
559
            project_id: Some(project_id.clone()),
560
            path: None,
561
            component_name: Some("db".to_string()),
562
        };
563
564
        let rust_rule = Rule::new_with_options(
565
            org_id.clone(),
566
            Some(project_id.clone()),
567
            "Rust Rule".to_string(),
568
            "rust-only".to_string(),
569
            Some("Rust".to_string()),
570
            "code".to_string(),
571
            true,
572
            100,
573
        );
574
        let ts_rule = Rule::new_with_options(
575
            org_id.clone(),
576
            Some(project_id.clone()),
577
            "TS Rule".to_string(),
578
            "ts-only".to_string(),
579
            Some("TypeScript".to_string()),
580
            "code".to_string(),
581
            true,
582
            100,
583
        );
584
        let global_rule = Rule::new_with_options(
585
            org_id,
586
            Some(project_id),
587
            "Commit Message Format".to_string(),
588
            "global".to_string(),
589
            None,
590
            "git".to_string(),
591
            true,
592
            100,
593
        );
594
595
        let filtered =
596
            filter_rules_for_context(&query, &config, vec![rust_rule, ts_rule, global_rule]);
597
        let names = filtered
598
            .into_iter()
599
            .map(|rule| rule.name)
600
            .collect::<Vec<_>>();
601
602
        assert!(names.contains(&"Rust Rule".to_string()));
603
        assert!(!names.contains(&"TS Rule".to_string()));
604
        assert!(names.contains(&"Commit Message Format".to_string()));
605
    }
606
607
    #[allure_rs::allure_parent_suite("tenet-api")]
608
    #[allure_rs::allure_test]
609
    #[test]
610
    fn skill_filter_includes_root_when_subskill_is_linked_to_target() {
611
        let org_id = OrganisationId::try_from("550e8400-e29b-41d4-a716-446655440000".to_string())
612
            .expect("org id");
613
        let project = Project::new(
614
            org_id.clone(),
615
            "TENET".to_string(),
616
            None,
617
            ProjectType::Monorepo,
618
            None,
619
        );
620
        let project_id = project.id.clone();
621
622
        let container = Component::new_container(
623
            project_id.clone(),
624
            org_id.clone(),
625
            "tenet-infra".to_string(),
626
            None,
627
            "tenet-infra/**".to_string(),
628
            &ContainerMetadata {
629
                container_kind: ContainerKind::Library,
630
                technology: Some("Terraform".to_string()),
631
                framework: None,
632
                runtime: None,
633
                deployment: None,
634
            },
635
        )
636
        .expect("container");
637
638
        let config = ProjectArchitectureConfig {
639
            project,
640
            containers: vec![tenet_aws::models::ContainerConfig::new(
641
                container.clone(),
642
                vec![],
643
            )],
644
        };
645
646
        let query = ConfigQuery {
647
            org_id,
648
            project_id: Some(project_id),
649
            path: None,
650
            component_name: Some("tenet-infra".to_string()),
651
        };
652
653
        let root_id =
654
            SkillId::try_from("11111111-1111-4111-8111-111111111111".to_string()).expect("root id");
655
        let sub_id =
656
            SkillId::try_from("22222222-2222-4222-8222-222222222222".to_string()).expect("sub id");
657
658
        let roots = vec![SkillSummaryView {
659
            id: root_id.clone(),
660
            name: "terraform-skill".to_string(),
661
            display_name: "Terraform Skill".to_string(),
662
            description: "Terraform guidance".to_string(),
663
            sort_order: 0,
664
            sub_skill_count: 1,
665
            sub_skills: vec![],
666
        }];
667
668
        let children_by_parent = HashMap::from([(
669
            root_id,
670
            vec![SkillSummaryView {
671
                id: sub_id.clone(),
672
                name: "terraform-skill/security-compliance".to_string(),
673
                display_name: "Security & Compliance".to_string(),
674
                description: "Compliance patterns".to_string(),
675
                sort_order: 0,
676
                sub_skill_count: 0,
677
                sub_skills: vec![],
678
            }],
679
        )]);
680
681
        let links_by_skill = HashMap::from([(
682
            sub_id,
683
            vec![SkillTargetLinkView {
684
                edge_id: "edge-1".to_string(),
685
                target_id: container.id.to_string(),
686
                target_kind: tenet_aws::models::skill::ArchitectureLinkTargetKind::Container,
687
                target_name: Some(container.name.clone()),
688
                target_path: Some(container.path.clone()),
689
            }],
690
        )]);
691
692
        let mut metrics = ConfigRequestMetrics::new();
693
        let filtered = filter_skills_for_context(
694
            &query,
695
            &config,
696
            roots,
697
            &children_by_parent,
698
            &links_by_skill,
699
            &mut metrics,
700
        );
701
702
        assert_eq!(filtered.len(), 1);
703
        assert_eq!(filtered[0].name, "terraform-skill");
704
    }
705
706
    #[allure_rs::allure_parent_suite("tenet-api")]
707
    #[allure_rs::allure_test]
708
    #[test]
709
    fn filter_connections_scopes_to_selected_component_context() {
710
        let org_id = OrganisationId::try_from("550e8400-e29b-41d4-a716-446655440000".to_string())
711
            .expect("org id");
712
        let project = Project::new(
713
            org_id.clone(),
714
            "TENET".to_string(),
715
            None,
716
            ProjectType::Monorepo,
717
            None,
718
        );
719
        let project_id = project.id.clone();
720
721
        let container = Component::new_container(
722
            project_id.clone(),
723
            org_id.clone(),
724
            "api".to_string(),
725
            None,
726
            "apps/api/**".to_string(),
727
            &ContainerMetadata {
728
                container_kind: ContainerKind::App,
729
                technology: Some("Rust".to_string()),
730
                framework: Some("Axum".to_string()),
731
                runtime: None,
732
                deployment: None,
733
            },
734
        )
735
        .expect("container");
736
        let module = Component::new_architecture_component(
737
            container.id.clone(),
738
            project_id.clone(),
739
            org_id.clone(),
740
            "db".to_string(),
741
            None,
742
            "apps/api/src/db/**".to_string(),
743
            &ArchitectureComponentMetadata {
744
                component_kind: ArchitectureComponentKind::Module,
745
            },
746
        )
747
        .expect("module");
748
749
        let config = ProjectArchitectureConfig {
750
            project,
751
            containers: vec![tenet_aws::models::ContainerConfig::new(
752
                container.clone(),
753
                vec![module.clone()],
754
            )],
755
        };
756
757
        let related = Connection::new(
758
            project_id.clone(),
759
            org_id.clone(),
760
            container.id.to_string(),
761
            ConnectionSourceKind::Container,
762
            module.id.to_string(),
763
            ConnectionTargetKind::Component,
764
            "uses".to_string(),
765
            None,
766
        );
767
        let unrelated = Connection::new(
768
            project_id.clone(),
769
            org_id,
770
            "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".to_string(),
771
            ConnectionSourceKind::Container,
772
            "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb".to_string(),
773
            ConnectionTargetKind::Component,
774
            "uses".to_string(),
775
            None,
776
        );
777
778
        let query = ConfigQuery {
779
            org_id: config.project.organisation_id.clone(),
780
            project_id: Some(project_id),
781
            path: None,
782
            component_name: Some("db".to_string()),
783
        };
784
785
        let filtered = filter_connections_for_context(&query, &config, vec![related, unrelated]);
786
        assert_eq!(filtered.len(), 1);
787
        assert_eq!(filtered[0].target_id, module.id.to_string());
788
    }
789
790
    #[allure_rs::allure_parent_suite("tenet-api")]
791
    #[allure_rs::allure_test]
792
    #[test]
793
    fn no_context_filter_returns_full_rules_skills_and_connections() {
794
        let org_id = OrganisationId::try_from("550e8400-e29b-41d4-a716-446655440099".to_string())
795
            .expect("org id");
796
        let project = Project::new(
797
            org_id.clone(),
798
            "TENET".to_string(),
799
            None,
800
            ProjectType::Monorepo,
801
            None,
802
        );
803
        let project_id = project.id.clone();
804
805
        let container = Component::new_container(
806
            project_id.clone(),
807
            org_id.clone(),
808
            "tenet-api".to_string(),
809
            None,
810
            "tenet-api/**".to_string(),
811
            &ContainerMetadata {
812
                container_kind: ContainerKind::App,
813
                technology: Some("Rust".to_string()),
814
                framework: Some("Axum".to_string()),
815
                runtime: None,
816
                deployment: None,
817
            },
818
        )
819
        .expect("container");
820
821
        let config = ProjectArchitectureConfig {
822
            project,
823
            containers: vec![tenet_aws::models::ContainerConfig::new(
824
                container.clone(),
825
                vec![],
826
            )],
827
        };
828
829
        let query = ConfigQuery {
830
            org_id: org_id.clone(),
831
            project_id: Some(project_id.clone()),
832
            path: None,
833
            component_name: None,
834
        };
835
836
        let rules = vec![
837
            Rule::new_with_options(
838
                org_id.clone(),
839
                Some(project_id.clone()),
840
                "Rule A".to_string(),
841
                "A".to_string(),
842
                Some("Rust".to_string()),
843
                "code-style".to_string(),
844
                true,
845
                1,
846
            ),
847
            Rule::new_with_options(
848
                org_id.clone(),
849
                Some(project_id.clone()),
850
                "Rule B".to_string(),
851
                "B".to_string(),
852
                None,
853
                "security".to_string(),
854
                true,
855
                2,
856
            ),
857
        ];
858
        let filtered_rules = filter_rules_for_context(&query, &config, rules.clone());
859
        assert_eq!(filtered_rules.len(), rules.len());
860
861
        let skills = vec![
862
            SkillSummaryView {
863
                id: SkillId::new(),
864
                name: "Skill A".to_string(),
865
                display_name: "Skill A".to_string(),
866
                description: "A".to_string(),
867
                sort_order: 1,
868
                sub_skill_count: 0,
869
                sub_skills: vec![],
870
            },
871
            SkillSummaryView {
872
                id: SkillId::new(),
873
                name: "Skill B".to_string(),
874
                display_name: "Skill B".to_string(),
875
                description: "B".to_string(),
876
                sort_order: 2,
877
                sub_skill_count: 0,
878
                sub_skills: vec![],
879
            },
880
        ];
881
        let filtered_skills = filter_skills_for_context(
882
            &query,
883
            &config,
884
            skills.clone(),
885
            &HashMap::<SkillId, Vec<SkillSummaryView>>::new(),
886
            &HashMap::<SkillId, Vec<SkillTargetLinkView>>::new(),
887
            &mut ConfigRequestMetrics::new(),
888
        );
889
        assert_eq!(filtered_skills.len(), skills.len());
890
891
        let connections = vec![
892
            Connection::new(
893
                project_id.clone(),
894
                org_id.clone(),
895
                container.id.to_string(),
896
                ConnectionSourceKind::Container,
897
                container.id.to_string(),
898
                ConnectionTargetKind::Container,
899
                "self".to_string(),
900
                None,
901
            ),
902
            Connection::new(
903
                project_id,
904
                org_id,
905
                "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".to_string(),
906
                ConnectionSourceKind::Container,
907
                "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb".to_string(),
908
                ConnectionTargetKind::Component,
909
                "uses".to_string(),
910
                None,
911
            ),
912
        ];
913
        let filtered_connections =
914
            filter_connections_for_context(&query, &config, connections.clone());
915
        assert_eq!(filtered_connections.len(), connections.len());
916
    }
917
}