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::sync::Arc;
15
16pub mod args;
17mod cli;
18mod commands;
19pub mod config;
20pub mod error;
21pub mod generate;
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
40pub async fn run() -> Result<()> {
46 std::panic::set_hook(Box::new(|info| {
48 let msg = info.to_string();
49 if msg.contains("Broken pipe") || msg.contains("broken pipe") {
50 std::process::exit(0);
52 }
53 eprintln!("{msg}");
55 }));
56
57 utils::process_guard::spawn_parent_exit_guard();
59
60 let mut cli = Cli::parse();
61
62 if let Some(target) = cli.prompt.clone() {
63 prompt::emit(&target, cli.command.as_ref())?;
64 return Ok(());
65 }
66
67 initialize_logging(&cli)?;
68
69 let args: Vec<String> = std::env::args().collect();
70 let mut cli_preferences = preferences::load();
71 apply_preference_defaults(&mut cli, &cli_preferences, &args);
72
73 let metrics = PerformanceMetrics::default();
74
75 #[cfg(feature = "flamegraph")]
76 let profiler_guard = start_flamegraph_if_requested(&cli);
77
78 execute_command(cli.clone(), metrics.clone(), &mut cli_preferences).await?;
79
80 #[cfg(feature = "flamegraph")]
81 stop_flamegraph_if_started(profiler_guard);
82
83 print_diagnostics(&cli, &metrics);
84
85 if let Err(err) = preferences::save(&cli_preferences) {
86 warn!("failed to persist CLI preferences: {err}");
87 }
88
89 Ok(())
90}
91
92fn initialize_logging(cli: &Cli) -> Result<()> {
93 let mut level = if cli.verbose || cli.debug {
95 Level::DEBUG
96 } else if cli.quiet {
97 Level::ERROR
98 } else {
99 Level::WARN
100 };
101
102 let mut machine_output = false;
105 if !(cli.verbose || cli.debug) {
106 #[allow(deprecated)]
107 let command_format = match &cli.command {
108 Some(
109 Commands::Search { format, .. }
110 | Commands::Find { format, .. }
111 | Commands::List { format, .. }
112 | Commands::Stats { format, .. }
113 | Commands::History { format, .. }
114 | Commands::Lookup { format, .. }
115 | Commands::Get { format, .. }
116 | Commands::Info { format, .. }
117 | Commands::Completions { format, .. },
118 ) => Some(format.resolve(cli.quiet)),
119 Some(Commands::Query(args)) => Some(args.format.resolve(cli.quiet)),
120 Some(Commands::Map(args)) => Some(args.format.resolve(cli.quiet)),
121 Some(Commands::Check(args)) => Some(args.format.resolve(cli.quiet)),
122 _ => None,
123 };
124
125 if let Some(fmt) = command_format {
126 if matches!(
127 fmt,
128 crate::output::OutputFormat::Json | crate::output::OutputFormat::Jsonl
129 ) {
130 level = Level::ERROR;
131 machine_output = true;
132 }
133 }
134 }
135
136 let subscriber = FmtSubscriber::builder()
137 .with_max_level(level)
138 .with_target(false)
139 .with_thread_ids(false)
140 .with_thread_names(false)
141 .with_writer(std::io::stderr)
142 .finish();
143
144 tracing::subscriber::set_global_default(subscriber)?;
145
146 let env_no_color = std::env::var("NO_COLOR").ok().is_some();
148 if cli.no_color || env_no_color || machine_output {
149 color_control::set_override(false);
150 }
151 Ok(())
152}
153
154#[cfg(feature = "flamegraph")]
155fn start_flamegraph_if_requested(cli: &Cli) -> Option<pprof::ProfilerGuard<'static>> {
156 if cli.flamegraph {
157 match start_profiling() {
158 Ok(guard) => {
159 println!("🔥 CPU profiling started - flamegraph will be generated");
160 Some(guard)
161 },
162 Err(e) => {
163 eprintln!("Failed to start profiling: {e}");
164 None
165 },
166 }
167 } else {
168 None
169 }
170}
171
172#[cfg(feature = "flamegraph")]
173fn stop_flamegraph_if_started(guard: Option<pprof::ProfilerGuard<'static>>) {
174 if let Some(guard) = guard {
175 if let Err(e) = stop_profiling_and_report(&guard) {
176 eprintln!("Failed to generate flamegraph: {e}");
177 }
178 }
179}
180
181async fn execute_command(
182 cli: Cli,
183 metrics: PerformanceMetrics,
184 prefs: &mut CliPreferences,
185) -> Result<()> {
186 let quiet = cli.quiet;
187 match cli.command {
188 Some(Commands::Instruct) => {
189 prompt::emit("__global__", Some(&Commands::Instruct))?;
190 eprintln!("`blz instruct` is deprecated. Use `blz --prompt` instead.");
191 },
192 Some(Commands::Completions {
193 shell,
194 list,
195 format,
196 }) => {
197 handle_completions(shell, list, format.resolve(quiet));
198 },
199 Some(Commands::Docs { command }) => {
200 handle_docs(command, quiet, metrics.clone(), prefs).await?;
201 },
202 Some(Commands::ClaudePlugin { command }) => handle_claude_plugin(command)?,
203 Some(Commands::Alias { command }) => handle_alias(command).await?,
204 Some(Commands::Add(args)) => handle_add(args, quiet, metrics).await?,
205 Some(Commands::Lookup {
206 query,
207 format,
208 limit,
209 }) => {
210 dispatch_lookup(query, format, limit, quiet, metrics).await?;
211 },
212 Some(Commands::Registry { command }) => handle_registry(command, quiet, metrics).await?,
213 Some(cmd @ Commands::Search { .. }) => dispatch_search(cmd, quiet, metrics, prefs).await?,
214 Some(Commands::History {
215 limit,
216 format,
217 clear,
218 clear_before,
219 }) => {
220 dispatch_history(limit, &format, clear, clear_before.as_deref(), quiet, prefs)?;
221 },
222 Some(cmd @ Commands::Get { .. }) => dispatch_get(cmd, quiet).await?,
223 Some(Commands::Query(args)) => handle_query(args, quiet, prefs, metrics.clone()).await?,
224 Some(Commands::Map(args)) => dispatch_map(args, quiet).await?,
225 Some(Commands::Sync(args)) => dispatch_sync(args, quiet, metrics).await?,
226 Some(Commands::Check(args)) => {
227 commands::check_source(args.alias, args.all, args.format.resolve(quiet)).await?;
228 },
229 Some(Commands::Rm(args)) => commands::rm_source(vec![args.alias], args.yes).await?,
230 #[allow(deprecated)]
231 Some(cmd @ Commands::Find { .. }) => {
232 dispatch_find(cmd, quiet, prefs, metrics.clone()).await?;
233 },
234 Some(Commands::Info { alias, format }) => {
235 commands::execute_info(&alias, format.resolve(quiet)).await?;
236 },
237 Some(Commands::List {
238 format,
239 status,
240 details,
241 limit,
242 }) => {
243 dispatch_list(format, status, details, limit, quiet).await?;
244 },
245 Some(Commands::Stats { format, limit }) => {
246 commands::show_stats(format.resolve(quiet), limit)?;
247 },
248 #[allow(deprecated)]
249 Some(Commands::Validate { alias, all, format }) => {
250 dispatch_validate(alias, all, format, quiet).await?;
251 },
252 Some(Commands::Doctor { format, fix }) => {
253 commands::run_doctor(format.resolve(quiet), fix).await?;
254 },
255 #[allow(deprecated)]
256 Some(cmd @ Commands::Refresh { .. }) => dispatch_refresh(cmd, quiet, metrics).await?,
257 #[allow(deprecated)]
258 Some(cmd @ Commands::Update { .. }) => dispatch_update(cmd, quiet, metrics).await?,
259 #[allow(deprecated)]
260 Some(Commands::Remove { alias, yes }) => dispatch_remove(alias, yes, quiet).await?,
261 Some(Commands::Clear { force }) => commands::clear_cache(force)?,
262 Some(Commands::Diff { alias, since }) => {
263 commands::show_diff(&alias, since.as_deref()).await?;
264 },
265 Some(Commands::McpServer) => commands::mcp_server().await?,
266 Some(Commands::Anchor { command }) => handle_anchor(command, quiet).await?,
267 #[allow(deprecated)]
268 Some(cmd @ Commands::Toc { .. }) => dispatch_toc(cmd, quiet).await?,
269 None => {
270 Cli::command().print_help()?;
272 },
273 }
274 Ok(())
275}
276
277#[allow(clippy::too_many_lines)]
282async fn dispatch_search(
283 cmd: Commands,
284 quiet: bool,
285 metrics: PerformanceMetrics,
286 prefs: &mut CliPreferences,
287) -> Result<()> {
288 let Commands::Search {
289 query,
290 sources,
291 next,
292 previous,
293 last,
294 limit,
295 all,
296 page,
297 top,
298 heading_level,
299 format,
300 show,
301 no_summary,
302 score_precision,
303 snippet_lines,
304 max_chars,
305 context,
306 context_deprecated,
307 after_context,
308 before_context,
309 block,
310 max_lines,
311 headings_only,
312 no_history,
313 copy,
314 timing,
315 } = cmd
316 else {
317 unreachable!("dispatch_search called with non-Search command");
318 };
319
320 let resolved_format = format.resolve(quiet);
321 let merged_context =
322 crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
323
324 handle_search(
325 query,
326 sources,
327 next,
328 previous,
329 last,
330 limit,
331 all,
332 page,
333 top,
334 heading_level,
335 resolved_format,
336 show,
337 no_summary,
338 score_precision,
339 snippet_lines,
340 max_chars,
341 merged_context,
342 block,
343 max_lines,
344 headings_only,
345 no_history,
346 copy,
347 timing,
348 quiet,
349 metrics,
350 prefs,
351 )
352 .await
353}
354
355#[allow(clippy::too_many_lines)]
357async fn dispatch_get(cmd: Commands, quiet: bool) -> Result<()> {
358 let Commands::Get {
359 targets,
360 lines,
361 source,
362 context,
363 context_deprecated,
364 after_context,
365 before_context,
366 block,
367 max_lines,
368 format,
369 copy,
370 } = cmd
371 else {
372 unreachable!("dispatch_get called with non-Get command");
373 };
374
375 handle_get(
376 targets,
377 lines,
378 source,
379 context,
380 context_deprecated,
381 after_context,
382 before_context,
383 block,
384 max_lines,
385 format.resolve(quiet),
386 copy,
387 )
388 .await
389}
390
391#[allow(clippy::too_many_lines)]
393async fn dispatch_find(
394 cmd: Commands,
395 quiet: bool,
396 prefs: &mut CliPreferences,
397 metrics: PerformanceMetrics,
398) -> Result<()> {
399 #[allow(deprecated)]
400 let Commands::Find {
401 inputs,
402 sources,
403 limit,
404 all,
405 page,
406 top,
407 heading_level,
408 format,
409 show,
410 no_summary,
411 score_precision,
412 snippet_lines,
413 max_chars,
414 context,
415 context_deprecated,
416 after_context,
417 before_context,
418 block,
419 max_lines,
420 headings_only,
421 no_history,
422 copy,
423 timing,
424 } = cmd
425 else {
426 unreachable!("dispatch_find called with non-Find command");
427 };
428
429 handle_find(
430 inputs,
431 sources,
432 limit,
433 all,
434 page,
435 top,
436 heading_level,
437 format.resolve(quiet),
438 show,
439 no_summary,
440 score_precision,
441 snippet_lines,
442 max_chars,
443 context,
444 context_deprecated,
445 after_context,
446 before_context,
447 block,
448 max_lines,
449 headings_only,
450 no_history,
451 copy,
452 timing,
453 quiet,
454 prefs,
455 metrics,
456 )
457 .await
458}
459
460#[allow(deprecated)]
462async fn dispatch_toc(cmd: Commands, quiet: bool) -> Result<()> {
463 let Commands::Toc {
464 alias,
465 format,
466 filter,
467 max_depth,
468 heading_level,
469 sources,
470 all,
471 tree,
472 anchors,
473 show_anchors,
474 next,
475 previous,
476 last,
477 limit,
478 page,
479 } = cmd
480 else {
481 unreachable!("dispatch_toc called with non-Toc command");
482 };
483
484 handle_toc(
485 alias,
486 sources,
487 all,
488 format.resolve(quiet),
489 anchors,
490 show_anchors,
491 limit,
492 max_depth,
493 heading_level,
494 filter,
495 tree,
496 next,
497 previous,
498 last,
499 page,
500 )
501 .await
502}
503
504#[allow(deprecated)]
506async fn dispatch_refresh(cmd: Commands, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
507 let Commands::Refresh {
508 aliases,
509 all,
510 yes: _,
511 reindex,
512 filter,
513 no_filter,
514 } = cmd
515 else {
516 unreachable!("dispatch_refresh called with non-Refresh command");
517 };
518
519 if !utils::cli_args::deprecation_warnings_suppressed() {
520 eprintln!(
521 "{}",
522 "Warning: 'refresh' is deprecated, use 'sync' instead".yellow()
523 );
524 }
525
526 handle_refresh(aliases, all, reindex, filter, no_filter, metrics, quiet).await
527}
528
529async fn dispatch_map(args: crate::cli::MapArgs, quiet: bool) -> Result<()> {
531 commands::show_map(
532 args.alias.as_deref(),
533 &args.sources,
534 args.all,
535 args.format.resolve(quiet),
536 args.anchors,
537 args.show_anchors,
538 args.limit,
539 args.max_depth,
540 args.heading_level.as_ref(),
541 args.filter.as_deref(),
542 args.tree,
543 args.next,
544 args.previous,
545 args.last,
546 args.page,
547 )
548 .await
549}
550
551async fn dispatch_sync(
553 args: crate::cli::SyncArgs,
554 quiet: bool,
555 metrics: PerformanceMetrics,
556) -> Result<()> {
557 commands::sync_source(
558 args.aliases,
559 args.all,
560 args.yes,
561 args.reindex,
562 args.filter,
563 args.no_filter,
564 metrics,
565 quiet,
566 )
567 .await
568}
569
570fn dispatch_history(
572 limit: usize,
573 format: &crate::utils::cli_args::FormatArg,
574 clear: bool,
575 clear_before: Option<&str>,
576 quiet: bool,
577 prefs: &CliPreferences,
578) -> Result<()> {
579 commands::show_history(prefs, limit, format.resolve(quiet), clear, clear_before)
580}
581
582async fn dispatch_list(
584 format: crate::utils::cli_args::FormatArg,
585 status: bool,
586 details: bool,
587 limit: Option<usize>,
588 quiet: bool,
589) -> Result<()> {
590 commands::list_sources(format.resolve(quiet), status, details, limit).await
591}
592
593#[allow(deprecated)]
595async fn dispatch_validate(
596 alias: Option<String>,
597 all: bool,
598 format: crate::utils::cli_args::FormatArg,
599 quiet: bool,
600) -> Result<()> {
601 if !utils::cli_args::deprecation_warnings_suppressed() {
602 eprintln!(
603 "{}",
604 "Warning: 'validate' is deprecated, use 'check' instead".yellow()
605 );
606 }
607 commands::validate_source(alias, all, format.resolve(quiet)).await
608}
609
610#[allow(deprecated)]
612async fn dispatch_update(cmd: Commands, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
613 let Commands::Update {
614 aliases,
615 all,
616 yes: _,
617 } = cmd
618 else {
619 unreachable!("dispatch_update called with non-Update command");
620 };
621
622 if !utils::cli_args::deprecation_warnings_suppressed() {
623 eprintln!(
624 "{}",
625 "Warning: 'update' is deprecated, use 'refresh' instead".yellow()
626 );
627 }
628 handle_refresh(aliases, all, false, None, false, metrics, quiet).await
629}
630
631#[allow(deprecated)]
633async fn dispatch_remove(alias: String, yes: bool, quiet: bool) -> Result<()> {
634 if !utils::cli_args::deprecation_warnings_suppressed() {
635 eprintln!(
636 "{}",
637 "Warning: 'remove' is deprecated, use 'rm' instead".yellow()
638 );
639 }
640 commands::remove_source(&alias, yes, quiet).await
641}
642
643async fn dispatch_lookup(
645 query: String,
646 format: crate::utils::cli_args::FormatArg,
647 limit: Option<usize>,
648 quiet: bool,
649 metrics: PerformanceMetrics,
650) -> Result<()> {
651 commands::lookup_registry(&query, metrics, quiet, format.resolve(quiet), limit).await
652}
653
654fn handle_completions(
655 shell: Option<clap_complete::Shell>,
656 list: bool,
657 format: crate::output::OutputFormat,
658) {
659 if list {
660 commands::list_supported(format);
661 } else if let Some(shell) = shell {
662 commands::generate(shell);
663 } else {
664 commands::list_supported(format);
665 }
666}
667
668async fn handle_add(
669 args: crate::cli::AddArgs,
670 quiet: bool,
671 metrics: PerformanceMetrics,
672) -> Result<()> {
673 if let Some(manifest) = &args.manifest {
674 commands::add_manifest(
675 manifest,
676 &args.only,
677 metrics,
678 commands::AddFlowOptions::new(args.dry_run, quiet, args.no_language_filter),
679 )
680 .await
681 } else {
682 let alias = args
683 .alias
684 .as_deref()
685 .ok_or_else(|| anyhow!("alias is required when manifest is not provided"))?;
686 let url = args
687 .url
688 .as_deref()
689 .ok_or_else(|| anyhow!("url is required when manifest is not provided"))?;
690
691 let descriptor = DescriptorInput::from_cli_inputs(
692 &args.aliases,
693 args.name.as_deref(),
694 args.description.as_deref(),
695 args.category.as_deref(),
696 &args.tags,
697 );
698
699 let request = AddRequest::new(
700 alias.to_string(),
701 url.to_string(),
702 descriptor,
703 args.dry_run,
704 quiet,
705 metrics,
706 args.no_language_filter,
707 );
708
709 commands::add_source(request).await
710 }
711}
712
713#[allow(clippy::too_many_arguments)]
714async fn handle_get(
715 targets: Vec<String>,
716 lines: Option<String>,
717 source: Option<String>,
718 context: Option<crate::cli::ContextMode>,
719 context_deprecated: Option<crate::cli::ContextMode>,
720 after_context: Option<usize>,
721 before_context: Option<usize>,
722 block: bool,
723 max_lines: Option<usize>,
724 format: crate::output::OutputFormat,
725 copy: bool,
726) -> Result<()> {
727 let request_specs = parse_get_targets(&targets, lines.as_deref(), source)?;
728
729 let merged_context =
730 crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
731
732 commands::get_lines(
733 &request_specs,
734 merged_context.as_ref(),
735 block,
736 max_lines,
737 format,
738 copy,
739 )
740 .await
741}
742
743fn parse_get_targets(
745 targets: &[String],
746 lines: Option<&str>,
747 source: Option<String>,
748) -> Result<Vec<RequestSpec>> {
749 if targets.is_empty() {
750 anyhow::bail!("At least one target is required. Use format: alias[:ranges]");
751 }
752
753 if lines.is_some() && targets.len() > 1 {
754 anyhow::bail!(
755 "--lines can only be combined with a single alias. \
756 Provide explicit ranges via colon syntax for each additional target."
757 );
758 }
759
760 let mut request_specs = Vec::with_capacity(targets.len());
761 for (idx, target) in targets.iter().enumerate() {
762 let spec = parse_single_target(target, idx, lines)?;
763 request_specs.push(spec);
764 }
765
766 apply_source_override(&mut request_specs, source)?;
767 Ok(request_specs)
768}
769
770fn parse_single_target(target: &str, idx: usize, lines: Option<&str>) -> Result<RequestSpec> {
772 let trimmed = target.trim();
773 if trimmed.is_empty() {
774 anyhow::bail!("Alias at position {} cannot be empty.", idx + 1);
775 }
776
777 if let Some((alias_part, range_part)) = trimmed.split_once(':') {
778 let trimmed_alias = alias_part.trim();
779 if trimmed_alias.is_empty() {
780 anyhow::bail!(
781 "Alias at position {} cannot be empty. Use syntax like 'bun:120-142'.",
782 idx + 1
783 );
784 }
785 if range_part.is_empty() {
786 anyhow::bail!(
787 "Alias '{trimmed_alias}' is missing a range. \
788 Use syntax like '{trimmed_alias}:120-142'."
789 );
790 }
791 Ok(RequestSpec {
792 alias: trimmed_alias.to_string(),
793 line_expression: range_part.trim().to_string(),
794 })
795 } else {
796 let Some(line_expr) = lines else {
797 anyhow::bail!(
798 "Missing line specification for alias '{trimmed}'. \
799 Use '{trimmed}:1-3' or provide --lines."
800 );
801 };
802 Ok(RequestSpec {
803 alias: trimmed.to_string(),
804 line_expression: line_expr.to_string(),
805 })
806 }
807}
808
809fn apply_source_override(request_specs: &mut [RequestSpec], source: Option<String>) -> Result<()> {
811 if let Some(explicit_source) = source {
812 if request_specs.len() > 1 {
813 anyhow::bail!("--source cannot be combined with multiple alias targets.");
814 }
815 if let Some(first) = request_specs.first_mut() {
816 first.alias = explicit_source;
817 }
818 }
819 Ok(())
820}
821
822async fn handle_query(
823 args: crate::cli::QueryArgs,
824 quiet: bool,
825 prefs: &mut CliPreferences,
826 metrics: PerformanceMetrics,
827) -> Result<()> {
828 let resolved_format = args.format.resolve(quiet);
829 let merged_context = crate::cli::merge_context_flags(
830 args.context,
831 args.context_deprecated,
832 args.after_context,
833 args.before_context,
834 );
835
836 commands::query(
837 &args.inputs,
838 &args.sources,
839 args.limit,
840 args.all,
841 args.page,
842 false, args.top,
844 args.heading_level.clone(),
845 resolved_format,
846 &args.show,
847 args.no_summary,
848 args.score_precision,
849 args.snippet_lines,
850 args.max_chars,
851 merged_context.as_ref(),
852 args.block,
853 args.max_lines,
854 args.no_history,
855 args.copy,
856 quiet,
857 args.headings_only,
858 args.timing,
859 Some(prefs),
860 metrics,
861 None,
862 )
863 .await
864}
865
866#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
867async fn handle_find(
868 inputs: Vec<String>,
869 sources: Vec<String>,
870 limit: Option<usize>,
871 all: bool,
872 page: usize,
873 top: Option<u8>,
874 heading_level: Option<String>,
875 format: crate::output::OutputFormat,
876 show: Vec<crate::cli::ShowComponent>,
877 no_summary: bool,
878 score_precision: Option<u8>,
879 snippet_lines: u8,
880 max_chars: Option<usize>,
881 context: Option<crate::cli::ContextMode>,
882 context_deprecated: Option<crate::cli::ContextMode>,
883 after_context: Option<usize>,
884 before_context: Option<usize>,
885 block: bool,
886 max_lines: Option<usize>,
887 headings_only: bool,
888 no_history: bool,
889 copy: bool,
890 timing: bool,
891 quiet: bool,
892 prefs: &mut CliPreferences,
893 metrics: PerformanceMetrics,
894) -> Result<()> {
895 if !utils::cli_args::deprecation_warnings_suppressed() {
896 eprintln!(
897 "{}",
898 "Warning: 'find' is deprecated, use 'query' or 'get' instead".yellow()
899 );
900 }
901
902 let merged_context =
903 crate::cli::merge_context_flags(context, context_deprecated, after_context, before_context);
904
905 commands::find(
906 &inputs,
907 &sources,
908 limit,
909 all,
910 page,
911 false, top,
913 heading_level,
914 format,
915 &show,
916 no_summary,
917 score_precision,
918 snippet_lines,
919 max_chars,
920 merged_context.as_ref(),
921 block,
922 max_lines,
923 no_history,
924 copy,
925 quiet,
926 headings_only,
927 timing,
928 Some(prefs),
929 metrics,
930 None,
931 )
932 .await
933}
934
935#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
936async fn handle_toc(
937 alias: Option<String>,
938 sources: Vec<String>,
939 all: bool,
940 format: crate::output::OutputFormat,
941 anchors: bool,
942 show_anchors: bool,
943 limit: Option<usize>,
944 max_depth: Option<u8>,
945 heading_level: Option<crate::utils::heading_filter::HeadingLevelFilter>,
946 filter: Option<String>,
947 tree: bool,
948 next: bool,
949 previous: bool,
950 last: bool,
951 page: usize,
952) -> Result<()> {
953 if !utils::cli_args::deprecation_warnings_suppressed() {
954 eprintln!(
955 "{}",
956 "Warning: 'toc' is deprecated, use 'map' instead".yellow()
957 );
958 }
959
960 commands::show_toc(
961 alias.as_deref(),
962 &sources,
963 all,
964 format,
965 anchors,
966 show_anchors,
967 limit,
968 max_depth,
969 heading_level.as_ref(),
970 filter.as_deref(),
971 tree,
972 next,
973 previous,
974 last,
975 page,
976 )
977 .await
978}
979
980async fn handle_docs(
981 command: Option<DocsCommands>,
982 quiet: bool,
983 metrics: PerformanceMetrics,
984 _prefs: &mut CliPreferences,
985) -> Result<()> {
986 match command {
987 Some(DocsCommands::Search(args)) => docs_search(args, quiet, metrics.clone()).await?,
988 Some(DocsCommands::Sync {
989 force,
990 quiet: sync_quiet,
991 }) => docs_sync(force, sync_quiet, metrics.clone())?,
992 Some(DocsCommands::Overview) => {
993 docs_overview(quiet, metrics.clone())?;
994 },
995 Some(DocsCommands::Cat) => {
996 docs_cat(metrics.clone())?;
997 },
998 Some(DocsCommands::Export { format }) => {
999 docs_export(Some(format))?;
1000 },
1001 None => {
1002 docs_overview(quiet, metrics.clone())?;
1004 },
1005 }
1006
1007 Ok(())
1008}
1009
1010fn handle_claude_plugin(command: ClaudePluginCommands) -> Result<()> {
1011 match command {
1012 ClaudePluginCommands::Install { scope } => {
1013 commands::install_local_plugin(scope)?;
1014 },
1015 }
1016
1017 Ok(())
1018}
1019
1020async fn docs_search(args: DocsSearchArgs, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1021 sync_and_report(false, quiet, metrics.clone())?;
1022 let query = args.query.join(" ").trim().to_string();
1023 if query.is_empty() {
1024 anyhow::bail!("Search query cannot be empty");
1025 }
1026
1027 let format = args.format.resolve(quiet);
1029
1030 let storage = Storage::new()?;
1032 if let Ok(content_path) = storage.llms_txt_path(BUNDLED_ALIAS) {
1033 if let Ok(content) = std::fs::read_to_string(&content_path) {
1034 if content.contains("# BLZ bundled docs (placeholder)") {
1035 let error_msg = if matches!(format, crate::output::OutputFormat::Json) {
1036 let error_json = serde_json::json!({
1038 "error": "Bundled documentation content not yet available",
1039 "reason": "The blz-docs source currently contains placeholder content",
1040 "suggestions": [
1041 "Use 'blz docs overview' for quick-start information",
1042 "Use 'blz docs export' to view CLI documentation",
1043 "Full bundled documentation will be included in a future release"
1044 ]
1045 });
1046 return Err(anyhow!(serde_json::to_string_pretty(&error_json)?));
1047 } else {
1048 "Bundled documentation content not yet available.\n\
1050 \n\
1051 The blz-docs source currently contains placeholder content.\n\
1052 Full documentation will be included in a future release.\n\
1053 \n\
1054 Available alternatives:\n\
1055 • Run 'blz docs overview' for quick-start information\n\
1056 • Run 'blz docs export' to view CLI documentation\n\
1057 • Run 'blz docs cat' to view the current placeholder content"
1058 };
1059 anyhow::bail!("{error_msg}");
1060 }
1061 }
1062 }
1063
1064 let sources = vec![BUNDLED_ALIAS.to_string()];
1065
1066 let context_mode = args.context.map(crate::cli::ContextMode::Symmetric);
1068
1069 commands::search(
1070 &query,
1071 &sources,
1072 false,
1073 args.limit,
1074 1,
1075 args.top,
1076 None, format,
1078 &args.show,
1079 args.no_summary,
1080 args.score_precision,
1081 args.snippet_lines,
1082 args.max_chars.unwrap_or(commands::DEFAULT_MAX_CHARS),
1083 context_mode.as_ref(),
1084 args.block,
1085 args.max_block_lines,
1086 false,
1087 true,
1088 args.copy,
1089 false, quiet,
1091 None,
1092 metrics,
1093 None,
1094 )
1095 .await
1096}
1097
1098fn docs_sync(force: bool, quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1099 let status = sync_and_report(force, quiet, metrics)?;
1100 if !quiet && matches!(status, DocsSyncStatus::Installed | DocsSyncStatus::Updated) {
1101 let storage = Storage::new()?;
1102 let llms_path = storage.llms_txt_path(BUNDLED_ALIAS)?;
1103 println!("Bundled docs stored at {}", llms_path.display());
1104 }
1105 Ok(())
1106}
1107
1108fn docs_overview(quiet: bool, metrics: PerformanceMetrics) -> Result<()> {
1109 let status = sync_and_report(false, quiet, metrics)?;
1110 if !quiet {
1111 let storage = Storage::new()?;
1112 let llms_path = storage.llms_txt_path(BUNDLED_ALIAS)?;
1113 println!("Bundled docs status: {status:?}");
1114 println!("Alias: {BUNDLED_ALIAS} (also @blz)");
1115 println!("Stored at: {}", llms_path.display());
1116 }
1117 print_overview();
1118 Ok(())
1119}
1120
1121fn docs_cat(metrics: PerformanceMetrics) -> Result<()> {
1122 sync_and_report(false, true, metrics)?;
1123 print_full_content();
1124 Ok(())
1125}
1126
1127fn docs_export(format: Option<crate::commands::DocsFormat>) -> Result<()> {
1128 let requested = format.unwrap_or(crate::commands::DocsFormat::Markdown);
1129 let effective = match (std::env::var("BLZ_OUTPUT_FORMAT").ok(), requested) {
1130 (Some(v), crate::commands::DocsFormat::Markdown) if v.eq_ignore_ascii_case("json") => {
1131 crate::commands::DocsFormat::Json
1132 },
1133 _ => requested,
1134 };
1135 commands::generate_docs(effective)
1136}
1137
1138fn sync_and_report(
1139 force: bool,
1140 quiet: bool,
1141 metrics: PerformanceMetrics,
1142) -> Result<DocsSyncStatus> {
1143 let status = sync_bundled_docs(force, metrics)?;
1144 if !quiet {
1145 match status {
1146 DocsSyncStatus::UpToDate => {
1147 println!("Bundled docs already up to date");
1148 },
1149 DocsSyncStatus::Installed => {
1150 println!("Installed bundled docs source: {BUNDLED_ALIAS}");
1151 },
1152 DocsSyncStatus::Updated => {
1153 println!("Updated bundled docs source: {BUNDLED_ALIAS}");
1154 },
1155 }
1156 }
1157 Ok(status)
1158}
1159
1160async fn handle_anchor(command: AnchorCommands, quiet: bool) -> Result<()> {
1161 match command {
1162 AnchorCommands::List {
1163 alias,
1164 format,
1165 anchors,
1166 limit,
1167 max_depth,
1168 filter,
1169 } => {
1170 commands::show_toc(
1171 Some(&alias),
1172 &[],
1173 false,
1174 format.resolve(quiet),
1175 anchors,
1176 false, limit,
1178 max_depth,
1179 None,
1180 filter.as_deref(),
1181 false,
1182 false, false, false, 1, )
1187 .await
1188 },
1189 AnchorCommands::Get {
1190 alias,
1191 anchor,
1192 context,
1193 format,
1194 } => commands::get_by_anchor(&alias, &anchor, context, format.resolve(quiet)).await,
1195 }
1196}
1197
1198async fn handle_alias(command: AliasCommands) -> Result<()> {
1199 match command {
1200 AliasCommands::Add { source, alias } => {
1201 commands::manage_alias(commands::AliasCommand::Add { source, alias }).await
1202 },
1203 AliasCommands::Rm { source, alias } => {
1204 commands::manage_alias(commands::AliasCommand::Rm { source, alias }).await
1205 },
1206 }
1207}
1208
1209async fn handle_registry(
1210 command: RegistryCommands,
1211 quiet: bool,
1212 metrics: PerformanceMetrics,
1213) -> Result<()> {
1214 match command {
1215 RegistryCommands::CreateSource {
1216 name,
1217 url,
1218 description,
1219 category,
1220 tags,
1221 npm,
1222 github,
1223 add,
1224 yes,
1225 } => {
1226 commands::create_registry_source(
1227 &name,
1228 &url,
1229 description,
1230 category,
1231 tags,
1232 npm,
1233 github,
1234 add,
1235 yes,
1236 quiet,
1237 metrics,
1238 )
1239 .await
1240 },
1241 }
1242}
1243
1244#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1245async fn handle_search(
1246 query: Option<String>,
1247 sources: Vec<String>,
1248 next: bool,
1249 previous: bool,
1250 last: bool,
1251 limit: Option<usize>,
1252 all: bool,
1253 page: usize,
1254 top: Option<u8>,
1255 heading_level: Option<String>,
1256 format: crate::output::OutputFormat,
1257 show: Vec<crate::cli::ShowComponent>,
1258 no_summary: bool,
1259 score_precision: Option<u8>,
1260 snippet_lines: u8,
1261 max_chars: Option<usize>,
1262 context: Option<crate::cli::ContextMode>,
1263 block: bool,
1264 max_lines: Option<usize>,
1265 headings_only: bool,
1266 no_history: bool,
1267 copy: bool,
1268 timing: bool,
1269 quiet: bool,
1270 metrics: PerformanceMetrics,
1271 prefs: &mut CliPreferences,
1272) -> Result<()> {
1273 const DEFAULT_LIMIT: usize = 50;
1274 const ALL_RESULTS_LIMIT: usize = 10_000;
1275 const DEFAULT_SNIPPET_LINES: u8 = 3;
1276
1277 let provided_query = query.is_some();
1278 let limit_was_explicit = all || limit.is_some();
1279 let mut use_headings_only = headings_only;
1280
1281 if snippet_lines != DEFAULT_SNIPPET_LINES {
1283 let args: Vec<String> = std::env::args().collect();
1284 if flag_present(&args, "--snippet-lines") || std::env::var("BLZ_SNIPPET_LINES").is_ok() {
1285 utils::cli_args::emit_snippet_lines_deprecation(false);
1287 }
1288 }
1289
1290 if next {
1291 validate_continuation_flag("--next", provided_query, &sources, page, last)?;
1292 }
1293
1294 if previous {
1295 validate_continuation_flag("--previous", provided_query, &sources, page, last)?;
1296 }
1297
1298 let history_entry = if next || previous || !provided_query {
1299 let mut records = utils::history_log::recent_for_active_scope(1);
1300 if records.is_empty() {
1301 anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
1302 }
1303 Some(records.remove(0))
1304 } else {
1305 None
1306 };
1307
1308 if let Some(entry) = history_entry.as_ref() {
1309 if (next || previous) && headings_only != entry.headings_only {
1310 anyhow::bail!(
1311 "Cannot change --headings-only while using --next/--previous. Rerun without continuation flags."
1312 );
1313 }
1314 if !headings_only {
1315 use_headings_only = entry.headings_only;
1316 }
1317 }
1318
1319 let actual_query = resolve_query(query, history_entry.as_ref())?;
1320 let actual_sources = resolve_sources(sources, history_entry.as_ref());
1321
1322 let base_limit = if all {
1323 ALL_RESULTS_LIMIT
1324 } else {
1325 limit.unwrap_or(DEFAULT_LIMIT)
1326 };
1327 let actual_max_chars = max_chars.map_or(commands::DEFAULT_MAX_CHARS, commands::clamp_max_chars);
1328
1329 let (actual_page, actual_limit) = if let Some(entry) = history_entry.as_ref() {
1330 let adj = apply_history_pagination(
1331 entry,
1332 next,
1333 previous,
1334 provided_query,
1335 all,
1336 limit,
1337 limit_was_explicit,
1338 page,
1339 base_limit,
1340 ALL_RESULTS_LIMIT,
1341 )?;
1342 (adj.page, adj.limit)
1343 } else {
1344 (page, base_limit)
1345 };
1346
1347 commands::search(
1348 &actual_query,
1349 &actual_sources,
1350 last,
1351 actual_limit,
1352 actual_page,
1353 top,
1354 heading_level.clone(),
1355 format,
1356 &show,
1357 no_summary,
1358 score_precision,
1359 snippet_lines,
1360 actual_max_chars,
1361 context.as_ref(),
1362 block,
1363 max_lines,
1364 use_headings_only,
1365 no_history,
1366 copy,
1367 timing,
1368 quiet,
1369 Some(prefs),
1370 metrics,
1371 None,
1372 )
1373 .await
1374}
1375
1376#[allow(clippy::fn_params_excessive_bools)]
1377async fn handle_refresh(
1378 aliases: Vec<String>,
1379 all: bool,
1380 reindex: bool,
1381 filter: Option<String>,
1382 no_filter: bool,
1383 metrics: PerformanceMetrics,
1384 quiet: bool,
1385) -> Result<()> {
1386 let mut aliases = aliases;
1387 let mut filter = filter;
1388
1389 if !all && aliases.is_empty() {
1390 if let Some(raw_value) = filter.take() {
1391 if crate::utils::filter_flags::is_known_filter_expression(&raw_value) {
1392 filter = Some(raw_value);
1393 } else {
1394 aliases.push(raw_value);
1395 filter = Some(String::from("all"));
1396 }
1397 }
1398 }
1399
1400 if all || aliases.is_empty() {
1401 return commands::refresh_all(metrics, quiet, reindex, filter.as_ref(), no_filter).await;
1402 }
1403
1404 for alias in aliases {
1405 let metrics_clone = PerformanceMetrics {
1406 search_count: Arc::clone(&metrics.search_count),
1407 total_search_time: Arc::clone(&metrics.total_search_time),
1408 index_build_count: Arc::clone(&metrics.index_build_count),
1409 total_index_time: Arc::clone(&metrics.total_index_time),
1410 bytes_processed: Arc::clone(&metrics.bytes_processed),
1411 lines_searched: Arc::clone(&metrics.lines_searched),
1412 };
1413 commands::refresh_source(
1414 &alias,
1415 metrics_clone,
1416 quiet,
1417 reindex,
1418 filter.as_ref(),
1419 no_filter,
1420 )
1421 .await?;
1422 }
1423
1424 Ok(())
1425}
1426
1427fn print_diagnostics(cli: &Cli, metrics: &PerformanceMetrics) {
1428 if cli.debug {
1429 metrics.print_summary();
1430 }
1431}
1432
1433fn apply_preference_defaults(cli: &mut Cli, prefs: &CliPreferences, args: &[String]) {
1434 if let Some(Commands::Search {
1435 show,
1436 score_precision,
1437 snippet_lines,
1438 ..
1439 }) = cli.command.as_mut()
1440 {
1441 let show_env = std::env::var("BLZ_SHOW").is_ok();
1442 if show.is_empty() && !flag_present(args, "--show") && !show_env {
1443 *show = prefs.default_show_components();
1444 }
1445
1446 if score_precision.is_none()
1447 && !flag_present(args, "--score-precision")
1448 && std::env::var("BLZ_SCORE_PRECISION").is_err()
1449 {
1450 *score_precision = Some(prefs.default_score_precision());
1451 }
1452
1453 if !flag_present(args, "--snippet-lines") && std::env::var("BLZ_SNIPPET_LINES").is_err() {
1454 *snippet_lines = prefs.default_snippet_lines();
1455 }
1456 }
1457}
1458
1459fn flag_present(args: &[String], flag: &str) -> bool {
1460 let flag_eq = flag;
1461 let flag_eq_with_equal = format!("{flag}=");
1462 args.iter()
1463 .any(|arg| arg == flag_eq || arg.starts_with(&flag_eq_with_equal))
1464}
1465
1466struct PaginationAdjustment {
1468 page: usize,
1469 limit: usize,
1470}
1471
1472#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
1476fn apply_history_pagination(
1477 entry: &utils::preferences::SearchHistoryEntry,
1478 next: bool,
1479 previous: bool,
1480 provided_query: bool,
1481 all: bool,
1482 limit: Option<usize>,
1483 limit_was_explicit: bool,
1484 current_page: usize,
1485 current_limit: usize,
1486 all_results_limit: usize,
1487) -> Result<PaginationAdjustment> {
1488 let mut actual_page = current_page;
1489 let mut actual_limit = current_limit;
1490
1491 if next {
1492 validate_history_has_results(entry)?;
1493 validate_pagination_limit_consistency(
1494 "--next",
1495 all,
1496 limit_was_explicit,
1497 limit,
1498 entry.limit,
1499 all_results_limit,
1500 )?;
1501
1502 if let (Some(prev_page), Some(total_pages)) = (entry.page, entry.total_pages) {
1503 if prev_page >= total_pages {
1504 anyhow::bail!("Already at the last page (page {prev_page} of {total_pages})");
1505 }
1506 actual_page = prev_page + 1;
1507 } else {
1508 actual_page = entry.page.unwrap_or(1) + 1;
1509 }
1510
1511 if !limit_was_explicit {
1512 actual_limit = entry.limit.unwrap_or(actual_limit);
1513 }
1514 } else if previous {
1515 validate_history_has_results(entry)?;
1516 validate_pagination_limit_consistency(
1517 "--previous",
1518 all,
1519 limit_was_explicit,
1520 limit,
1521 entry.limit,
1522 all_results_limit,
1523 )?;
1524
1525 if let Some(prev_page) = entry.page {
1526 if prev_page <= 1 {
1527 anyhow::bail!("Already on first page");
1528 }
1529 actual_page = prev_page - 1;
1530 } else {
1531 anyhow::bail!("No previous page found in search history");
1532 }
1533
1534 if !limit_was_explicit {
1535 actual_limit = entry.limit.unwrap_or(actual_limit);
1536 }
1537 } else if !provided_query && !limit_was_explicit {
1538 actual_limit = entry.limit.unwrap_or(actual_limit);
1539 }
1540
1541 Ok(PaginationAdjustment {
1542 page: actual_page,
1543 limit: actual_limit,
1544 })
1545}
1546
1547fn validate_history_has_results(entry: &utils::preferences::SearchHistoryEntry) -> Result<()> {
1549 if matches!(entry.total_pages, Some(0)) || matches!(entry.total_results, Some(0)) {
1550 anyhow::bail!(
1551 "Previous search returned 0 results. Rerun with a different query or source."
1552 );
1553 }
1554 Ok(())
1555}
1556
1557fn resolve_query(
1559 mut query: Option<String>,
1560 history: Option<&utils::preferences::SearchHistoryEntry>,
1561) -> Result<String> {
1562 if let Some(value) = query.take() {
1563 Ok(value)
1564 } else if let Some(entry) = history {
1565 Ok(entry.query.clone())
1566 } else {
1567 anyhow::bail!("No previous search found. Use 'blz search <query>' first.");
1568 }
1569}
1570
1571fn resolve_sources(
1573 sources: Vec<String>,
1574 history: Option<&utils::preferences::SearchHistoryEntry>,
1575) -> Vec<String> {
1576 if !sources.is_empty() {
1577 sources
1578 } else if let Some(entry) = history {
1579 entry.source.as_ref().map_or_else(Vec::new, |source_str| {
1580 source_str
1581 .split(',')
1582 .map(|s| s.trim().to_string())
1583 .collect()
1584 })
1585 } else {
1586 Vec::new()
1587 }
1588}
1589
1590fn validate_pagination_limit_consistency(
1592 flag: &str,
1593 all: bool,
1594 limit_was_explicit: bool,
1595 limit: Option<usize>,
1596 history_limit: Option<usize>,
1597 all_results_limit: usize,
1598) -> Result<()> {
1599 let history_all = history_limit.is_some_and(|value| value >= all_results_limit);
1600 if all != history_all {
1601 anyhow::bail!(
1602 "Cannot use {flag} when changing page size or --all; rerun without {flag} or reuse the previous pagination flags."
1603 );
1604 }
1605 if limit_was_explicit {
1606 if let Some(requested_limit) = limit {
1607 if history_limit != Some(requested_limit) {
1608 anyhow::bail!(
1609 "Cannot use {flag} when changing page size; rerun without {flag} or reuse the previous limit."
1610 );
1611 }
1612 }
1613 }
1614 Ok(())
1615}
1616
1617fn validate_continuation_flag(
1619 flag: &str,
1620 provided_query: bool,
1621 sources: &[String],
1622 page: usize,
1623 last: bool,
1624) -> Result<()> {
1625 if provided_query {
1626 anyhow::bail!(
1627 "Cannot combine {flag} with an explicit query. Remove the query to continue from the previous search."
1628 );
1629 }
1630 if !sources.is_empty() {
1631 anyhow::bail!(
1632 "Cannot combine {flag} with --source. Omit --source to reuse the last search context."
1633 );
1634 }
1635 if page != 1 {
1636 anyhow::bail!("Cannot combine {flag} with --page. Use one pagination option at a time.");
1637 }
1638 if last {
1639 anyhow::bail!("Cannot combine {flag} with --last. Choose a single continuation flag.");
1640 }
1641 Ok(())
1642}
1643
1644#[cfg(test)]
1645#[allow(
1646 clippy::unwrap_used,
1647 clippy::panic,
1648 clippy::disallowed_macros,
1649 clippy::needless_collect,
1650 clippy::unnecessary_wraps,
1651 clippy::deref_addrof
1652)]
1653mod tests {
1654 use super::*;
1655 use crate::utils::constants::RESERVED_KEYWORDS;
1656 use crate::utils::parsing::{LineRange, parse_line_ranges};
1657 use crate::utils::validation::validate_alias;
1658 use std::collections::HashSet;
1659
1660 #[test]
1661 fn test_reserved_keywords_validation() {
1662 for &keyword in RESERVED_KEYWORDS {
1663 let result = validate_alias(keyword);
1664 assert!(
1665 result.is_err(),
1666 "Reserved keyword '{keyword}' should be rejected"
1667 );
1668
1669 let error_msg = result.unwrap_err().to_string();
1670 assert!(
1671 error_msg.contains(keyword),
1672 "Error message should contain the reserved keyword '{keyword}'"
1673 );
1674 }
1675 }
1676
1677 #[test]
1678 fn test_valid_aliases_allowed() {
1679 let valid_aliases = ["react", "nextjs", "python", "rust", "docs", "api", "guide"];
1680
1681 for &alias in &valid_aliases {
1682 let result = validate_alias(alias);
1683 assert!(result.is_ok(), "Valid alias '{alias}' should be accepted");
1684 }
1685 }
1686
1687 #[test]
1688 fn test_language_names_are_not_reserved() {
1689 let language_names = [
1690 "node",
1691 "python",
1692 "rust",
1693 "go",
1694 "java",
1695 "javascript",
1696 "typescript",
1697 ];
1698
1699 for &lang in &language_names {
1700 assert!(
1701 !RESERVED_KEYWORDS.contains(&lang),
1702 "Language name '{lang}' should not be reserved"
1703 );
1704
1705 let result = validate_alias(lang);
1706 assert!(
1707 result.is_ok(),
1708 "Language name '{lang}' should be usable as alias"
1709 );
1710 }
1711 }
1712
1713 #[test]
1714 fn test_reserved_keywords_case_insensitive() {
1715 let result = validate_alias("ADD");
1716 assert!(
1717 result.is_err(),
1718 "Reserved keyword 'ADD' (uppercase) should be rejected"
1719 );
1720
1721 let result = validate_alias("Add");
1722 assert!(
1723 result.is_err(),
1724 "Reserved keyword 'Add' (mixed case) should be rejected"
1725 );
1726 }
1727
1728 #[test]
1729 fn test_line_range_parsing() {
1730 let ranges = parse_line_ranges("42").expect("Should parse single line");
1732 assert_eq!(ranges.len(), 1);
1733 assert!(matches!(ranges[0], LineRange::Single(42)));
1734
1735 let ranges = parse_line_ranges("120:142").expect("Should parse colon range");
1737 assert_eq!(ranges.len(), 1);
1738 assert!(matches!(ranges[0], LineRange::Range(120, 142)));
1739
1740 let ranges = parse_line_ranges("120-142").expect("Should parse dash range");
1742 assert_eq!(ranges.len(), 1);
1743 assert!(matches!(ranges[0], LineRange::Range(120, 142)));
1744
1745 let ranges = parse_line_ranges("36+20").expect("Should parse plus syntax");
1747 assert_eq!(ranges.len(), 1);
1748 assert!(matches!(ranges[0], LineRange::PlusCount(36, 20)));
1749
1750 let ranges =
1752 parse_line_ranges("36:43,120-142,200+10").expect("Should parse multiple ranges");
1753 assert_eq!(ranges.len(), 3);
1754 }
1755
1756 #[test]
1757 fn test_line_range_parsing_errors() {
1758 assert!(parse_line_ranges("0").is_err(), "Line 0 should be invalid");
1759 assert!(
1760 parse_line_ranges("50:30").is_err(),
1761 "Backwards range should be invalid"
1762 );
1763 assert!(
1764 parse_line_ranges("50+0").is_err(),
1765 "Plus zero count should be invalid"
1766 );
1767 assert!(
1768 parse_line_ranges("abc").is_err(),
1769 "Invalid format should be rejected"
1770 );
1771 }
1772
1773 #[test]
1774 fn test_reserved_keywords_no_duplicates() {
1775 let mut seen = HashSet::new();
1776 for &keyword in RESERVED_KEYWORDS {
1777 assert!(
1778 seen.insert(keyword),
1779 "Reserved keyword '{keyword}' appears multiple times"
1780 );
1781 }
1782 }
1783
1784 #[test]
1787 fn test_cli_flag_combinations() {
1788 use clap::Parser;
1789
1790 let valid_combinations = vec![
1792 vec!["blz", "search", "rust", "--limit", "20"],
1793 vec!["blz", "search", "rust", "--alias", "node", "--limit", "10"],
1794 vec!["blz", "search", "rust", "--all"],
1795 vec!["blz", "add", "test", "https://example.com/llms.txt"],
1796 vec!["blz", "list", "--output", "json"],
1797 vec!["blz", "update", "--all"],
1798 vec!["blz", "remove", "test"],
1799 vec!["blz", "get", "test", "--lines", "1-10"],
1800 vec!["blz", "lookup", "react"],
1801 ];
1802
1803 for combination in valid_combinations {
1804 let result = Cli::try_parse_from(combination.clone());
1805 assert!(
1806 result.is_ok(),
1807 "Valid combination should parse: {combination:?}"
1808 );
1809 }
1810 }
1811
1812 #[test]
1813 #[allow(deprecated)]
1814 fn test_cli_parse_refresh_multiple_aliases() {
1815 use clap::Parser;
1816
1817 let cli = Cli::try_parse_from(vec!["blz", "refresh", "bun", "react"]).unwrap();
1818 match cli.command {
1819 Some(Commands::Refresh { aliases, all, .. }) => {
1820 assert_eq!(aliases, vec!["bun", "react"]);
1821 assert!(!all);
1822 },
1823 other => panic!("Expected refresh command, got {other:?}"),
1824 }
1825 }
1826
1827 #[test]
1828 fn test_cli_refresh_all_conflict_with_aliases() {
1829 use clap::Parser;
1830
1831 let result = Cli::try_parse_from(vec!["blz", "refresh", "bun", "--all"]);
1832 assert!(
1833 result.is_err(),
1834 "Should error when both --all and aliases are provided"
1835 );
1836 }
1837
1838 #[test]
1839 #[allow(deprecated)]
1840 fn test_cli_parse_update_multiple_aliases() {
1841 use clap::Parser;
1842
1843 let cli = Cli::try_parse_from(vec!["blz", "update", "bun", "react"]).unwrap();
1845 match cli.command {
1846 Some(Commands::Update { aliases, all, .. }) => {
1847 assert_eq!(aliases, vec!["bun", "react"]);
1848 assert!(!all);
1849 },
1850 other => panic!("Expected update command, got {other:?}"),
1851 }
1852 }
1853
1854 #[test]
1855 #[allow(deprecated)]
1856 fn test_cli_update_all_conflict_with_aliases() {
1857 use clap::Parser;
1858
1859 let result = Cli::try_parse_from(vec!["blz", "update", "bun", "--all"]);
1861 assert!(
1862 result.is_err(),
1863 "--all should conflict with explicit aliases"
1864 );
1865 }
1866
1867 #[test]
1868 fn test_cli_invalid_flag_combinations() {
1869 use clap::Parser;
1870
1871 let invalid_combinations = vec![
1873 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"], ];
1888
1889 for combination in invalid_combinations {
1890 let result = Cli::try_parse_from(combination.clone());
1891 assert!(
1892 result.is_err(),
1893 "Invalid combination should fail: {combination:?}"
1894 );
1895 }
1896 }
1897
1898 #[test]
1899 fn test_cli_help_generation() {
1900 use clap::Parser;
1901
1902 let help_commands = vec![
1904 vec!["blz", "--help"],
1905 vec!["blz", "search", "--help"],
1906 vec!["blz", "add", "--help"],
1907 vec!["blz", "list", "--help"],
1908 vec!["blz", "get", "--help"],
1909 vec!["blz", "update", "--help"],
1910 vec!["blz", "remove", "--help"],
1911 vec!["blz", "lookup", "--help"],
1912 vec!["blz", "completions", "--help"],
1913 ];
1914
1915 for help_cmd in help_commands {
1916 let result = Cli::try_parse_from(help_cmd.clone());
1917 if let Err(error) = result {
1919 assert!(
1920 error.kind() == clap::error::ErrorKind::DisplayHelp,
1921 "Help command should display help: {help_cmd:?}"
1922 );
1923 } else {
1924 panic!("Help command should not succeed: {help_cmd:?}");
1925 }
1926 }
1927 }
1928
1929 #[test]
1930 fn test_cli_version_flag() {
1931 use clap::Parser;
1932
1933 let version_commands = vec![vec!["blz", "--version"], vec!["blz", "-V"]];
1934
1935 for version_cmd in version_commands {
1936 let result = Cli::try_parse_from(version_cmd.clone());
1937 if let Err(error) = result {
1939 assert!(
1940 error.kind() == clap::error::ErrorKind::DisplayVersion,
1941 "Version command should display version: {version_cmd:?}"
1942 );
1943 } else {
1944 panic!("Version command should not succeed: {version_cmd:?}");
1945 }
1946 }
1947 }
1948
1949 #[test]
1950 fn test_cli_default_values() {
1951 use clap::Parser;
1952
1953 let cli = Cli::try_parse_from(vec!["blz", "search", "test"]).unwrap();
1955
1956 if let Some(Commands::Search {
1957 limit,
1958 page,
1959 all,
1960 format,
1961 ..
1962 }) = cli.command
1963 {
1964 assert_eq!(
1965 limit, None,
1966 "Default limit should be unset (defaults to 50)"
1967 );
1968 assert_eq!(page, 1, "Default page should be 1");
1969 assert!(!all, "Default all should be false");
1970 let expected_format = if is_terminal::IsTerminal::is_terminal(&std::io::stdout()) {
1972 crate::output::OutputFormat::Text
1973 } else {
1974 crate::output::OutputFormat::Json
1975 };
1976 assert_eq!(
1977 format.resolve(false),
1978 expected_format,
1979 "Default format should be JSON when piped, Text when terminal"
1980 );
1981 } else {
1982 panic!("Expected search command");
1983 }
1984 }
1985
1986 #[test]
1987 fn test_cli_flag_validation_edge_cases() {
1988 use clap::Parser;
1989
1990 let edge_cases = vec![
1992 vec!["blz", "search", "rust", "--limit", "999999"],
1994 vec!["blz", "search", "rust", "--page", "999999"],
1995 vec!["blz", "search", "rust", "--limit", "1"],
1997 vec!["blz", "search", "rust", "--page", "1"],
1998 vec!["blz", "search", "rust", "--limit", "10000"],
2000 vec!["blz", "search", "rust", "--page", "1000"],
2001 ];
2002
2003 for edge_case in edge_cases {
2004 let result = Cli::try_parse_from(edge_case.clone());
2005
2006 assert!(result.is_ok(), "Edge case should parse: {edge_case:?}");
2008 }
2009 }
2010
2011 #[test]
2012 fn test_cli_string_argument_validation() {
2013 use clap::Parser;
2014
2015 let string_cases = vec![
2017 vec!["blz", "search", "normal query"],
2019 vec!["blz", "add", "test-alias", "https://example.com/llms.txt"],
2020 vec!["blz", "lookup", "react"],
2021 vec!["blz", "search", ""], vec![
2024 "blz",
2025 "search",
2026 "very-long-query-with-lots-of-words-to-test-limits",
2027 ],
2028 vec!["blz", "add", "alias", "file:///local/path.txt"], vec!["blz", "search", "query with spaces"],
2031 vec!["blz", "search", "query-with-dashes"],
2032 vec!["blz", "search", "query_with_underscores"],
2033 vec!["blz", "add", "test", "https://example.com/path?query=value"],
2034 ];
2035
2036 for string_case in string_cases {
2037 let result = Cli::try_parse_from(string_case.clone());
2038
2039 assert!(result.is_ok(), "String case should parse: {string_case:?}");
2041 }
2042 }
2043
2044 #[test]
2045 fn test_cli_output_format_validation() {
2046 use clap::Parser;
2047
2048 let format_options = vec![
2050 ("text", crate::output::OutputFormat::Text),
2051 ("json", crate::output::OutputFormat::Json),
2052 ("jsonl", crate::output::OutputFormat::Jsonl),
2053 ];
2054
2055 for (format_str, expected_format) in &format_options {
2056 let cli = Cli::try_parse_from(vec!["blz", "list", "--format", *format_str]).unwrap();
2057
2058 if let Some(Commands::List { format, .. }) = cli.command {
2059 assert_eq!(
2060 format.resolve(false),
2061 *expected_format,
2062 "Format should match: {format_str}"
2063 );
2064 } else {
2065 panic!("Expected list command");
2066 }
2067 }
2068
2069 for (format_str, expected_format) in &format_options {
2071 let cli = Cli::try_parse_from(vec!["blz", "list", "--output", *format_str]).unwrap();
2072
2073 if let Some(Commands::List { format, .. }) = cli.command {
2074 assert_eq!(
2075 format.resolve(false),
2076 *expected_format,
2077 "Alias --output should map to {format_str}"
2078 );
2079 } else {
2080 panic!("Expected list command");
2081 }
2082 }
2083
2084 let result = Cli::try_parse_from(vec!["blz", "list", "--format", "invalid"]);
2086 assert!(result.is_err(), "Invalid output format should fail");
2087 }
2088
2089 fn to_string_vec(items: &[&str]) -> Vec<String> {
2090 items.iter().copied().map(str::to_owned).collect()
2091 }
2092
2093 #[test]
2094 #[allow(deprecated)]
2095 fn anchors_alias_still_parses_to_toc() {
2096 use clap::Parser;
2097
2098 let raw = to_string_vec(&["blz", "anchors", "react"]);
2099 let cli = Cli::try_parse_from(raw).expect("anchors alias should parse");
2100 match cli.command {
2101 Some(Commands::Toc { alias, .. }) => assert_eq!(alias, Some("react".to_string())),
2102 other => panic!("expected toc command, got {other:?}"),
2103 }
2104 }
2105
2106 #[test]
2107 fn test_cli_boolean_flags() {
2108 use clap::Parser;
2109
2110 let bool_flag_cases = vec![
2112 (
2114 vec!["blz", "--verbose", "search", "test"],
2115 true,
2116 false,
2117 false,
2118 ),
2119 (vec!["blz", "-v", "search", "test"], true, false, false),
2120 (vec!["blz", "--debug", "search", "test"], false, true, false),
2122 (
2124 vec!["blz", "--profile", "search", "test"],
2125 false,
2126 false,
2127 true,
2128 ),
2129 (
2131 vec!["blz", "--verbose", "--debug", "--profile", "search", "test"],
2132 true,
2133 true,
2134 true,
2135 ),
2136 (
2137 vec!["blz", "-v", "--debug", "--profile", "search", "test"],
2138 true,
2139 true,
2140 true,
2141 ),
2142 ];
2143
2144 for (args, expected_verbose, expected_debug, expected_profile) in bool_flag_cases {
2145 let cli = Cli::try_parse_from(args.clone()).unwrap();
2146
2147 assert_eq!(
2148 cli.verbose, expected_verbose,
2149 "Verbose flag mismatch for: {args:?}"
2150 );
2151 assert_eq!(
2152 cli.debug, expected_debug,
2153 "Debug flag mismatch for: {args:?}"
2154 );
2155 assert_eq!(
2156 cli.profile, expected_profile,
2157 "Profile flag mismatch for: {args:?}"
2158 );
2159 }
2160 }
2161
2162 #[test]
2163 fn test_cli_subcommand_specific_flags() {
2164 use clap::Parser;
2165
2166 let cli = Cli::try_parse_from(vec![
2168 "blz", "search", "rust", "--alias", "node", "--limit", "20", "--page", "2", "--top",
2169 "10", "--format", "json",
2170 ])
2171 .unwrap();
2172
2173 if let Some(Commands::Search {
2174 sources,
2175 limit,
2176 page,
2177 top,
2178 format,
2179 ..
2180 }) = cli.command
2181 {
2182 assert_eq!(sources, vec!["node"]);
2183 assert_eq!(limit, Some(20));
2184 assert_eq!(page, 2);
2185 assert!(top.is_some());
2186 assert_eq!(format.resolve(false), crate::output::OutputFormat::Json);
2187 } else {
2188 panic!("Expected search command");
2189 }
2190
2191 let cli = Cli::try_parse_from(vec![
2193 "blz",
2194 "add",
2195 "test",
2196 "https://example.com/llms.txt",
2197 "--yes",
2198 ])
2199 .unwrap();
2200
2201 if let Some(Commands::Add(args)) = cli.command {
2202 assert_eq!(args.alias.as_deref(), Some("test"));
2203 assert_eq!(args.url.as_deref(), Some("https://example.com/llms.txt"));
2204 assert!(args.aliases.is_empty());
2205 assert!(args.tags.is_empty());
2206 assert!(args.name.is_none());
2207 assert!(args.description.is_none());
2208 assert!(args.category.is_none());
2209 assert!(args.yes);
2210 assert!(!args.dry_run);
2211 assert!(args.manifest.is_none());
2212 } else {
2213 panic!("Expected add command");
2214 }
2215
2216 let cli = Cli::try_parse_from(vec![
2218 "blz",
2219 "get",
2220 "test",
2221 "--lines",
2222 "1-10",
2223 "--context",
2224 "5",
2225 ])
2226 .unwrap();
2227
2228 if let Some(Commands::Get {
2229 targets,
2230 lines,
2231 source,
2232 context,
2233 block,
2234 max_lines,
2235 format,
2236 copy: _,
2237 ..
2238 }) = cli.command
2239 {
2240 assert_eq!(targets, vec!["test".to_string()]);
2241 assert_eq!(lines, Some("1-10".to_string()));
2242 assert!(source.is_none());
2243 assert_eq!(context, Some(crate::cli::ContextMode::Symmetric(5)));
2244 assert!(!block);
2245 assert_eq!(max_lines, None);
2246 let _ = format; } else {
2248 panic!("Expected get command");
2249 }
2250 }
2251
2252 #[test]
2253 fn test_cli_special_argument_parsing() {
2254 use clap::Parser;
2255
2256 let line_range_cases = vec![
2258 "1",
2259 "1-10",
2260 "1:10",
2261 "1+5",
2262 "10,20,30",
2263 "1-5,10-15,20+5",
2264 "100:200",
2265 ];
2266
2267 for line_range in line_range_cases {
2268 let result = Cli::try_parse_from(vec!["blz", "get", "test", "--lines", line_range]);
2269 assert!(result.is_ok(), "Line range should parse: {line_range}");
2270 }
2271
2272 let url_cases = vec![
2274 "https://example.com/llms.txt",
2275 "http://localhost:3000/llms.txt",
2276 "https://api.example.com/v1/docs/llms.txt",
2277 "https://example.com/llms.txt?version=1",
2278 "https://raw.githubusercontent.com/user/repo/main/llms.txt",
2279 ];
2280
2281 for url in url_cases {
2282 let result = Cli::try_parse_from(vec!["blz", "add", "test", url]);
2283 assert!(result.is_ok(), "URL should parse: {url}");
2284 }
2285 }
2286
2287 #[test]
2288 fn test_cli_error_messages() {
2289 use clap::Parser;
2290
2291 let error_cases = vec![
2293 (vec!["blz", "add"], "missing"),
2295 (vec!["blz", "search"], "required"),
2296 (vec!["blz", "list", "--format", "invalid"], "invalid"),
2299 ];
2300
2301 for (args, expected_error_content) in error_cases {
2302 let result = Cli::try_parse_from(args.clone());
2303
2304 assert!(result.is_err(), "Should error for: {args:?}");
2305
2306 let error_msg = format!("{:?}", result.unwrap_err()).to_lowercase();
2307 assert!(
2308 error_msg.contains(expected_error_content),
2309 "Error message should contain '{expected_error_content}' for args {args:?}, got: {error_msg}"
2310 );
2311 }
2312 }
2313
2314 #[test]
2315 fn test_cli_argument_order_independence() {
2316 use clap::Parser;
2317
2318 let equivalent_commands = vec![
2320 vec![
2321 vec!["blz", "--verbose", "search", "rust"],
2322 vec!["blz", "search", "--verbose", "rust"],
2323 ],
2324 vec![
2325 vec!["blz", "--debug", "--profile", "search", "rust"],
2326 vec!["blz", "search", "rust", "--debug", "--profile"],
2327 vec!["blz", "--debug", "search", "--profile", "rust"],
2328 ],
2329 ];
2330
2331 for command_group in equivalent_commands {
2332 let mut parsed_commands = Vec::new();
2333
2334 for args in &command_group {
2335 let result = Cli::try_parse_from(args.clone());
2336 assert!(result.is_ok(), "Should parse: {args:?}");
2337 parsed_commands.push(result.unwrap());
2338 }
2339
2340 let first = &parsed_commands[0];
2342 for other in &parsed_commands[1..] {
2343 assert_eq!(first.verbose, other.verbose, "Verbose flags should match");
2344 assert_eq!(first.debug, other.debug, "Debug flags should match");
2345 assert_eq!(first.profile, other.profile, "Profile flags should match");
2346 }
2347 }
2348 }
2349
2350 #[test]
2353 fn test_completion_generation_for_all_shells() {
2354 use clap_complete::Shell;
2355
2356 let shells = vec![
2358 Shell::Bash,
2359 Shell::Zsh,
2360 Shell::Fish,
2361 Shell::PowerShell,
2362 Shell::Elvish,
2363 ];
2364
2365 for shell in shells {
2366 let result = std::panic::catch_unwind(|| {
2368 crate::commands::generate(shell);
2369 });
2370
2371 assert!(
2372 result.is_ok(),
2373 "Completion generation should not panic for {shell:?}"
2374 );
2375 }
2376 }
2377
2378 #[test]
2379 fn test_completion_cli_structure_contains_all_subcommands() {
2380 use crate::cli::Cli;
2381 use clap::CommandFactory;
2382
2383 let cmd = Cli::command();
2385
2386 let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
2387
2388 let expected_commands = vec![
2390 "search",
2391 "add",
2392 "list",
2393 "get",
2394 "refresh",
2395 "update",
2396 "remove",
2397 "lookup",
2398 "diff",
2399 "completions",
2400 ];
2401
2402 for expected_command in expected_commands {
2403 assert!(
2404 subcommands.contains(&expected_command),
2405 "CLI should have '{expected_command}' subcommand for completions"
2406 );
2407 }
2408
2409 let list_cmd = cmd
2411 .get_subcommands()
2412 .find(|sub| sub.get_name() == "list")
2413 .expect("Should have list command");
2414
2415 let aliases: Vec<&str> = list_cmd.get_all_aliases().collect();
2416 assert!(
2417 aliases.contains(&"sources"),
2418 "List command should have 'sources' alias"
2419 );
2420
2421 let rm_cmd = cmd
2423 .get_subcommands()
2424 .find(|sub| sub.get_name() == "rm")
2425 .expect("Should have rm command");
2426 assert_eq!(rm_cmd.get_name(), "rm");
2427
2428 let remove_cmd = cmd.get_subcommands().find(|sub| sub.get_name() == "remove");
2430 assert!(
2431 remove_cmd.is_some(),
2432 "Remove command should still exist (deprecated)"
2433 );
2434 }
2435
2436 #[test]
2437 fn test_completion_cli_structure_contains_global_flags() {
2438 use crate::cli::Cli;
2439 use clap::CommandFactory;
2440
2441 let cmd = Cli::command();
2443
2444 let global_args: Vec<&str> = cmd
2445 .get_arguments()
2446 .filter(|arg| arg.is_global_set())
2447 .map(|arg| arg.get_id().as_str())
2448 .collect();
2449
2450 let expected_global_flags = vec!["verbose", "debug", "profile"];
2452
2453 for expected_flag in expected_global_flags {
2454 assert!(
2455 global_args.contains(&expected_flag),
2456 "CLI should have global flag '{expected_flag}' for completions"
2457 );
2458 }
2459
2460 let verbose_arg = cmd
2462 .get_arguments()
2463 .find(|arg| arg.get_id().as_str() == "verbose")
2464 .expect("Should have verbose argument");
2465
2466 assert!(
2467 verbose_arg.get_long().is_some(),
2468 "Verbose should have long form --verbose"
2469 );
2470 assert_eq!(
2471 verbose_arg.get_long(),
2472 Some("verbose"),
2473 "Verbose long form should be --verbose"
2474 );
2475 assert!(verbose_arg.is_global_set(), "Verbose should be global");
2476 }
2477
2478 #[test]
2479 fn test_completion_cli_structure_contains_subcommand_flags() {
2480 use crate::cli::Cli;
2481 use clap::CommandFactory;
2482
2483 let cmd = Cli::command();
2484
2485 let search_cmd = cmd
2487 .get_subcommands()
2488 .find(|sub| sub.get_name() == "search")
2489 .expect("Should have search command");
2490
2491 let search_args: Vec<&str> = search_cmd
2492 .get_arguments()
2493 .map(|arg| arg.get_id().as_str())
2494 .collect();
2495
2496 let expected_search_flags = vec![
2497 "sources",
2498 "limit",
2499 "all",
2500 "page",
2501 "top",
2502 "format",
2503 "show",
2504 "no_summary",
2505 ];
2506 for expected_flag in expected_search_flags {
2507 assert!(
2508 search_args.contains(&expected_flag),
2509 "Search command should have '{expected_flag}' flag for completions"
2510 );
2511 }
2512
2513 let add_cmd = cmd
2515 .get_subcommands()
2516 .find(|sub| sub.get_name() == "add")
2517 .expect("Should have add command");
2518
2519 let add_args: Vec<&str> = add_cmd
2520 .get_arguments()
2521 .map(|arg| arg.get_id().as_str())
2522 .collect();
2523
2524 assert!(
2525 add_args.contains(&"yes"),
2526 "Add command should have 'yes' flag"
2527 );
2528
2529 let get_cmd = cmd
2531 .get_subcommands()
2532 .find(|sub| sub.get_name() == "get")
2533 .expect("Should have get command");
2534
2535 let get_args: Vec<&str> = get_cmd
2536 .get_arguments()
2537 .map(|arg| arg.get_id().as_str())
2538 .collect();
2539
2540 assert!(
2541 get_args.contains(&"lines"),
2542 "Get command should have 'lines' flag"
2543 );
2544 assert!(
2545 get_args.contains(&"context"),
2546 "Get command should have 'context' flag"
2547 );
2548
2549 let format_arg = search_cmd
2551 .get_arguments()
2552 .find(|arg| arg.get_id().as_str() == "format")
2553 .expect("Search should have format argument");
2554
2555 assert!(
2556 !format_arg.get_possible_values().is_empty(),
2557 "Format argument should have possible values for completion"
2558 );
2559 }
2560
2561 #[test]
2562 fn test_completion_generation_consistency() {
2563 use clap_complete::Shell;
2564
2565 let shells_to_test = vec![Shell::Bash, Shell::Zsh, Shell::Fish];
2567
2568 for shell in shells_to_test {
2569 for _ in 0..3 {
2571 let result = std::panic::catch_unwind(|| {
2572 crate::commands::generate(shell);
2573 });
2574 assert!(
2575 result.is_ok(),
2576 "Completion generation should be consistent for {shell:?}"
2577 );
2578 }
2579 }
2580 }
2581
2582 #[test]
2583 fn test_completion_command_parsing() {
2584 use clap::Parser;
2585
2586 let shell_completions = vec![
2588 vec!["blz", "completions", "bash"],
2589 vec!["blz", "completions", "zsh"],
2590 vec!["blz", "completions", "fish"],
2591 vec!["blz", "completions", "powershell"],
2592 vec!["blz", "completions", "elvish"],
2593 ];
2594
2595 for args in shell_completions {
2596 let result = Cli::try_parse_from(args.clone());
2597 assert!(result.is_ok(), "Completions command should parse: {args:?}");
2598
2599 if let Ok(cli) = result {
2600 match cli.command {
2601 Some(Commands::Completions { shell: _, .. }) => {
2602 },
2604 other => {
2605 panic!("Expected Completions command, got: {other:?} for args: {args:?}");
2606 },
2607 }
2608 }
2609 }
2610 }
2611
2612 #[test]
2613 fn test_completion_invalid_shell_handling() {
2614 use clap::Parser;
2615
2616 let invalid_shells = vec![
2618 vec!["blz", "completions", "invalid"],
2619 vec!["blz", "completions", "cmd"],
2620 vec!["blz", "completions", ""],
2621 vec!["blz", "completions", "bash_typo"],
2622 vec!["blz", "completions", "ZSH"], ];
2624
2625 for args in invalid_shells {
2626 let result = Cli::try_parse_from(args.clone());
2627 assert!(
2628 result.is_err(),
2629 "Invalid shell should be rejected: {args:?}"
2630 );
2631 }
2632 }
2633
2634 #[test]
2635 fn test_completion_help_generation() {
2636 use clap::Parser;
2637
2638 let help_commands = vec![
2640 vec!["blz", "completions", "--help"],
2641 vec!["blz", "completions", "-h"],
2642 ];
2643
2644 for help_cmd in help_commands {
2645 let result = Cli::try_parse_from(help_cmd.clone());
2646
2647 if let Err(error) = result {
2648 assert_eq!(
2649 error.kind(),
2650 clap::error::ErrorKind::DisplayHelp,
2651 "Completions help should display help: {help_cmd:?}"
2652 );
2653
2654 let help_text = error.to_string();
2655 assert!(
2656 help_text.contains("completions"),
2657 "Help text should mention completions"
2658 );
2659 assert!(
2660 help_text.contains("shell") || help_text.contains("Shell"),
2661 "Help text should mention shell parameter"
2662 );
2663 } else {
2664 panic!("Help command should not succeed: {help_cmd:?}");
2665 }
2666 }
2667 }
2668
2669 #[test]
2670 fn test_completion_integration_with_clap() {
2671 use crate::cli::Cli;
2672 use clap::CommandFactory;
2673
2674 let cmd = Cli::command();
2676
2677 assert_eq!(cmd.get_name(), "blz", "Command name should be 'blz'");
2679
2680 let subcommands: Vec<&str> = cmd.get_subcommands().map(clap::Command::get_name).collect();
2682
2683 let expected_subcommands = vec![
2684 "completions",
2685 "add",
2686 "lookup",
2687 "search",
2688 "get",
2689 "list",
2690 "refresh",
2691 "update",
2692 "remove",
2693 "diff",
2694 ];
2695
2696 for expected in expected_subcommands {
2697 assert!(
2698 subcommands.contains(&expected),
2699 "Command should have subcommand '{expected}', found: {subcommands:?}"
2700 );
2701 }
2702
2703 let completions_cmd = cmd
2705 .get_subcommands()
2706 .find(|sub| sub.get_name() == "completions")
2707 .expect("Should have completions subcommand");
2708
2709 let shell_arg = completions_cmd
2710 .get_arguments()
2711 .find(|arg| arg.get_id() == "shell")
2712 .expect("Completions should have shell argument");
2713
2714 assert!(
2715 shell_arg.is_positional(),
2716 "Shell argument should be positional"
2717 );
2718 }
2719
2720 #[test]
2721 fn test_multi_source_search_parsing() {
2722 use clap::Parser;
2723
2724 let cli = Cli::try_parse_from(vec![
2726 "blz",
2727 "search",
2728 "hooks",
2729 "--source",
2730 "react,vue,svelte",
2731 ])
2732 .unwrap();
2733
2734 if let Some(Commands::Search { sources, .. }) = cli.command {
2735 assert_eq!(sources, vec!["react", "vue", "svelte"]);
2736 } else {
2737 panic!("Expected search command");
2738 }
2739 }
2740
2741 #[test]
2742 fn test_single_source_search_parsing() {
2743 use clap::Parser;
2744
2745 let cli = Cli::try_parse_from(vec!["blz", "search", "hooks", "--source", "react"]).unwrap();
2747
2748 if let Some(Commands::Search { sources, .. }) = cli.command {
2749 assert_eq!(sources, vec!["react"]);
2750 } else {
2751 panic!("Expected search command");
2752 }
2753 }
2754
2755 #[test]
2756 fn test_no_source_search_parsing() {
2757 use clap::Parser;
2758
2759 let cli = Cli::try_parse_from(vec!["blz", "search", "hooks"]).unwrap();
2761
2762 if let Some(Commands::Search { sources, .. }) = cli.command {
2763 assert!(sources.is_empty());
2764 } else {
2765 panic!("Expected search command");
2766 }
2767 }
2768
2769 #[test]
2770 fn test_multi_source_shorthand_parsing() {
2771 use clap::Parser;
2772
2773 let cli = Cli::try_parse_from(vec!["blz", "search", "api", "-s", "bun,node,deno"]).unwrap();
2775
2776 if let Some(Commands::Search { sources, .. }) = cli.command {
2777 assert_eq!(sources, vec!["bun", "node", "deno"]);
2778 } else {
2779 panic!("Expected search command");
2780 }
2781 }
2782
2783 #[test]
2784 fn test_get_command_with_source_flag() {
2785 use clap::Parser;
2786
2787 let cli = Cli::try_parse_from(vec![
2788 "blz", "get", "meta", "--lines", "1-3", "--source", "bun",
2789 ])
2790 .unwrap();
2791
2792 if let Some(Commands::Get {
2793 targets,
2794 source,
2795 lines,
2796 ..
2797 }) = cli.command
2798 {
2799 assert_eq!(targets, vec!["meta".to_string()]);
2800 assert_eq!(source.as_deref(), Some("bun"));
2801 assert_eq!(lines.as_deref(), Some("1-3"));
2802 } else {
2803 panic!("Expected get command");
2804 }
2805 }
2806
2807 #[test]
2808 fn test_get_command_with_source_shorthand() {
2809 use clap::Parser;
2810
2811 let cli = Cli::try_parse_from(vec!["blz", "get", "meta:4-6", "-s", "canonical"]).unwrap();
2812
2813 if let Some(Commands::Get {
2814 targets, source, ..
2815 }) = cli.command
2816 {
2817 assert_eq!(targets, vec!["meta:4-6".to_string()]);
2818 assert_eq!(source.as_deref(), Some("canonical"));
2819 } else {
2820 panic!("Expected get command");
2821 }
2822 }
2823}