use crate::skill;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub const DESCRIPTIONS_DIR: &str = "descriptions";
pub const TOOLS_SUBDIR: &str = "tools";
pub const SKILLS_SUBDIR: &str = "skills";
pub const SKILL_MANIFEST: &str = "SKILL.md";
const JSON_EXT: &str = "json";
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("descriptions I/O at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("skill {name}: {} has no YAML frontmatter block", SKILL_MANIFEST)]
NoFrontmatter { name: String },
#[error("skill {name}: {path} frontmatter is malformed: {source}")]
SkillFrontmatter {
name: String,
path: PathBuf,
#[source]
source: serde_yaml_ng::Error,
},
#[error("tool {name}: {path} is not valid JSON: {source}")]
ToolSchema {
name: String,
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
fn io_err(path: &Path, source: io::Error) -> Error {
Error::Io {
path: path.to_path_buf(),
source,
}
}
pub fn snapshot(data_root: &Path, worktree: &Path) -> Result<(), Error> {
copy_tool_schemas(&data_root.join(TOOLS_SUBDIR), worktree)?;
copy_skill_frontmatter(&data_root.join(SKILLS_SUBDIR), worktree)?;
Ok(())
}
fn copy_tool_schemas(pool: &Path, worktree: &Path) -> Result<(), Error> {
let dest = worktree.join(DESCRIPTIONS_DIR).join(TOOLS_SUBDIR);
for entry in read_pool(pool)? {
let path = entry.path();
if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some(JSON_EXT) {
continue;
}
let raw = fs::read(&path).map_err(|e| io_err(&path, e))?;
let name = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
serde_json::from_slice::<serde_json::Value>(&raw).map_err(|source| Error::ToolSchema {
name,
path: path.clone(),
source,
})?;
ensure_dir(&dest)?;
let target = dest.join(entry.file_name());
fs::write(&target, &raw).map_err(|e| io_err(&target, e))?;
}
Ok(())
}
fn copy_skill_frontmatter(pool: &Path, worktree: &Path) -> Result<(), Error> {
let dest = worktree.join(DESCRIPTIONS_DIR).join(SKILLS_SUBDIR);
for entry in read_pool(pool)? {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let manifest = dir.join(SKILL_MANIFEST);
let raw = match fs::read_to_string(&manifest) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => return Err(io_err(&manifest, e)),
};
let name = entry.file_name().to_string_lossy().into_owned();
let body =
skill::frontmatter_yaml(&raw).ok_or(Error::NoFrontmatter { name: name.clone() })?;
skill::parse(body).map_err(|source| Error::SkillFrontmatter {
name: name.clone(),
path: manifest.clone(),
source,
})?;
ensure_dir(&dest)?;
let target = dest.join(format!("{name}.md"));
fs::write(&target, body).map_err(|e| io_err(&target, e))?;
}
Ok(())
}
fn read_pool(pool: &Path) -> Result<Vec<fs::DirEntry>, Error> {
let iter = match fs::read_dir(pool) {
Ok(it) => it,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(io_err(pool, e)),
};
let mut entries: Vec<fs::DirEntry> = iter.flatten().collect();
entries.sort_by_key(|e| e.file_name());
Ok(entries)
}
fn ensure_dir(dir: &Path) -> Result<(), Error> {
fs::create_dir_all(dir).map_err(|e| io_err(dir, e))
}
#[cfg(test)]
mod tests;