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#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ModelRef {
36    /// A path on this machine, pre-provisioned by the operator.
37    Path(String),
38}
39
40/// How a job's data may be handled.
41///
42/// This is discovery metadata, consumed by the agent harness — it is *not*
43/// enforcement. What actually gates file access is the capability list, checked
44/// by the host at runtime. The distinction matters: `data_policy` tells the
45/// calling *agent* to behave differently (pass paths, not contents), while
46/// capabilities tell the *sandbox* what it may touch.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DataPolicy {
49    /// Content must not leave the machine; the agent should pass paths.
50    LocalOnly,
51    /// No special handling requested.
52    Any,
53}
54
55/// A parsed spec.
56#[derive(Debug, Clone, PartialEq)]
57pub struct Spec {
58    /// Job name, used to submit against it.
59    pub name: String,
60    /// Trigger conditions for a calling agent — when to use this, never how it
61    /// works. A description that summarises the workflow invites an agent to
62    /// act on the summary instead of reading the real contract.
63    pub description: String,
64    /// Which model serves this job's inference.
65    pub model: ModelRef,
66    /// Data-handling policy; see [`DataPolicy`].
67    pub data_policy: DataPolicy,
68    /// Directories this job may read beneath. Empty means none.
69    pub read_roots: Vec<PathBuf>,
70    /// The proc-block implementing the job.
71    pub block: PathBuf,
72}
73
74/// Why a spec was rejected.
75#[derive(Debug, Error, PartialEq, Eq)]
76pub enum SpecError {
77    /// A required key was absent.
78    #[error("missing required field `{0}`")]
79    MissingField(&'static str),
80    /// A key that this version does not understand.
81    #[error("unknown field `{0}`")]
82    UnknownField(String),
83    /// Structurally malformed input.
84    #[error("malformed spec: {0}")]
85    Malformed(String),
86    /// A model kind that exists in the design but not in this build.
87    #[error("unsupported model kind `{0}` (this build supports only `Path`)")]
88    UnsupportedModel(String),
89    /// A capability kind that exists in the design but not in this build.
90    #[error("unsupported capability `{0}` (this build supports only `Read`)")]
91    UnsupportedCapability(String),
92}
93
94/// Strip surrounding double quotes, or explain that they were required.
95fn quoted(value: &str, field: &str) -> Result<String, SpecError> {
96    value
97        .trim()
98        .strip_prefix('"')
99        .and_then(|v| v.strip_suffix('"'))
100        .map(str::to_string)
101        .ok_or_else(|| SpecError::Malformed(format!("field `{field}` must be a quoted string")))
102}
103
104/// Parse the capability list: `[ Read "a", Read "b" ]`.
105fn capabilities(value: &str) -> Result<Vec<PathBuf>, SpecError> {
106    let inner = value
107        .trim()
108        .strip_prefix('[')
109        .and_then(|v| v.strip_suffix(']'))
110        .ok_or_else(|| SpecError::Malformed("capabilities must be a `[...]` list".into()))?;
111
112    inner
113        .split(',')
114        .map(str::trim)
115        .filter(|entry| !entry.is_empty())
116        .map(|entry| {
117            let rest = entry.strip_prefix("Read ").ok_or_else(|| {
118                // Name the offending kind rather than saying "invalid": the
119                // author needs to know which entry, and this list is one place
120                // a typo grants nothing while looking correct.
121                let kind = entry.split_whitespace().next().unwrap_or(entry);
122                SpecError::UnsupportedCapability(kind.to_string())
123            })?;
124            Ok(PathBuf::from(quoted(rest, "capabilities")?))
125        })
126        .collect()
127}
128
129/// Parse a spec.
130pub fn parse_spec(src: &str) -> Result<Spec, SpecError> {
131    let open = src
132        .find('{')
133        .ok_or_else(|| SpecError::Malformed("expected `{`".into()))?;
134    let close = src
135        .rfind('}')
136        .ok_or_else(|| SpecError::Malformed("expected `}`".into()))?;
137    if close < open {
138        return Err(SpecError::Malformed("`}` before `{`".into()));
139    }
140
141    let name = src[..open]
142        .trim()
143        .strip_prefix("spec")
144        .and_then(|header| header.split('=').next())
145        .map(str::trim)
146        .filter(|name| !name.is_empty())
147        .ok_or_else(|| SpecError::Malformed("expected `spec <name> = {`".into()))?
148        .to_string();
149
150    let (mut description, mut model, mut data_policy, mut read_roots, mut block) =
151        (None, None, None, None, None);
152
153    for statement in src[open + 1..close].split(';') {
154        let statement = statement.trim();
155        if statement.is_empty() {
156            continue;
157        }
158
159        let (key, value) = statement.split_once('=').ok_or_else(|| {
160            SpecError::Malformed(format!("expected `key = value` in `{statement}`"))
161        })?;
162        let value = value.trim();
163
164        match key.trim() {
165            "description" => description = Some(quoted(value, "description")?),
166            "block" => block = Some(PathBuf::from(quoted(value, "block")?)),
167            "capabilities" => read_roots = Some(capabilities(value)?),
168            "model" => {
169                let (kind, rest) = value
170                    .split_once(char::is_whitespace)
171                    .ok_or_else(|| SpecError::Malformed("model needs a kind and a value".into()))?;
172                match kind {
173                    "Path" => model = Some(ModelRef::Path(quoted(rest, "model")?)),
174                    other => return Err(SpecError::UnsupportedModel(other.to_string())),
175                }
176            }
177            "data_policy" => {
178                data_policy = Some(match value {
179                    "Local_only" => DataPolicy::LocalOnly,
180                    "Any" => DataPolicy::Any,
181                    other => {
182                        return Err(SpecError::Malformed(format!(
183                            "unknown data_policy `{other}`"
184                        )))
185                    }
186                })
187            }
188            other => return Err(SpecError::UnknownField(other.to_string())),
189        }
190    }
191
192    Ok(Spec {
193        name,
194        description: description.ok_or(SpecError::MissingField("description"))?,
195        model: model.ok_or(SpecError::MissingField("model"))?,
196        data_policy: data_policy.ok_or(SpecError::MissingField("data_policy"))?,
197        read_roots: read_roots.ok_or(SpecError::MissingField("capabilities"))?,
198        block: block.ok_or(SpecError::MissingField("block"))?,
199    })
200}