1use crate::analyzer::{
4 AnalyzedItem, CrateInfo, CrateRegistry, DependencyAnalyzer, InstalledCrate, RustAnalyzer,
5};
6use crate::config::Settings;
7use crate::crates_io::CrateDocInfo;
8use crate::error::Result;
9use crate::ui::theme::Theme;
10use crate::ui::{filter_candidates, CandidateKind, CompletionCandidate, Focus, Tab};
11use crate::utils::dir_size;
12
13use ratatui::widgets::ListState;
14use std::collections::{HashMap, HashSet};
15use std::fmt::Write;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::sync::mpsc;
19use std::thread;
20
21pub struct App {
23 pub items: Vec<AnalyzedItem>,
25 pub filtered_items: Vec<usize>,
26 pub crate_info: Option<CrateInfo>,
27 pub dependency_tree: Vec<(String, usize)>,
28 pub filtered_dependency_indices: Vec<usize>,
30
31 pub crate_registry: CrateRegistry,
33 pub installed_crates_list: Vec<String>,
34 pub selected_installed_crate: Option<InstalledCrate>,
35 pub installed_crate_items: Vec<AnalyzedItem>,
36 pub installed_crate_filtered: Vec<usize>,
37
38 pub search_input: String,
40 pub current_tab: Tab,
41 pub focus: Focus,
42 pub list_state: ListState,
43 pub completion_selected: usize,
44 pub show_completion: bool,
45 pub show_help: bool,
46 pub show_settings: bool,
47 pub status_message: String,
48
49 pub candidates: Vec<CompletionCandidate>,
51 pub filtered_candidates: Vec<CompletionCandidate>,
52
53 pub settings: Settings,
55 pub theme: Theme,
56
57 pub should_quit: bool,
59 pub project_path: Option<PathBuf>,
60
61 pub copilot_chat_open: bool,
63 pub copilot_chat_messages: Vec<(String, String)>,
65 pub copilot_chat_input: String,
66 pub copilot_chat_loading: bool,
67 pub copilot_chat_scroll: usize,
68 pub target_size_bytes: Option<u64>,
70
71 pub crate_docs_cache: HashMap<String, CrateDocInfo>,
73 pub crate_docs_loading: Option<String>,
74 pub crate_docs_failed: HashSet<String>,
75 crate_docs_tx: mpsc::Sender<(String, Option<CrateDocInfo>)>,
76 pub crate_docs_rx: mpsc::Receiver<(String, Option<CrateDocInfo>)>,
77
78 pub copilot_tx: mpsc::Sender<String>,
79 pub copilot_rx: mpsc::Receiver<String>,
80}
81
82const CRATE_DOCS_CACHE_MAX: usize = 50;
84
85impl App {
86 pub fn new() -> Self {
87 let (crate_docs_tx, crate_docs_rx) = mpsc::channel();
88 let (copilot_tx, copilot_rx) = mpsc::channel();
89 Self {
90 items: Vec::new(),
91 filtered_items: Vec::new(),
92 crate_info: None,
93 dependency_tree: Vec::new(),
94 filtered_dependency_indices: Vec::new(),
95 crate_registry: CrateRegistry::new(),
96 installed_crates_list: Vec::new(),
97 selected_installed_crate: None,
98 installed_crate_items: Vec::new(),
99 installed_crate_filtered: Vec::new(),
100 search_input: String::new(),
101 current_tab: Tab::default(),
102 focus: Focus::default(),
103 list_state: ListState::default(),
104 completion_selected: 0,
105 show_completion: false,
106 show_help: false,
107 show_settings: false,
108 status_message: String::from("Ready"),
109 candidates: Vec::new(),
110 filtered_candidates: Vec::new(),
111 settings: Settings::default(),
112 theme: Theme::default(),
113 should_quit: false,
114 project_path: None,
115 target_size_bytes: None,
116 copilot_chat_open: false,
117 copilot_chat_messages: Vec::new(),
118 copilot_chat_input: String::new(),
119 copilot_chat_loading: false,
120 copilot_chat_scroll: 0,
121 crate_docs_cache: HashMap::new(),
122 crate_docs_loading: None,
123 crate_docs_failed: HashSet::new(),
124 crate_docs_tx,
125 crate_docs_rx,
126 copilot_tx,
127 copilot_rx,
128 }
129 }
130
131 pub fn load_settings(&mut self) -> Result<()> {
133 self.settings = Settings::load()?;
134 self.theme = Theme::from_name(&self.settings.ui.theme);
135 Ok(())
136 }
137
138 pub fn cycle_theme(&mut self) {
140 let next = self.theme.kind().next();
141 self.theme = Theme::from_kind(next);
142 self.settings.ui.theme = next.name().to_string();
143 self.status_message = format!("Theme: {}", next.display_name());
144 let _ = self.settings.save();
145 }
146
147 pub fn toggle_settings(&mut self) {
148 self.show_settings = !self.show_settings;
149 }
150
151 pub fn analyze_project(&mut self, path: &Path) -> Result<()> {
153 if !path.exists() {
154 return Err(crate::error::OracleError::Other(format!(
155 "Path does not exist: {}",
156 path.display()
157 )));
158 }
159 self.project_path = Some(path.to_path_buf());
160 self.status_message = format!("Analyzing {}...", path.display());
161
162 let manifest_path = path.join("Cargo.toml");
164 if manifest_path.exists() {
165 match DependencyAnalyzer::from_manifest(&manifest_path) {
166 Ok(analyzer) => {
167 if let Some(root) = analyzer.root_package() {
168 self.dependency_tree = analyzer.dependency_tree(&root.name);
169 self.crate_info = Some(root);
170 }
171 }
172 Err(e) => {
173 self.status_message = format!("Cargo analysis failed: {e}");
174 }
175 }
176 }
177
178 let analyzer = RustAnalyzer::new().with_private(self.settings.analyzer.include_private);
180
181 let src_path = path.join("src");
182 if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
183 self.items = analyzer.analyze_file(path)?;
184 } else if src_path.exists() {
185 self.analyze_directory(&analyzer, &src_path)?;
186 } else if path.is_dir() {
187 self.analyze_directory(&analyzer, &path.to_path_buf())?;
189 }
190
191 self.update_candidates();
192 self.filter_items();
193 self.status_message = if self.items.is_empty() {
194 format!("No Rust files found in {}", path.display())
195 } else {
196 format!("Found {} items", self.items.len())
197 };
198
199 let target_dir = path.join("target");
201 if target_dir.is_dir() {
202 self.target_size_bytes = dir_size(&target_dir);
203 } else {
204 self.target_size_bytes = None;
205 }
206
207 Ok(())
208 }
209
210 fn analyze_directory(&mut self, analyzer: &RustAnalyzer, dir: &PathBuf) -> Result<()> {
211 for entry in std::fs::read_dir(dir)? {
212 let entry = entry?;
213 let path = entry.path();
214
215 if path.is_dir() {
216 self.analyze_directory(analyzer, &path)?;
217 } else if path.extension().is_some_and(|ext| ext == "rs") {
218 match analyzer.analyze_file(&path) {
219 Ok(items) => self.items.extend(items),
220 Err(e) => {
221 eprintln!("Warning: Failed to analyze {}: {}", path.display(), e);
223 }
224 }
225 }
226 }
227 Ok(())
228 }
229
230 pub fn update_candidates(&mut self) {
232 self.candidates = self
233 .items
234 .iter()
235 .map(|item| {
236 let kind = match item {
237 AnalyzedItem::Function(_) => CandidateKind::Function,
238 AnalyzedItem::Struct(_) => CandidateKind::Struct,
239 AnalyzedItem::Enum(_) => CandidateKind::Enum,
240 AnalyzedItem::Trait(_) => CandidateKind::Trait,
241 AnalyzedItem::Module(_) => CandidateKind::Module,
242 AnalyzedItem::TypeAlias(_) => CandidateKind::Type,
243 AnalyzedItem::Const(_) | AnalyzedItem::Static(_) => CandidateKind::Const,
244 _ => CandidateKind::Other,
245 };
246
247 let secondary = item.documentation().map(|d| {
248 let first_line = d.lines().next().unwrap_or("");
249 if first_line.len() > 40 {
250 format!("{}...", &first_line[..37])
251 } else {
252 first_line.to_string()
253 }
254 });
255
256 CompletionCandidate {
257 primary: item.name().to_string(),
258 secondary,
259 kind,
260 score: 0,
261 }
262 })
263 .collect();
264
265 self.filtered_candidates = self.candidates.clone();
266 }
267
268 pub fn filter_items(&mut self) {
270 let query = self.search_input.to_lowercase();
271
272 if self.current_tab == Tab::Crates && self.selected_installed_crate.is_some() {
274 self.filter_installed_crates();
275 return;
276 }
277
278 if self.current_tab == Tab::Crates {
280 let mut indices: Vec<usize> = self
281 .dependency_tree
282 .iter()
283 .enumerate()
284 .filter(|(_, (name, _))| {
285 query.is_empty()
286 || name.to_lowercase().contains(&query)
287 || name.to_lowercase().replace('-', "_").contains(&query)
288 })
289 .map(|(i, _)| i)
290 .collect();
291 indices.sort_by(|&a, &b| {
292 self.dependency_tree[a]
293 .0
294 .to_lowercase()
295 .cmp(&self.dependency_tree[b].0.to_lowercase())
296 });
297 self.filtered_dependency_indices = indices;
298 if self
299 .list_state
300 .selected()
301 .is_some_and(|s| s >= self.filtered_dependency_indices.len())
302 {
303 self.list_state.select(Some(0));
304 }
305 self.filtered_candidates = Vec::new();
306 self.completion_selected = 0;
307 return;
308 }
309
310 self.filtered_items = self
311 .items
312 .iter()
313 .enumerate()
314 .filter(|(_, item)| {
315 let tab_match = match self.current_tab {
317 Tab::Types => matches!(
318 item,
319 AnalyzedItem::Struct(_)
320 | AnalyzedItem::Enum(_)
321 | AnalyzedItem::TypeAlias(_)
322 ),
323 Tab::Functions => matches!(item, AnalyzedItem::Function(_)),
324 Tab::Modules => matches!(item, AnalyzedItem::Module(_)),
325 Tab::Crates => true, };
327
328 let search_match = query.is_empty() || item.name().to_lowercase().contains(&query);
330
331 tab_match && search_match
332 })
333 .map(|(i, _)| i)
334 .collect();
335
336 if self
338 .list_state
339 .selected()
340 .is_some_and(|s| s >= self.filtered_items.len())
341 {
342 self.list_state.select(Some(0));
343 }
344
345 let matched = filter_candidates(&self.candidates, &self.search_input);
347 self.filtered_candidates = match self.current_tab {
348 Tab::Types => matched
349 .into_iter()
350 .filter(|c| {
351 matches!(
352 c.kind,
353 CandidateKind::Struct | CandidateKind::Enum | CandidateKind::Type
354 )
355 })
356 .collect(),
357 Tab::Functions => matched
358 .into_iter()
359 .filter(|c| c.kind == CandidateKind::Function)
360 .collect(),
361 Tab::Modules => matched
362 .into_iter()
363 .filter(|c| c.kind == CandidateKind::Module)
364 .collect(),
365 Tab::Crates => Vec::new(),
366 };
367 self.completion_selected = 0;
368 }
369
370 pub fn scan_installed_crates(&mut self) -> Result<()> {
372 self.status_message = "Scanning installed crates...".to_string();
373 self.crate_registry.scan()?;
374 self.installed_crates_list = self
375 .crate_registry
376 .crate_names()
377 .into_iter()
378 .map(|s| s.to_string())
379 .collect();
380 self.status_message = format!(
381 "Found {} installed crates",
382 self.installed_crates_list.len()
383 );
384 Ok(())
385 }
386
387 fn filter_installed_crates(&mut self) {
390 let query = self.search_input.to_lowercase();
391
392 if self.selected_installed_crate.is_some() {
393 self.installed_crate_filtered = self
395 .installed_crate_items
396 .iter()
397 .enumerate()
398 .filter(|(_, item)| {
399 if query.is_empty() {
400 return true;
401 }
402 if query.contains("::") {
404 item.qualified_name().to_lowercase().contains(&query) ||
406 item.module_path().iter()
408 .any(|p| p.to_lowercase().contains(&query.replace("::", "")))
409 } else {
410 item.name().to_lowercase().contains(&query)
412 }
413 })
414 .map(|(i, _)| i)
415 .collect();
416 }
417
418 if self
420 .list_state
421 .selected()
422 .is_some_and(|s| s >= self.get_current_list_len())
423 {
424 self.list_state.select(Some(0));
425 }
426 }
427
428 pub fn search_qualified_path(&mut self) -> bool {
431 let query = self.search_input.clone();
432 let query = query.trim();
433
434 if !query.contains("::") {
436 return false;
437 }
438
439 let parts: Vec<&str> = query.split("::").collect();
440 if parts.is_empty() {
441 return false;
442 }
443
444 let crate_name = parts[0].to_string();
445
446 let crate_exists = self.installed_crates_list.iter().any(|name| {
448 name.to_lowercase() == crate_name.to_lowercase()
449 || name.to_lowercase().replace('-', "_") == crate_name.to_lowercase()
450 });
451
452 if !crate_exists {
453 self.status_message = format!("Crate '{}' not found", crate_name);
454 return false;
455 }
456
457 let actual_name = self
459 .installed_crates_list
460 .iter()
461 .find(|name| {
462 name.to_lowercase() == crate_name.to_lowercase()
463 || name.to_lowercase().replace('-', "_") == crate_name.to_lowercase()
464 })
465 .cloned();
466
467 let already_selected = self
469 .selected_installed_crate
470 .as_ref()
471 .map(|c| c.name.to_lowercase() == crate_name.to_lowercase())
472 .unwrap_or(false);
473
474 if !already_selected {
475 if let Some(name) = actual_name {
476 let _ = self.select_installed_crate(&name);
477 }
478 }
479
480 if parts.len() > 1 {
482 self.search_input = parts[1..].join("::");
484 self.filter_installed_crates();
485 }
486
487 true
488 }
489
490 pub fn select_installed_crate(&mut self, name: &str) -> Result<()> {
492 if let Some(crate_info) = self.crate_registry.latest(name) {
493 self.selected_installed_crate = Some(crate_info.clone());
494 self.status_message = format!("Analyzing {}...", name);
495
496 match self.crate_registry.analyze_crate(name, None) {
497 Ok(items) => {
498 self.installed_crate_items = items;
499 self.installed_crate_filtered = (0..self.installed_crate_items.len()).collect();
500 self.status_message =
501 format!("{}: {} items", name, self.installed_crate_items.len());
502 }
503 Err(e) => {
504 self.status_message = format!("Analysis failed: {e}");
505 }
506 }
507 }
508 Ok(())
509 }
510
511 pub fn clear_installed_crate(&mut self) {
513 self.selected_installed_crate = None;
514 self.installed_crate_items.clear();
515 self.installed_crate_filtered.clear();
516 self.list_state.select(Some(0));
517 }
518
519 pub fn installed_crates_display_list(&self) -> Vec<String> {
521 let project_dep_names: HashSet<String> = self
522 .dependency_tree
523 .iter()
524 .filter(|(_, depth)| *depth > 0)
525 .map(|(name, _)| name.clone())
526 .collect();
527 if project_dep_names.is_empty() {
528 self.installed_crates_list.clone()
529 } else {
530 self.installed_crates_list
531 .iter()
532 .filter(|n| project_dep_names.contains(*n))
533 .cloned()
534 .collect()
535 }
536 }
537
538 pub fn selected_crate_name_for_display(&self) -> Option<String> {
540 if self.current_tab != Tab::Crates {
541 return None;
542 }
543 if let Some(ref c) = self.selected_installed_crate {
544 return Some(c.name.clone());
545 }
546 self.selected_dependency_name()
547 }
548
549 pub fn selected_dependency_name(&self) -> Option<String> {
551 if self.current_tab != Tab::Crates
552 || self.selected_installed_crate.is_some()
553 || self.dependency_tree.is_empty()
554 {
555 return None;
556 }
557 let list_idx = self.list_state.selected().unwrap_or(0);
558 let tree_idx = self.filtered_dependency_indices.get(list_idx).copied()?;
559 self.dependency_tree
560 .get(tree_idx)
561 .map(|(name, _)| name.clone())
562 }
563
564 pub fn dependency_root_name(&self) -> Option<&str> {
566 self.dependency_tree.first().map(|(n, _)| n.as_str())
567 }
568
569 pub fn poll_crate_docs_rx(&mut self) {
571 while let Ok((name, doc)) = self.crate_docs_rx.try_recv() {
572 if self.crate_docs_loading.as_deref() == Some(name.as_str()) {
573 self.crate_docs_loading = None;
574 }
575 if let Some(info) = doc {
576 if self.crate_docs_cache.len() >= CRATE_DOCS_CACHE_MAX {
577 if let Some(key) = self.crate_docs_cache.keys().next().cloned() {
578 self.crate_docs_cache.remove(&key);
579 }
580 }
581 self.crate_docs_cache.insert(name.clone(), info);
582 } else {
583 self.crate_docs_failed.insert(name);
584 }
585 }
586 }
587
588 pub fn maybe_start_crate_doc_fetch(&mut self) {
590 if self.current_tab != Tab::Crates {
591 return;
592 }
593 let Some(name) = self.selected_dependency_name() else {
594 return;
595 };
596 if self.dependency_root_name() == Some(name.as_str()) {
597 return; }
599 if self.crate_docs_cache.contains_key(&name)
600 || self.crate_docs_loading.as_deref() == Some(name.as_str())
601 || self.crate_docs_failed.contains(&name)
602 {
603 return;
604 }
605 self.crate_docs_loading = Some(name.clone());
606 let tx = self.crate_docs_tx.clone();
607 thread::spawn(move || {
608 let result = crate::crates_io::fetch_crate_docs(&name);
609 let _ = tx.send((name, result));
610 });
611 }
612
613 pub fn get_current_list_len(&self) -> usize {
615 if self.current_tab == Tab::Crates {
616 if self.selected_installed_crate.is_some() {
617 self.installed_crate_filtered.len()
618 } else {
619 let n = self.filtered_dependency_indices.len();
620 if self.dependency_tree.is_empty() || n == 0 {
621 1
622 } else {
623 n
624 }
625 }
626 } else {
627 self.filtered_items.len()
628 }
629 }
630
631 pub fn selected_item(&self) -> Option<&AnalyzedItem> {
633 if self.current_tab == Tab::Crates && self.selected_installed_crate.is_some() {
634 return self
635 .list_state
636 .selected()
637 .and_then(|i| self.installed_crate_filtered.get(i))
638 .and_then(|&idx| self.installed_crate_items.get(idx));
639 }
640 if self.current_tab == Tab::Crates {
641 return None; }
643 self.list_state
644 .selected()
645 .and_then(|i| self.filtered_items.get(i))
646 .and_then(|&idx| self.items.get(idx))
647 }
648
649 pub fn get_filtered_items(&self) -> Vec<&AnalyzedItem> {
651 if self.current_tab == Tab::Crates && self.selected_installed_crate.is_some() {
652 self.installed_crate_filtered
653 .iter()
654 .filter_map(|&i| self.installed_crate_items.get(i))
655 .collect()
656 } else {
657 self.filtered_items
658 .iter()
659 .filter_map(|&i| self.items.get(i))
660 .collect()
661 }
662 }
663
664 pub fn next_item(&mut self) {
666 let len = self.get_current_list_len();
667 if len == 0 {
668 return;
669 }
670 let i = match self.list_state.selected() {
671 Some(i) => (i + 1) % len,
672 None => 0,
673 };
674 self.list_state.select(Some(i));
675 }
676
677 pub fn prev_item(&mut self) {
678 let len = self.get_current_list_len();
679 if len == 0 {
680 return;
681 }
682 let i = match self.list_state.selected() {
683 Some(i) => i.checked_sub(1).unwrap_or(len - 1),
684 None => 0,
685 };
686 self.list_state.select(Some(i));
687 }
688
689 pub fn next_tab(&mut self) {
690 self.current_tab = self.current_tab.next();
691 self.list_state.select(Some(0));
692 self.show_completion = false; self.filter_items();
694
695 if self.current_tab == Tab::Crates && self.installed_crates_list.is_empty() {
697 let _ = self.scan_installed_crates();
698 }
699 }
700
701 pub fn prev_tab(&mut self) {
702 self.current_tab = self.current_tab.prev();
703 self.list_state.select(Some(0));
704 self.show_completion = false; self.filter_items();
706
707 if self.current_tab == Tab::Crates && self.installed_crates_list.is_empty() {
708 let _ = self.scan_installed_crates();
709 }
710 }
711
712 pub fn next_focus(&mut self) {
713 self.focus = self.focus.next(self.copilot_chat_open);
714 }
715
716 pub fn prev_focus(&mut self) {
717 self.focus = self.focus.prev(self.copilot_chat_open);
718 }
719
720 pub fn next_completion(&mut self) {
721 if !self.filtered_candidates.is_empty() {
722 self.completion_selected =
723 (self.completion_selected + 1) % self.filtered_candidates.len();
724 }
725 }
726
727 pub fn prev_completion(&mut self) {
728 if !self.filtered_candidates.is_empty() {
729 self.completion_selected = self
730 .completion_selected
731 .checked_sub(1)
732 .unwrap_or(self.filtered_candidates.len() - 1);
733 }
734 }
735
736 pub fn select_completion(&mut self) {
737 if let Some(candidate) = self.filtered_candidates.get(self.completion_selected) {
738 self.search_input = candidate.primary.clone();
739 self.show_completion = false;
740 self.filter_items();
741 }
742 }
743
744 pub fn on_char(&mut self, c: char) {
746 self.search_input.push(c);
747 self.filter_items();
748 self.show_completion = self.search_input.len() >= 2
750 && !(self.current_tab == Tab::Crates && self.selected_installed_crate.is_some());
751 }
752
753 pub fn on_backspace(&mut self) {
754 self.search_input.pop();
755 self.filter_items();
756 self.show_completion = self.search_input.len() >= 2
757 && !(self.current_tab == Tab::Crates && self.selected_installed_crate.is_some());
758 }
759
760 pub fn clear_search(&mut self) {
761 self.search_input.clear();
762 self.show_completion = false;
763 self.filter_items();
764 }
765
766 pub fn toggle_help(&mut self) {
767 self.show_help = !self.show_help;
768 }
769
770 pub fn build_copilot_context(&self) -> Option<String> {
772 let item = self.selected_item()?;
773 let loc = item
774 .source_location()
775 .and_then(|l| l.file.as_ref())
776 .map(|p| p.display().to_string())
777 .unwrap_or_else(|| "unknown".to_string());
778 let line = item
779 .source_location()
780 .and_then(|l| l.line)
781 .map(|n| format!(":{}", n))
782 .unwrap_or_default();
783 let mut ctx = format!(
784 "I'm inspecting this Rust item in Oracle TUI. Use it as context.\n\n\
785 **Item:** {} {}\n**Location:** {}{}\n**Definition:**\n```rust\n{}\n```\n",
786 item.kind(),
787 item.qualified_name(),
788 loc,
789 line,
790 item.definition(),
791 );
792 if let Some(doc) = item.documentation() {
793 let doc = doc.lines().take(10).collect::<Vec<_>>().join("\n");
794 ctx.push_str("\n**Docs:**\n");
795 ctx.push_str(&doc);
796 ctx.push('\n');
797 }
798 ctx.push_str("\n---\nAnswer the user's question about this item.");
799 Some(ctx)
800 }
801
802 pub fn submit_copilot_message(&mut self) {
804 let input = self.copilot_chat_input.trim().to_string();
805 if input.is_empty() {
806 return;
807 }
808 self.copilot_chat_input.clear();
809 self.copilot_chat_messages
810 .push(("user".to_string(), input.clone()));
811
812 let context = if let Some(c) = self.build_copilot_context() {
813 c
814 } else {
815 self.copilot_chat_messages
816 .push(("assistant".to_string(), "No item selected.".to_string()));
817 return;
818 };
819
820 let mut full_prompt = context;
821 full_prompt.push_str("\n\n**Conversation:**\n");
822 for (role, content) in &self.copilot_chat_messages {
823 let label = if role == "user" { "User" } else { "Assistant" };
824 let _ = writeln!(full_prompt, "{}: {}", label, content);
825 }
826 full_prompt.push_str("\nRespond to the user's latest message above.");
827
828 let tx = self.copilot_tx.clone();
829 let project_path = self.project_path.clone();
830 thread::spawn(move || {
831 let mut cmd = Command::new("copilot");
832 cmd.arg("-p").arg(&full_prompt).arg("--allow-all").arg("-s");
833 if let Some(ref p) = project_path {
834 cmd.arg("--add-dir").arg(p);
835 }
836 let output = cmd.output();
837 let response = match output {
838 Ok(o) if o.status.success() => {
839 String::from_utf8_lossy(&o.stdout).trim().to_string()
840 }
841 Ok(o) => format!(
842 "Copilot error (exit {}): {}",
843 o.status,
844 String::from_utf8_lossy(&o.stderr)
845 ),
846 Err(e) => format!("Failed to run copilot: {}", e),
847 };
848 let _ = tx.send(response);
849 });
850 self.copilot_chat_loading = true;
851 }
852
853 pub fn toggle_copilot_chat(&mut self) {
855 self.copilot_chat_open = !self.copilot_chat_open;
856 if self.copilot_chat_open && self.selected_item().is_some() {
857 self.focus = Focus::CopilotChat;
858 } else if !self.copilot_chat_open && self.focus == Focus::CopilotChat {
859 self.focus = Focus::Inspector;
860 }
861 }
862}
863
864impl Default for App {
865 fn default() -> Self {
866 Self::new()
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use crate::analyzer::RustAnalyzer;
874
875 fn make_app_with_items() -> App {
876 let source = r#"
877 pub struct Foo {}
878 pub fn bar() {}
879 pub mod baz {}
880 "#;
881 let items = RustAnalyzer::new().analyze_source(source).unwrap();
882 let mut app = App::new();
883 app.items = items;
884 app.filtered_items = vec![0, 1, 2];
885 app.list_state.select(Some(0));
886 app
887 }
888
889 #[test]
890 fn test_get_current_list_len_types_tab() {
891 let mut app = make_app_with_items();
892 app.current_tab = Tab::Types;
893 app.filter_items();
894 assert_eq!(app.get_current_list_len(), 1);
895 }
896
897 #[test]
898 fn test_get_current_list_len_functions_tab() {
899 let mut app = make_app_with_items();
900 app.current_tab = Tab::Functions;
901 app.filter_items();
902 assert_eq!(app.get_current_list_len(), 1);
903 }
904
905 #[test]
906 fn test_get_current_list_len_crates_tab_empty_tree() {
907 let mut app = App::new();
908 app.current_tab = Tab::Crates;
909 app.dependency_tree = vec![];
910 app.filtered_dependency_indices = vec![];
911 assert_eq!(app.get_current_list_len(), 1);
912 }
913
914 #[test]
915 fn test_get_current_list_len_crates_tab_with_deps() {
916 let mut app = App::new();
917 app.current_tab = Tab::Crates;
918 app.dependency_tree = vec![
919 ("oracle".to_string(), 0),
920 ("serde".to_string(), 1),
921 ("ratatui".to_string(), 1),
922 ];
923 app.filtered_dependency_indices = vec![0, 1, 2];
924 assert_eq!(app.get_current_list_len(), 3);
925 }
926
927 #[test]
928 fn test_selected_dependency_name_none_when_wrong_tab() {
929 let mut app = App::new();
930 app.current_tab = Tab::Types;
931 app.dependency_tree = vec![("oracle".to_string(), 0)];
932 app.filtered_dependency_indices = vec![0];
933 app.list_state.select(Some(0));
934 assert!(app.selected_dependency_name().is_none());
935 }
936
937 #[test]
938 fn test_selected_dependency_name_returns_selected() {
939 let mut app = App::new();
940 app.current_tab = Tab::Crates;
941 app.dependency_tree = vec![("oracle".to_string(), 0), ("serde".to_string(), 1)];
942 app.filtered_dependency_indices = vec![0, 1];
943 app.list_state.select(Some(1));
944 assert_eq!(app.selected_dependency_name(), Some("serde".to_string()));
945 }
946
947 #[test]
948 fn test_dependency_root_name() {
949 let mut app = App::new();
950 app.dependency_tree = vec![("oracle".to_string(), 0), ("serde".to_string(), 1)];
951 assert_eq!(app.dependency_root_name(), Some("oracle"));
952 app.dependency_tree.clear();
953 assert!(app.dependency_root_name().is_none());
954 }
955
956 #[test]
957 fn test_selected_item_types_tab() {
958 let mut app = make_app_with_items();
959 app.current_tab = Tab::Types;
960 app.filter_items();
961 app.list_state.select(Some(0));
962 let item = app.selected_item().unwrap();
963 assert_eq!(item.name(), "Foo");
964 }
965
966 #[test]
967 fn test_get_filtered_items() {
968 let mut app = make_app_with_items();
969 app.current_tab = Tab::Types;
970 app.filter_items();
971 let filtered = app.get_filtered_items();
972 assert_eq!(filtered.len(), 1);
973 assert_eq!(filtered[0].name(), "Foo");
974 }
975
976 #[test]
977 fn test_installed_crates_display_list_empty_tree_returns_all_installed() {
978 let mut app = App::new();
979 app.dependency_tree = vec![];
980 app.installed_crates_list = vec!["foo".into(), "bar".into()];
981 let list = app.installed_crates_display_list();
982 assert_eq!(list, vec!["foo", "bar"]);
983 }
984
985 #[test]
986 fn test_installed_crates_display_list_filters_by_project_deps() {
987 let mut app = App::new();
988 app.dependency_tree = vec![("oracle".to_string(), 0), ("serde".to_string(), 1)];
989 app.installed_crates_list = vec!["serde".into(), "other".into()];
990 let list = app.installed_crates_display_list();
991 assert_eq!(list, vec!["serde"]);
992 }
993}