roboticus-cli 0.11.4

CLI commands and migration engine for the Roboticus agent runtime
Documentation
//! Channel config migration transforms.

use std::fs;
use std::path::Path;

use super::{
    AreaResult, LegacyConfig, MigrationArea, err, qt, resolve_channel_token_for_export,
    store_in_keystore,
};

pub(crate) fn import_channels(oc_root: &Path, ic_root: &Path) -> AreaResult {
    let ks_path = ic_root.join("keystore.enc");
    let config_path = oc_root.join("legacy.json");
    if !config_path.exists() {
        return AreaResult {
            area: MigrationArea::Channels,
            success: true,
            items_processed: 0,
            warnings: vec!["No legacy.json found; skipping channel import".into()],
            error: None,
        };
    }
    let content = match fs::read_to_string(&config_path) {
        Ok(c) => c,
        Err(e) => {
            return err(
                MigrationArea::Channels,
                format!("Failed to read legacy.json: {e}"),
            );
        }
    };
    let oc_cfg: LegacyConfig = match serde_json::from_str(&content) {
        Ok(c) => c,
        Err(e) => {
            return err(
                MigrationArea::Channels,
                format!("Failed to parse legacy.json: {e}"),
            );
        }
    };

    let mut items = 0;
    let mut warnings = Vec::new();
    let mut lines = vec!["[channels]".to_string()];

    if let Some(channels) = &oc_cfg.channels {
        if let Some(tg) = &channels.telegram {
            lines.push(String::new());
            lines.push("[channels.telegram]".into());
            lines.push(format!("enabled = {}", tg.enabled.unwrap_or(false)));
            if let Some(token) = &tg.token {
                match store_in_keystore("telegram_bot_token", token, &ks_path) {
                    Ok(()) => {
                        lines.push("token_ref = \"keystore:telegram_bot_token\"".into());
                        warnings.push(
                            "Telegram token stored in encrypted keystore as \"telegram_bot_token\""
                                .into(),
                        );
                    }
                    Err(e) => {
                        lines.push("token_env = \"TELEGRAM_BOT_TOKEN\"".into());
                        warnings.push(format!(
                            "Keystore unavailable ({e}); set env var TELEGRAM_BOT_TOKEN=<token>"
                        ));
                    }
                }
            }
            items += 1;
        }
        if let Some(wa) = &channels.whatsapp {
            lines.push(String::new());
            lines.push("[channels.whatsapp]".into());
            lines.push(format!("enabled = {}", wa.enabled.unwrap_or(false)));
            if let Some(token) = &wa.token {
                match store_in_keystore("whatsapp_token", token, &ks_path) {
                    Ok(()) => {
                        lines.push("token_ref = \"keystore:whatsapp_token\"".into());
                        warnings.push(
                            "WhatsApp token stored in encrypted keystore as \"whatsapp_token\""
                                .into(),
                        );
                    }
                    Err(e) => {
                        lines.push("token_env = \"WHATSAPP_TOKEN\"".into());
                        warnings.push(format!(
                            "Keystore unavailable ({e}); set env var WHATSAPP_TOKEN=<token>"
                        ));
                    }
                }
            }
            if let Some(phone) = &wa.phone_id {
                lines.push(format!("phone_id = {}", qt(phone)));
            }
            items += 1;
        }
    }

    if items == 0 {
        return AreaResult {
            area: MigrationArea::Channels,
            success: true,
            items_processed: 0,
            warnings: vec!["No channel configuration found in Legacy config".into()],
            error: None,
        };
    }

    if let Err(e) = fs::create_dir_all(ic_root) {
        return err(
            MigrationArea::Channels,
            format!("Failed to create dir: {e}"),
        );
    }
    if let Err(e) = fs::write(ic_root.join("channels.toml"), lines.join("\n") + "\n") {
        return err(
            MigrationArea::Channels,
            format!("Failed to write channels.toml: {e}"),
        );
    }

    AreaResult {
        area: MigrationArea::Channels,
        success: true,
        items_processed: items,
        warnings,
        error: None,
    }
}

pub(crate) fn export_channels(ic_root: &Path, oc_root: &Path) -> AreaResult {
    let ks_path = ic_root.join("keystore.enc");
    let channels_path = ic_root.join("channels.toml");
    let config_path = ic_root.join("roboticus.toml");
    let mut warnings = Vec::new();

    let channel_toml = if channels_path.exists() {
        match fs::read_to_string(&channels_path) {
            Ok(c) => c,
            Err(e) => {
                return err(
                    MigrationArea::Channels,
                    format!("Failed to read channels.toml: {e}"),
                );
            }
        }
    } else if config_path.exists() {
        match fs::read_to_string(&config_path) {
            Ok(c) => c,
            Err(e) => {
                return err(
                    MigrationArea::Channels,
                    format!("Failed to read roboticus.toml: {e}"),
                );
            }
        }
    } else {
        return AreaResult {
            area: MigrationArea::Channels,
            success: true,
            items_processed: 0,
            warnings: vec!["No channel configuration found".into()],
            error: None,
        };
    };

    let parsed: toml::Value = match toml::from_str(&channel_toml) {
        Ok(v) => v,
        Err(_) => {
            return AreaResult {
                area: MigrationArea::Channels,
                success: true,
                items_processed: 0,
                warnings: vec!["Could not parse channel config".into()],
                error: None,
            };
        }
    };

    let mut oc_channels = serde_json::Map::new();
    let mut items = 0;

    if let Some(channels) = parsed.get("channels").and_then(|v| v.as_table()) {
        if let Some(tg) = channels.get("telegram").and_then(|v| v.as_table()) {
            let mut obj = serde_json::Map::new();
            if let Some(e) = tg.get("enabled").and_then(|v| v.as_bool()) {
                obj.insert("enabled".into(), serde_json::Value::Bool(e));
            }
            if !resolve_channel_token_for_export(tg, &mut obj, &mut warnings, "telegram", &ks_path)
            {
                // no token resolved
            }
            oc_channels.insert("telegram".into(), serde_json::Value::Object(obj));
            items += 1;
        }
        if let Some(wa) = channels.get("whatsapp").and_then(|v| v.as_table()) {
            let mut obj = serde_json::Map::new();
            if let Some(e) = wa.get("enabled").and_then(|v| v.as_bool()) {
                obj.insert("enabled".into(), serde_json::Value::Bool(e));
            }
            if !resolve_channel_token_for_export(wa, &mut obj, &mut warnings, "whatsapp", &ks_path)
            {
                // no token resolved
            }
            if let Some(phone) = wa.get("phone_id").and_then(|v| v.as_str()) {
                obj.insert("phone_id".into(), serde_json::Value::String(phone.into()));
            }
            oc_channels.insert("whatsapp".into(), serde_json::Value::Object(obj));
            items += 1;
        }
    }

    if items == 0 {
        return AreaResult {
            area: MigrationArea::Channels,
            success: true,
            items_processed: 0,
            warnings: vec!["No channel definitions found to export".into()],
            error: None,
        };
    }

    // Merge into existing legacy.json
    let oc_config_path = oc_root.join("legacy.json");
    let mut oc_config: serde_json::Map<String, serde_json::Value> = if oc_config_path.exists() {
        match fs::read_to_string(&oc_config_path) {
            Ok(c) => match serde_json::from_str(&c) {
                Ok(map) => map,
                Err(e) => {
                    warnings.push(format!(
                        "Could not parse existing legacy.json: {e}; starting fresh"
                    ));
                    serde_json::Map::new()
                }
            },
            Err(e) => {
                warnings.push(format!(
                    "Could not read existing legacy.json: {e}; starting fresh"
                ));
                serde_json::Map::new()
            }
        }
    } else {
        serde_json::Map::new()
    };
    oc_config.insert("channels".into(), serde_json::Value::Object(oc_channels));

    if let Err(e) = fs::create_dir_all(oc_root) {
        return err(
            MigrationArea::Channels,
            format!("Failed to create output dir: {e}"),
        );
    }
    let serialized = match serde_json::to_string_pretty(&oc_config) {
        Ok(s) => s,
        Err(e) => {
            return err(
                MigrationArea::Channels,
                format!("Failed to serialize legacy.json: {e}"),
            );
        }
    };
    if let Err(e) = fs::write(&oc_config_path, &serialized) {
        return err(
            MigrationArea::Channels,
            format!("Failed to write legacy.json: {e}"),
        );
    }

    AreaResult {
        area: MigrationArea::Channels,
        success: true,
        items_processed: items,
        warnings,
        error: None,
    }
}