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//! # 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}