use anyhow::{Result, bail};
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const DEFAULT_SKILLS_PATH: &str = ".agents/skills";
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct SkillsConfig {
pub path: Option<PathBuf>,
#[serde(default, rename = "source")]
pub sources: Vec<SkillSource>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct SkillSource {
pub url: String,
#[serde(default)]
pub all: bool,
#[serde(default)]
pub select: Vec<String>,
#[serde(default)]
pub subdir: Option<PathBuf>,
}
#[derive(Deserialize)]
struct SkillsBlock {
skills: Option<SkillsConfig>,
}
impl SkillSource {
pub fn selection(&self) -> Result<crate::skills::SelectionMode> {
match (self.all, self.select.is_empty()) {
(true, false) => bail!(
"source '{}' declares both `all` and `select`; pick one",
self.url
),
(true, true) => Ok(crate::skills::SelectionMode::All),
(false, false) => Ok(crate::skills::SelectionMode::Select(self.select.clone())),
(false, true) => bail!(
"source '{}' declares neither `all = true` nor `select = [...]`",
self.url
),
}
}
}
impl SkillsConfig {
pub fn load(config_file: &Path) -> Result<Option<Self>> {
let content = std::fs::read_to_string(config_file)
.map_err(|e| anyhow::anyhow!("read {}: {e}", config_file.display()))?;
let block: SkillsBlock = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("parse {}: {e}", config_file.display()))?;
Ok(block.skills)
}
pub fn resolved_path(&self, root: &Path) -> PathBuf {
let raw = self
.path
.clone()
.unwrap_or_else(|| PathBuf::from(DEFAULT_SKILLS_PATH));
if raw.is_absolute() {
raw
} else {
root.join(raw)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::SelectionMode;
use tempfile::tempdir;
fn write_config(body: &str) -> (tempfile::TempDir, PathBuf) {
let dir = tempdir().unwrap();
let path = dir.path().join("asobi.toml");
std::fs::write(&path, body).unwrap();
(dir, path)
}
#[test]
fn loads_sources_in_file_order() {
let (_dir, cfg) = write_config(
r#"
data_dir = ".asobi/data"
[[skills.source]]
url = "https://github.com/a/one"
all = true
[[skills.source]]
url = "https://github.com/b/two"
select = ["alpha", "beta"]
"#,
);
let skills = SkillsConfig::load(&cfg).unwrap().unwrap();
assert_eq!(skills.sources.len(), 2);
assert_eq!(skills.sources[0].url, "https://github.com/a/one");
assert_eq!(skills.sources[0].selection().unwrap(), SelectionMode::All);
assert_eq!(
skills.sources[1].selection().unwrap(),
SelectionMode::Select(vec!["alpha".into(), "beta".into()])
);
}
#[test]
fn subdir_defaults_to_none_and_parses_when_declared() {
let (_dir, cfg) = write_config(
r#"
[[skills.source]]
url = "https://github.com/a/one"
all = true
[[skills.source]]
url = "https://github.com/b/two"
select = ["alpha"]
subdir = "skills"
"#,
);
let skills = SkillsConfig::load(&cfg).unwrap().unwrap();
assert_eq!(skills.sources[0].subdir, None);
assert_eq!(skills.sources[1].subdir, Some(PathBuf::from("skills")));
}
#[test]
fn no_skills_block_is_none_not_an_error() {
let (_dir, cfg) = write_config("data_dir = \".asobi/data\"\n");
assert!(SkillsConfig::load(&cfg).unwrap().is_none());
}
#[test]
fn selection_requires_exactly_one_of_all_or_select() {
let both = SkillSource {
url: "u".into(),
all: true,
select: vec!["a".into()],
subdir: None,
};
assert!(both.selection().is_err());
let neither = SkillSource {
url: "u".into(),
all: false,
select: vec![],
subdir: None,
};
assert!(neither.selection().is_err());
}
#[test]
fn path_defaults_to_agents_skills_anchored_at_root() {
let cfg = SkillsConfig::default();
assert_eq!(
cfg.resolved_path(Path::new("/proj")),
Path::new("/proj/.agents/skills")
);
}
#[test]
fn absolute_path_is_not_anchored() {
let cfg = SkillsConfig {
path: Some(PathBuf::from("/elsewhere/skills")),
sources: vec![],
};
assert_eq!(
cfg.resolved_path(Path::new("/proj")),
Path::new("/elsewhere/skills")
);
}
#[test]
fn unknown_key_in_a_source_is_rejected() {
let (_dir, cfg) = write_config(
r#"
[[skills.source]]
url = "https://github.com/a/one"
alll = true
"#,
);
assert!(SkillsConfig::load(&cfg).is_err());
}
}