Skip to main content

graphy_web/
lib.rs

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/// Shared application state.
25#[derive(Clone)]
26pub struct AppState {
27    pub graph: Arc<RwLock<CodeGraph>>,
28    pub search: Arc<SearchIndex>,
29    pub project_root: PathBuf,
30}
31
32/// Launch the web server with graceful shutdown support.
33pub 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
67/// Strip the project root prefix from a path, returning a relative path string.
68fn 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
75// ── Frontend Serving ────────────────────────────────────────
76
77async 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// ── API Handlers ────────────────────────────────────────────
105
106#[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    // Collect eligible nodes with a relevance score for prioritization
303    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            // Score: type defs and files are important, complexity adds weight, callers add weight
308            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    // Sort by score descending, then take top N
325    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            // Only include edges where both endpoints are in our node set
365            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// ── New API Endpoints ───────────────────────────────────────
408
409#[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    // Use the liveness scores computed by Phase 13 during indexing.
480    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    // Language distribution
569    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    // File sizes
582    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    // Node kind distribution
598    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    // Edge kind distribution
624    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    // God classes
667    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    // Long parameter lists
686    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    // High complexity
705    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    // Deep nesting
721    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// ── File Content & Symbols ──────────────────────────────────
816
817#[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(&params.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(&params.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    // Collect IDs that are children of something in this file
898    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    // Top-level = not a child of anything in this file
909    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    // ── relative_path ──────────────────────────────────────
953
954    #[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        // If root is same as path
978        let path = Path::new("/project");
979        let root = Path::new("/project");
980        assert_eq!(relative_path(path, root), "");
981    }
982
983    // ── default_limit ──────────────────────────────────────
984
985    #[test]
986    fn default_limit_is_20() {
987        assert_eq!(default_limit(), 20);
988    }
989
990    // ── node_to_ref ────────────────────────────────────────
991
992    #[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    // ── Serde structs ──────────────────────────────────────
1010
1011    #[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); // default_limit()
1091    }
1092
1093    // ── serve_embedded_file ────────────────────────────────
1094
1095    #[test]
1096    fn serve_embedded_file_not_found() {
1097        // Non-existent file returns 404
1098        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            // Should fall back to index.html (SPA routing) or return something
1102            // Just verify it doesn't panic
1103            let _ = response;
1104        });
1105    }
1106}