roboticus-cli 0.11.3

CLI commands and migration engine for the Roboticus agent runtime
Documentation
//! Provider/model configuration update logic.

use std::path::PathBuf;

use super::{
    ContentRecord, OverwriteChoice, UpdateState, bytes_sha256, colors, confirm_action,
    confirm_overwrite, fetch_file, fetch_manifest, file_sha256, icons, now_iso, print_diff,
    registry_base_url, resolve_registry_url,
};
use crate::cli::{CRT_DRAW_MS, heading, theme};

pub(super) fn providers_local_path(config_path: &str) -> PathBuf {
    if let Ok(content) = std::fs::read_to_string(config_path)
        && let Ok(config) = content.parse::<toml::Value>()
        && let Some(path) = config.get("providers_file").and_then(|v| v.as_str())
    {
        return PathBuf::from(path);
    }
    super::roboticus_home().join("providers.toml")
}

pub(super) async fn apply_providers_update(
    yes: bool,
    registry_url: &str,
    config_path: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
    let (DIM, BOLD, _, GREEN, YELLOW, _, _, RESET, MONO) = colors();
    let (OK, _, WARN, DETAIL, _) = icons();
    let client = super::http_client()?;

    println!("\n  {BOLD}Provider Configs{RESET}\n");

    let manifest = match fetch_manifest(&client, registry_url).await {
        Ok(m) => m,
        Err(e) => {
            println!("    {WARN} Could not fetch registry manifest: {e}");
            return Ok(false);
        }
    };

    let base_url = registry_base_url(registry_url);
    let remote_content = match fetch_file(&client, &base_url, &manifest.packs.providers.path).await
    {
        Ok(c) => c,
        Err(e) => {
            println!("    {WARN} Could not fetch providers.toml: {e}");
            return Ok(false);
        }
    };

    let remote_hash = bytes_sha256(remote_content.as_bytes());
    let state = UpdateState::load();

    let local_path = providers_local_path(config_path);
    let local_exists = local_path.exists();
    let local_content = if local_exists {
        std::fs::read_to_string(&local_path).unwrap_or_default()
    } else {
        String::new()
    };

    if local_exists {
        let local_hash = bytes_sha256(local_content.as_bytes());
        if local_hash == remote_hash {
            println!("    {OK} Provider configs are up to date");
            return Ok(false);
        }
    }

    let user_modified = if let Some(ref record) = state.installed_content.providers {
        if local_exists {
            let current_hash = file_sha256(&local_path).unwrap_or_default();
            current_hash != record.sha256
        } else {
            false
        }
    } else {
        local_exists
    };

    if !local_exists {
        println!("    {GREEN}+ New provider configuration available{RESET}");
        print_diff("", &remote_content);
    } else if user_modified {
        println!("    {YELLOW}Provider config has been modified locally{RESET}");
        println!("    Changes from registry:");
        print_diff(&local_content, &remote_content);
    } else {
        println!("    Updated provider configuration available");
        print_diff(&local_content, &remote_content);
    }

    println!();

    if user_modified {
        match confirm_overwrite("providers config") {
            OverwriteChoice::Overwrite => {}
            OverwriteChoice::Backup => {
                let backup = local_path.with_extension("toml.bak");
                std::fs::copy(&local_path, &backup)?;
                println!("    {DETAIL} Backed up to {}", backup.display());
            }
            OverwriteChoice::Skip => {
                println!("    Skipped.");
                return Ok(false);
            }
        }
    } else if !yes && !confirm_action("Apply provider updates?", true) {
        println!("    Skipped.");
        return Ok(false);
    }

    if let Some(parent) = local_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&local_path, &remote_content)?;

    let mut state = UpdateState::load();
    state.installed_content.providers = Some(ContentRecord {
        version: manifest.version.clone(),
        sha256: remote_hash,
        installed_at: now_iso(),
    });
    state.last_check = now_iso();
    state
        .save()
        .inspect_err(
            |e| tracing::warn!(error = %e, "failed to save update state after provider install"),
        )
        .ok();

    println!("    {OK} Provider configs updated to v{}", manifest.version);
    Ok(true)
}

// ── CLI entry point ──────────────────────────────────────────

pub async fn cmd_update_providers(
    yes: bool,
    registry_url_override: Option<&str>,
    config_path: &str,
    hygiene_fn: Option<&super::HygieneFn>,
) -> Result<(), Box<dyn std::error::Error>> {
    heading("Provider Config Update");
    let registry_url = resolve_registry_url(registry_url_override, config_path);
    apply_providers_update(yes, &registry_url, config_path).await?;
    super::run_oauth_storage_maintenance();
    super::run_mechanic_checks_maintenance(config_path, hygiene_fn);
    println!();
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::EnvGuard;

    #[test]
    fn local_path_helpers_fallback_when_config_missing() {
        let p = providers_local_path("/no/such/file.toml");
        assert!(p.ends_with("providers.toml"));
    }

    #[serial_test::serial]
    #[tokio::test]
    async fn apply_providers_update_fetches_and_writes_local_file() {
        let temp = tempfile::tempdir().unwrap();
        let _home_guard = EnvGuard::set("HOME", temp.path().to_str().unwrap());
        let config_path = temp.path().join("roboticus.toml");
        let providers_path = temp.path().join("providers.toml");
        std::fs::write(
            &config_path,
            format!(
                "providers_file = \"{}\"\n",
                providers_path.display().to_string().replace('\\', "/")
            ),
        )
        .unwrap();

        let providers = "[providers.openai]\nurl = \"https://api.openai.com\"\n".to_string();
        let (registry_url, handle) = crate::cli::update::tests_support::start_mock_registry(
            providers.clone(),
            "# hello\nbody\n".to_string(),
        )
        .await;

        let changed = apply_providers_update(true, &registry_url, config_path.to_str().unwrap())
            .await
            .unwrap();
        assert!(changed);
        assert_eq!(std::fs::read_to_string(&providers_path).unwrap(), providers);

        let changed_second =
            apply_providers_update(true, &registry_url, config_path.to_str().unwrap())
                .await
                .unwrap();
        assert!(!changed_second);
        handle.abort();
    }
}