1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::Result;
6use axum::extract::{Query, State};
7use axum::http::{header, StatusCode};
8use axum::response::{IntoResponse, Json, Response};
9use axum::routing::get;
10use axum::Router;
11use rust_embed::Embed;
12use serde::{Deserialize, Serialize};
13use tokio::sync::RwLock;
14use tower_http::cors::CorsLayer;
15use tracing::info;
16
17use graphy_core::{CodeGraph, EdgeKind, NodeKind, Visibility};
18use graphy_search::SearchIndex;
19
20#[derive(Embed)]
21#[folder = "dist"]
22struct FrontendAssets;
23
24#[derive(Clone)]
26pub struct AppState {
27 pub graph: Arc<RwLock<CodeGraph>>,
28 pub search: Arc<SearchIndex>,
29 pub project_root: PathBuf,
30}
31
32pub async fn serve(
34 state: AppState,
35 port: u16,
36 shutdown: tokio::sync::watch::Receiver<()>,
37) -> Result<()> {
38 let app = Router::new()
39 .route("/api/stats", get(api_stats))
40 .route("/api/search", get(api_search))
41 .route("/api/symbol/:name", get(api_symbol))
42 .route("/api/graph", get(api_graph_data))
43 .route("/api/files", get(api_files))
44 .route("/api/hotspots", get(api_hotspots))
45 .route("/api/dead-code", get(api_dead_code))
46 .route("/api/taint", get(api_taint))
47 .route("/api/architecture", get(api_architecture))
48 .route("/api/patterns", get(api_patterns))
49 .route("/api/api-surface", get(api_surface))
50 .route("/api/file-content", get(api_file_content))
51 .route("/api/file-symbols", get(api_file_symbols))
52 .route("/", get(serve_frontend_index))
53 .fallback(get(serve_frontend))
54 .layer(CorsLayer::permissive())
55 .with_state(state);
56
57 let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?;
58 info!("Web UI available at http://localhost:{port}");
59 axum::serve(listener, app)
60 .with_graceful_shutdown(async move {
61 let _ = shutdown.clone().changed().await;
62 })
63 .await?;
64 Ok(())
65}
66
67fn relative_path(path: &std::path::Path, root: &std::path::Path) -> String {
69 path.strip_prefix(root)
70 .unwrap_or(path)
71 .to_string_lossy()
72 .into_owned()
73}
74
75async fn serve_frontend_index() -> Response {
78 serve_embedded_file("index.html")
79}
80
81async fn serve_frontend(uri: axum::http::Uri) -> Response {
82 let path = uri.path().trim_start_matches('/');
83 if !path.is_empty() && FrontendAssets::get(path).is_some() {
84 serve_embedded_file(path)
85 } else {
86 serve_embedded_file("index.html")
87 }
88}
89
90fn serve_embedded_file(path: &str) -> Response {
91 match FrontendAssets::get(path) {
92 Some(content) => {
93 let mime = mime_guess::from_path(path).first_or_octet_stream();
94 (
95 [(header::CONTENT_TYPE, mime.as_ref().to_string())],
96 content.data.to_vec(),
97 )
98 .into_response()
99 }
100 None => (StatusCode::NOT_FOUND, "Not found").into_response(),
101 }
102}
103
104#[derive(Serialize)]
107struct StatsResponse {
108 nodes: usize,
109 edges: usize,
110 files: usize,
111 classes: usize,
112 structs: usize,
113 enums: usize,
114 traits: usize,
115 functions: usize,
116 methods: usize,
117 imports: usize,
118 variables: usize,
119 constants: usize,
120}
121
122async fn api_stats(State(state): State<AppState>) -> Json<StatsResponse> {
123 let graph = state.graph.read().await;
124 Json(StatsResponse {
125 nodes: graph.node_count(),
126 edges: graph.edge_count(),
127 files: graph.find_by_kind(NodeKind::File).len(),
128 classes: graph.find_by_kind(NodeKind::Class).len(),
129 structs: graph.find_by_kind(NodeKind::Struct).len(),
130 enums: graph.find_by_kind(NodeKind::Enum).len(),
131 traits: graph.find_by_kind(NodeKind::Trait).len(),
132 functions: graph.find_by_kind(NodeKind::Function).len(),
133 methods: graph.find_by_kind(NodeKind::Method).len(),
134 imports: graph.find_by_kind(NodeKind::Import).len(),
135 variables: graph.find_by_kind(NodeKind::Variable).len(),
136 constants: graph.find_by_kind(NodeKind::Constant).len(),
137 })
138}
139
140#[derive(Deserialize)]
141struct SearchQuery {
142 q: String,
143 #[serde(default = "default_limit")]
144 limit: usize,
145 kind: Option<String>,
146 lang: Option<String>,
147 file: Option<String>,
148}
149
150fn default_limit() -> usize {
151 20
152}
153
154async fn api_search(
155 State(state): State<AppState>,
156 Query(params): Query<SearchQuery>,
157) -> impl IntoResponse {
158 let query = params.q.trim();
159 let has_filters = params.kind.is_some() || params.lang.is_some() || params.file.is_some();
160 if query.is_empty() && !has_filters {
161 return (StatusCode::BAD_REQUEST, "Query parameter 'q' must not be empty").into_response();
162 }
163 let limit = params.limit.clamp(1, 1000);
164
165 let result = if has_filters {
166 state.search.search_filtered(
167 query,
168 params.kind.as_deref(),
169 params.lang.as_deref(),
170 params.file.as_deref(),
171 limit,
172 )
173 } else {
174 state.search.search(query, limit)
175 };
176 match result {
177 Ok(results) => Json(results).into_response(),
178 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
179 }
180}
181
182#[derive(Serialize)]
183struct SymbolDetail {
184 name: String,
185 kind: String,
186 file_path: String,
187 start_line: u32,
188 end_line: u32,
189 visibility: String,
190 language: String,
191 signature: Option<String>,
192 doc: Option<String>,
193 complexity: Option<ComplexityInfo>,
194 callers: Vec<SymbolRef>,
195 callees: Vec<SymbolRef>,
196 children: Vec<SymbolRef>,
197}
198
199#[derive(Serialize)]
200struct ComplexityInfo {
201 cyclomatic: u32,
202 cognitive: u32,
203 loc: u32,
204 sloc: u32,
205 parameter_count: u32,
206 max_nesting_depth: u32,
207}
208
209#[derive(Serialize, Clone)]
210struct SymbolRef {
211 name: String,
212 kind: String,
213 file_path: String,
214 start_line: u32,
215}
216
217fn node_to_ref(n: &graphy_core::GirNode, root: &std::path::Path) -> SymbolRef {
218 SymbolRef {
219 name: n.name.clone(),
220 kind: format!("{:?}", n.kind),
221 file_path: relative_path(&n.file_path, root),
222 start_line: n.span.start_line,
223 }
224}
225
226async fn api_symbol(
227 State(state): State<AppState>,
228 axum::extract::Path(name): axum::extract::Path<String>,
229) -> impl IntoResponse {
230 let graph = state.graph.read().await;
231 let nodes = graph.find_by_name(&name);
232
233 if nodes.is_empty() {
234 return (StatusCode::NOT_FOUND, "Symbol not found").into_response();
235 }
236
237 let root = &state.project_root;
238 let details: Vec<SymbolDetail> = nodes
239 .iter()
240 .map(|n| {
241 let complexity = n.complexity.as_ref().map(|cx| ComplexityInfo {
242 cyclomatic: cx.cyclomatic,
243 cognitive: cx.cognitive,
244 loc: cx.loc,
245 sloc: cx.sloc,
246 parameter_count: cx.parameter_count,
247 max_nesting_depth: cx.max_nesting_depth,
248 });
249
250 SymbolDetail {
251 name: n.name.clone(),
252 kind: format!("{:?}", n.kind),
253 file_path: relative_path(&n.file_path, root),
254 start_line: n.span.start_line,
255 end_line: n.span.end_line,
256 visibility: format!("{:?}", n.visibility),
257 language: format!("{:?}", n.language),
258 signature: n.signature.clone(),
259 doc: n.doc.clone(),
260 complexity,
261 callers: graph.callers(n.id).iter().map(|c| node_to_ref(c, root)).collect(),
262 callees: graph.callees(n.id).iter().map(|c| node_to_ref(c, root)).collect(),
263 children: graph.children(n.id).iter().map(|c| node_to_ref(c, root)).collect(),
264 }
265 })
266 .collect();
267
268 Json(details).into_response()
269}
270
271#[derive(Serialize)]
272struct GraphData {
273 nodes: Vec<GraphNode>,
274 edges: Vec<GraphEdge>,
275}
276
277#[derive(Serialize)]
278struct GraphNode {
279 id: String,
280 label: String,
281 kind: String,
282 file: String,
283 size: u32,
284 visibility: String,
285 complexity: Option<u32>,
286}
287
288#[derive(Serialize)]
289struct GraphEdge {
290 source: String,
291 target: String,
292 kind: String,
293 confidence: f32,
294}
295
296async fn api_graph_data(State(state): State<AppState>) -> Json<GraphData> {
297 let graph = state.graph.read().await;
298 let root = &state.project_root;
299
300 const MAX_GRAPH_NODES: usize = 400;
301
302 let mut scored_nodes: Vec<(&graphy_core::GirNode, u32)> = graph
304 .all_nodes()
305 .filter(|n| n.kind.is_callable() || n.kind.is_type_def() || n.kind == NodeKind::File)
306 .map(|n| {
307 let base = match n.kind {
309 NodeKind::Class | NodeKind::Struct | NodeKind::Trait | NodeKind::Interface => 100,
310 NodeKind::File => 50,
311 NodeKind::Enum => 80,
312 _ => 10,
313 };
314 let complexity_bonus = n
315 .complexity
316 .as_ref()
317 .map(|cx| cx.cyclomatic.min(30))
318 .unwrap_or(0);
319 let caller_bonus = (graph.callers(n.id).len() as u32).min(20) * 3;
320 (n, base + complexity_bonus + caller_bonus)
321 })
322 .collect();
323
324 scored_nodes.sort_by(|a, b| b.1.cmp(&a.1));
326 scored_nodes.truncate(MAX_GRAPH_NODES);
327
328 let node_ids: std::collections::HashSet<graphy_core::SymbolId> =
329 scored_nodes.iter().map(|(n, _)| n.id).collect();
330
331 let nodes: Vec<GraphNode> = scored_nodes
332 .iter()
333 .map(|(n, _)| {
334 let size = match n.kind {
335 NodeKind::Class | NodeKind::Struct | NodeKind::Trait => 10,
336 NodeKind::File => 6,
337 NodeKind::Enum | NodeKind::Interface => 8,
338 _ => {
339 n.complexity
340 .as_ref()
341 .map(|cx| 4 + (cx.cyclomatic.min(20) / 2))
342 .unwrap_or(4)
343 }
344 };
345 GraphNode {
346 id: n.id.to_string(),
347 label: n.name.clone(),
348 kind: format!("{:?}", n.kind),
349 file: relative_path(&n.file_path, root),
350 size,
351 visibility: format!("{:?}", n.visibility),
352 complexity: n.complexity.as_ref().map(|cx| cx.cyclomatic),
353 }
354 })
355 .collect();
356
357 use petgraph::visit::{EdgeRef, IntoEdgeReferences};
358 let edges: Vec<GraphEdge> = graph
359 .graph
360 .edge_references()
361 .filter_map(|e| {
362 let src = graph.graph.node_weight(e.source())?;
363 let tgt = graph.graph.node_weight(e.target())?;
364 if !node_ids.contains(&src.id) || !node_ids.contains(&tgt.id) {
366 return None;
367 }
368 let w = e.weight();
369 if matches!(
370 w.kind,
371 EdgeKind::Calls
372 | EdgeKind::Inherits
373 | EdgeKind::Implements
374 | EdgeKind::Imports
375 | EdgeKind::ImportsFrom
376 | EdgeKind::DataFlowsTo
377 | EdgeKind::TaintedBy
378 ) {
379 Some(GraphEdge {
380 source: src.id.to_string(),
381 target: tgt.id.to_string(),
382 kind: format!("{:?}", w.kind),
383 confidence: w.confidence,
384 })
385 } else {
386 None
387 }
388 })
389 .collect();
390
391 Json(GraphData { nodes, edges })
392}
393
394async fn api_files(State(state): State<AppState>) -> Json<Vec<String>> {
395 let graph = state.graph.read().await;
396 let root = &state.project_root;
397 let mut files: Vec<String> = graph
398 .find_by_kind(NodeKind::File)
399 .iter()
400 .map(|n| relative_path(&n.file_path, root))
401 .filter(|p| !p.is_empty())
402 .collect();
403 files.sort();
404 Json(files)
405}
406
407#[derive(Serialize)]
410struct HotspotItem {
411 name: String,
412 kind: String,
413 file_path: String,
414 start_line: u32,
415 cyclomatic: u32,
416 cognitive: u32,
417 loc: u32,
418 caller_count: usize,
419 risk_score: f64,
420}
421
422#[derive(Deserialize)]
423struct LimitQuery {
424 #[serde(default = "default_limit")]
425 limit: usize,
426}
427
428async fn api_hotspots(
429 State(state): State<AppState>,
430 Query(params): Query<LimitQuery>,
431) -> Json<Vec<HotspotItem>> {
432 let graph = state.graph.read().await;
433 let root = &state.project_root;
434
435 let mut hotspots: Vec<HotspotItem> = graph
436 .all_nodes()
437 .filter(|n| n.kind.is_callable())
438 .filter_map(|n| {
439 let cx = n.complexity.as_ref()?;
440 let caller_count = graph.callers(n.id).len();
441 let risk_score = cx.cyclomatic as f64 * (1.0 + caller_count as f64 * 0.5);
442 Some(HotspotItem {
443 name: n.name.clone(),
444 kind: format!("{:?}", n.kind),
445 file_path: relative_path(&n.file_path, root),
446 start_line: n.span.start_line,
447 cyclomatic: cx.cyclomatic,
448 cognitive: cx.cognitive,
449 loc: cx.loc,
450 caller_count,
451 risk_score,
452 })
453 })
454 .collect();
455
456 hotspots.sort_by(|a, b| b.risk_score.total_cmp(&a.risk_score));
457 hotspots.truncate(params.limit.min(1000));
458
459 Json(hotspots)
460}
461
462#[derive(Serialize)]
463struct DeadCodeItem {
464 name: String,
465 kind: String,
466 file_path: String,
467 start_line: u32,
468 visibility: String,
469 dead_probability: f32,
470}
471
472async fn api_dead_code(
473 State(state): State<AppState>,
474 Query(params): Query<LimitQuery>,
475) -> Json<Vec<DeadCodeItem>> {
476 let graph = state.graph.read().await;
477 let root = &state.project_root;
478
479 let callable = [NodeKind::Function, NodeKind::Method];
481 let mut dead: Vec<DeadCodeItem> = graph
482 .all_nodes()
483 .filter(|n| callable.contains(&n.kind))
484 .filter(|n| !graph.is_phantom(n.id))
485 .filter(|n| n.confidence < 0.5)
486 .map(|n| DeadCodeItem {
487 name: n.name.clone(),
488 kind: format!("{:?}", n.kind),
489 file_path: relative_path(&n.file_path, root),
490 start_line: n.span.start_line,
491 visibility: format!("{:?}", n.visibility),
492 dead_probability: 1.0 - n.confidence,
493 })
494 .collect();
495
496 dead.sort_by(|a, b| b.dead_probability.total_cmp(&a.dead_probability));
497 dead.truncate(params.limit.min(1000));
498
499 Json(dead)
500}
501
502#[derive(Serialize)]
503struct TaintPath {
504 target_name: String,
505 target_file: String,
506 target_line: u32,
507 sources: Vec<SymbolRef>,
508}
509
510async fn api_taint(State(state): State<AppState>) -> Json<Vec<TaintPath>> {
511 let graph = state.graph.read().await;
512 let root = &state.project_root;
513
514 let paths: Vec<TaintPath> = graph
515 .all_nodes()
516 .filter(|n| !graph.outgoing(n.id, EdgeKind::TaintedBy).is_empty())
517 .map(|n| {
518 let sources = graph
519 .outgoing(n.id, EdgeKind::TaintedBy)
520 .iter()
521 .map(|s| node_to_ref(s, root))
522 .collect();
523 TaintPath {
524 target_name: n.name.clone(),
525 target_file: relative_path(&n.file_path, root),
526 target_line: n.span.start_line,
527 sources,
528 }
529 })
530 .collect();
531
532 Json(paths)
533}
534
535#[derive(Serialize)]
536struct ArchitectureResponse {
537 file_count: usize,
538 symbol_count: usize,
539 edge_count: usize,
540 languages: Vec<LangCount>,
541 largest_files: Vec<FileSize>,
542 kind_distribution: Vec<KindCount>,
543 edge_distribution: Vec<KindCount>,
544}
545
546#[derive(Serialize)]
547struct LangCount {
548 language: String,
549 count: usize,
550}
551
552#[derive(Serialize)]
553struct FileSize {
554 path: String,
555 symbol_count: usize,
556}
557
558#[derive(Serialize)]
559struct KindCount {
560 kind: String,
561 count: usize,
562}
563
564async fn api_architecture(State(state): State<AppState>) -> Json<ArchitectureResponse> {
565 let graph = state.graph.read().await;
566 let root = &state.project_root;
567
568 let mut lang_map: HashMap<String, usize> = HashMap::new();
570 for node in graph.all_nodes() {
571 *lang_map
572 .entry(format!("{:?}", node.language))
573 .or_default() += 1;
574 }
575 let mut languages: Vec<LangCount> = lang_map
576 .into_iter()
577 .map(|(language, count)| LangCount { language, count })
578 .collect();
579 languages.sort_by(|a, b| b.count.cmp(&a.count));
580
581 let mut file_counts: HashMap<String, usize> = HashMap::new();
583 for node in graph.all_nodes() {
584 if node.kind != NodeKind::File && node.kind != NodeKind::Folder {
585 *file_counts
586 .entry(relative_path(&node.file_path, root))
587 .or_default() += 1;
588 }
589 }
590 let mut largest_files: Vec<FileSize> = file_counts
591 .into_iter()
592 .map(|(path, symbol_count)| FileSize { path, symbol_count })
593 .collect();
594 largest_files.sort_by(|a, b| b.symbol_count.cmp(&a.symbol_count));
595 largest_files.truncate(15);
596
597 let kinds = [
599 NodeKind::File,
600 NodeKind::Class,
601 NodeKind::Struct,
602 NodeKind::Enum,
603 NodeKind::Interface,
604 NodeKind::Trait,
605 NodeKind::Function,
606 NodeKind::Method,
607 NodeKind::Constructor,
608 NodeKind::Import,
609 NodeKind::Variable,
610 NodeKind::Constant,
611 NodeKind::Field,
612 NodeKind::TypeAlias,
613 ];
614 let kind_distribution: Vec<KindCount> = kinds
615 .iter()
616 .map(|k| KindCount {
617 kind: format!("{:?}", k),
618 count: graph.find_by_kind(*k).len(),
619 })
620 .filter(|kc| kc.count > 0)
621 .collect();
622
623 use petgraph::visit::IntoEdgeReferences;
625 let mut edge_map: HashMap<String, usize> = HashMap::new();
626 for e in graph.graph.edge_references() {
627 *edge_map
628 .entry(format!("{:?}", e.weight().kind))
629 .or_default() += 1;
630 }
631 let mut edge_distribution: Vec<KindCount> = edge_map
632 .into_iter()
633 .map(|(kind, count)| KindCount { kind, count })
634 .collect();
635 edge_distribution.sort_by(|a, b| b.count.cmp(&a.count));
636
637 Json(ArchitectureResponse {
638 file_count: graph.find_by_kind(NodeKind::File).len(),
639 symbol_count: graph.node_count(),
640 edge_count: graph.edge_count(),
641 languages,
642 largest_files,
643 kind_distribution,
644 edge_distribution,
645 })
646}
647
648#[derive(Serialize)]
649struct PatternFinding {
650 pattern: String,
651 severity: String,
652 symbol_name: String,
653 detail: String,
654 file_path: String,
655 line: u32,
656}
657
658async fn api_patterns(
659 State(state): State<AppState>,
660 Query(params): Query<LimitQuery>,
661) -> Json<Vec<PatternFinding>> {
662 let graph = state.graph.read().await;
663 let root = &state.project_root;
664 let mut findings = Vec::new();
665
666 for node in graph.all_nodes().filter(|n| n.kind == NodeKind::Class) {
668 let methods = graph.children(node.id);
669 let method_count = methods
670 .iter()
671 .filter(|c| c.kind == NodeKind::Method || c.kind == NodeKind::Constructor)
672 .count();
673 if method_count > 15 {
674 findings.push(PatternFinding {
675 pattern: "God Class".into(),
676 severity: "warning".into(),
677 symbol_name: node.name.clone(),
678 detail: format!("{} methods", method_count),
679 file_path: relative_path(&node.file_path, root),
680 line: node.span.start_line,
681 });
682 }
683 }
684
685 for node in graph.all_nodes().filter(|n| n.kind.is_callable()) {
687 let param_count = graph
688 .children(node.id)
689 .iter()
690 .filter(|c| c.kind == NodeKind::Parameter)
691 .count();
692 if param_count > 5 {
693 findings.push(PatternFinding {
694 pattern: "Long Parameter List".into(),
695 severity: "info".into(),
696 symbol_name: node.name.clone(),
697 detail: format!("{} parameters", param_count),
698 file_path: relative_path(&node.file_path, root),
699 line: node.span.start_line,
700 });
701 }
702 }
703
704 for node in graph.all_nodes().filter(|n| n.kind.is_callable()) {
706 if let Some(cx) = &node.complexity {
707 if cx.cyclomatic > 15 {
708 findings.push(PatternFinding {
709 pattern: "High Complexity".into(),
710 severity: "warning".into(),
711 symbol_name: node.name.clone(),
712 detail: format!("cyclomatic={}, cognitive={}", cx.cyclomatic, cx.cognitive),
713 file_path: relative_path(&node.file_path, root),
714 line: node.span.start_line,
715 });
716 }
717 }
718 }
719
720 for node in graph.all_nodes().filter(|n| n.kind.is_callable()) {
722 if let Some(cx) = &node.complexity {
723 if cx.max_nesting_depth > 5 {
724 findings.push(PatternFinding {
725 pattern: "Deep Nesting".into(),
726 severity: "info".into(),
727 symbol_name: node.name.clone(),
728 detail: format!("max depth {}", cx.max_nesting_depth),
729 file_path: relative_path(&node.file_path, root),
730 line: node.span.start_line,
731 });
732 }
733 }
734 }
735
736 findings.truncate(params.limit.min(1000));
737 Json(findings)
738}
739
740#[derive(Serialize)]
741struct ApiSurfaceResponse {
742 public: Vec<ApiSymbolEntry>,
743 effectively_internal: Vec<ApiSymbolEntry>,
744 internal_count: usize,
745 private_count: usize,
746}
747
748#[derive(Serialize)]
749struct ApiSymbolEntry {
750 name: String,
751 kind: String,
752 file_path: String,
753 start_line: u32,
754 signature: Option<String>,
755 external_callers: usize,
756}
757
758async fn api_surface(State(state): State<AppState>) -> Json<ApiSurfaceResponse> {
759 let graph = state.graph.read().await;
760 let root = &state.project_root;
761
762 let mut public = Vec::new();
763 let mut effectively_internal = Vec::new();
764 let mut internal_count = 0usize;
765 let mut private_count = 0usize;
766
767 for node in graph.all_nodes() {
768 if !node.kind.is_callable() && !node.kind.is_type_def() {
769 continue;
770 }
771
772 match node.visibility {
773 Visibility::Private => {
774 private_count += 1;
775 }
776 Visibility::Internal => {
777 internal_count += 1;
778 }
779 Visibility::Public | Visibility::Exported => {
780 let callers = graph.callers(node.id);
781 let external_callers = callers
782 .iter()
783 .filter(|c| c.file_path != node.file_path)
784 .count();
785
786 let entry = ApiSymbolEntry {
787 name: node.name.clone(),
788 kind: format!("{:?}", node.kind),
789 file_path: relative_path(&node.file_path, root),
790 start_line: node.span.start_line,
791 signature: node.signature.clone(),
792 external_callers,
793 };
794
795 if external_callers == 0 {
796 effectively_internal.push(entry);
797 } else {
798 public.push(entry);
799 }
800 }
801 }
802 }
803
804 public.sort_by(|a, b| b.external_callers.cmp(&a.external_callers));
805 effectively_internal.sort_by(|a, b| a.name.cmp(&b.name));
806
807 Json(ApiSurfaceResponse {
808 public,
809 effectively_internal,
810 internal_count,
811 private_count,
812 })
813}
814
815#[derive(Deserialize)]
818struct FileContentQuery {
819 path: String,
820}
821
822#[derive(Serialize)]
823struct FileContentResponse {
824 path: String,
825 content: String,
826 line_count: usize,
827 size_bytes: u64,
828}
829
830async fn api_file_content(
831 State(state): State<AppState>,
832 Query(params): Query<FileContentQuery>,
833) -> impl IntoResponse {
834 let full_path = state.project_root.join(¶ms.path);
835 let canonical = match full_path.canonicalize() {
836 Ok(p) => p,
837 Err(_) => return (StatusCode::NOT_FOUND, "File not found").into_response(),
838 };
839 let root_canonical = state
840 .project_root
841 .canonicalize()
842 .unwrap_or_else(|_| state.project_root.clone());
843 if !canonical.starts_with(&root_canonical) {
844 return (StatusCode::FORBIDDEN, "Path outside project").into_response();
845 }
846 let metadata = match std::fs::metadata(&canonical) {
847 Ok(m) => m,
848 Err(_) => return (StatusCode::NOT_FOUND, "File not found").into_response(),
849 };
850 if metadata.len() > 1_048_576 {
851 return (StatusCode::BAD_REQUEST, "File too large (>1MB)").into_response();
852 }
853 match std::fs::read_to_string(&canonical) {
854 Ok(content) => {
855 let line_count = content.lines().count();
856 Json(FileContentResponse {
857 path: params.path,
858 content,
859 line_count,
860 size_bytes: metadata.len(),
861 })
862 .into_response()
863 }
864 Err(_) => (StatusCode::BAD_REQUEST, "Binary or unreadable file").into_response(),
865 }
866}
867
868#[derive(Serialize, Clone)]
869struct FileSymbolEntry {
870 name: String,
871 kind: String,
872 start_line: u32,
873 end_line: u32,
874 children: Vec<FileSymbolEntry>,
875}
876
877#[derive(Serialize)]
878struct FileSymbolsResponse {
879 path: String,
880 symbols: Vec<FileSymbolEntry>,
881 symbol_count: usize,
882}
883
884async fn api_file_symbols(
885 State(state): State<AppState>,
886 Query(params): Query<FileContentQuery>,
887) -> impl IntoResponse {
888 let graph = state.graph.read().await;
889 let root = &state.project_root;
890 let full_path = root.join(¶ms.path);
891
892 let file_nodes: Vec<_> = graph
893 .all_nodes()
894 .filter(|n| n.file_path == full_path && n.kind != NodeKind::File && n.kind != NodeKind::Folder)
895 .collect();
896
897 let mut child_ids: std::collections::HashSet<graphy_core::SymbolId> =
899 std::collections::HashSet::new();
900 for n in &file_nodes {
901 for c in graph.children(n.id) {
902 if c.file_path == full_path {
903 child_ids.insert(c.id);
904 }
905 }
906 }
907
908 let mut symbols: Vec<FileSymbolEntry> = file_nodes
910 .iter()
911 .filter(|n| !child_ids.contains(&n.id))
912 .map(|n| {
913 let mut children: Vec<FileSymbolEntry> = graph
914 .children(n.id)
915 .iter()
916 .filter(|c| c.file_path == full_path)
917 .map(|c| FileSymbolEntry {
918 name: c.name.clone(),
919 kind: format!("{:?}", c.kind),
920 start_line: c.span.start_line,
921 end_line: c.span.end_line,
922 children: vec![],
923 })
924 .collect();
925 children.sort_by_key(|c| c.start_line);
926 FileSymbolEntry {
927 name: n.name.clone(),
928 kind: format!("{:?}", n.kind),
929 start_line: n.span.start_line,
930 end_line: n.span.end_line,
931 children,
932 }
933 })
934 .collect();
935
936 symbols.sort_by_key(|s| s.start_line);
937 let symbol_count = symbols.len() + symbols.iter().map(|s| s.children.len()).sum::<usize>();
938
939 Json(FileSymbolsResponse {
940 path: params.path,
941 symbols,
942 symbol_count,
943 })
944 .into_response()
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use std::path::Path;
951
952 #[test]
955 fn relative_path_strips_prefix() {
956 let path = Path::new("/home/user/project/src/main.rs");
957 let root = Path::new("/home/user/project");
958 assert_eq!(relative_path(path, root), "src/main.rs");
959 }
960
961 #[test]
962 fn relative_path_no_common_prefix() {
963 let path = Path::new("/other/path/file.rs");
964 let root = Path::new("/home/user/project");
965 assert_eq!(relative_path(path, root), "/other/path/file.rs");
966 }
967
968 #[test]
969 fn relative_path_same_directory() {
970 let path = Path::new("/project/file.rs");
971 let root = Path::new("/project");
972 assert_eq!(relative_path(path, root), "file.rs");
973 }
974
975 #[test]
976 fn relative_path_root_is_file() {
977 let path = Path::new("/project");
979 let root = Path::new("/project");
980 assert_eq!(relative_path(path, root), "");
981 }
982
983 #[test]
986 fn default_limit_is_20() {
987 assert_eq!(default_limit(), 20);
988 }
989
990 #[test]
993 fn node_to_ref_builds_symbol_ref() {
994 let node = graphy_core::GirNode::new(
995 "my_func".into(),
996 NodeKind::Function,
997 PathBuf::from("/project/src/main.rs"),
998 graphy_core::Span::new(10, 0, 20, 0),
999 graphy_core::Language::Rust,
1000 );
1001 let root = Path::new("/project");
1002 let sym_ref = node_to_ref(&node, root);
1003 assert_eq!(sym_ref.name, "my_func");
1004 assert_eq!(sym_ref.kind, "Function");
1005 assert_eq!(sym_ref.file_path, "src/main.rs");
1006 assert_eq!(sym_ref.start_line, 10);
1007 }
1008
1009 #[test]
1012 fn stats_response_serialization() {
1013 let stats = StatsResponse {
1014 nodes: 100, edges: 200, files: 10, classes: 5,
1015 structs: 3, enums: 2, traits: 1, functions: 30,
1016 methods: 20, imports: 15, variables: 5, constants: 2,
1017 };
1018 let json = serde_json::to_value(&stats).unwrap();
1019 assert_eq!(json["nodes"], 100);
1020 assert_eq!(json["edges"], 200);
1021 }
1022
1023 #[test]
1024 fn hotspot_item_serialization() {
1025 let item = HotspotItem {
1026 name: "complex_fn".into(),
1027 kind: "Function".into(),
1028 file_path: "src/main.rs".into(),
1029 start_line: 10,
1030 cyclomatic: 25,
1031 cognitive: 30,
1032 loc: 100,
1033 caller_count: 5,
1034 risk_score: 37.5,
1035 };
1036 let json = serde_json::to_value(&item).unwrap();
1037 assert_eq!(json["name"], "complex_fn");
1038 assert_eq!(json["risk_score"], 37.5);
1039 }
1040
1041 #[test]
1042 fn dead_code_item_serialization() {
1043 let item = DeadCodeItem {
1044 name: "unused_fn".into(),
1045 kind: "Function".into(),
1046 file_path: "src/lib.rs".into(),
1047 start_line: 42,
1048 visibility: "Public".into(),
1049 dead_probability: 0.95,
1050 };
1051 let json = serde_json::to_value(&item).unwrap();
1052 let prob = json["dead_probability"].as_f64().unwrap();
1053 assert!((prob - 0.95).abs() < 0.001);
1054 }
1055
1056 #[test]
1057 fn pattern_finding_serialization() {
1058 let finding = PatternFinding {
1059 pattern: "God Class".into(),
1060 severity: "warning".into(),
1061 symbol_name: "BigController".into(),
1062 detail: "20 methods".into(),
1063 file_path: "src/controller.rs".into(),
1064 line: 1,
1065 };
1066 let json = serde_json::to_value(&finding).unwrap();
1067 assert_eq!(json["pattern"], "God Class");
1068 assert_eq!(json["severity"], "warning");
1069 }
1070
1071 #[test]
1072 fn search_query_deserialization() {
1073 let json = serde_json::json!({
1074 "q": "main",
1075 "limit": 10,
1076 "kind": "Function"
1077 });
1078 let query: SearchQuery = serde_json::from_value(json).unwrap();
1079 assert_eq!(query.q, "main");
1080 assert_eq!(query.limit, 10);
1081 assert_eq!(query.kind, Some("Function".into()));
1082 assert!(query.lang.is_none());
1083 assert!(query.file.is_none());
1084 }
1085
1086 #[test]
1087 fn search_query_defaults() {
1088 let json = serde_json::json!({ "q": "test" });
1089 let query: SearchQuery = serde_json::from_value(json).unwrap();
1090 assert_eq!(query.limit, 20); }
1092
1093 #[test]
1096 fn serve_embedded_file_not_found() {
1097 let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
1099 rt.block_on(async {
1100 let response = serve_frontend(axum::http::Uri::from_static("/nonexistent_path_xyz")).await;
1101 let _ = response;
1104 });
1105 }
1106}