use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
pub const MANIFEST_FILE: &str = "ai.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub targets: Vec<String>,
#[serde(default, deserialize_with = "deserialize_unique_skills")]
pub skills: BTreeMap<String, SkillSpec>,
}
fn deserialize_unique_skills<'de, D>(d: D) -> Result<BTreeMap<String, SkillSpec>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::{self, MapAccess, Visitor};
struct SkillsVisitor;
impl<'de> Visitor<'de> for SkillsVisitor {
type Value = BTreeMap<String, SkillSpec>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a map of unique skill names")
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut out = BTreeMap::new();
while let Some((name, spec)) = map.next_entry::<String, SkillSpec>()? {
if out.contains_key(&name) {
return Err(de::Error::custom(format!(
"duplicate skill name `{name}` in the `skills` map; \
each skill name may appear only once — remove or rename \
one of the `{name}` entries"
)));
}
out.insert(name, spec);
}
Ok(out)
}
}
d.deserialize_map(SkillsVisitor)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillSpec {
pub git: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub path: Option<String>,
}
pub enum Version {
Tag(String),
Branch(String),
Commit(String),
}
impl Version {
pub fn label(&self) -> String {
match self {
Version::Tag(t) => format!("tag:{t}"),
Version::Branch(b) => format!("branch:{b}"),
Version::Commit(c) => format!("commit:{c}"),
}
}
}
impl SkillSpec {
pub fn version(&self) -> Result<Version> {
match (&self.tag, &self.branch, &self.commit) {
(Some(t), None, None) => Ok(Version::Tag(t.clone())),
(None, Some(b), None) => Ok(Version::Branch(b.clone())),
(None, None, Some(c)) => Ok(Version::Commit(c.clone())),
(None, None, None) => bail!("skill `{}`: set one of tag/branch/commit", self.git),
_ => bail!("skill `{}`: set only one of tag/branch/commit", self.git),
}
}
}
pub fn validate_skill_name(name: &str) -> Result<()> {
let bad = name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0');
if bad {
bail!(
"invalid skill name `{name}`: names must be non-empty and must not contain \
path separators, `.`, `..`, or NUL"
);
}
Ok(())
}
pub fn validate_subpath(path: &str) -> Result<()> {
let p = Path::new(path);
for comp in p.components() {
match comp {
Component::Prefix(_) | Component::RootDir => {
bail!("invalid path `{path}`: must be relative to the repo root")
}
Component::ParentDir => {
bail!("invalid path `{path}`: `..` components are not allowed")
}
Component::CurDir | Component::Normal(_) => {}
}
}
if path.contains('\0') {
bail!("invalid path `{path}`: must not contain NUL");
}
Ok(())
}
impl Manifest {
pub fn path_in(dir: &Path) -> PathBuf {
dir.join(MANIFEST_FILE)
}
pub fn load(dir: &Path) -> Result<Self> {
let p = Self::path_in(dir);
let text = std::fs::read_to_string(&p)
.with_context(|| format!("no {MANIFEST_FILE} found at {}", p.display()))?;
let value: serde_json::Value =
serde_json::from_str(&text).with_context(|| format!("parsing {}", p.display()))?;
crate::schema::validate(&value).with_context(|| format!("in {}", p.display()))?;
let manifest: Self =
serde_json::from_str(&text).with_context(|| format!("parsing {}", p.display()))?;
for (name, spec) in &manifest.skills {
validate_skill_name(name).with_context(|| format!("in {}", p.display()))?;
if let Some(sub) = &spec.path {
validate_subpath(sub)
.with_context(|| format!("skill `{name}` in {}", p.display()))?;
}
}
Ok(manifest)
}
pub fn save(&self, dir: &Path) -> Result<()> {
let p = Self::path_in(dir);
let text = serde_json::to_string_pretty(self)?;
std::fs::write(&p, text + "\n").with_context(|| format!("writing {}", p.display()))
}
pub fn exists(dir: &Path) -> bool {
Self::path_in(dir).exists()
}
}