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