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::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)]
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    /// The proc-blocks implementing the job, as a graph of nodes.
119    ///
120    /// Each node's declared input is typechecked against the nodes feeding
121    /// it before anything runs. `block = "...";` is sugar for a one-node
122    /// graph — see [`crate::graph::NodeGraph::single`].
123    pub nodes: crate::graph::NodeGraph,
124    /// Conditional dispatch: which branch target fires for each labeled
125    /// route a branching node produces. Empty when the spec has none.
126    pub branches: crate::graph::Branches,
127}
128
129/// Why a spec was rejected.
130#[derive(Debug, Error, PartialEq, Eq)]
131pub enum SpecError {
132    /// A required key was absent.
133    #[error("missing required field `{0}`")]
134    MissingField(&'static str),
135    /// A key that this version does not understand.
136    #[error("unknown field `{0}`")]
137    UnknownField(String),
138    /// Structurally malformed input.
139    #[error("malformed spec: {0}")]
140    Malformed(String),
141    /// A capability kind that exists in the design but not in this build.
142    #[error("unsupported capability `{0}` (this build supports only `Read`)")]
143    UnsupportedCapability(String),
144}
145
146use crate::lex::{lex, Tok, Token};
147
148/// Parse a spec.
149///
150/// Recursive descent over tokens, not splitting on punctuation — see
151/// [`crate::lex`] for why that distinction is load-bearing rather than
152/// stylistic.
153pub fn parse_spec(src: &str) -> Result<Spec, SpecError> {
154    let tokens = lex(src).map_err(|e| SpecError::Malformed(e.to_string()))?;
155    Parser {
156        tokens: &tokens,
157        at: 0,
158    }
159    .spec()
160}
161
162struct Parser<'a> {
163    tokens: &'a [Token],
164    at: usize,
165}
166
167impl<'a> Parser<'a> {
168    fn peek(&self) -> Option<&'a Tok> {
169        self.tokens.get(self.at).map(|t| &t.tok)
170    }
171
172    /// Describe where the parser is, for an error message.
173    fn here(&self) -> String {
174        match self.tokens.get(self.at) {
175            Some(t) => format!("{} at {}", t.tok.describe(), t.span),
176            None => "end of input".into(),
177        }
178    }
179
180    fn advance(&mut self) -> Option<&'a Token> {
181        let t = self.tokens.get(self.at);
182        if t.is_some() {
183            self.at += 1;
184        }
185        t
186    }
187
188    fn expect(&mut self, want: &Tok) -> Result<(), SpecError> {
189        match self.peek() {
190            Some(got) if got == want => {
191                self.at += 1;
192                Ok(())
193            }
194            _ => Err(SpecError::Malformed(format!(
195                "expected {}, found {}",
196                want.describe(),
197                self.here()
198            ))),
199        }
200    }
201
202    fn ident(&mut self) -> Result<String, SpecError> {
203        match self.advance().map(|t| &t.tok) {
204            Some(Tok::Ident(name)) => Ok(name.clone()),
205            _ => {
206                self.at = self.at.saturating_sub(1);
207                Err(SpecError::Malformed(format!(
208                    "expected a name, found {}",
209                    self.here()
210                )))
211            }
212        }
213    }
214
215    fn string(&mut self, field: &str) -> Result<String, SpecError> {
216        match self.advance().map(|t| &t.tok) {
217            Some(Tok::Str(value)) => Ok(value.clone()),
218            _ => {
219                self.at = self.at.saturating_sub(1);
220                Err(SpecError::Malformed(format!(
221                    "field `{field}` must be a quoted string, found {}",
222                    self.here()
223                )))
224            }
225        }
226    }
227
228    /// `spec NAME = { field* }`
229    fn spec(&mut self) -> Result<Spec, SpecError> {
230        match self.ident()?.as_str() {
231            "spec" => {}
232            other => {
233                return Err(SpecError::Malformed(format!(
234                    "a spec file starts with `spec`, found `{other}`"
235                )))
236            }
237        }
238        let name = self.ident()?;
239        self.expect(&Tok::Equals)?;
240        self.expect(&Tok::OpenBrace)?;
241
242        let (mut description, mut model, mut data_policy, mut read_roots, mut nodes, mut branches) =
243            (None, None, None, None, None, None);
244
245        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
246            let key = self.ident()?;
247            self.expect(&Tok::Equals)?;
248
249            match key.as_str() {
250                "description" => description = Some(self.string("description")?),
251                "block" => {
252                    nodes = Some(crate::graph::NodeGraph::single(PathBuf::from(
253                        self.string("block")?,
254                    )))
255                }
256                "nodes" => {
257                    let (g, new_at) = crate::graph::GraphParser {
258                        tokens: self.tokens,
259                        at: self.at,
260                    }
261                    .node_graph()?;
262                    self.at = new_at; // advance Parser's own cursor past what GraphParser consumed
263                    nodes = Some(g);
264                }
265                "branches" => {
266                    let (b, new_at) = crate::graph::GraphParser {
267                        tokens: self.tokens,
268                        at: self.at,
269                    }
270                    .branches()?;
271                    self.at = new_at;
272                    branches = Some(b);
273                }
274                "capabilities" => read_roots = Some(self.capabilities()?),
275                "model" => model = Some(self.model()?),
276                "data_policy" => {
277                    data_policy = Some(match self.ident()?.as_str() {
278                        "Local_only" => DataPolicy::LocalOnly,
279                        "Any" => DataPolicy::Any,
280                        other => {
281                            return Err(SpecError::Malformed(format!(
282                                "unknown data_policy `{other}`"
283                            )))
284                        }
285                    })
286                }
287                other => return Err(SpecError::UnknownField(other.to_string())),
288            }
289
290            // A trailing semicolon is conventional but not required — and,
291            // unlike before, one *inside* a string is just a character.
292            if self.peek() == Some(&Tok::Semicolon) {
293                self.at += 1;
294            }
295        }
296        self.expect(&Tok::CloseBrace)?;
297
298        Ok(Spec {
299            name,
300            description: description.ok_or(SpecError::MissingField("description"))?,
301            model: model.ok_or(SpecError::MissingField("model"))?,
302            data_policy: data_policy.ok_or(SpecError::MissingField("data_policy"))?,
303            read_roots: read_roots.ok_or(SpecError::MissingField("capabilities"))?,
304            nodes: nodes.ok_or(SpecError::MissingField("block"))?,
305            branches: branches.unwrap_or_default(),
306        })
307    }
308
309    /// `Provider "target"`
310    fn model(&mut self) -> Result<ModelRef, SpecError> {
311        let provider = self.ident()?;
312        if provider.is_empty() || !provider.chars().all(|c| c.is_alphanumeric() || c == '_') {
313            return Err(SpecError::Malformed(format!(
314                "`{provider}` is not a valid model provider name"
315            )));
316        }
317        Ok(ModelRef::new(provider, self.string("model")?))
318    }
319
320    /// `[ Read "a", Read "b" ]`
321    fn capabilities(&mut self) -> Result<Vec<PathBuf>, SpecError> {
322        let mut roots = Vec::new();
323        self.expect(&Tok::OpenBracket)?;
324        while self.peek() != Some(&Tok::CloseBracket) {
325            let kind = self.ident()?;
326            if kind != "Read" {
327                return Err(SpecError::UnsupportedCapability(kind));
328            }
329            roots.push(PathBuf::from(self.string("capabilities")?));
330            if self.peek() == Some(&Tok::Comma) {
331                self.at += 1;
332            } else {
333                break;
334            }
335        }
336        self.expect(&Tok::CloseBracket)?;
337        Ok(roots)
338    }
339}