saya-cli 0.3.1

Database-aware AI agent for the terminal: full-screen TUI, schema discovery, and bounded read-only SQL over PostgreSQL, MySQL, SQLite, DuckDB, and Snowflake.
use super::sources::Paths;
use crate::cli::GlobalOptions;
use saya_config::{CliOverrides, ConnectionsFile, ResolutionInput, resolve};
use std::{
    collections::BTreeMap,
    path::{Path, PathBuf},
};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum RuntimeError {
    #[error("{0}")]
    Config(#[from] saya_config::ConfigError),
    #[error("could not read {path}: {source}")]
    Read {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("explicit path does not exist: {0}")]
    Missing(PathBuf),
    #[error("invalid approval mode: {0}")]
    Approval(String),
}

#[derive(Clone)]
pub struct RuntimeConfig {
    pub resolved: saya_config::ResolvedConfig,
    pub connections: ConnectionsFile,
    pub config_path: Option<PathBuf>,
    pub connections_path: Option<PathBuf>,
    pub cache_scope: PathBuf,
    pub(crate) secret_values: BTreeMap<String, String>,
}

pub fn load(options: &GlobalOptions, cwd: &Path) -> Result<RuntimeConfig, RuntimeError> {
    let runtime = load_with_sources(
        options,
        cwd,
        &super::sources::user_config_dir(),
        super::sources::process_env(),
    )?;
    warn_ignored_project_overrides(&runtime);
    Ok(runtime)
}

/// The project layer is untrusted: when it tried to change security-critical
/// settings, say so instead of silently ignoring the attempt.
fn warn_ignored_project_overrides(runtime: &RuntimeConfig) {
    let ignored = &runtime.resolved.ignored_project_overrides;
    if ignored.is_empty() {
        return;
    }
    eprintln!(
        "warning: ignored security-critical setting(s) from the project's .saya/config.toml: {}. \
         Pass --trust-project-config to accept them.",
        ignored.join(", ")
    );
}

pub fn load_with_sources(
    options: &GlobalOptions,
    cwd: &Path,
    user_dir: &Path,
    process: BTreeMap<String, String>,
) -> Result<RuntimeConfig, RuntimeError> {
    let paths = Paths::discover(cwd, user_dir);
    let user = super::sources::read_config(&paths.user_config)?;
    let selected_config = options
        .config
        .as_ref()
        .or_else(|| paths.project_config.as_ref().filter(|path| path.exists()))
        .or_else(|| paths.user_config.as_ref().filter(|path| path.exists()));
    let project = match options.config.as_ref() {
        Some(path) => Some(super::sources::read_required_config(path)?),
        None => super::sources::read_config(&paths.project_config)?,
    };
    let selected_connections = options
        .connections
        .as_ref()
        .or_else(|| {
            paths
                .project_connections
                .as_ref()
                .filter(|path| path.exists())
        })
        .or_else(|| paths.user_connections.as_ref().filter(|path| path.exists()));
    let connections =
        super::sources::read_connections(selected_connections, options.connections.is_some())?;
    let env_file = match options.env_file.as_ref() {
        Some(path) => super::sources::read_env_file(path)?,
        None => BTreeMap::new(),
    };
    let mut secret_values = env_file.clone();
    secret_values.extend(process.clone());
    let provider = match options.provider.as_deref() {
        Some(raw) => Some(saya_config::AiProvider::parse(raw).ok_or_else(|| {
            RuntimeError::Config(saya_config::ConfigError::Parse(format!(
                "invalid --provider '{raw}' (expected ollama, openai, openai_compatible, anthropic, or gemini)"
            )))
        })?),
        None => None,
    };
    let input = ResolutionInput::new(connections.clone())
        .with_user(user.unwrap_or_default())
        .with_project(project.unwrap_or_default())
        .with_env_file(env_file)
        .with_process_env(process)
        .with_cli(CliOverrides {
            profile: options.profile.clone(),
            provider,
            model: options.model.clone(),
            allow_data_sharing: if options.no_data_sharing {
                Some(false)
            } else {
                options.allow_data_sharing.then_some(true)
            },
            max_rows: options.max_rows,
            trust_project_config: options.trust_project_config,
        });
    let mut resolved = resolve(input)?;
    if options.no_color {
        resolved.output_color = saya_config::ColorChoice::Never;
    }
    let cache_scope = super::scope::resolve(selected_connections, cwd);
    Ok(RuntimeConfig {
        resolved,
        connections,
        config_path: selected_config.cloned(),
        connections_path: selected_connections.cloned(),
        cache_scope,
        secret_values,
    })
}

impl std::fmt::Debug for RuntimeConfig {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("RuntimeConfig")
            .field("resolved", &self.resolved)
            .field("connections", &self.connections)
            .field("config_path", &self.config_path)
            .field("connections_path", &self.connections_path)
            .field("cache_scope", &"[redacted]")
            .field("secret_values", &"[redacted]")
            .finish()
    }
}

pub fn approval_mode(options: &GlobalOptions) -> Result<saya_agent::ApprovalPolicy, RuntimeError> {
    let value = match options.approval_mode.as_deref() {
        Some(value) => value,
        None if options.non_interactive => "never",
        None => "ask",
    };
    value
        .parse()
        .map_err(|error: saya_agent::ApprovalPolicyParseError| {
            RuntimeError::Approval(error.to_string())
        })
}

pub fn approval_name(options: &GlobalOptions) -> Result<String, RuntimeError> {
    Ok(match approval_mode(options)? {
        saya_agent::ApprovalPolicy::Ask => "ask",
        saya_agent::ApprovalPolicy::ReadOnly => "read-only",
        saya_agent::ApprovalPolicy::Never => "never",
    }
    .into())
}

pub fn format_name(
    options: &GlobalOptions,
    resolved: &saya_config::ResolvedConfig,
) -> crate::render::RenderFormat {
    if options.format != crate::cli::FormatArg::Text {
        options.format.into()
    } else {
        resolved.output_format.into()
    }
}