1use crate::{
7 cli::{Cli, Command},
8 config::SymbolFilter,
9 fetch::{CachePolicy, FetchSource, HierarchyQuery, HierarchyResponse},
10 state::{
11 HierarchyDirection, HierarchyKind, NodeId, SourceLocation, SymbolIdentity, Viewport,
12 graph::RelationGraph,
13 },
14};
15
16use std::path::PathBuf;
17
18mod config;
19mod help;
20mod save;
21mod search;
22
23pub use help::HelpState;
24pub use save::{SaveState, SaveStatus};
25use search::refresh_search_items;
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub enum AnalysisBackend {
29 Lsp(String),
30 TreeSitter(String),
31 None,
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum AnalysisPhase {
36 Inactive,
37 Ready,
38 Working,
39 Warning,
40 Error,
41 Disconnected,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct AnalysisStatus {
51 pub backend: AnalysisBackend,
52 pub phase: AnalysisPhase,
53 pub message: Option<String>,
54 pub percentage: Option<u32>,
55}
56
57impl AnalysisStatus {
58 pub fn inactive(message: impl Into<String>) -> Self {
59 Self {
60 backend: AnalysisBackend::None,
61 phase: AnalysisPhase::Inactive,
62 message: Some(message.into()),
63 percentage: None,
64 }
65 }
66
67 pub fn lsp(server: impl Into<String>, phase: AnalysisPhase) -> Self {
68 Self {
69 backend: AnalysisBackend::Lsp(server.into()),
70 phase,
71 message: None,
72 percentage: None,
73 }
74 }
75
76 pub fn tree_sitter(language: impl Into<String>, phase: AnalysisPhase) -> Self {
77 Self {
78 backend: AnalysisBackend::TreeSitter(language.into()),
79 phase,
80 message: None,
81 percentage: None,
82 }
83 }
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum SearchKind {
88 Call,
89 Type,
90}
91
92impl SearchKind {
93 pub fn hierarchy_kind(self) -> HierarchyKind {
94 match self {
95 Self::Call => HierarchyKind::Call,
96 Self::Type => HierarchyKind::Type,
97 }
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct SearchItem {
103 pub name: String,
104 pub container_name: Option<String>,
105 pub location: String,
106 pub source: Option<SourceLocation>,
107}
108
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub enum SearchStatus {
111 Debouncing,
112 Loading,
113 Ready,
114 Error(String),
115}
116
117#[derive(Debug)]
118pub struct SearchState {
119 pub kind: SearchKind,
120 pub input: String,
121 pub items: Vec<SearchItem>,
122 pub selected: Option<usize>,
123 pub status: SearchStatus,
124 candidates: Vec<SearchItem>,
125 request_id: u64,
126 provider_available: bool,
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct SearchRequest {
131 pub request_id: u64,
132 pub kind: SearchKind,
133 pub query: String,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub struct HierarchyLoadRequest {
138 pub request_id: u64,
139 pub node_id: NodeId,
140 pub query: HierarchyQuery,
141 pub cache_policy: CachePolicy,
142 previous_load_state: crate::state::LoadState,
143}
144
145#[derive(Debug)]
146pub struct App {
147 pub should_quit: bool,
148 pub workspace: PathBuf,
149 pub graph: RelationGraph,
150 pub selected: Option<NodeId>,
151 pub pending_key: Option<char>,
152 pub search: Option<SearchState>,
153 pub save: Option<SaveState>,
154 pub help: Option<HelpState>,
155 pub analysis_status: AnalysisStatus,
156 pub message_history: Vec<String>,
157 pub viewport: Viewport,
158 pub canvas_notice: Option<String>,
159 canvas_notice_is_error: bool,
160 symbol_filter: SymbolFilter,
161 analysis_error: Option<String>,
162 next_search_request_id: u64,
163 next_hierarchy_request_id: u64,
164}
165
166impl App {
167 pub fn from_cli(cli: Cli) -> Self {
168 let mut graph = RelationGraph::default();
169 let workspace = cli.workspace.clone();
170 let selected = cli.command.map(|command| {
171 let (symbol, kind) = match command {
172 Command::Call { symbol } => (symbol, HierarchyKind::Call),
173 Command::Type { symbol } => (symbol, HierarchyKind::Type),
174 };
175 graph.pin_symbol(SymbolIdentity {
176 symbol,
177 kind,
178 location: None,
179 })
180 });
181
182 Self {
183 should_quit: false,
184 workspace,
185 graph,
186 selected,
187 pending_key: None,
188 search: None,
189 save: None,
190 help: None,
191 analysis_status: AnalysisStatus::inactive("No analysis backend"),
192 message_history: Vec::new(),
193 viewport: Viewport::default(),
194 canvas_notice: None,
195 canvas_notice_is_error: false,
196 symbol_filter: SymbolFilter::default(),
197 analysis_error: None,
198 next_search_request_id: 1,
199 next_hierarchy_request_id: 1,
200 }
201 }
202
203 pub fn quit(&mut self) {
204 self.should_quit = true;
205 }
206
207 pub fn set_analysis_error(&mut self, error: impl Into<String>) {
208 let error = error.into();
209 self.analysis_error = Some(error.clone());
210 self.set_canvas_error(error);
211 }
212
213 pub fn set_analysis_status(&mut self, status: AnalysisStatus) {
214 if matches!(
215 status.phase,
216 AnalysisPhase::Warning | AnalysisPhase::Error | AnalysisPhase::Disconnected
217 ) && let Some(message) = status.message.as_ref()
218 && !message.is_empty()
219 {
220 if matches!(
221 status.phase,
222 AnalysisPhase::Error | AnalysisPhase::Disconnected
223 ) {
224 self.set_canvas_error(message.clone());
225 } else {
226 self.set_canvas_notice(message.clone());
227 }
228 }
229 self.analysis_status = status;
230 }
231
232 pub fn set_canvas_notice(&mut self, message: impl Into<String>) {
233 let message = message.into();
234 self.record_message(message.clone());
235 self.canvas_notice = Some(message);
236 self.canvas_notice_is_error = false;
237 }
238
239 pub fn set_canvas_error(&mut self, message: impl Into<String>) {
240 let message = message.into();
241 self.record_message(message.clone());
242 self.canvas_notice = Some(message);
243 self.canvas_notice_is_error = true;
244 }
245
246 pub fn clear_canvas_notice(&mut self) {
247 self.canvas_notice = None;
248 self.canvas_notice_is_error = false;
249 }
250
251 pub fn canvas_notice_is_error(&self) -> bool {
252 self.canvas_notice_is_error
253 }
254
255 fn record_message(&mut self, message: String) {
256 if self.message_history.last() != Some(&message) {
257 self.message_history.push(message);
258 }
259 }
260
261 pub fn pan_viewport(&mut self, delta_x: i32, delta_y: i32) {
262 self.viewport.offset_x = self.viewport.offset_x.saturating_add(delta_x);
263 self.viewport.offset_y = self.viewport.offset_y.saturating_add(delta_y);
264 }
265
266 pub fn open_search(
267 &mut self,
268 kind: SearchKind,
269 provider_available: bool,
270 ) -> Option<SearchRequest> {
271 let status = if provider_available {
272 SearchStatus::Debouncing
273 } else {
274 let error = self
275 .analysis_error
276 .clone()
277 .unwrap_or_else(|| "No workspace-symbol provider is available".to_owned());
278 self.set_canvas_error(format!("Workspace symbol search unavailable: {error}"));
279 SearchStatus::Error(error)
280 };
281 self.pending_key = None;
282 self.search = Some(SearchState {
283 kind,
284 input: String::new(),
285 items: Vec::new(),
286 selected: None,
287 status,
288 candidates: Vec::new(),
289 request_id: 0,
290 provider_available,
291 });
292
293 self.request_current_search()
294 }
295
296 pub fn close_search(&mut self) {
297 self.search = None;
298 }
299
300 pub fn push_search_char(&mut self, character: char) -> Option<SearchRequest> {
301 let search = self.search.as_mut()?;
302 search.input.push(character);
303 refresh_search_items(search);
304 self.request_current_search()
305 }
306
307 pub fn pop_search_char(&mut self) -> Option<SearchRequest> {
308 let search = self.search.as_mut()?;
309 search.input.pop();
310 refresh_search_items(search);
311 self.request_current_search()
312 }
313
314 pub fn finish_search(&mut self, request_id: u64, result: Result<Vec<SearchItem>, String>) {
315 let Some(current_request_id) = self.search.as_ref().map(|search| search.request_id) else {
316 return;
317 };
318 if current_request_id != request_id {
321 return;
322 }
323
324 if let Err(error) = &result {
325 self.set_canvas_error(format!("Workspace symbol query failed: {error}"));
326 }
327
328 let Some(search) = self.search.as_mut() else {
329 return;
330 };
331
332 match result {
333 Ok(candidates) => {
334 let symbol_filter = &self.symbol_filter;
335 search.candidates = candidates
336 .into_iter()
337 .filter(|candidate| !symbol_filter.is_ignored(&candidate.name))
338 .collect();
339 search.status = SearchStatus::Ready;
340 refresh_search_items(search);
341 }
342 Err(error) => {
343 search.candidates.clear();
344 search.items.clear();
345 search.selected = None;
346 search.status = SearchStatus::Error(error);
347 }
348 }
349 }
350
351 pub fn start_search(&mut self, request_id: u64) {
352 let Some(search) = self.search.as_mut() else {
353 return;
354 };
355 if search.request_id == request_id {
356 search.status = SearchStatus::Loading;
357 }
358 }
359
360 pub fn select_search_item(&mut self, index: usize) {
361 let Some(search) = self.search.as_mut() else {
362 return;
363 };
364 if index < search.items.len() {
365 search.selected = Some(index);
366 }
367 }
368
369 pub fn move_search_selection(&mut self, offset: isize) {
370 let Some(search) = self.search.as_mut() else {
371 return;
372 };
373 if search.items.is_empty() {
374 search.selected = None;
375 return;
376 }
377
378 let current = search.selected.unwrap_or(0);
379 let last = search.items.len() - 1;
380 search.selected = Some(current.saturating_add_signed(offset).min(last));
381 }
382
383 pub fn accept_search_selection(&mut self) {
384 let Some(search) = self.search.as_ref() else {
385 return;
386 };
387 let Some(item) = search.selected.and_then(|index| search.items.get(index)) else {
388 return;
389 };
390 let node_id = self.graph.pin_symbol(SymbolIdentity {
391 symbol: item.name.clone(),
392 kind: search.kind.hierarchy_kind(),
393 location: item.source.clone(),
394 });
395 self.selected = Some(node_id);
396 self.viewport = Viewport::default();
397 self.close_search();
398 }
399
400 pub fn focus_symbol(&mut self, identity: SymbolIdentity) -> Result<NodeId, String> {
401 if identity.symbol.trim().is_empty() {
402 return Err("symbol must not be empty".to_owned());
403 }
404 if let Some(location) = &identity.location
405 && (location
406 .uri
407 .strip_prefix("file://")
408 .is_none_or(str::is_empty)
409 || location.uri.chars().any(char::is_control)
410 || location.line.is_none()
411 || location.character.is_none())
412 {
413 return Err(
414 "location must contain a file URI and exact zero-based line/character".to_owned(),
415 );
416 }
417
418 let node_id = if identity.location.is_some() {
419 self.graph.pin_symbol(identity)
420 } else {
421 match self
422 .graph
423 .nodes_named(&identity.symbol, identity.kind)
424 .as_slice()
425 {
426 [] => self.graph.pin_symbol(identity),
427 [node_id] => {
428 self.graph.pin(*node_id);
429 *node_id
430 }
431 _ => {
432 return Err(format!(
433 "symbol {:?} is ambiguous; include an exact source location",
434 identity.symbol
435 ));
436 }
437 }
438 };
439 self.selected = Some(node_id);
440 self.viewport = Viewport::default();
441 self.clear_canvas_notice();
442 Ok(node_id)
443 }
444
445 pub fn delete_selected_anchor(&mut self) -> bool {
446 let Some(selected) = self.selected else {
447 return false;
448 };
449 if !self.graph.unpin(selected) {
450 self.set_canvas_notice("Selected node is not an anchor");
451 return false;
452 }
453 self.clear_canvas_notice();
454 self.selected = self.graph.anchors().last().copied();
455 true
456 }
457
458 pub fn delete_selected_branch(&mut self, direction: HierarchyDirection) -> bool {
459 let Some(selected) = self.selected else {
460 return false;
461 };
462 let cleared = self.graph.clear_branch(selected, direction);
463 if cleared {
464 self.clear_canvas_notice();
465 }
466 cleared
467 }
468
469 pub fn select_node(&mut self, node_id: NodeId) -> bool {
470 if let Some(node_id) = self.graph.resolve_id(node_id) {
471 self.selected = Some(node_id);
472 return true;
473 }
474 false
475 }
476
477 pub fn toggle_selected_branch(
478 &mut self,
479 direction: HierarchyDirection,
480 hierarchy_available: bool,
481 ) -> Option<HierarchyLoadRequest> {
482 let selected = self.selected?;
483 self.toggle_node_branch(selected, direction, hierarchy_available)
484 }
485
486 pub fn toggle_node_branch(
487 &mut self,
488 node_id: NodeId,
489 direction: HierarchyDirection,
490 hierarchy_available: bool,
491 ) -> Option<HierarchyLoadRequest> {
492 let node_id = self.graph.resolve_id(node_id)?;
493 let node = self.graph.node_mut(node_id)?;
494 self.selected = Some(node_id);
495 let identity = node.identity();
496 let branch = node.branch_mut(direction);
497
498 if branch.can_toggle() {
499 branch.toggle();
500 return None;
501 }
502 if branch.load_state == crate::state::LoadState::Loading {
503 branch.expanded = !branch.expanded;
504 return None;
505 }
506 if branch.load_state == crate::state::LoadState::Loaded {
507 return None;
508 }
509 if !hierarchy_available {
510 let message = format!(
511 "Hierarchy query unavailable for {}: no analysis provider",
512 identity.symbol
513 );
514 branch.load_state = crate::state::LoadState::Failed;
515 branch.expanded = false;
516 branch.failure = Some("Hierarchy requires an available LSP server".to_owned());
517 branch.active_request_id = None;
518 self.set_canvas_error(message);
519 return None;
520 }
521
522 self.begin_hierarchy_load(node_id, identity, direction, CachePolicy::UseCache, true)
523 }
524
525 pub fn refresh_selected_branches(
526 &mut self,
527 hierarchy_available: bool,
528 ) -> Vec<HierarchyLoadRequest> {
529 let Some(selected) = self
530 .selected
531 .and_then(|node_id| self.graph.resolve_id(node_id))
532 else {
533 return Vec::new();
534 };
535 self.selected = Some(selected);
536 if !hierarchy_available {
537 self.set_canvas_notice("Refresh requires an available LSP server");
538 return Vec::new();
539 }
540
541 self.clear_canvas_notice();
542 let identity = self
543 .graph
544 .node(selected)
545 .expect("resolved graph nodes exist")
546 .identity();
547 [HierarchyDirection::Incoming, HierarchyDirection::Outgoing]
548 .into_iter()
549 .filter_map(|direction| {
550 self.begin_hierarchy_load(
551 selected,
552 identity.clone(),
553 direction,
554 CachePolicy::Refresh,
555 false,
556 )
557 })
558 .collect()
559 }
560
561 pub fn finish_hierarchy(
562 &mut self,
563 request: &HierarchyLoadRequest,
564 result: Result<HierarchyResponse, String>,
565 ) -> bool {
566 let Some(node_id) = self.graph.resolve_id(request.node_id) else {
567 return false;
568 };
569 let branch = self
570 .graph
571 .node_mut(node_id)
572 .expect("resolved graph nodes exist")
573 .branch_mut(request.query.direction);
574 if branch.active_request_id != Some(request.request_id) {
575 return false;
576 }
577 match result {
578 Ok(response) => {
579 let source = response.source;
580 let was_selected = self.selected == Some(node_id);
581 let Some(node_id) = self
582 .graph
583 .resolve_symbol(node_id, response.query.symbol.clone())
584 else {
585 return false;
586 };
587 if was_selected {
588 self.selected = Some(node_id);
589 }
590 if self
591 .graph
592 .node(node_id)
593 .expect("resolved graph nodes exist")
594 .branch(request.query.direction)
595 .active_request_id
596 != Some(request.request_id)
597 {
598 return false;
599 }
600 let children = response
601 .children
602 .into_iter()
603 .filter(|child| !self.symbol_filter.is_ignored(&child.symbol))
604 .collect();
605 self.graph
606 .replace_branch_neighbors(node_id, request.query.direction, children);
607 let branch = self
608 .graph
609 .node_mut(node_id)
610 .expect("resolved graph nodes exist")
611 .branch_mut(request.query.direction);
612 branch.active_request_id = None;
613 branch.load_state = crate::state::LoadState::Loaded;
614 branch.failure = None;
615 if branch.neighbors.is_empty() {
616 branch.expanded = false;
617 }
618 if source == FetchSource::TreeSitter {
619 self.set_canvas_notice(
620 "Tree-sitter: syntactic relations only; dynamic dispatch may be omitted"
621 .to_owned(),
622 );
623 }
624 }
625 Err(error) => {
626 let direction = match request.query.direction {
627 HierarchyDirection::Incoming => "incoming/parent",
628 HierarchyDirection::Outgoing => "outgoing/child",
629 };
630 self.set_canvas_error(format!(
631 "Hierarchy query failed for {} ({direction}): {error}",
632 request.query.symbol.symbol
633 ));
634 let branch = self
635 .graph
636 .node_mut(node_id)
637 .expect("resolved graph nodes exist")
638 .branch_mut(request.query.direction);
639 branch.active_request_id = None;
640 if request.cache_policy == CachePolicy::Refresh {
641 branch.load_state = request.previous_load_state;
642 } else {
643 branch.load_state = crate::state::LoadState::Failed;
644 branch.expanded = false;
645 }
646 branch.failure = Some(error);
647 }
648 }
649 true
650 }
651
652 fn begin_hierarchy_load(
653 &mut self,
654 node_id: NodeId,
655 identity: SymbolIdentity,
656 direction: HierarchyDirection,
657 cache_policy: CachePolicy,
658 expand: bool,
659 ) -> Option<HierarchyLoadRequest> {
660 let request_id = self.next_hierarchy_request_id;
661 self.next_hierarchy_request_id = self.next_hierarchy_request_id.wrapping_add(1);
662 let branch = self.graph.node_mut(node_id)?.branch_mut(direction);
663 let previous_load_state = match branch.load_state {
664 crate::state::LoadState::Loading if branch.neighbors.is_empty() => {
665 crate::state::LoadState::NotLoaded
666 }
667 crate::state::LoadState::Loading => crate::state::LoadState::Loaded,
668 state => state,
669 };
670 branch.load_state = crate::state::LoadState::Loading;
671 if expand {
672 branch.expanded = true;
673 }
674 branch.failure = None;
675 branch.active_request_id = Some(request_id);
676 Some(HierarchyLoadRequest {
677 request_id,
678 node_id,
679 query: HierarchyQuery {
680 symbol: identity,
681 direction,
682 },
683 cache_policy,
684 previous_load_state,
685 })
686 }
687
688 fn request_current_search(&mut self) -> Option<SearchRequest> {
689 let search = self.search.as_ref()?;
690 if !search.provider_available {
691 return None;
692 }
693
694 let request_id = self.next_search_request_id;
695 self.next_search_request_id = self.next_search_request_id.wrapping_add(1);
696 let search = self.search.as_mut().expect("search was checked above");
697 search.request_id = request_id;
698 search.status = SearchStatus::Debouncing;
699 Some(SearchRequest {
700 request_id,
701 kind: search.kind,
702 query: search.input.clone(),
703 })
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use clap::Parser;
710
711 use super::{App, SearchItem, SearchKind, SearchStatus};
712 use crate::{
713 cli::Cli,
714 config::SymbolFilter,
715 fetch::{CachePolicy, FetchSource, HierarchyResponse},
716 state::{
717 HierarchyDirection, HierarchyKind, LoadState, NodeId, SourceLocation, SymbolIdentity,
718 },
719 };
720
721 #[test]
722 fn queries_on_open_and_after_each_text_change() {
723 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
724 let open_request = app.open_search(SearchKind::Call, true).unwrap();
725
726 assert_eq!(open_request.query, "");
727 assert_eq!(
728 app.search.as_ref().unwrap().status,
729 SearchStatus::Debouncing
730 );
731 let first_request = app.push_search_char('F').unwrap();
732 let current_request = app.push_search_char('B').unwrap();
733 assert_eq!(first_request.query, "F");
734 assert_eq!(current_request.query, "FB");
735 app.start_search(open_request.request_id);
736 assert_eq!(
737 app.search.as_ref().unwrap().status,
738 SearchStatus::Debouncing
739 );
740 app.start_search(current_request.request_id);
741 assert_eq!(app.search.as_ref().unwrap().status, SearchStatus::Loading);
742
743 app.finish_search(open_request.request_id, Ok(vec![item("stale")]));
744 app.finish_search(
745 current_request.request_id,
746 Ok(vec![item("Bar"), item("FooBar"), item("FastBuffer")]),
747 );
748
749 let search = app.search.as_ref().unwrap();
750 assert_eq!(
751 search
752 .items
753 .iter()
754 .map(|item| item.name.as_str())
755 .collect::<Vec<_>>(),
756 ["FooBar", "FastBuffer"]
757 );
758 assert_eq!(search.status, SearchStatus::Ready);
759 }
760
761 #[test]
762 fn ignores_results_from_a_closed_search_session() {
763 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
764 let old_request = app.open_search(SearchKind::Call, true).unwrap();
765 app.close_search();
766 let current_request = app.open_search(SearchKind::Call, true).unwrap();
767
768 app.finish_search(old_request.request_id, Ok(vec![item("old")]));
769 assert!(app.search.as_ref().unwrap().items.is_empty());
770
771 app.finish_search(current_request.request_id, Ok(vec![item("current")]));
772 assert_eq!(app.search.as_ref().unwrap().items[0].name, "current");
773 }
774
775 #[test]
776 fn ranks_exact_prefix_and_subsequence_matches() {
777 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
778 app.open_search(SearchKind::Call, true).unwrap();
779 let mut request = None;
780 for character in "main".chars() {
781 request = app.push_search_char(character);
782 }
783 app.finish_search(
784 request.unwrap().request_id,
785 Ok(vec![item("my_main"), item("main_loop"), item("main")]),
786 );
787
788 let names = app
789 .search
790 .as_ref()
791 .unwrap()
792 .items
793 .iter()
794 .map(|item| item.name.as_str())
795 .collect::<Vec<_>>();
796 assert_eq!(names, ["main", "main_loop", "my_main"]);
797 }
798
799 #[test]
800 fn matches_remaining_query_parts_against_container_and_path() {
801 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
802 app.open_search(SearchKind::Call, true).unwrap();
803 let mut request = None;
804 for character in "run service".chars() {
805 request = app.push_search_char(character);
806 }
807 let mut service_run = item("run");
808 service_run.container_name = Some("Service".to_owned());
809 let mut controller_run = item("run");
810 controller_run.container_name = Some("Controller".to_owned());
811 app.finish_search(
812 request.unwrap().request_id,
813 Ok(vec![controller_run, service_run]),
814 );
815
816 let names = app
817 .search
818 .as_ref()
819 .unwrap()
820 .items
821 .iter()
822 .map(|item| item.container_name.as_deref().unwrap())
823 .collect::<Vec<_>>();
824 assert_eq!(names, ["Service"]);
825 }
826
827 #[test]
828 fn applies_project_symbol_filter_to_search_and_hierarchy_results() {
829 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
830 app.set_symbol_filter(
831 SymbolFilter::from_patterns(["*::into", "Option::is_some", "*::Some"]).unwrap(),
832 );
833 let search = app.open_search(SearchKind::Call, true).unwrap();
834 app.finish_search(
835 search.request_id,
836 Ok(vec![item("Vec::into"), item("main"), item("Option::Some")]),
837 );
838
839 assert_eq!(
840 app.search
841 .as_ref()
842 .unwrap()
843 .items
844 .iter()
845 .map(|item| item.name.as_str())
846 .collect::<Vec<_>>(),
847 ["main"]
848 );
849 app.accept_search_selection();
850 let hierarchy = app
851 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
852 .unwrap();
853 assert!(app.finish_hierarchy(
854 &hierarchy,
855 Ok(HierarchyResponse {
856 query: hierarchy.query.clone(),
857 children: vec![
858 identity("Option::is_some", HierarchyKind::Call),
859 identity("work", HierarchyKind::Call),
860 identity("Option::some", HierarchyKind::Call),
861 ],
862 source: FetchSource::Lsp,
863 })
864 ));
865
866 let root = app.selected.unwrap();
867 assert_eq!(
868 branch_names(&app, root, HierarchyDirection::Outgoing),
869 ["work", "Option::some"]
870 );
871 }
872
873 #[test]
874 fn accepts_a_result_as_a_deduplicated_anchor() {
875 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
876 app.open_search(SearchKind::Type, true).unwrap();
877 app.push_search_char('S').unwrap();
878 let request = app.push_search_char('t').unwrap();
879 app.finish_search(request.request_id, Ok(vec![item("Student")]));
880 app.accept_search_selection();
881
882 assert!(app.search.is_none());
883 assert_eq!(app.graph.anchors().len(), 1);
884 let existing_root = app.graph.anchors()[0];
885 assert_eq!(app.graph.node(existing_root).unwrap().symbol, "Student");
886 assert_eq!(
887 app.graph
888 .node(existing_root)
889 .unwrap()
890 .location
891 .as_ref()
892 .unwrap()
893 .uri,
894 "file:///workspace/main.rs"
895 );
896
897 app.pan_viewport(12, -4);
898 app.open_search(SearchKind::Type, true).unwrap();
899 app.push_search_char('S').unwrap();
900 let request = app.push_search_char('t').unwrap();
901 app.finish_search(request.request_id, Ok(vec![item("Student")]));
902 app.accept_search_selection();
903
904 assert_eq!(app.graph.anchors().len(), 1);
905 assert_eq!(app.selected, Some(existing_root));
906 assert_eq!(app.viewport.offset_x, 0);
907 assert_eq!(app.viewport.offset_y, 0);
908 }
909
910 #[test]
911 fn external_focus_reuses_semantic_nodes_and_rejects_ambiguous_names() {
912 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
913 let first = identity("run", HierarchyKind::Call);
914 let first_id = app.graph.insert_symbol(first.clone());
915 app.viewport.offset_x = 17;
916
917 assert_eq!(app.focus_symbol(first.clone()).unwrap(), first_id);
918 assert_eq!(app.graph.node_count(), 1);
919 assert_eq!(app.graph.anchors(), [first_id]);
920 assert_eq!(app.selected, Some(first_id));
921 assert_eq!(app.viewport, crate::state::Viewport::default());
922
923 let second = SymbolIdentity {
924 symbol: "run".to_owned(),
925 kind: HierarchyKind::Call,
926 location: Some(SourceLocation {
927 uri: "file:///workspace/src/other.rs".to_owned(),
928 line: Some(3),
929 character: Some(1),
930 }),
931 };
932 app.graph.insert_symbol(second);
933 let error = app
934 .focus_symbol(SymbolIdentity {
935 symbol: "run".to_owned(),
936 kind: HierarchyKind::Call,
937 location: None,
938 })
939 .unwrap_err();
940 assert!(error.contains("ambiguous"));
941 assert_eq!(app.graph.node_count(), 2);
942
943 let created = app
944 .focus_symbol(SymbolIdentity {
945 symbol: "new_type".to_owned(),
946 kind: HierarchyKind::Type,
947 location: None,
948 })
949 .unwrap();
950 assert_eq!(app.graph.node(created).unwrap().symbol, "new_type");
951 assert!(app.graph.is_anchor(created));
952
953 for location in [
954 SourceLocation {
955 uri: "file://".to_owned(),
956 line: Some(0),
957 character: Some(0),
958 },
959 SourceLocation {
960 uri: "file:///workspace/src/main.py".to_owned(),
961 line: Some(0),
962 character: None,
963 },
964 ] {
965 let error = app
966 .focus_symbol(SymbolIdentity {
967 symbol: "invalid".to_owned(),
968 kind: HierarchyKind::Call,
969 location: Some(location),
970 })
971 .unwrap_err();
972 assert!(error.contains("exact zero-based line/character"));
973 }
974 }
975
976 #[test]
977 fn only_deletes_selected_anchors_and_selects_a_remaining_anchor() {
978 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
979 let first_root = app.graph.pin_symbol(identity("first", HierarchyKind::Call));
980 let child_id = app
981 .graph
982 .replace_branch_neighbors(
983 first_root,
984 HierarchyDirection::Outgoing,
985 vec![identity("child", HierarchyKind::Call)],
986 )
987 .unwrap()[0];
988 let second_root = app
989 .graph
990 .pin_symbol(identity("second", HierarchyKind::Type));
991 app.selected = Some(child_id);
992
993 assert!(!app.delete_selected_anchor());
994 assert_eq!(
995 app.canvas_notice.as_deref(),
996 Some("Selected node is not an anchor")
997 );
998 assert_eq!(app.graph.anchors(), [first_root, second_root]);
999
1000 app.selected = Some(first_root);
1001 assert!(app.delete_selected_anchor());
1002 assert_eq!(app.graph.anchors(), [second_root]);
1003 assert_eq!(app.selected, Some(second_root));
1004
1005 assert!(app.delete_selected_anchor());
1006 assert!(app.graph.anchors().is_empty());
1007 assert_eq!(app.selected, None);
1008 }
1009
1010 #[test]
1011 fn deletes_only_the_selected_nodes_requested_branch() {
1012 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1013 let selected = app.graph.pin_symbol(identity("root", HierarchyKind::Call));
1014 app.graph
1015 .replace_branch_neighbors(
1016 selected,
1017 HierarchyDirection::Incoming,
1018 vec![identity("caller", HierarchyKind::Call)],
1019 )
1020 .unwrap();
1021 app.graph
1022 .replace_branch_neighbors(
1023 selected,
1024 HierarchyDirection::Outgoing,
1025 vec![identity("callee", HierarchyKind::Call)],
1026 )
1027 .unwrap();
1028 app.selected = Some(selected);
1029
1030 assert!(app.delete_selected_branch(HierarchyDirection::Incoming));
1031 assert!(
1032 app.graph
1033 .node(selected)
1034 .unwrap()
1035 .incoming
1036 .neighbors
1037 .is_empty()
1038 );
1039 assert_eq!(
1040 app.graph.node(selected).unwrap().outgoing.neighbors.len(),
1041 1
1042 );
1043 }
1044
1045 #[test]
1046 fn selects_nested_nodes_and_toggles_one_requested_branch() {
1047 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1048 let root = app.graph.pin_symbol(identity("root", HierarchyKind::Call));
1049 let child_id = app
1050 .graph
1051 .replace_branch_neighbors(
1052 root,
1053 HierarchyDirection::Incoming,
1054 vec![identity("child", HierarchyKind::Call)],
1055 )
1056 .unwrap()[0];
1057 app.graph
1058 .replace_branch_neighbors(
1059 child_id,
1060 HierarchyDirection::Outgoing,
1061 vec![identity("grandchild", HierarchyKind::Call)],
1062 )
1063 .unwrap();
1064 app.graph.node_mut(root).unwrap().incoming.expanded = true;
1065
1066 assert!(app.select_node(child_id));
1067 assert_eq!(app.selected, Some(child_id));
1068 assert_eq!(
1069 app.toggle_selected_branch(HierarchyDirection::Outgoing, false),
1070 None
1071 );
1072 assert!(app.graph.node(child_id).unwrap().outgoing.expanded);
1073 assert!(!app.select_node(NodeId(u64::MAX)));
1074 assert_eq!(app.selected, Some(child_id));
1075 }
1076
1077 #[test]
1078 fn lazily_loads_a_branch_once_and_reuses_its_children() {
1079 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1080 let request = app
1081 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1082 .unwrap();
1083 let root = app.selected.unwrap();
1084
1085 assert_eq!(
1086 app.graph.node(root).unwrap().outgoing.load_state,
1087 LoadState::Loading
1088 );
1089 assert!(app.graph.node(root).unwrap().outgoing.expanded);
1090 assert_eq!(
1091 app.toggle_selected_branch(HierarchyDirection::Outgoing, true),
1092 None
1093 );
1094 assert!(!app.graph.node(root).unwrap().outgoing.expanded);
1095
1096 assert!(app.finish_hierarchy(
1097 &request,
1098 Ok(HierarchyResponse {
1099 query: request.query.clone(),
1100 children: vec![identity("child", HierarchyKind::Call)],
1101 source: FetchSource::Lsp,
1102 })
1103 ));
1104 assert_eq!(
1105 app.graph.node(root).unwrap().outgoing.load_state,
1106 LoadState::Loaded
1107 );
1108 assert_eq!(app.graph.node(root).unwrap().outgoing.neighbors.len(), 1);
1109 assert!(!app.graph.node(root).unwrap().outgoing.expanded);
1110
1111 assert_eq!(
1112 app.toggle_selected_branch(HierarchyDirection::Outgoing, true),
1113 None
1114 );
1115 assert!(app.graph.node(root).unwrap().outgoing.expanded);
1116 }
1117
1118 #[test]
1119 fn retries_failed_hierarchy_and_ignores_stale_results() {
1120 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "type", "Root"]).unwrap());
1121 let failed = app
1122 .toggle_selected_branch(HierarchyDirection::Incoming, true)
1123 .unwrap();
1124 let root = app.selected.unwrap();
1125 assert!(app.finish_hierarchy(&failed, Err("not supported".to_owned())));
1126 assert_eq!(
1127 app.graph.node(root).unwrap().incoming.load_state,
1128 LoadState::Failed
1129 );
1130 assert_eq!(
1131 app.graph.node(root).unwrap().incoming.failure(),
1132 Some("not supported")
1133 );
1134 assert_eq!(
1135 app.message_history.last().map(String::as_str),
1136 Some("Hierarchy query failed for Root (incoming/parent): not supported")
1137 );
1138
1139 let retry = app
1140 .toggle_selected_branch(HierarchyDirection::Incoming, true)
1141 .unwrap();
1142 assert_ne!(retry.request_id, failed.request_id);
1143 assert!(!app.finish_hierarchy(
1144 &failed,
1145 Ok(HierarchyResponse {
1146 query: failed.query.clone(),
1147 children: vec![identity("stale", HierarchyKind::Type)],
1148 source: FetchSource::Lsp,
1149 })
1150 ));
1151 assert!(app.graph.node(root).unwrap().incoming.neighbors.is_empty());
1152 assert!(app.finish_hierarchy(
1153 &retry,
1154 Ok(HierarchyResponse {
1155 query: retry.query.clone(),
1156 children: vec![identity("Parent", HierarchyKind::Type)],
1157 source: FetchSource::Lsp,
1158 })
1159 ));
1160 assert_eq!(
1161 branch_names(&app, root, HierarchyDirection::Incoming),
1162 ["Parent"]
1163 );
1164 }
1165
1166 #[test]
1167 fn keeps_successful_empty_hierarchy_distinct_from_failure() {
1168 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "leaf"]).unwrap());
1169 let request = app
1170 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1171 .unwrap();
1172 let root = app.selected.unwrap();
1173
1174 assert!(app.finish_hierarchy(
1175 &request,
1176 Ok(HierarchyResponse {
1177 query: request.query.clone(),
1178 children: Vec::new(),
1179 source: FetchSource::Lsp,
1180 })
1181 ));
1182 assert_eq!(
1183 app.graph.node(root).unwrap().outgoing.load_state,
1184 LoadState::Loaded
1185 );
1186 assert!(app.graph.node(root).unwrap().outgoing.neighbors.is_empty());
1187 assert_eq!(app.graph.node(root).unwrap().outgoing.failure(), None);
1188 assert_eq!(
1189 app.toggle_selected_branch(HierarchyDirection::Outgoing, true),
1190 None
1191 );
1192 assert_eq!(
1193 app.graph.node(root).unwrap().outgoing.load_state,
1194 LoadState::Loaded
1195 );
1196 }
1197
1198 #[test]
1199 fn tree_sitter_hierarchy_reports_its_syntactic_confidence() {
1200 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1201 let request = app
1202 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1203 .unwrap();
1204
1205 assert!(app.finish_hierarchy(
1206 &request,
1207 Ok(HierarchyResponse {
1208 query: request.query.clone(),
1209 children: vec![identity("child", HierarchyKind::Call)],
1210 source: FetchSource::TreeSitter,
1211 })
1212 ));
1213
1214 assert_eq!(
1215 app.canvas_notice.as_deref(),
1216 Some("Tree-sitter: syntactic relations only; dynamic dispatch may be omitted")
1217 );
1218 }
1219
1220 #[test]
1221 fn deduplicates_children_globally_but_preserves_both_direction_relations() {
1222 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1223 let incoming = app
1224 .toggle_selected_branch(HierarchyDirection::Incoming, true)
1225 .unwrap();
1226 let shared = identity("shared", HierarchyKind::Call);
1227 assert!(app.finish_hierarchy(
1228 &incoming,
1229 Ok(HierarchyResponse {
1230 query: incoming.query.clone(),
1231 children: vec![
1232 shared.clone(),
1233 shared.clone(),
1234 identity("left-only", HierarchyKind::Call),
1235 ],
1236 source: FetchSource::Lsp,
1237 })
1238 ));
1239
1240 let outgoing = app
1241 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1242 .unwrap();
1243 assert!(app.finish_hierarchy(
1244 &outgoing,
1245 Ok(HierarchyResponse {
1246 query: outgoing.query.clone(),
1247 children: vec![
1248 shared.clone(),
1249 shared,
1250 identity("right-only", HierarchyKind::Call),
1251 ],
1252 source: FetchSource::Lsp,
1253 })
1254 ));
1255
1256 let root = app.selected.unwrap();
1257 let incoming_names = branch_names(&app, root, HierarchyDirection::Incoming);
1258 let outgoing_names = branch_names(&app, root, HierarchyDirection::Outgoing);
1259 assert_eq!(incoming_names, ["shared", "left-only"]);
1260 assert_eq!(outgoing_names, ["shared", "right-only"]);
1261 assert_eq!(app.graph.node_count(), 4);
1262 }
1263
1264 #[test]
1265 fn refreshes_both_branches_and_preserves_existing_descendant_state() {
1266 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1267 let incoming = app
1268 .toggle_selected_branch(HierarchyDirection::Incoming, true)
1269 .unwrap();
1270 let caller_identity = identity("caller", HierarchyKind::Call);
1271 assert!(app.finish_hierarchy(
1272 &incoming,
1273 Ok(HierarchyResponse {
1274 query: incoming.query.clone(),
1275 children: vec![caller_identity.clone()],
1276 source: FetchSource::Lsp,
1277 })
1278 ));
1279 let outgoing = app
1280 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1281 .unwrap();
1282 let removed_identity = identity("removed", HierarchyKind::Call);
1283 assert!(app.finish_hierarchy(
1284 &outgoing,
1285 Ok(HierarchyResponse {
1286 query: outgoing.query.clone(),
1287 children: vec![removed_identity],
1288 source: FetchSource::Lsp,
1289 })
1290 ));
1291
1292 let root = app.selected.unwrap();
1293 let caller = app.graph.node(root).unwrap().incoming.neighbors[0];
1294 app.graph
1295 .replace_branch_neighbors(
1296 caller,
1297 HierarchyDirection::Outgoing,
1298 vec![identity("grandchild", HierarchyKind::Call)],
1299 )
1300 .unwrap();
1301 let caller_branch = &mut app.graph.node_mut(caller).unwrap().outgoing;
1302 caller_branch.load_state = LoadState::Loaded;
1303 caller_branch.expanded = true;
1304
1305 let requests = app.refresh_selected_branches(true);
1306 assert_eq!(requests.len(), 2);
1307 assert!(
1308 requests
1309 .iter()
1310 .all(|request| request.cache_policy == CachePolicy::Refresh)
1311 );
1312 assert_eq!(
1313 requests
1314 .iter()
1315 .map(|request| request.query.direction)
1316 .collect::<Vec<_>>(),
1317 [HierarchyDirection::Incoming, HierarchyDirection::Outgoing]
1318 );
1319 assert!(app.graph.node(root).unwrap().incoming.expanded);
1320 assert!(app.graph.node(root).unwrap().outgoing.expanded);
1321
1322 for request in &requests {
1323 let children = match request.query.direction {
1324 HierarchyDirection::Incoming => vec![
1325 caller_identity.clone(),
1326 identity("new-caller", HierarchyKind::Call),
1327 ],
1328 HierarchyDirection::Outgoing => {
1329 vec![identity("new-callee", HierarchyKind::Call)]
1330 }
1331 };
1332 assert!(app.finish_hierarchy(
1333 request,
1334 Ok(HierarchyResponse {
1335 query: request.query.clone(),
1336 children,
1337 source: FetchSource::Lsp,
1338 })
1339 ));
1340 }
1341
1342 assert_eq!(app.graph.node(root).unwrap().incoming.neighbors[0], caller);
1343 assert!(app.graph.node(caller).unwrap().outgoing.expanded);
1344 assert_eq!(
1345 branch_names(&app, caller, HierarchyDirection::Outgoing),
1346 ["grandchild"]
1347 );
1348 assert_eq!(
1349 branch_names(&app, root, HierarchyDirection::Incoming),
1350 ["caller", "new-caller"]
1351 );
1352 assert_eq!(
1353 branch_names(&app, root, HierarchyDirection::Outgoing),
1354 ["new-callee"]
1355 );
1356 let new_callee = app.graph.node(root).unwrap().outgoing.neighbors[0];
1357 assert_eq!(
1358 app.graph.node(new_callee).unwrap().outgoing.load_state,
1359 LoadState::NotLoaded
1360 );
1361 assert!(
1362 app.graph.visible_graph().nodes.iter().all(|node_id| app
1363 .graph
1364 .node(*node_id)
1365 .unwrap()
1366 .symbol
1367 != "removed")
1368 );
1369 }
1370
1371 #[test]
1372 fn failed_refresh_keeps_cached_neighbors_and_rejects_older_results() {
1373 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1374 let initial = app
1375 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1376 .unwrap();
1377 assert!(app.finish_hierarchy(
1378 &initial,
1379 Ok(HierarchyResponse {
1380 query: initial.query.clone(),
1381 children: vec![identity("cached", HierarchyKind::Call)],
1382 source: FetchSource::Lsp,
1383 })
1384 ));
1385 let root = app.selected.unwrap();
1386
1387 let older = app.refresh_selected_branches(true);
1388 let current = app.refresh_selected_branches(true);
1389 let older_outgoing = older
1390 .iter()
1391 .find(|request| request.query.direction == HierarchyDirection::Outgoing)
1392 .unwrap();
1393 assert!(!app.finish_hierarchy(
1394 older_outgoing,
1395 Ok(HierarchyResponse {
1396 query: older_outgoing.query.clone(),
1397 children: vec![identity("stale", HierarchyKind::Call)],
1398 source: FetchSource::Lsp,
1399 })
1400 ));
1401
1402 for request in ¤t {
1403 assert!(app.finish_hierarchy(request, Err("refresh failed".to_owned())));
1404 }
1405 assert_eq!(
1406 branch_names(&app, root, HierarchyDirection::Outgoing),
1407 ["cached"]
1408 );
1409 let outgoing = &app.graph.node(root).unwrap().outgoing;
1410 assert_eq!(outgoing.load_state, LoadState::Loaded);
1411 assert!(outgoing.expanded);
1412 assert_eq!(outgoing.failure(), Some("refresh failed"));
1413 }
1414
1415 fn item(name: &str) -> SearchItem {
1416 SearchItem {
1417 name: name.to_owned(),
1418 container_name: None,
1419 location: "file:///workspace/main.rs:1".to_owned(),
1420 source: Some(crate::state::SourceLocation {
1421 uri: "file:///workspace/main.rs".to_owned(),
1422 line: Some(0),
1423 character: Some(0),
1424 }),
1425 }
1426 }
1427
1428 fn identity(symbol: &str, kind: HierarchyKind) -> SymbolIdentity {
1429 SymbolIdentity {
1430 symbol: symbol.to_owned(),
1431 kind,
1432 location: Some(SourceLocation {
1433 uri: "file:///workspace/main.rs".to_owned(),
1434 line: Some(symbol.bytes().map(u32::from).sum()),
1435 character: Some(0),
1436 }),
1437 }
1438 }
1439
1440 fn branch_names(app: &App, node_id: NodeId, direction: HierarchyDirection) -> Vec<&str> {
1441 app.graph
1442 .node(node_id)
1443 .unwrap()
1444 .branch(direction)
1445 .neighbors
1446 .iter()
1447 .map(|neighbor| app.graph.node(*neighbor).unwrap().symbol.as_str())
1448 .collect()
1449 }
1450}