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)]
pub skills: BTreeMap<String, SkillSpec>,
}
#[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_value(value).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()
}
}