stakpak 0.3.68

Stakpak: Your DevOps AI Agent. Generate infrastructure code, debug Kubernetes, configure CI/CD, automate deployments, without giving an LLM the keys to production.
use std::path::Path;

use stakpak_shared::utils::normalize_optional_string;

use super::AppConfig;

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ResolvedProfileOverrides {
    pub model: Option<String>,
    pub auto_approve: Option<Vec<String>>,
    pub allowed_tools: Option<Vec<String>>,
    pub system_prompt: Option<String>,
    pub max_turns: Option<usize>,
}

pub(crate) fn resolve_profile_run_overrides(
    profile_name: &str,
    config_path: Option<&str>,
) -> Option<ResolvedProfileOverrides> {
    let config = AppConfig::load(profile_name, config_path.map(Path::new)).ok()?;

    let model = normalize_optional_string(config.model);
    let auto_approve = normalize_tool_list(config.auto_approve);
    let allowed_tools = normalize_tool_list(config.allowed_tools);
    let system_prompt = normalize_optional_string(config.system_prompt);
    let max_turns = config.max_turns;

    if model.is_none()
        && auto_approve.is_none()
        && allowed_tools.is_none()
        && system_prompt.is_none()
        && max_turns.is_none()
    {
        return None;
    }

    Some(ResolvedProfileOverrides {
        model,
        auto_approve,
        allowed_tools,
        system_prompt,
        max_turns,
    })
}

fn normalize_tool_list(tools: Option<Vec<String>>) -> Option<Vec<String>> {
    tools.map(|tools| {
        tools
            .into_iter()
            .filter_map(|tool| {
                let normalized = stakpak_server::strip_tool_prefix(&tool).trim().to_string();
                if normalized.is_empty() {
                    None
                } else {
                    Some(normalized)
                }
            })
            .collect::<std::collections::BTreeSet<_>>()
            .into_iter()
            .collect()
    })
}

#[cfg(test)]
mod tests {
    use super::resolve_profile_run_overrides;
    use std::path::PathBuf;

    fn temp_file_path(name: &str) -> PathBuf {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|value| value.as_nanos())
            .unwrap_or(0);

        std::env::temp_dir().join(format!(
            "stakpak-{}-{}-{}.toml",
            name,
            std::process::id(),
            nanos
        ))
    }

    fn write_profile_config(path: &PathBuf, content: &str) {
        let write_result = std::fs::write(path, content);
        assert!(write_result.is_ok());
    }

    #[test]
    fn resolve_profile_trims_whitespace_values() {
        let path = temp_file_path("profile-resolver-trim");
        write_profile_config(
            &path,
            r#"
[settings]
editor = "nano"

[profiles.default]
api_key = "default-key"

[profiles.monitoring]
model = "  anthropic/claude-sonnet-4-5  "
system_prompt = "  Report only  "
"#,
        );

        let resolved =
            resolve_profile_run_overrides("monitoring", Some(path.to_string_lossy().as_ref()));
        assert!(resolved.is_some());

        if let Some(resolved) = resolved {
            assert_eq!(
                resolved.model.as_deref(),
                Some("anthropic/claude-sonnet-4-5")
            );
            assert_eq!(resolved.system_prompt.as_deref(), Some("Report only"));
        }

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn resolve_profile_extracts_system_prompt_and_max_turns() {
        let path = temp_file_path("profile-resolver-overrides");
        write_profile_config(
            &path,
            r#"
[settings]
editor = "nano"

[profiles.default]
api_key = "default-key"

[profiles.monitoring]
system_prompt = "Report only"
max_turns = 16
"#,
        );

        let resolved =
            resolve_profile_run_overrides("monitoring", Some(path.to_string_lossy().as_ref()));
        assert!(resolved.is_some());

        if let Some(resolved) = resolved {
            assert_eq!(resolved.system_prompt.as_deref(), Some("Report only"));
            assert_eq!(resolved.max_turns, Some(16));
        }

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn resolve_profile_filters_empty_system_prompt() {
        let path = temp_file_path("profile-resolver-empty-prompt");
        write_profile_config(
            &path,
            r#"
[settings]
editor = "nano"

[profiles.default]
api_key = "default-key"

[profiles.monitoring]
system_prompt = "   "
"#,
        );

        let resolved =
            resolve_profile_run_overrides("monitoring", Some(path.to_string_lossy().as_ref()));
        assert!(resolved.is_none());

        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn resolve_profile_default_name_is_not_short_circuited() {
        let path = temp_file_path("profile-resolver-default");
        write_profile_config(
            &path,
            r#"
[settings]
editor = "nano"

[profiles.default]
model = "anthropic/claude-sonnet-4-5"
"#,
        );

        let resolved =
            resolve_profile_run_overrides("default", Some(path.to_string_lossy().as_ref()));
        assert!(resolved.is_some());

        let _ = std::fs::remove_file(path);
    }
}