Skip to main content

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    /// Where to write a medallion warehouse of this job's results, if
134    /// anywhere.
135    ///
136    /// Opt-in, and *alongside* the JSONL projection rather than instead of
137    /// it: downstream nodes read `results_path` with `open`/`slice`, so
138    /// replacing it would break every existing spec. A job that just wants an
139    /// answer pays nothing for a warehouse it never asked for.
140    ///
141    /// Relative paths resolve against the spec's own directory, like every
142    /// other path in a spec.
143    pub warehouse: Option<PathBuf>,
144    /// The proc-blocks implementing the job, as a graph of nodes.
145    ///
146    /// Each node's declared input is typechecked against the nodes feeding
147    /// it before anything runs. `block = "...";` is sugar for a one-node
148    /// graph — see [`crate::graph::NodeGraph::single`].
149    pub nodes: crate::graph::NodeGraph,
150    /// Conditional dispatch: which branch target fires for each labeled
151    /// route a branching node produces. Empty when the spec has none.
152    pub branches: crate::graph::Branches,
153}
154
155/// Why a spec was rejected.
156#[derive(Debug, Error, PartialEq, Eq)]
157pub enum SpecError {
158    /// A required key was absent.
159    #[error("missing required field `{0}`")]
160    MissingField(&'static str),
161    /// A key that this version does not understand.
162    #[error("unknown field `{0}`")]
163    UnknownField(String),
164    /// Structurally malformed input.
165    #[error("malformed spec: {0}")]
166    Malformed(String),
167    /// A capability kind that exists in the design but not in this build.
168    #[error("unsupported capability `{0}` (supported: `Read`, `Fetch`)")]
169    UnsupportedCapability(String),
170}
171
172use crate::lex::{lex, Tok, Token};
173
174/// Whether `root` contains `candidate`, comparing on meaningful path
175/// components only.
176///
177/// Not `Path::starts_with`: a leading `./` is a real `Component::CurDir`, so
178/// `Path::new("./corpus/m.jsonl").starts_with("corpus")` is `false` — and a
179/// spec author writing `capabilities = [ Read "corpus" ]` beside
180/// `over = "./corpus/m.jsonl"` has no reason to expect one spelling of the
181/// same directory to be rejected.
182fn path_covers(root: &Path, candidate: &Path) -> bool {
183    fn significant(p: &Path) -> Vec<std::ffi::OsString> {
184        p.components()
185            .filter(|c| !matches!(c, std::path::Component::CurDir))
186            .map(|c| c.as_os_str().to_os_string())
187            .collect()
188    }
189    candidate_starts_with(&significant(candidate), &significant(root))
190}
191
192fn candidate_starts_with(candidate: &[std::ffi::OsString], root: &[std::ffi::OsString]) -> bool {
193    candidate.len() >= root.len() && candidate[..root.len()] == *root
194}
195
196impl Spec {
197    /// Refuse a spec whose host-read paths sit outside its granted roots.
198    ///
199    /// Fan-out manifests and acceptance schemas are read by the *host*,
200    /// which is not sandboxed — so nothing would otherwise stop either
201    /// reading a path the spec never granted. Requiring them inside a
202    /// declared `Read` root keeps the capability list a truthful description
203    /// of everything the job touches, which is the property the whole
204    /// capability model rests on.
205    ///
206    /// Call this **after** resolving `read_roots`, `over` and schema paths
207    /// against the spec's directory, so both sides are absolute and
208    /// canonical. Comparing them as written cannot work: a spec that grants
209    /// an absolute root and names a relative manifest is entirely ordinary,
210    /// and lexically a relative path never starts with an absolute one.
211    pub fn validate_host_read_paths(&self) -> Result<(), SpecError> {
212        for (name, node) in &self.nodes.nodes {
213            if let Some(manifest) = &node.over {
214                if !self
215                    .read_roots
216                    .iter()
217                    .any(|root| path_covers(root, manifest))
218                {
219                    return Err(SpecError::Malformed(format!(
220                        "node `{name}`'s manifest {} is outside every path granted by \
221                         `capabilities` — add a `Read` covering it. Granted: {}",
222                        manifest.display(),
223                        roots_for_error(&self.read_roots)
224                    )));
225                }
226            }
227            for check in &node.accept {
228                if let crate::graph::AcceptCheck::Schema(schema) = check {
229                    if !self.read_roots.iter().any(|root| path_covers(root, schema)) {
230                        return Err(SpecError::Malformed(format!(
231                            "node `{name}`'s accept schema {} is outside every path granted \
232                             by `capabilities` — add a `Read` covering it. Granted: {}",
233                            schema.display(),
234                            roots_for_error(&self.read_roots)
235                        )));
236                    }
237                }
238            }
239        }
240        Ok(())
241    }
242}
243
244/// Granted roots, for an error message.
245///
246/// Listed because the failure is nearly always a path that looks right: the
247/// grant and the target differ by a symlink, or by one being relative. Naming
248/// what *was* granted turns a guess into a comparison.
249fn roots_for_error(roots: &[PathBuf]) -> String {
250    if roots.is_empty() {
251        return "(nothing)".to_string();
252    }
253    roots
254        .iter()
255        .map(|r| r.display().to_string())
256        .collect::<Vec<_>>()
257        .join(", ")
258}
259
260/// Parse a spec.
261///
262/// Recursive descent over tokens, not splitting on punctuation — see
263/// [`crate::lex`] for why that distinction is load-bearing rather than
264/// stylistic.
265pub fn parse_spec(src: &str) -> Result<Spec, SpecError> {
266    let tokens = lex(src).map_err(|e| SpecError::Malformed(e.to_string()))?;
267    Parser {
268        tokens: &tokens,
269        at: 0,
270    }
271    .spec()
272}
273
274struct Parser<'a> {
275    tokens: &'a [Token],
276    at: usize,
277}
278
279impl<'a> Parser<'a> {
280    fn peek(&self) -> Option<&'a Tok> {
281        self.tokens.get(self.at).map(|t| &t.tok)
282    }
283
284    /// Describe where the parser is, for an error message.
285    fn here(&self) -> String {
286        match self.tokens.get(self.at) {
287            Some(t) => format!("{} at {}", t.tok.describe(), t.span),
288            None => "end of input".into(),
289        }
290    }
291
292    fn advance(&mut self) -> Option<&'a Token> {
293        let t = self.tokens.get(self.at);
294        if t.is_some() {
295            self.at += 1;
296        }
297        t
298    }
299
300    fn expect(&mut self, want: &Tok) -> Result<(), SpecError> {
301        match self.peek() {
302            Some(got) if got == want => {
303                self.at += 1;
304                Ok(())
305            }
306            _ => Err(SpecError::Malformed(format!(
307                "expected {}, found {}",
308                want.describe(),
309                self.here()
310            ))),
311        }
312    }
313
314    fn ident(&mut self) -> Result<String, SpecError> {
315        match self.advance().map(|t| &t.tok) {
316            Some(Tok::Ident(name)) => Ok(name.clone()),
317            _ => {
318                self.at = self.at.saturating_sub(1);
319                Err(SpecError::Malformed(format!(
320                    "expected a name, found {}",
321                    self.here()
322                )))
323            }
324        }
325    }
326
327    fn string(&mut self, field: &str) -> Result<String, SpecError> {
328        match self.advance().map(|t| &t.tok) {
329            Some(Tok::Str(value)) => Ok(value.clone()),
330            _ => {
331                self.at = self.at.saturating_sub(1);
332                Err(SpecError::Malformed(format!(
333                    "field `{field}` must be a quoted string, found {}",
334                    self.here()
335                )))
336            }
337        }
338    }
339
340    /// `spec NAME = { field* }`
341    fn spec(&mut self) -> Result<Spec, SpecError> {
342        match self.ident()?.as_str() {
343            "spec" => {}
344            other => {
345                return Err(SpecError::Malformed(format!(
346                    "a spec file starts with `spec`, found `{other}`"
347                )))
348            }
349        }
350        let name = self.ident()?;
351        self.expect(&Tok::Equals)?;
352        self.expect(&Tok::OpenBrace)?;
353
354        let mut fetch_prefixes: Vec<String> = Vec::new();
355        let mut embedding_model: Option<ModelRef> = None;
356        let mut warehouse: Option<PathBuf> = None;
357        let (mut description, mut model, mut data_policy, mut read_roots, mut nodes, mut branches) =
358            (None, None, None, None, None, None);
359
360        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
361            let key = self.ident()?;
362            self.expect(&Tok::Equals)?;
363
364            match key.as_str() {
365                "description" => description = Some(self.string("description")?),
366                "block" => {
367                    nodes = Some(crate::graph::NodeGraph::single(PathBuf::from(
368                        self.string("block")?,
369                    )))
370                }
371                "nodes" => {
372                    let (g, new_at) = crate::graph::GraphParser {
373                        tokens: self.tokens,
374                        at: self.at,
375                    }
376                    .node_graph()?;
377                    self.at = new_at; // advance Parser's own cursor past what GraphParser consumed
378                    nodes = Some(g);
379                }
380                "branches" => {
381                    let (b, new_at) = crate::graph::GraphParser {
382                        tokens: self.tokens,
383                        at: self.at,
384                    }
385                    .branches()?;
386                    self.at = new_at;
387                    branches = Some(b);
388                }
389                "embedding_model" => embedding_model = Some(self.model()?),
390                "warehouse" => warehouse = Some(PathBuf::from(self.string("warehouse")?)),
391                "capabilities" => {
392                    let (roots, fetch) = self.capabilities()?;
393                    read_roots = Some(roots);
394                    fetch_prefixes = fetch;
395                }
396                "model" => model = Some(self.model()?),
397                "data_policy" => {
398                    data_policy = Some(match self.ident()?.as_str() {
399                        "Local_only" => DataPolicy::LocalOnly,
400                        "Any" => DataPolicy::Any,
401                        other => {
402                            return Err(SpecError::Malformed(format!(
403                                "unknown data_policy `{other}`"
404                            )))
405                        }
406                    })
407                }
408                other => return Err(SpecError::UnknownField(other.to_string())),
409            }
410
411            // A trailing semicolon is conventional but not required — and,
412            // unlike before, one *inside* a string is just a character.
413            if self.peek() == Some(&Tok::Semicolon) {
414                self.at += 1;
415            }
416        }
417        self.expect(&Tok::CloseBrace)?;
418
419        let read_roots = read_roots.ok_or(SpecError::MissingField("capabilities"))?;
420        let nodes = nodes.ok_or(SpecError::MissingField("block"))?;
421
422        // The equivalent check on manifests and acceptance schemas lives in
423        // `validate_host_read_paths`, called once these paths have been
424        // resolved against the spec's directory. It cannot be done here: a
425        // spec may grant an absolute root and name a relative manifest, and
426        // comparing those as written is not merely imprecise but *always*
427        // wrong — a relative path can never begin with an absolute one, so
428        // every such spec was rejected regardless of where the file actually
429        // sat.
430
431        Ok(Spec {
432            name,
433            description: description.ok_or(SpecError::MissingField("description"))?,
434            model: model.ok_or(SpecError::MissingField("model"))?,
435            data_policy: data_policy.ok_or(SpecError::MissingField("data_policy"))?,
436            read_roots,
437            fetch_prefixes,
438            embedding_model,
439            warehouse,
440            nodes,
441            branches: branches.unwrap_or_default(),
442        })
443    }
444
445    /// `Provider "target"`
446    fn model(&mut self) -> Result<ModelRef, SpecError> {
447        let provider = self.ident()?;
448        if provider.is_empty() || !provider.chars().all(|c| c.is_alphanumeric() || c == '_') {
449            return Err(SpecError::Malformed(format!(
450                "`{provider}` is not a valid model provider name"
451            )));
452        }
453        Ok(ModelRef::new(provider, self.string("model")?))
454    }
455
456    /// `[ Read "a", Fetch "https://example.org/" ]`
457    ///
458    /// Returns the read roots and the fetch prefixes separately: they grant
459    /// different things and are checked by different code, and collapsing
460    /// them into one list would make "what may this job reach" require
461    /// reading the strings to find out.
462    fn capabilities(&mut self) -> Result<(Vec<PathBuf>, Vec<String>), SpecError> {
463        let (mut roots, mut fetch) = (Vec::new(), Vec::new());
464        self.expect(&Tok::OpenBracket)?;
465        while self.peek() != Some(&Tok::CloseBracket) {
466            let kind = self.ident()?;
467            match kind.as_str() {
468                "Read" => roots.push(PathBuf::from(self.string("capabilities")?)),
469                "Fetch" => {
470                    let prefix = self.string("capabilities")?;
471                    // A prefix that is not a URL cannot match anything, so it
472                    // is an authoring mistake worth catching now rather than
473                    // as a puzzling denial at runtime.
474                    if !prefix.starts_with("http://") && !prefix.starts_with("https://") {
475                        return Err(SpecError::Malformed(format!(
476                            "`Fetch {prefix:?}` is not a URL prefix — write it as \
477                             `Fetch \"https://host/path\"`. A fetch grant covers every URL \
478                             beginning with the string given."
479                        )));
480                    }
481                    fetch.push(prefix);
482                }
483                other => return Err(SpecError::UnsupportedCapability(other.to_string())),
484            }
485            if self.peek() == Some(&Tok::Comma) {
486                self.at += 1;
487            } else {
488                break;
489            }
490        }
491        self.expect(&Tok::CloseBracket)?;
492        Ok((roots, fetch))
493    }
494}