Skip to main content

duckduckgo_search_cli/
lib.rs

1//! # duckduckgo-search-cli
2//!
3//! Rust CLI for searching DuckDuckGo via pure HTTP, with structured JSON output
4//! for LLM consumption. No paid API. No Chrome (during the search phase).
5//! No cache. Universal cross-platform (Linux including Alpine/NixOS/Flatpak/Snap,
6//! macOS including Apple Silicon, Windows including cmd.exe and PowerShell).
7//!
8//! ## Module Structure
9//!
10//! | Module        | Responsibility                                               |
11//! |---------------|--------------------------------------------------------------|
12//! | [`cli`]       | Clap structs (command-line argument parsing).                |
13//! | [`http`]      | `reqwest::Client` construction and User-Agent selection.     |
14//! | [`search`]    | URL building and HTTP request to the DuckDuckGo endpoint.    |
15//! | [`extraction`]| HTML parsing with `scraper` and ad filtering.                |
16//! | [`pipeline`]  | Single/multi orchestration, deduplication and source reading.|
17//! | [`parallel`]  | Multi-query fan-out with JoinSet, Semaphore, CancellationToken.|
18//! | [`output`]    | JSON serialization and stdout writing (ONLY module with `println!`).|
19//! | [`platform`]  | Cross-platform initialization (UTF-8 on Windows, TTY detect).|
20//! | [`types`]     | Shared structs and enums.                                    |
21//! | [`error`]     | Error codes and exit codes.                                  |
22//! | [`content`]   | HTTP + readability extraction for `--fetch-content` (iter. 5).|
23//! | [`fetch_conteudo`] | Parallel fan-out + per-host rate-limit (iter. 5 / 6).  |
24//! | [`selectors`] | Loading of external `ConfiguracaoSeletores` (iter. 6).      |
25//! | [`signals`]   | Cross-platform signal handlers (SIGPIPE, Ctrl+C).            |
26//! | [`config_init`] | `init-config` subcommand (iter. 6).                       |
27//! | [`paths`]     | Path validation and sanitization for I/O.                    |
28//! | `browser`     | Headless Chrome cross-platform under feature `chrome` (iter.7).|
29//!
30//! ## Entry Point
31//!
32//! The public function [`run`] is called by `main.rs` and returns an exit code
33//! as specified in section 17.7 of the specification.
34
35pub mod cli;
36pub mod config_init;
37pub mod content;
38pub mod error;
39pub mod extraction;
40pub mod fetch_conteudo;
41pub mod http;
42pub mod output;
43pub mod parallel;
44pub mod paths;
45pub mod pipeline;
46pub mod platform;
47pub mod search;
48pub mod selectors;
49pub mod signals;
50pub mod types;
51
52// browser.rs só compila com a feature `chrome` (zero overhead no MVP).
53#[cfg(feature = "chrome")]
54pub mod browser;
55
56use crate::cli::{
57    ArgumentosCli, ArgumentosInitConfig, ArgumentosRaiz, EndpointCli, FiltroTemporalCli,
58    SafeSearchCli, Subcomando,
59};
60use crate::error::exit_codes;
61use crate::types::{Configuracoes, Endpoint, FiltroTemporal, FormatoSaida, SafeSearch};
62use anyhow::{Context, Result};
63use clap::Parser;
64use tokio_util::sync::CancellationToken;
65use tracing_subscriber::{fmt, EnvFilter};
66
67/// Library entry point. Called by `main.rs`.
68///
69/// Returns the appropriate exit code (0 success, 1 generic error, 2 invalid config, etc.).
70pub async fn run(cancelamento: CancellationToken) -> i32 {
71    // Parse da linha de comando — clap termina o processo com código 2 em caso de erro.
72    let raiz = ArgumentosRaiz::parse();
73
74    // Despacha subcomando (ou cai no default = Buscar).
75    let argumentos = match raiz.subcomando {
76        Some(Subcomando::InitConfig(args)) => {
77            return executar_init_config(args);
78        }
79        Some(Subcomando::Buscar(args)) => *args,
80        None => raiz.buscar,
81    };
82
83    // Inicializa logging em stderr (antes de qualquer operação que possa emitir logs).
84    inicializar_logging(argumentos.verboso, argumentos.silencioso);
85
86    // Inicializa plataforma (UTF-8 no Windows, etc.).
87    platform::iniciar();
88
89    // Converte ArgumentosCli em Configuracoes internas.
90    let configuracoes = match montar_configuracoes(&argumentos) {
91        Ok(c) => c,
92        Err(erro) => {
93            tracing::error!(?erro, "Configuração inválida");
94            eprintln!("Erro de configuração: {erro:#}");
95            return exit_codes::CONFIGURACAO_INVALIDA;
96        }
97    };
98
99    let formato = configuracoes.formato;
100    let arquivo_saida = configuracoes.arquivo_saida.clone();
101    let timeout_global = std::time::Duration::from_secs(configuracoes.timeout_global_segundos);
102
103    // Envolve o pipeline em `tokio::time::timeout` — se expirar, cancela tudo
104    // e retorna exit code 4 (TIMEOUT_GLOBAL).
105    let cancelamento_interno = cancelamento.clone();
106    let futuro_pipeline = pipeline::executar_pipeline(configuracoes, cancelamento_interno);
107
108    let resultado_pipeline = match tokio::time::timeout(timeout_global, futuro_pipeline).await {
109        Ok(resultado) => resultado,
110        Err(_elapsed) => {
111            // Propaga cancelamento para qualquer task que ainda esteja em voo.
112            cancelamento.cancel();
113            tracing::error!(
114                segundos = timeout_global.as_secs(),
115                "timeout global excedido — execução abortada"
116            );
117            eprintln!(
118                "Erro: timeout global de {}s excedido",
119                timeout_global.as_secs()
120            );
121            return exit_codes::TIMEOUT_GLOBAL;
122        }
123    };
124
125    match resultado_pipeline {
126        Ok(resultado) => {
127            let total = resultado.total_resultados();
128            let codigo_saida = if total == 0 {
129                tracing::warn!("Zero resultados retornados em todas as queries");
130                exit_codes::ZERO_RESULTADOS
131            } else {
132                exit_codes::SUCESSO
133            };
134
135            if let Err(erro) =
136                output::emitir_resultado(&resultado, formato, arquivo_saida.as_deref())
137            {
138                if output::eh_broken_pipe(&erro) {
139                    // Pipe fechado pelo consumidor (ex: `| jaq`, `| head`).
140                    // Comportamento Unix padrão — exit 0 silenciosamente.
141                    return exit_codes::SUCESSO;
142                }
143                tracing::error!(?erro, "Falha ao emitir resultado");
144                eprintln!("Erro ao escrever output: {erro:#}");
145                return exit_codes::ERRO_GENERICO;
146            }
147
148            codigo_saida
149        }
150        Err(erro) => {
151            tracing::error!(?erro, "Falha na execução do pipeline");
152            eprintln!("Erro: {erro:#}");
153            exit_codes::ERRO_GENERICO
154        }
155    }
156}
157
158/// Executes the `init-config` subcommand and prints the report in JSON format.
159///
160/// Returns `SUCESSO` if all files were processed (including skipped ones);
161/// returns `ERRO_GENERICO` on fatal failure (e.g., config directory undetermined).
162fn executar_init_config(args: ArgumentosInitConfig) -> i32 {
163    // Inicializa logging mínimo (info) para o relatório.
164    inicializar_logging(false, false);
165    platform::iniciar();
166
167    let relatorio = match config_init::inicializar_config(args.forcar, args.dry_run) {
168        Ok(r) => r,
169        Err(erro) => {
170            tracing::error!(?erro, "falha ao inicializar config");
171            eprintln!("Erro: {erro:#}");
172            return exit_codes::ERRO_GENERICO;
173        }
174    };
175
176    match serde_json::to_string_pretty(&relatorio) {
177        Ok(json) => {
178            if let Err(erro) = output::imprimir_linha_stdout(&json) {
179                if output::eh_broken_pipe(&erro) {
180                    return exit_codes::SUCESSO;
181                }
182                tracing::error!(?erro, "falha ao emitir relatório");
183                return exit_codes::ERRO_GENERICO;
184            }
185        }
186        Err(erro) => {
187            tracing::error!(?erro, "falha ao serializar relatório JSON");
188            return exit_codes::ERRO_GENERICO;
189        }
190    }
191
192    // Houve erro em algum arquivo individual? Retornar erro genérico mesmo assim.
193    let houve_erro = relatorio
194        .arquivos
195        .iter()
196        .any(|a| matches!(a.acao, crate::config_init::AcaoArquivoConfig::Erro { .. }));
197    if houve_erro {
198        return exit_codes::ERRO_GENERICO;
199    }
200
201    exit_codes::SUCESSO
202}
203
204/// Initializes the tracing subscriber writing to stderr.
205///
206/// - `--quiet` → `ERROR` only.
207/// - `--verbose` → `DEBUG` and above.
208/// - Default → `INFO` and above (but respects `RUST_LOG` if set).
209fn inicializar_logging(verboso: bool, silencioso: bool) {
210    let filtro = if silencioso {
211        EnvFilter::new("error")
212    } else if verboso {
213        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug"))
214    } else {
215        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
216    };
217
218    // Escrevemos em stderr para NÃO contaminar o output JSON em stdout.
219    let subscriber = fmt()
220        .with_env_filter(filtro)
221        .with_writer(std::io::stderr)
222        .with_target(false)
223        .compact()
224        .finish();
225
226    // try_init permite que testes instalem seu próprio subscriber sem conflito.
227    let _ = tracing::subscriber::set_global_default(subscriber);
228}
229
230/// Converts raw CLI arguments into validated `Configuracoes`.
231///
232/// Combines queries from: (1) positional arguments, (2) file via
233/// `--queries-file`, (3) stdin when it is not a TTY. Deduplicates while
234/// preserving the order of first occurrence.
235fn montar_configuracoes(argumentos: &ArgumentosCli) -> Result<Configuracoes> {
236    let formato = FormatoSaida::a_partir_de_str(&argumentos.formato)
237        .with_context(|| format!("formato desconhecido: {:?}", argumentos.formato))?;
238
239    argumentos
240        .validar_paralelismo()
241        .map_err(|e| anyhow::anyhow!(e))?;
242    argumentos
243        .validar_paginas()
244        .map_err(|e| anyhow::anyhow!(e))?;
245    argumentos
246        .validar_retries()
247        .map_err(|e| anyhow::anyhow!(e))?;
248    argumentos
249        .validar_max_tamanho_conteudo()
250        .map_err(|e| anyhow::anyhow!(e))?;
251    argumentos
252        .validar_global_timeout()
253        .map_err(|e| anyhow::anyhow!(e))?;
254    argumentos.validar_proxy().map_err(|e| anyhow::anyhow!(e))?;
255    argumentos
256        .validar_limite_por_host()
257        .map_err(|e| anyhow::anyhow!(e))?;
258    argumentos
259        .validar_timeout_segundos()
260        .map_err(|e| anyhow::anyhow!(e))?;
261    if let Some(caminho) = &argumentos.arquivo_saida {
262        crate::paths::validar_caminho_saida(caminho)?;
263    }
264
265    let queries_arquivo = match &argumentos.arquivo_queries {
266        Some(caminho) => pipeline::ler_queries_de_arquivo(caminho)
267            .with_context(|| format!("falha ao processar --queries-file {}", caminho.display()))?,
268        None => Vec::new(),
269    };
270
271    // Lê stdin apenas se nenhum argumento posicional foi fornecido E nenhum
272    // arquivo foi passado. Isso reproduz o comportamento Unix clássico.
273    let queries_stdin = if argumentos.queries.is_empty() && argumentos.arquivo_queries.is_none() {
274        pipeline::ler_queries_de_stdin_se_pipe().context("falha ao ler queries de stdin")?
275    } else {
276        Vec::new()
277    };
278
279    let queries = pipeline::combinar_e_deduplicar_queries(
280        argumentos.queries.clone(),
281        queries_arquivo,
282        queries_stdin,
283    );
284
285    if queries.is_empty() {
286        anyhow::bail!(
287            "nenhuma query fornecida (argumentos posicionais, --queries-file ou stdin vazios)"
288        );
289    }
290
291    let primeira = queries[0].clone();
292
293    // Carrega lista de UAs — tenta arquivo externo, cai em defaults embutidos.
294    let lista_uas = http::carregar_user_agents(argumentos.corresponde_plataforma_ua);
295    let perfil_browser = http::escolher_perfil_da_lista(&lista_uas);
296    let user_agent = perfil_browser.user_agent.clone();
297
298    // Carrega seletores CSS — tenta arquivo TOML externo, cai em defaults embutidos.
299    let seletores = selectors::carregar_seletores();
300
301    // --- Default de --num e auto-paginação (v0.4.0) ---
302    //
303    // Semântica (decidida em v0.4.0):
304    // - Se o usuário NÃO passa `--num`, usamos 15 como default efetivo.
305    // - Se o `num` efetivo for > 10 e o usuário NÃO customizou `--pages`
306    //   (ou seja, `paginas == 1`, que é o default do clap), auto-elevamos
307    //   `paginas` para `ceil(num/10)`, limitado ao teto de 5 (PAGINAS_MAXIMO
308    //   validado em `validar_paginas`).
309    // - Se o usuário passa `--pages > 1` explicitamente, RESPEITAMOS o valor
310    //   dele sem sobrescrever (caso raro: `--pages 1` explícito é
311    //   indistinguível do default; trade-off aceito).
312    let num_efetivo = argumentos.num_resultados.unwrap_or(15);
313    let paginas_efetivas = if argumentos.paginas > 1 {
314        argumentos.paginas
315    } else if num_efetivo > 10 {
316        num_efetivo.div_ceil(10).min(5)
317    } else {
318        1
319    };
320
321    Ok(Configuracoes {
322        query: primeira,
323        queries,
324        num_resultados: Some(num_efetivo),
325        formato,
326        timeout_segundos: argumentos.timeout_segundos,
327        idioma: argumentos.idioma.clone(),
328        pais: argumentos.pais.clone(),
329        modo_verboso: argumentos.verboso,
330        modo_silencioso: argumentos.silencioso,
331        user_agent,
332        perfil_browser,
333        paralelismo: argumentos.paralelismo,
334        paginas: paginas_efetivas,
335        retries: argumentos.retries,
336        endpoint: converter_endpoint(argumentos.endpoint),
337        filtro_temporal: argumentos.filtro_temporal.map(converter_filtro_temporal),
338        safe_search: converter_safe_search(argumentos.safe_search),
339        modo_stream: argumentos.modo_stream,
340        arquivo_saida: argumentos.arquivo_saida.clone(),
341        buscar_conteudo: argumentos.buscar_conteudo,
342        max_tamanho_conteudo: argumentos.max_tamanho_conteudo,
343        proxy: argumentos.proxy.clone(),
344        sem_proxy: argumentos.sem_proxy,
345        timeout_global_segundos: argumentos.timeout_global_segundos,
346        corresponde_plataforma_ua: argumentos.corresponde_plataforma_ua,
347        limite_por_host: argumentos.limite_por_host as usize,
348        caminho_chrome: argumentos.caminho_chrome.clone(),
349        seletores,
350    })
351}
352
353/// Converts the `EndpointCli` enum (clap) into the internal `Endpoint` type.
354fn converter_endpoint(origem: EndpointCli) -> Endpoint {
355    match origem {
356        EndpointCli::Html => Endpoint::Html,
357        EndpointCli::Lite => Endpoint::Lite,
358    }
359}
360
361/// Converts the `FiltroTemporalCli` enum (clap) into the internal `FiltroTemporal` type.
362fn converter_filtro_temporal(origem: FiltroTemporalCli) -> FiltroTemporal {
363    match origem {
364        FiltroTemporalCli::D => FiltroTemporal::Dia,
365        FiltroTemporalCli::W => FiltroTemporal::Semana,
366        FiltroTemporalCli::M => FiltroTemporal::Mes,
367        FiltroTemporalCli::Y => FiltroTemporal::Ano,
368    }
369}
370
371/// Converts the `SafeSearchCli` enum (clap) into the internal `SafeSearch` type.
372fn converter_safe_search(origem: SafeSearchCli) -> SafeSearch {
373    match origem {
374        SafeSearchCli::Off => SafeSearch::Off,
375        SafeSearchCli::Moderate => SafeSearch::Moderate,
376        SafeSearchCli::On => SafeSearch::Strict,
377    }
378}
379
380#[cfg(test)]
381mod testes {
382    use super::*;
383
384    fn argumentos_base() -> ArgumentosCli {
385        ArgumentosCli {
386            queries: vec!["rust async".to_string()],
387            num_resultados: Some(5),
388            formato: "json".to_string(),
389            arquivo_saida: None,
390            timeout_segundos: 15,
391            idioma: "pt".to_string(),
392            pais: "br".to_string(),
393            paralelismo: 5,
394            arquivo_queries: None,
395            paginas: 1,
396            retries: 2,
397            endpoint: EndpointCli::Html,
398            filtro_temporal: None,
399            safe_search: SafeSearchCli::Moderate,
400            modo_stream: false,
401            verboso: false,
402            silencioso: false,
403            buscar_conteudo: false,
404            max_tamanho_conteudo: crate::cli::MAX_CONTENT_LENGTH_PADRAO,
405            proxy: None,
406            sem_proxy: false,
407            timeout_global_segundos: crate::cli::GLOBAL_TIMEOUT_PADRAO,
408            corresponde_plataforma_ua: false,
409            limite_por_host: crate::cli::PER_HOST_LIMIT_PADRAO,
410            caminho_chrome: None,
411        }
412    }
413
414    #[test]
415    fn montar_configuracoes_com_argumentos_validos() {
416        let argumentos = argumentos_base();
417        let cfg = montar_configuracoes(&argumentos).expect("deve montar configurações");
418        assert_eq!(cfg.query, "rust async");
419        assert_eq!(cfg.queries, vec!["rust async".to_string()]);
420        assert_eq!(cfg.formato, FormatoSaida::Json);
421        assert_eq!(cfg.num_resultados, Some(5));
422        assert_eq!(cfg.paralelismo, 5);
423        assert_eq!(cfg.paginas, 1);
424        assert!(!cfg.modo_stream);
425    }
426
427    #[test]
428    fn montar_configuracoes_rejeita_queries_todas_vazias() {
429        let mut argumentos = argumentos_base();
430        argumentos.queries = vec!["   ".to_string(), "".to_string()];
431        let resultado = montar_configuracoes(&argumentos);
432        assert!(resultado.is_err());
433    }
434
435    #[test]
436    fn montar_configuracoes_rejeita_formato_desconhecido() {
437        let mut argumentos = argumentos_base();
438        argumentos.formato = "xml".to_string();
439        assert!(montar_configuracoes(&argumentos).is_err());
440    }
441
442    #[test]
443    fn montar_configuracoes_rejeita_paralelismo_zero() {
444        let mut argumentos = argumentos_base();
445        argumentos.paralelismo = 0;
446        assert!(montar_configuracoes(&argumentos).is_err());
447    }
448
449    #[test]
450    fn montar_configuracoes_rejeita_paralelismo_acima_do_maximo() {
451        let mut argumentos = argumentos_base();
452        argumentos.paralelismo = 50;
453        assert!(montar_configuracoes(&argumentos).is_err());
454    }
455
456    #[test]
457    fn montar_configuracoes_aplica_default_num_15_quando_omitido() {
458        // v0.4.0: quando `--num` é omitido (None), o default efetivo é 15
459        // E isso auto-eleva `--pages` para 2 (já que 15 > 10 e pages=1 é o default).
460        let mut argumentos = argumentos_base();
461        argumentos.num_resultados = None;
462        argumentos.paginas = 1;
463        let cfg = montar_configuracoes(&argumentos).expect("deve montar");
464        assert_eq!(cfg.num_resultados, Some(15), "default 15 quando None");
465        assert_eq!(cfg.paginas, 2, "auto-eleva para ceil(15/10) = 2");
466    }
467
468    #[test]
469    fn montar_configuracoes_respeita_pages_explicito_acima_de_1() {
470        // Se o usuário passa `--pages 3` explícito, NÃO sobrescrever com
471        // auto-paginação, mesmo que num efetivo exigisse menos.
472        let mut argumentos = argumentos_base();
473        argumentos.num_resultados = Some(20);
474        argumentos.paginas = 3;
475        let cfg = montar_configuracoes(&argumentos).expect("deve montar");
476        assert_eq!(cfg.num_resultados, Some(20));
477        assert_eq!(cfg.paginas, 3, "respeita --pages explícito do usuário");
478    }
479
480    #[test]
481    fn montar_configuracoes_auto_pagina_quando_num_maior_que_10() {
482        // Casos de fronteira do auto-paginador.
483        let casos = [
484            (11u32, 2u32), // ceil(11/10) = 2
485            (15, 2),       // ceil(15/10) = 2
486            (20, 2),       // ceil(20/10) = 2
487            (21, 3),       // ceil(21/10) = 3
488            (45, 5),       // ceil(45/10) = 5
489            (60, 5),       // ceil(60/10) = 6 mas clamp em 5
490        ];
491        for (num, paginas_esperadas) in casos {
492            let mut argumentos = argumentos_base();
493            argumentos.num_resultados = Some(num);
494            argumentos.paginas = 1;
495            let cfg = montar_configuracoes(&argumentos)
496                .unwrap_or_else(|e| panic!("deve montar para num={num}: {e}"));
497            assert_eq!(
498                cfg.paginas, paginas_esperadas,
499                "para num={num}, paginas deveria ser {paginas_esperadas}"
500            );
501        }
502    }
503
504    #[test]
505    fn montar_configuracoes_nao_auto_pagina_quando_num_10_ou_menos() {
506        // Se num efetivo <= 10, mantém paginas=1 (sem auto-paginação).
507        for num in [1u32, 5, 10] {
508            let mut argumentos = argumentos_base();
509            argumentos.num_resultados = Some(num);
510            argumentos.paginas = 1;
511            let cfg = montar_configuracoes(&argumentos).expect("deve montar");
512            assert_eq!(cfg.paginas, 1, "num={num} não deveria auto-paginar");
513        }
514    }
515
516    #[test]
517    fn montar_configuracoes_combina_multiplas_queries_posicionais() {
518        let mut argumentos = argumentos_base();
519        argumentos.queries = vec![
520            "alfa".to_string(),
521            "beta".to_string(),
522            "alfa".to_string(), // duplicata
523            "gama".to_string(),
524        ];
525        let cfg = montar_configuracoes(&argumentos).expect("deve montar configurações");
526        assert_eq!(cfg.queries, vec!["alfa", "beta", "gama"]);
527        assert_eq!(cfg.query, "alfa");
528    }
529}