use std::fs;
use std::path::Path;
use super::{AreaResult, MigrationArea, err, uuid_v4};
const SKIP_AGENT_DIRS: &[&str] = &["duncan", "main"];
fn is_model_wrapper(name: &str) -> bool {
let lower = name.to_lowercase();
lower.starts_with("anthropic-")
|| lower.starts_with("google-")
|| lower.starts_with("moonshot-")
|| lower.starts_with("ollama-")
|| lower.starts_with("openai-")
}
fn agent_display_name(name: &str) -> String {
name.split('-')
.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(" ")
}
fn detect_agent_model(agent_dir: &Path) -> String {
let models_path = agent_dir.join("agent").join("models.json");
if let Ok(content) = fs::read_to_string(&models_path)
&& let Ok(val) = serde_json::from_str::<serde_json::Value>(&content)
{
if let Some(p) = val.get("primary").and_then(|v| v.as_str())
&& !p.is_empty()
{
return p.to_string();
}
if let Some(providers) = val.get("providers").and_then(|v| v.as_object()) {
for (prov_name, prov) in providers {
if let Some(models) = prov.get("models").and_then(|v| v.as_array())
&& let Some(first) = models.first()
&& let Some(id) = first.get("id").and_then(|v| v.as_str())
{
return format!("{prov_name}/{id}");
}
}
}
}
String::new()
}
pub(crate) fn import_agents(oc_root: &Path, ic_root: &Path) -> AreaResult {
let agents_dir = oc_root.join("agents");
if !agents_dir.exists() {
return AreaResult {
area: MigrationArea::Agents,
success: true,
items_processed: 0,
warnings: vec!["No agents directory found in Legacy root".into()],
error: None,
};
}
let db_path = ic_root.join("state.db");
let db = match roboticus_db::Database::new(&db_path.to_string_lossy()) {
Ok(d) => d,
Err(e) => {
return err(
MigrationArea::Agents,
format!("Failed to open database: {e}"),
);
}
};
let mut items = 0;
let mut warnings = Vec::new();
let entries = match fs::read_dir(&agents_dir) {
Ok(e) => e,
Err(e) => {
return err(
MigrationArea::Agents,
format!("Failed to read agents directory: {e}"),
);
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match entry.file_name().to_str() {
Some(n) => n.to_string(),
None => continue,
};
if SKIP_AGENT_DIRS.contains(&name.as_str()) {
continue;
}
let role = if is_model_wrapper(&name) {
"model-proxy"
} else {
"specialist"
};
let model = detect_agent_model(&path);
let session_count = path
.join("sessions")
.read_dir()
.map(|rd| rd.count() as i64)
.unwrap_or(0);
let display_name = agent_display_name(&name);
let agent = roboticus_db::agents::SubAgentRow {
id: uuid_v4(),
name: name.clone(),
display_name: Some(display_name),
model,
fallback_models_json: Some("[]".to_string()),
role: role.to_string(),
description: None,
skills_json: None,
enabled: role == "specialist",
session_count,
last_used_at: None,
};
match roboticus_db::agents::upsert_sub_agent(&db, &agent) {
Ok(()) => items += 1,
Err(e) => warnings.push(format!("Failed to import agent '{name}': {e}")),
}
}
AreaResult {
area: MigrationArea::Agents,
success: true,
items_processed: items,
warnings,
error: None,
}
}
pub(crate) fn export_agents(ic_root: &Path, _oc_root: &Path) -> AreaResult {
let db_path = ic_root.join("state.db");
if !db_path.exists() {
return AreaResult {
area: MigrationArea::Agents,
success: true,
items_processed: 0,
warnings: vec!["No database found".into()],
error: None,
};
}
let db = match roboticus_db::Database::new(&db_path.to_string_lossy()) {
Ok(d) => d,
Err(e) => {
return err(
MigrationArea::Agents,
format!("Failed to open database: {e}"),
);
}
};
let agents = match roboticus_db::agents::list_sub_agents(&db) {
Ok(a) => a,
Err(e) => {
return err(MigrationArea::Agents, format!("Failed to list agents: {e}"));
}
};
AreaResult {
area: MigrationArea::Agents,
success: true,
items_processed: agents.len(),
warnings: vec![],
error: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn is_model_wrapper_detects_provider_prefixed_names() {
assert!(is_model_wrapper("anthropic-claude"));
assert!(is_model_wrapper("google-gemini"));
assert!(is_model_wrapper("moonshot-v1"));
assert!(is_model_wrapper("ollama-qwen"));
assert!(is_model_wrapper("openai-gpt4"));
assert!(is_model_wrapper("OpenAI-GPT4"));
}
#[test]
fn is_model_wrapper_rejects_non_provider_names() {
assert!(!is_model_wrapper("geo-specialist"));
assert!(!is_model_wrapper("risk-analyst"));
assert!(!is_model_wrapper("duncan"));
assert!(!is_model_wrapper(""));
}
#[test]
fn agent_display_name_capitalizes_and_joins() {
assert_eq!(agent_display_name("geo-specialist"), "Geo Specialist");
assert_eq!(agent_display_name("risk"), "Risk");
assert_eq!(agent_display_name("multi-word-name"), "Multi Word Name");
}
#[test]
fn agent_display_name_empty_segments() {
assert_eq!(agent_display_name("-test-"), " Test ");
}
#[test]
fn detect_agent_model_returns_empty_for_nonexistent_dir() {
assert_eq!(detect_agent_model(Path::new("/nonexistent/agent")), "");
}
#[test]
fn detect_agent_model_reads_primary_field() {
let dir = TempDir::new().unwrap();
let agent_dir = dir.path().join("agent");
fs::create_dir_all(&agent_dir).unwrap();
let models = serde_json::json!({"primary": "openai/gpt-4o"});
fs::write(
agent_dir.join("models.json"),
serde_json::to_string(&models).unwrap(),
)
.unwrap();
assert_eq!(detect_agent_model(dir.path()), "openai/gpt-4o");
}
#[test]
fn detect_agent_model_reads_from_providers() {
let dir = TempDir::new().unwrap();
let agent_dir = dir.path().join("agent");
fs::create_dir_all(&agent_dir).unwrap();
let models = serde_json::json!({
"providers": {
"openai": {
"models": [{"id": "gpt-4"}]
}
}
});
fs::write(
agent_dir.join("models.json"),
serde_json::to_string(&models).unwrap(),
)
.unwrap();
assert_eq!(detect_agent_model(dir.path()), "openai/gpt-4");
}
#[test]
fn detect_agent_model_returns_empty_for_empty_json() {
let dir = TempDir::new().unwrap();
let agent_dir = dir.path().join("agent");
fs::create_dir_all(&agent_dir).unwrap();
fs::write(agent_dir.join("models.json"), "{}").unwrap();
assert_eq!(detect_agent_model(dir.path()), "");
}
}