Skip to main content

memstead_schema/
builtins.rs

1//! Embedded builtin schemas, baked into the binary via `include_dir!`.
2//!
3//! Every directory under `builtins/schemas/` is loaded as a first-class schema
4//! and registered in the default `SchemaRegistry`. Ships `default` (the legacy
5//! 10-knowledge-type bundle) plus domain-specific schemas (`ingest`,
6//! `planning`, `project`, `software`) that mems may pin via
7//! `schema = "<name>@<version>"` in their per-mem config.
8
9use std::sync::Arc;
10
11use include_dir::{Dir, include_dir};
12
13use crate::loader::{self, SchemaLoadError};
14use crate::schema::Schema;
15
16static BUILTIN_SCHEMAS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/builtins/schemas");
17
18/// Access to the embedded `builtins/schemas` directory.
19///
20/// Exposed so the source-collection path
21/// (`crate::source::collect_schema_source`) can lift raw YAML bytes out
22/// of the binary when a mem pins a builtin schema but has no
23/// workspace or cache copy.
24pub(crate) fn builtin_schemas_dir() -> &'static Dir<'static> {
25    &BUILTIN_SCHEMAS
26}
27
28/// Read a built-in schema package's optional `mem-template.json` —
29/// the `MemConfig` starter a client (`memstead mem create`, the
30/// planning skills) fills and passes through the `write_guidance`
31/// create-parameter. Returns the parsed JSON object when the package
32/// ships a template, `None` when it does not (`default`, `ingest`) or
33/// when `name` is not a built-in.
34///
35/// The template is opaque to the engine (schema-strictness D8): it is
36/// surfaced verbatim for the client to fill `<REQUIRED: …>`
37/// placeholders. Loaded from the embedded `builtins/schemas/` tree so a
38/// mem pinning a built-in resolves its template without a workspace
39/// or cache copy, mirroring [`builtin_schemas_dir`].
40pub fn builtin_mem_template(name: &str) -> Option<serde_json::Value> {
41    let file = BUILTIN_SCHEMAS.get_file(format!("{name}/mem-template.json").as_str())?;
42    serde_json::from_slice(file.contents()).ok()
43}
44
45/// One embedded built-in schema package: its identity plus every file
46/// it ships, addressed relative to the package directory and sorted by
47/// path. The raw-bytes view of the catalogue — [`load_builtin_schemas`]
48/// is the parsed view. Consumed by the retention guard
49/// (`tests/builtin_retention.rs`), which seals each shipped package's
50/// content hash in `builtins/MANIFEST.toml`: a shipped `(name,
51/// version)` must exist in every future binary with byte-identical
52/// content, so a rebuild can never strand a workspace pinning it.
53pub struct BuiltinPackage {
54    pub name: String,
55    pub version: String,
56    /// `(path-relative-to-package-dir, bytes)`, sorted by path.
57    pub files: Vec<(String, &'static [u8])>,
58}
59
60/// Enumerate every embedded built-in package with its raw file bytes.
61/// Identity comes from each package's `schema.yaml` (`name:` /
62/// `version:` keys); the directory name is organisational only.
63pub fn builtin_packages() -> Vec<BuiltinPackage> {
64    fn collect_files(dir: &Dir<'static>, root: &str, out: &mut Vec<(String, &'static [u8])>) {
65        for file in dir.files() {
66            let rel = file
67                .path()
68                .strip_prefix(root)
69                .unwrap_or(file.path())
70                .display()
71                .to_string();
72            out.push((rel, file.contents()));
73        }
74        for sub in dir.dirs() {
75            collect_files(sub, root, out);
76        }
77    }
78
79    let mut out = Vec::new();
80    for dir in BUILTIN_SCHEMAS.dirs() {
81        let root = dir.path().display().to_string();
82        let manifest = dir
83            .get_file(format!("{root}/schema.yaml").as_str())
84            .and_then(|f| f.contents_utf8());
85        let Some(manifest) = manifest else { continue };
86        let header: Option<(String, String)> =
87            serde_yaml_ng::from_str::<serde_yaml_ng::Value>(manifest)
88                .ok()
89                .and_then(|v| {
90                    let name = v.get("name")?.as_str()?.to_string();
91                    let version = v.get("version")?.as_str()?.to_string();
92                    Some((name, version))
93                });
94        let Some((name, version)) = header else {
95            continue;
96        };
97        let mut files = Vec::new();
98        collect_files(dir, &root, &mut files);
99        files.sort_by(|a, b| a.0.cmp(&b.0));
100        out.push(BuiltinPackage {
101            name,
102            version,
103            files,
104        });
105    }
106    out
107}
108
109/// One embedded built-in package by identity, or `None` when no
110/// built-in carries that `(name, version)`.
111pub fn builtin_package(name: &str, version: &str) -> Option<BuiltinPackage> {
112    builtin_packages()
113        .into_iter()
114        .find(|p| p.name == name && p.version == version)
115}
116
117/// The file name a package's README travels under.
118pub const PACKAGE_README_FILE: &str = "README.md";
119
120/// Render a package README for the package it ships in: every
121/// `<name>@<x.y.z>` reference to the package's OWN name is rewritten to
122/// the resolved `<name>@<version>`. References to other schemas, to
123/// names that merely contain this one (`my-default@1.0.0`), and bare
124/// names without a pin are left alone; the bytes themselves are never
125/// touched (a shipped built-in is sealed by the retention guard).
126///
127/// Why at render time: sibling versions of a built-in ship the README
128/// of their first generation verbatim, so 17 of 25 sealed packages
129/// stated a version that was not theirs. A docs-only correction does
130/// not justify a new schema generation, and editing a sealed package
131/// in place is refused by design; the resolved manifest is the one
132/// source of the identity, so the reader gets it from there.
133pub fn render_package_readme(name: &str, version: &str, readme: &str) -> String {
134    let needle = format!("{name}@");
135    let mut out = String::with_capacity(readme.len());
136    let mut rest = readme;
137    while let Some(at) = rest.find(&needle) {
138        let (before, tail) = rest.split_at(at);
139        let after = &tail[needle.len()..];
140        // Word boundary before the name: not part of a longer name.
141        let bounded = before
142            .chars()
143            .next_back()
144            .is_none_or(|c| !(c.is_alphanumeric() || c == '-' || c == '_'));
145        let pin_len = semver_prefix_len(after);
146        if bounded && pin_len > 0 {
147            out.push_str(before);
148            out.push_str(&needle);
149            out.push_str(version);
150            rest = &after[pin_len..];
151        } else {
152            out.push_str(before);
153            out.push_str(&needle);
154            rest = after;
155        }
156    }
157    out.push_str(rest);
158    out
159}
160
161/// Length of a leading `MAJOR.MINOR.PATCH` in `s`, or 0 when `s` does
162/// not start with one (a trailing `.4` or a pre-release tag ends the
163/// match at the patch number; a fourth component refuses the match).
164fn semver_prefix_len(s: &str) -> usize {
165    let mut len = 0;
166    for part in 0..3 {
167        let digits = s[len..].chars().take_while(|c| c.is_ascii_digit()).count();
168        if digits == 0 {
169            return 0;
170        }
171        len += digits;
172        if part < 2 {
173            if !s[len..].starts_with('.') {
174                return 0;
175            }
176            len += 1;
177        }
178    }
179    // A fourth dotted number is not a semver pin.
180    if s[len..].starts_with('.') && s[len + 1..].starts_with(|c: char| c.is_ascii_digit()) {
181        return 0;
182    }
183    len
184}
185
186/// Load every embedded schema into owned `Schema` values.
187pub fn load_builtin_schemas() -> Result<Vec<Arc<Schema>>, SchemaLoadError> {
188    let mut out = Vec::new();
189    for dir in BUILTIN_SCHEMAS.dirs() {
190        let schema = load_builtin_dir(dir)?;
191        out.push(Arc::new(schema));
192    }
193    Ok(out)
194}
195
196fn load_builtin_dir(dir: &Dir<'_>) -> Result<Schema, SchemaLoadError> {
197    let manifest_file = dir.get_file(format!("{}/schema.yaml", dir.path().display()).as_str());
198    let manifest_text = manifest_file
199        .and_then(|f| f.contents_utf8())
200        .ok_or_else(|| SchemaLoadError::Io {
201            path: dir.path().join("schema.yaml"),
202            source: std::io::Error::new(
203                std::io::ErrorKind::NotFound,
204                "embedded schema.yaml missing or not utf-8",
205            ),
206        })?;
207
208    let mut types: Vec<(String, String)> = Vec::new();
209    let types_path = format!("{}/types", dir.path().display());
210    if let Some(types_dir) = dir.get_dir(types_path.as_str()) {
211        for file in types_dir.files() {
212            if file.path().extension().and_then(|s| s.to_str()) != Some("yaml") {
213                continue;
214            }
215            let Some(stem) = file
216                .path()
217                .file_stem()
218                .and_then(|s| s.to_str())
219                .map(str::to_owned)
220            else {
221                continue;
222            };
223            let Some(contents) = file.contents_utf8() else {
224                return Err(SchemaLoadError::Io {
225                    path: file.path().to_path_buf(),
226                    source: std::io::Error::new(
227                        std::io::ErrorKind::InvalidData,
228                        "embedded type yaml is not utf-8",
229                    ),
230                });
231            };
232            types.push((stem, contents.to_string()));
233        }
234    }
235
236    // New builtin generations carry the format marker; sealed prior
237    // generations don't and keep their legacy written meaning.
238    let marker_path = format!(
239        "{}/{}",
240        dir.path().display(),
241        loader::SCHEMA_FORMAT_MARKER_FILE
242    );
243    let format = if dir.get_file(marker_path.as_str()).is_some() {
244        loader::MetadataPolarityFormat::RequiredOptIn
245    } else {
246        loader::MetadataPolarityFormat::Legacy
247    };
248    loader::load_schema_from_memory_with_format(manifest_text, &types, format)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn render_package_readme_rewrites_own_pins_only() {
257        let readme = "Pin `default@1.0.0` (or `default@1.0.0`); see planning@0.1.0 and \
258                      my-default@1.0.0; bare `default` stays; default@1.0.0.1 is not a pin; \
259                      default@1.0.0-rc1 keeps its tag";
260        let out = render_package_readme("default", "1.3.0", readme);
261        assert_eq!(
262            out,
263            "Pin `default@1.3.0` (or `default@1.3.0`); see planning@0.1.0 and \
264             my-default@1.0.0; bare `default` stays; default@1.0.0.1 is not a pin; \
265             default@1.3.0-rc1 keeps its tag"
266        );
267        // Unchanged input when nothing matches, byte for byte.
268        assert_eq!(render_package_readme("software", "0.4.0", readme), readme);
269        assert_eq!(render_package_readme("default", "1.3.0", ""), "");
270    }
271
272    #[test]
273    fn every_builtin_readme_renders_its_own_identity() {
274        let mut rendered = 0;
275        for pkg in builtin_packages() {
276            let Some((_, bytes)) = pkg.files.iter().find(|(p, _)| p == PACKAGE_README_FILE) else {
277                continue;
278            };
279            let readme = std::str::from_utf8(bytes).expect("README is UTF-8");
280            let out = render_package_readme(&pkg.name, &pkg.version, readme);
281            let own = format!("{}@{}", pkg.name, pkg.version);
282            let needle = format!("{}@", pkg.name);
283            for (i, _) in out.match_indices(&needle) {
284                let bounded = out[..i]
285                    .chars()
286                    .next_back()
287                    .is_none_or(|c| !(c.is_alphanumeric() || c == '-' || c == '_'));
288                let tail = &out[i + needle.len()..];
289                if bounded && semver_prefix_len(tail) > 0 {
290                    assert!(
291                        tail.starts_with(&pkg.version),
292                        "{own}: README still states {}",
293                        &out[i..i + needle.len() + semver_prefix_len(tail)]
294                    );
295                }
296            }
297            assert!(
298                out.contains(&own) || !readme.contains(&needle),
299                "{own}: a README that pins its own name must render the resolved pin"
300            );
301            rendered += 1;
302        }
303        assert!(rendered > 0, "at least one built-in ships a README");
304    }
305
306    /// The three scaffolding-bearing built-ins ship a parseable
307    /// `mem-template.json` carrying their instance writeGuidance key;
308    /// the deprecated literal `goal`/`avoid` (now in the schema's
309    /// `default_writing_guidance`) must NOT be present.
310    #[test]
311    fn builtin_mem_templates_carry_instance_keys_only() {
312        let cases = [
313            ("planning", "phase_context"),
314            ("project", "scope"),
315            ("software", "stack"),
316        ];
317        for (name, instance_key) in cases {
318            let tpl = builtin_mem_template(name)
319                .unwrap_or_else(|| panic!("{name} must ship a mem-template.json"));
320            assert!(
321                tpl["language"].is_string(),
322                "{name}: template carries language"
323            );
324            let wg = &tpl["writeGuidance"];
325            assert!(
326                wg.get(instance_key).is_some(),
327                "{name}: template carries instance key {instance_key}",
328            );
329            assert!(
330                wg.get("goal").is_none() && wg.get("avoid").is_none(),
331                "{name}: template must not carry the deprecated literal goal/avoid (schema owns those)",
332            );
333        }
334    }
335
336    /// Packages without a template (and unknown names) resolve to None.
337    #[test]
338    fn builtin_mem_template_absent_is_none() {
339        assert!(builtin_mem_template("default").is_none());
340        assert!(builtin_mem_template("ingest").is_none());
341        assert!(builtin_mem_template("not-a-builtin").is_none());
342    }
343
344    /// The added template files are inert to schema loading — every
345    /// built-in still loads (the loader reads only schema.yaml + types/).
346    #[test]
347    fn all_builtins_still_load_with_templates_present() {
348        let schemas = load_builtin_schemas().expect("built-ins load");
349        assert!(
350            schemas.iter().any(|s| s.manifest.name == "planning"),
351            "planning still loads alongside its mem-template.json",
352        );
353    }
354}