ownserp 0.1.5

OwnSERP, an MCP server for the Serper.dev search API, over stdio and Streamable HTTP
use std::io::{BufRead, IsTerminal, Read};

use anyhow::{Context, bail};
use ownserp_core::{Overrides, SecretString, Settings, keychain_delete, keychain_set};

use crate::cli::{ConfigAction, ConfigArgs};
use crate::output::emit;

pub(crate) fn run(args: ConfigArgs) -> anyhow::Result<()> {
    match args.action {
        ConfigAction::SetApiKey => set_key(),
        ConfigAction::Show => show(),
        ConfigAction::DeleteApiKey => {
            keychain_delete().context("removing the key")?;
            emit("stored key removed; the environment variable is untouched")?;
            Ok(())
        }
    }
}

fn set_key() -> anyhow::Result<()> {
    if std::io::stdin().is_terminal() {
        bail!(
            "stdin is a terminal and a typed key would be echoed; pipe the key instead, for example: printf '%s\\n' \"$SERPER_API_KEY\" | ownserp config set-api-key"
        );
    }
    eprintln!("reading the API key from stdin");
    let mut line = String::new();
    std::io::stdin()
        .lock()
        .take(4096)
        .read_line(&mut line)
        .context("reading stdin")?;
    let trimmed = clean_key(&line)?;
    let secret = SecretString::from(trimmed);
    keychain_set(&secret).context("storing the key in the OS keychain")?;
    emit("stored the key in the OS keychain")?;
    Ok(())
}

fn clean_key(raw: &str) -> anyhow::Result<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        bail!("no key on stdin");
    }
    if trimmed.chars().count() > 512 {
        bail!("the key is longer than 512 characters");
    }
    if trimmed.chars().any(char::is_control) {
        bail!("the key contains a control character");
    }
    Ok(trimmed.to_owned())
}

fn show() -> anyhow::Result<()> {
    let settings = Settings::load(Overrides::default()).context("resolving settings")?;
    for line in summary(&settings) {
        emit(&line)?;
    }
    Ok(())
}

fn summary(settings: &Settings) -> Vec<String> {
    vec![
        format!("api key source: {}", settings.api_key_source().label()),
        format!("api key: {}", settings.masked_key()),
        format!("pack tier: {:?}", settings.pack()),
        format!("ledger directory: {}", settings.ledger_dir().display()),
        format!(
            "profile: {}",
            settings
                .profile_path()
                .map_or_else(|| "none".to_owned(), |path| path.display().to_string())
        ),
    ]
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use super::*;

    fn settings_with_key() -> Settings {
        let map = BTreeMap::from([("SERPER_API_KEY".to_owned(), "abcdefgh-test-key".to_owned())]);
        let profile = std::env::temp_dir().join("ownserp-config-summary.toml");
        std::fs::write(&profile, "").expect("profile");
        Settings::load_with(
            Overrides {
                profile: Some(profile),
                ledger_dir: Some(std::env::temp_dir().join("ownserp-config-summary-ledger")),
                cache_ttl_secs: Some(0),
                ..Overrides::default()
            },
            |name| map.get(name).cloned(),
        )
        .expect("settings")
    }

    #[test]
    fn a_key_line_is_trimmed_and_bounded() {
        assert_eq!(clean_key("  abc123  \n").expect("key"), "abc123");
        assert!(clean_key("   ").is_err());
        assert!(clean_key(&"a".repeat(513)).is_err());
        assert!(clean_key("abc\u{7}").is_err());
    }

    #[test]
    fn the_summary_masks_the_key_and_names_every_field() {
        let lines = summary(&settings_with_key());
        assert_eq!(lines.len(), 5);
        let joined = lines.join("\n");
        assert!(joined.contains("api key source: environment"));
        assert!(joined.contains("api key: ****-key"));
        assert!(!joined.contains("abcdefgh-test-key"));
        assert!(joined.contains("pack tier: Starter"));
        assert!(joined.contains("ledger directory:"));
    }
}