use std::collections::HashMap;
use std::fs;
use std::path::Path;
use super::{
AreaResult, MigrationArea, SafetyVerdict, copy_dir_recursive, err, scan_directory_safety,
};
pub(crate) fn import_skills(oc_root: &Path, ic_root: &Path, no_safety_check: bool) -> AreaResult {
let skills_dir = oc_root.join("workspace").join("skills");
if !skills_dir.exists() {
return AreaResult {
area: MigrationArea::Skills,
success: true,
items_processed: 0,
warnings: vec!["No skills directory found in Legacy workspace".into()],
error: None,
};
}
let out_dir = ic_root.join("skills");
if let Err(e) = fs::create_dir_all(&out_dir) {
return err(
MigrationArea::Skills,
format!("Failed to create skills dir: {e}"),
);
}
let mut warnings = Vec::new();
if !no_safety_check {
let report = scan_directory_safety(&skills_dir);
if let SafetyVerdict::Critical(n) = report.verdict {
return AreaResult {
area: MigrationArea::Skills,
success: false,
items_processed: 0,
warnings: vec![format!("{n} critical safety finding(s); import blocked")],
error: Some(
"Skills blocked by safety check. Use --no-safety-check to override.".into(),
),
};
}
if let SafetyVerdict::Warnings(n) = report.verdict {
warnings.push(format!(
"{n} warning(s) found in skill scripts; review recommended"
));
}
} else {
warnings.push("Safety checks skipped (--no-safety-check)".into());
}
let skill_entries: HashMap<String, bool> = fs::read_to_string(oc_root.join("legacy.json"))
.inspect_err(|e| tracing::warn!("failed to read legacy.json for skill entries: {e}"))
.ok()
.and_then(|s| {
serde_json::from_str::<serde_json::Value>(&s)
.inspect_err(|e| {
tracing::warn!("failed to parse legacy.json for skill entries: {e}")
})
.ok()
})
.and_then(|v| {
v.get("skills")?.get("entries")?.as_object().map(|obj| {
obj.iter()
.map(|(k, v)| {
let enabled = v.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true);
(k.clone(), enabled)
})
.collect()
})
})
.unwrap_or_default();
let mut items = 0;
let mut skill_names: Vec<(String, bool)> = Vec::new();
if let Ok(entries) = fs::read_dir(&skills_dir) {
for entry in entries.flatten() {
let src = entry.path();
let dest = out_dir.join(entry.file_name());
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || name == "gateway.log" {
continue;
}
if src.is_file() {
if let Err(e) = fs::copy(&src, &dest) {
warnings.push(format!("Failed to copy {}: {e}", src.display()));
} else {
items += 1;
}
} else if src.is_dir() {
if let Err(e) = copy_dir_recursive(&src, &dest) {
warnings.push(format!("Failed to copy dir {}: {e}", src.display()));
} else {
let enabled = skill_entries.get(&name).copied().unwrap_or(true);
skill_names.push((name, enabled));
items += 1;
}
}
}
}
let db_path = ic_root.join("state.db");
if db_path.exists() && !skill_names.is_empty() {
match roboticus_db::Database::new(db_path.to_string_lossy().as_ref()) {
Ok(db) => {
let mut registered = 0u32;
let mut disabled_count = 0u32;
for (name, enabled) in &skill_names {
let skill_md = out_dir.join(name).join("SKILL.md");
let description = fs::read_to_string(&skill_md)
.inspect_err(
|e| tracing::warn!(skill = %name, "failed to read SKILL.md: {e}"),
)
.ok()
.and_then(|content| parse_skill_description(&content));
let source_path = out_dir.join(name).to_string_lossy().to_string();
let content_hash = format!("migrated-{}", chrono::Utc::now().timestamp());
let kind = if out_dir.join(name).join("SKILL.md").exists() {
"instruction"
} else {
"scripted"
};
match roboticus_db::skills::register_skill(
&db,
name,
kind,
description.as_deref(),
&source_path,
&content_hash,
None,
None,
None,
None,
None,
) {
Ok(id) => {
registered += 1;
if !enabled {
let conn = db.conn();
if let Err(e) = conn.execute(
"UPDATE skills SET enabled = 0 WHERE id = ?1",
rusqlite::params![id],
) {
warnings.push(format!("Failed to disable skill {name}: {e}"));
}
disabled_count += 1;
}
}
Err(e) => {
warnings.push(format!("Failed to register skill {name}: {e}"));
}
}
}
if registered > 0 {
warnings.push(format!(
"{registered} skill(s) registered in database ({} enabled, {disabled_count} disabled)",
registered - disabled_count
));
}
}
Err(e) => {
warnings.push(format!(
"Could not open database to register skills: {e}. \
Run `roboticus skills reload` after migration to register them."
));
}
}
} else if !skill_names.is_empty() {
warnings.push(
"Database not found; skills copied but not registered. \
Run `roboticus skills reload` after starting the server."
.into(),
);
}
AreaResult {
area: MigrationArea::Skills,
success: true,
items_processed: items,
warnings,
error: None,
}
}
fn parse_skill_description(content: &str) -> Option<String> {
let content = content.trim();
if !content.starts_with("---") {
return None;
}
let rest = &content[3..];
let end = rest.find("---")?;
let frontmatter = &rest[..end];
for line in frontmatter.lines() {
let line = line.trim();
if let Some(desc) = line.strip_prefix("description:") {
let desc = desc.trim().trim_matches('"').trim_matches('\'');
if !desc.is_empty() {
return Some(desc.to_string());
}
}
}
None
}
pub(crate) fn export_skills(ic_root: &Path, oc_root: &Path) -> AreaResult {
let skills_dir = ic_root.join("skills");
if !skills_dir.exists() {
return AreaResult {
area: MigrationArea::Skills,
success: true,
items_processed: 0,
warnings: vec!["No skills directory found in Roboticus workspace".into()],
error: None,
};
}
let out_dir = oc_root.join("workspace").join("skills");
if let Err(e) = fs::create_dir_all(&out_dir) {
return err(
MigrationArea::Skills,
format!("Failed to create output skills dir: {e}"),
);
}
let mut items = 0;
let mut warnings = Vec::new();
if let Ok(entries) = fs::read_dir(&skills_dir) {
for entry in entries.flatten() {
let src = entry.path();
let dest = out_dir.join(entry.file_name());
if src.is_file() {
if let Err(e) = fs::copy(&src, &dest) {
warnings.push(format!("Failed to copy {}: {e}", src.display()));
} else {
items += 1;
}
} else if src.is_dir() {
if let Err(e) = copy_dir_recursive(&src, &dest) {
warnings.push(format!("Failed to copy dir {}: {e}", src.display()));
} else {
items += 1;
}
}
}
}
AreaResult {
area: MigrationArea::Skills,
success: true,
items_processed: items,
warnings,
error: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_skill_description_extracts_from_frontmatter() {
let content = "---\nname: my-skill\ndescription: A cool skill\n---\n# Body\nContent here.";
assert_eq!(
parse_skill_description(content),
Some("A cool skill".to_string())
);
}
#[test]
fn parse_skill_description_handles_quoted_desc() {
let content = "---\ndescription: \"Quoted description\"\n---\nBody";
assert_eq!(
parse_skill_description(content),
Some("Quoted description".to_string())
);
}
#[test]
fn parse_skill_description_returns_none_without_frontmatter() {
assert_eq!(parse_skill_description("Just text"), None);
assert_eq!(parse_skill_description(""), None);
}
#[test]
fn parse_skill_description_returns_none_for_empty_desc() {
let content = "---\nname: test\ndescription: \n---\nBody";
assert_eq!(parse_skill_description(content), None);
}
#[test]
fn parse_skill_description_no_closing_frontmatter() {
let content = "---\nname: test\ndescription: missing close";
assert_eq!(parse_skill_description(content), None);
}
}