1use 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
28const 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
36const API_JS: &str = include_str!("static/api.js");
38const UTILS_JS: &str = include_str!("static/utils.js");
39
40const 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
49const 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
61const LIB_PERSISTENCE_JS: &str = include_str!("static/lib/persistence.js");
63
64#[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 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#[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 pub hierarchical: Option<bool>,
150 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#[derive(Serialize)]
184pub struct HierarchicalGraphResponse {
185 pub root: HierarchyNode,
187 pub current_path: String,
189 pub totals: HierarchyTotals,
191}
192
193#[derive(Serialize, Clone)]
194pub struct HierarchyNode {
195 pub name: String,
197 pub path: String,
199 #[serde(rename = "type")]
201 pub node_type: String,
202 pub value: usize,
204 pub dead: usize,
206 pub health: u8,
208 #[serde(skip_serializing_if = "std::ops::Not::not")]
210 pub cycle: bool,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub children: Option<Vec<HierarchyNode>>,
214 #[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#[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#[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#[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#[derive(Serialize)]
305pub struct CallersResponse {
306 pub symbol_id: u32,
307 pub callers: Vec<CallerInfo>,
308 pub total: usize,
309}
310
311#[derive(Serialize)]
313pub struct CalleeInfo {
314 pub id: u32,
315 pub name: String,
316 pub file: String,
317 pub line: u32,
318}
319
320#[derive(Serialize)]
322pub struct CalleesResponse {
323 pub symbol_id: u32,
324 pub callees: Vec<CalleeInfo>,
325 pub total: usize,
326}
327
328#[derive(Serialize)]
330pub struct RefInfo {
331 pub file: String,
332 pub line: u32,
333 pub kind: String,
334 pub context: String,
335}
336
337#[derive(Serialize)]
339pub struct RefsResponse {
340 pub symbol_id: u32,
341 pub refs: Vec<RefInfo>,
342 pub total: usize,
343}
344
345#[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#[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#[derive(Serialize)]
365pub struct CycleSymbol {
366 pub id: u32,
367 pub name: String,
368 pub file: String,
369}
370
371#[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#[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#[derive(Serialize)]
395pub struct SnapshotsListResponse {
396 pub snapshots: Vec<SnapshotSummaryResponse>,
397 pub total: usize,
398}
399
400#[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#[derive(Deserialize)]
414pub struct CreateSnapshotRequest {
415 pub name: Option<String>,
416}
417
418#[derive(Serialize)]
420pub struct SnapshotCompareResponse {
421 pub a: SnapshotSummaryResponse,
422 pub b: SnapshotSummaryResponse,
423 pub diff: SnapshotDiffResponse,
424}
425
426#[derive(Serialize)]
428pub struct SnapshotDiffResponse {
429 pub files: i32,
430 pub symbols: i32,
431 pub dead: i32,
432 pub cycles: i32,
433}
434
435fn 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
455fn 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
510fn 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 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
579fn build_file_tree(
581 index: &SemanticIndex,
582 dead_symbols: &HashSet<u32>,
583 cycle_files: &HashSet<u16>,
584) -> TreeNode {
585 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 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 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_tree_children(&mut root);
624
625 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 if current.children.is_none() {
660 current.children = Some(Vec::new());
661 }
662
663 let children = current.children.as_mut().unwrap();
664
665 let child_idx = children.iter().position(|c| c.name == part);
667
668 if let Some(idx) = child_idx {
669 if is_last {
670 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 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 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 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 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 for child in children.iter_mut() {
754 sort_tree_children(child);
755 }
756 }
757}
758
759fn 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
775fn redact_tree_paths(node: &mut TreeNode, state: &AppState) {
777 if let Some(ref path) = node.path {
779 node.path = Some(state.redact(path));
780 }
781
782 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
790async 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 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 if query.hierarchical.unwrap_or(false) {
1099 return api_graph_hierarchical(State(state), query)
1100 .await
1101 .into_response();
1102 }
1103
1104 let index = &state.index;
1106
1107 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 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 if symbol.is_entry_point() {
1124 *file_exports.entry(symbol.file_id).or_insert(0) += 1;
1125 }
1126 }
1127
1128 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 let cycle_files = find_cycle_files(index);
1144
1145 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 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 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 if nodes.len() > 100 {
1212 nodes.sort_by(|a, b| b.symbols.cmp(&a.symbols));
1214 nodes.truncate(100);
1215
1216 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
1224async 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 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; if state.dead_symbols.contains(&symbol.id) {
1240 entry.1 += 1; }
1242 }
1243
1244 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 let root = build_treemap_hierarchy(index, &file_stats, &base_path, &state);
1253
1254 let totals = calc_treemap_totals(&root);
1256
1257 Json(HierarchicalGraphResponse {
1258 root,
1259 current_path: state.redact(&base_path),
1260 totals,
1261 })
1262}
1263
1264fn build_treemap_hierarchy(
1266 index: &SemanticIndex,
1267 file_stats: &HashMap<u16, (usize, usize, bool)>,
1268 base_path: &str,
1269 state: &AppState,
1270) -> HierarchyNode {
1271 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 if !base_path.is_empty() && !path_str.starts_with(base_path) {
1280 continue;
1281 }
1282
1283 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 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 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 if files.len() == 1 && files[0].0 == child_path {
1320 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), dead,
1337 health,
1338 cycle,
1339 children: None,
1340 file_count: None,
1341 });
1342 } else {
1343 let sub_node = build_treemap_hierarchy(index, file_stats, &child_path, state);
1345 children.push(sub_node);
1346 }
1347 }
1348
1349 children.sort_by(|a, b| b.value.cmp(&a.value));
1351
1352 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
1396fn 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 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 let decoded_path = urlencoding::decode(&file_path)
1447 .map(|s| s.into_owned())
1448 .unwrap_or(file_path);
1449
1450 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 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 symbols.sort_by_key(|s| s.line);
1485
1486 Ok(Json(FileResponse {
1487 path: state.redact(&decoded_path),
1488 symbols,
1489 }))
1490}
1491
1492async 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 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
1540async 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 if index.symbol(symbol_id).is_none() {
1549 return Err(StatusCode::NOT_FOUND);
1550 }
1551
1552 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 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 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 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 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
1603async 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 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 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
1645async 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 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 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 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
1709async 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 if index.symbol(symbol_id).is_none() {
1718 return Err(StatusCode::NOT_FOUND);
1719 }
1720
1721 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 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 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 if symbol.is_entry_point() {
1742 affected_entry_points.insert(current_id);
1743
1744 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 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 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
1798async fn api_cycles(State(state): State<AppState>) -> Json<CyclesResponse> {
1800 let index = &state.index;
1801
1802 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 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 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 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 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 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
1897fn 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 if let Some(start_idx) = path.iter().position(|&n| n == neighbor) {
1917 let cycle: Vec<u16> = path[start_idx..].to_vec();
1918 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
1934async 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
1971async fn api_create_snapshot(
1973 State(state): State<AppState>,
1974 Json(req): Json<CreateSnapshotRequest>,
1975) -> impl IntoResponse {
1976 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
2007async 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#[derive(Deserialize)]
2034pub struct CompareQuery {
2035 pub a: String,
2036 pub b: String,
2037}
2038
2039async 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
2092pub 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 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 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 let projects_state = ProjectsState {
2139 active_path: Arc::new(RwLock::new(project.root.clone())),
2140 };
2141
2142 let events_state = EventsState::new(project.root.clone());
2144
2145 let events_state_clone = events_state.clone();
2147 tokio::spawn(async move {
2148 start_daemon_event_forwarder(events_state_clone).await;
2149 });
2150
2151 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 .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 .route("/cycles", get(api_cycles))
2166 .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 let app = Router::new()
2190 .route("/", get(index_html))
2191 .route("/style.css", get(style_css))
2192 .route("/app.js", get(app_js))
2193 .route("/api.js", get(api_js))
2195 .route("/utils.js", get(utils_js))
2196 .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 .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 .route("/lib/persistence.js", get(lib_persistence_js))
2217 .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}