roboticus-cli 0.11.4

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

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

use super::{AreaResult, MigrationArea, err, qt_ml};

pub(crate) fn import_personality(oc_root: &Path, ic_root: &Path) -> AreaResult {
    let ws = oc_root.join("workspace");
    let soul_path = ws.join("SOUL.md");
    let agents_path = ws.join("AGENTS.md");
    let out_dir = ic_root.join("workspace");
    if let Err(e) = fs::create_dir_all(&out_dir) {
        return err(
            MigrationArea::Personality,
            format!("Failed to create workspace dir: {e}"),
        );
    }

    let mut warnings = Vec::new();
    let mut items = 0;

    if soul_path.exists() {
        match fs::read_to_string(&soul_path) {
            Ok(md) => {
                let toml_str = markdown_to_personality_toml(&md, "os");
                if let Err(e) = fs::write(out_dir.join("OS.toml"), &toml_str) {
                    return err(
                        MigrationArea::Personality,
                        format!("Failed to write OS.toml: {e}"),
                    );
                }
                items += 1;
            }
            Err(e) => {
                return err(
                    MigrationArea::Personality,
                    format!("Failed to read SOUL.md: {e}"),
                );
            }
        }
    } else {
        warnings.push("SOUL.md not found; OS.toml will use defaults".into());
    }

    if agents_path.exists() {
        match fs::read_to_string(&agents_path) {
            Ok(md) => {
                let toml_str = markdown_to_personality_toml(&md, "firmware");
                if let Err(e) = fs::write(out_dir.join("FIRMWARE.toml"), &toml_str) {
                    return err(
                        MigrationArea::Personality,
                        format!("Failed to write FIRMWARE.toml: {e}"),
                    );
                }
                items += 1;
            }
            Err(e) => {
                return err(
                    MigrationArea::Personality,
                    format!("Failed to read AGENTS.md: {e}"),
                );
            }
        }
    } else {
        warnings.push("AGENTS.md not found; FIRMWARE.toml will use defaults".into());
    }

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

pub(crate) fn export_personality(ic_root: &Path, oc_root: &Path) -> AreaResult {
    let ic_ws = ic_root.join("workspace");
    let out_ws = oc_root.join("workspace");
    if let Err(e) = fs::create_dir_all(&out_ws) {
        return err(
            MigrationArea::Personality,
            format!("Failed to create workspace dir: {e}"),
        );
    }

    let mut warnings = Vec::new();
    let mut items = 0;

    let os_path = ic_ws.join("OS.toml");
    if os_path.exists() {
        match fs::read_to_string(&os_path) {
            Ok(content) => {
                let md = personality_toml_to_markdown(&content, "SOUL");
                if let Err(e) = fs::write(out_ws.join("SOUL.md"), &md) {
                    return err(
                        MigrationArea::Personality,
                        format!("Failed to write SOUL.md: {e}"),
                    );
                }
                items += 1;
            }
            Err(e) => {
                return err(
                    MigrationArea::Personality,
                    format!("Failed to read OS.toml: {e}"),
                );
            }
        }
    } else {
        warnings.push("OS.toml not found; SOUL.md will be minimal".into());
    }

    let fw_path = ic_ws.join("FIRMWARE.toml");
    if fw_path.exists() {
        match fs::read_to_string(&fw_path) {
            Ok(content) => {
                let md = personality_toml_to_markdown(&content, "AGENTS");
                if let Err(e) = fs::write(out_ws.join("AGENTS.md"), &md) {
                    return err(
                        MigrationArea::Personality,
                        format!("Failed to write AGENTS.md: {e}"),
                    );
                }
                items += 1;
            }
            Err(e) => {
                return err(
                    MigrationArea::Personality,
                    format!("Failed to read FIRMWARE.toml: {e}"),
                );
            }
        }
    } else {
        warnings.push("FIRMWARE.toml not found; AGENTS.md will be minimal".into());
    }

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

pub(crate) fn markdown_to_personality_toml(md: &str, kind: &str) -> String {
    let mut lines = vec![
        format!("# Converted from Legacy {kind} markdown"),
        format!("[{kind}]"),
    ];
    // Preserve full original as prompt_text for round-trip fidelity
    lines.push(format!("prompt_text = {}", qt_ml(md)));

    let mut current_section = String::new();
    let mut section_content = Vec::new();

    for line in md.lines() {
        if line.starts_with("# ") || line.starts_with("## ") {
            if !current_section.is_empty() && !section_content.is_empty() {
                let key = current_section.to_lowercase().replace([' ', '-'], "_");
                let val = section_content.join("\n");
                lines.push(format!("{key} = {}", qt_ml(&val)));
                section_content.clear();
            }
            current_section = line.trim_start_matches('#').trim().to_string();
        } else if !line.trim().is_empty() {
            section_content.push(line.to_string());
        }
    }

    if !current_section.is_empty() && !section_content.is_empty() {
        let key = current_section.to_lowercase().replace([' ', '-'], "_");
        let val = section_content.join("\n");
        lines.push(format!("{key} = {}", qt_ml(&val)));
    }

    lines.join("\n") + "\n"
}

pub(crate) fn personality_toml_to_markdown(toml_str: &str, title: &str) -> String {
    let parsed: Result<toml::Value, _> = toml::from_str(toml_str);
    match parsed {
        Ok(toml::Value::Table(table)) => {
            // Check for prompt_text (round-trip fidelity)
            for (_section_key, section_val) in &table {
                if let toml::Value::Table(inner) = section_val
                    && let Some(pt) = inner.get("prompt_text").and_then(|v| v.as_str())
                    && !pt.is_empty()
                {
                    return pt.to_string();
                }
                if let Some(pt) = section_val.as_str()
                    && _section_key == "prompt_text"
                    && !pt.is_empty()
                {
                    return pt.to_string();
                }
            }

            // Generate from structured fields
            let mut lines = vec![format!("# {title}"), String::new()];
            for (_section_key, section_val) in &table {
                if let toml::Value::Table(inner) = section_val {
                    for (key, val) in inner {
                        if key == "prompt_text" {
                            continue;
                        }
                        let heading = titlecase(key);
                        lines.push(format!("## {heading}"));
                        lines.push(String::new());
                        if let Some(s) = val.as_str() {
                            lines.push(s.to_string());
                        } else {
                            lines.push(val.to_string());
                        }
                        lines.push(String::new());
                    }
                }
            }
            lines.join("\n") + "\n"
        }
        _ => format!("# {title}\n\n{toml_str}\n"),
    }
}

pub(crate) fn titlecase(key: &str) -> String {
    key.replace('_', " ")
        .split_whitespace()
        .map(|w| {
            let mut c = w.chars();
            match c.next() {
                None => String::new(),
                Some(f) => f.to_uppercase().to_string() + c.as_str(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}