collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use super::paths::{config_file_path, ensure_config_dir, secrets_file_path};
use super::secrets::{
    decrypt_key, extract_secrets, load_secrets, save_secrets, update_gitignore_for_secrets,
};
use super::types::{ConfigFile, LegacyForkSection, LegacyHiveSection, ProviderEntry};
use crate::agent::swarm::config::{CollaborationMode, CollaborationSection};
use crate::common::{AgentError, Result};
use fs2::FileExt;
use std::path::Path;

// ---------------------------------------------------------------------------
// Load config file
// ---------------------------------------------------------------------------

/// Migrate deprecated `[fork]` / `[hive]` sections into the unified `[collaboration]`.
///
/// Only applies when `[collaboration].mode` is not already set (new config takes
/// precedence over legacy). Prints a deprecation warning to stderr.
pub(super) fn migrate_legacy_sections(
    fork: &Option<LegacyForkSection>,
    hive: &Option<LegacyHiveSection>,
    collab: &mut CollaborationSection,
) {
    // Only migrate if the new [collaboration] mode is unset
    if collab.mode.is_some() {
        return;
    }

    // Migrate [hive] (higher tier takes precedence)
    if let Some(old_hive) = hive
        && old_hive.enabled.unwrap_or(false)
    {
        eprintln!(
            "\x1b[33m[DEPRECATION]\x1b[0m Found old [hive] section in config.toml. \
                 Please migrate to:\n  [collaboration]\n  mode = \"hive\"\n"
        );
        collab.mode = Some(CollaborationMode::Hive);
        collab.max_agents = collab.max_agents.or(old_hive.max_agents);
        collab.worker_model = collab
            .worker_model
            .take()
            .or_else(|| old_hive.worker_model.clone());
        collab.coordinator_model = collab
            .coordinator_model
            .take()
            .or_else(|| old_hive.coordinator_model.clone());
        collab.strategy = collab.strategy.take().or_else(|| old_hive.strategy.clone());
        collab.require_consensus = collab.require_consensus.or(old_hive.require_consensus);
        collab.conflict_resolution = collab
            .conflict_resolution
            .take()
            .or_else(|| old_hive.conflict_resolution.clone());
        collab.auto_suggest = collab.auto_suggest.or(old_hive.auto_suggest);
        return;
    }

    // Migrate [fork] (only if [hive] was not enabled)
    if let Some(old_fork) = fork
        && old_fork.enabled.unwrap_or(false)
    {
        eprintln!(
            "\x1b[33m[DEPRECATION]\x1b[0m Found old [fork] section in config.toml. \
                 Please migrate to:\n  [collaboration]\n  mode = \"fork\"\n"
        );
        collab.mode = Some(CollaborationMode::Fork);
        collab.max_agents = collab.max_agents.or(old_fork.max_agents);
        collab.worker_model = collab
            .worker_model
            .take()
            .or_else(|| old_fork.worker_model.clone());
        collab.worktree = collab.worktree.or(old_fork.worktree);
        collab.auto_suggest = collab.auto_suggest.or(old_fork.auto_suggest);
    }
}

/// Load and parse the TOML config file. Returns default if file doesn't exist.
/// Secrets from `.secrets` are automatically merged in (secrets win on conflict).
pub fn load_config_file() -> Result<ConfigFile> {
    let path = config_file_path();
    let mut config: ConfigFile = if path.exists() {
        // Acquire a shared read lock while reading so concurrent migration
        // (which holds an exclusive lock) cannot write to the file mid-read.
        let file = std::fs::File::open(&path).map_err(|e| {
            AgentError::Config(format!("Failed to open config: {} - {}", path.display(), e))
        })?;
        file.lock_shared().map_err(|e| {
            AgentError::Config(format!("Failed to lock config: {} - {}", path.display(), e))
        })?;
        let content = std::fs::read_to_string(&path).map_err(|e| {
            AgentError::Config(format!("Failed to read config: {} - {}", path.display(), e))
        })?;
        file.unlock().map_err(|e| {
            AgentError::Config(format!(
                "Failed to unlock config: {} - {}",
                path.display(),
                e
            ))
        })?;
        toml::from_str(&content).map_err(|e| {
            AgentError::Config(format!(
                "Failed to parse config: {} - {}",
                path.display(),
                e
            ))
        })?
    } else {
        ConfigFile::default()
    };
    // One-time migration: move secrets from config.toml → .secrets if .secrets doesn't exist yet
    if path.exists() && !secrets_file_path().exists() {
        let _ = migrate_secrets_from_config(&path, &config);
    }
    // Overlay .secrets (encrypted credentials kept separately from config.toml)
    let secrets = load_secrets();
    super::secrets::merge_secrets(&mut config, &secrets);
    Ok(config)
}

/// One-time migration: extract sensitive fields from `config.toml`, write them to `.secrets`,
/// and rewrite `config.toml` without those fields.
fn migrate_secrets_from_config(path: &Path, cf: &ConfigFile) -> Result<()> {
    // Save secrets extracted from the current config
    let machine_id = load_secrets().machine_id;
    let extracted = extract_secrets(cf, machine_id);
    let has_secrets = extracted.api.api_key_enc.is_some()
        || !extracted.providers.is_empty()
        || extracted.web.password_enc.is_some()
        || extracted.telegram.token_enc.is_some()
        || extracted.slack.bot_token_enc.is_some()
        || extracted.slack.app_token_enc.is_some()
        || extracted.discord.token_enc.is_some();

    if !has_secrets {
        return Ok(());
    }

    save_secrets(&extracted)?;
    update_gitignore_for_secrets();

    // Rewrite config.toml without the sensitive fields
    let raw = toml::to_string_pretty(cf)
        .map_err(|e| AgentError::Config(format!("Failed to serialize config: {e}")))?;
    let mut clean: ConfigFile = toml::from_str(&raw)
        .map_err(|e| AgentError::Config(format!("Failed to re-parse config: {e}")))?;
    clean.api.api_key_enc = None;
    clean.api.api_key = None;
    clean.api.base_url = None; // resolved at runtime from [[providers]]
    clean.web.password_enc = None;
    clean.telegram.token_enc = None;
    clean.telegram.token = None;
    clean.slack.bot_token_enc = None;
    clean.slack.bot_token = None;
    clean.slack.app_token_enc = None;
    clean.slack.app_token = None;
    clean.discord.token_enc = None;
    clean.discord.token = None;
    for p in clean.providers.iter_mut() {
        p.api_key_enc = None;
    }
    let toml_str = toml::to_string_pretty(&clean)
        .map_err(|e| AgentError::Config(format!("Failed to serialize config: {e}")))?;

    // Hold an exclusive lock during migration write to prevent concurrent startup
    // from reading a partially-written config (TOCTOU guard).
    let lock_file = std::fs::OpenOptions::new()
        .write(true)
        .open(path)
        .map_err(|e| AgentError::Config(format!("Failed to open config for migration: {e}")))?;
    lock_file
        .lock_exclusive()
        .map_err(|e| AgentError::Config(format!("Failed to lock config for migration: {e}")))?;

    // Atomic write: write to temp then rename so readers never see a partial file.
    let tmp = path.with_extension("toml.tmp");
    std::fs::write(&tmp, toml_str).map_err(|e| {
        AgentError::Config(format!(
            "Failed to write temp config {}: {e}",
            tmp.display()
        ))
    })?;
    std::fs::rename(&tmp, path).map_err(|e| {
        AgentError::Config(format!("Failed to replace config {}: {e}", path.display()))
    })?;
    lock_file
        .unlock()
        .map_err(|e| AgentError::Config(format!("Failed to unlock config after migration: {e}")))?;

    Ok(())
}

/// Look up a provider entry by name and return it with the decrypted API key.
///
/// Returns `(ProviderEntry, api_key)`. For local (keyless) providers the
/// key string will be empty.
/// Resolve the global `[default]` section provider/model as a final fallback.
/// Returns (provider_name, entry, api_key, model).
pub fn resolve_default_provider() -> Option<(String, ProviderEntry, String, String)> {
    let file = load_config_file().ok()?;
    // Prefer legacy fields; fall back to first entry of `providers` chain.
    let (provider_name, model) = if let Some(ref p) = file.api.provider {
        (p.clone(), file.api.model.clone().unwrap_or_default())
    } else if let Some(ref chain) = file.api.providers {
        let first = chain
            .split(',')
            .next()
            .map(str::trim)
            .filter(|s| !s.is_empty())?;
        let slash = first.find('/')?;
        (first[..slash].to_string(), first[slash + 1..].to_string())
    } else {
        return None;
    };
    let (entry, key) = resolve_provider(&provider_name)?;
    Some((provider_name, entry, key, model))
}

pub fn resolve_provider(name: &str) -> Option<(ProviderEntry, String)> {
    // Accept both "provider-name" and "provider-name/model" formats.
    let provider_name = name.split('/').next().unwrap_or(name);
    let file = load_config_file().ok()?;
    let entry = file
        .providers
        .iter()
        .find(|p| p.name.eq_ignore_ascii_case(provider_name))?
        .clone();
    let api_key = entry
        .api_key_enc
        .as_ref()
        .filter(|enc| !enc.is_empty())
        .and_then(|enc| decrypt_key(enc).ok())
        .unwrap_or_default();
    Some((entry, api_key))
}

/// Persist the selected theme name to `[ui] theme` in the config file.
///
/// Handles three cases:
/// - existing `theme = "..."` line → replace value
/// - commented `# theme = "..."` line → uncomment and set
/// - no theme line yet → insert after `[ui]` header (or append `[ui]` section)
pub fn save_ui_theme(name: &str) -> Result<()> {
    let path = config_file_path();

    if !path.exists() {
        ensure_config_dir()?;
        std::fs::write(&path, format!("[ui]\ntheme = \"{name}\"\n"))
            .map_err(|e| AgentError::Config(format!("Failed to write config: {e}")))?;
        return Ok(());
    }

    let content = std::fs::read_to_string(&path)
        .map_err(|e| AgentError::Config(format!("Failed to read config: {e}")))?;

    let mut in_ui = false;
    let mut replaced = false;
    let mut lines: Vec<String> = content
        .lines()
        .map(|line| {
            let trimmed = line.trim();
            if trimmed.starts_with('[') {
                in_ui = trimmed == "[ui]";
            }
            if in_ui && !replaced {
                let stripped = trimmed.trim_start_matches('#').trim();
                if stripped.starts_with("theme") && stripped.contains('=') {
                    replaced = true;
                    return format!("theme = \"{name}\"");
                }
            }
            line.to_string()
        })
        .collect();

    if !replaced {
        if let Some(pos) = lines.iter().position(|l| l.trim() == "[ui]") {
            lines.insert(pos + 1, format!("theme = \"{name}\""));
        } else {
            lines.push(String::new());
            lines.push("[ui]".to_string());
            lines.push(format!("theme = \"{name}\""));
        }
    }

    let mut result = lines.join("\n");
    if content.ends_with('\n') {
        result.push('\n');
    }

    std::fs::write(&path, result)
        .map_err(|e| AgentError::Config(format!("Failed to write config: {e}")))?;
    Ok(())
}