context7-cli 0.2.4

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
//! 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 `XDG_CONFIG_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 `XDG_CONFIG_HOME` apontando para um diretório
/// temporário sem nenhum `config.toml`.
fn cmd_isolado(xdg_home: &TempDir) -> Command {
    let mut cmd = Command::cargo_bin("context7").unwrap();
    cmd.env_clear()
        .env("XDG_CONFIG_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 XDG_CONFIG_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 XDG_CONFIG_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}"
    );
}