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//! The `nodes = { ... }` / `branches = { ... }` graph syntax added since is not
17//! that pipeline syntax: it is still the same flat `key = value` grammar, just
18//! shaped to describe a graph, with no expressions or inference beyond the
19//! `node.out` reference syntax itself.
20//!
21//! # Why it refuses so much
22//!
23//! A spec grants capabilities. Every accepted-but-misunderstood construct is a
24//! job running under permissions nobody wrote down, so anything not fully
25//! understood is an error:
26//!
27//! - An unknown key is rejected rather than skipped. Silently ignoring one is
28//! how a misspelled `capabilities` becomes a spec with no capabilities that
29//! still runs — and looks fine.
30//! - An unsupported model kind or capability kind is rejected by name, rather
31//! than being treated as the nearest supported thing.
32//!
33//! Being liberal in what it accepts would be exactly the wrong instinct here.
34
35use std::path::{Path, PathBuf};
36use thiserror::Error;
37
38/// Where a job's model comes from.
39///
40/// Deliberately *not* an enum of known providers. Inference can come from a
41/// local Ollama, an OpenAI-compatible HTTP endpoint, an embedded llama.cpp, or
42/// something not thought of yet, and this crate has no business knowing which
43/// of those exist — it parses job descriptions.
44///
45/// So a model reference is a provider name and a target, and resolving one into
46/// something that can actually generate is the host's job, via its backend
47/// registry. Adding a provider therefore touches neither this type nor the
48/// parser: an unknown provider is a resolution error naming what *is*
49/// available, not a syntax error.
50///
51/// In a spec this is written `model = Provider "target"`:
52///
53/// ```text
54/// model = Ollama "llama3.2:1b"; // a local Ollama
55/// model = OpenAi "http://host/v1#gpt-4"; // an OpenAI-compatible endpoint
56/// model = Path "./models/qwen.gguf"; // a local file, for embedded runtimes
57/// ```
58#[derive(Debug, Clone, PartialEq, Eq, Hash)]
59pub struct ModelRef {
60 /// Which backend should serve this, lowercased — `ollama`, `path`, `stub`.
61 ///
62 /// Lowercased at parse time so that `Ollama` and `OLLAMA` name the same
63 /// provider; a spec should not fail over capitalisation.
64 pub provider: String,
65 /// What to ask that backend for. Its meaning belongs entirely to the
66 /// provider: a model tag for Ollama, a filesystem path for an embedded
67 /// runtime, a URL for an HTTP endpoint.
68 pub target: String,
69}
70
71impl ModelRef {
72 /// Construct a reference directly, mostly for tests and for callers
73 /// building a spec without parsing one.
74 pub fn new(provider: impl Into<String>, target: impl Into<String>) -> Self {
75 Self {
76 provider: provider.into().to_lowercase(),
77 target: target.into(),
78 }
79 }
80}
81
82impl std::fmt::Display for ModelRef {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 write!(f, "{}:{}", self.provider, self.target)
85 }
86}
87
88/// How a job's data may be handled.
89///
90/// This is discovery metadata, consumed by the agent harness — it is *not*
91/// enforcement. What actually gates file access is the capability list, checked
92/// by the host at runtime. The distinction matters: `data_policy` tells the
93/// calling *agent* to behave differently (pass paths, not contents), while
94/// capabilities tell the *sandbox* what it may touch.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum DataPolicy {
97 /// Content must not leave the machine; the agent should pass paths.
98 LocalOnly,
99 /// No special handling requested.
100 Any,
101}
102
103/// A parsed spec.
104#[derive(Debug, Clone, PartialEq)]
105pub struct Spec {
106 /// Job name, used to submit against it.
107 pub name: String,
108 /// Trigger conditions for a calling agent — when to use this, never how it
109 /// works. A description that summarises the workflow invites an agent to
110 /// act on the summary instead of reading the real contract.
111 pub description: String,
112 /// Which model serves this job's inference.
113 pub model: ModelRef,
114 /// Data-handling policy; see [`DataPolicy`].
115 pub data_policy: DataPolicy,
116 /// Directories this job may read beneath. Empty means none.
117 pub read_roots: Vec<PathBuf>,
118 /// URL prefixes this job may fetch, from `Fetch "https://host/path"`.
119 ///
120 /// An allowlist by prefix, exactly as `read_roots` is for the
121 /// filesystem: a corpus that lives on the web is still a corpus, and the
122 /// capability list has to describe reaching it or it stops being a
123 /// truthful account of what the job touches. Empty means the job cannot
124 /// fetch anything, which is the default.
125 pub fetch_prefixes: Vec<String>,
126 /// The model serving `embed`, if the spec declares one.
127 ///
128 /// Separate from `model` on purpose: an embedding model and a chat model
129 /// are different things, and reusing one field would let a spec ask a
130 /// chat model for vectors — which either fails or returns something
131 /// shaped like an embedding that is not one.
132 pub embedding_model: Option<ModelRef>,
133 /// The proc-blocks implementing the job, as a graph of nodes.
134 ///
135 /// Each node's declared input is typechecked against the nodes feeding
136 /// it before anything runs. `block = "...";` is sugar for a one-node
137 /// graph — see [`crate::graph::NodeGraph::single`].
138 pub nodes: crate::graph::NodeGraph,
139 /// Conditional dispatch: which branch target fires for each labeled
140 /// route a branching node produces. Empty when the spec has none.
141 pub branches: crate::graph::Branches,
142}
143
144/// Why a spec was rejected.
145#[derive(Debug, Error, PartialEq, Eq)]
146pub enum SpecError {
147 /// A required key was absent.
148 #[error("missing required field `{0}`")]
149 MissingField(&'static str),
150 /// A key that this version does not understand.
151 #[error("unknown field `{0}`")]
152 UnknownField(String),
153 /// Structurally malformed input.
154 #[error("malformed spec: {0}")]
155 Malformed(String),
156 /// A capability kind that exists in the design but not in this build.
157 #[error("unsupported capability `{0}` (supported: `Read`, `Fetch`)")]
158 UnsupportedCapability(String),
159}
160
161use crate::lex::{lex, Tok, Token};
162
163/// Whether `root` contains `candidate`, comparing on meaningful path
164/// components only.
165///
166/// Not `Path::starts_with`: a leading `./` is a real `Component::CurDir`, so
167/// `Path::new("./corpus/m.jsonl").starts_with("corpus")` is `false` — and a
168/// spec author writing `capabilities = [ Read "corpus" ]` beside
169/// `over = "./corpus/m.jsonl"` has no reason to expect one spelling of the
170/// same directory to be rejected.
171fn path_covers(root: &Path, candidate: &Path) -> bool {
172 fn significant(p: &Path) -> Vec<std::ffi::OsString> {
173 p.components()
174 .filter(|c| !matches!(c, std::path::Component::CurDir))
175 .map(|c| c.as_os_str().to_os_string())
176 .collect()
177 }
178 candidate_starts_with(&significant(candidate), &significant(root))
179}
180
181fn candidate_starts_with(candidate: &[std::ffi::OsString], root: &[std::ffi::OsString]) -> bool {
182 candidate.len() >= root.len() && candidate[..root.len()] == *root
183}
184
185impl Spec {
186 /// Refuse a spec whose host-read paths sit outside its granted roots.
187 ///
188 /// Fan-out manifests and acceptance schemas are read by the *host*,
189 /// which is not sandboxed — so nothing would otherwise stop either
190 /// reading a path the spec never granted. Requiring them inside a
191 /// declared `Read` root keeps the capability list a truthful description
192 /// of everything the job touches, which is the property the whole
193 /// capability model rests on.
194 ///
195 /// Call this **after** resolving `read_roots`, `over` and schema paths
196 /// against the spec's directory, so both sides are absolute and
197 /// canonical. Comparing them as written cannot work: a spec that grants
198 /// an absolute root and names a relative manifest is entirely ordinary,
199 /// and lexically a relative path never starts with an absolute one.
200 pub fn validate_host_read_paths(&self) -> Result<(), SpecError> {
201 for (name, node) in &self.nodes.nodes {
202 if let Some(manifest) = &node.over {
203 if !self
204 .read_roots
205 .iter()
206 .any(|root| path_covers(root, manifest))
207 {
208 return Err(SpecError::Malformed(format!(
209 "node `{name}`'s manifest {} is outside every path granted by \
210 `capabilities` — add a `Read` covering it. Granted: {}",
211 manifest.display(),
212 roots_for_error(&self.read_roots)
213 )));
214 }
215 }
216 for check in &node.accept {
217 if let crate::graph::AcceptCheck::Schema(schema) = check {
218 if !self.read_roots.iter().any(|root| path_covers(root, schema)) {
219 return Err(SpecError::Malformed(format!(
220 "node `{name}`'s accept schema {} is outside every path granted \
221 by `capabilities` — add a `Read` covering it. Granted: {}",
222 schema.display(),
223 roots_for_error(&self.read_roots)
224 )));
225 }
226 }
227 }
228 }
229 Ok(())
230 }
231}
232
233/// Granted roots, for an error message.
234///
235/// Listed because the failure is nearly always a path that looks right: the
236/// grant and the target differ by a symlink, or by one being relative. Naming
237/// what *was* granted turns a guess into a comparison.
238fn roots_for_error(roots: &[PathBuf]) -> String {
239 if roots.is_empty() {
240 return "(nothing)".to_string();
241 }
242 roots
243 .iter()
244 .map(|r| r.display().to_string())
245 .collect::<Vec<_>>()
246 .join(", ")
247}
248
249/// Parse a spec.
250///
251/// Recursive descent over tokens, not splitting on punctuation — see
252/// [`crate::lex`] for why that distinction is load-bearing rather than
253/// stylistic.
254pub fn parse_spec(src: &str) -> Result<Spec, SpecError> {
255 let tokens = lex(src).map_err(|e| SpecError::Malformed(e.to_string()))?;
256 Parser {
257 tokens: &tokens,
258 at: 0,
259 }
260 .spec()
261}
262
263struct Parser<'a> {
264 tokens: &'a [Token],
265 at: usize,
266}
267
268impl<'a> Parser<'a> {
269 fn peek(&self) -> Option<&'a Tok> {
270 self.tokens.get(self.at).map(|t| &t.tok)
271 }
272
273 /// Describe where the parser is, for an error message.
274 fn here(&self) -> String {
275 match self.tokens.get(self.at) {
276 Some(t) => format!("{} at {}", t.tok.describe(), t.span),
277 None => "end of input".into(),
278 }
279 }
280
281 fn advance(&mut self) -> Option<&'a Token> {
282 let t = self.tokens.get(self.at);
283 if t.is_some() {
284 self.at += 1;
285 }
286 t
287 }
288
289 fn expect(&mut self, want: &Tok) -> Result<(), SpecError> {
290 match self.peek() {
291 Some(got) if got == want => {
292 self.at += 1;
293 Ok(())
294 }
295 _ => Err(SpecError::Malformed(format!(
296 "expected {}, found {}",
297 want.describe(),
298 self.here()
299 ))),
300 }
301 }
302
303 fn ident(&mut self) -> Result<String, SpecError> {
304 match self.advance().map(|t| &t.tok) {
305 Some(Tok::Ident(name)) => Ok(name.clone()),
306 _ => {
307 self.at = self.at.saturating_sub(1);
308 Err(SpecError::Malformed(format!(
309 "expected a name, found {}",
310 self.here()
311 )))
312 }
313 }
314 }
315
316 fn string(&mut self, field: &str) -> Result<String, SpecError> {
317 match self.advance().map(|t| &t.tok) {
318 Some(Tok::Str(value)) => Ok(value.clone()),
319 _ => {
320 self.at = self.at.saturating_sub(1);
321 Err(SpecError::Malformed(format!(
322 "field `{field}` must be a quoted string, found {}",
323 self.here()
324 )))
325 }
326 }
327 }
328
329 /// `spec NAME = { field* }`
330 fn spec(&mut self) -> Result<Spec, SpecError> {
331 match self.ident()?.as_str() {
332 "spec" => {}
333 other => {
334 return Err(SpecError::Malformed(format!(
335 "a spec file starts with `spec`, found `{other}`"
336 )))
337 }
338 }
339 let name = self.ident()?;
340 self.expect(&Tok::Equals)?;
341 self.expect(&Tok::OpenBrace)?;
342
343 let mut fetch_prefixes: Vec<String> = Vec::new();
344 let mut embedding_model: Option<ModelRef> = None;
345 let (mut description, mut model, mut data_policy, mut read_roots, mut nodes, mut branches) =
346 (None, None, None, None, None, None);
347
348 while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
349 let key = self.ident()?;
350 self.expect(&Tok::Equals)?;
351
352 match key.as_str() {
353 "description" => description = Some(self.string("description")?),
354 "block" => {
355 nodes = Some(crate::graph::NodeGraph::single(PathBuf::from(
356 self.string("block")?,
357 )))
358 }
359 "nodes" => {
360 let (g, new_at) = crate::graph::GraphParser {
361 tokens: self.tokens,
362 at: self.at,
363 }
364 .node_graph()?;
365 self.at = new_at; // advance Parser's own cursor past what GraphParser consumed
366 nodes = Some(g);
367 }
368 "branches" => {
369 let (b, new_at) = crate::graph::GraphParser {
370 tokens: self.tokens,
371 at: self.at,
372 }
373 .branches()?;
374 self.at = new_at;
375 branches = Some(b);
376 }
377 "embedding_model" => embedding_model = Some(self.model()?),
378 "capabilities" => {
379 let (roots, fetch) = self.capabilities()?;
380 read_roots = Some(roots);
381 fetch_prefixes = fetch;
382 }
383 "model" => model = Some(self.model()?),
384 "data_policy" => {
385 data_policy = Some(match self.ident()?.as_str() {
386 "Local_only" => DataPolicy::LocalOnly,
387 "Any" => DataPolicy::Any,
388 other => {
389 return Err(SpecError::Malformed(format!(
390 "unknown data_policy `{other}`"
391 )))
392 }
393 })
394 }
395 other => return Err(SpecError::UnknownField(other.to_string())),
396 }
397
398 // A trailing semicolon is conventional but not required — and,
399 // unlike before, one *inside* a string is just a character.
400 if self.peek() == Some(&Tok::Semicolon) {
401 self.at += 1;
402 }
403 }
404 self.expect(&Tok::CloseBrace)?;
405
406 let read_roots = read_roots.ok_or(SpecError::MissingField("capabilities"))?;
407 let nodes = nodes.ok_or(SpecError::MissingField("block"))?;
408
409 // The equivalent check on manifests and acceptance schemas lives in
410 // `validate_host_read_paths`, called once these paths have been
411 // resolved against the spec's directory. It cannot be done here: a
412 // spec may grant an absolute root and name a relative manifest, and
413 // comparing those as written is not merely imprecise but *always*
414 // wrong — a relative path can never begin with an absolute one, so
415 // every such spec was rejected regardless of where the file actually
416 // sat.
417
418 Ok(Spec {
419 name,
420 description: description.ok_or(SpecError::MissingField("description"))?,
421 model: model.ok_or(SpecError::MissingField("model"))?,
422 data_policy: data_policy.ok_or(SpecError::MissingField("data_policy"))?,
423 read_roots,
424 fetch_prefixes,
425 embedding_model,
426 nodes,
427 branches: branches.unwrap_or_default(),
428 })
429 }
430
431 /// `Provider "target"`
432 fn model(&mut self) -> Result<ModelRef, SpecError> {
433 let provider = self.ident()?;
434 if provider.is_empty() || !provider.chars().all(|c| c.is_alphanumeric() || c == '_') {
435 return Err(SpecError::Malformed(format!(
436 "`{provider}` is not a valid model provider name"
437 )));
438 }
439 Ok(ModelRef::new(provider, self.string("model")?))
440 }
441
442 /// `[ Read "a", Fetch "https://example.org/" ]`
443 ///
444 /// Returns the read roots and the fetch prefixes separately: they grant
445 /// different things and are checked by different code, and collapsing
446 /// them into one list would make "what may this job reach" require
447 /// reading the strings to find out.
448 fn capabilities(&mut self) -> Result<(Vec<PathBuf>, Vec<String>), SpecError> {
449 let (mut roots, mut fetch) = (Vec::new(), Vec::new());
450 self.expect(&Tok::OpenBracket)?;
451 while self.peek() != Some(&Tok::CloseBracket) {
452 let kind = self.ident()?;
453 match kind.as_str() {
454 "Read" => roots.push(PathBuf::from(self.string("capabilities")?)),
455 "Fetch" => {
456 let prefix = self.string("capabilities")?;
457 // A prefix that is not a URL cannot match anything, so it
458 // is an authoring mistake worth catching now rather than
459 // as a puzzling denial at runtime.
460 if !prefix.starts_with("http://") && !prefix.starts_with("https://") {
461 return Err(SpecError::Malformed(format!(
462 "`Fetch {prefix:?}` is not a URL prefix — write it as \
463 `Fetch \"https://host/path\"`. A fetch grant covers every URL \
464 beginning with the string given."
465 )));
466 }
467 fetch.push(prefix);
468 }
469 other => return Err(SpecError::UnsupportedCapability(other.to_string())),
470 }
471 if self.peek() == Some(&Tok::Comma) {
472 self.at += 1;
473 } else {
474 break;
475 }
476 }
477 self.expect(&Tok::CloseBracket)?;
478 Ok((roots, fetch))
479 }
480}