greppy/web/
server.rs

1//! Axum web server for greppy web UI
2
3use axum::{
4    extract::{Path, Query, State},
5    http::{header, StatusCode},
6    response::{Html, IntoResponse, Response},
7    routing::{get, post},
8    Json, Router,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet, VecDeque};
12use std::net::SocketAddr;
13use std::path::PathBuf;
14use std::sync::{Arc, RwLock};
15
16use crate::core::error::Result;
17use crate::core::project::Project;
18use crate::trace::{
19    compare_snapshots, create_snapshot, find_dead_symbols, list_snapshots, load_index,
20    load_snapshot, trace_index_exists, trace_index_path, SemanticIndex, SymbolKind,
21};
22use crate::web::events::{api_events, start_daemon_event_forwarder, EventsState};
23use crate::web::projects::{api_projects, api_switch_project, ProjectsState};
24use crate::web::settings::{
25    api_get_settings, api_put_settings, redact_path, SettingsState, WebSettings,
26};
27
28// =============================================================================
29// STATIC FILES (EMBEDDED)
30// =============================================================================
31
32const INDEX_HTML: &str = include_str!("static/index.html");
33const STYLE_CSS: &str = include_str!("static/style.css");
34const APP_JS: &str = include_str!("static/app.js");
35
36// Module files
37const API_JS: &str = include_str!("static/api.js");
38const UTILS_JS: &str = include_str!("static/utils.js");
39
40// Views
41const VIEWS_LIST_JS: &str = include_str!("static/views/list.js");
42const VIEWS_STATS_JS: &str = include_str!("static/views/stats.js");
43const VIEWS_GRAPH_JS: &str = include_str!("static/views/graph.js");
44const VIEWS_TREE_JS: &str = include_str!("static/views/tree.js");
45const VIEWS_TABLES_JS: &str = include_str!("static/views/tables.js");
46const VIEWS_CYCLES_JS: &str = include_str!("static/views/cycles.js");
47const VIEWS_TIMELINE_JS: &str = include_str!("static/views/timeline.js");
48
49// Components
50const COMPONENTS_DETAIL_JS: &str = include_str!("static/components/detail.js");
51const COMPONENTS_DROPDOWN_JS: &str = include_str!("static/components/dropdown.js");
52const COMPONENTS_SSE_JS: &str = include_str!("static/components/sse.js");
53const COMPONENTS_CYCLES_JS: &str = include_str!("static/components/cycles.js");
54const COMPONENTS_EXPORT_JS: &str = include_str!("static/components/export.js");
55const COMPONENTS_SEARCH_JS: &str = include_str!("static/components/search.js");
56const COMPONENTS_SETTINGS_JS: &str = include_str!("static/components/settings.js");
57const COMPONENTS_SKELETON_JS: &str = include_str!("static/components/skeleton.js");
58const COMPONENTS_EMPTY_JS: &str = include_str!("static/components/empty.js");
59const COMPONENTS_ERROR_JS: &str = include_str!("static/components/error.js");
60
61// Lib
62const LIB_PERSISTENCE_JS: &str = include_str!("static/lib/persistence.js");
63
64// =============================================================================
65// STATE
66// =============================================================================
67
68#[derive(Clone)]
69pub struct AppState {
70    pub project_name: String,
71    pub project_path: PathBuf,
72    pub index: Arc<SemanticIndex>,
73    pub dead_symbols: Arc<HashSet<u32>>,
74    pub settings: Arc<RwLock<WebSettings>>,
75}
76
77impl AppState {
78    /// Redact a path if streamer mode is enabled
79    fn redact(&self, path: &str) -> String {
80        let settings = self.settings.read().unwrap();
81        if settings.streamer_mode {
82            redact_path(path, &settings)
83        } else {
84            path.to_string()
85        }
86    }
87}
88
89// =============================================================================
90// API TYPES
91// =============================================================================
92
93#[derive(Serialize)]
94pub struct StatsResponse {
95    pub project: String,
96    pub files: usize,
97    pub symbols: usize,
98    pub dead: usize,
99    pub cycles: usize,
100    pub last_indexed: String,
101    pub breakdown: SymbolBreakdown,
102}
103
104#[derive(Serialize)]
105pub struct SymbolBreakdown {
106    pub functions: usize,
107    pub classes: usize,
108    pub types: usize,
109    pub variables: usize,
110    pub interfaces: usize,
111    pub methods: usize,
112}
113
114#[derive(Deserialize)]
115pub struct ListQuery {
116    #[serde(rename = "type")]
117    pub symbol_type: Option<String>,
118    pub state: Option<String>,
119    pub search: Option<String>,
120    pub limit: Option<usize>,
121}
122
123#[derive(Serialize)]
124pub struct ListResponse {
125    pub items: Vec<ListItem>,
126    pub total: usize,
127}
128
129#[derive(Serialize)]
130pub struct ListItem {
131    pub id: u32,
132    pub name: String,
133    #[serde(rename = "type")]
134    pub symbol_type: String,
135    pub path: String,
136    pub line: u32,
137    pub refs: usize,
138    pub callers: usize,
139    pub callees: usize,
140    pub state: String,
141}
142
143#[derive(Deserialize)]
144pub struct GraphQuery {
145    #[serde(rename = "type")]
146    pub symbol_type: Option<String>,
147    pub state: Option<String>,
148    /// If true, return hierarchical data for treemap visualization
149    pub hierarchical: Option<bool>,
150    /// Path to zoom into (for drill-down navigation)
151    pub path: Option<String>,
152}
153
154#[derive(Serialize)]
155pub struct GraphResponse {
156    pub nodes: Vec<GraphNode>,
157    pub edges: Vec<GraphEdge>,
158}
159
160#[derive(Serialize)]
161pub struct GraphNode {
162    pub id: String,
163    pub name: String,
164    pub symbols: usize,
165    pub dead: usize,
166    pub imports: usize,
167    pub exports: usize,
168    #[serde(skip_serializing_if = "std::ops::Not::not")]
169    pub cycle: bool,
170}
171
172#[derive(Serialize)]
173pub struct GraphEdge {
174    pub source: String,
175    pub target: String,
176    pub weight: usize,
177}
178
179// =============================================================================
180// HIERARCHICAL GRAPH TYPES (FOR TREEMAP)
181// =============================================================================
182
183#[derive(Serialize)]
184pub struct HierarchicalGraphResponse {
185    /// Root of the hierarchy
186    pub root: HierarchyNode,
187    /// Current path for breadcrumb navigation
188    pub current_path: String,
189    /// Total counts at this level
190    pub totals: HierarchyTotals,
191}
192
193#[derive(Serialize, Clone)]
194pub struct HierarchyNode {
195    /// Display name (folder or file name)
196    pub name: String,
197    /// Full path from root
198    pub path: String,
199    /// "dir" or "file"
200    #[serde(rename = "type")]
201    pub node_type: String,
202    /// Symbol count (used for treemap sizing)
203    pub value: usize,
204    /// Number of dead symbols
205    pub dead: usize,
206    /// Health percentage (0-100)
207    pub health: u8,
208    /// Is this node or any child involved in a cycle?
209    #[serde(skip_serializing_if = "std::ops::Not::not")]
210    pub cycle: bool,
211    /// Children (folders or files)
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub children: Option<Vec<HierarchyNode>>,
214    /// Number of files (for directories)
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub file_count: Option<usize>,
217}
218
219#[derive(Serialize)]
220pub struct HierarchyTotals {
221    pub files: usize,
222    pub symbols: usize,
223    pub dead: usize,
224    pub cycles: usize,
225    pub health: u8,
226}
227
228// =============================================================================
229// TREE API TYPES
230// =============================================================================
231
232#[derive(Serialize)]
233pub struct TreeResponse {
234    pub root: TreeNode,
235}
236
237#[derive(Serialize, Clone)]
238pub struct TreeNode {
239    pub name: String,
240    #[serde(rename = "type")]
241    pub node_type: String,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub path: Option<String>,
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub children: Option<Vec<TreeNode>>,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub symbols: Option<usize>,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub dead: Option<usize>,
250    #[serde(skip_serializing_if = "std::ops::Not::not")]
251    pub cycle: bool,
252}
253
254#[derive(Serialize)]
255pub struct FileResponse {
256    pub path: String,
257    pub symbols: Vec<FileSymbol>,
258}
259
260#[derive(Serialize)]
261pub struct FileSymbol {
262    pub id: u32,
263    pub name: String,
264    #[serde(rename = "type")]
265    pub symbol_type: String,
266    pub line: u32,
267    pub end_line: u32,
268    pub refs: usize,
269    pub dead: bool,
270}
271
272// =============================================================================
273// SYMBOL DETAIL API TYPES
274// =============================================================================
275
276/// Full details for a single symbol
277#[derive(Serialize)]
278pub struct SymbolDetailResponse {
279    pub id: u32,
280    pub name: String,
281    pub kind: String,
282    pub file: String,
283    pub line: u32,
284    pub end_line: u32,
285    pub refs: usize,
286    pub callers_count: usize,
287    pub callees_count: usize,
288    pub is_dead: bool,
289    pub in_cycle: bool,
290    pub is_entry_point: bool,
291}
292
293/// Caller information with depth
294#[derive(Serialize)]
295pub struct CallerInfo {
296    pub id: u32,
297    pub name: String,
298    pub file: String,
299    pub line: u32,
300    pub depth: usize,
301}
302
303/// Response for symbol callers endpoint
304#[derive(Serialize)]
305pub struct CallersResponse {
306    pub symbol_id: u32,
307    pub callers: Vec<CallerInfo>,
308    pub total: usize,
309}
310
311/// Callee information
312#[derive(Serialize)]
313pub struct CalleeInfo {
314    pub id: u32,
315    pub name: String,
316    pub file: String,
317    pub line: u32,
318}
319
320/// Response for symbol callees endpoint
321#[derive(Serialize)]
322pub struct CalleesResponse {
323    pub symbol_id: u32,
324    pub callees: Vec<CalleeInfo>,
325    pub total: usize,
326}
327
328/// Reference information with context
329#[derive(Serialize)]
330pub struct RefInfo {
331    pub file: String,
332    pub line: u32,
333    pub kind: String,
334    pub context: String,
335}
336
337/// Response for symbol refs endpoint
338#[derive(Serialize)]
339pub struct RefsResponse {
340    pub symbol_id: u32,
341    pub refs: Vec<RefInfo>,
342    pub total: usize,
343}
344
345/// Blast radius information
346#[derive(Serialize)]
347pub struct BlastRadius {
348    pub direct_callers: usize,
349    pub transitive_callers: usize,
350    pub files_affected: usize,
351    pub entry_points_affected: usize,
352}
353
354/// Response for symbol impact endpoint
355#[derive(Serialize)]
356pub struct ImpactResponse {
357    pub symbol_id: u32,
358    pub risk_level: String,
359    pub blast_radius: BlastRadius,
360    pub paths_to_entry: Vec<Vec<String>>,
361}
362
363/// Cycle symbol information
364#[derive(Serialize)]
365pub struct CycleSymbol {
366    pub id: u32,
367    pub name: String,
368    pub file: String,
369}
370
371/// Individual cycle information
372#[derive(Serialize)]
373pub struct CycleInfo {
374    pub id: usize,
375    pub size: usize,
376    pub severity: String,
377    pub symbols: Vec<CycleSymbol>,
378    pub path: Vec<String>,
379}
380
381/// Response for cycles endpoint
382#[derive(Serialize)]
383pub struct CyclesResponse {
384    pub total_cycles: usize,
385    pub total_symbols_in_cycles: usize,
386    pub cycles: Vec<CycleInfo>,
387}
388
389// =============================================================================
390// SNAPSHOT API TYPES
391// =============================================================================
392
393/// Response for snapshot list endpoint
394#[derive(Serialize)]
395pub struct SnapshotsListResponse {
396    pub snapshots: Vec<SnapshotSummaryResponse>,
397    pub total: usize,
398}
399
400/// Summary of a snapshot
401#[derive(Serialize)]
402pub struct SnapshotSummaryResponse {
403    pub id: String,
404    pub name: Option<String>,
405    pub created_at: String,
406    pub files: u32,
407    pub symbols: u32,
408    pub dead: u32,
409    pub cycles: u32,
410}
411
412/// Request to create a snapshot
413#[derive(Deserialize)]
414pub struct CreateSnapshotRequest {
415    pub name: Option<String>,
416}
417
418/// Response for snapshot comparison
419#[derive(Serialize)]
420pub struct SnapshotCompareResponse {
421    pub a: SnapshotSummaryResponse,
422    pub b: SnapshotSummaryResponse,
423    pub diff: SnapshotDiffResponse,
424}
425
426/// Diff between two snapshots
427#[derive(Serialize)]
428pub struct SnapshotDiffResponse {
429    pub files: i32,
430    pub symbols: i32,
431    pub dead: i32,
432    pub cycles: i32,
433}
434
435// =============================================================================
436// HELPERS
437// =============================================================================
438
439fn symbol_kind_str(kind: SymbolKind) -> &'static str {
440    match kind {
441        SymbolKind::Function => "function",
442        SymbolKind::Method => "method",
443        SymbolKind::Class => "class",
444        SymbolKind::Struct => "struct",
445        SymbolKind::Enum => "enum",
446        SymbolKind::Interface => "interface",
447        SymbolKind::TypeAlias => "type",
448        SymbolKind::Constant => "constant",
449        SymbolKind::Variable => "variable",
450        SymbolKind::Module => "module",
451        SymbolKind::Unknown => "unknown",
452    }
453}
454
455/// Count cycles using DFS (simplified version)
456fn count_cycles(index: &SemanticIndex) -> usize {
457    let mut graph: HashMap<u16, HashSet<u16>> = HashMap::new();
458
459    for edge in &index.edges {
460        if let (Some(from_sym), Some(to_sym)) =
461            (index.symbol(edge.from_symbol), index.symbol(edge.to_symbol))
462        {
463            if from_sym.file_id != to_sym.file_id {
464                graph
465                    .entry(from_sym.file_id)
466                    .or_default()
467                    .insert(to_sym.file_id);
468            }
469        }
470    }
471
472    let mut cycles = 0;
473    let mut visited = HashSet::new();
474    let mut rec_stack = HashSet::new();
475
476    for &node in graph.keys() {
477        if !visited.contains(&node) {
478            cycles += count_cycles_dfs(node, &graph, &mut visited, &mut rec_stack);
479        }
480    }
481
482    cycles
483}
484
485fn count_cycles_dfs(
486    node: u16,
487    graph: &HashMap<u16, HashSet<u16>>,
488    visited: &mut HashSet<u16>,
489    rec_stack: &mut HashSet<u16>,
490) -> usize {
491    visited.insert(node);
492    rec_stack.insert(node);
493
494    let mut cycles = 0;
495
496    if let Some(neighbors) = graph.get(&node) {
497        for &neighbor in neighbors {
498            if !visited.contains(&neighbor) {
499                cycles += count_cycles_dfs(neighbor, graph, visited, rec_stack);
500            } else if rec_stack.contains(&neighbor) {
501                cycles += 1;
502            }
503        }
504    }
505
506    rec_stack.remove(&node);
507    cycles
508}
509
510/// Find files involved in cycles
511fn find_cycle_files(index: &SemanticIndex) -> HashSet<u16> {
512    let mut graph: HashMap<u16, HashSet<u16>> = HashMap::new();
513
514    for edge in &index.edges {
515        if let (Some(from_sym), Some(to_sym)) =
516            (index.symbol(edge.from_symbol), index.symbol(edge.to_symbol))
517        {
518            if from_sym.file_id != to_sym.file_id {
519                graph
520                    .entry(from_sym.file_id)
521                    .or_default()
522                    .insert(to_sym.file_id);
523            }
524        }
525    }
526
527    let mut cycle_files = HashSet::new();
528    let mut visited = HashSet::new();
529    let mut rec_stack = HashSet::new();
530    let mut path = Vec::new();
531
532    for &node in graph.keys() {
533        if !visited.contains(&node) {
534            find_cycle_files_dfs(
535                node,
536                &graph,
537                &mut visited,
538                &mut rec_stack,
539                &mut path,
540                &mut cycle_files,
541            );
542        }
543    }
544
545    cycle_files
546}
547
548fn find_cycle_files_dfs(
549    node: u16,
550    graph: &HashMap<u16, HashSet<u16>>,
551    visited: &mut HashSet<u16>,
552    rec_stack: &mut HashSet<u16>,
553    path: &mut Vec<u16>,
554    cycle_files: &mut HashSet<u16>,
555) {
556    visited.insert(node);
557    rec_stack.insert(node);
558    path.push(node);
559
560    if let Some(neighbors) = graph.get(&node) {
561        for &neighbor in neighbors {
562            if !visited.contains(&neighbor) {
563                find_cycle_files_dfs(neighbor, graph, visited, rec_stack, path, cycle_files);
564            } else if rec_stack.contains(&neighbor) {
565                // Found cycle - mark all files in the cycle
566                if let Some(start) = path.iter().position(|&n| n == neighbor) {
567                    for &f in &path[start..] {
568                        cycle_files.insert(f);
569                    }
570                }
571            }
572        }
573    }
574
575    path.pop();
576    rec_stack.remove(&node);
577}
578
579/// Build a hierarchical tree from flat file paths
580fn build_file_tree(
581    index: &SemanticIndex,
582    dead_symbols: &HashSet<u32>,
583    cycle_files: &HashSet<u16>,
584) -> TreeNode {
585    // Collect file info: (path, file_id, symbol_count, dead_count, is_cycle)
586    let mut file_info: Vec<(String, u16, usize, usize, bool)> = Vec::new();
587
588    for (file_id, path) in index.files.iter().enumerate() {
589        let file_id = file_id as u16;
590        let path_str = path.to_string_lossy().to_string();
591
592        // Count symbols and dead symbols in this file
593        let mut symbol_count = 0usize;
594        let mut dead_count = 0usize;
595
596        for symbol in index.symbols_in_file(file_id) {
597            symbol_count += 1;
598            if dead_symbols.contains(&symbol.id) {
599                dead_count += 1;
600            }
601        }
602
603        let is_cycle = cycle_files.contains(&file_id);
604        file_info.push((path_str, file_id, symbol_count, dead_count, is_cycle));
605    }
606
607    // Build tree structure
608    let mut root = TreeNode {
609        name: "root".to_string(),
610        node_type: "dir".to_string(),
611        path: None,
612        children: Some(Vec::new()),
613        symbols: None,
614        dead: None,
615        cycle: false,
616    };
617
618    for (path, _file_id, symbol_count, dead_count, is_cycle) in file_info {
619        insert_path_into_tree(&mut root, &path, symbol_count, dead_count, is_cycle);
620    }
621
622    // Sort children alphabetically, directories first
623    sort_tree_children(&mut root);
624
625    // Propagate cycle status up the tree
626    propagate_cycle_status(&mut root);
627
628    root
629}
630
631fn insert_path_into_tree(
632    root: &mut TreeNode,
633    path: &str,
634    symbol_count: usize,
635    dead_count: usize,
636    is_cycle: bool,
637) {
638    let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
639    insert_path_recursive(root, &parts, 0, path, symbol_count, dead_count, is_cycle);
640}
641
642fn insert_path_recursive(
643    current: &mut TreeNode,
644    parts: &[&str],
645    index: usize,
646    full_path: &str,
647    symbol_count: usize,
648    dead_count: usize,
649    is_cycle: bool,
650) {
651    if index >= parts.len() {
652        return;
653    }
654
655    let part = parts[index];
656    let is_last = index == parts.len() - 1;
657
658    // Ensure children vec exists
659    if current.children.is_none() {
660        current.children = Some(Vec::new());
661    }
662
663    let children = current.children.as_mut().unwrap();
664
665    // Find existing child or create new one
666    let child_idx = children.iter().position(|c| c.name == part);
667
668    if let Some(idx) = child_idx {
669        if is_last {
670            // Update existing file node
671            let child = &mut children[idx];
672            child.symbols = Some(symbol_count);
673            child.dead = if dead_count > 0 {
674                Some(dead_count)
675            } else {
676                None
677            };
678            child.cycle = is_cycle;
679            child.path = Some(full_path.to_string());
680        } else {
681            // Recurse into existing directory
682            insert_path_recursive(
683                &mut children[idx],
684                parts,
685                index + 1,
686                full_path,
687                symbol_count,
688                dead_count,
689                is_cycle,
690            );
691        }
692    } else {
693        // Create new node
694        let new_node = if is_last {
695            TreeNode {
696                name: part.to_string(),
697                node_type: "file".to_string(),
698                path: Some(full_path.to_string()),
699                children: None,
700                symbols: Some(symbol_count),
701                dead: if dead_count > 0 {
702                    Some(dead_count)
703                } else {
704                    None
705                },
706                cycle: is_cycle,
707            }
708        } else {
709            TreeNode {
710                name: part.to_string(),
711                node_type: "dir".to_string(),
712                path: None,
713                children: Some(Vec::new()),
714                symbols: None,
715                dead: None,
716                cycle: false,
717            }
718        };
719
720        children.push(new_node);
721
722        if !is_last {
723            // Recurse into newly created directory
724            let len = children.len();
725            insert_path_recursive(
726                &mut children[len - 1],
727                parts,
728                index + 1,
729                full_path,
730                symbol_count,
731                dead_count,
732                is_cycle,
733            );
734        }
735    }
736}
737
738fn sort_tree_children(node: &mut TreeNode) {
739    if let Some(ref mut children) = node.children {
740        // Sort: directories first, then alphabetically
741        children.sort_by(|a, b| {
742            let a_is_dir = a.node_type == "dir";
743            let b_is_dir = b.node_type == "dir";
744
745            match (a_is_dir, b_is_dir) {
746                (true, false) => std::cmp::Ordering::Less,
747                (false, true) => std::cmp::Ordering::Greater,
748                _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
749            }
750        });
751
752        // Recursively sort children
753        for child in children.iter_mut() {
754            sort_tree_children(child);
755        }
756    }
757}
758
759/// Propagate cycle status up the tree (if any child has cycle, parent has cycle)
760fn propagate_cycle_status(node: &mut TreeNode) -> bool {
761    if let Some(ref mut children) = node.children {
762        let mut has_cycle = node.cycle;
763        for child in children.iter_mut() {
764            if propagate_cycle_status(child) {
765                has_cycle = true;
766            }
767        }
768        node.cycle = has_cycle;
769        has_cycle
770    } else {
771        node.cycle
772    }
773}
774
775/// Recursively redact all paths in the tree for streamer mode
776fn redact_tree_paths(node: &mut TreeNode, state: &AppState) {
777    // Redact this node's path if present
778    if let Some(ref path) = node.path {
779        node.path = Some(state.redact(path));
780    }
781
782    // Recursively redact children
783    if let Some(ref mut children) = node.children {
784        for child in children.iter_mut() {
785            redact_tree_paths(child, state);
786        }
787    }
788}
789
790// =============================================================================
791// ROUTES
792// =============================================================================
793
794async fn index_html() -> Html<&'static str> {
795    Html(INDEX_HTML)
796}
797
798async fn style_css() -> Response {
799    (
800        StatusCode::OK,
801        [(header::CONTENT_TYPE, "text/css")],
802        STYLE_CSS,
803    )
804        .into_response()
805}
806
807async fn app_js() -> Response {
808    (
809        StatusCode::OK,
810        [(header::CONTENT_TYPE, "application/javascript")],
811        APP_JS,
812    )
813        .into_response()
814}
815
816async fn api_js() -> Response {
817    (
818        StatusCode::OK,
819        [(header::CONTENT_TYPE, "application/javascript")],
820        API_JS,
821    )
822        .into_response()
823}
824
825async fn utils_js() -> Response {
826    (
827        StatusCode::OK,
828        [(header::CONTENT_TYPE, "application/javascript")],
829        UTILS_JS,
830    )
831        .into_response()
832}
833
834async fn views_list_js() -> Response {
835    (
836        StatusCode::OK,
837        [(header::CONTENT_TYPE, "application/javascript")],
838        VIEWS_LIST_JS,
839    )
840        .into_response()
841}
842
843async fn views_stats_js() -> Response {
844    (
845        StatusCode::OK,
846        [(header::CONTENT_TYPE, "application/javascript")],
847        VIEWS_STATS_JS,
848    )
849        .into_response()
850}
851
852async fn views_graph_js() -> Response {
853    (
854        StatusCode::OK,
855        [(header::CONTENT_TYPE, "application/javascript")],
856        VIEWS_GRAPH_JS,
857    )
858        .into_response()
859}
860
861async fn views_tree_js() -> Response {
862    (
863        StatusCode::OK,
864        [(header::CONTENT_TYPE, "application/javascript")],
865        VIEWS_TREE_JS,
866    )
867        .into_response()
868}
869
870async fn views_tables_js() -> Response {
871    (
872        StatusCode::OK,
873        [(header::CONTENT_TYPE, "application/javascript")],
874        VIEWS_TABLES_JS,
875    )
876        .into_response()
877}
878
879async fn views_cycles_js() -> Response {
880    (
881        StatusCode::OK,
882        [(header::CONTENT_TYPE, "application/javascript")],
883        VIEWS_CYCLES_JS,
884    )
885        .into_response()
886}
887
888async fn views_timeline_js() -> Response {
889    (
890        StatusCode::OK,
891        [(header::CONTENT_TYPE, "application/javascript")],
892        VIEWS_TIMELINE_JS,
893    )
894        .into_response()
895}
896
897async fn components_detail_js() -> Response {
898    (
899        StatusCode::OK,
900        [(header::CONTENT_TYPE, "application/javascript")],
901        COMPONENTS_DETAIL_JS,
902    )
903        .into_response()
904}
905
906async fn components_dropdown_js() -> Response {
907    (
908        StatusCode::OK,
909        [(header::CONTENT_TYPE, "application/javascript")],
910        COMPONENTS_DROPDOWN_JS,
911    )
912        .into_response()
913}
914
915async fn components_sse_js() -> Response {
916    (
917        StatusCode::OK,
918        [(header::CONTENT_TYPE, "application/javascript")],
919        COMPONENTS_SSE_JS,
920    )
921        .into_response()
922}
923
924async fn components_cycles_js() -> Response {
925    (
926        StatusCode::OK,
927        [(header::CONTENT_TYPE, "application/javascript")],
928        COMPONENTS_CYCLES_JS,
929    )
930        .into_response()
931}
932
933async fn components_export_js() -> Response {
934    (
935        StatusCode::OK,
936        [(header::CONTENT_TYPE, "application/javascript")],
937        COMPONENTS_EXPORT_JS,
938    )
939        .into_response()
940}
941
942async fn components_search_js() -> Response {
943    (
944        StatusCode::OK,
945        [(header::CONTENT_TYPE, "application/javascript")],
946        COMPONENTS_SEARCH_JS,
947    )
948        .into_response()
949}
950
951async fn components_settings_js() -> Response {
952    (
953        StatusCode::OK,
954        [(header::CONTENT_TYPE, "application/javascript")],
955        COMPONENTS_SETTINGS_JS,
956    )
957        .into_response()
958}
959
960async fn components_skeleton_js() -> Response {
961    (
962        StatusCode::OK,
963        [(header::CONTENT_TYPE, "application/javascript")],
964        COMPONENTS_SKELETON_JS,
965    )
966        .into_response()
967}
968
969async fn components_empty_js() -> Response {
970    (
971        StatusCode::OK,
972        [(header::CONTENT_TYPE, "application/javascript")],
973        COMPONENTS_EMPTY_JS,
974    )
975        .into_response()
976}
977
978async fn components_error_js() -> Response {
979    (
980        StatusCode::OK,
981        [(header::CONTENT_TYPE, "application/javascript")],
982        COMPONENTS_ERROR_JS,
983    )
984        .into_response()
985}
986
987async fn lib_persistence_js() -> Response {
988    (
989        StatusCode::OK,
990        [(header::CONTENT_TYPE, "application/javascript")],
991        LIB_PERSISTENCE_JS,
992    )
993        .into_response()
994}
995
996async fn api_stats(State(state): State<AppState>) -> Json<StatsResponse> {
997    let index = &state.index;
998    let stats = index.stats();
999
1000    let mut breakdown = HashMap::new();
1001    for symbol in &index.symbols {
1002        let kind = symbol_kind_str(symbol.symbol_kind());
1003        *breakdown.entry(kind).or_insert(0usize) += 1;
1004    }
1005
1006    let dead = state.dead_symbols.len();
1007    let cycles = count_cycles(index);
1008
1009    Json(StatsResponse {
1010        project: state.project_name.clone(),
1011        files: stats.files,
1012        symbols: stats.symbols,
1013        dead,
1014        cycles,
1015        last_indexed: "just now".to_string(),
1016        breakdown: SymbolBreakdown {
1017            functions: *breakdown.get("function").unwrap_or(&0),
1018            classes: *breakdown.get("class").unwrap_or(&0),
1019            types: *breakdown.get("type").unwrap_or(&0),
1020            variables: *breakdown.get("variable").unwrap_or(&0),
1021            interfaces: *breakdown.get("interface").unwrap_or(&0),
1022            methods: *breakdown.get("method").unwrap_or(&0),
1023        },
1024    })
1025}
1026
1027async fn api_list(
1028    State(state): State<AppState>,
1029    Query(query): Query<ListQuery>,
1030) -> Json<ListResponse> {
1031    let index = &state.index;
1032    let limit = query.limit.unwrap_or(200).min(1000);
1033
1034    let mut items = Vec::new();
1035
1036    for symbol in &index.symbols {
1037        let name = index.symbol_name(symbol).unwrap_or("").to_string();
1038        let kind = symbol_kind_str(symbol.symbol_kind());
1039        let path = index
1040            .file_path(symbol.file_id)
1041            .map(|p| state.redact(&p.to_string_lossy()))
1042            .unwrap_or_default();
1043
1044        // Apply filters
1045        if let Some(ref type_filter) = query.symbol_type {
1046            if type_filter != "all" && kind != type_filter {
1047                continue;
1048            }
1049        }
1050
1051        let is_dead = state.dead_symbols.contains(&symbol.id);
1052
1053        if let Some(ref state_filter) = query.state {
1054            match state_filter.as_str() {
1055                "dead" if !is_dead => continue,
1056                "used" if is_dead => continue,
1057                _ => {}
1058            }
1059        }
1060
1061        if let Some(ref search) = query.search {
1062            if !search.is_empty() && !name.to_lowercase().contains(&search.to_lowercase()) {
1063                continue;
1064            }
1065        }
1066
1067        let refs = index.references_to(symbol.id).count();
1068        let callers = index.callers(symbol.id).len();
1069        let callees = index.callees(symbol.id).len();
1070
1071        items.push(ListItem {
1072            id: symbol.id,
1073            name,
1074            symbol_type: kind.to_string(),
1075            path,
1076            line: symbol.start_line as u32,
1077            refs,
1078            callers,
1079            callees,
1080            state: if is_dead {
1081                "dead".to_string()
1082            } else {
1083                "used".to_string()
1084            },
1085        });
1086
1087        if items.len() >= limit {
1088            break;
1089        }
1090    }
1091
1092    let total = items.len();
1093    Json(ListResponse { items, total })
1094}
1095
1096async fn api_graph(State(state): State<AppState>, Query(query): Query<GraphQuery>) -> Response {
1097    // Check if hierarchical treemap data is requested
1098    if query.hierarchical.unwrap_or(false) {
1099        return api_graph_hierarchical(State(state), query)
1100            .await
1101            .into_response();
1102    }
1103
1104    // Original force-directed graph logic
1105    let index = &state.index;
1106
1107    // Build file-level graph
1108    let mut file_symbols: HashMap<u16, usize> = HashMap::new();
1109    let mut file_dead: HashMap<u16, usize> = HashMap::new();
1110    let mut file_edges: HashMap<(u16, u16), usize> = HashMap::new();
1111    let mut file_imports: HashMap<u16, usize> = HashMap::new();
1112    let mut file_exports: HashMap<u16, usize> = HashMap::new();
1113
1114    // Count symbols per file
1115    for symbol in &index.symbols {
1116        *file_symbols.entry(symbol.file_id).or_insert(0) += 1;
1117
1118        if state.dead_symbols.contains(&symbol.id) {
1119            *file_dead.entry(symbol.file_id).or_insert(0) += 1;
1120        }
1121
1122        // Count exports (entry points)
1123        if symbol.is_entry_point() {
1124            *file_exports.entry(symbol.file_id).or_insert(0) += 1;
1125        }
1126    }
1127
1128    // Build edges between files
1129    for edge in &index.edges {
1130        if let (Some(from_sym), Some(to_sym)) =
1131            (index.symbol(edge.from_symbol), index.symbol(edge.to_symbol))
1132        {
1133            if from_sym.file_id != to_sym.file_id {
1134                *file_edges
1135                    .entry((from_sym.file_id, to_sym.file_id))
1136                    .or_insert(0) += 1;
1137                *file_imports.entry(from_sym.file_id).or_insert(0) += 1;
1138            }
1139        }
1140    }
1141
1142    // Find cycle files
1143    let cycle_files = find_cycle_files(index);
1144
1145    // Filter files based on query
1146    let include_file = |file_id: u16| -> bool {
1147        if let Some(ref state_filter) = query.state {
1148            match state_filter.as_str() {
1149                "dead" => return file_dead.get(&file_id).copied().unwrap_or(0) > 0,
1150                "cycle" => return cycle_files.contains(&file_id),
1151                _ => {}
1152            }
1153        }
1154        true
1155    };
1156
1157    // Build nodes
1158    let mut nodes = Vec::new();
1159    for (file_id, &symbol_count) in &file_symbols {
1160        if !include_file(*file_id) {
1161            continue;
1162        }
1163
1164        let raw_path = index
1165            .file_path(*file_id)
1166            .map(|p| p.to_string_lossy().to_string())
1167            .unwrap_or_else(|| format!("file_{}", file_id));
1168
1169        let path = state.redact(&raw_path);
1170
1171        let name = std::path::Path::new(&raw_path)
1172            .file_name()
1173            .map(|n| n.to_string_lossy().to_string())
1174            .unwrap_or_else(|| path.clone());
1175
1176        nodes.push(GraphNode {
1177            id: path.clone(),
1178            name,
1179            symbols: symbol_count,
1180            dead: file_dead.get(file_id).copied().unwrap_or(0),
1181            imports: file_imports.get(file_id).copied().unwrap_or(0),
1182            exports: file_exports.get(file_id).copied().unwrap_or(0),
1183            cycle: cycle_files.contains(file_id),
1184        });
1185    }
1186
1187    // Build edges (only between included nodes)
1188    let node_ids: HashSet<_> = nodes.iter().map(|n| n.id.clone()).collect();
1189    let mut edges = Vec::new();
1190
1191    for ((from_id, to_id), weight) in &file_edges {
1192        let from_path = index
1193            .file_path(*from_id)
1194            .map(|p| state.redact(&p.to_string_lossy()))
1195            .unwrap_or_default();
1196        let to_path = index
1197            .file_path(*to_id)
1198            .map(|p| state.redact(&p.to_string_lossy()))
1199            .unwrap_or_default();
1200
1201        if node_ids.contains(&from_path) && node_ids.contains(&to_path) {
1202            edges.push(GraphEdge {
1203                source: from_path,
1204                target: to_path,
1205                weight: *weight,
1206            });
1207        }
1208    }
1209
1210    // Limit for performance
1211    if nodes.len() > 100 {
1212        // Sort by symbol count and take top 100
1213        nodes.sort_by(|a, b| b.symbols.cmp(&a.symbols));
1214        nodes.truncate(100);
1215
1216        // Filter edges to only include remaining nodes
1217        let remaining_ids: HashSet<_> = nodes.iter().map(|n| n.id.clone()).collect();
1218        edges.retain(|e| remaining_ids.contains(&e.source) && remaining_ids.contains(&e.target));
1219    }
1220
1221    Json(GraphResponse { nodes, edges }).into_response()
1222}
1223
1224/// Build hierarchical treemap data for scalable visualization
1225async fn api_graph_hierarchical(
1226    State(state): State<AppState>,
1227    query: GraphQuery,
1228) -> Json<HierarchicalGraphResponse> {
1229    let index = &state.index;
1230    let cycle_files = find_cycle_files(index);
1231    let base_path = query.path.unwrap_or_default();
1232
1233    // Build file stats map: file_id -> (symbols, dead, in_cycle)
1234    let mut file_stats: HashMap<u16, (usize, usize, bool)> = HashMap::new();
1235
1236    for symbol in &index.symbols {
1237        let entry = file_stats.entry(symbol.file_id).or_insert((0, 0, false));
1238        entry.0 += 1; // symbol count
1239        if state.dead_symbols.contains(&symbol.id) {
1240            entry.1 += 1; // dead count
1241        }
1242    }
1243
1244    // Mark cycle files
1245    for file_id in &cycle_files {
1246        if let Some(entry) = file_stats.get_mut(file_id) {
1247            entry.2 = true;
1248        }
1249    }
1250
1251    // Build hierarchy from file paths
1252    let root = build_treemap_hierarchy(index, &file_stats, &base_path, &state);
1253
1254    // Calculate totals
1255    let totals = calc_treemap_totals(&root);
1256
1257    Json(HierarchicalGraphResponse {
1258        root,
1259        current_path: state.redact(&base_path),
1260        totals,
1261    })
1262}
1263
1264/// Build a hierarchical tree structure from file paths
1265fn build_treemap_hierarchy(
1266    index: &SemanticIndex,
1267    file_stats: &HashMap<u16, (usize, usize, bool)>,
1268    base_path: &str,
1269    state: &AppState,
1270) -> HierarchyNode {
1271    // Group files by their path components
1272    let mut dir_children: HashMap<String, Vec<(String, u16)>> = HashMap::new();
1273
1274    for (file_id, _stats) in file_stats {
1275        if let Some(path) = index.file_path(*file_id) {
1276            let path_str: String = path.to_string_lossy().to_string();
1277
1278            // Filter by base_path if specified
1279            if !base_path.is_empty() && !path_str.starts_with(base_path) {
1280                continue;
1281            }
1282
1283            // Get relative path from base
1284            let relative = if base_path.is_empty() {
1285                path_str.clone()
1286            } else {
1287                path_str
1288                    .strip_prefix(base_path)
1289                    .unwrap_or(&path_str)
1290                    .trim_start_matches('/')
1291                    .to_string()
1292            };
1293
1294            // Get first component (immediate child)
1295            let first_component = relative.split('/').next().unwrap_or(&relative);
1296            let full_child_path = if base_path.is_empty() {
1297                first_component.to_string()
1298            } else {
1299                format!("{}/{}", base_path, first_component)
1300            };
1301
1302            dir_children
1303                .entry(full_child_path)
1304                .or_default()
1305                .push((path_str, *file_id));
1306        }
1307    }
1308
1309    // Build children nodes
1310    let mut children: Vec<HierarchyNode> = Vec::new();
1311
1312    for (child_path, files) in dir_children {
1313        let name = std::path::Path::new(&child_path)
1314            .file_name()
1315            .map(|n| n.to_string_lossy().to_string())
1316            .unwrap_or_else(|| child_path.clone());
1317
1318        // Check if this is a single file or a directory
1319        if files.len() == 1 && files[0].0 == child_path {
1320            // It's a file
1321            let (symbols, dead, cycle) = file_stats
1322                .get(&files[0].1)
1323                .copied()
1324                .unwrap_or((1, 0, false));
1325            let health = if symbols > 0 {
1326                (((symbols - dead) as f64 / symbols as f64) * 100.0) as u8
1327            } else {
1328                100
1329            };
1330
1331            children.push(HierarchyNode {
1332                name,
1333                path: state.redact(&child_path),
1334                node_type: "file".to_string(),
1335                value: symbols.max(1), // Ensure minimum value for treemap
1336                dead,
1337                health,
1338                cycle,
1339                children: None,
1340                file_count: None,
1341            });
1342        } else {
1343            // It's a directory - recurse
1344            let sub_node = build_treemap_hierarchy(index, file_stats, &child_path, state);
1345            children.push(sub_node);
1346        }
1347    }
1348
1349    // Sort children by value (largest first)
1350    children.sort_by(|a, b| b.value.cmp(&a.value));
1351
1352    // Calculate aggregate stats for this directory
1353    let (total_value, total_dead, any_cycle, file_count) =
1354        children.iter().fold((0, 0, false, 0), |acc, child| {
1355            let files = if child.node_type == "file" {
1356                1
1357            } else {
1358                child.file_count.unwrap_or(0)
1359            };
1360            (
1361                acc.0 + child.value,
1362                acc.1 + child.dead,
1363                acc.2 || child.cycle,
1364                acc.3 + files,
1365            )
1366        });
1367
1368    let health = if total_value > 0 {
1369        (((total_value - total_dead) as f64 / total_value as f64) * 100.0) as u8
1370    } else {
1371        100
1372    };
1373
1374    let name = if base_path.is_empty() {
1375        ".".to_string()
1376    } else {
1377        std::path::Path::new(base_path)
1378            .file_name()
1379            .map(|n| n.to_string_lossy().to_string())
1380            .unwrap_or_else(|| base_path.to_string())
1381    };
1382
1383    HierarchyNode {
1384        name,
1385        path: state.redact(base_path),
1386        node_type: "dir".to_string(),
1387        value: total_value.max(1),
1388        dead: total_dead,
1389        health,
1390        cycle: any_cycle,
1391        children: Some(children),
1392        file_count: Some(file_count),
1393    }
1394}
1395
1396/// Calculate totals for the hierarchy response
1397fn calc_treemap_totals(root: &HierarchyNode) -> HierarchyTotals {
1398    fn count_recursive(node: &HierarchyNode) -> (usize, usize, usize, usize) {
1399        if node.node_type == "file" {
1400            let cycle_count = if node.cycle { 1 } else { 0 };
1401            (1, node.value, node.dead, cycle_count)
1402        } else if let Some(children) = &node.children {
1403            children.iter().fold((0, 0, 0, 0), |acc, child| {
1404                let (f, s, d, c) = count_recursive(child);
1405                (acc.0 + f, acc.1 + s, acc.2 + d, acc.3 + c)
1406            })
1407        } else {
1408            (0, 0, 0, 0)
1409        }
1410    }
1411
1412    let (files, symbols, dead, cycles) = count_recursive(root);
1413    let health = if symbols > 0 {
1414        (((symbols - dead) as f64 / symbols as f64) * 100.0) as u8
1415    } else {
1416        100
1417    };
1418
1419    HierarchyTotals {
1420        files,
1421        symbols,
1422        dead,
1423        cycles,
1424        health,
1425    }
1426}
1427
1428async fn api_tree(State(state): State<AppState>) -> Json<TreeResponse> {
1429    let index = &state.index;
1430    let cycle_files = find_cycle_files(index);
1431    let mut tree = build_file_tree(index, &state.dead_symbols, &cycle_files);
1432
1433    // Apply path redaction for streamer mode
1434    redact_tree_paths(&mut tree, &state);
1435
1436    Json(TreeResponse { root: tree })
1437}
1438
1439async fn api_file(
1440    State(state): State<AppState>,
1441    Path(file_path): Path<String>,
1442) -> std::result::Result<Json<FileResponse>, StatusCode> {
1443    let index = &state.index;
1444
1445    // URL decode the path
1446    let decoded_path = urlencoding::decode(&file_path)
1447        .map(|s| s.into_owned())
1448        .unwrap_or(file_path);
1449
1450    // Find the file_id for this path
1451    let file_id = index
1452        .files
1453        .iter()
1454        .enumerate()
1455        .find(|(_, p)| p.to_string_lossy() == decoded_path)
1456        .map(|(id, _)| id as u16);
1457
1458    let file_id = match file_id {
1459        Some(id) => id,
1460        None => return Err(StatusCode::NOT_FOUND),
1461    };
1462
1463    // Get symbols for this file
1464    let mut symbols: Vec<FileSymbol> = Vec::new();
1465
1466    for symbol in index.symbols_in_file(file_id) {
1467        let name = index.symbol_name(symbol).unwrap_or("").to_string();
1468        let kind = symbol_kind_str(symbol.symbol_kind());
1469        let refs = index.references_to(symbol.id).count();
1470        let is_dead = state.dead_symbols.contains(&symbol.id);
1471
1472        symbols.push(FileSymbol {
1473            id: symbol.id,
1474            name,
1475            symbol_type: kind.to_string(),
1476            line: symbol.start_line,
1477            end_line: symbol.end_line,
1478            refs,
1479            dead: is_dead,
1480        });
1481    }
1482
1483    // Sort by line number
1484    symbols.sort_by_key(|s| s.line);
1485
1486    Ok(Json(FileResponse {
1487        path: state.redact(&decoded_path),
1488        symbols,
1489    }))
1490}
1491
1492// =============================================================================
1493// SYMBOL DETAIL API HANDLERS
1494// =============================================================================
1495
1496/// GET /api/symbol/:id - Full details for a single symbol
1497async fn api_symbol_detail(
1498    State(state): State<AppState>,
1499    Path(symbol_id): Path<u32>,
1500) -> std::result::Result<Json<SymbolDetailResponse>, StatusCode> {
1501    let index = &state.index;
1502
1503    let symbol = match index.symbol(symbol_id) {
1504        Some(s) => s,
1505        None => return Err(StatusCode::NOT_FOUND),
1506    };
1507
1508    let name = index.symbol_name(symbol).unwrap_or("").to_string();
1509    let kind = symbol_kind_str(symbol.symbol_kind()).to_string();
1510    let file = index
1511        .file_path(symbol.file_id)
1512        .map(|p| state.redact(&p.to_string_lossy()))
1513        .unwrap_or_default();
1514
1515    let refs = index.references_to(symbol.id).count();
1516    let callers_count = index.callers(symbol.id).len();
1517    let callees_count = index.callees(symbol.id).len();
1518    let is_dead = state.dead_symbols.contains(&symbol.id);
1519
1520    // Check if symbol is in a cycle
1521    let cycle_files = find_cycle_files(index);
1522    let in_cycle = cycle_files.contains(&symbol.file_id);
1523
1524    Ok(Json(SymbolDetailResponse {
1525        id: symbol.id,
1526        name,
1527        kind,
1528        file,
1529        line: symbol.start_line,
1530        end_line: symbol.end_line,
1531        refs,
1532        callers_count,
1533        callees_count,
1534        is_dead,
1535        in_cycle,
1536        is_entry_point: symbol.is_entry_point(),
1537    }))
1538}
1539
1540/// GET /api/symbol/:id/callers - All callers of a symbol with depth
1541async fn api_symbol_callers(
1542    State(state): State<AppState>,
1543    Path(symbol_id): Path<u32>,
1544) -> std::result::Result<Json<CallersResponse>, StatusCode> {
1545    let index = &state.index;
1546
1547    // Verify symbol exists
1548    if index.symbol(symbol_id).is_none() {
1549        return Err(StatusCode::NOT_FOUND);
1550    }
1551
1552    // BFS to find all callers with depth
1553    let mut callers: Vec<CallerInfo> = Vec::new();
1554    let mut visited: HashSet<u32> = HashSet::new();
1555    let mut queue: VecDeque<(u32, usize)> = VecDeque::new();
1556
1557    // Start with direct callers (depth 1)
1558    for &caller_id in index.callers(symbol_id) {
1559        if visited.insert(caller_id) {
1560            queue.push_back((caller_id, 1));
1561        }
1562    }
1563
1564    // BFS traversal
1565    while let Some((current_id, depth)) = queue.pop_front() {
1566        if let Some(symbol) = index.symbol(current_id) {
1567            let name = index.symbol_name(symbol).unwrap_or("").to_string();
1568            let file = index
1569                .file_path(symbol.file_id)
1570                .map(|p| state.redact(&p.to_string_lossy()))
1571                .unwrap_or_default();
1572
1573            callers.push(CallerInfo {
1574                id: current_id,
1575                name,
1576                file,
1577                line: symbol.start_line,
1578                depth,
1579            });
1580
1581            // Continue BFS up to depth 10
1582            if depth < 10 {
1583                for &parent_id in index.callers(current_id) {
1584                    if visited.insert(parent_id) {
1585                        queue.push_back((parent_id, depth + 1));
1586                    }
1587                }
1588            }
1589        }
1590    }
1591
1592    // Sort by depth, then by name
1593    callers.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.name.cmp(&b.name)));
1594
1595    let total = callers.len();
1596    Ok(Json(CallersResponse {
1597        symbol_id,
1598        callers,
1599        total,
1600    }))
1601}
1602
1603/// GET /api/symbol/:id/callees - All symbols this symbol calls
1604async fn api_symbol_callees(
1605    State(state): State<AppState>,
1606    Path(symbol_id): Path<u32>,
1607) -> std::result::Result<Json<CalleesResponse>, StatusCode> {
1608    let index = &state.index;
1609
1610    // Verify symbol exists
1611    if index.symbol(symbol_id).is_none() {
1612        return Err(StatusCode::NOT_FOUND);
1613    }
1614
1615    let mut callees: Vec<CalleeInfo> = Vec::new();
1616
1617    for &callee_id in index.callees(symbol_id) {
1618        if let Some(symbol) = index.symbol(callee_id) {
1619            let name = index.symbol_name(symbol).unwrap_or("").to_string();
1620            let file = index
1621                .file_path(symbol.file_id)
1622                .map(|p| state.redact(&p.to_string_lossy()))
1623                .unwrap_or_default();
1624
1625            callees.push(CalleeInfo {
1626                id: callee_id,
1627                name,
1628                file,
1629                line: symbol.start_line,
1630            });
1631        }
1632    }
1633
1634    // Sort by name
1635    callees.sort_by(|a, b| a.name.cmp(&b.name));
1636
1637    let total = callees.len();
1638    Ok(Json(CalleesResponse {
1639        symbol_id,
1640        callees,
1641        total,
1642    }))
1643}
1644
1645/// GET /api/symbol/:id/refs - All references to a symbol with context
1646async fn api_symbol_refs(
1647    State(state): State<AppState>,
1648    Path(symbol_id): Path<u32>,
1649) -> std::result::Result<Json<RefsResponse>, StatusCode> {
1650    let index = &state.index;
1651
1652    // Verify symbol exists
1653    if index.symbol(symbol_id).is_none() {
1654        return Err(StatusCode::NOT_FOUND);
1655    }
1656
1657    let mut refs: Vec<RefInfo> = Vec::new();
1658
1659    for reference in index.references_to(symbol_id) {
1660        if let Some(token) = index.token(reference.token_id) {
1661            let file = index
1662                .file_path(token.file_id)
1663                .map(|p| state.redact(&p.to_string_lossy()))
1664                .unwrap_or_default();
1665
1666            let kind = match reference.ref_kind() {
1667                crate::trace::RefKind::Read => "Read",
1668                crate::trace::RefKind::Write => "Write",
1669                crate::trace::RefKind::Call => "Call",
1670                crate::trace::RefKind::TypeAnnotation => "Type",
1671                crate::trace::RefKind::Import => "Import",
1672                crate::trace::RefKind::Export => "Export",
1673                crate::trace::RefKind::Inheritance => "Inheritance",
1674                crate::trace::RefKind::Decorator => "Decorator",
1675                crate::trace::RefKind::Construction => "Construction",
1676                crate::trace::RefKind::Unknown => "Unknown",
1677            }
1678            .to_string();
1679
1680            // Build context string (we don't have source content, so use token name)
1681            let token_name = index.token_name(token).unwrap_or("");
1682            let context = format!(
1683                "{}:{} - {}",
1684                file.split('/').last().unwrap_or(&file),
1685                token.line,
1686                token_name
1687            );
1688
1689            refs.push(RefInfo {
1690                file,
1691                line: token.line,
1692                kind,
1693                context,
1694            });
1695        }
1696    }
1697
1698    // Sort by file, then by line
1699    refs.sort_by(|a, b| a.file.cmp(&b.file).then_with(|| a.line.cmp(&b.line)));
1700
1701    let total = refs.len();
1702    Ok(Json(RefsResponse {
1703        symbol_id,
1704        refs,
1705        total,
1706    }))
1707}
1708
1709/// GET /api/symbol/:id/impact - Impact analysis for a symbol
1710async fn api_symbol_impact(
1711    State(state): State<AppState>,
1712    Path(symbol_id): Path<u32>,
1713) -> std::result::Result<Json<ImpactResponse>, StatusCode> {
1714    let index = &state.index;
1715
1716    // Verify symbol exists
1717    if index.symbol(symbol_id).is_none() {
1718        return Err(StatusCode::NOT_FOUND);
1719    }
1720
1721    // Calculate transitive callers using BFS
1722    let mut all_callers: HashSet<u32> = HashSet::new();
1723    let mut affected_files: HashSet<u16> = HashSet::new();
1724    let mut affected_entry_points: HashSet<u32> = HashSet::new();
1725    let mut paths_to_entry: Vec<Vec<String>> = Vec::new();
1726    let mut queue: VecDeque<(u32, Vec<u32>)> = VecDeque::new();
1727
1728    // Start with direct callers
1729    let direct_callers = index.callers(symbol_id).len();
1730    for &caller_id in index.callers(symbol_id) {
1731        queue.push_back((caller_id, vec![caller_id]));
1732        all_callers.insert(caller_id);
1733    }
1734
1735    // BFS to find all transitive callers and paths to entry points
1736    while let Some((current_id, path)) = queue.pop_front() {
1737        if let Some(symbol) = index.symbol(current_id) {
1738            affected_files.insert(symbol.file_id);
1739
1740            // Check if this is an entry point
1741            if symbol.is_entry_point() {
1742                affected_entry_points.insert(current_id);
1743
1744                // Build path names (limit to 5 paths)
1745                if paths_to_entry.len() < 5 {
1746                    let path_names: Vec<String> = path
1747                        .iter()
1748                        .filter_map(|&id| {
1749                            index
1750                                .symbol(id)
1751                                .and_then(|s| index.symbol_name(s).map(|n| n.to_string()))
1752                        })
1753                        .collect();
1754                    if !path_names.is_empty() {
1755                        paths_to_entry.push(path_names);
1756                    }
1757                }
1758            }
1759
1760            // Continue BFS (limit depth to 50)
1761            if path.len() < 50 {
1762                for &parent_id in index.callers(current_id) {
1763                    if all_callers.insert(parent_id) {
1764                        let mut new_path = path.clone();
1765                        new_path.push(parent_id);
1766                        queue.push_back((parent_id, new_path));
1767                    }
1768                }
1769            }
1770        }
1771    }
1772
1773    // Calculate risk level
1774    let risk_level = if affected_entry_points.len() > 10 || affected_files.len() > 50 {
1775        "critical"
1776    } else if affected_entry_points.len() > 5 || affected_files.len() > 20 {
1777        "high"
1778    } else if direct_callers > 5 || affected_files.len() > 5 {
1779        "medium"
1780    } else {
1781        "low"
1782    }
1783    .to_string();
1784
1785    Ok(Json(ImpactResponse {
1786        symbol_id,
1787        risk_level,
1788        blast_radius: BlastRadius {
1789            direct_callers,
1790            transitive_callers: all_callers.len(),
1791            files_affected: affected_files.len(),
1792            entry_points_affected: affected_entry_points.len(),
1793        },
1794        paths_to_entry,
1795    }))
1796}
1797
1798/// GET /api/cycles - All circular dependencies
1799async fn api_cycles(State(state): State<AppState>) -> Json<CyclesResponse> {
1800    let index = &state.index;
1801
1802    // Build file-level graph and find cycles
1803    let mut graph: HashMap<u16, HashSet<u16>> = HashMap::new();
1804
1805    for edge in &index.edges {
1806        if let (Some(from_sym), Some(to_sym)) =
1807            (index.symbol(edge.from_symbol), index.symbol(edge.to_symbol))
1808        {
1809            if from_sym.file_id != to_sym.file_id {
1810                graph
1811                    .entry(from_sym.file_id)
1812                    .or_default()
1813                    .insert(to_sym.file_id);
1814            }
1815        }
1816    }
1817
1818    // Find all cycles using DFS with path tracking
1819    let mut all_cycles: Vec<Vec<u16>> = Vec::new();
1820    let mut visited: HashSet<u16> = HashSet::new();
1821    let mut rec_stack: HashSet<u16> = HashSet::new();
1822    let mut path: Vec<u16> = Vec::new();
1823
1824    for &node in graph.keys() {
1825        if !visited.contains(&node) {
1826            find_all_cycles(
1827                node,
1828                &graph,
1829                &mut visited,
1830                &mut rec_stack,
1831                &mut path,
1832                &mut all_cycles,
1833            );
1834        }
1835    }
1836
1837    // Convert cycles to response format
1838    let mut cycles: Vec<CycleInfo> = Vec::new();
1839    let mut symbols_in_cycles: HashSet<u32> = HashSet::new();
1840
1841    for (i, cycle) in all_cycles.iter().enumerate() {
1842        let mut cycle_symbols: Vec<CycleSymbol> = Vec::new();
1843        let mut path_names: Vec<String> = Vec::new();
1844
1845        for &file_id in cycle {
1846            // Get first symbol from this file for display
1847            if let Some(symbol) = index.symbols.iter().find(|s| s.file_id == file_id) {
1848                let name = index.symbol_name(symbol).unwrap_or("").to_string();
1849                let file = index
1850                    .file_path(file_id)
1851                    .map(|p| state.redact(&p.to_string_lossy()))
1852                    .unwrap_or_default();
1853
1854                cycle_symbols.push(CycleSymbol {
1855                    id: symbol.id,
1856                    name: name.clone(),
1857                    file,
1858                });
1859                path_names.push(name);
1860                symbols_in_cycles.insert(symbol.id);
1861            }
1862        }
1863
1864        // Close the cycle path
1865        if let Some(first) = path_names.first() {
1866            path_names.push(first.clone());
1867        }
1868
1869        let severity = if cycle.len() > 5 {
1870            "critical"
1871        } else if cycle.len() > 3 {
1872            "high"
1873        } else {
1874            "medium"
1875        }
1876        .to_string();
1877
1878        cycles.push(CycleInfo {
1879            id: i + 1,
1880            size: cycle.len(),
1881            severity,
1882            symbols: cycle_symbols,
1883            path: path_names,
1884        });
1885    }
1886
1887    // Sort by size (largest first)
1888    cycles.sort_by(|a, b| b.size.cmp(&a.size));
1889
1890    Json(CyclesResponse {
1891        total_cycles: cycles.len(),
1892        total_symbols_in_cycles: symbols_in_cycles.len(),
1893        cycles,
1894    })
1895}
1896
1897/// Helper function to find all cycles in the graph
1898fn find_all_cycles(
1899    node: u16,
1900    graph: &HashMap<u16, HashSet<u16>>,
1901    visited: &mut HashSet<u16>,
1902    rec_stack: &mut HashSet<u16>,
1903    path: &mut Vec<u16>,
1904    cycles: &mut Vec<Vec<u16>>,
1905) {
1906    visited.insert(node);
1907    rec_stack.insert(node);
1908    path.push(node);
1909
1910    if let Some(neighbors) = graph.get(&node) {
1911        for &neighbor in neighbors {
1912            if !visited.contains(&neighbor) {
1913                find_all_cycles(neighbor, graph, visited, rec_stack, path, cycles);
1914            } else if rec_stack.contains(&neighbor) {
1915                // Found a cycle - extract it
1916                if let Some(start_idx) = path.iter().position(|&n| n == neighbor) {
1917                    let cycle: Vec<u16> = path[start_idx..].to_vec();
1918                    // Only add unique cycles (avoid duplicates from different starting points)
1919                    if !cycles
1920                        .iter()
1921                        .any(|c| c.len() == cycle.len() && cycle.iter().all(|n| c.contains(n)))
1922                    {
1923                        cycles.push(cycle);
1924                    }
1925                }
1926            }
1927        }
1928    }
1929
1930    path.pop();
1931    rec_stack.remove(&node);
1932}
1933
1934// =============================================================================
1935// SNAPSHOT HANDLERS
1936// =============================================================================
1937
1938/// GET /api/snapshots - List all snapshots
1939async fn api_list_snapshots(State(state): State<AppState>) -> impl IntoResponse {
1940    match list_snapshots(&state.project_path) {
1941        Ok(list) => {
1942            let snapshots: Vec<SnapshotSummaryResponse> = list
1943                .snapshots
1944                .into_iter()
1945                .map(|s| SnapshotSummaryResponse {
1946                    id: s.id,
1947                    name: s.name,
1948                    created_at: s.created_at.to_rfc3339(),
1949                    files: s.files,
1950                    symbols: s.symbols,
1951                    dead: s.dead,
1952                    cycles: s.cycles,
1953                })
1954                .collect();
1955
1956            let total = snapshots.len();
1957            (
1958                StatusCode::OK,
1959                Json(SnapshotsListResponse { snapshots, total }),
1960            )
1961                .into_response()
1962        }
1963        Err(e) => (
1964            StatusCode::INTERNAL_SERVER_ERROR,
1965            Json(serde_json::json!({ "error": e })),
1966        )
1967            .into_response(),
1968    }
1969}
1970
1971/// POST /api/snapshots - Create a new snapshot
1972async fn api_create_snapshot(
1973    State(state): State<AppState>,
1974    Json(req): Json<CreateSnapshotRequest>,
1975) -> impl IntoResponse {
1976    // Count cycles for the snapshot
1977    let cycles_count = count_cycles(&state.index) as u32;
1978
1979    match create_snapshot(
1980        &state.index,
1981        &state.project_path,
1982        &state.project_name,
1983        &state.dead_symbols,
1984        cycles_count,
1985        req.name,
1986    ) {
1987        Ok(snapshot) => {
1988            let response = SnapshotSummaryResponse {
1989                id: snapshot.id,
1990                name: snapshot.name,
1991                created_at: snapshot.created_at.to_rfc3339(),
1992                files: snapshot.metrics.files,
1993                symbols: snapshot.metrics.symbols,
1994                dead: snapshot.metrics.dead,
1995                cycles: snapshot.metrics.cycles,
1996            };
1997            (StatusCode::CREATED, Json(response)).into_response()
1998        }
1999        Err(e) => (
2000            StatusCode::INTERNAL_SERVER_ERROR,
2001            Json(serde_json::json!({ "error": e })),
2002        )
2003            .into_response(),
2004    }
2005}
2006
2007/// GET /api/snapshots/:id - Get a specific snapshot
2008async fn api_get_snapshot(
2009    State(state): State<AppState>,
2010    Path(id): Path<String>,
2011) -> impl IntoResponse {
2012    match load_snapshot(&state.project_path, &id) {
2013        Ok(snapshot) => (StatusCode::OK, Json(snapshot)).into_response(),
2014        Err(e) => {
2015            if e.contains("not found") {
2016                (
2017                    StatusCode::NOT_FOUND,
2018                    Json(serde_json::json!({ "error": e })),
2019                )
2020                    .into_response()
2021            } else {
2022                (
2023                    StatusCode::INTERNAL_SERVER_ERROR,
2024                    Json(serde_json::json!({ "error": e })),
2025                )
2026                    .into_response()
2027            }
2028        }
2029    }
2030}
2031
2032/// Query parameters for snapshot comparison
2033#[derive(Deserialize)]
2034pub struct CompareQuery {
2035    pub a: String,
2036    pub b: String,
2037}
2038
2039/// GET /api/snapshots/compare?a=id1&b=id2 - Compare two snapshots
2040async fn api_compare_snapshots(
2041    State(state): State<AppState>,
2042    Query(query): Query<CompareQuery>,
2043) -> impl IntoResponse {
2044    match compare_snapshots(&state.project_path, &query.a, &query.b) {
2045        Ok(comparison) => {
2046            let response = SnapshotCompareResponse {
2047                a: SnapshotSummaryResponse {
2048                    id: comparison.a.id,
2049                    name: comparison.a.name,
2050                    created_at: comparison.a.created_at.to_rfc3339(),
2051                    files: comparison.a.files,
2052                    symbols: comparison.a.symbols,
2053                    dead: comparison.a.dead,
2054                    cycles: comparison.a.cycles,
2055                },
2056                b: SnapshotSummaryResponse {
2057                    id: comparison.b.id,
2058                    name: comparison.b.name,
2059                    created_at: comparison.b.created_at.to_rfc3339(),
2060                    files: comparison.b.files,
2061                    symbols: comparison.b.symbols,
2062                    dead: comparison.b.dead,
2063                    cycles: comparison.b.cycles,
2064                },
2065                diff: SnapshotDiffResponse {
2066                    files: comparison.diff.files,
2067                    symbols: comparison.diff.symbols,
2068                    dead: comparison.diff.dead,
2069                    cycles: comparison.diff.cycles,
2070                },
2071            };
2072            (StatusCode::OK, Json(response)).into_response()
2073        }
2074        Err(e) => {
2075            if e.contains("not found") {
2076                (
2077                    StatusCode::NOT_FOUND,
2078                    Json(serde_json::json!({ "error": e })),
2079                )
2080                    .into_response()
2081            } else {
2082                (
2083                    StatusCode::INTERNAL_SERVER_ERROR,
2084                    Json(serde_json::json!({ "error": e })),
2085                )
2086                    .into_response()
2087            }
2088        }
2089    }
2090}
2091
2092// =============================================================================
2093// SERVER
2094// =============================================================================
2095
2096pub async fn run(project_path: PathBuf, port: u16, open_browser: bool) -> Result<()> {
2097    let project = Project::detect(&project_path)?;
2098    let project_name = project
2099        .root
2100        .file_name()
2101        .map(|s| s.to_string_lossy().to_string())
2102        .unwrap_or_else(|| "unknown".to_string());
2103
2104    if !trace_index_exists(&project.root) {
2105        eprintln!("\x1b[31m>\x1b[0m Trace index not found. Run 'greppy index' first.");
2106        return Err(crate::core::error::Error::IndexError {
2107            message: "Index not found".to_string(),
2108        });
2109    }
2110
2111    eprintln!("\x1b[36m>\x1b[0m Loading index...");
2112    let index_path = trace_index_path(&project.root);
2113    let index = load_index(&index_path)?;
2114
2115    // Pre-compute dead symbols
2116    let dead_symbols: HashSet<u32> = find_dead_symbols(&index).iter().map(|s| s.id).collect();
2117
2118    let stats = index.stats();
2119    eprintln!(
2120        "\x1b[36m>\x1b[0m Loaded {} files, {} symbols ({} dead)",
2121        stats.files,
2122        stats.symbols,
2123        dead_symbols.len()
2124    );
2125
2126    // Create settings state (shared between AppState and settings routes)
2127    let settings_state = SettingsState::new();
2128
2129    let state = AppState {
2130        project_name,
2131        project_path: project.root.clone(),
2132        index: Arc::new(index),
2133        dead_symbols: Arc::new(dead_symbols),
2134        settings: settings_state.settings.clone(),
2135    };
2136
2137    // Create project selector state
2138    let projects_state = ProjectsState {
2139        active_path: Arc::new(RwLock::new(project.root.clone())),
2140    };
2141
2142    // Create events state for SSE
2143    let events_state = EventsState::new(project.root.clone());
2144
2145    // Start daemon event forwarder in background
2146    let events_state_clone = events_state.clone();
2147    tokio::spawn(async move {
2148        start_daemon_event_forwarder(events_state_clone).await;
2149    });
2150
2151    // Build sub-routers with their respective states
2152    let data_routes = Router::new()
2153        .route("/stats", get(api_stats))
2154        .route("/list", get(api_list))
2155        .route("/graph", get(api_graph))
2156        .route("/tree", get(api_tree))
2157        .route("/file/*path", get(api_file))
2158        // Symbol detail endpoints
2159        .route("/symbol/:id", get(api_symbol_detail))
2160        .route("/symbol/:id/callers", get(api_symbol_callers))
2161        .route("/symbol/:id/callees", get(api_symbol_callees))
2162        .route("/symbol/:id/refs", get(api_symbol_refs))
2163        .route("/symbol/:id/impact", get(api_symbol_impact))
2164        // Cycles endpoint
2165        .route("/cycles", get(api_cycles))
2166        // Snapshot/timeline endpoints
2167        .route(
2168            "/snapshots",
2169            get(api_list_snapshots).post(api_create_snapshot),
2170        )
2171        .route("/snapshots/compare", get(api_compare_snapshots))
2172        .route("/snapshots/:id", get(api_get_snapshot))
2173        .with_state(state);
2174
2175    let projects_routes = Router::new()
2176        .route("/", get(api_projects))
2177        .route("/switch", post(api_switch_project))
2178        .with_state(projects_state);
2179
2180    let settings_routes = Router::new()
2181        .route("/", get(api_get_settings).put(api_put_settings))
2182        .with_state(settings_state);
2183
2184    let events_routes = Router::new()
2185        .route("/", get(api_events))
2186        .with_state(events_state);
2187
2188    // Build main router
2189    let app = Router::new()
2190        .route("/", get(index_html))
2191        .route("/style.css", get(style_css))
2192        .route("/app.js", get(app_js))
2193        // Module files
2194        .route("/api.js", get(api_js))
2195        .route("/utils.js", get(utils_js))
2196        // Views
2197        .route("/views/list.js", get(views_list_js))
2198        .route("/views/stats.js", get(views_stats_js))
2199        .route("/views/graph.js", get(views_graph_js))
2200        .route("/views/tree.js", get(views_tree_js))
2201        .route("/views/tables.js", get(views_tables_js))
2202        .route("/views/cycles.js", get(views_cycles_js))
2203        .route("/views/timeline.js", get(views_timeline_js))
2204        // Components
2205        .route("/components/detail.js", get(components_detail_js))
2206        .route("/components/dropdown.js", get(components_dropdown_js))
2207        .route("/components/sse.js", get(components_sse_js))
2208        .route("/components/cycles.js", get(components_cycles_js))
2209        .route("/components/export.js", get(components_export_js))
2210        .route("/components/search.js", get(components_search_js))
2211        .route("/components/settings.js", get(components_settings_js))
2212        .route("/components/skeleton.js", get(components_skeleton_js))
2213        .route("/components/empty.js", get(components_empty_js))
2214        .route("/components/error.js", get(components_error_js))
2215        // Lib
2216        .route("/lib/persistence.js", get(lib_persistence_js))
2217        // Nest API routes with their states
2218        .nest("/api", data_routes)
2219        .nest("/api/projects", projects_routes)
2220        .nest("/api/settings", settings_routes)
2221        .nest("/api/events", events_routes);
2222
2223    let addr = SocketAddr::from(([127, 0, 0, 1], port));
2224
2225    eprintln!();
2226    eprintln!(
2227        "\x1b[36m>\x1b[0m greppy web running at \x1b[36mhttp://{}\x1b[0m",
2228        addr
2229    );
2230    eprintln!("\x1b[90m  Press Ctrl+C to stop\x1b[0m");
2231
2232    if open_browser {
2233        let url = format!("http://{}", addr);
2234        let _ = open::that(&url);
2235    }
2236
2237    let listener = tokio::net::TcpListener::bind(addr).await?;
2238    axum::serve(listener, app).await?;
2239
2240    Ok(())
2241}