1use anyhow::{Result, anyhow};
7use blz_core::{PerformanceMetrics, Storage};
8use clap::{CommandFactory, Parser};
9use colored::Colorize;
10use colored::control as color_control;
11use tracing::{Level, warn};
12use tracing_subscriber::FmtSubscriber;
13
14use std::collections::BTreeSet;
15use std::sync::{Arc, OnceLock};
16
17pub mod args;
18mod cli;
19mod commands;
20pub mod config;
21pub mod error;
22mod output;
23mod prompt;
24mod utils;
25
26use crate::commands::{
27 AddRequest, BUNDLED_ALIAS, DescriptorInput, DocsSyncStatus, RequestSpec, print_full_content,
28 print_overview, sync_bundled_docs,
29};
30
31use crate::utils::preferences::{self, CliPreferences};
32use cli::{
33 AliasCommands, AnchorCommands, ClaudePluginCommands, Cli, Commands, DocsCommands,
34 DocsSearchArgs, RegistryCommands,
35};
36
37#[cfg(feature = "flamegraph")]
38use blz_core::profiling::{start_profiling, stop_profiling_and_report};
39
40fn preprocess_args() -> Vec<String> {
46 let raw: Vec<String> = std::env::args().collect();
47 preprocess_args_from(&raw)
48}
49
50#[derive(Clone, Copy, PartialEq, Eq)]
51enum FlagKind {
52 Switch,
53 TakesValue,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
57enum SearchFlagMatch {
58 None,
59 RequiresValue {
60 flag: &'static str,
61 attached: Option<String>,
62 },
63 OptionalValue {
64 flag: &'static str,
65 attached: Option<String>,
66 },
67 NoValue(&'static str),
68 FormatAlias(&'static str),
69}
70
71fn preprocess_args_from(raw: &[String]) -> Vec<String> {
72 if raw.len() <= 1 {
73 return raw.to_vec();
74 }
75
76 let (first_non_global_idx, search_flag_found) = scan_args_for_search_flags(raw);
77 let explicit_subcommand =
78 first_non_global_idx < raw.len() && is_known_subcommand(raw[first_non_global_idx].as_str());
79 let should_inject_search = search_flag_found && !explicit_subcommand;
80
81 build_preprocessed_result(raw, first_non_global_idx, should_inject_search)
82}
83
84fn scan_args_for_search_flags(raw: &[String]) -> (usize, bool) {
88 let mut first_non_global_idx = raw.len();
89 let mut search_flag_found = false;
90 let mut idx = 1;
91
92 while idx < raw.len() {
94 let arg = raw[idx].as_str();
95 if arg == "--" {
96 break;
97 }
98
99 if let Some(kind) = classify_global_flag(arg) {
100 if kind == FlagKind::TakesValue && idx + 1 < raw.len() {
101 idx += 1;
102 }
103 idx += 1;
104 continue;
105 }
106
107 if first_non_global_idx == raw.len() {
108 first_non_global_idx = idx;
109 }
110
111 if !matches!(classify_search_flag(arg), SearchFlagMatch::None) {
112 search_flag_found = true;
113 }
114
115 idx += 1;
116 }
117
118 if first_non_global_idx == raw.len() && idx < raw.len() {
119 first_non_global_idx = idx;
120 }
121
122 for arg in raw.iter().skip(first_non_global_idx) {
124 if arg == "--" {
125 break;
126 }
127 if !matches!(classify_search_flag(arg), SearchFlagMatch::None) {
128 search_flag_found = true;
129 }
130 }
131
132 (first_non_global_idx, search_flag_found)
133}
134
135fn build_preprocessed_result(
137 raw: &[String],
138 first_non_global_idx: usize,
139 should_inject_search: bool,
140) -> Vec<String> {
141 let mut result = Vec::with_capacity(raw.len() + 4);
142
143 result.push(raw[0].clone());
144
145 for arg in raw.iter().take(first_non_global_idx).skip(1) {
147 result.push(arg.clone());
148 }
149
150 if should_inject_search {
151 result.push("search".to_string());
152 }
153
154 let (processed_idx, encountered_sentinel) =
155 normalize_remaining_args(raw, first_non_global_idx, should_inject_search, &mut result);
156
157 if encountered_sentinel {
158 result.extend(raw.iter().skip(processed_idx).cloned());
159 }
160
161 result
162}
163
164fn normalize_remaining_args(
168 raw: &[String],
169 start_idx: usize,
170 should_inject_search: bool,
171 result: &mut Vec<String>,
172) -> (usize, bool) {
173 let mut idx = start_idx;
174
175 while idx < raw.len() {
176 let arg = raw[idx].as_str();
177 if arg == "--" {
178 result.push(raw[idx].clone());
179 return (idx + 1, true);
180 }
181
182 idx = process_single_arg(raw, idx, should_inject_search, result);
183 }
184
185 (idx, false)
186}
187
188fn process_single_arg(
192 raw: &[String],
193 idx: usize,
194 should_inject_search: bool,
195 result: &mut Vec<String>,
196) -> usize {
197 let arg = raw[idx].as_str();
198
199 match classify_search_flag(arg) {
200 SearchFlagMatch::None => {
201 result.push(raw[idx].clone());
202 idx + 1
203 },
204 SearchFlagMatch::NoValue(flag) => {
205 result.push(flag.to_string());
206 idx + 1
207 },
208 SearchFlagMatch::FormatAlias(format) => {
209 if should_inject_search {
211 result.push("--format".to_string());
212 result.push(format.to_string());
213 } else {
214 result.push(raw[idx].clone());
215 }
216 idx + 1
217 },
218 SearchFlagMatch::RequiresValue { flag, attached } => {
219 result.push(flag.to_string());
220 if let Some(value) = attached {
221 result.push(value);
222 idx + 1
223 } else if idx + 1 < raw.len() {
224 result.push(raw[idx + 1].clone());
225 idx + 2
226 } else {
227 idx + 1
228 }
229 },
230 SearchFlagMatch::OptionalValue { flag, attached } => {
231 result.push(flag.to_string());
232 if let Some(value) = attached {
233 result.push(value);
234 idx + 1
235 } else if idx + 1 < raw.len() && !raw[idx + 1].starts_with('-') {
236 result.push(raw[idx + 1].clone());
238 idx + 2
239 } else {
240 idx + 1
242 }
243 },
244 }
245}
246
247fn is_known_subcommand(value: &str) -> bool {
248 known_subcommands().contains(value)
249}
250
251const RESERVED_SUBCOMMANDS: &[&str] = &["toc", "anchors", "anchor"];
252
253fn known_subcommands() -> &'static BTreeSet<String> {
254 static CACHE: OnceLock<BTreeSet<String>> = OnceLock::new();
255 CACHE.get_or_init(|| {
256 let mut names = BTreeSet::new();
257 for sub in Cli::command().get_subcommands() {
258 names.insert(sub.get_name().to_owned());
259 for alias in sub.get_all_aliases() {
260 names.insert(alias.to_owned());
261 }
262 }
263 for extra in RESERVED_SUBCOMMANDS {
264 names.insert((*extra).to_owned());
265 }
266 names
267 })
268}
269
270fn classify_global_flag(arg: &str) -> Option<FlagKind> {
271 match arg {
272 "-v" | "--verbose" | "-q" | "--quiet" | "--debug" | "--profile" | "--no-color" | "-h"
273 | "--help" | "-V" | "--version" | "--flamegraph" => Some(FlagKind::Switch),
274 "--config" | "--config-dir" | "--prompt" => Some(FlagKind::TakesValue),
275 _ if arg.starts_with("--config=")
276 || arg.starts_with("--config-dir=")
277 || arg.starts_with("--prompt=") =>
278 {
279 Some(FlagKind::Switch)
280 },
281 _ => None,
282 }
283}
284
285fn match_flag_with_value(
287 arg: &str,
288 flags: &[(&'static str, &'static str)],
289 optional: bool,
290) -> SearchFlagMatch {
291 for (flag, canonical) in flags {
292 if let Some(value) = arg.strip_prefix(&format!("{flag}=")) {
293 return if optional {
294 SearchFlagMatch::OptionalValue {
295 flag: canonical,
296 attached: Some(value.to_string()),
297 }
298 } else {
299 SearchFlagMatch::RequiresValue {
300 flag: canonical,
301 attached: Some(value.to_string()),
302 }
303 };
304 }
305 if arg == *flag {
306 return if optional {
307 SearchFlagMatch::OptionalValue {
308 flag: canonical,
309 attached: None,
310 }
311 } else {
312 SearchFlagMatch::RequiresValue {
313 flag: canonical,
314 attached: None,
315 }
316 };
317 }
318 }
319 SearchFlagMatch::None
320}
321
322fn classify_search_flag(arg: &str) -> SearchFlagMatch {
337 if let result @ (SearchFlagMatch::NoValue(_) | SearchFlagMatch::FormatAlias(_)) =
339 classify_simple_search_flag(arg)
340 {
341 return result;
342 }
343
344 if let Some(result) = classify_format_alias_with_value(arg) {
346 return result;
347 }
348
349 let result = classify_long_search_flags(arg);
351 if !matches!(result, SearchFlagMatch::None) {
352 return result;
353 }
354
355 classify_short_search_flags(arg)
357}
358
359fn classify_simple_search_flag(arg: &str) -> SearchFlagMatch {
361 match arg {
362 "--last" => SearchFlagMatch::NoValue("--last"),
363 "--next" => SearchFlagMatch::NoValue("--next"),
364 "--previous" => SearchFlagMatch::NoValue("--previous"),
365 "--all" => SearchFlagMatch::NoValue("--all"),
366 "--no-summary" => SearchFlagMatch::NoValue("--no-summary"),
367 "--block" => SearchFlagMatch::NoValue("--block"),
368 "--no-history" => SearchFlagMatch::NoValue("--no-history"),
369 "--copy" => SearchFlagMatch::NoValue("--copy"),
370 "--json" => SearchFlagMatch::FormatAlias("json"),
371 "--jsonl" => SearchFlagMatch::FormatAlias("jsonl"),
372 "--text" => SearchFlagMatch::FormatAlias("text"),
373 _ => SearchFlagMatch::None,
374 }
375}
376
377fn classify_format_alias_with_value(arg: &str) -> Option<SearchFlagMatch> {
379 const FORMAT_PREFIXES: &[(&str, &str)] = &[
380 ("--json=", "json"),
381 ("--jsonl=", "jsonl"),
382 ("--text=", "text"),
383 ];
384
385 for (prefix, format) in FORMAT_PREFIXES {
386 if let Some(value) = arg.strip_prefix(prefix) {
387 if !value.is_empty() {
388 return Some(SearchFlagMatch::FormatAlias(format));
389 }
390 }
391 }
392 None
393}
394
395const LONG_CONTEXT_FLAGS: &[(&str, &str)] = &[
397 ("--context", "--context"),
398 ("--after-context", "--after-context"),
399 ("--before-context", "--before-context"),
400];
401
402const LONG_REQUIRED_VALUE_FLAGS: &[(&str, &str)] = &[
403 ("--max-lines", "--max-lines"),
404 ("--max-chars", "--max-chars"),
405];
406
407const LONG_SEARCH_FLAGS: &[(&str, &str)] = &[
408 ("--alias", "--alias"),
409 ("--source", "--source"),
410 ("--limit", "--limit"),
411 ("--page", "--page"),
412 ("--top", "--top"),
413 ("--format", "--format"),
414 ("--output", "--output"),
415 ("--show", "--show"),
416 ("--score-precision", "--score-precision"),
417 ("--snippet-lines", "--snippet-lines"),
418];
419
420const SHORT_OPTIONAL_FLAGS: &[(&str, &str)] =
422 &[("-C", "-C"), ("-c", "-c"), ("-A", "-A"), ("-B", "-B")];
423
424const SHORT_REQUIRED_FLAGS: &[(&str, &str)] =
425 &[("-s", "-s"), ("-n", "-n"), ("-f", "-f"), ("-o", "-o")];
426
427fn classify_long_search_flags(arg: &str) -> SearchFlagMatch {
429 let result = match_flag_with_value(arg, LONG_CONTEXT_FLAGS, true);
431 if !matches!(result, SearchFlagMatch::None) {
432 return result;
433 }
434
435 let result = match_flag_with_value(arg, LONG_REQUIRED_VALUE_FLAGS, false);
437 if !matches!(result, SearchFlagMatch::None) {
438 return result;
439 }
440
441 match_flag_with_value(arg, LONG_SEARCH_FLAGS, false)
443}
444
445fn classify_short_search_flags(arg: &str) -> SearchFlagMatch {
447 if let Some(result) = match_short_flag(arg, SHORT_OPTIONAL_FLAGS, true) {
449 return result;
450 }
451
452 if let Some(result) = match_short_flag(arg, SHORT_REQUIRED_FLAGS, false) {
454 return result;
455 }
456
457 SearchFlagMatch::None
458}
459
460fn match_short_flag(
462 arg: &str,
463 flags: &[(&'static str, &'static str)],
464 optional: bool,
465) -> Option<SearchFlagMatch> {
466 for (prefix, canonical) in flags {
467 if arg == *prefix {
468 return Some(if optional {
469 SearchFlagMatch::OptionalValue {
470 flag: canonical,
471 attached: None,
472 }
473 } else {
474 SearchFlagMatch::RequiresValue {
475 flag: canonical,
476 attached: None,
477 }
478 });
479 }
480 if arg.starts_with(prefix) && arg.len() > prefix.len() {
481 let attached = Some(arg[prefix.len()..].to_string());
482 return Some(if optional {
483 SearchFlagMatch::OptionalValue {
484 flag: canonical,
485 attached,
486 }
487 } else {
488 SearchFlagMatch::RequiresValue {
489 flag: canonical,
490 attached,
491 }
492 });
493 }
494 }
495 None
496}
497
498pub async fn run() -> Result<()> {
504 std::panic::set_hook(Box::new(|info| {
506 let msg = info.to_string();
507 if msg.contains("Broken pipe") || msg.contains("broken pipe") {
508 std::process::exit(0);
510 }
511 eprintln!("{msg}");
513 }));
514
515 utils::process_guard::spawn_parent_exit_guard();
517
518 let args = preprocess_args();
520 let mut cli = Cli::parse_from(args);
521
522 if let Some(target) = cli.prompt.clone() {
523 prompt::emit(&target, cli.command.as_ref())?;
524 return Ok(());
525 }
526
527 initialize_logging(&cli)?;
528
529 let args: Vec<String> = std::env::args().collect();
530 let mut cli_preferences = preferences::load();
531 apply_preference_defaults(&mut cli, &cli_preferences, &args);
532
533 let metrics = PerformanceMetrics::default();
534
535 #[cfg(feature = "flamegraph")]
536 let profiler_guard = start_flamegraph_if_requested(&cli);
537
538 execute_command(cli.clone(), metrics.clone(), &mut cli_preferences).await?;
539
540 #[cfg(feature = "flamegraph")]
541 stop_flamegraph_if_started(profiler_guard);
542
543 print_diagnostics(&cli, &metrics);
544
545 if let Err(err) = preferences::save(&cli_preferences) {
546 warn!("failed to persist CLI preferences: {err}");
547 }
548
549 Ok(())
550}
551
552fn initialize_logging(cli: &Cli) -> Result<()> {
553 let mut level = if cli.verbose || cli.debug {
555 Level::DEBUG
556 } else if cli.quiet {
557 Level::ERROR
558 } else {
559 Level::WARN
560 };
561
562 let mut machine_output = false;
565 if !(cli.verbose || cli.debug) {
566 #[allow(deprecated)]
567 let command_format = match &cli.command {
568 Some(
569 Commands::Search { format, .. }
570 | Commands::Find { format, .. }
571 | Commands::List { format, .. }
572 | Commands::Stats { format, .. }
573 | Commands::History { format, .. }
574 | Commands::Lookup { format, .. }
575 | Commands::Get { format, .. }
576 | Commands::Info { format, .. }
577 | Commands::Completions { format, .. },
578 ) => Some(format.resolve(cli.quiet)),
579 Some(Commands::Query(args)) => Some(args.format.resolve(cli.quiet)),
580 Some(Commands::Map(args)) => Some(args.format.resolve(cli.quiet)),
581 Some(Commands::Check(args)) => Some(args.format.resolve(cli.quiet)),
582 _ => None,
583 };
584
585 if let Some(fmt) = command_format {
586 if matches!(
587 fmt,
588 crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
589 ) {
590 level = Level::ERROR;
591 machine_output = true;
592 }
593 }
594 }
595
596 let subscriber = FmtSubscriber::builder()
597 .with_max_level(level)
598 .with_target(false)
599 .with_thread_ids(false)
600 .with_thread_names(false)
601 .with_writer(std::io::stderr)
602 .finish();
603
604 tracing::subscriber::set_global_default(subscriber)?;
605
606 let env_no_color = std::env::var("NO_COLOR").ok().is_some();
608 if cli.no_color || env_no_color || machine_output {
609 color_control::set_override(false);
610 }
611 Ok(())
612}
613
614#[cfg(feature = "flamegraph")]
615fn start_flamegraph_if_requested(cli: &Cli) -> Option<pprof::ProfilerGuard<'static>> {
616 if cli.flamegraph {
617 match start_profiling() {
618 Ok(guard) => {
619 println!("🔥 CPU profiling started - flamegraph will be generated");
620 Some(guard)
621 },
622 Err(e) => {
623 eprintln!("Failed to start profiling: {e}");
624 None
625 },
626 }
627 } else {
628 None
629 }
630}
631
632#[cfg(feature = "flamegraph")]
633fn stop_flamegraph_if_started(guard: Option<pprof::ProfilerGuard<'static>>) {
634 if let Some(guard) = guard {
635 if let Err(e) = stop_profiling_and_report(&guard) {
636 eprintln!("Failed to generate flamegraph: {e}");
637 }
638 }
639}
640
641async fn execute_command(
642 cli: Cli,
643 metrics: PerformanceMetrics,
644 prefs: &mut CliPreferences,
645) -> Result<()> {
646 let quiet = cli.quiet;
647 match cli.command {
648 Some(Commands::Instruct) => {
649 prompt::emit("__global__", Some(&Commands::Instruct))?;
650 eprintln!("`blz instruct` is deprecated. Use `blz --prompt` instead.");
651 },
652 Some(Commands::Completions {
653 shell,
654 list,
655 format,
656 }) => {
657 handle_completions(shell, list, format.resolve(quiet));
658 },
659 Some(Commands::Docs { command }) => {
660 handle_docs(command, quiet, metrics.clone(), prefs).await?;
661 },
662 Some(Commands::ClaudePlugin { command }) => handle_claude_plugin(command)?,
663 Some(Commands::Alias { command }) => handle_alias(command).await?,
664 Some(Commands::Add(args)) => handle_add(args, quiet, metrics).await?,
665 Some(Commands::Lookup {
666 query,
667 format,
668 limit,
669 }) => {
670 dispatch_lookup(query, format, limit, quiet, metrics).await?;
671 },
672 Some(Commands::Registry { command }) => handle_registry(command, quiet, metrics).await?,
673 Some(cmd @ Commands::Search { .. }) => dispatch_search(cmd, quiet, metrics, prefs).await?,
674 Some(Commands::History {
675 limit,
676 format,
677 clear,
678 clear_before,
679 }) => {
680 dispatch_history(limit, &format, clear, clear_before.as_deref(), quiet, prefs)?;
681 },
682 Some(cmd @ Commands::Get { .. }) => dispatch_get(cmd, quiet).await?,
683 Some(Commands::Query(args)) => handle_query(args, quiet, prefs, metrics.clone()).await?,
684 Some(Commands::Map(args)) => dispatch_map(args, quiet).await?,
685 Some(Commands::Sync(args)) => dispatch_sync(args, quiet, metrics).await?,
686 Some(Commands::Check(args)) => {
687 commands::check_source(args.alias, args.all, args.format.resolve(quiet)).await?;
688 },
689 Some(Commands::Rm(args)) => commands::rm_source(vec![args.alias], args.yes).await?,
690 #[allow(deprecated)]
691 Some(cmd @ Commands::Find { .. }) => {
692 dispatch_find(cmd, quiet, prefs, metrics.clone()).await?;
693 },
694 Some(Commands::Info { alias, format }) => {
695 commands::execute_info(&alias, format.resolve(quiet)).await?;
696 },
697 Some(Commands::List {
698 format,
699 status,
700 details,
701 limit,
702 }) => {
703 dispatch_list(format, status, details, limit, quiet).await?;
704 },
705 Some(Commands::Stats { format, limit }) => {
706 commands::show_stats(format.resolve(quiet), limit)?;
707 },
708 #[allow(deprecated)]
709 Some(Commands::Validate { alias, all, format }) => {
710 dispatch_validate(alias, all, format, quiet).await?;
711 },
712 Some(Commands::Doctor { format, fix }) => {
713 commands::run_doctor(format.resolve(quiet), fix).await?;
714 },
715 #[allow(deprecated)]
716 Some(cmd @ Commands::Refresh { .. }) => dispatch_refresh(cmd, quiet, metrics).await?,
717 #[allow(deprecated)]
718 Some(cmd @ Commands::Update { .. }) => dispatch_update(cmd, quiet, metrics).await?,
719 #[allow(deprecated)]
720 Some(Commands::Remove { alias, yes }) => dispatch_remove(alias, yes, quiet).await?,
721 Some(Commands::Clear { force }) => commands::clear_cache(force)?,
722 Some(Commands::Diff { alias, since }) => {
723 commands::show_diff(&alias, since.as_deref()).await?;
724 },
725 Some(Commands::McpServer) => commands::mcp_server().await?,
726 Some(Commands::Anchor { command }) => handle_anchor(command, quiet).await?,
727 #[allow(deprecated)]
728 Some(cmd @ Commands::Toc { .. }) => dispatch_toc(cmd, quiet).await?,
729 None => commands::handle_default_search(&cli.query, metrics, None, prefs, quiet).await?,
730 }
731 Ok(())
732}
733
734#[allow(clippy::too_many_lines)]
739async fn dispatch_search(
740 cmd: Commands,
741 quiet: bool,
742 metrics: PerformanceMetrics,
743 prefs: &mut CliPreferences,
744) -> Result<()> {
745 let Commands::Search {
746 query,
747 sources,
748 next,
749 previous,
750 last,
751 limit,
752 all,
753 page,
754 top,
755 heading_level,
756 format,
757 show,
758 no_summary,
759 score_precision,
760 snippet_lines,
761 max_chars,
762 context,
763 context_deprecated,
764 after_context,
765 before_context,
766 block,
767 max_lines,
768 headings_only,
769 no_history,
770 copy,
771 timing,
772 } = cmd
773 else {
774 unreachable!("dispatch_search called with non-Search command");
775 };
776
777 let resolved_format = format.resolve(quiet);
778 let merged_context =
779 crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
780
781 handle_search(
782 query,
783 sources,
784 next,
785 previous,
786 last,
787 limit,
788 all,
789 page,
790 top,
791 heading_level,
792 resolved_format,
793 show,
794 no_summary,
795 score_precision,
796 snippet_lines,
797 max_chars,
798 merged_context,
799 block,
800 max_lines,
801 headings_only,
802 no_history,
803 copy,
804 timing,
805 quiet,
806 metrics,
807 prefs,
808 )
809 .await
810}
811
812#[allow(clippy::too_many_lines)]
814async fn dispatch_get(cmd: Commands, quiet: bool) -> Result<()> {
815 let Commands::Get {
816 targets,
817 lines,
818 source,
819 context,
820 context_deprecated,
821 after_context,
822 before_context,
823 block,
824 max_lines,
825 format,
826 copy,
827 } = cmd
828 else {
829 unreachable!("dispatch_get called with non-Get command");
830 };
831
832 handle_get(
833 targets,
834 lines,
835 source,
836 context,
837 context_deprecated,
838 after_context,
839 before_context,
840 block,
841 max_lines,
842 format.resolve(quiet),
843 copy,
844 )
845 .await
846}
847
848#[allow(clippy::too_many_lines)]
850async fn dispatch_find(
851 cmd: Commands,
852 quiet: bool,
853 prefs: &mut CliPreferences,
854 metrics: PerformanceMetrics,
855) -> Result<()> {
856 #[allow(deprecated)]
857 let Commands::Find {
858 inputs,
859 sources,
860 limit,
861 all,
862 page,
863 top,
864 heading_level,
865 format,
866 show,
867 no_summary,
868 score_precision,
869 snippet_lines,
870 max_chars,
871 context,
872 context_deprecated,
873 after_context,
874 before_context,
875 block,
876 max_lines,
877 headings_only,
878 no_history,
879 copy,
880 timing,
881 } = cmd
882 else {
883 unreachable!("dispatch_find called with non-Find command");
884 };
885
886 handle_find(
887 inputs,
888 sources,
889 limit,
890 all,
891 page,
892 top,
893 heading_level,
894 format.resolve(quiet),
895 show,
896 no_summary,
897 score_precision,
898 snippet_lines,
899 max_chars,
900 context,
901 context_deprecated,
902 after_context,
903 before_context,
904 block,
905 max_lines,
906 headings_only,
907 no_history,
908 copy,
909 timing,
910 quiet,
911 prefs,
912 metrics,
913 )
914 .await
915}
916
917#[allow(deprecated)]
919async fn dispatch_toc(cmd: Commands, quiet: bool) -> Result<()> {
920 let Commands::Toc {
921 alias,
922 format,
923 filter,
924 max_depth,
925 heading_level,
926 sources,
927 all,
928 tree,
929 anchors,
930 show_anchors,
931 next,
932 previous,
933 last,
934 limit,
935 page,
936 } = cmd
937 else {
938 unreachable!("dispatch_toc called with non-Toc command");
939 };
940
941 handle_toc(
942 alias,
943 sources,
944 all,
945 format.resolve(quiet),
946 anchors,
947 show_anchors,
948 limit,
949 max_depth,
950 heading_level,
951 filter,
952 tree,
953 next,
954 previous,
955 last,
956 page,
957 )
958 .await
959}
960
961#[allow(deprecated)]
963async fn dispatch_refresh(cmd: Commands, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
964 let Commands::Refresh {
965 aliases,
966 all,
967 yes: _,
968 reindex,
969 filter,
970 no_filter,
971 } = cmd
972 else {
973 unreachable!("dispatch_refresh called with non-Refresh command");
974 };
975
976 if !utils::cli_args::deprecation_warnings_suppressed() {
977 eprintln!(
978 "{}",
979 "Warning: 'refresh' is deprecated, use 'sync' instead".yellow()
980 );
981 }
982
983 handle_refresh(aliases, all, reindex, filter, no_filter, metrics, quiet).await
984}
985
986async fn dispatch_map(args: crate::cli::MapArgs, quiet: bool) -> Result<()> {
988 commands::show_map(
989 args.alias.as_deref(),
990 &args.sources,
991 args.all,
992 args.format.resolve(quiet),
993 args.anchors,
994 args.show_anchors,
995 args.limit,
996 args.max_depth,
997 args.heading_level.as_ref(),
998 args.filter.as_deref(),
999 args.tree,
1000 args.next,
1001 args.previous,
1002 args.last,
1003 args.page,
1004 )
1005 .await
1006}
1007
1008async fn dispatch_sync(
1010 args: crate::cli::SyncArgs,
1011 quiet: bool,
1012 metrics: PerformanceMetrics,
1013) -> Result<()> {
1014 commands::sync_source(
1015 args.aliases,
1016 args.all,
1017 args.yes,
1018 args.reindex,
1019 args.filter,
1020 args.no_filter,
1021 metrics,
1022 quiet,
1023 )
1024 .await
1025}
1026
1027fn dispatch_history(
1029 limit: usize,
1030 format: &crate::utils::cli_args::FormatArg,
1031 clear: bool,
1032 clear_before: Option<&str>,
1033 quiet: bool,
1034 prefs: &CliPreferences,
1035) -> Result<()> {
1036 commands::show_history(prefs, limit, format.resolve(quiet), clear, clear_before)
1037}
1038
1039async fn dispatch_list(
1041 format: crate::utils::cli_args::FormatArg,
1042 status: bool,
1043 details: bool,
1044 limit: Option<usize>,
1045 quiet: bool,
1046) -> Result<()> {
1047 commands::list_sources(format.resolve(quiet), status, details, limit).await
1048}
1049
1050#[allow(deprecated)]
1052async fn dispatch_validate(
1053 alias: Option<String>,
1054 all: bool,
1055 format: crate::utils::cli_args::FormatArg,
1056 quiet: bool,
1057) -> Result<()> {
1058 if !utils::cli_args::deprecation_warnings_suppressed() {
1059 eprintln!(
1060 "{}",
1061 "Warning: 'validate' is deprecated, use 'check' instead".yellow()
1062 );
1063 }
1064 commands::validate_source(alias, all, format.resolve(quiet)).await
1065}
1066
1067#[allow(deprecated)]
1069async fn dispatch_update(cmd: Commands, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1070 let Commands::Update {
1071 aliases,
1072 all,
1073 yes: _,
1074 } = cmd
1075 else {
1076 unreachable!("dispatch_update called with non-Update command");
1077 };
1078
1079 if !utils::cli_args::deprecation_warnings_suppressed() {
1080 eprintln!(
1081 "{}",
1082 "Warning: 'update' is deprecated, use 'refresh' instead".yellow()
1083 );
1084 }
1085 handle_refresh(aliases, all, false, None, false, metrics, quiet).await
1086}
1087
1088#[allow(deprecated)]
1090async fn dispatch_remove(alias: String, yes: bool, quiet: bool) -> Result<()> {
1091 if !utils::cli_args::deprecation_warnings_suppressed() {
1092 eprintln!(
1093 "{}",
1094 "Warning: 'remove' is deprecated, use 'rm' instead".yellow()
1095 );
1096 }
1097 commands::remove_source(&alias, yes, quiet).await
1098}
1099
1100async fn dispatch_lookup(
1102 query: String,
1103 format: crate::utils::cli_args::FormatArg,
1104 limit: Option<usize>,
1105 quiet: bool,
1106 metrics: PerformanceMetrics,
1107) -> Result<()> {
1108 commands::lookup_registry(&query, metrics, quiet, format.resolve(quiet), limit).await
1109}
1110
1111fn handle_completions(
1112 shell: Option<clap_complete::Shell>,
1113 list: bool,
1114 format: crate::output::OutputFormat,
1115) {
1116 if list {
1117 commands::list_supported(format);
1118 } else if let Some(shell) = shell {
1119 commands::generate(shell);
1120 } else {
1121 commands::list_supported(format);
1122 }
1123}
1124
1125async fn handle_add(
1126 args: crate::cli::AddArgs,
1127 quiet: bool,
1128 metrics: PerformanceMetrics,
1129) -> Result<()> {
1130 if let Some(manifest) = &args.manifest {
1131 commands::add_manifest(
1132 manifest,
1133 &args.only,
1134 metrics,
1135 commands::AddFlowOptions::new(args.dry_run, quiet, args.no_language_filter),
1136 )
1137 .await
1138 } else {
1139 let alias = args
1140 .alias
1141 .as_deref()
1142 .ok_or_else(|| anyhow!("alias is required when manifest is not provided"))?;
1143 let url = args
1144 .url
1145 .as_deref()
1146 .ok_or_else(|| anyhow!("url is required when manifest is not provided"))?;
1147
1148 let descriptor = DescriptorInput::from_cli_inputs(
1149 &args.aliases,
1150 args.name.as_deref(),
1151 args.description.as_deref(),
1152 args.category.as_deref(),
1153 &args.tags,
1154 );
1155
1156 let request = AddRequest::new(
1157 alias.to_string(),
1158 url.to_string(),
1159 descriptor,
1160 args.dry_run,
1161 quiet,
1162 metrics,
1163 args.no_language_filter,
1164 );
1165
1166 commands::add_source(request).await
1167 }
1168}
1169
1170#[allow(clippy::too_many_arguments)]
1171async fn handle_get(
1172 targets: Vec<String>,
1173 lines: Option<String>,
1174 source: Option<String>,
1175 context: Option<crate::cli::ContextMode>,
1176 context_deprecated: Option<crate::cli::ContextMode>,
1177 after_context: Option<usize>,
1178 before_context: Option<usize>,
1179 block: bool,
1180 max_lines: Option<usize>,
1181 format: crate::output::OutputFormat,
1182 copy: bool,
1183) -> Result<()> {
1184 let request_specs = parse_get_targets(&targets, lines.as_deref(), source)?;
1185
1186 let merged_context =
1187 crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
1188
1189 commands::get_lines(
1190 &request_specs,
1191 merged_context.as_ref(),
1192 block,
1193 max_lines,
1194 format,
1195 copy,
1196 )
1197 .await
1198}
1199
1200fn parse_get_targets(
1202 targets: &[String],
1203 lines: Option<&str>,
1204 source: Option<String>,
1205) -> Result<Vec<RequestSpec>> {
1206 if targets.is_empty() {
1207 anyhow::bail!("At least one target is required. Use format: alias[:ranges]");
1208 }
1209
1210 if lines.is_some() && targets.len() > 1 {
1211 anyhow::bail!(
1212 "--lines can only be combined with a single alias. \
1213 Provide explicit ranges via colon syntax for each additional target."
1214 );
1215 }
1216
1217 let mut request_specs = Vec::with_capacity(targets.len());
1218 for (idx, target) in targets.iter().enumerate() {
1219 let spec = parse_single_target(target, idx, lines)?;
1220 request_specs.push(spec);
1221 }
1222
1223 apply_source_override(&mut request_specs, source)?;
1224 Ok(request_specs)
1225}
1226
1227fn parse_single_target(target: &str, idx: usize, lines: Option<&str>) -> Result<RequestSpec> {
1229 let trimmed = target.trim();
1230 if trimmed.is_empty() {
1231 anyhow::bail!("Alias at position {} cannot be empty.", idx + 1);
1232 }
1233
1234 if let Some((alias_part, range_part)) = trimmed.split_once(':') {
1235 let trimmed_alias = alias_part.trim();
1236 if trimmed_alias.is_empty() {
1237 anyhow::bail!(
1238 "Alias at position {} cannot be empty. Use syntax like 'bun:120-142'.",
1239 idx + 1
1240 );
1241 }
1242 if range_part.is_empty() {
1243 anyhow::bail!(
1244 "Alias '{trimmed_alias}' is missing a range. \
1245 Use syntax like '{trimmed_alias}:120-142'."
1246 );
1247 }
1248 Ok(RequestSpec {
1249 alias: trimmed_alias.to_string(),
1250 line_expression: range_part.trim().to_string(),
1251 })
1252 } else {
1253 let Some(line_expr) = lines else {
1254 anyhow::bail!(
1255 "Missing line specification for alias '{trimmed}'. \
1256 Use '{trimmed}:1-3' or provide --lines."
1257 );
1258 };
1259 Ok(RequestSpec {
1260 alias: trimmed.to_string(),
1261 line_expression: line_expr.to_string(),
1262 })
1263 }
1264}
1265
1266fn apply_source_override(request_specs: &mut [RequestSpec], source: Option<String>) -> Result<()> {
1268 if let Some(explicit_source) = source {
1269 if request_specs.len() > 1 {
1270 anyhow::bail!("--source cannot be combined with multiple alias targets.");
1271 }
1272 if let Some(first) = request_specs.first_mut() {
1273 first.alias = explicit_source;
1274 }
1275 }
1276 Ok(())
1277}
1278
1279async fn handle_query(
1280 args: crate::cli::QueryArgs,
1281 quiet: bool,
1282 prefs: &mut CliPreferences,
1283 metrics: PerformanceMetrics,
1284) -> Result<()> {
1285 let resolved_format = args.format.resolve(quiet);
1286 let merged_context = crate::cli::merge_context_flags(
1287 args.context,
1288 args.context_deprecated,
1289 args.after_context,
1290 args.before_context,
1291 );
1292
1293 commands::query(
1294 &args.inputs,
1295 &args.sources,
1296 args.limit,
1297 args.all,
1298 args.page,
1299 false, args.top,
1301 args.heading_level.clone(),
1302 resolved_format,
1303 &args.show,
1304 args.no_summary,
1305 args.score_precision,
1306 args.snippet_lines,
1307 args.max_chars,
1308 merged_context.as_ref(),
1309 args.block,
1310 args.max_lines,
1311 args.no_history,
1312 args.copy,
1313 quiet,
1314 args.headings_only,
1315 args.timing,
1316 Some(prefs),
1317 metrics,
1318 None,
1319 )
1320 .await
1321}
1322
1323#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1324async fn handle_find(
1325 inputs: Vec<String>,
1326 sources: Vec<String>,
1327 limit: Option<usize>,
1328 all: bool,
1329 page: usize,
1330 top: Option<u8>,
1331 heading_level: Option<String>,
1332 format: crate::output::OutputFormat,
1333 show: Vec<crate::cli::ShowComponent>,
1334 no_summary: bool,
1335 score_precision: Option<u8>,
1336 snippet_lines: u8,
1337 max_chars: Option<usize>,
1338 context: Option<crate::cli::ContextMode>,
1339 context_deprecated: Option<crate::cli::ContextMode>,
1340 after_context: Option<usize>,
1341 before_context: Option<usize>,
1342 block: bool,
1343 max_lines: Option<usize>,
1344 headings_only: bool,
1345 no_history: bool,
1346 copy: bool,
1347 timing: bool,
1348 quiet: bool,
1349 prefs: &mut CliPreferences,
1350 metrics: PerformanceMetrics,
1351) -> Result<()> {
1352 if !utils::cli_args::deprecation_warnings_suppressed() {
1353 eprintln!(
1354 "{}",
1355 "Warning: 'find' is deprecated, use 'query' or 'get' instead".yellow()
1356 );
1357 }
1358
1359 let merged_context =
1360 crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
1361
1362 commands::find(
1363 &inputs,
1364 &sources,
1365 limit,
1366 all,
1367 page,
1368 false, top,
1370 heading_level,
1371 format,
1372 &show,
1373 no_summary,
1374 score_precision,
1375 snippet_lines,
1376 max_chars,
1377 merged_context.as_ref(),
1378 block,
1379 max_lines,
1380 no_history,
1381 copy,
1382 quiet,
1383 headings_only,
1384 timing,
1385 Some(prefs),
1386 metrics,
1387 None,
1388 )
1389 .await
1390}
1391
1392#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1393async fn handle_toc(
1394 alias: Option<String>,
1395 sources: Vec<String>,
1396 all: bool,
1397 format: crate::output::OutputFormat,
1398 anchors: bool,
1399 show_anchors: bool,
1400 limit: Option<usize>,
1401 max_depth: Option<u8>,
1402 heading_level: Option<crate::utils::heading_filter::HeadingLevelFilter>,
1403 filter: Option<String>,
1404 tree: bool,
1405 next: bool,
1406 previous: bool,
1407 last: bool,
1408 page: usize,
1409) -> Result<()> {
1410 if !utils::cli_args::deprecation_warnings_suppressed() {
1411 eprintln!(
1412 "{}",
1413 "Warning: 'toc' is deprecated, use 'map' instead".yellow()
1414 );
1415 }
1416
1417 commands::show_toc(
1418 alias.as_deref(),
1419 &sources,
1420 all,
1421 format,
1422 anchors,
1423 show_anchors,
1424 limit,
1425 max_depth,
1426 heading_level.as_ref(),
1427 filter.as_deref(),
1428 tree,
1429 next,
1430 previous,
1431 last,
1432 page,
1433 )
1434 .await
1435}
1436
1437async fn handle_docs(
1438 command: Option<DocsCommands>,
1439 quiet: bool,
1440 metrics: PerformanceMetrics,
1441 _prefs: &mut CliPreferences,
1442) -> Result<()> {
1443 match command {
1444 Some(DocsCommands::Search(args)) => docs_search(args, quiet, metrics.clone()).await?,
1445 Some(DocsCommands::Sync {
1446 force,
1447 quiet: sync_quiet,
1448 }) => docs_sync(force, sync_quiet, metrics.clone())?,
1449 Some(DocsCommands::Overview) => {
1450 docs_overview(quiet, metrics.clone())?;
1451 },
1452 Some(DocsCommands::Cat) => {
1453 docs_cat(metrics.clone())?;
1454 },
1455 Some(DocsCommands::Export { format }) => {
1456 docs_export(Some(format))?;
1457 },
1458 None => {
1459 docs_overview(quiet, metrics.clone())?;
1461 },
1462 }
1463
1464 Ok(())
1465}
1466
1467fn handle_claude_plugin(command: ClaudePluginCommands) -> Result<()> {
1468 match command {
1469 ClaudePluginCommands::Install { scope } => {
1470 commands::install_local_plugin(scope)?;
1471 },
1472 }
1473
1474 Ok(())
1475}
1476
1477async fn docs_search(args: DocsSearchArgs, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1478 sync_and_report(false, quiet, metrics.clone())?;
1479 let query = args.query.join(" ").trim().to_string();
1480 if query.is_empty() {
1481 anyhow::bail!("Search query cannot be empty");
1482 }
1483
1484 let format = args.format.resolve(quiet);
1486
1487 let storage = Storage::new()?;
1489 if let Ok(content_path) = storage.llms_txt_path(BUNDLED_ALIAS) {
1490 if let Ok(content) = std::fs::read_to_string(&content_path) {
1491 if content.contains("# BLZ bundled docs (placeholder)") {
1492 let error_msg = if matches!(format, crate::output::OutputFormat::Json) {
1493 let error_json = serde_json::json!({
1495 "error": "Bundled documentation content not yet available",
1496 "reason": "The blz-docs source currently contains placeholder content",
1497 "suggestions": [
1498 "Use 'blz docs overview' for quick-start information",
1499 "Use 'blz docs export' to view CLI documentation",
1500 "Full bundled documentation will be included in a future release"
1501 ]
1502 });
1503 return Err(anyhow!(serde_json::to_string_pretty(&error_json)?));
1504 } else {
1505 "Bundled documentation content not yet available.\n\
1507 \n\
1508 The blz-docs source currently contains placeholder content.\n\
1509 Full documentation will be included in a future release.\n\
1510 \n\
1511 Available alternatives:\n\
1512 • Run 'blz docs overview' for quick-start information\n\
1513 • Run 'blz docs export' to view CLI documentation\n\
1514 • Run 'blz docs cat' to view the current placeholder content"
1515 };
1516 anyhow::bail!("{error_msg}");
1517 }
1518 }
1519 }
1520
1521 let sources = vec![BUNDLED_ALIAS.to_string()];
1522
1523 let context_mode = args.context.map(crate::cli::ContextMode::Symmetric);
1525
1526 commands::search(
1527 &query,
1528 &sources,
1529 false,
1530 args.limit,
1531 1,
1532 args.top,
1533 None, format,
1535 &args.show,
1536 args.no_summary,
1537 args.score_precision,
1538 args.snippet_lines,
1539 args.max_chars.unwrap_or(commands::DEFAULT_MAX_CHARS),
1540 context_mode.as_ref(),
1541 args.block,
1542 args.max_block_lines,
1543 false,
1544 true,
1545 args.copy,
1546 false, quiet,
1548 None,
1549 metrics,
1550 None,
1551 )
1552 .await
1553}
1554
1555fn docs_sync(force: bool, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1556 let status = sync_and_report(force, quiet, metrics)?;
1557 if !quiet && matches!(status, DocsSyncStatus::Installed | DocsSyncStatus::Updated) {
1558 let storage = Storage::new()?;
1559 let llms_path = storage.llms_txt_path(BUNDLED_ALIAS)?;
1560 println!("Bundled docs stored at {}", llms_path.display());
1561 }
1562 Ok(())
1563}
1564
1565fn docs_overview(quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1566 let status = sync_and_report(false, quiet, metrics)?;
1567 if !quiet {
1568 let storage = Storage::new()?;
1569 let llms_path = storage.llms_txt_path(BUNDLED_ALIAS)?;
1570 println!("Bundled docs status: {status:?}");
1571 println!("Alias: {BUNDLED_ALIAS} (also @blz)");
1572 println!("Stored at: {}", llms_path.display());
1573 }
1574 print_overview();
1575 Ok(())
1576}
1577
1578fn docs_cat(metrics: PerformanceMetrics) -> Result<()> {
1579 sync_and_report(false, true, metrics)?;
1580 print_full_content();
1581 Ok(())
1582}
1583
1584fn docs_export(format: Option<crate::commands::DocsFormat>) -> Result<()> {
1585 let requested = format.unwrap_or(crate::commands::DocsFormat::Markdown);
1586 let effective = match (std::env::var("BLZ_OUTPUT_FORMAT").ok(), requested) {
1587 (Some(v), crate::commands::DocsFormat::Markdown) if v.eq_ignore_ascii_case("json") => {
1588 crate::commands::DocsFormat::Json
1589 },
1590 _ => requested,
1591 };
1592 commands::generate_docs(effective)
1593}
1594
1595fn sync_and_report(
1596 force: bool,
1597 quiet: bool,
1598 metrics: PerformanceMetrics,
1599) -> Result<DocsSyncStatus> {
1600 let status = sync_bundled_docs(force, metrics)?;
1601 if !quiet {
1602 match status {
1603 DocsSyncStatus::UpToDate => {
1604 println!("Bundled docs already up to date");
1605 },
1606 DocsSyncStatus::Installed => {
1607 println!("Installed bundled docs source: {BUNDLED_ALIAS}");
1608 },
1609 DocsSyncStatus::Updated => {
1610 println!("Updated bundled docs source: {BUNDLED_ALIAS}");
1611 },
1612 }
1613 }
1614 Ok(status)
1615}
1616
1617async fn handle_anchor(command: AnchorCommands, quiet: bool) -> Result<()> {
1618 match command {
1619 AnchorCommands::List {
1620 alias,
1621 format,
1622 anchors,
1623 limit,
1624 max_depth,
1625 filter,
1626 } => {
1627 commands::show_toc(
1628 Some(&alias),
1629 &[],
1630 false,
1631 format.resolve(quiet),
1632 anchors,
1633 false, limit,
1635 max_depth,
1636 None,
1637 filter.as_deref(),
1638 false,
1639 false, false, false, 1, )
1644 .await
1645 },
1646 AnchorCommands::Get {
1647 alias,
1648 anchor,
1649 context,
1650 format,
1651 } => commands::get_by_anchor(&alias, &anchor, context, format.resolve(quiet)).await,
1652 }
1653}
1654
1655async fn handle_alias(command: AliasCommands) -> Result<()> {
1656 match command {
1657 AliasCommands::Add { source, alias } => {
1658 commands::manage_alias(commands::AliasCommand::Add { source, alias }).await
1659 },
1660 AliasCommands::Rm { source, alias } => {
1661 commands::manage_alias(commands::AliasCommand::Rm { source, alias }).await
1662 },
1663 }
1664}
1665
1666async fn handle_registry(
1667 command: RegistryCommands,
1668 quiet: bool,
1669 metrics: PerformanceMetrics,
1670) -> Result<()> {
1671 match command {
1672 RegistryCommands::CreateSource {
1673 name,
1674 url,
1675 description,
1676 category,
1677 tags,
1678 npm,
1679 github,
1680 add,
1681 yes,
1682 } => {
1683 commands::create_registry_source(
1684 &name,
1685 &url,
1686 description,
1687 category,
1688 tags,
1689 npm,
1690 github,
1691 add,
1692 yes,
1693 quiet,
1694 metrics,
1695 )
1696 .await
1697 },
1698 }
1699}
1700
1701#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1702async fn handle_search(
1703 query: Option<String>,
1704 sources: Vec<String>,
1705 next: bool,
1706 previous: bool,
1707 last: bool,
1708 limit: Option<usize>,
1709 all: bool,
1710 page: usize,
1711 top: Option<u8>,
1712 heading_level: Option<String>,
1713 format: crate::output::OutputFormat,
1714 show: Vec<crate::cli::ShowComponent>,
1715 no_summary: bool,
1716 score_precision: Option<u8>,
1717 snippet_lines: u8,
1718 max_chars: Option<usize>,
1719 context: Option<crate::cli::ContextMode>,
1720 block: bool,
1721 max_lines: Option<usize>,
1722 headings_only: bool,
1723 no_history: bool,
1724 copy: bool,
1725 timing: bool,
1726 quiet: bool,
1727 metrics: PerformanceMetrics,
1728 prefs: &mut CliPreferences,
1729) -> Result<()> {
1730 const DEFAULT_LIMIT: usize = 50;
1731 const ALL_RESULTS_LIMIT: usize = 10_000;
1732 const DEFAULT_SNIPPET_LINES: u8 = 3;
1733
1734 let provided_query = query.is_some();
1735 let limit_was_explicit = all || limit.is_some();
1736 let mut use_headings_only = headings_only;
1737
1738 if snippet_lines != DEFAULT_SNIPPET_LINES {
1740 let args: Vec<String> = std::env::args().collect();
1741 if flag_present(&args, "--snippet-lines") || std::env::var("BLZ_SNIPPET_LINES").is_ok() {
1742 utils::cli_args::emit_snippet_lines_deprecation(false);
1744 }
1745 }
1746
1747 if next {
1748 validate_continuation_flag("--next", provided_query, &sources, page, last)?;
1749 }
1750
1751 if previous {
1752 validate_continuation_flag("--previous", provided_query, &sources, page, last)?;
1753 }
1754
1755 let history_entry = if next || previous || !provided_query {
1756 let mut records = utils::history_log::recent_for_active_scope(1);
1757 if records.is_empty() {
1758 anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
1759 }
1760 Some(records.remove(0))
1761 } else {
1762 None
1763 };
1764
1765 if let Some(entry) = history_entry.as_ref() {
1766 if (next || previous) && headings_only != entry.headings_only {
1767 anyhow::bail!(
1768 "Cannot change --headings-only while using --next/--previous. Rerun without continuation flags."
1769 );
1770 }
1771 if !headings_only {
1772 use_headings_only = entry.headings_only;
1773 }
1774 }
1775
1776 let actual_query = resolve_query(query, history_entry.as_ref())?;
1777 let actual_sources = resolve_sources(sources, history_entry.as_ref());
1778
1779 let base_limit = if all {
1780 ALL_RESULTS_LIMIT
1781 } else {
1782 limit.unwrap_or(DEFAULT_LIMIT)
1783 };
1784 let actual_max_chars = max_chars.map_or(commands::DEFAULT_MAX_CHARS, commands::clamp_max_chars);
1785
1786 let (actual_page, actual_limit) = if let Some(entry) = history_entry.as_ref() {
1787 let adj = apply_history_pagination(
1788 entry,
1789 next,
1790 previous,
1791 provided_query,
1792 all,
1793 limit,
1794 limit_was_explicit,
1795 page,
1796 base_limit,
1797 ALL_RESULTS_LIMIT,
1798 )?;
1799 (adj.page, adj.limit)
1800 } else {
1801 (page, base_limit)
1802 };
1803
1804 commands::search(
1805 &actual_query,
1806 &actual_sources,
1807 last,
1808 actual_limit,
1809 actual_page,
1810 top,
1811 heading_level.clone(),
1812 format,
1813 &show,
1814 no_summary,
1815 score_precision,
1816 snippet_lines,
1817 actual_max_chars,
1818 context.as_ref(),
1819 block,
1820 max_lines,
1821 use_headings_only,
1822 no_history,
1823 copy,
1824 timing,
1825 quiet,
1826 Some(prefs),
1827 metrics,
1828 None,
1829 )
1830 .await
1831}
1832
1833#[allow(clippy::fn_params_excessive_bools)]
1834async fn handle_refresh(
1835 aliases: Vec<String>,
1836 all: bool,
1837 reindex: bool,
1838 filter: Option<String>,
1839 no_filter: bool,
1840 metrics: PerformanceMetrics,
1841 quiet: bool,
1842) -> Result<()> {
1843 let mut aliases = aliases;
1844 let mut filter = filter;
1845
1846 if !all && aliases.is_empty() {
1847 if let Some(raw_value) = filter.take() {
1848 if crate::utils::filter_flags::is_known_filter_expression(&raw_value) {
1849 filter = Some(raw_value);
1850 } else {
1851 aliases.push(raw_value);
1852 filter = Some(String::from("all"));
1853 }
1854 }
1855 }
1856
1857 if all || aliases.is_empty() {
1858 return commands::refresh_all(metrics, quiet, reindex, filter.as_ref(), no_filter).await;
1859 }
1860
1861 for alias in aliases {
1862 let metrics_clone = PerformanceMetrics {
1863 search_count: Arc::clone(&metrics.search_count),
1864 total_search_time: Arc::clone(&metrics.total_search_time),
1865 index_build_count: Arc::clone(&metrics.index_build_count),
1866 total_index_time: Arc::clone(&metrics.total_index_time),
1867 bytes_processed: Arc::clone(&metrics.bytes_processed),
1868 lines_searched: Arc::clone(&metrics.lines_searched),
1869 };
1870 commands::refresh_source(
1871 &alias,
1872 metrics_clone,
1873 quiet,
1874 reindex,
1875 filter.as_ref(),
1876 no_filter,
1877 )
1878 .await?;
1879 }
1880
1881 Ok(())
1882}
1883
1884fn print_diagnostics(cli: &Cli, metrics: &PerformanceMetrics) {
1885 if cli.debug {
1886 metrics.print_summary();
1887 }
1888}
1889
1890fn apply_preference_defaults(cli: &mut Cli, prefs: &CliPreferences, args: &[String]) {
1891 if let Some(Commands::Search {
1892 show,
1893 score_precision,
1894 snippet_lines,
1895 ..
1896 }) = cli.command.as_mut()
1897 {
1898 let show_env = std::env::var("BLZ_SHOW").is_ok();
1899 if show.is_empty() && !flag_present(args, "--show") && !show_env {
1900 *show = prefs.default_show_components();
1901 }
1902
1903 if score_precision.is_none()
1904 && !flag_present(args, "--score-precision")
1905 && std::env::var("BLZ_SCORE_PRECISION").is_err()
1906 {
1907 *score_precision = Some(prefs.default_score_precision());
1908 }
1909
1910 if !flag_present(args, "--snippet-lines") && std::env::var("BLZ_SNIPPET_LINES").is_err() {
1911 *snippet_lines = prefs.default_snippet_lines();
1912 }
1913 }
1914}
1915
1916fn flag_present(args: &[String], flag: &str) -> bool {
1917 let flag_eq = flag;
1918 let flag_eq_with_equal = format!("{flag}=");
1919 args.iter()
1920 .any(|arg| arg == flag_eq || arg.starts_with(&flag_eq_with_equal))
1921}
1922
1923struct PaginationAdjustment {
1925 page: usize,
1926 limit: usize,
1927}
1928
1929#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1933fn apply_history_pagination(
1934 entry: &utils::preferences::SearchHistoryEntry,
1935 next: bool,
1936 previous: bool,
1937 provided_query: bool,
1938 all: bool,
1939 limit: Option<usize>,
1940 limit_was_explicit: bool,
1941 current_page: usize,
1942 current_limit: usize,
1943 all_results_limit: usize,
1944) -> Result<PaginationAdjustment> {
1945 let mut actual_page = current_page;
1946 let mut actual_limit = current_limit;
1947
1948 if next {
1949 validate_history_has_results(entry)?;
1950 validate_pagination_limit_consistency(
1951 "--next",
1952 all,
1953 limit_was_explicit,
1954 limit,
1955 entry.limit,
1956 all_results_limit,
1957 )?;
1958
1959 if let (Some(prev_page), Some(total_pages)) = (entry.page, entry.total_pages) {
1960 if prev_page >= total_pages {
1961 anyhow::bail!("Already at the last page (page {prev_page} of {total_pages})");
1962 }
1963 actual_page = prev_page + 1;
1964 } else {
1965 actual_page = entry.page.unwrap_or(1) + 1;
1966 }
1967
1968 if !limit_was_explicit {
1969 actual_limit = entry.limit.unwrap_or(actual_limit);
1970 }
1971 } else if previous {
1972 validate_history_has_results(entry)?;
1973 validate_pagination_limit_consistency(
1974 "--previous",
1975 all,
1976 limit_was_explicit,
1977 limit,
1978 entry.limit,
1979 all_results_limit,
1980 )?;
1981
1982 if let Some(prev_page) = entry.page {
1983 if prev_page <= 1 {
1984 anyhow::bail!("Already on first page");
1985 }
1986 actual_page = prev_page - 1;
1987 } else {
1988 anyhow::bail!("No previous page found in search history");
1989 }
1990
1991 if !limit_was_explicit {
1992 actual_limit = entry.limit.unwrap_or(actual_limit);
1993 }
1994 } else if !provided_query && !limit_was_explicit {
1995 actual_limit = entry.limit.unwrap_or(actual_limit);
1996 }
1997
1998 Ok(PaginationAdjustment {
1999 page: actual_page,
2000 limit: actual_limit,
2001 })
2002}
2003
2004fn validate_history_has_results(entry: &utils::preferences::SearchHistoryEntry) -> Result<()> {
2006 if matches!(entry.total_pages, Some(0)) || matches!(entry.total_results, Some(0)) {
2007 anyhow::bail!(
2008 "Previous search returned 0 results. Rerun with a different query or source."
2009 );
2010 }
2011 Ok(())
2012}
2013
2014fn resolve_query(
2016 mut query: Option<String>,
2017 history: Option<&utils::preferences::SearchHistoryEntry>,
2018) -> Result<String> {
2019 if let Some(value) = query.take() {
2020 Ok(value)
2021 } else if let Some(entry) = history {
2022 Ok(entry.query.clone())
2023 } else {
2024 anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
2025 }
2026}
2027
2028fn resolve_sources(
2030 sources: Vec<String>,
2031 history: Option<&utils::preferences::SearchHistoryEntry>,
2032) -> Vec<String> {
2033 if !sources.is_empty() {
2034 sources
2035 } else if let Some(entry) = history {
2036 entry.source.as_ref().map_or_else(Vec::new, |source_str| {
2037 source_str
2038 .split(',')
2039 .map(|s| s.trim().to_string())
2040 .collect()
2041 })
2042 } else {
2043 Vec::new()
2044 }
2045}
2046
2047fn validate_pagination_limit_consistency(
2049 flag: &str,
2050 all: bool,
2051 limit_was_explicit: bool,
2052 limit: Option<usize>,
2053 history_limit: Option<usize>,
2054 all_results_limit: usize,
2055) -> Result<()> {
2056 let history_all = history_limit.is_some_and(|value| value >= all_results_limit);
2057 if all != history_all {
2058 anyhow::bail!(
2059 "Cannot use {flag} when changing page size or --all; rerun without {flag} or reuse the previous pagination flags."
2060 );
2061 }
2062 if limit_was_explicit {
2063 if let Some(requested_limit) = limit {
2064 if history_limit != Some(requested_limit) {
2065 anyhow::bail!(
2066 "Cannot use {flag} when changing page size; rerun without {flag} or reuse the previous limit."
2067 );
2068 }
2069 }
2070 }
2071 Ok(())
2072}
2073
2074fn validate_continuation_flag(
2076 flag: &str,
2077 provided_query: bool,
2078 sources: &[String],
2079 page: usize,
2080 last: bool,
2081) -> Result<()> {
2082 if provided_query {
2083 anyhow::bail!(
2084 "Cannot combine {flag} with an explicit query. Remove the query to continue from the previous search."
2085 );
2086 }
2087 if !sources.is_empty() {
2088 anyhow::bail!(
2089 "Cannot combine {flag} with --source. Omit --source to reuse the last search context."
2090 );
2091 }
2092 if page != 1 {
2093 anyhow::bail!("Cannot combine {flag} with --page. Use one pagination option at a time.");
2094 }
2095 if last {
2096 anyhow::bail!("Cannot combine {flag} with --last. Choose a single continuation flag.");
2097 }
2098 Ok(())
2099}
2100
2101#[cfg(test)]
2102#[allow(
2103 clippy::unwrap_used,
2104 clippy::panic,
2105 clippy::disallowed_macros,
2106 clippy::needless_collect,
2107 clippy::unnecessary_wraps,
2108 clippy::deref_addrof
2109)]
2110mod tests {
2111 use super::*;
2112 use crate::utils::constants::RESERVED_KEYWORDS;
2113 use crate::utils::parsing::{LineRange, parse_line_ranges};
2114 use crate::utils::validation::validate_alias;
2115 use std::collections::HashSet;
2116
2117 #[test]
2118 fn test_reserved_keywords_validation() {
2119 for &keyword in RESERVED_KEYWORDS {
2120 let result = validate_alias(keyword);
2121 assert!(
2122 result.is_err(),
2123 "Reserved keyword '{keyword}' should be rejected"
2124 );
2125
2126 let error_msg = result.unwrap_err().to_string();
2127 assert!(
2128 error_msg.contains(keyword),
2129 "Error message should contain the reserved keyword '{keyword}'"
2130 );
2131 }
2132 }
2133
2134 #[test]
2135 fn test_valid_aliases_allowed() {
2136 let valid_aliases = ["react", "nextjs", "python", "rust", "docs", "api", "guide"];
2137
2138 for &alias in &valid_aliases {
2139 let result = validate_alias(alias);
2140 assert!(result.is_ok(), "Valid alias '{alias}' should be accepted");
2141 }
2142 }
2143
2144 #[test]
2145 fn test_language_names_are_not_reserved() {
2146 let language_names = [
2147 "node",
2148 "python",
2149 "rust",
2150 "go",
2151 "java",
2152 "javascript",
2153 "typescript",
2154 ];
2155
2156 for &lang in &language_names {
2157 assert!(
2158 !RESERVED_KEYWORDS.contains(&lang),
2159 "Language name '{lang}' should not be reserved"
2160 );
2161
2162 let result = validate_alias(lang);
2163 assert!(
2164 result.is_ok(),
2165 "Language name '{lang}' should be usable as alias"
2166 );
2167 }
2168 }
2169
2170 #[test]
2171 fn test_reserved_keywords_case_insensitive() {
2172 let result = validate_alias("ADD");
2173 assert!(
2174 result.is_err(),
2175 "Reserved keyword 'ADD' (uppercase) should be rejected"
2176 );
2177
2178 let result = validate_alias("Add");
2179 assert!(
2180 result.is_err(),
2181 "Reserved keyword 'Add' (mixed case) should be rejected"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_line_range_parsing() {
2187 let ranges = parse_line_ranges("42").expect("Should parse single line");
2189 assert_eq!(ranges.len(), 1);
2190 assert!(matches!(ranges[0], LineRange::Single(42)));
2191
2192 let ranges = parse_line_ranges("120:142").expect("Should parse colon range");
2194 assert_eq!(ranges.len(), 1);
2195 assert!(matches!(ranges[0], LineRange::Range(120, 142)));
2196
2197 let ranges = parse_line_ranges("120-142").expect("Should parse dash range");
2199 assert_eq!(ranges.len(), 1);
2200 assert!(matches!(ranges[0], LineRange::Range(120, 142)));
2201
2202 let ranges = parse_line_ranges("36+20").expect("Should parse plus syntax");
2204 assert_eq!(ranges.len(), 1);
2205 assert!(matches!(ranges[0], LineRange::PlusCount(36, 20)));
2206
2207 let ranges =
2209 parse_line_ranges("36:43,120-142,200+10").expect("Should parse multiple ranges");
2210 assert_eq!(ranges.len(), 3);
2211 }
2212
2213 #[test]
2214 fn test_line_range_parsing_errors() {
2215 assert!(parse_line_ranges("0").is_err(), "Line 0 should be invalid");
2216 assert!(
2217 parse_line_ranges("50:30").is_err(),
2218 "Backwards range should be invalid"
2219 );
2220 assert!(
2221 parse_line_ranges("50+0").is_err(),
2222 "Plus zero count should be invalid"
2223 );
2224 assert!(
2225 parse_line_ranges("abc").is_err(),
2226 "Invalid format should be rejected"
2227 );
2228 }
2229
2230 #[test]
2231 fn test_reserved_keywords_no_duplicates() {
2232 let mut seen = HashSet::new();
2233 for &keyword in RESERVED_KEYWORDS {
2234 assert!(
2235 seen.insert(keyword),
2236 "Reserved keyword '{keyword}' appears multiple times"
2237 );
2238 }
2239 }
2240
2241 #[test]
2244 fn test_cli_flag_combinations() {
2245 use clap::Parser;
2246
2247 let valid_combinations = vec![
2249 vec!["blz", "search", "rust", "--limit", "20"],
2250 vec!["blz", "search", "rust", "--alias", "node", "--limit", "10"],
2251 vec!["blz", "search", "rust", "--all"],
2252 vec!["blz", "add", "test", "https://example.com/llms.txt"],
2253 vec!["blz", "list", "--output", "json"],
2254 vec!["blz", "update", "--all"],
2255 vec!["blz", "remove", "test"],
2256 vec!["blz", "get", "test", "--lines", "1-10"],
2257 vec!["blz", "lookup", "react"],
2258 ];
2259
2260 for combination in valid_combinations {
2261 let result = Cli::try_parse_from(combination.clone());
2262 assert!(
2263 result.is_ok(),
2264 "Valid combination should parse: {combination:?}"
2265 );
2266 }
2267 }
2268
2269 #[test]
2270 #[allow(deprecated)]
2271 fn test_cli_parse_refresh_multiple_aliases() {
2272 use clap::Parser;
2273
2274 let cli = Cli::try_parse_from(vec!["blz", "refresh", "bun", "react"]).unwrap();
2275 match cli.command {
2276 Some(Commands::Refresh { aliases, all, .. }) => {
2277 assert_eq!(aliases, vec!["bun", "react"]);
2278 assert!(!all);
2279 },
2280 other => panic!("Expected refresh command, got {other:?}"),
2281 }
2282 }
2283
2284 #[test]
2285 fn test_cli_refresh_all_conflict_with_aliases() {
2286 use clap::Parser;
2287
2288 let result = Cli::try_parse_from(vec!["blz", "refresh", "bun", "--all"]);
2289 assert!(
2290 result.is_err(),
2291 "Should error when both --all and aliases are provided"
2292 );
2293 }
2294
2295 #[test]
2296 #[allow(deprecated)]
2297 fn test_cli_parse_update_multiple_aliases() {
2298 use clap::Parser;
2299
2300 let cli = Cli::try_parse_from(vec!["blz", "update", "bun", "react"]).unwrap();
2302 match cli.command {
2303 Some(Commands::Update { aliases, all, .. }) => {
2304 assert_eq!(aliases, vec!["bun", "react"]);
2305 assert!(!all);
2306 },
2307 other => panic!("Expected update command, got {other:?}"),
2308 }
2309 }
2310
2311 #[test]
2312 #[allow(deprecated)]
2313 fn test_cli_update_all_conflict_with_aliases() {
2314 use clap::Parser;
2315
2316 let result = Cli::try_parse_from(vec!["blz", "update", "bun", "--all"]);
2318 assert!(
2319 result.is_err(),
2320 "--all should conflict with explicit aliases"
2321 );
2322 }
2323
2324 #[test]
2325 fn test_preprocess_shorthand_context_flags() {
2326 fn assert_processed(input: &[&str], expected: &[&str]) {
2327 let raw = to_string_vec(input);
2328 let processed = preprocess_args_from(&raw);
2329 assert_eq!(
2330 processed,
2331 to_string_vec(expected),
2332 "unexpected preprocess result for {input:?}"
2333 );
2334 }
2335
2336 assert_processed(
2337 &["blz", "hooks", "--context", "all"],
2338 &["blz", "search", "hooks", "--context", "all"],
2339 );
2340 assert_processed(
2341 &["blz", "hooks", "--context", "5"],
2342 &["blz", "search", "hooks", "--context", "5"],
2343 );
2344 assert_processed(
2345 &["blz", "hooks", "-C5"],
2346 &["blz", "search", "hooks", "-C", "5"],
2347 );
2348 assert_processed(
2349 &["blz", "hooks", "-A3"],
2350 &["blz", "search", "hooks", "-A", "3"],
2351 );
2352 assert_processed(
2353 &["blz", "hooks", "-B2"],
2354 &["blz", "search", "hooks", "-B", "2"],
2355 );
2356 assert_processed(
2357 &["blz", "hooks", "--after-context", "4"],
2358 &["blz", "search", "hooks", "--after-context", "4"],
2359 );
2360 assert_processed(
2361 &["blz", "hooks", "--before-context", "4"],
2362 &["blz", "search", "hooks", "--before-context", "4"],
2363 );
2364 assert_processed(
2365 &["blz", "hooks", "--context", "all", "--source", "ctx"],
2366 &[
2367 "blz",
2368 "search",
2369 "hooks",
2370 "--context",
2371 "all",
2372 "--source",
2373 "ctx",
2374 ],
2375 );
2376 }
2377
2378 #[test]
2379 fn test_cli_invalid_flag_combinations() {
2380 use clap::Parser;
2381
2382 let invalid_combinations = vec![
2384 vec!["blz", "add", "alias"], vec!["blz", "search"], vec!["blz", "lookup"], vec!["blz", "search", "rust", "--limit", "-5"], vec!["blz", "search", "rust", "--page", "-1"], vec!["blz", "list", "--output", "invalid"], ];
2399
2400 for combination in invalid_combinations {
2401 let result = Cli::try_parse_from(combination.clone());
2402 assert!(
2403 result.is_err(),
2404 "Invalid combination should fail: {combination:?}"
2405 );
2406 }
2407 }
2408
2409 #[test]
2410 fn test_cli_help_generation() {
2411 use clap::Parser;
2412
2413 let help_commands = vec![
2415 vec!["blz", "--help"],
2416 vec!["blz", "search", "--help"],
2417 vec!["blz", "add", "--help"],
2418 vec!["blz", "list", "--help"],
2419 vec!["blz", "get", "--help"],
2420 vec!["blz", "update", "--help"],
2421 vec!["blz", "remove", "--help"],
2422 vec!["blz", "lookup", "--help"],
2423 vec!["blz", "completions", "--help"],
2424 ];
2425
2426 for help_cmd in help_commands {
2427 let result = Cli::try_parse_from(help_cmd.clone());
2428 if let Err(error) = result {
2430 assert!(
2431 error.kind() == clap::error::ErrorKind::DisplayHelp,
2432 "Help command should display help: {help_cmd:?}"
2433 );
2434 } else {
2435 panic!("Help command should not succeed: {help_cmd:?}");
2436 }
2437 }
2438 }
2439
2440 #[test]
2441 fn test_cli_version_flag() {
2442 use clap::Parser;
2443
2444 let version_commands = vec![vec!["blz", "--version"], vec!["blz", "-V"]];
2445
2446 for version_cmd in version_commands {
2447 let result = Cli::try_parse_from(version_cmd.clone());
2448 if let Err(error) = result {
2450 assert!(
2451 error.kind() == clap::error::ErrorKind::DisplayVersion,
2452 "Version command should display version: {version_cmd:?}"
2453 );
2454 } else {
2455 panic!("Version command should not succeed: {version_cmd:?}");
2456 }
2457 }
2458 }
2459
2460 #[test]
2461 fn test_cli_default_values() {
2462 use clap::Parser;
2463
2464 let cli = Cli::try_parse_from(vec!["blz", "search", "test"]).unwrap();
2466
2467 if let Some(Commands::Search {
2468 limit,
2469 page,
2470 all,
2471 format,
2472 ..
2473 }) = cli.command
2474 {
2475 assert_eq!(
2476 limit, None,
2477 "Default limit should be unset (defaults to 50)"
2478 );
2479 assert_eq!(page, 1, "Default page should be 1");
2480 assert!(!all, "Default all should be false");
2481 let expected_format = if is_terminal::IsTerminal::is_terminal(&std::io::stdout()) {
2483 crate::output::OutputFormat::Text
2484 } else {
2485 crate::output::OutputFormat::Json
2486 };
2487 assert_eq!(
2488 format.resolve(false),
2489 expected_format,
2490 "Default format should be JSON when piped, Text when terminal"
2491 );
2492 } else {
2493 panic!("Expected search command");
2494 }
2495 }
2496
2497 #[test]
2498 fn test_cli_flag_validation_edge_cases() {
2499 use clap::Parser;
2500
2501 let edge_cases = vec![
2503 vec!["blz", "search", "rust", "--limit", "999999"],
2505 vec!["blz", "search", "rust", "--page", "999999"],
2506 vec!["blz", "search", "rust", "--limit", "1"],
2508 vec!["blz", "search", "rust", "--page", "1"],
2509 vec!["blz", "search", "rust", "--limit", "10000"],
2511 vec!["blz", "search", "rust", "--page", "1000"],
2512 ];
2513
2514 for edge_case in edge_cases {
2515 let result = Cli::try_parse_from(edge_case.clone());
2516
2517 assert!(result.is_ok(), "Edge case should parse: {edge_case:?}");
2519 }
2520 }
2521
2522 #[test]
2523 fn test_cli_string_argument_validation() {
2524 use clap::Parser;
2525
2526 let string_cases = vec![
2528 vec!["blz", "search", "normal query"],
2530 vec!["blz", "add", "test-alias", "https://example.com/llms.txt"],
2531 vec!["blz", "lookup", "react"],
2532 vec!["blz", "search", ""], vec![
2535 "blz",
2536 "search",
2537 "very-long-query-with-lots-of-words-to-test-limits",
2538 ],
2539 vec!["blz", "add", "alias", "file:///local/path.txt"], vec!["blz", "search", "query with spaces"],
2542 vec!["blz", "search", "query-with-dashes"],
2543 vec!["blz", "search", "query_with_underscores"],
2544 vec!["blz", "add", "test", "https://example.com/path?query=value"],
2545 ];
2546
2547 for string_case in string_cases {
2548 let result = Cli::try_parse_from(string_case.clone());
2549
2550 assert!(result.is_ok(), "String case should parse: {string_case:?}");
2552 }
2553 }
2554
2555 #[test]
2556 fn test_cli_output_format_validation() {
2557 use clap::Parser;
2558
2559 let format_options = vec![
2561 ("text", crate::output::OutputFormat::Text),
2562 ("json", crate::output::OutputFormat::Json),
2563 ("jsonl", crate::output::OutputFormat::Jsonl),
2564 ];
2565
2566 for (format_str, expected_format) in &format_options {
2567 let cli = Cli::try_parse_from(vec!["blz", "list", "--format", *format_str]).unwrap();
2568
2569 if let Some(Commands::List { format, .. }) = cli.command {
2570 assert_eq!(
2571 format.resolve(false),
2572 *expected_format,
2573 "Format should match: {format_str}"
2574 );
2575 } else {
2576 panic!("Expected list command");
2577 }
2578 }
2579
2580 for (format_str, expected_format) in &format_options {
2582 let cli = Cli::try_parse_from(vec!["blz", "list", "--output", *format_str]).unwrap();
2583
2584 if let Some(Commands::List { format, .. }) = cli.command {
2585 assert_eq!(
2586 format.resolve(false),
2587 *expected_format,
2588 "Alias --output should map to {format_str}"
2589 );
2590 } else {
2591 panic!("Expected list command");
2592 }
2593 }
2594
2595 let result = Cli::try_parse_from(vec!["blz", "list", "--format", "invalid"]);
2597 assert!(result.is_err(), "Invalid output format should fail");
2598 }
2599
2600 fn to_string_vec(items: &[&str]) -> Vec<String> {
2601 items.iter().copied().map(str::to_owned).collect()
2602 }
2603
2604 #[test]
2605 fn preprocess_injects_search_for_shorthand_flags() {
2606 use clap::Parser;
2607
2608 let raw = to_string_vec(&["blz", "searchterm", "-s", "react"]);
2610 let processed = preprocess_args_from(&raw);
2611
2612 let expected = to_string_vec(&["blz", "search", "searchterm", "-s", "react"]);
2613 assert_eq!(processed, expected);
2614
2615 let cli = Cli::try_parse_from(processed).unwrap();
2616 match cli.command {
2617 Some(Commands::Search { sources, .. }) => {
2618 assert_eq!(sources, vec!["react"]);
2619 },
2620 _ => panic!("expected search command"),
2621 }
2622 }
2623
2624 #[test]
2625 fn preprocess_injects_search_for_next_flag() {
2626 let raw = to_string_vec(&["blz", "--next"]);
2627 let processed = preprocess_args_from(&raw);
2628 let expected = to_string_vec(&["blz", "search", "--next"]);
2629 assert_eq!(processed, expected);
2630 }
2631
2632 #[test]
2633 fn preprocess_preserves_global_flags_order() {
2634 let raw = to_string_vec(&["blz", "--quiet", "searchterm", "-s", "docs"]);
2636 let processed = preprocess_args_from(&raw);
2637 let expected = to_string_vec(&["blz", "--quiet", "search", "searchterm", "-s", "docs"]);
2638 assert_eq!(processed, expected);
2639 }
2640
2641 #[test]
2642 fn preprocess_converts_json_aliases() {
2643 use clap::Parser;
2644
2645 let raw = to_string_vec(&["blz", "searchterm", "--json"]);
2647 let processed = preprocess_args_from(&raw);
2648 let expected = to_string_vec(&["blz", "search", "searchterm", "--format", "json"]);
2649 assert_eq!(processed, expected);
2650
2651 let cli = Cli::try_parse_from(processed).unwrap();
2652 match cli.command {
2653 Some(Commands::Search { format, .. }) => {
2654 assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
2655 },
2656 _ => panic!("expected search command"),
2657 }
2658 }
2659
2660 #[test]
2661 fn preprocess_handles_list_subcommand_without_injection() {
2662 use clap::Parser;
2663
2664 let raw = to_string_vec(&["blz", "list", "--jsonl"]);
2665 let processed = preprocess_args_from(&raw);
2666 assert_eq!(processed, raw);
2667
2668 let cli = Cli::try_parse_from(raw).unwrap();
2669 match cli.command {
2670 Some(Commands::List { format, .. }) => {
2671 assert_eq!(format.resolve(false), crate::output::OutputFormat::Jsonl);
2672 },
2673 _ => panic!("expected list command"),
2674 }
2675 }
2676
2677 #[test]
2678 fn preprocess_respects_sentinel() {
2679 let raw = to_string_vec(&["blz", "query", "--", "-s", "react"]);
2680 let processed = preprocess_args_from(&raw);
2681 assert_eq!(processed, raw);
2682 }
2683
2684 #[test]
2685 fn preprocess_does_not_inject_hidden_subcommands() {
2686 let raw = to_string_vec(&["blz", "toc", "e2e", "-f", "json"]);
2687 let processed = preprocess_args_from(&raw);
2688 assert_eq!(processed, raw);
2689 }
2690
2691 #[test]
2692 fn preprocess_retains_hidden_subcommand_with_search_flags() {
2693 let raw = to_string_vec(&["blz", "toc", "e2e", "--limit", "5", "--json"]);
2694 let processed = preprocess_args_from(&raw);
2695 assert_eq!(
2697 processed, raw,
2698 "hidden subcommands must not trigger shorthand injection or format conversion"
2699 );
2700 }
2701
2702 #[test]
2703 #[allow(deprecated)]
2704 fn anchors_alias_still_parses_to_toc() {
2705 use clap::Parser;
2706
2707 let raw = to_string_vec(&["blz", "anchors", "react"]);
2708 let cli = Cli::try_parse_from(raw).expect("anchors alias should parse");
2709 match cli.command {
2710 Some(Commands::Toc { alias, .. }) => assert_eq!(alias, Some("react".to_string())),
2711 other => panic!("expected toc command, got {other:?}"),
2712 }
2713 }
2714
2715 #[test]
2716 fn preprocess_handles_context_flags() {
2717 use clap::Parser;
2718
2719 let raw = to_string_vec(&["blz", "searchterm", "--context", "all"]);
2721 let processed = preprocess_args_from(&raw);
2722 let expected = to_string_vec(&["blz", "search", "searchterm", "--context", "all"]);
2723 assert_eq!(processed, expected);
2724
2725 let cli = Cli::try_parse_from(processed).unwrap();
2726 match cli.command {
2727 Some(Commands::Search { context, .. }) => {
2728 assert!(context.is_some());
2729 },
2730 _ => panic!("expected search command"),
2731 }
2732 }
2733
2734 #[test]
2735 fn preprocess_handles_short_context_flags() {
2736 use clap::Parser;
2737
2738 let raw = to_string_vec(&["blz", "hooks", "-C5"]);
2740 let processed = preprocess_args_from(&raw);
2741 let expected = to_string_vec(&["blz", "search", "hooks", "-C", "5"]);
2742 assert_eq!(processed, expected);
2743
2744 let cli = Cli::try_parse_from(processed).unwrap();
2745 match cli.command {
2746 Some(Commands::Search { context, .. }) => {
2747 assert!(context.is_some());
2748 },
2749 _ => panic!("expected search command"),
2750 }
2751 }
2752
2753 #[test]
2754 fn preprocess_handles_after_context_flag() {
2755 use clap::Parser;
2756
2757 let raw = to_string_vec(&["blz", "api", "-A3"]);
2759 let processed = preprocess_args_from(&raw);
2760 let expected = to_string_vec(&["blz", "search", "api", "-A", "3"]);
2761 assert_eq!(processed, expected);
2762
2763 let cli = Cli::try_parse_from(processed).unwrap();
2764 match cli.command {
2765 Some(Commands::Search { after_context, .. }) => {
2766 assert_eq!(after_context, Some(3));
2767 },
2768 _ => panic!("expected search command"),
2769 }
2770 }
2771
2772 #[test]
2773 fn preprocess_handles_before_context_flag() {
2774 let raw = to_string_vec(&["blz", "documentation", "-B2"]);
2776 let processed = preprocess_args_from(&raw);
2777 let expected = to_string_vec(&["blz", "search", "documentation", "-B", "2"]);
2778 assert_eq!(processed, expected);
2779 }
2780
2781 #[test]
2782 fn preprocess_handles_deprecated_context_flag() {
2783 let raw = to_string_vec(&["blz", "example", "-c5"]);
2785 let processed = preprocess_args_from(&raw);
2786 let expected = to_string_vec(&["blz", "search", "example", "-c", "5"]);
2787 assert_eq!(processed, expected);
2788 }
2789
2790 #[test]
2791 fn preprocess_handles_block_flag() {
2792 use clap::Parser;
2793
2794 let raw = to_string_vec(&["blz", "guide", "--block"]);
2795 let processed = preprocess_args_from(&raw);
2796 let expected = to_string_vec(&["blz", "search", "guide", "--block"]);
2797 assert_eq!(processed, expected);
2798
2799 let cli = Cli::try_parse_from(processed).unwrap();
2800 match cli.command {
2801 Some(Commands::Search { block, .. }) => {
2802 assert!(block);
2803 },
2804 _ => panic!("expected search command"),
2805 }
2806 }
2807
2808 #[test]
2809 fn preprocess_handles_max_lines_flag() {
2810 let raw = to_string_vec(&["blz", "example", "--max-lines", "50"]);
2811 let processed = preprocess_args_from(&raw);
2812 let expected = to_string_vec(&["blz", "search", "example", "--max-lines", "50"]);
2813 assert_eq!(processed, expected);
2814 }
2815
2816 #[test]
2817 fn preprocess_handles_max_chars_flag() {
2818 let raw = to_string_vec(&["blz", "example", "--max-chars", "300"]);
2819 let processed = preprocess_args_from(&raw);
2820 let expected = to_string_vec(&["blz", "search", "example", "--max-chars", "300"]);
2821 assert_eq!(processed, expected);
2822 }
2823
2824 #[test]
2825 fn preprocess_handles_no_history_flag() {
2826 let raw = to_string_vec(&["blz", "searchterm", "--no-history"]);
2828 let processed = preprocess_args_from(&raw);
2829 let expected = to_string_vec(&["blz", "search", "searchterm", "--no-history"]);
2830 assert_eq!(processed, expected);
2831 }
2832
2833 #[test]
2834 fn preprocess_handles_copy_flag() {
2835 let raw = to_string_vec(&["blz", "searchterm", "--copy"]);
2837 let processed = preprocess_args_from(&raw);
2838 let expected = to_string_vec(&["blz", "search", "searchterm", "--copy"]);
2839 assert_eq!(processed, expected);
2840 }
2841
2842 #[test]
2843 fn preprocess_handles_multiple_context_flags() {
2844 use clap::Parser;
2845
2846 let raw = to_string_vec(&[
2848 "blz",
2849 "test",
2850 "--context",
2851 "all",
2852 "-s",
2853 "react",
2854 "--limit",
2855 "10",
2856 ]);
2857 let processed = preprocess_args_from(&raw);
2858 let expected = to_string_vec(&[
2859 "blz",
2860 "search",
2861 "test",
2862 "--context",
2863 "all",
2864 "-s",
2865 "react",
2866 "--limit",
2867 "10",
2868 ]);
2869 assert_eq!(processed, expected);
2870
2871 let cli = Cli::try_parse_from(processed).unwrap();
2872 match cli.command {
2873 Some(Commands::Search {
2874 context,
2875 sources,
2876 limit,
2877 ..
2878 }) => {
2879 assert!(context.is_some());
2880 assert_eq!(sources, vec!["react"]);
2881 assert_eq!(limit, Some(10));
2882 },
2883 _ => panic!("expected search command"),
2884 }
2885 }
2886
2887 #[test]
2888 fn preprocess_handles_context_with_json_flag() {
2889 use clap::Parser;
2890
2891 let raw = to_string_vec(&["blz", "test runner", "--context", "all", "--json"]);
2893 let processed = preprocess_args_from(&raw);
2894 let expected = to_string_vec(&[
2895 "blz",
2896 "search",
2897 "test runner",
2898 "--context",
2899 "all",
2900 "--format",
2901 "json",
2902 ]);
2903 assert_eq!(processed, expected);
2904
2905 let cli = Cli::try_parse_from(processed).unwrap();
2906 match cli.command {
2907 Some(Commands::Search {
2908 context, format, ..
2909 }) => {
2910 assert!(context.is_some());
2911 assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
2912 },
2913 _ => panic!("expected search command"),
2914 }
2915 }
2916
2917 #[test]
2918 fn preprocess_handles_combined_context_flags() {
2919 let raw = to_string_vec(&["blz", "documentation", "-A3", "-B2"]);
2921 let processed = preprocess_args_from(&raw);
2922 let expected = to_string_vec(&["blz", "search", "documentation", "-A", "3", "-B", "2"]);
2923 assert_eq!(processed, expected);
2924 }
2925
2926 #[test]
2927 fn known_subcommands_cover_clap_definitions() {
2928 use clap::CommandFactory;
2929
2930 let command = Cli::command();
2931 for sub in command.get_subcommands() {
2932 let name = sub.get_name();
2933 assert!(
2934 is_known_subcommand(name),
2935 "expected known subcommand to include {name}"
2936 );
2937
2938 for alias in sub.get_all_aliases() {
2939 assert!(
2940 is_known_subcommand(alias),
2941 "expected alias {alias} to be recognized"
2942 );
2943 }
2944 }
2945 }
2946
2947 #[test]
2948 fn test_cli_boolean_flags() {
2949 use clap::Parser;
2950
2951 let bool_flag_cases = vec![
2953 (
2955 vec!["blz", "--verbose", "search", "test"],
2956 true,
2957 false,
2958 false,
2959 ),
2960 (vec!["blz", "-v", "search", "test"], true, false, false),
2961 (vec!["blz", "--debug", "search", "test"], false, true, false),
2963 (
2965 vec!["blz", "--profile", "search", "test"],
2966 false,
2967 false,
2968 true,
2969 ),
2970 (
2972 vec!["blz", "--verbose", "--debug", "--profile", "search", "test"],
2973 true,
2974 true,
2975 true,
2976 ),
2977 (
2978 vec!["blz", "-v", "--debug", "--profile", "search", "test"],
2979 true,
2980 true,
2981 true,
2982 ),
2983 ];
2984
2985 for (args, expected_verbose, expected_debug, expected_profile) in bool_flag_cases {
2986 let cli = Cli::try_parse_from(args.clone()).unwrap();
2987
2988 assert_eq!(
2989 cli.verbose, expected_verbose,
2990 "Verbose flag mismatch for: {args:?}"
2991 );
2992 assert_eq!(
2993 cli.debug, expected_debug,
2994 "Debug flag mismatch for: {args:?}"
2995 );
2996 assert_eq!(
2997 cli.profile, expected_profile,
2998 "Profile flag mismatch for: {args:?}"
2999 );
3000 }
3001 }
3002
3003 #[test]
3004 fn test_cli_subcommand_specific_flags() {
3005 use clap::Parser;
3006
3007 let cli = Cli::try_parse_from(vec![
3009 "blz", "search", "rust", "--alias", "node", "--limit", "20", "--page", "2", "--top",
3010 "10", "--format", "json",
3011 ])
3012 .unwrap();
3013
3014 if let Some(Commands::Search {
3015 sources,
3016 limit,
3017 page,
3018 top,
3019 format,
3020 ..
3021 }) = cli.command
3022 {
3023 assert_eq!(sources, vec!["node"]);
3024 assert_eq!(limit, Some(20));
3025 assert_eq!(page, 2);
3026 assert!(top.is_some());
3027 assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
3028 } else {
3029 panic!("Expected search command");
3030 }
3031
3032 let cli = Cli::try_parse_from(vec![
3034 "blz",
3035 "add",
3036 "test",
3037 "https://example.com/llms.txt",
3038 "--yes",
3039 ])
3040 .unwrap();
3041
3042 if let Some(Commands::Add(args)) = cli.command {
3043 assert_eq!(args.alias.as_deref(), Some("test"));
3044 assert_eq!(args.url.as_deref(), Some("https://example.com/llms.txt"));
3045 assert!(args.aliases.is_empty());
3046 assert!(args.tags.is_empty());
3047 assert!(args.name.is_none());
3048 assert!(args.description.is_none());
3049 assert!(args.category.is_none());
3050 assert!(args.yes);
3051 assert!(!args.dry_run);
3052 assert!(args.manifest.is_none());
3053 } else {
3054 panic!("Expected add command");
3055 }
3056
3057 let cli = Cli::try_parse_from(vec![
3059 "blz",
3060 "get",
3061 "test",
3062 "--lines",
3063 "1-10",
3064 "--context",
3065 "5",
3066 ])
3067 .unwrap();
3068
3069 if let Some(Commands::Get {
3070 targets,
3071 lines,
3072 source,
3073 context,
3074 block,
3075 max_lines,
3076 format,
3077 copy: _,
3078 ..
3079 }) = cli.command
3080 {
3081 assert_eq!(targets, vec!["test".to_string()]);
3082 assert_eq!(lines, Some("1-10".to_string()));
3083 assert!(source.is_none());
3084 assert_eq!(context, Some(crate::cli::ContextMode::Symmetric(5)));
3085 assert!(!block);
3086 assert_eq!(max_lines, None);
3087 let _ = format; } else {
3089 panic!("Expected get command");
3090 }
3091 }
3092
3093 #[test]
3094 fn test_cli_special_argument_parsing() {
3095 use clap::Parser;
3096
3097 let line_range_cases = vec![
3099 "1",
3100 "1-10",
3101 "1:10",
3102 "1+5",
3103 "10,20,30",
3104 "1-5,10-15,20+5",
3105 "100:200",
3106 ];
3107
3108 for line_range in line_range_cases {
3109 let result = Cli::try_parse_from(vec!["blz", "get", "test", "--lines", line_range]);
3110 assert!(result.is_ok(), "Line range should parse: {line_range}");
3111 }
3112
3113 let url_cases = vec![
3115 "https://example.com/llms.txt",
3116 "http://localhost:3000/llms.txt",
3117 "https://api.example.com/v1/docs/llms.txt",
3118 "https://example.com/llms.txt?version=1",
3119 "https://raw.githubusercontent.com/user/repo/main/llms.txt",
3120 ];
3121
3122 for url in url_cases {
3123 let result = Cli::try_parse_from(vec!["blz", "add", "test", url]);
3124 assert!(result.is_ok(), "URL should parse: {url}");
3125 }
3126 }
3127
3128 #[test]
3129 fn test_cli_error_messages() {
3130 use clap::Parser;
3131
3132 let error_cases = vec![
3134 (vec!["blz", "add"], "missing"),
3136 (vec!["blz", "search"], "required"),
3137 (vec!["blz", "list", "--format", "invalid"], "invalid"),
3140 ];
3141
3142 for (args, expected_error_content) in error_cases {
3143 let result = Cli::try_parse_from(args.clone());
3144
3145 assert!(result.is_err(), "Should error for: {args:?}");
3146
3147 let error_msg = format!("{:?}", result.unwrap_err()).to_lowercase();
3148 assert!(
3149 error_msg.contains(expected_error_content),
3150 "Error message should contain '{expected_error_content}' for args {args:?}, got: {error_msg}"
3151 );
3152 }
3153 }
3154
3155 #[test]
3156 fn test_cli_argument_order_independence() {
3157 use clap::Parser;
3158
3159 let equivalent_commands = vec![
3161 vec![
3162 vec!["blz", "--verbose", "search", "rust"],
3163 vec!["blz", "search", "--verbose", "rust"],
3164 ],
3165 vec![
3166 vec!["blz", "--debug", "--profile", "search", "rust"],
3167 vec!["blz", "search", "rust", "--debug", "--profile"],
3168 vec!["blz", "--debug", "search", "--profile", "rust"],
3169 ],
3170 ];
3171
3172 for command_group in equivalent_commands {
3173 let mut parsed_commands = Vec::new();
3174
3175 for args in &command_group {
3176 let result = Cli::try_parse_from(args.clone());
3177 assert!(result.is_ok(), "Should parse: {args:?}");
3178 parsed_commands.push(result.unwrap());
3179 }
3180
3181 let first = &parsed_commands[0];
3183 for other in &parsed_commands[1..] {
3184 assert_eq!(first.verbose, other.verbose, "Verbose flags should match");
3185 assert_eq!(first.debug, other.debug, "Debug flags should match");
3186 assert_eq!(first.profile, other.profile, "Profile flags should match");
3187 }
3188 }
3189 }
3190
3191 #[test]
3194 fn test_completion_generation_for_all_shells() {
3195 use clap_complete::Shell;
3196
3197 let shells = vec![
3199 Shell::Bash,
3200 Shell::Zsh,
3201 Shell::Fish,
3202 Shell::PowerShell,
3203 Shell::Elvish,
3204 ];
3205
3206 for shell in shells {
3207 let result = std::panic::catch_unwind(|| {
3209 crate::commands::generate(shell);
3210 });
3211
3212 assert!(
3213 result.is_ok(),
3214 "Completion generation should not panic for {shell:?}"
3215 );
3216 }
3217 }
3218
3219 #[test]
3220 fn test_completion_cli_structure_contains_all_subcommands() {
3221 use crate::cli::Cli;
3222 use clap::CommandFactory;
3223
3224 let cmd = Cli::command();
3226
3227 let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
3228
3229 let expected_commands = vec![
3231 "search",
3232 "add",
3233 "list",
3234 "get",
3235 "refresh",
3236 "update",
3237 "remove",
3238 "lookup",
3239 "diff",
3240 "completions",
3241 ];
3242
3243 for expected_command in expected_commands {
3244 assert!(
3245 subcommands.contains(&expected_command),
3246 "CLI should have '{expected_command}' subcommand for completions"
3247 );
3248 }
3249
3250 let list_cmd = cmd
3252 .get_subcommands()
3253 .find(|sub| sub.get_name() == "list")
3254 .expect("Should have list command");
3255
3256 let aliases: Vec<&str> = list_cmd.get_all_aliases().collect();
3257 assert!(
3258 aliases.contains(&"sources"),
3259 "List command should have 'sources' alias"
3260 );
3261
3262 let rm_cmd = cmd
3264 .get_subcommands()
3265 .find(|sub| sub.get_name() == "rm")
3266 .expect("Should have rm command");
3267 assert_eq!(rm_cmd.get_name(), "rm");
3268
3269 let remove_cmd = cmd.get_subcommands().find(|sub| sub.get_name() == "remove");
3271 assert!(
3272 remove_cmd.is_some(),
3273 "Remove command should still exist (deprecated)"
3274 );
3275 }
3276
3277 #[test]
3278 fn test_completion_cli_structure_contains_global_flags() {
3279 use crate::cli::Cli;
3280 use clap::CommandFactory;
3281
3282 let cmd = Cli::command();
3284
3285 let global_args: Vec<&str> = cmd
3286 .get_arguments()
3287 .filter(|arg| arg.is_global_set())
3288 .map(|arg| arg.get_id().as_str())
3289 .collect();
3290
3291 let expected_global_flags = vec!["verbose", "debug", "profile"];
3293
3294 for expected_flag in expected_global_flags {
3295 assert!(
3296 global_args.contains(&expected_flag),
3297 "CLI should have global flag '{expected_flag}' for completions"
3298 );
3299 }
3300
3301 let verbose_arg = cmd
3303 .get_arguments()
3304 .find(|arg| arg.get_id().as_str() == "verbose")
3305 .expect("Should have verbose argument");
3306
3307 assert!(
3308 verbose_arg.get_long().is_some(),
3309 "Verbose should have long form --verbose"
3310 );
3311 assert_eq!(
3312 verbose_arg.get_long(),
3313 Some("verbose"),
3314 "Verbose long form should be --verbose"
3315 );
3316 assert!(verbose_arg.is_global_set(), "Verbose should be global");
3317 }
3318
3319 #[test]
3320 fn test_completion_cli_structure_contains_subcommand_flags() {
3321 use crate::cli::Cli;
3322 use clap::CommandFactory;
3323
3324 let cmd = Cli::command();
3325
3326 let search_cmd = cmd
3328 .get_subcommands()
3329 .find(|sub| sub.get_name() == "search")
3330 .expect("Should have search command");
3331
3332 let search_args: Vec<&str> = search_cmd
3333 .get_arguments()
3334 .map(|arg| arg.get_id().as_str())
3335 .collect();
3336
3337 let expected_search_flags = vec![
3338 "sources",
3339 "limit",
3340 "all",
3341 "page",
3342 "top",
3343 "format",
3344 "show",
3345 "no_summary",
3346 ];
3347 for expected_flag in expected_search_flags {
3348 assert!(
3349 search_args.contains(&expected_flag),
3350 "Search command should have '{expected_flag}' flag for completions"
3351 );
3352 }
3353
3354 let add_cmd = cmd
3356 .get_subcommands()
3357 .find(|sub| sub.get_name() == "add")
3358 .expect("Should have add command");
3359
3360 let add_args: Vec<&str> = add_cmd
3361 .get_arguments()
3362 .map(|arg| arg.get_id().as_str())
3363 .collect();
3364
3365 assert!(
3366 add_args.contains(&"yes"),
3367 "Add command should have 'yes' flag"
3368 );
3369
3370 let get_cmd = cmd
3372 .get_subcommands()
3373 .find(|sub| sub.get_name() == "get")
3374 .expect("Should have get command");
3375
3376 let get_args: Vec<&str> = get_cmd
3377 .get_arguments()
3378 .map(|arg| arg.get_id().as_str())
3379 .collect();
3380
3381 assert!(
3382 get_args.contains(&"lines"),
3383 "Get command should have 'lines' flag"
3384 );
3385 assert!(
3386 get_args.contains(&"context"),
3387 "Get command should have 'context' flag"
3388 );
3389
3390 let format_arg = search_cmd
3392 .get_arguments()
3393 .find(|arg| arg.get_id().as_str() == "format")
3394 .expect("Search should have format argument");
3395
3396 assert!(
3397 !format_arg.get_possible_values().is_empty(),
3398 "Format argument should have possible values for completion"
3399 );
3400 }
3401
3402 #[test]
3403 fn test_completion_generation_consistency() {
3404 use clap_complete::Shell;
3405
3406 let shells_to_test = vec![Shell::Bash, Shell::Zsh, Shell::Fish];
3408
3409 for shell in shells_to_test {
3410 for _ in 0..3 {
3412 let result = std::panic::catch_unwind(|| {
3413 crate::commands::generate(shell);
3414 });
3415 assert!(
3416 result.is_ok(),
3417 "Completion generation should be consistent for {shell:?}"
3418 );
3419 }
3420 }
3421 }
3422
3423 #[test]
3424 fn test_completion_command_parsing() {
3425 use clap::Parser;
3426
3427 let shell_completions = vec![
3429 vec!["blz", "completions", "bash"],
3430 vec!["blz", "completions", "zsh"],
3431 vec!["blz", "completions", "fish"],
3432 vec!["blz", "completions", "powershell"],
3433 vec!["blz", "completions", "elvish"],
3434 ];
3435
3436 for args in shell_completions {
3437 let result = Cli::try_parse_from(args.clone());
3438 assert!(result.is_ok(), "Completions command should parse: {args:?}");
3439
3440 if let Ok(cli) = result {
3441 match cli.command {
3442 Some(Commands::Completions { shell: _, .. }) => {
3443 },
3445 other => {
3446 panic!("Expected Completions command, got: {other:?} for args: {args:?}");
3447 },
3448 }
3449 }
3450 }
3451 }
3452
3453 #[test]
3454 fn test_completion_invalid_shell_handling() {
3455 use clap::Parser;
3456
3457 let invalid_shells = vec![
3459 vec!["blz", "completions", "invalid"],
3460 vec!["blz", "completions", "cmd"],
3461 vec!["blz", "completions", ""],
3462 vec!["blz", "completions", "bash_typo"],
3463 vec!["blz", "completions", "ZSH"], ];
3465
3466 for args in invalid_shells {
3467 let result = Cli::try_parse_from(args.clone());
3468 assert!(
3469 result.is_err(),
3470 "Invalid shell should be rejected: {args:?}"
3471 );
3472 }
3473 }
3474
3475 #[test]
3476 fn test_completion_help_generation() {
3477 use clap::Parser;
3478
3479 let help_commands = vec![
3481 vec!["blz", "completions", "--help"],
3482 vec!["blz", "completions", "-h"],
3483 ];
3484
3485 for help_cmd in help_commands {
3486 let result = Cli::try_parse_from(help_cmd.clone());
3487
3488 if let Err(error) = result {
3489 assert_eq!(
3490 error.kind(),
3491 clap::error::ErrorKind::DisplayHelp,
3492 "Completions help should display help: {help_cmd:?}"
3493 );
3494
3495 let help_text = error.to_string();
3496 assert!(
3497 help_text.contains("completions"),
3498 "Help text should mention completions"
3499 );
3500 assert!(
3501 help_text.contains("shell") || help_text.contains("Shell"),
3502 "Help text should mention shell parameter"
3503 );
3504 } else {
3505 panic!("Help command should not succeed: {help_cmd:?}");
3506 }
3507 }
3508 }
3509
3510 #[test]
3511 fn test_completion_integration_with_clap() {
3512 use crate::cli::Cli;
3513 use clap::CommandFactory;
3514
3515 let cmd = Cli::command();
3517
3518 assert_eq!(cmd.get_name(), "blz", "Command name should be 'blz'");
3520
3521 let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
3523
3524 let expected_subcommands = vec![
3525 "completions",
3526 "add",
3527 "lookup",
3528 "search",
3529 "get",
3530 "list",
3531 "refresh",
3532 "update",
3533 "remove",
3534 "diff",
3535 ];
3536
3537 for expected in expected_subcommands {
3538 assert!(
3539 subcommands.contains(&expected),
3540 "Command should have subcommand '{expected}', found: {subcommands:?}"
3541 );
3542 }
3543
3544 let completions_cmd = cmd
3546 .get_subcommands()
3547 .find(|sub| sub.get_name() == "completions")
3548 .expect("Should have completions subcommand");
3549
3550 let shell_arg = completions_cmd
3551 .get_arguments()
3552 .find(|arg| arg.get_id() == "shell")
3553 .expect("Completions should have shell argument");
3554
3555 assert!(
3556 shell_arg.is_positional(),
3557 "Shell argument should be positional"
3558 );
3559 }
3560
3561 #[test]
3562 fn test_multi_source_search_parsing() {
3563 use clap::Parser;
3564
3565 let cli = Cli::try_parse_from(vec![
3567 "blz",
3568 "search",
3569 "hooks",
3570 "--source",
3571 "react,vue,svelte",
3572 ])
3573 .unwrap();
3574
3575 if let Some(Commands::Search { sources, .. }) = cli.command {
3576 assert_eq!(sources, vec!["react", "vue", "svelte"]);
3577 } else {
3578 panic!("Expected search command");
3579 }
3580 }
3581
3582 #[test]
3583 fn test_single_source_search_parsing() {
3584 use clap::Parser;
3585
3586 let cli = Cli::try_parse_from(vec!["blz", "search", "hooks", "--source", "react"]).unwrap();
3588
3589 if let Some(Commands::Search { sources, .. }) = cli.command {
3590 assert_eq!(sources, vec!["react"]);
3591 } else {
3592 panic!("Expected search command");
3593 }
3594 }
3595
3596 #[test]
3597 fn test_no_source_search_parsing() {
3598 use clap::Parser;
3599
3600 let cli = Cli::try_parse_from(vec!["blz", "search", "hooks"]).unwrap();
3602
3603 if let Some(Commands::Search { sources, .. }) = cli.command {
3604 assert!(sources.is_empty());
3605 } else {
3606 panic!("Expected search command");
3607 }
3608 }
3609
3610 #[test]
3611 fn test_multi_source_shorthand_parsing() {
3612 use clap::Parser;
3613
3614 let cli = Cli::try_parse_from(vec!["blz", "search", "api", "-s", "bun,node,deno"]).unwrap();
3616
3617 if let Some(Commands::Search { sources, .. }) = cli.command {
3618 assert_eq!(sources, vec!["bun", "node", "deno"]);
3619 } else {
3620 panic!("Expected search command");
3621 }
3622 }
3623
3624 #[test]
3625 fn test_get_command_with_source_flag() {
3626 use clap::Parser;
3627
3628 let cli = Cli::try_parse_from(vec![
3629 "blz", "get", "meta", "--lines", "1-3", "--source", "bun",
3630 ])
3631 .unwrap();
3632
3633 if let Some(Commands::Get {
3634 targets,
3635 source,
3636 lines,
3637 ..
3638 }) = cli.command
3639 {
3640 assert_eq!(targets, vec!["meta".to_string()]);
3641 assert_eq!(source.as_deref(), Some("bun"));
3642 assert_eq!(lines.as_deref(), Some("1-3"));
3643 } else {
3644 panic!("Expected get command");
3645 }
3646 }
3647
3648 #[test]
3649 fn test_get_command_with_source_shorthand() {
3650 use clap::Parser;
3651
3652 let cli = Cli::try_parse_from(vec!["blz", "get", "meta:4-6", "-s", "canonical"]).unwrap();
3653
3654 if let Some(Commands::Get {
3655 targets, source, ..
3656 }) = cli.command
3657 {
3658 assert_eq!(targets, vec!["meta:4-6".to_string()]);
3659 assert_eq!(source.as_deref(), Some("canonical"));
3660 } else {
3661 panic!("Expected get command");
3662 }
3663 }
3664
3665 #[test]
3666 fn preprocess_preserves_following_flags_for_optional_context() {
3667 use crate::output::OutputFormat;
3668 use clap::Parser;
3669
3670 let raw = to_string_vec(&["blz", "foo", "--context", "--json"]);
3672 let processed = preprocess_args_from(&raw);
3673 let expected = to_string_vec(&["blz", "search", "foo", "--context", "--format", "json"]);
3674 assert_eq!(processed, expected);
3675
3676 let cli = Cli::try_parse_from(processed).unwrap();
3678 match cli.command {
3679 Some(Commands::Search {
3680 context, format, ..
3681 }) => {
3682 assert!(context.is_some()); assert_eq!(format.format, Some(OutputFormat::Json));
3684 },
3685 _ => panic!("expected search command"),
3686 }
3687 }
3688
3689 #[test]
3690 fn preprocess_preserves_following_flags_for_short_context() {
3691 use clap::Parser;
3692
3693 let raw = to_string_vec(&["blz", "hooks", "-C", "--source", "react"]);
3695 let processed = preprocess_args_from(&raw);
3696 let expected = to_string_vec(&["blz", "search", "hooks", "-C", "--source", "react"]);
3697 assert_eq!(processed, expected);
3698
3699 let cli = Cli::try_parse_from(processed).unwrap();
3701 match cli.command {
3702 Some(Commands::Search {
3703 context, sources, ..
3704 }) => {
3705 assert!(context.is_some()); assert_eq!(sources, vec!["react"]);
3707 },
3708 _ => panic!("expected search command"),
3709 }
3710 }
3711
3712 #[test]
3713 fn preprocess_preserves_following_flags_for_after_context() {
3714 use clap::Parser;
3715
3716 let raw = to_string_vec(&["blz", "api", "-A", "--limit", "5"]);
3718 let processed = preprocess_args_from(&raw);
3719 let expected = to_string_vec(&["blz", "search", "api", "-A", "--limit", "5"]);
3720 assert_eq!(processed, expected);
3721
3722 let cli = Cli::try_parse_from(processed).unwrap();
3724 match cli.command {
3725 Some(Commands::Search {
3726 after_context,
3727 limit,
3728 ..
3729 }) => {
3730 assert!(after_context.is_some()); assert_eq!(limit, Some(5));
3732 },
3733 _ => panic!("expected search command"),
3734 }
3735 }
3736}