cuttlefish_core/spec.rs
1//! Parsing `Cuttlefish.spec` files.
2//!
3//! # Scope, and why this is a scanner rather than a parser library
4//!
5//! The language this project is heading toward is a typed DSL with `let`-bound
6//! pipelines, block signatures, and inference over them. This is not that. It
7//! reads a deliberately flat subset — a `spec NAME = { key = value; ... }` block
8//! with a fixed set of keys — because that is all the first working end-to-end
9//! job needs.
10//!
11//! Reaching for a parser-combinator library before the grammar has expressions
12//! in it would be building the abstraction for a language that does not exist
13//! yet, against guesses about its shape. When the pipeline syntax lands, this
14//! module gets replaced rather than extended.
15//!
16//! # Why it refuses so much
17//!
18//! A spec grants capabilities. Every accepted-but-misunderstood construct is a
19//! job running under permissions nobody wrote down, so anything not fully
20//! understood is an error:
21//!
22//! - An unknown key is rejected rather than skipped. Silently ignoring one is
23//! how a misspelled `capabilities` becomes a spec with no capabilities that
24//! still runs — and looks fine.
25//! - An unsupported model kind or capability kind is rejected by name, rather
26//! than being treated as the nearest supported thing.
27//!
28//! Being liberal in what it accepts would be exactly the wrong instinct here.
29
30use std::path::PathBuf;
31use thiserror::Error;
32
33/// Where a job's model comes from.
34///
35/// Deliberately *not* an enum of known providers. Inference can come from a
36/// local Ollama, an OpenAI-compatible HTTP endpoint, an embedded llama.cpp, or
37/// something not thought of yet, and this crate has no business knowing which
38/// of those exist — it parses job descriptions.
39///
40/// So a model reference is a provider name and a target, and resolving one into
41/// something that can actually generate is the host's job, via its backend
42/// registry. Adding a provider therefore touches neither this type nor the
43/// parser: an unknown provider is a resolution error naming what *is*
44/// available, not a syntax error.
45///
46/// In a spec this is written `model = Provider "target"`:
47///
48/// ```text
49/// model = Ollama "llama3.2:1b"; // a local Ollama
50/// model = OpenAi "http://host/v1#gpt-4"; // an OpenAI-compatible endpoint
51/// model = Path "./models/qwen.gguf"; // a local file, for embedded runtimes
52/// ```
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ModelRef {
55 /// Which backend should serve this, lowercased — `ollama`, `path`, `stub`.
56 ///
57 /// Lowercased at parse time so that `Ollama` and `OLLAMA` name the same
58 /// provider; a spec should not fail over capitalisation.
59 pub provider: String,
60 /// What to ask that backend for. Its meaning belongs entirely to the
61 /// provider: a model tag for Ollama, a filesystem path for an embedded
62 /// runtime, a URL for an HTTP endpoint.
63 pub target: String,
64}
65
66impl ModelRef {
67 /// Construct a reference directly, mostly for tests and for callers
68 /// building a spec without parsing one.
69 pub fn new(provider: impl Into<String>, target: impl Into<String>) -> Self {
70 Self {
71 provider: provider.into().to_lowercase(),
72 target: target.into(),
73 }
74 }
75}
76
77impl std::fmt::Display for ModelRef {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 write!(f, "{}:{}", self.provider, self.target)
80 }
81}
82
83/// How a job's data may be handled.
84///
85/// This is discovery metadata, consumed by the agent harness — it is *not*
86/// enforcement. What actually gates file access is the capability list, checked
87/// by the host at runtime. The distinction matters: `data_policy` tells the
88/// calling *agent* to behave differently (pass paths, not contents), while
89/// capabilities tell the *sandbox* what it may touch.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum DataPolicy {
92 /// Content must not leave the machine; the agent should pass paths.
93 LocalOnly,
94 /// No special handling requested.
95 Any,
96}
97
98/// A parsed spec.
99#[derive(Debug, Clone, PartialEq)]
100pub struct Spec {
101 /// Job name, used to submit against it.
102 pub name: String,
103 /// Trigger conditions for a calling agent — when to use this, never how it
104 /// works. A description that summarises the workflow invites an agent to
105 /// act on the summary instead of reading the real contract.
106 pub description: String,
107 /// Which model serves this job's inference.
108 pub model: ModelRef,
109 /// Data-handling policy; see [`DataPolicy`].
110 pub data_policy: DataPolicy,
111 /// Directories this job may read beneath. Empty means none.
112 pub read_roots: Vec<PathBuf>,
113 /// The proc-block implementing the job.
114 pub block: PathBuf,
115}
116
117/// Why a spec was rejected.
118#[derive(Debug, Error, PartialEq, Eq)]
119pub enum SpecError {
120 /// A required key was absent.
121 #[error("missing required field `{0}`")]
122 MissingField(&'static str),
123 /// A key that this version does not understand.
124 #[error("unknown field `{0}`")]
125 UnknownField(String),
126 /// Structurally malformed input.
127 #[error("malformed spec: {0}")]
128 Malformed(String),
129 /// A capability kind that exists in the design but not in this build.
130 #[error("unsupported capability `{0}` (this build supports only `Read`)")]
131 UnsupportedCapability(String),
132}
133
134/// Strip surrounding double quotes, or explain that they were required.
135fn quoted(value: &str, field: &str) -> Result<String, SpecError> {
136 value
137 .trim()
138 .strip_prefix('"')
139 .and_then(|v| v.strip_suffix('"'))
140 .map(str::to_string)
141 .ok_or_else(|| SpecError::Malformed(format!("field `{field}` must be a quoted string")))
142}
143
144/// Parse the capability list: `[ Read "a", Read "b" ]`.
145fn capabilities(value: &str) -> Result<Vec<PathBuf>, SpecError> {
146 let inner = value
147 .trim()
148 .strip_prefix('[')
149 .and_then(|v| v.strip_suffix(']'))
150 .ok_or_else(|| SpecError::Malformed("capabilities must be a `[...]` list".into()))?;
151
152 inner
153 .split(',')
154 .map(str::trim)
155 .filter(|entry| !entry.is_empty())
156 .map(|entry| {
157 let rest = entry.strip_prefix("Read ").ok_or_else(|| {
158 // Name the offending kind rather than saying "invalid": the
159 // author needs to know which entry, and this list is one place
160 // a typo grants nothing while looking correct.
161 let kind = entry.split_whitespace().next().unwrap_or(entry);
162 SpecError::UnsupportedCapability(kind.to_string())
163 })?;
164 Ok(PathBuf::from(quoted(rest, "capabilities")?))
165 })
166 .collect()
167}
168
169/// Parse a spec.
170pub fn parse_spec(src: &str) -> Result<Spec, SpecError> {
171 let open = src
172 .find('{')
173 .ok_or_else(|| SpecError::Malformed("expected `{`".into()))?;
174 let close = src
175 .rfind('}')
176 .ok_or_else(|| SpecError::Malformed("expected `}`".into()))?;
177 if close < open {
178 return Err(SpecError::Malformed("`}` before `{`".into()));
179 }
180
181 let name = src[..open]
182 .trim()
183 .strip_prefix("spec")
184 .and_then(|header| header.split('=').next())
185 .map(str::trim)
186 .filter(|name| !name.is_empty())
187 .ok_or_else(|| SpecError::Malformed("expected `spec <name> = {`".into()))?
188 .to_string();
189
190 let (mut description, mut model, mut data_policy, mut read_roots, mut block) =
191 (None, None, None, None, None);
192
193 for statement in src[open + 1..close].split(';') {
194 let statement = statement.trim();
195 if statement.is_empty() {
196 continue;
197 }
198
199 let (key, value) = statement.split_once('=').ok_or_else(|| {
200 SpecError::Malformed(format!("expected `key = value` in `{statement}`"))
201 })?;
202 let value = value.trim();
203
204 match key.trim() {
205 "description" => description = Some(quoted(value, "description")?),
206 "block" => block = Some(PathBuf::from(quoted(value, "block")?)),
207 "capabilities" => read_roots = Some(capabilities(value)?),
208 "model" => {
209 // Any `Provider "target"` parses. Whether that provider exists
210 // is the host's question, not this parser's — see `ModelRef`.
211 let (provider, rest) = value.split_once(char::is_whitespace).ok_or_else(|| {
212 SpecError::Malformed(
213 r#"model needs a provider and a target, as in `Ollama "llama3.2:1b"`"#
214 .into(),
215 )
216 })?;
217 if provider.is_empty() || !provider.chars().all(|c| c.is_alphanumeric() || c == '_')
218 {
219 return Err(SpecError::Malformed(format!(
220 "`{provider}` is not a valid model provider name"
221 )));
222 }
223 model = Some(ModelRef::new(provider, quoted(rest, "model")?));
224 }
225 "data_policy" => {
226 data_policy = Some(match value {
227 "Local_only" => DataPolicy::LocalOnly,
228 "Any" => DataPolicy::Any,
229 other => {
230 return Err(SpecError::Malformed(format!(
231 "unknown data_policy `{other}`"
232 )))
233 }
234 })
235 }
236 other => return Err(SpecError::UnknownField(other.to_string())),
237 }
238 }
239
240 Ok(Spec {
241 name,
242 description: description.ok_or(SpecError::MissingField("description"))?,
243 model: model.ok_or(SpecError::MissingField("model"))?,
244 data_policy: data_policy.ok_or(SpecError::MissingField("data_policy"))?,
245 read_roots: read_roots.ok_or(SpecError::MissingField("capabilities"))?,
246 block: block.ok_or(SpecError::MissingField("block"))?,
247 })
248}