Skip to main content

sqry_cli/commands/
index.rs

1//! Index command implementation
2
3use 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/// Thread pool creation metrics for diagnostic output.
20///
21/// Emitted as JSON to stdout when `SQRY_EMIT_THREAD_POOL_METRICS=1` is set.
22/// Used for build diagnostics and performance monitoring.
23#[derive(serde::Serialize)]
24struct ThreadPoolMetrics {
25    thread_pool_creations: u64,
26}
27
28/// Get the current HEAD commit SHA from a git repository.
29///
30/// # Arguments
31///
32/// * `path` - Path to the repository root (or any path within it)
33///
34/// # Returns
35///
36/// Returns `Some(commit_sha)` if the path is in a git repository with at least one commit,
37/// `None` otherwise (not a git repo, no commits, or git not available).
38fn 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// format_validation_prometheus removed
56// format_validation_summary removed
57// validation_warning_count removed
58
59/// Run index build command
60///
61/// # Arguments
62///
63/// * `cli` - CLI configuration (for validation flags)
64/// * `path` - Directory to index
65/// * `force` - Force rebuild even if index exists
66/// * `threads` - Number of threads for parallel indexing (None = auto-detect)
67///
68/// # Errors
69///
70/// Returns an error if index build or persistence fails.
71///
72/// # Panics
73///
74/// Panics if the index is missing after a successful build-and-save sequence.
75#[allow(clippy::too_many_arguments)]
76#[allow(clippy::fn_params_excessive_bools)] // CLI flags map directly to booleans.
77#[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    // Check if graph already exists
100    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    // Log macro boundary analysis configuration
108    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    // Build unified graph using the consolidated pipeline
125    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    // Emit thread pool metrics if requested (diagnostic feature)
143    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    // Report success
156    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    // Create progress reporter (disable when not connected to a TTY)
224    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
247// emit_index_summary removed — logic inlined in run_index
248// handle_update_validation removed — validation moved to core
249// emit_validation_failures removed — validation moved to core
250// handle_validation_strictness removed — validation moved to core
251
252// emit_update_summary removed
253
254// build_index_status removed
255
256// collect_languages removed
257
258// write_index_status_json removed
259// write_index_status_text removed
260// write_index_status_found removed
261// write_index_status_metadata removed
262// write_index_status_missing removed
263// write_validation_report_text removed
264// write_dependency_validation removed
265// write_id_validation removed
266// write_graph_validation removed
267
268fn build_graph_status(storage: &GraphStorage) -> Result<IndexStatus> {
269    if !storage.exists() {
270        return Ok(IndexStatus::not_found());
271    }
272
273    // Load manifest
274    let manifest = storage
275        .load_manifest()
276        .context("Failed to load graph manifest")?;
277
278    // Compute age
279    let age_seconds = storage
280        .snapshot_age(&manifest)
281        .context("Failed to compute snapshot age")?
282        .as_secs();
283
284    // Get file count: prefer snapshot header (fast), fallback to manifest (CLI-built indexes)
285    let total_files: Option<usize> =
286        if let Ok(header) = load_header_from_path(storage.snapshot_path()) {
287            // Read from snapshot header (always accurate)
288            Some(header.file_count)
289        } else if !manifest.file_count.is_empty() {
290            // Fallback: sum manifest file counts (legacy CLI-built indexes)
291            Some(manifest.file_count.values().sum())
292        } else {
293            // No file count available
294            None
295        };
296
297    // Check if trigram index exists in graph storage
298    // Trigram files would be stored alongside the snapshot
299    let trigram_path = storage.graph_dir().join("trigram.idx");
300    let has_trigram = trigram_path.exists();
301
302    // Build status (map graph data to IndexStatus for compatibility)
303    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) // Map nodes → symbols
309    .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
405/// Create a `BuildConfig` from CLI flags.
406pub(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
427/// Run index update command
428///
429/// # Arguments
430///
431/// * `cli` - CLI configuration (for validation flags)
432/// * `path` - Directory with existing index
433/// * `show_stats` - Show detailed statistics
434///
435/// # Errors
436/// Returns an error if the index cannot be loaded, updated, or validated.
437pub 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    // Check if graph exists
449    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    // Determine update mode based on git availability
461    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    // Determine if we're using git-aware or hash-based mode
472    let using_git_mode = !git_mode_disabled && current_commit.is_some();
473
474    let (progress_bar, progress) = create_progress_reporter(cli);
475
476    // Update graph using consolidated pipeline
477    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    // Report success with appropriate message based on update mode
495    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)]
525/// Run index status command for programmatic consumers.
526///
527/// # Arguments
528///
529/// * `cli` - CLI configuration
530/// * `path` - Directory to check for index
531///
532/// # Errors
533/// Returns an error if the index status cannot be loaded or rendered.
534pub fn run_index_status(
535    cli: &Cli,
536    path: &str,
537    _metrics_format: crate::args::MetricsFormat,
538) -> Result<()> {
539    // Redirect to graph status as legacy index is removed
540    run_graph_status(cli, path)
541}
542
543/// Run graph status command using unified graph architecture.
544///
545/// This command reports on the state of the unified graph snapshot
546/// stored in `.sqry/graph/` directory instead of the legacy `.sqry-index`.
547///
548/// # Errors
549///
550/// Returns an error if manifest cannot be loaded or output formatting fails.
551pub 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    // Output result (same format as run_index_status for compatibility)
557    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
570/// Handles the .gitignore check and modification.
571fn 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
604/// Find the root of the git repository by traversing up from the given path.
605fn 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
619/// Prints a standard warning message about .gitignore.
620fn 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, // no_compress
654            false, // enable_macro_expansion
655            &[],  // cfg_flags
656            None,  // expand_cache
657        );
658        assert!(result.is_ok());
659
660        // Check index was created
661        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        // Build initial index
679        run_index(
680            &cli,
681            tmp_cli_workspace.path().to_str().unwrap(),
682            false,
683            None,
684            false,
685            false,
686            None,
687            false, // no_compress
688            false, // enable_macro_expansion
689            &[],   // cfg_flags
690            None,  // expand_cache
691        )
692        .unwrap();
693
694        // Try to rebuild without force (should skip)
695        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, // no_compress
704            false, // enable_macro_expansion
705            &[],  // cfg_flags
706            None,  // expand_cache
707        );
708        assert!(result.is_ok());
709
710        // Rebuild with force (should succeed)
711        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, // no_compress
720            false, // enable_macro_expansion
721            &[],  // cfg_flags
722            None,  // expand_cache
723        );
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        // Create CLI with JSON flag
759        let cli = Cli::parse_from(["sqry", "--json"]);
760
761        // Should succeed even with no index
762        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        // The output would be captured via OutputStreams
773        // We can't easily test the output here, but we verified it doesn't panic
774    }
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        // Build index first
790        run_index(
791            &cli,
792            tmp_cli_workspace.path().to_str().unwrap(),
793            false,
794            None,
795            false,
796            false,
797            None,
798            false, // no_compress
799            false, // enable_macro_expansion
800            &[],   // cfg_flags
801            None,  // expand_cache
802        )
803        .unwrap();
804
805        // Check status with JSON flag
806        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        // Verify the index actually exists
818        let storage = GraphStorage::new(tmp_cli_workspace.path());
819        assert!(storage.exists());
820
821        // Load index and verify it has the symbol
822        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        // Build initial index
840        run_index(
841            &cli,
842            tmp_cli_workspace.path().to_str().unwrap(),
843            false,
844            None,
845            false,
846            false,
847            None,
848            false, // no_compress
849            false, // enable_macro_expansion
850            &[],   // cfg_flags
851            None,  // expand_cache
852        )
853        .unwrap();
854
855        // Update should succeed
856        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}