pub mod config;
pub mod skills;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
#[derive(Debug, Default)]
pub struct MigrationReport {
pub openclaw_dir: PathBuf,
pub config_migrated: Vec<String>,
pub config_skipped: Vec<(String, String)>,
pub skills_copied: Vec<String>,
pub skills_skipped: Vec<(String, String)>,
pub warnings: Vec<String>,
pub not_portable: Vec<String>,
}
impl MigrationReport {
pub fn new(openclaw_dir: PathBuf) -> Self {
Self {
openclaw_dir,
..Default::default()
}
}
pub fn print_summary(&self) {
println!();
println!("Migration Report");
println!("================");
println!();
if !self.config_migrated.is_empty() {
println!(" Config fields migrated: {}", self.config_migrated.len());
for field in &self.config_migrated {
println!(" + {}", field);
}
}
if !self.config_skipped.is_empty() {
println!();
println!(" Config fields skipped: {}", self.config_skipped.len());
for (field, reason) in &self.config_skipped {
println!(" - {} ({})", field, reason);
}
}
if !self.skills_copied.is_empty() {
println!();
println!(" Skills copied: {}", self.skills_copied.len());
for name in &self.skills_copied {
println!(" + {}", name);
}
}
if !self.skills_skipped.is_empty() {
println!();
println!(" Skills skipped: {}", self.skills_skipped.len());
for (name, reason) in &self.skills_skipped {
println!(" - {} ({})", name, reason);
}
}
if !self.not_portable.is_empty() {
println!();
println!(" Not portable ({}):", self.not_portable.len());
for item in &self.not_portable {
println!(" ! {}", item);
}
}
if !self.warnings.is_empty() {
println!();
println!(" Warnings:");
for w in &self.warnings {
println!(" * {}", w);
}
}
println!();
}
}
const OPENCLAW_CONFIG_NAMES: &[&str] = &[
"openclaw.json",
"openclaw.json5",
"config.json",
"config.json5",
];
pub fn detect_openclaw_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("OPENCLAW_STATE_DIR") {
let p = PathBuf::from(&dir);
if has_openclaw_config(&p) {
return Some(p);
}
}
let home = dirs::home_dir()?;
for name in &[".openclaw", ".clawdbot", ".moldbot"] {
let candidate = home.join(name);
if has_openclaw_config(&candidate) {
return Some(candidate);
}
}
None
}
fn has_openclaw_config(dir: &Path) -> bool {
if !dir.is_dir() {
return false;
}
OPENCLAW_CONFIG_NAMES
.iter()
.any(|name| dir.join(name).is_file())
}
pub fn load_openclaw_config(openclaw_dir: &Path) -> Result<serde_json::Value> {
for name in OPENCLAW_CONFIG_NAMES {
let path = openclaw_dir.join(name);
if path.is_file() {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let value: serde_json::Value = json5::from_str(&content)
.with_context(|| format!("Failed to parse {} as JSON5", path.display()))?;
return Ok(value);
}
}
anyhow::bail!(
"No OpenClaw config file found in {}. Expected one of: {}",
openclaw_dir.display(),
OPENCLAW_CONFIG_NAMES.join(", ")
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_detect_returns_none_when_nothing_exists() {
let _ = detect_openclaw_dir();
}
#[test]
fn test_has_openclaw_config() {
let tmp = tempfile::tempdir().unwrap();
assert!(!has_openclaw_config(tmp.path()));
fs::write(tmp.path().join("openclaw.json5"), "{}").unwrap();
assert!(has_openclaw_config(tmp.path()));
}
#[test]
fn test_load_openclaw_config_json5() {
let tmp = tempfile::tempdir().unwrap();
let content = r#"{
// This is a JSON5 comment
models: {
providers: {
anthropic: {
apiKey: "sk-ant-test",
},
},
},
}"#;
fs::write(tmp.path().join("openclaw.json5"), content).unwrap();
let val = load_openclaw_config(tmp.path()).unwrap();
assert_eq!(
val["models"]["providers"]["anthropic"]["apiKey"],
"sk-ant-test"
);
}
#[test]
fn test_load_openclaw_config_missing() {
let tmp = tempfile::tempdir().unwrap();
let result = load_openclaw_config(tmp.path());
assert!(result.is_err());
}
}