modde-cli 0.3.1

CLI interface for modde
mod common;

use std::path::Path;

use common::Fixture;
use modde_core::settings::{AppSettings, DbBackend};

fn settings_path(fx: &Fixture) -> std::path::PathBuf {
    fx.home().join(".config/modde/settings.toml")
}

fn read_settings(fx: &Fixture) -> AppSettings {
    let path = settings_path(fx);
    assert!(
        path.starts_with(fx.root()),
        "settings path must stay inside fixture root: {}",
        path.display()
    );
    let content = std::fs::read_to_string(&path).expect("read settings.toml");
    toml::from_str(&content).expect("parse settings.toml")
}

fn stdout(output: &std::process::Output) -> String {
    String::from_utf8_lossy(&output.stdout).into_owned()
}

fn stderr(output: &std::process::Output) -> String {
    String::from_utf8_lossy(&output.stderr).into_owned()
}

fn assert_success(output: std::process::Output) -> std::process::Output {
    assert!(
        output.status.success(),
        "command failed\nstdout:\n{}\nstderr:\n{}",
        stdout(&output),
        stderr(&output)
    );
    output
}

fn assert_failure(output: std::process::Output) -> std::process::Output {
    assert!(
        !output.status.success(),
        "command unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}",
        stdout(&output),
        stderr(&output)
    );
    output
}

fn set_discrete_postgres(fx: &Fixture) {
    assert_success(
        fx.cmd()
            .args([
                "config",
                "set-database",
                "--backend",
                "postgres",
                "--name",
                "modde",
                "--host",
                "db.example.test",
                "--port",
                "5432",
                "--user",
                "modde_user",
            ])
            .output()
            .expect("spawn config set-database"),
    );
}

#[test]
fn set_database_writes_expected_settings_toml_without_password() {
    let fx = Fixture::new();

    set_discrete_postgres(&fx);

    let settings = read_settings(&fx);
    assert_eq!(settings.database.backend, DbBackend::Postgres);
    assert_eq!(settings.database.url, None);
    assert_eq!(settings.database.host.as_deref(), Some("db.example.test"));
    assert_eq!(settings.database.port, Some(5432));
    assert_eq!(settings.database.dbname.as_deref(), Some("modde"));
    assert_eq!(settings.database.user.as_deref(), Some("modde_user"));
    assert_eq!(settings.database.password_file, None);

    let content = std::fs::read_to_string(settings_path(&fx)).expect("read settings.toml");
    assert!(content.contains("[database]"));
    assert!(!content.contains("password"));
}

#[test]
fn show_reports_settings_and_discrete_env_overrides_with_sources() {
    let fx = Fixture::new();
    set_discrete_postgres(&fx);

    let stored = assert_success(
        fx.cmd()
            .args(["config", "show"])
            .output()
            .expect("spawn config show"),
    );
    let stored_stdout = stdout(&stored);
    assert!(
        stored_stdout.contains("database backend: postgres (from settings.toml)"),
        "unexpected show output:\n{stored_stdout}"
    );
    assert!(
        stored_stdout.contains("host: db.example.test (from settings.toml)"),
        "unexpected show output:\n{stored_stdout}"
    );
    assert!(
        stored_stdout.contains("port: 5432 (from settings.toml)"),
        "unexpected show output:\n{stored_stdout}"
    );
    assert!(
        stored_stdout.contains("dbname: modde (from settings.toml)"),
        "unexpected show output:\n{stored_stdout}"
    );
    assert!(
        stored_stdout.contains("user: modde_user (from settings.toml)"),
        "unexpected show output:\n{stored_stdout}"
    );

    let env = assert_success(
        fx.cmd()
            .env("MODDE_DATABASE_HOST", "env-db.example.test")
            .env("MODDE_DATABASE_PORT", "15432")
            .env("MODDE_DATABASE_NAME", "env_modde")
            .env("MODDE_DATABASE_USER", "env_user")
            .args(["config", "show"])
            .output()
            .expect("spawn config show with env"),
    );
    let env_stdout = stdout(&env);
    assert!(
        env_stdout.contains("host: env-db.example.test (from MODDE_DATABASE_HOST)"),
        "unexpected show output:\n{env_stdout}"
    );
    assert!(
        env_stdout.contains("port: 15432 (from MODDE_DATABASE_PORT)"),
        "unexpected show output:\n{env_stdout}"
    );
    assert!(
        env_stdout.contains("dbname: env_modde (from MODDE_DATABASE_NAME)"),
        "unexpected show output:\n{env_stdout}"
    );
    assert!(
        env_stdout.contains("user: env_user (from MODDE_DATABASE_USER)"),
        "unexpected show output:\n{env_stdout}"
    );
}

#[test]
fn clear_reset_and_empty_string_normalization_remove_postgres_fields() {
    let fx = Fixture::new();
    set_discrete_postgres(&fx);

    assert_success(
        fx.cmd()
            .args([
                "config",
                "set-database",
                "--backend",
                "postgres",
                "--name",
                "modde",
                "--clear",
                "host",
            ])
            .output()
            .expect("spawn config clear host"),
    );
    let settings = read_settings(&fx);
    assert_eq!(settings.database.host, None);
    assert_eq!(settings.database.dbname.as_deref(), Some("modde"));

    assert_success(
        fx.cmd()
            .args([
                "config",
                "set-database",
                "--backend",
                "postgres",
                "--name",
                "modde",
                "--url",
                "",
            ])
            .output()
            .expect("spawn config clear url"),
    );
    let settings = read_settings(&fx);
    assert_eq!(settings.database.url, None);

    assert_success(
        fx.cmd()
            .args(["config", "set-database", "--backend", "sqlite"])
            .output()
            .expect("spawn config set sqlite"),
    );
    let settings = read_settings(&fx);
    assert_eq!(settings.database.backend, DbBackend::Sqlite);
    assert_eq!(settings.database.url, None);
    assert_eq!(settings.database.host, None);
    assert_eq!(settings.database.port, None);
    assert_eq!(settings.database.dbname, None);
    assert_eq!(settings.database.user, None);
    assert_eq!(settings.database.password_file, None);

    set_discrete_postgres(&fx);
    assert_success(
        fx.cmd()
            .args(["config", "reset-database"])
            .output()
            .expect("spawn config reset-database"),
    );
    let settings = read_settings(&fx);
    assert_eq!(settings.database.backend, DbBackend::Sqlite);
    assert_eq!(settings.database.url, None);
    assert_eq!(settings.database.host, None);
    assert_eq!(settings.database.port, None);
    assert_eq!(settings.database.dbname, None);
    assert_eq!(settings.database.user, None);
    assert_eq!(settings.database.password_file, None);
}

#[test]
fn set_database_rejects_invalid_postgres_shapes() {
    let fx = Fixture::new();

    let missing_name = assert_failure(
        fx.cmd()
            .args(["config", "set-database", "--backend", "postgres"])
            .output()
            .expect("spawn invalid config set-database"),
    );
    let missing_name_stderr = stderr(&missing_name);
    assert!(
        missing_name_stderr.contains("requires either --url, or at least --name"),
        "unexpected stderr:\n{missing_name_stderr}"
    );

    let mixed = assert_failure(
        fx.cmd()
            .args([
                "config",
                "set-database",
                "--backend",
                "postgres",
                "--url",
                "postgres://modde@db.example.test/modde",
                "--name",
                "modde",
            ])
            .output()
            .expect("spawn invalid mixed config set-database"),
    );
    let mixed_stderr = stderr(&mixed);
    assert!(
        mixed_stderr.contains("set --url OR the discrete --host/--port/--name/--user fields"),
        "unexpected stderr:\n{mixed_stderr}"
    );
}

#[test]
fn config_test_sqlite_reports_ok_under_isolated_data_dir() {
    let fx = Fixture::new();

    let output = assert_success(
        fx.cmd()
            .args(["config", "test"])
            .output()
            .expect("spawn config test"),
    );
    let output = stdout(&output);
    assert!(
        output.contains("database backend: sqlite"),
        "unexpected output:\n{output}"
    );
    assert!(output.contains("OK"), "unexpected output:\n{output}");
    assert!(
        output.contains(&format!(
            "summary: path={}",
            fx.data_dir().join("modde.db").display()
        )),
        "unexpected output:\n{output}"
    );
}

#[test]
fn database_settings_serde_keeps_none_fields_absent_and_reads_legacy_partials() {
    let partial = r#"
backend = "postgres"
dbname = "legacy_modde"
"#;
    let parsed: modde_core::settings::DatabaseSettings =
        toml::from_str(partial).expect("parse legacy partial database settings");
    assert_eq!(parsed.backend, DbBackend::Postgres);
    assert_eq!(parsed.dbname.as_deref(), Some("legacy_modde"));
    assert_eq!(parsed.url, None);
    assert_eq!(parsed.host, None);
    assert_eq!(parsed.port, None);
    assert_eq!(parsed.user, None);
    assert_eq!(parsed.password_file, None);

    let serialized = toml::to_string(&parsed).expect("serialize database settings");
    assert!(serialized.contains("backend = \"postgres\""));
    assert!(serialized.contains("dbname = \"legacy_modde\""));
    assert!(!serialized.contains("url ="));
    assert!(!serialized.contains("host ="));
    assert!(!serialized.contains("port ="));
    assert!(!serialized.contains("user ="));
    assert!(!serialized.contains("password_file ="));
}

#[test]
fn optional_postgres_doctor_redacts_password_when_modde_test_pg_url_is_set() {
    let Ok(pg_url) = std::env::var("MODDE_TEST_PG_URL") else {
        return;
    };
    let fx = Fixture::new();

    let output = assert_success(
        fx.cmd()
            .env("MODDE_DATABASE_BACKEND", "postgres")
            .env("MODDE_DATABASE_URL", &pg_url)
            .args(["config", "test"])
            .output()
            .expect("spawn config test against postgres"),
    );
    let combined = format!("{}\n{}", stdout(&output), stderr(&output));
    assert!(
        combined.contains("database backend: postgres"),
        "unexpected output:\n{combined}"
    );
    assert!(combined.contains("OK"), "unexpected output:\n{combined}");

    if let Ok(url) = url::Url::parse(&pg_url)
        && let Some(password) = url.password()
    {
        assert!(
            !password.is_empty() && !combined.contains(password),
            "doctor output leaked the URL password:\n{combined}"
        );
    }
}

#[test]
fn fixture_config_path_is_isolated_from_real_home() {
    let fx = Fixture::new();
    set_discrete_postgres(&fx);
    let path = settings_path(&fx);
    assert!(Path::new(&path).starts_with(fx.root()));
    assert!(path.exists());
}