1use crate::args::Cli;
4use crate::progress::{CliProgressReporter, CliStepProgressReporter, StepRunner};
5use anyhow::{Context, Result};
6use sqry_core::graph::unified::analysis::ReachabilityStrategy;
7use sqry_core::graph::unified::build::BuildResult;
8use sqry_core::graph::unified::build::entrypoint::AnalysisStrategySummary;
9use sqry_core::graph::unified::persistence::{GraphStorage, load_header_from_path};
10use sqry_core::json_response::IndexStatus;
11use sqry_core::progress::{SharedReporter, no_op_reporter};
12use std::fs;
13use std::io::{BufRead, BufReader, IsTerminal, Write};
14use std::path::Path;
15use std::process::Command;
16use std::sync::Arc;
17use std::time::Instant;
18
19#[derive(serde::Serialize)]
24struct ThreadPoolMetrics {
25 thread_pool_creations: u64,
26}
27
28fn get_git_head_commit(path: &Path) -> Option<String> {
39 let output = Command::new("git")
40 .arg("-C")
41 .arg(path)
42 .args(["rev-parse", "HEAD"])
43 .output()
44 .ok()?;
45
46 if output.status.success() {
47 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
48 if sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit()) {
49 return Some(sha);
50 }
51 }
52 None
53}
54
55#[allow(clippy::too_many_arguments)]
76#[allow(clippy::fn_params_excessive_bools)] #[allow(clippy::too_many_arguments)]
78pub fn run_index(
79 cli: &Cli,
80 path: &str,
81 force: bool,
82 threads: Option<usize>,
83 add_to_gitignore: bool,
84 _no_incremental: bool,
85 _cache_dir: Option<&str>,
86 _no_compress: bool,
87 enable_macro_expansion: bool,
88 cfg_flags: &[String],
89 expand_cache: Option<&std::path::Path>,
90) -> Result<()> {
91 if let Some(0) = threads {
92 anyhow::bail!("--threads must be >= 1");
93 }
94
95 let root_path = Path::new(path);
96
97 handle_gitignore(root_path, add_to_gitignore);
98
99 let storage = GraphStorage::new(root_path);
101 if storage.exists() && !force {
102 println!("Index already exists at {}", storage.graph_dir().display());
103 println!("Use --force to rebuild, or run 'sqry update' to update incrementally");
104 return Ok(());
105 }
106
107 if enable_macro_expansion || !cfg_flags.is_empty() || expand_cache.is_some() {
109 log::info!(
110 "Macro boundary config: expansion={}, cfg_flags={:?}, expand_cache={:?}",
111 enable_macro_expansion,
112 cfg_flags,
113 expand_cache,
114 );
115 }
116
117 print_index_build_banner(root_path, threads);
118
119 let start = Instant::now();
120 let mut step_runner = StepRunner::new(!std::io::stderr().is_terminal() && !cli.json);
121
122 let (progress_bar, progress) = create_progress_reporter(cli);
123
124 let build_config = create_build_config(cli, root_path, threads)?;
126 let build_result = step_runner.step("Build unified graph", || -> Result<_> {
127 let (_graph, build_result) =
128 sqry_core::graph::unified::build::build_and_persist_graph_with_progress(
129 root_path,
130 &sqry_plugin_registry::create_plugin_manager(),
131 &build_config,
132 "cli:index",
133 progress.clone(),
134 )?;
135 Ok(build_result)
136 })?;
137
138 finish_progress_bar(progress_bar.as_ref());
139
140 let elapsed = start.elapsed();
141
142 if std::env::var("SQRY_EMIT_THREAD_POOL_METRICS")
144 .ok()
145 .is_some_and(|v| v == "1")
146 {
147 let metrics = ThreadPoolMetrics {
148 thread_pool_creations: 1,
149 };
150 if let Ok(json) = serde_json::to_string(&metrics) {
151 println!("{json}");
152 }
153 }
154
155 if !cli.json {
157 let status = build_graph_status(&storage)?;
158 emit_graph_summary(
159 &storage,
160 &status,
161 &build_result,
162 elapsed,
163 "✓ Index built successfully!",
164 );
165 }
166
167 Ok(())
168}
169
170fn emit_graph_summary(
171 storage: &GraphStorage,
172 status: &IndexStatus,
173 build_result: &BuildResult,
174 elapsed: std::time::Duration,
175 summary_banner: &str,
176) {
177 println!("\n{summary_banner}");
178 println!(
179 " Graph: {} nodes, {} canonical edges ({} raw)",
180 build_result.node_count, build_result.edge_count, build_result.raw_edge_count
181 );
182 println!(
183 " Corpus: {} files across {} languages",
184 build_result.total_files,
185 build_result.file_count.len()
186 );
187 println!(
188 " Top languages: {}",
189 format_top_languages(&build_result.file_count)
190 );
191 println!(
192 " Reachability: {}",
193 format_analysis_strategy_highlights(&build_result.analysis_strategies)
194 );
195 if status.supports_relations {
196 println!(" Relations: Enabled");
197 }
198 println!(" Graph path: {}", storage.graph_dir().display());
199 println!(" Analysis path: {}", storage.analysis_dir().display());
200 println!(" Time taken: {:.2}s", elapsed.as_secs_f64());
201}
202
203fn print_index_build_banner(root_path: &Path, threads: Option<usize>) {
204 if let Some(1) = threads {
205 println!(
206 "Building index for {} (single-threaded)...",
207 root_path.display()
208 );
209 } else if let Some(count) = threads {
210 println!(
211 "Building index for {} using {} threads...",
212 root_path.display(),
213 count
214 );
215 } else {
216 println!("Building index for {} (parallel)...", root_path.display());
217 }
218}
219
220pub(crate) fn create_progress_reporter(
221 cli: &Cli,
222) -> (Option<Arc<CliProgressReporter>>, SharedReporter) {
223 let progress_bar = if std::io::stderr().is_terminal() && !cli.json {
225 Some(Arc::new(CliProgressReporter::new()))
226 } else {
227 None
228 };
229
230 let progress: SharedReporter = if let Some(progress_bar_ref) = &progress_bar {
231 Arc::clone(progress_bar_ref) as SharedReporter
232 } else if cli.json {
233 no_op_reporter()
234 } else {
235 Arc::new(CliStepProgressReporter::new()) as SharedReporter
236 };
237
238 (progress_bar, progress)
239}
240
241fn finish_progress_bar(progress_bar: Option<&Arc<CliProgressReporter>>) {
242 if let Some(progress_bar_ref) = progress_bar {
243 progress_bar_ref.finish();
244 }
245}
246
247fn build_graph_status(storage: &GraphStorage) -> Result<IndexStatus> {
269 if !storage.exists() {
270 return Ok(IndexStatus::not_found());
271 }
272
273 let manifest = storage
275 .load_manifest()
276 .context("Failed to load graph manifest")?;
277
278 let age_seconds = storage
280 .snapshot_age(&manifest)
281 .context("Failed to compute snapshot age")?
282 .as_secs();
283
284 let total_files: Option<usize> =
286 if let Ok(header) = load_header_from_path(storage.snapshot_path()) {
287 Some(header.file_count)
289 } else if !manifest.file_count.is_empty() {
290 Some(manifest.file_count.values().sum())
292 } else {
293 None
295 };
296
297 let trigram_path = storage.graph_dir().join("trigram.idx");
300 let has_trigram = trigram_path.exists();
301
302 Ok(IndexStatus::from_index(
304 storage.graph_dir().display().to_string(),
305 manifest.built_at.clone(),
306 age_seconds,
307 )
308 .symbol_count(manifest.node_count) .file_count_opt(total_files)
310 .has_relations(manifest.edge_count > 0)
311 .has_trigram(has_trigram)
312 .build())
313}
314
315fn write_graph_status_text(
316 streams: &mut crate::output::OutputStreams,
317 status: &IndexStatus,
318 root_path: &Path,
319) -> Result<()> {
320 if status.exists {
321 streams.write_result("✓ Graph snapshot found\n")?;
322 if let Some(path) = &status.path {
323 streams.write_result(&format!(" Path: {path}\n"))?;
324 }
325 if let Some(created_at) = &status.created_at {
326 streams.write_result(&format!(" Built: {created_at}\n"))?;
327 }
328 if let Some(age) = status.age_seconds {
329 streams.write_result(&format!(" Age: {}\n", format_age(age)))?;
330 }
331 if let Some(count) = status.symbol_count {
332 streams.write_result(&format!(" Nodes: {count}\n"))?;
333 }
334 if let Some(count) = status.file_count {
335 streams.write_result(&format!(" Files: {count}\n"))?;
336 }
337 if status.supports_relations {
338 streams.write_result(" Relations: ✓ Available\n")?;
339 }
340 } else {
341 streams.write_result("✗ No graph snapshot found\n")?;
342 streams.write_result("\nTo create a graph snapshot, run:\n")?;
343 streams.write_result(&format!(" sqry index --force {}\n", root_path.display()))?;
344 }
345
346 Ok(())
347}
348
349fn format_age(age_seconds: u64) -> String {
350 let hours = age_seconds / 3600;
351 let days = hours / 24;
352 if days > 0 {
353 format!("{} days, {} hours", days, hours % 24)
354 } else {
355 format!("{hours} hours")
356 }
357}
358
359fn format_top_languages(file_count: &std::collections::HashMap<String, usize>) -> String {
360 if file_count.is_empty() {
361 return "none".to_string();
362 }
363
364 let mut entries: Vec<_> = file_count.iter().collect();
365 entries.sort_by(|(left_name, left_count), (right_name, right_count)| {
366 right_count
367 .cmp(left_count)
368 .then_with(|| left_name.cmp(right_name))
369 });
370
371 entries
372 .into_iter()
373 .take(3)
374 .map(|(language, count)| format!("{language}={count}"))
375 .collect::<Vec<_>>()
376 .join(", ")
377}
378
379fn format_analysis_strategy_highlights(analysis_strategies: &[AnalysisStrategySummary]) -> String {
380 if analysis_strategies.is_empty() {
381 return "not available".to_string();
382 }
383
384 let mut interval_labels = Vec::new();
385 let mut dag_bfs = Vec::new();
386
387 for strategy in analysis_strategies {
388 match strategy.strategy {
389 ReachabilityStrategy::IntervalLabels => interval_labels.push(strategy.edge_kind),
390 ReachabilityStrategy::DagBfs => dag_bfs.push(strategy.edge_kind),
391 }
392 }
393
394 let mut groups = Vec::new();
395 if !interval_labels.is_empty() {
396 groups.push(format!("interval_labels({})", interval_labels.join(",")));
397 }
398 if !dag_bfs.is_empty() {
399 groups.push(format!("dag_bfs({})", dag_bfs.join(",")));
400 }
401
402 groups.join(" | ")
403}
404
405pub(crate) fn create_build_config(
407 cli: &Cli,
408 root_path: &Path,
409 threads: Option<usize>,
410) -> Result<sqry_core::graph::unified::build::BuildConfig> {
411 Ok(sqry_core::graph::unified::build::BuildConfig {
412 max_depth: if cli.max_depth == 0 {
413 None
414 } else {
415 Some(cli.max_depth)
416 },
417 follow_links: cli.follow,
418 include_hidden: cli.hidden,
419 num_threads: threads,
420 label_budget: sqry_core::graph::unified::analysis::resolve_label_budget_config(
421 root_path, None, None, None, false,
422 )?,
423 ..sqry_core::graph::unified::build::BuildConfig::default()
424 })
425}
426
427pub fn run_update(
438 cli: &Cli,
439 path: &str,
440 threads: Option<usize>,
441 show_stats: bool,
442 _no_incremental: bool,
443 _cache_dir: Option<&str>,
444) -> Result<()> {
445 let root_path = Path::new(path);
446 let mut step_runner = StepRunner::new(!std::io::stderr().is_terminal() && !cli.json);
447
448 let storage = GraphStorage::new(root_path);
450 if !storage.exists() {
451 anyhow::bail!(
452 "No index found at {}. Run 'sqry index' first.",
453 storage.graph_dir().display()
454 );
455 }
456
457 println!("Updating index for {}...", root_path.display());
458 let start = Instant::now();
459
460 let git_mode_disabled = std::env::var("SQRY_GIT_BACKEND")
462 .ok()
463 .is_some_and(|v| v == "none");
464
465 let current_commit = if git_mode_disabled {
466 None
467 } else {
468 get_git_head_commit(root_path)
469 };
470
471 let using_git_mode = !git_mode_disabled && current_commit.is_some();
473
474 let (progress_bar, progress) = create_progress_reporter(cli);
475
476 let build_config = create_build_config(cli, root_path, threads)?;
478 let build_result = step_runner.step("Update unified graph", || -> Result<_> {
479 let (_graph, build_result) =
480 sqry_core::graph::unified::build::build_and_persist_graph_with_progress(
481 root_path,
482 &sqry_plugin_registry::create_plugin_manager(),
483 &build_config,
484 "cli:update",
485 progress.clone(),
486 )?;
487 Ok(build_result)
488 })?;
489
490 finish_progress_bar(progress_bar.as_ref());
491
492 let elapsed = start.elapsed();
493
494 if !cli.json {
496 let status = build_graph_status(&storage)?;
497
498 if using_git_mode {
499 emit_graph_summary(
500 &storage,
501 &status,
502 &build_result,
503 elapsed,
504 "✓ Index updated successfully!",
505 );
506 } else {
507 emit_graph_summary(
508 &storage,
509 &status,
510 &build_result,
511 elapsed,
512 "✓ Index updated successfully (hash-based mode)!",
513 );
514 }
515 }
516
517 if show_stats {
518 println!("(Detailed stats are not available for unified graph update)");
519 }
520
521 Ok(())
522}
523
524#[allow(deprecated)]
525pub fn run_index_status(
535 cli: &Cli,
536 path: &str,
537 _metrics_format: crate::args::MetricsFormat,
538) -> Result<()> {
539 run_graph_status(cli, path)
541}
542
543pub fn run_graph_status(cli: &Cli, path: &str) -> Result<()> {
552 let root_path = Path::new(path);
553 let storage = GraphStorage::new(root_path);
554 let status = build_graph_status(&storage)?;
555
556 let mut streams = crate::output::OutputStreams::with_pager(cli.pager_config());
558
559 if cli.json {
560 let json =
561 serde_json::to_string_pretty(&status).context("Failed to serialize graph status")?;
562 streams.write_result(&json)?;
563 } else {
564 write_graph_status_text(&mut streams, &status, root_path)?;
565 }
566
567 streams.finish_checked()
568}
569
570fn handle_gitignore(path: &Path, add_to_gitignore: bool) {
572 if let Some(root) = find_git_root(path) {
573 let gitignore_path = root.join(".gitignore");
574 let entry = ".sqry-index/";
575 let mut is_already_indexed = false;
576
577 if gitignore_path.exists()
578 && let Ok(file) = fs::File::open(&gitignore_path)
579 {
580 let reader = BufReader::new(file);
581 if reader.lines().any(|line| {
582 line.map(|l| l.trim() == ".sqry-index" || l.trim() == ".sqry-index/")
583 .unwrap_or(false)
584 }) {
585 is_already_indexed = true;
586 }
587 }
588
589 if !is_already_indexed
590 && add_to_gitignore
591 && let Ok(mut file) = fs::OpenOptions::new()
592 .append(true)
593 .create(true)
594 .open(&gitignore_path)
595 && writeln!(file, "\n{entry}").is_ok()
596 {
597 println!("Added '{entry}' to .gitignore");
598 } else if !is_already_indexed {
599 print_gitignore_warning();
600 }
601 }
602}
603
604fn find_git_root(path: &Path) -> Option<&Path> {
606 let mut current = path;
607 loop {
608 if current.join(".git").is_dir() {
609 return Some(current);
610 }
611 if let Some(parent) = current.parent() {
612 current = parent;
613 } else {
614 return None;
615 }
616 }
617}
618
619fn print_gitignore_warning() {
621 eprintln!(
622 "\n\u{26a0}\u{fe0f} Warning: It is recommended to add the '.sqry-index/' directory to your .gitignore file."
623 );
624 eprintln!("This is a generated cache and can become large.\n");
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use crate::large_stack_test;
631 use std::fs;
632 use tempfile::TempDir;
633
634 large_stack_test! {
635 #[test]
636 fn test_run_index_basic() {
637 use crate::args::Cli;
638 use clap::Parser;
639
640 let tmp_cli_workspace = TempDir::new().unwrap();
641 let file_path = tmp_cli_workspace.path().join("test.rs");
642 fs::write(&file_path, "fn hello() {}").unwrap();
643
644 let cli = Cli::parse_from(["sqry", "index"]);
645 let result = run_index(
646 &cli,
647 tmp_cli_workspace.path().to_str().unwrap(),
648 false,
649 None,
650 false,
651 false,
652 None,
653 false, false, &[], None, );
658 assert!(result.is_ok());
659
660 let storage = GraphStorage::new(tmp_cli_workspace.path());
662 assert!(storage.exists());
663 }
664 }
665
666 large_stack_test! {
667 #[test]
668 fn test_run_index_force_rebuild() {
669 use crate::args::Cli;
670 use clap::Parser;
671
672 let tmp_cli_workspace = TempDir::new().unwrap();
673 let file_path = tmp_cli_workspace.path().join("test.rs");
674 fs::write(&file_path, "fn hello() {}").unwrap();
675
676 let cli = Cli::parse_from(["sqry", "index"]);
677
678 run_index(
680 &cli,
681 tmp_cli_workspace.path().to_str().unwrap(),
682 false,
683 None,
684 false,
685 false,
686 None,
687 false, false, &[], None, )
692 .unwrap();
693
694 let result = run_index(
696 &cli,
697 tmp_cli_workspace.path().to_str().unwrap(),
698 false,
699 None,
700 false,
701 false,
702 None,
703 false, false, &[], None, );
708 assert!(result.is_ok());
709
710 let result = run_index(
712 &cli,
713 tmp_cli_workspace.path().to_str().unwrap(),
714 true,
715 None,
716 false,
717 false,
718 None,
719 false, false, &[], None, );
724 assert!(result.is_ok());
725 }
726 }
727
728 large_stack_test! {
729 #[test]
730 fn test_run_update_no_index() {
731 use crate::args::Cli;
732 use clap::Parser;
733
734 let tmp_cli_workspace = TempDir::new().unwrap();
735 let cli = Cli::parse_from(["sqry", "update"]);
736
737 let result = run_update(
738 &cli,
739 tmp_cli_workspace.path().to_str().unwrap(),
740 None,
741 false,
742 false,
743 None,
744 );
745 assert!(result.is_err());
746 assert!(result.unwrap_err().to_string().contains("No index found"));
747 }
748 }
749
750 large_stack_test! {
751 #[test]
752 fn test_run_index_status_no_index() {
753 use crate::args::Cli;
754 use clap::Parser;
755
756 let tmp_cli_workspace = TempDir::new().unwrap();
757
758 let cli = Cli::parse_from(["sqry", "--json"]);
760
761 let result = run_index_status(
763 &cli,
764 tmp_cli_workspace.path().to_str().unwrap(),
765 crate::args::MetricsFormat::Json,
766 );
767 assert!(
768 result.is_ok(),
769 "Index status should not error on missing index"
770 );
771
772 }
775 }
776
777 large_stack_test! {
778 #[test]
779 fn test_run_index_status_with_index() {
780 use crate::args::Cli;
781 use clap::Parser;
782
783 let tmp_cli_workspace = TempDir::new().unwrap();
784 let file_path = tmp_cli_workspace.path().join("test.rs");
785 fs::write(&file_path, "fn test_func() {}").unwrap();
786
787 let cli = Cli::parse_from(["sqry", "index"]);
788
789 run_index(
791 &cli,
792 tmp_cli_workspace.path().to_str().unwrap(),
793 false,
794 None,
795 false,
796 false,
797 None,
798 false, false, &[], None, )
803 .unwrap();
804
805 let cli = Cli::parse_from(["sqry", "--json"]);
807 let result = run_index_status(
808 &cli,
809 tmp_cli_workspace.path().to_str().unwrap(),
810 crate::args::MetricsFormat::Json,
811 );
812 assert!(
813 result.is_ok(),
814 "Index status should succeed with existing index"
815 );
816
817 let storage = GraphStorage::new(tmp_cli_workspace.path());
819 assert!(storage.exists());
820
821 let manifest = storage.load_manifest().unwrap();
823 assert_eq!(manifest.node_count, 1, "Should have 1 symbol");
824 }
825 }
826
827 large_stack_test! {
828 #[test]
829 fn test_run_update_basic() {
830 use crate::args::Cli;
831 use clap::Parser;
832
833 let tmp_cli_workspace = TempDir::new().unwrap();
834 let file_path = tmp_cli_workspace.path().join("test.rs");
835 fs::write(&file_path, "fn hello() {}").unwrap();
836
837 let cli = Cli::parse_from(["sqry", "index"]);
838
839 run_index(
841 &cli,
842 tmp_cli_workspace.path().to_str().unwrap(),
843 false,
844 None,
845 false,
846 false,
847 None,
848 false, false, &[], None, )
853 .unwrap();
854
855 let result = run_update(
857 &cli,
858 tmp_cli_workspace.path().to_str().unwrap(),
859 None,
860 true,
861 false,
862 None,
863 );
864 assert!(result.is_ok());
865 }
866 }
867
868 #[test]
869 fn plugin_manager_registers_elixir_extensions() {
870 let pm = crate::plugin_defaults::create_plugin_manager();
871 assert!(
872 pm.plugin_for_extension("ex").is_some(),
873 "Elixir .ex extension missing"
874 );
875 assert!(
876 pm.plugin_for_extension("exs").is_some(),
877 "Elixir .exs extension missing"
878 );
879 }
880
881 #[test]
882 fn test_format_top_languages_orders_by_count_then_name() {
883 let counts = std::collections::HashMap::from([
884 ("rust".to_string(), 9_usize),
885 ("python".to_string(), 4_usize),
886 ("go".to_string(), 4_usize),
887 ("typescript".to_string(), 2_usize),
888 ]);
889
890 assert_eq!(format_top_languages(&counts), "rust=9, go=4, python=4");
891 }
892
893 #[test]
894 fn test_format_analysis_strategy_highlights_groups_by_strategy() {
895 let strategies = vec![
896 AnalysisStrategySummary {
897 edge_kind: "calls",
898 strategy: ReachabilityStrategy::IntervalLabels,
899 },
900 AnalysisStrategySummary {
901 edge_kind: "imports",
902 strategy: ReachabilityStrategy::DagBfs,
903 },
904 AnalysisStrategySummary {
905 edge_kind: "references",
906 strategy: ReachabilityStrategy::DagBfs,
907 },
908 AnalysisStrategySummary {
909 edge_kind: "inherits",
910 strategy: ReachabilityStrategy::IntervalLabels,
911 },
912 ];
913
914 assert_eq!(
915 format_analysis_strategy_highlights(&strategies),
916 "interval_labels(calls,inherits) | dag_bfs(imports,references)"
917 );
918 }
919}