memstead_schema/
builtins.rs1use 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
18pub(crate) fn builtin_schemas_dir() -> &'static Dir<'static> {
25 &BUILTIN_SCHEMAS
26}
27
28pub 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
45pub struct BuiltinPackage {
54 pub name: String,
55 pub version: String,
56 pub files: Vec<(String, &'static [u8])>,
58}
59
60pub 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
109pub 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
117pub const PACKAGE_README_FILE: &str = "README.md";
119
120pub 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 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
161fn 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 if s[len..].starts_with('.') && s[len + 1..].starts_with(|c: char| c.is_ascii_digit()) {
181 return 0;
182 }
183 len
184}
185
186pub 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 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 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 #[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 #[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 #[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}