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 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        // A result may arrive after the modal was closed and reopened. Request
319        // ids are global to App so an old session cannot replace a new one.
320        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 &current {
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}