Skip to main content

cgraph/
app.rs

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