use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRef {
pub provider: String,
pub target: String,
}
impl ModelRef {
pub fn new(provider: impl Into<String>, target: impl Into<String>) -> Self {
Self {
provider: provider.into().to_lowercase(),
target: target.into(),
}
}
}
impl std::fmt::Display for ModelRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.provider, self.target)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataPolicy {
LocalOnly,
Any,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Spec {
pub name: String,
pub description: String,
pub model: ModelRef,
pub data_policy: DataPolicy,
pub read_roots: Vec<PathBuf>,
pub block: PathBuf,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SpecError {
#[error("missing required field `{0}`")]
MissingField(&'static str),
#[error("unknown field `{0}`")]
UnknownField(String),
#[error("malformed spec: {0}")]
Malformed(String),
#[error("unsupported capability `{0}` (this build supports only `Read`)")]
UnsupportedCapability(String),
}
fn quoted(value: &str, field: &str) -> Result<String, SpecError> {
value
.trim()
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.map(str::to_string)
.ok_or_else(|| SpecError::Malformed(format!("field `{field}` must be a quoted string")))
}
fn capabilities(value: &str) -> Result<Vec<PathBuf>, SpecError> {
let inner = value
.trim()
.strip_prefix('[')
.and_then(|v| v.strip_suffix(']'))
.ok_or_else(|| SpecError::Malformed("capabilities must be a `[...]` list".into()))?;
inner
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(|entry| {
let rest = entry.strip_prefix("Read ").ok_or_else(|| {
let kind = entry.split_whitespace().next().unwrap_or(entry);
SpecError::UnsupportedCapability(kind.to_string())
})?;
Ok(PathBuf::from(quoted(rest, "capabilities")?))
})
.collect()
}
pub fn parse_spec(src: &str) -> Result<Spec, SpecError> {
let open = src
.find('{')
.ok_or_else(|| SpecError::Malformed("expected `{`".into()))?;
let close = src
.rfind('}')
.ok_or_else(|| SpecError::Malformed("expected `}`".into()))?;
if close < open {
return Err(SpecError::Malformed("`}` before `{`".into()));
}
let name = src[..open]
.trim()
.strip_prefix("spec")
.and_then(|header| header.split('=').next())
.map(str::trim)
.filter(|name| !name.is_empty())
.ok_or_else(|| SpecError::Malformed("expected `spec <name> = {`".into()))?
.to_string();
let (mut description, mut model, mut data_policy, mut read_roots, mut block) =
(None, None, None, None, None);
for statement in src[open + 1..close].split(';') {
let statement = statement.trim();
if statement.is_empty() {
continue;
}
let (key, value) = statement.split_once('=').ok_or_else(|| {
SpecError::Malformed(format!("expected `key = value` in `{statement}`"))
})?;
let value = value.trim();
match key.trim() {
"description" => description = Some(quoted(value, "description")?),
"block" => block = Some(PathBuf::from(quoted(value, "block")?)),
"capabilities" => read_roots = Some(capabilities(value)?),
"model" => {
let (provider, rest) = value.split_once(char::is_whitespace).ok_or_else(|| {
SpecError::Malformed(
r#"model needs a provider and a target, as in `Ollama "llama3.2:1b"`"#
.into(),
)
})?;
if provider.is_empty() || !provider.chars().all(|c| c.is_alphanumeric() || c == '_')
{
return Err(SpecError::Malformed(format!(
"`{provider}` is not a valid model provider name"
)));
}
model = Some(ModelRef::new(provider, quoted(rest, "model")?));
}
"data_policy" => {
data_policy = Some(match value {
"Local_only" => DataPolicy::LocalOnly,
"Any" => DataPolicy::Any,
other => {
return Err(SpecError::Malformed(format!(
"unknown data_policy `{other}`"
)))
}
})
}
other => return Err(SpecError::UnknownField(other.to_string())),
}
}
Ok(Spec {
name,
description: description.ok_or(SpecError::MissingField("description"))?,
model: model.ok_or(SpecError::MissingField("model"))?,
data_policy: data_policy.ok_or(SpecError::MissingField("data_policy"))?,
read_roots: read_roots.ok_or(SpecError::MissingField("capabilities"))?,
block: block.ok_or(SpecError::MissingField("block"))?,
})
}