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