context7-cli 0.2.0

CLI client for the Context7 API — search libraries and documentation from the terminal
Documentation
//! 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"
    );
}