blz_cli/
lib.rs

1//! blz CLI - Fast local search for llms.txt documentation
2//!
3//! This is the main entry point for the blz command-line interface.
4//! All command implementations are organized in separate modules for
5//! better maintainability and single responsibility.
6use anyhow::{Result, anyhow};
7use blz_core::PerformanceMetrics;
8use clap::{CommandFactory, Parser};
9use colored::control as color_control;
10use tracing::{Level, warn};
11use tracing_subscriber::FmtSubscriber;
12
13use std::collections::BTreeSet;
14use std::sync::OnceLock;
15
16mod cli;
17mod commands;
18mod output;
19mod prompt;
20mod utils;
21
22use crate::commands::{AddRequest, DescriptorInput};
23
24use crate::utils::preferences::{self, CliPreferences};
25use cli::{AliasCommands, AnchorCommands, Cli, Commands, RegistryCommands};
26
27#[cfg(feature = "flamegraph")]
28use blz_core::profiling::{start_profiling, stop_profiling_and_report};
29
30/// Preprocess command-line arguments so shorthand search syntax and format aliases work.
31///
32/// When search-only flags (for example `-s`, `--limit`, `--json`) are used without explicitly
33/// writing the `search` subcommand, we inject it and normalise aliases so clap parses them
34/// correctly.
35fn preprocess_args() -> Vec<String> {
36    let raw: Vec<String> = std::env::args().collect();
37    preprocess_args_from(&raw)
38}
39
40#[derive(Clone, Copy, PartialEq, Eq)]
41enum FlagKind {
42    Switch,
43    TakesValue,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
47enum SearchFlagMatch {
48    None,
49    RequiresValue {
50        flag: &'static str,
51        attached: Option<String>,
52    },
53    NoValue(&'static str),
54    FormatAlias(&'static str),
55}
56
57fn preprocess_args_from(raw: &[String]) -> Vec<String> {
58    if raw.len() <= 1 {
59        return raw.to_vec();
60    }
61
62    let mut first_non_global_idx = raw.len();
63    let mut search_flag_found = false;
64    let mut idx = 1;
65
66    while idx < raw.len() {
67        let arg = raw[idx].as_str();
68        if arg == "--" {
69            break;
70        }
71
72        if let Some(kind) = classify_global_flag(arg) {
73            if kind == FlagKind::TakesValue && idx + 1 < raw.len() {
74                idx += 1;
75            }
76            idx += 1;
77            continue;
78        }
79
80        if first_non_global_idx == raw.len() {
81            first_non_global_idx = idx;
82        }
83
84        if matches!(classify_search_flag(arg), SearchFlagMatch::None) {
85            // keep scanning
86        } else {
87            search_flag_found = true;
88        }
89
90        idx += 1;
91    }
92
93    if first_non_global_idx == raw.len() && idx < raw.len() {
94        first_non_global_idx = idx;
95    }
96
97    // Continue scanning from the first non-global argument for additional search flags
98    for arg in raw.iter().skip(first_non_global_idx) {
99        if arg == "--" {
100            break;
101        }
102        if !matches!(classify_search_flag(arg), SearchFlagMatch::None) {
103            search_flag_found = true;
104        }
105    }
106
107    let explicit_subcommand =
108        first_non_global_idx < raw.len() && is_known_subcommand(raw[first_non_global_idx].as_str());
109    let mut result = Vec::with_capacity(raw.len() + 4);
110
111    result.push(raw[0].clone());
112
113    // Copy leading global flags so we can insert `search` after them if needed
114    for arg in raw.iter().take(first_non_global_idx).skip(1) {
115        result.push(arg.clone());
116    }
117
118    let should_inject_search = search_flag_found && !explicit_subcommand;
119    if should_inject_search {
120        result.push("search".to_string());
121    }
122
123    let mut idx = first_non_global_idx;
124    let mut encountered_sentinel = false;
125
126    while idx < raw.len() {
127        let arg = raw[idx].as_str();
128        if arg == "--" {
129            result.push(raw[idx].clone());
130            idx += 1;
131            encountered_sentinel = true;
132            break;
133        }
134
135        match classify_search_flag(arg) {
136            SearchFlagMatch::None => {
137                result.push(raw[idx].clone());
138                idx += 1;
139            },
140            SearchFlagMatch::NoValue(flag) => {
141                result.push(flag.to_string());
142                idx += 1;
143            },
144            SearchFlagMatch::FormatAlias(format) => {
145                result.push("--format".to_string());
146                result.push(format.to_string());
147                idx += 1;
148            },
149            SearchFlagMatch::RequiresValue { flag, attached } => {
150                result.push(flag.to_string());
151                if let Some(value) = attached {
152                    result.push(value);
153                    idx += 1;
154                } else if idx + 1 < raw.len() {
155                    result.push(raw[idx + 1].clone());
156                    idx += 2;
157                } else {
158                    idx += 1;
159                }
160            },
161        }
162    }
163
164    if encountered_sentinel {
165        result.extend(raw.iter().skip(idx).cloned());
166    }
167
168    result
169}
170
171fn is_known_subcommand(value: &str) -> bool {
172    known_subcommands().contains(value)
173}
174
175const RESERVED_SUBCOMMANDS: &[&str] = &["anchors", "anchor"];
176
177fn known_subcommands() -> &'static BTreeSet<String> {
178    static CACHE: OnceLock<BTreeSet<String>> = OnceLock::new();
179    CACHE.get_or_init(|| {
180        let mut names = BTreeSet::new();
181        for sub in Cli::command().get_subcommands() {
182            names.insert(sub.get_name().to_owned());
183            for alias in sub.get_all_aliases() {
184                names.insert(alias.to_owned());
185            }
186        }
187        for extra in RESERVED_SUBCOMMANDS {
188            names.insert((*extra).to_owned());
189        }
190        names
191    })
192}
193
194fn classify_global_flag(arg: &str) -> Option<FlagKind> {
195    match arg {
196        "-v" | "--verbose" | "-q" | "--quiet" | "--debug" | "--profile" | "--no-color" | "-h"
197        | "--help" | "-V" | "--version" | "--flamegraph" => Some(FlagKind::Switch),
198        "--config" | "--config-dir" | "--prompt" => Some(FlagKind::TakesValue),
199        _ if arg.starts_with("--config=")
200            || arg.starts_with("--config-dir=")
201            || arg.starts_with("--prompt=") =>
202        {
203            Some(FlagKind::Switch)
204        },
205        _ => None,
206    }
207}
208
209fn classify_search_flag(arg: &str) -> SearchFlagMatch {
210    match arg {
211        "--last" => return SearchFlagMatch::NoValue("--last"),
212        "--next" => return SearchFlagMatch::NoValue("--next"),
213        "--all" => return SearchFlagMatch::NoValue("--all"),
214        "--no-summary" => return SearchFlagMatch::NoValue("--no-summary"),
215        "--json" => return SearchFlagMatch::FormatAlias("json"),
216        "--jsonl" => return SearchFlagMatch::FormatAlias("jsonl"),
217        "--text" => return SearchFlagMatch::FormatAlias("text"),
218        _ => {},
219    }
220
221    if let Some(value) = arg.strip_prefix("--json=") {
222        if !value.is_empty() {
223            return SearchFlagMatch::FormatAlias("json");
224        }
225    }
226    if let Some(value) = arg.strip_prefix("--jsonl=") {
227        if !value.is_empty() {
228            return SearchFlagMatch::FormatAlias("jsonl");
229        }
230    }
231    if let Some(value) = arg.strip_prefix("--text=") {
232        if !value.is_empty() {
233            return SearchFlagMatch::FormatAlias("text");
234        }
235    }
236
237    for (flag, canonical) in [
238        ("--alias", "--alias"),
239        ("--source", "--source"),
240        ("--limit", "--limit"),
241        ("--page", "--page"),
242        ("--top", "--top"),
243        ("--format", "--format"),
244        ("--output", "--output"),
245        ("--show", "--show"),
246        ("--score-precision", "--score-precision"),
247        ("--snippet-lines", "--snippet-lines"),
248    ] {
249        if let Some(value) = arg.strip_prefix(&format!("{flag}=")) {
250            return SearchFlagMatch::RequiresValue {
251                flag: canonical,
252                attached: Some(value.to_string()),
253            };
254        }
255        if arg == flag {
256            return SearchFlagMatch::RequiresValue {
257                flag: canonical,
258                attached: None,
259            };
260        }
261    }
262
263    for (prefix, canonical) in [("-s", "-s"), ("-n", "-n"), ("-f", "-f"), ("-o", "-o")] {
264        if arg == prefix {
265            return SearchFlagMatch::RequiresValue {
266                flag: canonical,
267                attached: None,
268            };
269        }
270        if arg.starts_with(prefix) && arg.len() > prefix.len() {
271            return SearchFlagMatch::RequiresValue {
272                flag: canonical,
273                attached: Some(arg[prefix.len()..].to_string()),
274            };
275        }
276    }
277
278    SearchFlagMatch::None
279}
280
281/// Execute the blz CLI with the currently configured environment.
282pub async fn run() -> Result<()> {
283    // Convert Broken pipe panics into a clean exit
284    std::panic::set_hook(Box::new(|info| {
285        let msg = info.to_string();
286        if msg.contains("Broken pipe") || msg.contains("broken pipe") {
287            // Exit silently for pipeline truncation
288            std::process::exit(0);
289        }
290        // Default behavior: print to stderr
291        eprintln!("{msg}");
292    }));
293
294    // Spawn process guard as early as possible to catch orphaned processes
295    utils::process_guard::spawn_parent_exit_guard();
296
297    // Preprocess arguments to handle shorthand search with flags
298    let args = preprocess_args();
299    let mut cli = Cli::parse_from(args);
300
301    if let Some(target) = cli.prompt.clone() {
302        prompt::emit(&target, cli.command.as_ref())?;
303        return Ok(());
304    }
305
306    initialize_logging(&cli)?;
307
308    let args: Vec<String> = std::env::args().collect();
309    let mut cli_preferences = preferences::load();
310    apply_preference_defaults(&mut cli, &cli_preferences, &args);
311
312    let metrics = PerformanceMetrics::default();
313
314    #[cfg(feature = "flamegraph")]
315    let profiler_guard = start_flamegraph_if_requested(&cli);
316
317    execute_command(cli.clone(), metrics.clone(), &mut cli_preferences).await?;
318
319    #[cfg(feature = "flamegraph")]
320    stop_flamegraph_if_started(profiler_guard);
321
322    print_diagnostics(&cli, &metrics);
323
324    if let Err(err) = preferences::save(&cli_preferences) {
325        warn!("failed to persist CLI preferences: {err}");
326    }
327
328    Ok(())
329}
330
331fn initialize_logging(cli: &Cli) -> Result<()> {
332    // Base level from global flags
333    let mut level = if cli.verbose || cli.debug {
334        Level::DEBUG
335    } else if cli.quiet {
336        Level::ERROR
337    } else {
338        Level::WARN
339    };
340
341    // If the selected command is emitting machine-readable output, suppress info logs
342    // to keep stdout/stderr clean unless verbose/debug was explicitly requested.
343    let mut machine_output = false;
344    if !(cli.verbose || cli.debug) {
345        let command_format = match &cli.command {
346            Some(
347                Commands::Search { format, .. }
348                | Commands::List { format, .. }
349                | Commands::Stats { format }
350                | Commands::History { format, .. }
351                | Commands::Lookup { format, .. }
352                | Commands::Get { format, .. }
353                | Commands::Info { format, .. }
354                | Commands::Completions { format, .. },
355            ) => Some(format.resolve(cli.quiet)),
356            _ => None,
357        };
358
359        if let Some(fmt) = command_format {
360            if matches!(
361                fmt,
362                crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
363            ) {
364                level = Level::ERROR;
365                machine_output = true;
366            }
367        }
368    }
369
370    let subscriber = FmtSubscriber::builder()
371        .with_max_level(level)
372        .with_target(false)
373        .with_thread_ids(false)
374        .with_thread_names(false)
375        .with_writer(std::io::stderr)
376        .finish();
377
378    tracing::subscriber::set_global_default(subscriber)?;
379
380    // Color control: disable when requested, NO_COLOR is set, or when emitting machine output
381    let env_no_color = std::env::var("NO_COLOR").ok().is_some();
382    if cli.no_color || env_no_color || machine_output {
383        color_control::set_override(false);
384    }
385    Ok(())
386}
387
388#[cfg(feature = "flamegraph")]
389fn start_flamegraph_if_requested(cli: &Cli) -> Option<pprof::ProfilerGuard<'static>> {
390    if cli.flamegraph {
391        match start_profiling() {
392            Ok(guard) => {
393                println!("🔥 CPU profiling started - flamegraph will be generated");
394                Some(guard)
395            },
396            Err(e) => {
397                eprintln!("Failed to start profiling: {e}");
398                None
399            },
400        }
401    } else {
402        None
403    }
404}
405
406#[cfg(feature = "flamegraph")]
407fn stop_flamegraph_if_started(guard: Option<pprof::ProfilerGuard<'static>>) {
408    if let Some(guard) = guard {
409        if let Err(e) = stop_profiling_and_report(&guard) {
410            eprintln!("Failed to generate flamegraph: {e}");
411        }
412    }
413}
414
415#[allow(clippy::too_many_lines)]
416async fn execute_command(
417    cli: Cli,
418    metrics: PerformanceMetrics,
419    prefs: &mut CliPreferences,
420) -> Result<()> {
421    match cli.command {
422        Some(Commands::Instruct) => {
423            prompt::emit("__global__", Some(&Commands::Instruct))?;
424            eprintln!("`blz instruct` is deprecated. Use `blz --prompt` instead.");
425        },
426        Some(Commands::Completions {
427            shell,
428            list,
429            format,
430        }) => {
431            let resolved_format = format.resolve(cli.quiet);
432            if list {
433                commands::list_supported(resolved_format);
434            } else if let Some(shell) = shell {
435                commands::generate(shell);
436            } else {
437                commands::list_supported(resolved_format);
438            }
439        },
440        Some(Commands::Docs { format }) => handle_docs(format)?,
441        Some(Commands::Alias { command }) => handle_alias(command).await?,
442        Some(Commands::Add(args)) => {
443            if let Some(manifest) = &args.manifest {
444                commands::add_manifest(
445                    manifest,
446                    &args.only,
447                    args.yes,
448                    args.dry_run,
449                    cli.quiet,
450                    metrics,
451                )
452                .await?;
453            } else {
454                let alias = args
455                    .alias
456                    .as_deref()
457                    .ok_or_else(|| anyhow!("alias is required when manifest is not provided"))?;
458                let url = args
459                    .url
460                    .as_deref()
461                    .ok_or_else(|| anyhow!("url is required when manifest is not provided"))?;
462
463                let descriptor = DescriptorInput::from_cli_inputs(
464                    &args.aliases,
465                    args.name.as_deref(),
466                    args.description.as_deref(),
467                    args.category.as_deref(),
468                    &args.tags,
469                );
470
471                let request = AddRequest::new(
472                    alias.to_string(),
473                    url.to_string(),
474                    descriptor,
475                    args.dry_run,
476                    cli.quiet,
477                    metrics,
478                );
479
480                commands::add_source(request).await?;
481            }
482        },
483        Some(Commands::Lookup { query, format }) => {
484            commands::lookup_registry(&query, metrics, cli.quiet, format.resolve(cli.quiet))
485                .await?;
486        },
487        Some(Commands::Registry { command }) => {
488            handle_registry(command, cli.quiet, metrics).await?;
489        },
490        Some(Commands::Search {
491            query,
492            sources,
493            next,
494            last,
495            limit,
496            all,
497            page,
498            top,
499            format,
500            show,
501            no_summary,
502            score_precision,
503            snippet_lines,
504            context,
505            block,
506            max_lines,
507            no_history,
508            copy,
509        }) => {
510            let resolved_format = format.resolve(cli.quiet);
511            handle_search(
512                query,
513                sources,
514                next,
515                last,
516                limit,
517                all,
518                page,
519                top,
520                resolved_format,
521                show,
522                no_summary,
523                score_precision,
524                snippet_lines,
525                context,
526                block,
527                max_lines,
528                no_history,
529                copy,
530                metrics,
531                prefs,
532            )
533            .await?;
534        },
535        Some(Commands::History {
536            limit,
537            format,
538            clear,
539            clear_before,
540        }) => {
541            commands::show_history(
542                prefs,
543                limit,
544                format.resolve(cli.quiet),
545                clear,
546                clear_before.as_deref(),
547            )?;
548        },
549        // Config command removed in v1.0.0-beta.1 - flavor preferences eliminated
550        Some(Commands::Get {
551            alias,
552            lines,
553            context,
554            block,
555            max_lines,
556            format,
557            copy,
558        }) => {
559            // Parse flexible syntax: "alias:lines" or "alias" with separate lines arg
560            let (parsed_alias, parsed_lines) = if let Some(colon_pos) = alias.find(':') {
561                // Colon syntax: "bun:1-3"
562                let (a, l) = alias.split_at(colon_pos);
563                let lines_part = &l[1..]; // Skip the colon
564
565                // If --lines flag was also provided, prefer it over colon syntax
566                let chosen_lines = lines.map_or_else(|| lines_part.to_string(), |l| l);
567                (a.to_string(), chosen_lines)
568            } else {
569                // No colon, must have --lines flag or error
570                match lines {
571                    Some(l) => (alias.clone(), l),
572                    None => {
573                        anyhow::bail!(
574                            "Missing line specification. Use one of:\n  \
575                             blz get {alias}:1-3\n  \
576                             blz get {alias} 1-3\n  \
577                             blz get {alias} --lines 1-3"
578                        );
579                    },
580                }
581            };
582
583            commands::get_lines(
584                &parsed_alias,
585                &parsed_lines,
586                context,
587                block,
588                max_lines,
589                format.resolve(cli.quiet),
590                copy,
591            )
592            .await?;
593        },
594        Some(Commands::Info { alias, format }) => {
595            commands::execute_info(&alias, format.resolve(cli.quiet)).await?;
596        },
597        Some(Commands::List {
598            format,
599            status,
600            details,
601        }) => {
602            commands::list_sources(format.resolve(cli.quiet), status, details).await?;
603        },
604        Some(Commands::Stats { format }) => {
605            commands::show_stats(format.resolve(cli.quiet))?;
606        },
607        Some(Commands::Validate { alias, all, format }) => {
608            commands::validate_source(alias.clone(), all, format.resolve(cli.quiet)).await?;
609        },
610        Some(Commands::Doctor { format, fix }) => {
611            commands::run_doctor(format.resolve(cli.quiet), fix).await?;
612        },
613        Some(Commands::Update {
614            alias,
615            all,
616            yes: _, // Ignored - kept for CLI backward compat
617        }) => {
618            handle_update(alias, all, metrics, cli.quiet).await?;
619        },
620        Some(Commands::Remove { alias, yes }) => {
621            commands::remove_source(&alias, yes, cli.quiet).await?;
622        },
623        Some(Commands::Clear { force }) => {
624            commands::clear_cache(force)?;
625        },
626        Some(Commands::Diff { alias, since }) => {
627            commands::show_diff(&alias, since.as_deref()).await?;
628        },
629        Some(Commands::Anchor { command }) => {
630            handle_anchor(command).await?;
631        },
632        Some(Commands::Anchors {
633            alias,
634            output,
635            mappings,
636        }) => {
637            commands::show_anchors(&alias, output, mappings).await?;
638        },
639        None => commands::handle_default_search(&cli.query, metrics, None, prefs).await?,
640    }
641
642    Ok(())
643}
644
645fn handle_docs(format: crate::commands::DocsFormat) -> Result<()> {
646    // If BLZ_OUTPUT_FORMAT=json and no explicit format set (markdown default), prefer JSON
647    let effective = match (std::env::var("BLZ_OUTPUT_FORMAT").ok(), format) {
648        (Some(v), crate::commands::DocsFormat::Markdown) if v.eq_ignore_ascii_case("json") => {
649            crate::commands::DocsFormat::Json
650        },
651        _ => format,
652    };
653    commands::generate_docs(effective)
654}
655
656async fn handle_anchor(command: AnchorCommands) -> Result<()> {
657    match command {
658        AnchorCommands::List {
659            alias,
660            output,
661            mappings,
662        } => commands::show_anchors(&alias, output, mappings).await,
663        AnchorCommands::Get {
664            alias,
665            anchor,
666            context,
667            output,
668        } => commands::get_by_anchor(&alias, &anchor, context, output).await,
669    }
670}
671
672async fn handle_alias(command: AliasCommands) -> Result<()> {
673    match command {
674        AliasCommands::Add { source, alias } => {
675            commands::manage_alias(commands::AliasCommand::Add { source, alias }).await
676        },
677        AliasCommands::Rm { source, alias } => {
678            commands::manage_alias(commands::AliasCommand::Rm { source, alias }).await
679        },
680    }
681}
682
683async fn handle_registry(
684    command: RegistryCommands,
685    quiet: bool,
686    metrics: PerformanceMetrics,
687) -> Result<()> {
688    match command {
689        RegistryCommands::CreateSource {
690            name,
691            url,
692            description,
693            category,
694            tags,
695            npm,
696            github,
697            add,
698            yes,
699        } => {
700            commands::create_registry_source(
701                &name,
702                &url,
703                description,
704                category,
705                tags,
706                npm,
707                github,
708                add,
709                yes,
710                quiet,
711                metrics,
712            )
713            .await
714        },
715    }
716}
717
718#[allow(
719    clippy::too_many_arguments,
720    clippy::fn_params_excessive_bools,
721    clippy::too_many_lines
722)]
723async fn handle_search(
724    mut query: Option<String>,
725    sources: Vec<String>,
726    next: bool,
727    last: bool,
728    limit: Option<usize>,
729    all: bool,
730    page: usize,
731    top: Option<u8>,
732    format: crate::output::OutputFormat,
733    show: Vec<crate::cli::ShowComponent>,
734    no_summary: bool,
735    score_precision: Option<u8>,
736    snippet_lines: u8,
737    context: Option<usize>,
738    block: bool,
739    max_lines: Option<usize>,
740    no_history: bool,
741    copy: bool,
742    metrics: PerformanceMetrics,
743    prefs: &mut CliPreferences,
744) -> Result<()> {
745    const DEFAULT_LIMIT: usize = 50;
746    const ALL_RESULTS_LIMIT: usize = 10_000;
747    let provided_query = query.is_some();
748    let limit_was_explicit = all || limit.is_some();
749
750    if next {
751        if provided_query {
752            anyhow::bail!(
753                "Cannot combine --next with an explicit query. Remove the query to continue from the previous search."
754            );
755        }
756        if !sources.is_empty() {
757            anyhow::bail!(
758                "Cannot combine --next with --source. Omit --source to reuse the last search context."
759            );
760        }
761        if page != 1 {
762            anyhow::bail!(
763                "Cannot combine --next with --page. Use one pagination option at a time."
764            );
765        }
766        if last {
767            anyhow::bail!("Cannot combine --next with --last. Choose a single continuation flag.");
768        }
769    }
770
771    let history_entry = if next || !provided_query {
772        let mut records = utils::history_log::recent_for_active_scope(1);
773        if records.is_empty() {
774            anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
775        }
776        Some(records.remove(0))
777    } else {
778        None
779    };
780
781    let actual_query = if let Some(value) = query.take() {
782        value
783    } else if let Some(ref entry) = history_entry {
784        entry.query.clone()
785    } else {
786        anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
787    };
788
789    let actual_sources = if !sources.is_empty() {
790        sources
791    } else if let Some(entry) = history_entry.as_ref() {
792        // Parse comma-separated sources from history
793        entry.source.as_ref().map_or_else(Vec::new, |source_str| {
794            source_str
795                .split(',')
796                .map(|s| s.trim().to_string())
797                .collect()
798        })
799    } else {
800        Vec::new()
801    };
802
803    let mut actual_limit = if all {
804        ALL_RESULTS_LIMIT
805    } else {
806        limit.unwrap_or(DEFAULT_LIMIT)
807    };
808    let mut actual_page = page;
809
810    if let Some(entry) = history_entry.as_ref() {
811        if next {
812            if matches!(entry.total_pages, Some(0)) || matches!(entry.total_results, Some(0)) {
813                anyhow::bail!(
814                    "Previous search returned 0 results. Rerun with a different query or source."
815                );
816            }
817
818            let history_limit = entry.limit;
819            let history_all = history_limit.is_some_and(|value| value >= ALL_RESULTS_LIMIT);
820            if all != history_all {
821                anyhow::bail!(
822                    "Cannot use --next when changing page size or --all; rerun without --next or reuse the previous pagination flags."
823                );
824            }
825            if limit_was_explicit {
826                if let Some(requested_limit) = limit {
827                    if history_limit != Some(requested_limit) {
828                        anyhow::bail!(
829                            "Cannot use --next when changing page size; rerun without --next or reuse the previous limit."
830                        );
831                    }
832                }
833            }
834
835            if let (Some(prev_page), Some(total_pages)) = (entry.page, entry.total_pages) {
836                if prev_page >= total_pages {
837                    anyhow::bail!(
838                        "Already at the last page (page {} of {})",
839                        prev_page,
840                        total_pages
841                    );
842                }
843                actual_page = prev_page + 1;
844            } else {
845                actual_page = entry.page.unwrap_or(1) + 1;
846            }
847
848            if !limit_was_explicit {
849                actual_limit = entry.limit.unwrap_or(actual_limit);
850            }
851        } else if !provided_query && !limit_was_explicit {
852            actual_limit = entry.limit.unwrap_or(actual_limit);
853        }
854    }
855
856    commands::search(
857        &actual_query,
858        &actual_sources,
859        last,
860        actual_limit,
861        actual_page,
862        top,
863        format,
864        &show,
865        no_summary,
866        score_precision,
867        snippet_lines,
868        context,
869        block,
870        max_lines,
871        no_history,
872        copy,
873        Some(prefs),
874        metrics,
875        None,
876    )
877    .await
878}
879
880async fn handle_update(
881    alias: Option<String>,
882    all: bool,
883    metrics: PerformanceMetrics,
884    quiet: bool,
885) -> Result<()> {
886    if all || alias.is_none() {
887        commands::update_all(metrics, quiet).await
888    } else if let Some(alias) = alias {
889        commands::update_source(&alias, metrics, quiet).await
890    } else {
891        Ok(())
892    }
893}
894
895fn print_diagnostics(cli: &Cli, metrics: &PerformanceMetrics) {
896    if cli.debug {
897        metrics.print_summary();
898    }
899}
900
901fn apply_preference_defaults(cli: &mut Cli, prefs: &CliPreferences, args: &[String]) {
902    if let Some(Commands::Search {
903        show,
904        score_precision,
905        snippet_lines,
906        ..
907    }) = cli.command.as_mut()
908    {
909        let show_env = std::env::var("BLZ_SHOW").is_ok();
910        if show.is_empty() && !flag_present(args, "--show") && !show_env {
911            *show = prefs.default_show_components();
912        }
913
914        if score_precision.is_none()
915            && !flag_present(args, "--score-precision")
916            && std::env::var("BLZ_SCORE_PRECISION").is_err()
917        {
918            *score_precision = Some(prefs.default_score_precision());
919        }
920
921        if !flag_present(args, "--snippet-lines") && std::env::var("BLZ_SNIPPET_LINES").is_err() {
922            *snippet_lines = prefs.default_snippet_lines();
923        }
924    }
925}
926
927fn flag_present(args: &[String], flag: &str) -> bool {
928    let flag_eq = flag;
929    let flag_eq_with_equal = format!("{flag}=");
930    args.iter()
931        .any(|arg| arg == flag_eq || arg.starts_with(&flag_eq_with_equal))
932}
933
934#[cfg(test)]
935#[allow(
936    clippy::unwrap_used,
937    clippy::panic,
938    clippy::disallowed_macros,
939    clippy::needless_collect,
940    clippy::unnecessary_wraps,
941    clippy::deref_addrof
942)]
943mod tests {
944    use super::*;
945    use crate::utils::constants::RESERVED_KEYWORDS;
946    use crate::utils::parsing::{LineRange, parse_line_ranges};
947    use crate::utils::validation::validate_alias;
948    use std::collections::HashSet;
949
950    #[test]
951    fn test_reserved_keywords_validation() {
952        for &keyword in RESERVED_KEYWORDS {
953            let result = validate_alias(keyword);
954            assert!(
955                result.is_err(),
956                "Reserved keyword '{keyword}' should be rejected"
957            );
958
959            let error_msg = result.unwrap_err().to_string();
960            assert!(
961                error_msg.contains(keyword),
962                "Error message should contain the reserved keyword '{keyword}'"
963            );
964        }
965    }
966
967    #[test]
968    fn test_valid_aliases_allowed() {
969        let valid_aliases = ["react", "nextjs", "python", "rust", "docs", "api", "guide"];
970
971        for &alias in &valid_aliases {
972            let result = validate_alias(alias);
973            assert!(result.is_ok(), "Valid alias '{alias}' should be accepted");
974        }
975    }
976
977    #[test]
978    fn test_language_names_are_not_reserved() {
979        let language_names = [
980            "node",
981            "python",
982            "rust",
983            "go",
984            "java",
985            "javascript",
986            "typescript",
987        ];
988
989        for &lang in &language_names {
990            assert!(
991                !RESERVED_KEYWORDS.contains(&lang),
992                "Language name '{lang}' should not be reserved"
993            );
994
995            let result = validate_alias(lang);
996            assert!(
997                result.is_ok(),
998                "Language name '{lang}' should be usable as alias"
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn test_reserved_keywords_case_insensitive() {
1005        let result = validate_alias("ADD");
1006        assert!(
1007            result.is_err(),
1008            "Reserved keyword 'ADD' (uppercase) should be rejected"
1009        );
1010
1011        let result = validate_alias("Add");
1012        assert!(
1013            result.is_err(),
1014            "Reserved keyword 'Add' (mixed case) should be rejected"
1015        );
1016    }
1017
1018    #[test]
1019    fn test_line_range_parsing() {
1020        // Single line
1021        let ranges = parse_line_ranges("42").expect("Should parse single line");
1022        assert_eq!(ranges.len(), 1);
1023        assert!(matches!(ranges[0], LineRange::Single(42)));
1024
1025        // Colon range
1026        let ranges = parse_line_ranges("120:142").expect("Should parse colon range");
1027        assert_eq!(ranges.len(), 1);
1028        assert!(matches!(ranges[0], LineRange::Range(120, 142)));
1029
1030        // Dash range
1031        let ranges = parse_line_ranges("120-142").expect("Should parse dash range");
1032        assert_eq!(ranges.len(), 1);
1033        assert!(matches!(ranges[0], LineRange::Range(120, 142)));
1034
1035        // Plus syntax
1036        let ranges = parse_line_ranges("36+20").expect("Should parse plus syntax");
1037        assert_eq!(ranges.len(), 1);
1038        assert!(matches!(ranges[0], LineRange::PlusCount(36, 20)));
1039
1040        // Multiple ranges
1041        let ranges =
1042            parse_line_ranges("36:43,120-142,200+10").expect("Should parse multiple ranges");
1043        assert_eq!(ranges.len(), 3);
1044    }
1045
1046    #[test]
1047    fn test_line_range_parsing_errors() {
1048        assert!(parse_line_ranges("0").is_err(), "Line 0 should be invalid");
1049        assert!(
1050            parse_line_ranges("50:30").is_err(),
1051            "Backwards range should be invalid"
1052        );
1053        assert!(
1054            parse_line_ranges("50+0").is_err(),
1055            "Plus zero count should be invalid"
1056        );
1057        assert!(
1058            parse_line_ranges("abc").is_err(),
1059            "Invalid format should be rejected"
1060        );
1061    }
1062
1063    #[test]
1064    fn test_reserved_keywords_no_duplicates() {
1065        let mut seen = HashSet::new();
1066        for &keyword in RESERVED_KEYWORDS {
1067            assert!(
1068                seen.insert(keyword),
1069                "Reserved keyword '{keyword}' appears multiple times"
1070            );
1071        }
1072    }
1073
1074    // CLI flag combination and validation tests
1075
1076    #[test]
1077    fn test_cli_flag_combinations() {
1078        use clap::Parser;
1079
1080        // Test valid flag combinations
1081        let valid_combinations = vec![
1082            vec!["blz", "search", "rust", "--limit", "20"],
1083            vec!["blz", "search", "rust", "--alias", "node", "--limit", "10"],
1084            vec!["blz", "search", "rust", "--all"],
1085            vec!["blz", "add", "test", "https://example.com/llms.txt"],
1086            vec!["blz", "list", "--output", "json"],
1087            vec!["blz", "update", "--all"],
1088            vec!["blz", "remove", "test"],
1089            vec!["blz", "get", "test", "--lines", "1-10"],
1090            vec!["blz", "lookup", "react"],
1091        ];
1092
1093        for combination in valid_combinations {
1094            let result = Cli::try_parse_from(combination.clone());
1095            assert!(
1096                result.is_ok(),
1097                "Valid combination should parse: {combination:?}"
1098            );
1099        }
1100    }
1101
1102    #[test]
1103    fn test_cli_invalid_flag_combinations() {
1104        use clap::Parser;
1105
1106        // Test invalid flag combinations that should fail
1107        let invalid_combinations = vec![
1108            // Missing required arguments
1109            vec!["blz", "add", "alias"], // Missing URL
1110            // Note: "blz get alias" is now valid (supports colon syntax like "alias:1-3")
1111            vec!["blz", "search"], // Missing query
1112            vec!["blz", "lookup"], // Missing query
1113            // Invalid flag values
1114            vec!["blz", "search", "rust", "--limit", "-5"], // Negative limit
1115            vec!["blz", "search", "rust", "--page", "-1"],  // Negative page
1116            vec!["blz", "list", "--output", "invalid"],     // Invalid output format
1117
1118                                                            // Note: --all with --limit is actually valid (--all sets limit to 10000)
1119                                                            // Note: update with alias and --all is also valid
1120
1121                                                            // Add actual invalid combinations here as needed
1122        ];
1123
1124        for combination in invalid_combinations {
1125            let result = Cli::try_parse_from(combination.clone());
1126            assert!(
1127                result.is_err(),
1128                "Invalid combination should fail: {combination:?}"
1129            );
1130        }
1131    }
1132
1133    #[test]
1134    fn test_cli_help_generation() {
1135        use clap::Parser;
1136
1137        // Test that help can be generated without errors
1138        let help_commands = vec![
1139            vec!["blz", "--help"],
1140            vec!["blz", "search", "--help"],
1141            vec!["blz", "add", "--help"],
1142            vec!["blz", "list", "--help"],
1143            vec!["blz", "get", "--help"],
1144            vec!["blz", "update", "--help"],
1145            vec!["blz", "remove", "--help"],
1146            vec!["blz", "lookup", "--help"],
1147            vec!["blz", "completions", "--help"],
1148        ];
1149
1150        for help_cmd in help_commands {
1151            let result = Cli::try_parse_from(help_cmd.clone());
1152            // Help commands should fail parsing but with a specific help error
1153            if let Err(error) = result {
1154                assert!(
1155                    error.kind() == clap::error::ErrorKind::DisplayHelp,
1156                    "Help command should display help: {help_cmd:?}"
1157                );
1158            } else {
1159                panic!("Help command should not succeed: {help_cmd:?}");
1160            }
1161        }
1162    }
1163
1164    #[test]
1165    fn test_cli_version_flag() {
1166        use clap::Parser;
1167
1168        let version_commands = vec![vec!["blz", "--version"], vec!["blz", "-V"]];
1169
1170        for version_cmd in version_commands {
1171            let result = Cli::try_parse_from(version_cmd.clone());
1172            // Version commands should fail parsing but with a specific version error
1173            if let Err(error) = result {
1174                assert!(
1175                    error.kind() == clap::error::ErrorKind::DisplayVersion,
1176                    "Version command should display version: {version_cmd:?}"
1177                );
1178            } else {
1179                panic!("Version command should not succeed: {version_cmd:?}");
1180            }
1181        }
1182    }
1183
1184    #[test]
1185    fn test_cli_default_values() {
1186        use clap::Parser;
1187
1188        // Test that default values are set correctly
1189        let cli = Cli::try_parse_from(vec!["blz", "search", "test"]).unwrap();
1190
1191        if let Some(Commands::Search {
1192            limit,
1193            page,
1194            all,
1195            format,
1196            ..
1197        }) = cli.command
1198        {
1199            assert_eq!(
1200                limit, None,
1201                "Default limit should be unset (defaults to 50)"
1202            );
1203            assert_eq!(page, 1, "Default page should be 1");
1204            assert!(!all, "Default all should be false");
1205            // When running tests, stdout is not a terminal, so default is JSON when piped
1206            let expected_format = if is_terminal::IsTerminal::is_terminal(&std::io::stdout()) {
1207                crate::output::OutputFormat::Text
1208            } else {
1209                crate::output::OutputFormat::Json
1210            };
1211            assert_eq!(
1212                format.resolve(false),
1213                expected_format,
1214                "Default format should be JSON when piped, Text when terminal"
1215            );
1216        } else {
1217            panic!("Expected search command");
1218        }
1219    }
1220
1221    #[test]
1222    fn test_cli_flag_validation_edge_cases() {
1223        use clap::Parser;
1224
1225        // Test edge cases for numeric values
1226        let edge_cases = vec![
1227            // Very large values
1228            vec!["blz", "search", "rust", "--limit", "999999"],
1229            vec!["blz", "search", "rust", "--page", "999999"],
1230            // Boundary values
1231            vec!["blz", "search", "rust", "--limit", "1"],
1232            vec!["blz", "search", "rust", "--page", "1"],
1233            // Maximum reasonable values
1234            vec!["blz", "search", "rust", "--limit", "10000"],
1235            vec!["blz", "search", "rust", "--page", "1000"],
1236        ];
1237
1238        for edge_case in edge_cases {
1239            let result = Cli::try_parse_from(edge_case.clone());
1240
1241            // All these should parse successfully (validation happens at runtime)
1242            assert!(result.is_ok(), "Edge case should parse: {edge_case:?}");
1243        }
1244    }
1245
1246    #[test]
1247    fn test_cli_string_argument_validation() {
1248        use clap::Parser;
1249
1250        // Test various string inputs
1251        let string_cases = vec![
1252            // Normal cases
1253            vec!["blz", "search", "normal query"],
1254            vec!["blz", "add", "test-alias", "https://example.com/llms.txt"],
1255            vec!["blz", "lookup", "react"],
1256            // Edge cases
1257            vec!["blz", "search", ""], // Empty query (should be handled at runtime)
1258            vec![
1259                "blz",
1260                "search",
1261                "very-long-query-with-lots-of-words-to-test-limits",
1262            ],
1263            vec!["blz", "add", "alias", "file:///local/path.txt"], // File URL
1264            // Special characters
1265            vec!["blz", "search", "query with spaces"],
1266            vec!["blz", "search", "query-with-dashes"],
1267            vec!["blz", "search", "query_with_underscores"],
1268            vec!["blz", "add", "test", "https://example.com/path?query=value"],
1269        ];
1270
1271        for string_case in string_cases {
1272            let result = Cli::try_parse_from(string_case.clone());
1273
1274            // Most string cases should parse (validation happens at runtime)
1275            assert!(result.is_ok(), "String case should parse: {string_case:?}");
1276        }
1277    }
1278
1279    #[test]
1280    fn test_cli_output_format_validation() {
1281        use clap::Parser;
1282
1283        // Test all valid output formats
1284        let format_options = vec![
1285            ("text", crate::output::OutputFormat::Text),
1286            ("json", crate::output::OutputFormat::Json),
1287            ("jsonl", crate::output::OutputFormat::Jsonl),
1288        ];
1289
1290        for (format_str, expected_format) in &format_options {
1291            let cli = Cli::try_parse_from(vec!["blz", "list", "--format", *format_str]).unwrap();
1292
1293            if let Some(Commands::List { format, .. }) = cli.command {
1294                assert_eq!(
1295                    format.resolve(false),
1296                    *expected_format,
1297                    "Format should match: {format_str}"
1298                );
1299            } else {
1300                panic!("Expected list command");
1301            }
1302        }
1303
1304        // Alias --output should continue to work for compatibility
1305        for (format_str, expected_format) in &format_options {
1306            let cli = Cli::try_parse_from(vec!["blz", "list", "--output", *format_str]).unwrap();
1307
1308            if let Some(Commands::List { format, .. }) = cli.command {
1309                assert_eq!(
1310                    format.resolve(false),
1311                    *expected_format,
1312                    "Alias --output should map to {format_str}"
1313                );
1314            } else {
1315                panic!("Expected list command");
1316            }
1317        }
1318
1319        // Test invalid format value
1320        let result = Cli::try_parse_from(vec!["blz", "list", "--format", "invalid"]);
1321        assert!(result.is_err(), "Invalid output format should fail");
1322    }
1323
1324    fn to_string_vec(items: &[&str]) -> Vec<String> {
1325        items.iter().copied().map(str::to_owned).collect()
1326    }
1327
1328    #[test]
1329    fn preprocess_injects_search_for_shorthand_flags() {
1330        use clap::Parser;
1331
1332        let raw = to_string_vec(&["blz", "query", "-s", "react"]);
1333        let processed = preprocess_args_from(&raw);
1334
1335        let expected = to_string_vec(&["blz", "search", "query", "-s", "react"]);
1336        assert_eq!(processed, expected);
1337
1338        let cli = Cli::try_parse_from(processed).unwrap();
1339        match cli.command {
1340            Some(Commands::Search { sources, .. }) => {
1341                assert_eq!(sources, vec!["react"]);
1342            },
1343            _ => panic!("expected search command"),
1344        }
1345    }
1346
1347    #[test]
1348    fn preprocess_injects_search_for_next_flag() {
1349        let raw = to_string_vec(&["blz", "--next"]);
1350        let processed = preprocess_args_from(&raw);
1351        let expected = to_string_vec(&["blz", "search", "--next"]);
1352        assert_eq!(processed, expected);
1353    }
1354
1355    #[test]
1356    fn preprocess_preserves_global_flags_order() {
1357        let raw = to_string_vec(&["blz", "--quiet", "query", "-s", "docs"]);
1358        let processed = preprocess_args_from(&raw);
1359        let expected = to_string_vec(&["blz", "--quiet", "search", "query", "-s", "docs"]);
1360        assert_eq!(processed, expected);
1361    }
1362
1363    #[test]
1364    fn preprocess_converts_json_aliases() {
1365        use clap::Parser;
1366
1367        let raw = to_string_vec(&["blz", "query", "--json"]);
1368        let processed = preprocess_args_from(&raw);
1369        let expected = to_string_vec(&["blz", "search", "query", "--format", "json"]);
1370        assert_eq!(processed, expected);
1371
1372        let cli = Cli::try_parse_from(processed).unwrap();
1373        match cli.command {
1374            Some(Commands::Search { format, .. }) => {
1375                assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
1376            },
1377            _ => panic!("expected search command"),
1378        }
1379    }
1380
1381    #[test]
1382    fn preprocess_handles_list_subcommand_without_injection() {
1383        use clap::Parser;
1384
1385        let raw = to_string_vec(&["blz", "list", "--jsonl"]);
1386        let processed = preprocess_args_from(&raw);
1387        let expected = to_string_vec(&["blz", "list", "--format", "jsonl"]);
1388        assert_eq!(processed, expected);
1389
1390        let cli = Cli::try_parse_from(processed).unwrap();
1391        match cli.command {
1392            Some(Commands::List { format, .. }) => {
1393                assert_eq!(format.resolve(false), crate::output::OutputFormat::Jsonl);
1394            },
1395            _ => panic!("expected list command"),
1396        }
1397    }
1398
1399    #[test]
1400    fn preprocess_respects_sentinel() {
1401        let raw = to_string_vec(&["blz", "query", "--", "-s", "react"]);
1402        let processed = preprocess_args_from(&raw);
1403        assert_eq!(processed, raw);
1404    }
1405
1406    #[test]
1407    fn preprocess_does_not_inject_hidden_subcommands() {
1408        let raw = to_string_vec(&["blz", "anchors", "e2e", "-f", "json"]);
1409        let processed = preprocess_args_from(&raw);
1410        assert_eq!(processed, raw);
1411    }
1412
1413    #[test]
1414    fn preprocess_retains_hidden_subcommand_with_search_flags() {
1415        let raw = to_string_vec(&["blz", "anchors", "e2e", "--limit", "5", "--json"]);
1416        let processed = preprocess_args_from(&raw);
1417        let expected =
1418            to_string_vec(&["blz", "anchors", "e2e", "--limit", "5", "--format", "json"]);
1419        assert_eq!(
1420            processed, expected,
1421            "hidden subcommands must not trigger shorthand injection"
1422        );
1423    }
1424
1425    #[test]
1426    fn known_subcommands_cover_clap_definitions() {
1427        use clap::CommandFactory;
1428
1429        let command = Cli::command();
1430        for sub in command.get_subcommands() {
1431            let name = sub.get_name();
1432            assert!(
1433                is_known_subcommand(name),
1434                "expected known subcommand to include {name}"
1435            );
1436
1437            for alias in sub.get_all_aliases() {
1438                assert!(
1439                    is_known_subcommand(alias),
1440                    "expected alias {alias} to be recognized"
1441                );
1442            }
1443        }
1444    }
1445
1446    #[test]
1447    fn test_cli_boolean_flags() {
1448        use clap::Parser;
1449
1450        // Test boolean flags
1451        let bool_flag_cases = vec![
1452            // Verbose flag
1453            (
1454                vec!["blz", "--verbose", "search", "test"],
1455                true,
1456                false,
1457                false,
1458            ),
1459            (vec!["blz", "-v", "search", "test"], true, false, false),
1460            // Debug flag
1461            (vec!["blz", "--debug", "search", "test"], false, true, false),
1462            // Profile flag
1463            (
1464                vec!["blz", "--profile", "search", "test"],
1465                false,
1466                false,
1467                true,
1468            ),
1469            // Multiple flags
1470            (
1471                vec!["blz", "--verbose", "--debug", "--profile", "search", "test"],
1472                true,
1473                true,
1474                true,
1475            ),
1476            (
1477                vec!["blz", "-v", "--debug", "--profile", "search", "test"],
1478                true,
1479                true,
1480                true,
1481            ),
1482        ];
1483
1484        for (args, expected_verbose, expected_debug, expected_profile) in bool_flag_cases {
1485            let cli = Cli::try_parse_from(args.clone()).unwrap();
1486
1487            assert_eq!(
1488                cli.verbose, expected_verbose,
1489                "Verbose flag mismatch for: {args:?}"
1490            );
1491            assert_eq!(
1492                cli.debug, expected_debug,
1493                "Debug flag mismatch for: {args:?}"
1494            );
1495            assert_eq!(
1496                cli.profile, expected_profile,
1497                "Profile flag mismatch for: {args:?}"
1498            );
1499        }
1500    }
1501
1502    #[test]
1503    fn test_cli_subcommand_specific_flags() {
1504        use clap::Parser;
1505
1506        // Test search-specific flags
1507        let cli = Cli::try_parse_from(vec![
1508            "blz", "search", "rust", "--alias", "node", "--limit", "20", "--page", "2", "--top",
1509            "10", "--format", "json",
1510        ])
1511        .unwrap();
1512
1513        if let Some(Commands::Search {
1514            sources,
1515            limit,
1516            page,
1517            top,
1518            format,
1519            ..
1520        }) = cli.command
1521        {
1522            assert_eq!(sources, vec!["node"]);
1523            assert_eq!(limit, Some(20));
1524            assert_eq!(page, 2);
1525            assert!(top.is_some());
1526            assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
1527        } else {
1528            panic!("Expected search command");
1529        }
1530
1531        // Test add-specific flags
1532        let cli = Cli::try_parse_from(vec![
1533            "blz",
1534            "add",
1535            "test",
1536            "https://example.com/llms.txt",
1537            "--yes",
1538        ])
1539        .unwrap();
1540
1541        if let Some(Commands::Add(args)) = cli.command {
1542            assert_eq!(args.alias.as_deref(), Some("test"));
1543            assert_eq!(args.url.as_deref(), Some("https://example.com/llms.txt"));
1544            assert!(args.aliases.is_empty());
1545            assert!(args.tags.is_empty());
1546            assert!(args.name.is_none());
1547            assert!(args.description.is_none());
1548            assert!(args.category.is_none());
1549            assert!(args.yes);
1550            assert!(!args.dry_run);
1551            assert!(args.manifest.is_none());
1552        } else {
1553            panic!("Expected add command");
1554        }
1555
1556        // Test get-specific flags
1557        let cli = Cli::try_parse_from(vec![
1558            "blz",
1559            "get",
1560            "test",
1561            "--lines",
1562            "1-10",
1563            "--context",
1564            "5",
1565        ])
1566        .unwrap();
1567
1568        if let Some(Commands::Get {
1569            alias,
1570            lines,
1571            context,
1572            block,
1573            max_lines,
1574            format,
1575            copy: _,
1576        }) = cli.command
1577        {
1578            assert_eq!(alias, "test");
1579            assert_eq!(lines, Some("1-10".to_string()));
1580            assert_eq!(context, Some(5));
1581            assert!(!block);
1582            assert_eq!(max_lines, None);
1583            let _ = format; // ignore
1584        } else {
1585            panic!("Expected get command");
1586        }
1587    }
1588
1589    #[test]
1590    fn test_cli_special_argument_parsing() {
1591        use clap::Parser;
1592
1593        // Test line range parsing edge cases
1594        let line_range_cases = vec![
1595            "1",
1596            "1-10",
1597            "1:10",
1598            "1+5",
1599            "10,20,30",
1600            "1-5,10-15,20+5",
1601            "100:200",
1602        ];
1603
1604        for line_range in line_range_cases {
1605            let result = Cli::try_parse_from(vec!["blz", "get", "test", "--lines", line_range]);
1606            assert!(result.is_ok(), "Line range should parse: {line_range}");
1607        }
1608
1609        // Test URL parsing for add command
1610        let url_cases = vec![
1611            "https://example.com/llms.txt",
1612            "http://localhost:3000/llms.txt",
1613            "https://api.example.com/v1/docs/llms.txt",
1614            "https://example.com/llms.txt?version=1",
1615            "https://raw.githubusercontent.com/user/repo/main/llms.txt",
1616        ];
1617
1618        for url in url_cases {
1619            let result = Cli::try_parse_from(vec!["blz", "add", "test", url]);
1620            assert!(result.is_ok(), "URL should parse: {url}");
1621        }
1622    }
1623
1624    #[test]
1625    fn test_cli_error_messages() {
1626        use clap::Parser;
1627
1628        // Test that error messages are informative
1629        let error_cases = vec![
1630            // Missing required arguments
1631            (vec!["blz", "add"], "missing"),
1632            (vec!["blz", "search"], "required"),
1633            // Note: "blz get alias" is now valid (supports colon syntax like "alias:1-3")
1634            // Invalid values
1635            (vec!["blz", "list", "--format", "invalid"], "invalid"),
1636        ];
1637
1638        for (args, expected_error_content) in error_cases {
1639            let result = Cli::try_parse_from(args.clone());
1640
1641            assert!(result.is_err(), "Should error for: {args:?}");
1642
1643            let error_msg = format!("{:?}", result.unwrap_err()).to_lowercase();
1644            assert!(
1645                error_msg.contains(expected_error_content),
1646                "Error message should contain '{expected_error_content}' for args {args:?}, got: {error_msg}"
1647            );
1648        }
1649    }
1650
1651    #[test]
1652    fn test_cli_argument_order_independence() {
1653        use clap::Parser;
1654
1655        // Test that global flags can appear in different positions
1656        let equivalent_commands = vec![
1657            vec![
1658                vec!["blz", "--verbose", "search", "rust"],
1659                vec!["blz", "search", "--verbose", "rust"],
1660            ],
1661            vec![
1662                vec!["blz", "--debug", "--profile", "search", "rust"],
1663                vec!["blz", "search", "rust", "--debug", "--profile"],
1664                vec!["blz", "--debug", "search", "--profile", "rust"],
1665            ],
1666        ];
1667
1668        for command_group in equivalent_commands {
1669            let mut parsed_commands = Vec::new();
1670
1671            for args in &command_group {
1672                let result = Cli::try_parse_from(args.clone());
1673                assert!(result.is_ok(), "Should parse: {args:?}");
1674                parsed_commands.push(result.unwrap());
1675            }
1676
1677            // All commands in the group should parse to equivalent structures
1678            let first = &parsed_commands[0];
1679            for other in &parsed_commands[1..] {
1680                assert_eq!(first.verbose, other.verbose, "Verbose flags should match");
1681                assert_eq!(first.debug, other.debug, "Debug flags should match");
1682                assert_eq!(first.profile, other.profile, "Profile flags should match");
1683            }
1684        }
1685    }
1686
1687    // Shell completion generation and accuracy tests
1688
1689    #[test]
1690    fn test_completion_generation_for_all_shells() {
1691        use clap_complete::Shell;
1692
1693        // Test that completions can be generated for all supported shells without panicking
1694        let shells = vec![
1695            Shell::Bash,
1696            Shell::Zsh,
1697            Shell::Fish,
1698            Shell::PowerShell,
1699            Shell::Elvish,
1700        ];
1701
1702        for shell in shells {
1703            // Should not panic - this is the main test
1704            let result = std::panic::catch_unwind(|| {
1705                crate::commands::generate(shell);
1706            });
1707
1708            assert!(
1709                result.is_ok(),
1710                "Completion generation should not panic for {shell:?}"
1711            );
1712        }
1713    }
1714
1715    #[test]
1716    fn test_completion_cli_structure_contains_all_subcommands() {
1717        use crate::cli::Cli;
1718        use clap::CommandFactory;
1719
1720        // Test that our CLI structure has all expected subcommands (which completions will include)
1721        let cmd = Cli::command();
1722
1723        let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
1724
1725        // Verify all main subcommands are present in CLI structure
1726        let expected_commands = vec![
1727            "search",
1728            "add",
1729            "list",
1730            "get",
1731            "update",
1732            "remove",
1733            "lookup",
1734            "diff",
1735            "completions",
1736        ];
1737
1738        for expected_command in expected_commands {
1739            assert!(
1740                subcommands.contains(&expected_command),
1741                "CLI should have '{expected_command}' subcommand for completions"
1742            );
1743        }
1744
1745        // Verify command aliases are configured in CLI structure
1746        let list_cmd = cmd
1747            .get_subcommands()
1748            .find(|sub| sub.get_name() == "list")
1749            .expect("Should have list command");
1750
1751        let aliases: Vec<&str> = list_cmd.get_all_aliases().collect();
1752        assert!(
1753            aliases.contains(&"sources"),
1754            "List command should have 'sources' alias"
1755        );
1756
1757        let remove_cmd = cmd
1758            .get_subcommands()
1759            .find(|sub| sub.get_name() == "remove")
1760            .expect("Should have remove command");
1761
1762        let remove_aliases: Vec<&str> = remove_cmd.get_all_aliases().collect();
1763        assert!(
1764            remove_aliases.contains(&"rm"),
1765            "Remove command should have 'rm' alias"
1766        );
1767        assert!(
1768            remove_aliases.contains(&"delete"),
1769            "Remove command should have 'delete' alias"
1770        );
1771    }
1772
1773    #[test]
1774    fn test_completion_cli_structure_contains_global_flags() {
1775        use crate::cli::Cli;
1776        use clap::CommandFactory;
1777
1778        // Test that our CLI structure has all expected global flags (which completions will include)
1779        let cmd = Cli::command();
1780
1781        let global_args: Vec<&str> = cmd
1782            .get_arguments()
1783            .filter(|arg| arg.is_global_set())
1784            .map(|arg| arg.get_id().as_str())
1785            .collect();
1786
1787        // Verify global flags are present in CLI structure
1788        let expected_global_flags = vec!["verbose", "debug", "profile"];
1789
1790        for expected_flag in expected_global_flags {
1791            assert!(
1792                global_args.contains(&expected_flag),
1793                "CLI should have global flag '{expected_flag}' for completions"
1794            );
1795        }
1796
1797        // Verify verbose flag properties
1798        let verbose_arg = cmd
1799            .get_arguments()
1800            .find(|arg| arg.get_id().as_str() == "verbose")
1801            .expect("Should have verbose argument");
1802
1803        assert!(
1804            verbose_arg.get_long().is_some(),
1805            "Verbose should have long form --verbose"
1806        );
1807        assert_eq!(
1808            verbose_arg.get_long(),
1809            Some("verbose"),
1810            "Verbose long form should be --verbose"
1811        );
1812        assert!(verbose_arg.is_global_set(), "Verbose should be global");
1813    }
1814
1815    #[test]
1816    fn test_completion_cli_structure_contains_subcommand_flags() {
1817        use crate::cli::Cli;
1818        use clap::CommandFactory;
1819
1820        let cmd = Cli::command();
1821
1822        // Check search command flags
1823        let search_cmd = cmd
1824            .get_subcommands()
1825            .find(|sub| sub.get_name() == "search")
1826            .expect("Should have search command");
1827
1828        let search_args: Vec<&str> = search_cmd
1829            .get_arguments()
1830            .map(|arg| arg.get_id().as_str())
1831            .collect();
1832
1833        let expected_search_flags = vec![
1834            "sources",
1835            "limit",
1836            "all",
1837            "page",
1838            "top",
1839            "format",
1840            "show",
1841            "no_summary",
1842        ];
1843        for expected_flag in expected_search_flags {
1844            assert!(
1845                search_args.contains(&expected_flag),
1846                "Search command should have '{expected_flag}' flag for completions"
1847            );
1848        }
1849
1850        // Check add command flags
1851        let add_cmd = cmd
1852            .get_subcommands()
1853            .find(|sub| sub.get_name() == "add")
1854            .expect("Should have add command");
1855
1856        let add_args: Vec<&str> = add_cmd
1857            .get_arguments()
1858            .map(|arg| arg.get_id().as_str())
1859            .collect();
1860
1861        assert!(
1862            add_args.contains(&"yes"),
1863            "Add command should have 'yes' flag"
1864        );
1865
1866        // Check get command flags
1867        let get_cmd = cmd
1868            .get_subcommands()
1869            .find(|sub| sub.get_name() == "get")
1870            .expect("Should have get command");
1871
1872        let get_args: Vec<&str> = get_cmd
1873            .get_arguments()
1874            .map(|arg| arg.get_id().as_str())
1875            .collect();
1876
1877        assert!(
1878            get_args.contains(&"lines"),
1879            "Get command should have 'lines' flag"
1880        );
1881        assert!(
1882            get_args.contains(&"context"),
1883            "Get command should have 'context' flag"
1884        );
1885
1886        // Check that output argument has value_enum (which provides completion values)
1887        let format_arg = search_cmd
1888            .get_arguments()
1889            .find(|arg| arg.get_id().as_str() == "format")
1890            .expect("Search should have format argument");
1891
1892        assert!(
1893            !format_arg.get_possible_values().is_empty(),
1894            "Format argument should have possible values for completion"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_completion_generation_consistency() {
1900        use clap_complete::Shell;
1901
1902        // Generate completions multiple times to ensure consistency (no panics)
1903        let shells_to_test = vec![Shell::Bash, Shell::Zsh, Shell::Fish];
1904
1905        for shell in shells_to_test {
1906            // Should not panic on multiple generations
1907            for _ in 0..3 {
1908                let result = std::panic::catch_unwind(|| {
1909                    crate::commands::generate(shell);
1910                });
1911                assert!(
1912                    result.is_ok(),
1913                    "Completion generation should be consistent for {shell:?}"
1914                );
1915            }
1916        }
1917    }
1918
1919    #[test]
1920    fn test_completion_command_parsing() {
1921        use clap::Parser;
1922
1923        // Test that the completions command parses correctly for all shells
1924        let shell_completions = vec![
1925            vec!["blz", "completions", "bash"],
1926            vec!["blz", "completions", "zsh"],
1927            vec!["blz", "completions", "fish"],
1928            vec!["blz", "completions", "powershell"],
1929            vec!["blz", "completions", "elvish"],
1930        ];
1931
1932        for args in shell_completions {
1933            let result = Cli::try_parse_from(args.clone());
1934            assert!(result.is_ok(), "Completions command should parse: {args:?}");
1935
1936            if let Ok(cli) = result {
1937                match cli.command {
1938                    Some(Commands::Completions { shell: _, .. }) => {
1939                        // Expected - completions command parsed successfully
1940                    },
1941                    other => {
1942                        panic!("Expected Completions command, got: {other:?} for args: {args:?}");
1943                    },
1944                }
1945            }
1946        }
1947    }
1948
1949    #[test]
1950    fn test_completion_invalid_shell_handling() {
1951        use clap::Parser;
1952
1953        // Test that invalid shell names are rejected
1954        let invalid_shells = vec![
1955            vec!["blz", "completions", "invalid"],
1956            vec!["blz", "completions", "cmd"],
1957            vec!["blz", "completions", ""],
1958            vec!["blz", "completions", "bash_typo"],
1959            vec!["blz", "completions", "ZSH"], // Wrong case
1960        ];
1961
1962        for args in invalid_shells {
1963            let result = Cli::try_parse_from(args.clone());
1964            assert!(
1965                result.is_err(),
1966                "Invalid shell should be rejected: {args:?}"
1967            );
1968        }
1969    }
1970
1971    #[test]
1972    fn test_completion_help_generation() {
1973        use clap::Parser;
1974
1975        // Test that help for completions command works
1976        let help_commands = vec![
1977            vec!["blz", "completions", "--help"],
1978            vec!["blz", "completions", "-h"],
1979        ];
1980
1981        for help_cmd in help_commands {
1982            let result = Cli::try_parse_from(help_cmd.clone());
1983
1984            if let Err(error) = result {
1985                assert_eq!(
1986                    error.kind(),
1987                    clap::error::ErrorKind::DisplayHelp,
1988                    "Completions help should display help: {help_cmd:?}"
1989                );
1990
1991                let help_text = error.to_string();
1992                assert!(
1993                    help_text.contains("completions"),
1994                    "Help text should mention completions"
1995                );
1996                assert!(
1997                    help_text.contains("shell") || help_text.contains("Shell"),
1998                    "Help text should mention shell parameter"
1999                );
2000            } else {
2001                panic!("Help command should not succeed: {help_cmd:?}");
2002            }
2003        }
2004    }
2005
2006    #[test]
2007    fn test_completion_integration_with_clap() {
2008        use crate::cli::Cli;
2009        use clap::CommandFactory;
2010
2011        // Test that our CLI structure is compatible with clap_complete
2012        let cmd = Cli::command();
2013
2014        // Verify basic command structure that completion depends on
2015        assert_eq!(cmd.get_name(), "blz", "Command name should be 'blz'");
2016
2017        // Verify subcommands are properly configured
2018        let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
2019
2020        let expected_subcommands = vec![
2021            "completions",
2022            "add",
2023            "lookup",
2024            "search",
2025            "get",
2026            "list",
2027            "update",
2028            "remove",
2029            "diff",
2030        ];
2031
2032        for expected in expected_subcommands {
2033            assert!(
2034                subcommands.contains(&expected),
2035                "Command should have subcommand '{expected}', found: {subcommands:?}"
2036            );
2037        }
2038
2039        // Verify completions subcommand has proper shell argument
2040        let completions_cmd = cmd
2041            .get_subcommands()
2042            .find(|sub| sub.get_name() == "completions")
2043            .expect("Should have completions subcommand");
2044
2045        let shell_arg = completions_cmd
2046            .get_arguments()
2047            .find(|arg| arg.get_id() == "shell")
2048            .expect("Completions should have shell argument");
2049
2050        assert!(
2051            shell_arg.is_positional(),
2052            "Shell argument should be positional"
2053        );
2054    }
2055
2056    #[test]
2057    fn test_multi_source_search_parsing() {
2058        use clap::Parser;
2059
2060        // Test comma-separated sources
2061        let cli = Cli::try_parse_from(vec![
2062            "blz",
2063            "search",
2064            "hooks",
2065            "--source",
2066            "react,vue,svelte",
2067        ])
2068        .unwrap();
2069
2070        if let Some(Commands::Search { sources, .. }) = cli.command {
2071            assert_eq!(sources, vec!["react", "vue", "svelte"]);
2072        } else {
2073            panic!("Expected search command");
2074        }
2075    }
2076
2077    #[test]
2078    fn test_single_source_search_parsing() {
2079        use clap::Parser;
2080
2081        // Test single source (backward compatibility)
2082        let cli = Cli::try_parse_from(vec!["blz", "search", "hooks", "--source", "react"]).unwrap();
2083
2084        if let Some(Commands::Search { sources, .. }) = cli.command {
2085            assert_eq!(sources, vec!["react"]);
2086        } else {
2087            panic!("Expected search command");
2088        }
2089    }
2090
2091    #[test]
2092    fn test_no_source_search_parsing() {
2093        use clap::Parser;
2094
2095        // Test no source (searches all)
2096        let cli = Cli::try_parse_from(vec!["blz", "search", "hooks"]).unwrap();
2097
2098        if let Some(Commands::Search { sources, .. }) = cli.command {
2099            assert!(sources.is_empty());
2100        } else {
2101            panic!("Expected search command");
2102        }
2103    }
2104
2105    #[test]
2106    fn test_multi_source_shorthand_parsing() {
2107        use clap::Parser;
2108
2109        // Test with -s shorthand
2110        let cli = Cli::try_parse_from(vec!["blz", "search", "api", "-s", "bun,node,deno"]).unwrap();
2111
2112        if let Some(Commands::Search { sources, .. }) = cli.command {
2113            assert_eq!(sources, vec!["bun", "node", "deno"]);
2114        } else {
2115            panic!("Expected search command");
2116        }
2117    }
2118}