rho-coding-agent 1.1.0

A lightweight agent harness inspired by Pi
Documentation
use super::{Config, LegacyWebSearchCredentials};

#[test]
fn config_debug_redacts_legacy_credentials() {
    let config = Config {
        legacy_web_search_credentials: LegacyWebSearchCredentials {
            openai: Some("openai-search-secret".into()),
            exa: Some("exa-search-secret".into()),
            brave: Some("brave-search-secret".into()),
        },
        ..Config::default()
    };

    let debug = format!("{config:?}");
    assert!(debug.contains("[REDACTED]"));
    assert!(!debug.contains("openai-search-secret"));
    assert!(!debug.contains("exa-search-secret"));
    assert!(!debug.contains("brave-search-secret"));
}

#[test]
fn loads_grouped_config_and_custom_keybinding() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(
        &path,
        r#"
[model]
provider = "anthropic"
model = "claude-sonnet-4-5"
reasoning = "high"

[display]
max_tool_output_lines = 24

[keybindings]
jump_to_bottom = "alt+g"
"#,
    )
    .unwrap();

    let config = Config::load(Some(path)).unwrap();

    assert_eq!(config.provider, "anthropic");
    assert_eq!(config.model, "claude-sonnet-4-5");
    assert_eq!(config.reasoning, crate::reasoning::ReasoningLevel::High);
    assert_eq!(config.max_tool_output_lines, 24);
    assert_eq!(config.keybindings.jump_to_bottom.to_string(), "alt+g");
    assert_eq!(config.keybindings.reset_conversation.to_string(), "ctrl+r");
}

#[test]
fn save_organizes_config_into_sections() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");

    Config::default().save(Some(path.clone())).unwrap();

    let saved = std::fs::read_to_string(path).unwrap();
    for section in [
        "[model]",
        "[display]",
        "[output]",
        "[compaction]",
        "[title]",
        "[web_search]",
        "[behavior]",
        "[keybindings]",
    ] {
        assert!(saved.contains(section), "missing {section} in {saved}");
    }
    assert!(!saved.contains("title_provider"), "{saved}");
}

#[test]
fn default_shows_reasoning_output() {
    assert!(Config::default().show_reasoning_output);
}

#[test]
fn loads_reasoning_output_visibility() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(&path, "show_reasoning_output = false\n").unwrap();

    let config = Config::load(Some(path)).unwrap();

    assert!(!config.show_reasoning_output);
}

#[test]
fn loads_check_for_updates() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(&path, "check_for_updates = false\n").unwrap();

    let config = Config::load(Some(path)).unwrap();

    assert!(!config.check_for_updates);
}

#[test]
fn loads_and_normalizes_compaction_percentages() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(
        &path,
        "auto_compact = true\ncompact_threshold_percent = 80\ncompact_target_percent = 95\n",
    )
    .unwrap();

    let config = Config::load(Some(path)).unwrap();

    assert!(config.auto_compact);
    assert_eq!(config.compact_threshold_percent, 80);
    assert_eq!(config.compact_target_percent, 79);
}

#[test]
fn loads_rtk_toggle() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(&path, "rtk = false\n").unwrap();

    let config = Config::load(Some(path)).unwrap();

    assert!(!config.rtk);
}

#[test]
fn loads_and_saves_favorite_models() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(
        &path,
        r#"
favorite_models = [
  " openai/gpt-5.5 ",
  "missing-separator",
  "openai/gpt-5.5",
  "anthropic/claude-sonnet-4-5",
]
"#,
    )
    .unwrap();

    let config = Config::load(Some(path.clone())).unwrap();

    assert_eq!(
        config.favorite_models,
        vec!["openai/gpt-5.5", "anthropic/claude-sonnet-4-5"]
    );

    config.save(Some(path.clone())).unwrap();
    let saved = std::fs::read_to_string(path).unwrap();
    assert!(saved.contains("favorite_models"), "{saved}");
    assert!(saved.contains("openai/gpt-5.5"), "{saved}");
    assert!(!saved.contains("missing-separator"), "{saved}");
}

#[test]
fn unsupported_web_search_config_providers_fall_back_to_auto() {
    for provider in ["parallel", "tavily", "perplexity", "gemini", "unknown"] {
        assert_eq!(
            super::SearchProvider::from_config_value(provider),
            super::SearchProvider::Auto
        );
    }
}

#[test]
fn supported_web_search_config_provider_is_preserved() {
    assert_eq!(
        super::SearchProvider::from_config_value(" brave "),
        super::SearchProvider::Brave
    );
}

#[test]
fn grouped_web_search_preserves_omitted_legacy_keys() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(
        &path,
        r#"
web_search_openai_api_key = "legacy-openai"
web_search_exa_api_key = "legacy-exa"

[web_search]
provider = "brave"
brave_api_key = "grouped-brave"
"#,
    )
    .unwrap();
    let store = crate::credentials::MemoryCredentialStore::default();

    let config = Config::load_with_store(path, &store).unwrap();

    assert_eq!(config.web_search_provider, super::SearchProvider::Brave);
    for (credential, expected) in [
        (
            crate::credentials::WebSearchCredential::OpenAi,
            "legacy-openai",
        ),
        (crate::credentials::WebSearchCredential::Exa, "legacy-exa"),
        (
            crate::credentials::WebSearchCredential::Brave,
            "grouped-brave",
        ),
    ] {
        assert_eq!(
            crate::credentials::load_web_search_api_key(&store, credential)
                .unwrap()
                .as_deref(),
            Some(expected)
        );
    }
}

#[test]
fn save_preserves_legacy_web_search_keys_when_credentials_are_unavailable() {
    struct UnavailableCredentialStore;

    impl crate::credentials::CredentialStore for UnavailableCredentialStore {
        fn get_secret(
            &self,
            _account: &str,
        ) -> crate::credentials::CredentialResult<Option<String>> {
            Err(crate::credentials::CredentialError::StoreUnavailable(
                "test store unavailable".into(),
            ))
        }

        fn set_secret(
            &self,
            _account: &str,
            _secret: &str,
        ) -> crate::credentials::CredentialResult<()> {
            Err(crate::credentials::CredentialError::StoreUnavailable(
                "test store unavailable".into(),
            ))
        }

        fn delete_secret(&self, _account: &str) -> crate::credentials::CredentialResult<bool> {
            Err(crate::credentials::CredentialError::StoreUnavailable(
                "test store unavailable".into(),
            ))
        }
    }

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    let config = Config {
        rtk: false,
        legacy_web_search_credentials: super::LegacyWebSearchCredentials {
            openai: Some("sk-test".into()),
            exa: None,
            brave: None,
        },
        ..Config::default()
    };

    config
        .save_with_store(path.clone(), &UnavailableCredentialStore)
        .unwrap();

    let saved = std::fs::read_to_string(path).unwrap();
    assert!(saved.contains("openai_api_key = \"sk-test\""), "{saved}");
    assert!(saved.contains("rtk = false"), "{saved}");
}

#[cfg(unix)]
#[test]
fn migrated_credential_cleanup_atomically_replaces_read_only_config() {
    use std::os::unix::fs::PermissionsExt;

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    std::fs::write(&path, "web_search_openai_api_key = \"sk-test\"\n").unwrap();
    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
    let store = crate::credentials::MemoryCredentialStore::default();

    let config = Config::load_with_store(path.clone(), &store).unwrap();

    assert_eq!(
        crate::credentials::load_web_search_api_key(
            &store,
            crate::credentials::WebSearchCredential::OpenAi
        )
        .unwrap()
        .as_deref(),
        Some("sk-test")
    );
    assert_eq!(
        config.legacy_web_search_api_key(crate::credentials::WebSearchCredential::OpenAi),
        None
    );
    let saved = std::fs::read_to_string(&path).unwrap();
    assert!(!saved.contains("web_search_openai_api_key"), "{saved}");

    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
}

#[test]
fn migrates_legacy_web_search_keys_to_credentials() {
    let store = crate::credentials::MemoryCredentialStore::default();
    let mut config = Config {
        legacy_web_search_credentials: super::LegacyWebSearchCredentials {
            openai: Some("sk-test".into()),
            exa: Some("exa-test".into()),
            brave: Some("BSA-test".into()),
        },
        ..Config::default()
    };

    assert!(config
        .migrate_legacy_web_search_credentials(&store)
        .unwrap());
    assert_eq!(
        crate::credentials::load_web_search_api_key(
            &store,
            crate::credentials::WebSearchCredential::OpenAi
        )
        .unwrap()
        .as_deref(),
        Some("sk-test")
    );
    assert_eq!(
        config.legacy_web_search_api_key(crate::credentials::WebSearchCredential::OpenAi),
        None
    );
}

#[test]
fn saved_config_omits_migrated_web_search_secrets() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("config.toml");
    let config = Config::default();

    super::write_config(&path, &config).unwrap();

    let saved = std::fs::read_to_string(path).unwrap();
    assert!(!saved.contains("web_search_openai_api_key"), "{saved}");
    assert!(!saved.contains("web_search_exa_api_key"), "{saved}");
    assert!(!saved.contains("web_search_brave_api_key"), "{saved}");
}