context7-cli 0.2.7

Search library documentation from your terminal — zero runtime, bilingual (EN/PT), multi-key rotation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
//! Testes de integração E2E para a CLI `context7`.
//!
//! Todos os testes invocam o binário compilado via `assert_cmd::Command::cargo_bin`.
//! Nenhum teste faz I/O de rede real — requisições à API Context7 são isoladas via
//! variável de ambiente ausente (sem chave) ou via wiremock para os paths HTTP.
//! Nenhum teste modifica o sistema de arquivos real do usuário — usa `CONTEXT7_HOME`
//! apontando para `tempfile::TempDir`.

use assert_cmd::Command;
use predicates::prelude::*;
use serial_test::serial;
use tempfile::TempDir;

// ── Helpers ───────────────────────────────────────────────────────────────────

/// Cria um comando `context7` isolado: sem variáveis de ambiente do shell do usuário
/// que possam vazar chaves de API ou configurações XDG reais.
///
/// IMPORTANTE: NÃO define `CONTEXT7_LANG` nem `CONTEXT7_API_KEYS` — definir como string
/// vazia faz o clap tentar parsear `""` contra `value_parser = ["en", "pt"]` e falha.
/// O isolamento de chaves é garantido pelo `CONTEXT7_HOME` apontando para um diretório
/// temporário sem nenhum `config.toml`.
#[allow(deprecated)] // cargo_bin depreciado no assert_cmd 2.1.0+ (build-dir custom); este projeto não usa build-dir customizado
fn cmd_isolado(xdg_home: &TempDir) -> Command {
    let mut cmd = Command::cargo_bin("context7").unwrap();
    cmd.env_clear()
        .env("CONTEXT7_HOME", xdg_home.path())
        .env("HOME", xdg_home.path());
    cmd
}

// ── Testes de --help ───────────────────────────────────────────────────────────

/// Verifica que `--help` termina com exit 0 e contém "Usage".
#[test]
fn testa_help_renderiza_sem_panico() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("Usage").or(predicate::str::contains("usage")));
}

/// Verifica que `--version` não causa panic (pode retornar erro se flag não habilitada).
/// A flag `--version` requer `#[command(version)]` no struct Cli do clap.
/// Na v0.1.0 sem essa anotação, a flag não existe — o teste verifica apenas ausência de panic.
#[test]
fn testa_version_nao_causa_panic() {
    let dir = TempDir::new().unwrap();
    let saida = cmd_isolado(&dir).arg("--version").output().unwrap();
    // Pode retornar exit != 0 se --version não estiver habilitada, mas não deve panic
    let stderr = String::from_utf8_lossy(&saida.stderr);
    assert!(
        !stderr.contains("thread 'main' panicked"),
        "--version não deve causar panic: {stderr}"
    );
}

/// Verifica que `context7 library --help` mostra a ajuda do subcomando.
#[test]
fn testa_help_subcomando_library() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["library", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Usage").or(predicate::str::contains("nome")));
}

/// Verifica que `context7 docs --help` mostra a ajuda do subcomando.
#[test]
fn testa_help_subcomando_docs() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["docs", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Usage").or(predicate::str::contains("library")));
}

/// Verifica que `context7 keys --help` mostra a ajuda do subcomando.
#[test]
fn testa_help_subcomando_keys() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["keys", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Usage").or(predicate::str::contains("subcommand")));
}

// ── Testes de erros sem chave de API ──────────────────────────────────────────

/// Sem chave de API, `library` deve falhar com mensagem amigável (não panic).
#[test]
#[serial]
fn testa_subcomando_library_sem_chave_retorna_erro_amigavel() {
    let dir = TempDir::new().unwrap();
    let saida = cmd_isolado(&dir)
        .args(["library", "react"])
        .output()
        .unwrap();
    // Deve terminar com exit code não-zero
    assert!(!saida.status.success(), "esperava falha sem chave de API");
    // A mensagem de erro deve ser amigável, não um panic
    let stderr = String::from_utf8_lossy(&saida.stderr);
    let stdout = String::from_utf8_lossy(&saida.stdout);
    let saida_combinada = format!("{}{}", stdout, stderr);
    assert!(
        !saida_combinada.contains("thread 'main' panicked"),
        "não deve causar panic: {saida_combinada}"
    );
    assert!(
        !saida_combinada.contains("unwrap()"),
        "não deve vazar mensagem de unwrap: {saida_combinada}"
    );
}

/// Sem chave de API, `docs` deve falhar com mensagem amigável (não panic).
#[test]
#[serial]
fn testa_subcomando_docs_sem_chave_retorna_erro_amigavel() {
    let dir = TempDir::new().unwrap();
    let saida = cmd_isolado(&dir)
        .args(["docs", "/facebook/react"])
        .output()
        .unwrap();
    assert!(!saida.status.success(), "esperava falha sem chave de API");
    let stderr = String::from_utf8_lossy(&saida.stderr);
    let stdout = String::from_utf8_lossy(&saida.stdout);
    let saida_combinada = format!("{}{}", stdout, stderr);
    assert!(
        !saida_combinada.contains("thread 'main' panicked"),
        "não deve causar panic: {saida_combinada}"
    );
}

// ── Testes do subcomando keys (sem rede) ──────────────────────────────────────

/// `keys list` com config vazia retorna mensagem apropriada (exit 0).
#[test]
#[serial]
fn testa_subcomando_keys_list_vazio_retorna_mensagem_apropriada() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["keys", "list"])
        .assert()
        .success()
        .stdout(
            predicate::str::contains("Nenhuma chave")
                .or(predicate::str::contains("0 chave"))
                .or(predicate::str::contains("No key"))
                .or(predicate::str::contains("No keys")),
        );
}

/// `keys path` retorna um caminho de arquivo (contém o CONTEXT7_HOME override).
#[test]
#[serial]
fn testa_subcomando_keys_path_retorna_caminho_xdg() {
    let dir = TempDir::new().unwrap();
    let dir_path = dir.path().to_str().unwrap().to_owned();
    cmd_isolado(&dir)
        .args(["keys", "path"])
        .assert()
        .success()
        .stdout(predicate::str::contains(&dir_path).or(predicate::str::contains("context7")));
}

/// `keys add` adiciona uma chave com sucesso (exit 0).
#[test]
#[serial]
fn testa_subcomando_keys_add_sucesso() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["keys", "add", "ctx7sk-teste-chave-123456789012"])
        .assert()
        .success();
}

/// `keys add` + `keys list` mostra a chave mascarada.
#[test]
#[serial]
fn testa_keys_add_depois_list_mostra_chave_mascarada() {
    let dir = TempDir::new().unwrap();
    // Adiciona chave
    cmd_isolado(&dir)
        .args(["keys", "add", "ctx7sk-chave-integracao-12345678"])
        .assert()
        .success();
    // Lista deve mostrar exatamente 1 chave
    cmd_isolado(&dir)
        .args(["keys", "list"])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 chave").or(predicate::str::contains("[1]")));
}

/// `keys remove` com índice inválido retorna mensagem de erro amigável (exit 0 — erro controlado).
#[test]
#[serial]
fn testa_keys_remove_indice_invalido_retorna_mensagem_amigavel() {
    let dir = TempDir::new().unwrap();
    // Sem chaves, remove índice 99 — deve ser controlado
    let saida = cmd_isolado(&dir)
        .args(["keys", "remove", "99"])
        .output()
        .unwrap();
    // Não deve panic
    let stderr = String::from_utf8_lossy(&saida.stderr);
    let stdout = String::from_utf8_lossy(&saida.stdout);
    assert!(
        !format!("{stdout}{stderr}").contains("thread 'main' panicked"),
        "remove com índice inválido não deve panic"
    );
}

/// `keys clear --yes` remove todas as chaves (exit 0).
#[test]
#[serial]
fn testa_keys_clear_yes_sucesso() {
    let dir = TempDir::new().unwrap();
    // Adiciona uma chave primeiro
    cmd_isolado(&dir)
        .args(["keys", "add", "ctx7sk-para-limpar-123456789012"])
        .assert()
        .success();
    // Limpa com --yes
    cmd_isolado(&dir)
        .args(["keys", "clear", "--yes"])
        .assert()
        .success();
    // Lista deve estar vazia agora
    cmd_isolado(&dir)
        .args(["keys", "list"])
        .assert()
        .success()
        .stdout(
            predicate::str::contains("Nenhuma chave")
                .or(predicate::str::contains("0 chave"))
                .or(predicate::str::contains("No key"))
                .or(predicate::str::contains("No keys")),
        );
}

/// `keys export` sem chaves produz saída vazia (exit 0).
#[test]
#[serial]
fn testa_keys_export_sem_chaves_saida_vazia() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["keys", "export"])
        .assert()
        .success()
        .stdout(predicate::str::is_empty().or(predicate::str::contains("CONTEXT7_API=")));
}

/// `keys export` com chave exporta no formato CONTEXT7_API=<valor>.
#[test]
#[serial]
fn testa_keys_export_com_chave_formato_correto() {
    let dir = TempDir::new().unwrap();
    let chave = "ctx7sk-export-teste-123456789012";
    cmd_isolado(&dir)
        .args(["keys", "add", chave])
        .assert()
        .success();
    cmd_isolado(&dir)
        .args(["keys", "export"])
        .assert()
        .success()
        .stdout(predicate::str::contains(format!("CONTEXT7_API={chave}")));
}

// ── Testes de aliases ──────────────────────────────────────────────────────────

/// O alias `lib` é equivalente a `library` — deve mostrar a mesma ajuda.
#[test]
fn testa_alias_lib_equivale_a_library() {
    let dir = TempDir::new().unwrap();
    let saida_library = cmd_isolado(&dir)
        .args(["library", "--help"])
        .output()
        .unwrap();
    let saida_lib = cmd_isolado(&dir).args(["lib", "--help"]).output().unwrap();
    assert_eq!(
        saida_library.status.success(),
        saida_lib.status.success(),
        "alias lib deve ter mesmo exit code que library"
    );
    assert_eq!(
        saida_library.stdout, saida_lib.stdout,
        "alias lib deve produzir mesma saída que library"
    );
}

/// O alias `doc` é equivalente a `docs` — deve mostrar a mesma ajuda.
#[test]
fn testa_alias_doc_equivale_a_docs() {
    let dir = TempDir::new().unwrap();
    let saida_docs = cmd_isolado(&dir).args(["docs", "--help"]).output().unwrap();
    let saida_doc = cmd_isolado(&dir).args(["doc", "--help"]).output().unwrap();
    assert_eq!(
        saida_docs.status.success(),
        saida_doc.status.success(),
        "alias doc deve ter mesmo exit code que docs"
    );
    assert_eq!(
        saida_docs.stdout, saida_doc.stdout,
        "alias doc deve produzir mesma saída que docs"
    );
}

/// O alias `key` é equivalente a `keys` — deve mostrar a mesma ajuda.
#[test]
fn testa_alias_key_equivale_a_keys() {
    let dir = TempDir::new().unwrap();
    let saida_keys = cmd_isolado(&dir).args(["keys", "--help"]).output().unwrap();
    let saida_key = cmd_isolado(&dir).args(["key", "--help"]).output().unwrap();
    assert_eq!(
        saida_keys.status.success(),
        saida_key.status.success(),
        "alias key deve ter mesmo exit code que keys"
    );
    assert_eq!(
        saida_keys.stdout, saida_key.stdout,
        "alias key deve produzir mesma saída que keys"
    );
}

/// Subcomando inválido deve retornar exit code não-zero.
#[test]
fn testa_subcomando_invalido_retorna_exit_code_nao_zero() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .arg("subcomando-que-nao-existe")
        .assert()
        .failure();
}

/// Flag `--json` combinada com `keys list` deve ser aceita sem crash.
#[test]
#[serial]
fn testa_flag_json_com_keys_list_nao_crasha() {
    let dir = TempDir::new().unwrap();
    // Não deve panic — independente do conteúdo da saída
    let saida = cmd_isolado(&dir)
        .args(["--json", "keys", "list"])
        .output()
        .unwrap();
    let stderr = String::from_utf8_lossy(&saida.stderr);
    assert!(
        !stderr.contains("thread 'main' panicked"),
        "não deve panic com --json keys list"
    );
}

// ── Testes novos v0.2.1 — cobertura dos bugs corrigidos ──────────────────────

/// `docs` sem chaves de API exibe mensagem de erro sem panic.
///
/// Garante que a ausência de chaves não causa panic ou stack overflow,
/// apenas uma mensagem de erro controlada.
#[test]
#[serial]
fn testa_docs_sem_chaves_exibe_erro_sem_panico() {
    let dir = TempDir::new().unwrap();
    let saida = cmd_isolado(&dir)
        .args(["docs", "/test/lib"])
        .output()
        .unwrap();

    assert!(!saida.status.success(), "docs sem chaves deve falhar");

    let stderr = String::from_utf8_lossy(&saida.stderr);
    let stdout = String::from_utf8_lossy(&saida.stdout);
    let combinado = format!("{stdout}{stderr}");

    assert!(
        !combinado.contains("thread 'main' panicked"),
        "docs sem chaves não deve causar panic: {combinado}"
    );
    assert!(
        !combinado.contains("unwrap()"),
        "docs sem chaves não deve vazar mensagem de unwrap: {combinado}"
    );
}

/// `context7 docs --help` renderiza ajuda sem crash.
#[test]
fn testa_docs_help_renderiza() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["docs", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Usage").or(predicate::str::contains("usage")));
}

/// `keys rotate` retorna "unrecognized subcommand" — garante que esse subcomando
/// foi intencionalmente removido na v0.2.0+ e não foi re-adicionado por engano.
///
/// Se este teste falhar, alguém adicionou `rotate` de volta — revisar intenção.
#[test]
fn testa_keys_rotate_retorna_erro_subcomando_nao_reconhecido() {
    let dir = TempDir::new().unwrap();
    let saida = cmd_isolado(&dir).args(["keys", "rotate"]).output().unwrap();

    assert!(
        !saida.status.success(),
        "keys rotate deve retornar exit != 0"
    );

    let stderr = String::from_utf8_lossy(&saida.stderr);
    assert!(
        stderr.contains("unrecognized") || stderr.contains("error"),
        "stderr deve mencionar subcomando inválido: {stderr}"
    );
    assert!(
        !stderr.contains("thread 'main' panicked"),
        "keys rotate não deve causar panic: {stderr}"
    );
}

// ── Testes novos v0.2.3 — parâmetros clap em EN (Bug #2) ─────────────────────

/// `library --help` deve exibir `<NAME>` e não `<NOME>` após renomear o parâmetro.
///
/// v0.2.2: exibia `<NOME>` (identificador Rust em PT).
/// v0.2.3: deve exibir `<NAME>` (padrão EN para binários publicados no crates.io).
#[test]
fn testa_library_help_exibe_name_em_ingles() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["library", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("<NAME>"))
        .stdout(predicate::str::contains("<NOME>").not());
}

/// `keys add --help` deve exibir `<KEY>` e não `<CHAVE>`.
///
/// v0.2.2: exibia `<CHAVE>`.
/// v0.2.3: deve exibir `<KEY>`.
#[test]
fn testa_keys_add_help_exibe_key_em_ingles() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["keys", "add", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("<KEY>"))
        .stdout(predicate::str::contains("<CHAVE>").not());
}

/// `keys remove --help` deve exibir `<INDEX>` e não `<INDICE>`.
///
/// v0.2.2: exibia `<INDICE>`.
/// v0.2.3: deve exibir `<INDEX>`.
#[test]
fn testa_keys_remove_help_exibe_index_em_ingles() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["keys", "remove", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("<INDEX>"))
        .stdout(predicate::str::contains("<INDICE>").not());
}

/// `keys import --help` deve exibir `<FILE>` e não `<ARQUIVO>`.
///
/// v0.2.2: exibia `<ARQUIVO>`.
/// v0.2.3: deve exibir `<FILE>`.
#[test]
fn testa_keys_import_help_exibe_file_em_ingles() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["keys", "import", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("<FILE>"))
        .stdout(predicate::str::contains("<ARQUIVO>").not());
}

// ── Testes novos v0.2.3 — hint hardcoded em PT removido (Bug #3) ─────────────

/// `library --help` NÃO deve conter o exemplo "hooks de efeito" (PT hardcoded).
///
/// v0.2.2: exibia `(e.g. "hooks de efeito")` no doc comment do parâmetro `query`.
/// v0.2.3: deve exibir `(e.g. "effect hooks")` — exemplo em EN neutro.
#[test]
fn testa_library_help_nao_contem_hooks_de_efeito() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["library", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("hooks de efeito").not());
}

/// `library --help` deve conter o exemplo em EN após correção do Bug #3.
///
/// v0.2.3: exemplo passa a ser `"effect hooks"` em vez de `"hooks de efeito"`.
#[test]
fn testa_library_help_contem_exemplo_em_ingles() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["library", "--help"])
        .assert()
        .success()
        .stdout(predicate::str::contains("effect hooks"));
}

// ── Testes novos v0.2.3 — dica BibliotecaNaoEncontradaApi (Bug #1) ───────────

/// `docs` com biblioteca inexistente deve exibir dica de verificar o ID no stderr.
///
/// v0.2.2: quando HTTP 404, o erro era "Library not found: /id" sem dica de como
/// encontrar o ID correto — deixava o usuário sem ação clara.
/// v0.2.3: após o erro, deve aparecer "context7 library <name>" (EN) ou
/// "context7 library <nome>" (PT) no stderr como dica de próxima ação.
///
/// Usa wiremock para simular resposta 404 sem depender de rede real.
/// NOTA: Este teste usa assert_cmd diretamente com CONTEXT7_HOME apontando
/// para um diretório com uma chave falsa, forçando o binário a chamar a API.
/// Como não temos como injetar wiremock no binário compilado facilmente,
/// testamos via testes unitários de output.rs e i18n.rs.
#[test]
fn testa_dica_biblioteca_nao_encontrada_mensagem_contem_library_command() {
    // Verifica que a mensagem de dica da variante BibliotecaNaoEncontradaApi
    // em ambos os idiomas menciona o comando "library" para guiar o usuário.
    use context7_cli::i18n::{Idioma, Mensagem};
    let en = Mensagem::BibliotecaNaoEncontradaApi.texto(Idioma::English);
    let pt = Mensagem::BibliotecaNaoEncontradaApi.texto(Idioma::Portugues);

    assert!(
        en.to_lowercase().contains("library"),
        "dica EN deve mencionar 'library': {en}"
    );
    assert!(
        pt.to_lowercase().contains("library"),
        "dica PT deve mencionar 'library': {pt}"
    );
}

/// `docs` sem chaves exibe mensagem de erro sem mencionar o mecanismo interno de retry.
///
/// Garante que a mensagem de erro não expõe detalhes de implementação (como
/// "No valid API key after 5 attempts") quando o verdadeiro problema é a ausência de chaves.
#[test]
#[serial]
fn testa_docs_sem_chaves_nao_expoe_detalhes_internos_de_retry() {
    let dir = TempDir::new().unwrap();
    let saida = cmd_isolado(&dir)
        .args(["docs", "/biblioteca/inexistente"])
        .output()
        .unwrap();

    assert!(!saida.status.success(), "docs sem chaves deve falhar");

    let stderr = String::from_utf8_lossy(&saida.stderr);
    let stdout = String::from_utf8_lossy(&saida.stdout);
    let combinado = format!("{stdout}{stderr}");

    // Não deve vazar detalhe de implementação de retry
    assert!(
        !combinado.contains("No valid API key after"),
        "mensagem não deve expor mecanismo de retry: {combinado}"
    );
    assert!(
        !combinado.contains("thread 'main' panicked"),
        "não deve causar panic: {combinado}"
    );
}

// ── Testes regression EXTRA-01/03/04 (v0.2.6) ────────────────────────────────

/// EXTRA-01 — Alias `-q` aparece no help de `docs` e está mapeado para `--query`.
///
/// Regression test: garante que a flag curta `-q` foi registrada no clap e aparece
/// no texto de ajuda do subcomando `docs`. Não realiza chamada de rede.
#[test]
fn testa_docs_help_exibe_alias_q_e_long_query() {
    let dir = TempDir::new().unwrap();
    let saida = cmd_isolado(&dir).args(["docs", "--help"]).output().unwrap();

    assert!(
        saida.status.success(),
        "docs --help deve terminar com sucesso"
    );

    let stdout = String::from_utf8_lossy(&saida.stdout);
    assert!(
        stdout.contains("-q") && stdout.contains("--query"),
        "help de docs deve exibir '-q, --query': {stdout}"
    );
}

/// EXTRA-01 — Alias `-q` é aceito pelo parser do clap sem erro de argumento.
///
/// Usa `CONTEXT7_API_KEYS=ctx7sk-fake` para forçar parse OK + falha na API (não parse),
/// verificando que `-q` não causa "unexpected argument".
#[test]
fn testa_docs_alias_q_e_aceito_como_argumento_valido() {
    let dir = TempDir::new().unwrap();
    let saida = cmd_isolado(&dir)
        .env("CONTEXT7_API_KEYS", "ctx7sk-fake-key-12345678901")
        .args(["docs", "/reactjs/react.dev", "-q", "hooks"])
        .output()
        .unwrap();

    let stderr = String::from_utf8_lossy(&saida.stderr);
    // O parser NÃO deve rejeitar -q como argumento desconhecido
    assert!(
        !stderr.contains("unexpected argument"),
        "'-q' não deve ser rejeitado como argumento desconhecido: {stderr}"
    );
    assert!(
        !stderr.contains("thread 'main' panicked"),
        "não deve causar panic: {stderr}"
    );
}

/// EXTRA-03 — `keys import` com arquivo inexistente exibe mensagem útil (não panic).
///
/// Regression test: garante que a mensagem de erro menciona o arquivo ou o problema
/// de leitura, sem expor stack trace ou mensagem de sistema ininteligível.
#[test]
fn testa_keys_import_arquivo_inexistente_mensagem_util() {
    let dir = TempDir::new().unwrap();
    cmd_isolado(&dir)
        .args(["keys", "import", "/tmp/arquivo-que-nao-existe-v026.env"])
        .assert()
        .failure()
        .stderr(
            predicate::str::contains("arquivo")
                .or(predicate::str::contains("file"))
                .or(predicate::str::contains("No such")),
        );
}

/// EXTRA-03 — `keys import` com arquivo sem entradas `CONTEXT7_API=` exibe mensagem útil.
///
/// Regression test: garante que a mensagem de erro menciona `CONTEXT7_API` para guiar
/// o usuário sobre o formato esperado.
#[test]
fn testa_keys_import_arquivo_sem_chaves_context7_mensagem_util() {
    let dir = TempDir::new().unwrap();
    let arquivo_invalido = dir.path().join("invalido.env");
    std::fs::write(
        &arquivo_invalido,
        "LIXO=nao_e_chave_context7\nOUTRA_VAR=valor\n",
    )
    .unwrap();

    cmd_isolado(&dir)
        .args(["keys", "import", arquivo_invalido.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("CONTEXT7_API"));
}

/// EXTRA-04 — `keys add` com chave duplicada avisa em vez de fingir sucesso silencioso.
///
/// Regression test: garante que a segunda adição da mesma chave exibe mensagem indicando
/// que a chave já existia (não simplesmente adiciona duplicata sem aviso).
#[test]
#[serial]
fn testa_keys_add_duplicata_exibe_aviso_de_chave_existente() {
    let dir = TempDir::new().unwrap();
    let chave = "ctx7sk-dedup-regression-v026-abc";

    // 1ª adição: deve ter sucesso
    cmd_isolado(&dir)
        .args(["keys", "add", chave])
        .assert()
        .success();

    // 2ª adição da mesma chave: deve avisar
    let saida = cmd_isolado(&dir)
        .args(["keys", "add", chave])
        .output()
        .unwrap();

    let stdout = String::from_utf8_lossy(&saida.stdout);
    let stderr = String::from_utf8_lossy(&saida.stderr);
    let combinado = format!("{stdout}{stderr}");

    // Deve conter indicação de chave existente ou ignorada
    assert!(
        combinado.to_lowercase().contains("existe")
            || combinado.to_lowercase().contains("exists")
            || combinado.to_lowercase().contains("already")
            || combinado.to_lowercase().contains("ignor")
            || combinado.to_lowercase().contains("skip"),
        "segunda adição deve exibir aviso de duplicata. stdout='{stdout}' stderr='{stderr}'"
    );

    // Não deve causar panic
    assert!(
        !combinado.contains("thread 'main' panicked"),
        "não deve causar panic: {combinado}"
    );
}