1pub const INSTALL_PROVENANCE_FILE: &str = "install-provenance.json";
32
33use std::path::{Path, PathBuf};
34
35use crate::builtins::builtin_schemas_dir;
36use crate::config::SchemaRef;
37
38#[derive(Debug, Clone)]
46pub struct SchemaSourceFile {
47 pub archive_path: String,
48 pub bytes: Vec<u8>,
49}
50
51#[derive(Debug, thiserror::Error)]
52pub enum SchemaSourceError {
53 #[error(
54 "schema {schema_ref} not found — candidate paths tried: [{}]",
55 .candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
56 )]
57 NotFound {
58 schema_ref: String,
59 candidates: Vec<PathBuf>,
63 },
64
65 #[error("i/o error reading schema source at {}: {source}", .path.display())]
66 Io {
67 path: PathBuf,
68 #[source]
69 source: std::io::Error,
70 },
71
72 #[error(
73 "schema manifest at {} does not declare version '{expected}' (found '{found}')",
74 .path.display()
75 )]
76 VersionMismatch {
77 path: PathBuf,
78 expected: String,
79 found: String,
80 },
81
82 #[error("schema manifest at {} is malformed: {reason}", .path.display())]
83 MalformedManifest { path: PathBuf, reason: String },
84}
85
86pub fn collect_schema_source(
107 _workspace_root: Option<&Path>,
108 workspace_schemas_dir: Option<&Path>,
109 schema_ref: &SchemaRef,
110) -> Result<Vec<SchemaSourceFile>, SchemaSourceError> {
111 let mut candidates: Vec<PathBuf> = Vec::new();
112
113 if let Some(ws_dir) = workspace_schemas_dir {
114 let versioned_dir = ws_dir.join(format!("{}@{}", schema_ref.name, schema_ref.version));
118 candidates.push(versioned_dir.clone());
119 if versioned_dir.is_dir()
120 && let Some(files) = try_collect_dir(&versioned_dir, schema_ref)?
121 {
122 return Ok(files);
123 }
124 let ws_schema_dir = ws_dir.join(&schema_ref.name);
125 candidates.push(ws_schema_dir.clone());
126 if ws_schema_dir.is_dir()
127 && let Some(files) = try_collect_dir(&ws_schema_dir, schema_ref)?
128 {
129 return Ok(files);
130 }
131 }
132
133 if let Some(files) = collect_builtin_source(schema_ref)? {
134 return Ok(files);
135 }
136
137 Err(SchemaSourceError::NotFound {
138 schema_ref: schema_ref.as_display(),
139 candidates,
140 })
141}
142
143fn try_collect_dir(
148 dir: &Path,
149 schema_ref: &SchemaRef,
150) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
151 let manifest_path = dir.join("schema.yaml");
152 let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| SchemaSourceError::Io {
153 path: manifest_path.clone(),
154 source: e,
155 })?;
156
157 if !manifest_matches(&manifest_bytes, schema_ref, &manifest_path)? {
158 return Ok(None);
159 }
160
161 let mut out = vec![SchemaSourceFile {
162 archive_path: "schema.yaml".to_string(),
163 bytes: manifest_bytes,
164 }];
165
166 let marker_path = dir.join(crate::loader::SCHEMA_FORMAT_MARKER_FILE);
170 if marker_path.is_file() {
171 let bytes = std::fs::read(&marker_path).map_err(|e| SchemaSourceError::Io {
172 path: marker_path.clone(),
173 source: e,
174 })?;
175 out.push(SchemaSourceFile {
176 archive_path: crate::loader::SCHEMA_FORMAT_MARKER_FILE.to_string(),
177 bytes,
178 });
179 }
180
181 let types_dir = dir.join("types");
182 if types_dir.is_dir() {
183 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaSourceError::Io {
184 path: types_dir.clone(),
185 source: e,
186 })?;
187 for entry in entries {
188 let entry = entry.map_err(|e| SchemaSourceError::Io {
189 path: types_dir.clone(),
190 source: e,
191 })?;
192 let path = entry.path();
193 if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
194 continue;
195 }
196 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
197 continue;
198 };
199 let bytes = std::fs::read(&path).map_err(|e| SchemaSourceError::Io {
200 path: path.clone(),
201 source: e,
202 })?;
203 out.push(SchemaSourceFile {
204 archive_path: format!("types/{stem}.yaml"),
205 bytes,
206 });
207 }
208 }
209
210 out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
211 Ok(Some(out))
212}
213
214fn collect_builtin_source(
215 schema_ref: &SchemaRef,
216) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
217 if let Some(schema_dir) = builtin_schemas_dir().get_dir(schema_ref.name.as_str())
219 && let Some(files) = collect_builtin_dir(schema_dir, schema_ref)?
220 {
221 return Ok(Some(files));
222 }
223 for schema_dir in builtin_schemas_dir().dirs() {
231 if let Some(files) = collect_builtin_dir(schema_dir, schema_ref)? {
232 return Ok(Some(files));
233 }
234 }
235 Ok(None)
236}
237
238fn collect_builtin_dir(
243 schema_dir: &include_dir::Dir<'static>,
244 schema_ref: &SchemaRef,
245) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
246 let prefix = schema_dir.path().display().to_string();
250 let manifest_key = format!("{prefix}/schema.yaml");
251 let Some(manifest_file) = schema_dir.get_file(manifest_key.as_str()) else {
252 return Ok(None);
253 };
254 let manifest_bytes = manifest_file.contents().to_vec();
255 if !manifest_matches(
256 &manifest_bytes,
257 schema_ref,
258 &PathBuf::from(format!("<builtin:{prefix}>/schema.yaml")),
259 )? {
260 return Ok(None);
261 }
262
263 let mut out = vec![SchemaSourceFile {
264 archive_path: "schema.yaml".to_string(),
265 bytes: manifest_bytes,
266 }];
267
268 let marker_key = format!("{prefix}/{}", crate::loader::SCHEMA_FORMAT_MARKER_FILE);
271 if let Some(marker) = schema_dir.get_file(marker_key.as_str()) {
272 out.push(SchemaSourceFile {
273 archive_path: crate::loader::SCHEMA_FORMAT_MARKER_FILE.to_string(),
274 bytes: marker.contents().to_vec(),
275 });
276 }
277
278 let types_key = format!("{prefix}/types");
279 if let Some(types_dir) = schema_dir.get_dir(types_key.as_str()) {
280 for file in types_dir.files() {
281 if file.path().extension().and_then(|s| s.to_str()) != Some("yaml") {
282 continue;
283 }
284 let Some(stem) = file.path().file_stem().and_then(|s| s.to_str()) else {
285 continue;
286 };
287 out.push(SchemaSourceFile {
288 archive_path: format!("types/{stem}.yaml"),
289 bytes: file.contents().to_vec(),
290 });
291 }
292 }
293
294 out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
295 Ok(Some(out))
296}
297
298fn manifest_matches(
305 manifest_bytes: &[u8],
306 schema_ref: &SchemaRef,
307 source_path: &Path,
308) -> Result<bool, SchemaSourceError> {
309 #[derive(serde::Deserialize)]
310 struct ManifestId {
311 name: String,
312 version: String,
313 }
314 let id: ManifestId = serde_yaml_ng::from_slice(manifest_bytes).map_err(|e| {
315 SchemaSourceError::MalformedManifest {
316 path: source_path.to_path_buf(),
317 reason: e.to_string(),
318 }
319 })?;
320 if id.name != schema_ref.name {
321 return Ok(false);
322 }
323 let declared =
324 semver::Version::parse(&id.version).map_err(|e| SchemaSourceError::MalformedManifest {
325 path: source_path.to_path_buf(),
326 reason: format!("invalid semver '{}': {e}", id.version),
327 })?;
328 if declared != schema_ref.version {
329 if source_path.to_string_lossy().starts_with("<builtin:") {
342 return Ok(false);
343 }
344 return Err(SchemaSourceError::VersionMismatch {
345 path: source_path.to_path_buf(),
346 expected: schema_ref.version.to_string(),
347 found: declared.to_string(),
348 });
349 }
350 Ok(true)
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use tempfile::TempDir;
357
358 fn write_schema(dir: &Path, name: &str, version: &str, types: &[&str]) {
359 let manifest = format!(
360 r#"name: {name}
361version: {version}
362description: test
363when_to_use: test
364types:
365 - {type_list}
366relationships:
367 mode: strict
368 definitions:
369 - name: _default
370 description: default
371 default_weight: 1.0
372 - name: PART_OF
373 description: hier
374 default_weight: 3.0
375community:
376 resolution: 1.0
377 seed: 42
378"#,
379 name = name,
380 version = version,
381 type_list = types.join("\n - "),
382 );
383 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
384 for t in types {
385 let td = format!(
386 r#"name: {t}
387description: test
388when_to_use: test
389sections:
390 - key: body
391 heading: Body
392 required: true
393 search_weight: 10.0
394 catch_all: true
395metadata_fields: []
396title_weight: 1.0
397text_fields: [body]
398hierarchy_relationship: PART_OF
399no_self_loop_relationships: []
400updatable_fields: [title, body]
401health_required_fields: [body]
402staleness_threshold_days: 30
403write_rules: []
404"#
405 );
406 std::fs::write(dir.join(format!("types/{t}.yaml")), td).unwrap();
407 }
408 }
409
410 #[test]
414 fn collectors_carry_format_marker_as_found() {
415 let marked: SchemaRef = "default@1.3.0".parse().unwrap();
416 let files = collect_schema_source(None, None, &marked).unwrap();
417 assert!(
418 files
419 .iter()
420 .any(|f| f.archive_path == crate::loader::SCHEMA_FORMAT_MARKER_FILE),
421 "current generation collects its marker"
422 );
423
424 let legacy: SchemaRef = "default@1.2.0".parse().unwrap();
425 let files = collect_schema_source(None, None, &legacy).unwrap();
426 assert!(
427 !files
428 .iter()
429 .any(|f| f.archive_path == crate::loader::SCHEMA_FORMAT_MARKER_FILE),
430 "retained pre-flip generation stays unmarked"
431 );
432 }
433
434 #[test]
435 fn collects_builtin_default_source() {
436 let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
437 let files = collect_schema_source(None, None, &schema_ref).unwrap();
438
439 assert!(
440 files.iter().any(|f| f.archive_path == "schema.yaml"),
441 "embedded builtin must expose schema.yaml"
442 );
443 let type_count = files
444 .iter()
445 .filter(|f| f.archive_path.starts_with("types/"))
446 .count();
447 assert_eq!(type_count, 10, "default schema has 10 types");
448 for pair in files.windows(2) {
449 assert!(pair[0].archive_path < pair[1].archive_path, "sorted");
450 }
451 }
452
453 #[test]
460 fn collects_builtin_source_for_every_retained_version() {
461 for (name, version) in [
462 ("planning", semver::Version::new(0, 2, 0)),
463 ("planning", semver::Version::new(0, 4, 0)),
464 ("ingest", semver::Version::new(0, 1, 0)),
465 ("ingest", semver::Version::new(0, 5, 0)),
466 ] {
467 let schema_ref = SchemaRef::new(name, version.clone());
468 let files = collect_schema_source(None, None, &schema_ref)
469 .unwrap_or_else(|e| panic!("{name}@{version} must resolve: {e}"));
470 let manifest = files
471 .iter()
472 .find(|f| f.archive_path == "schema.yaml")
473 .expect("manifest present");
474 let text = String::from_utf8_lossy(&manifest.bytes);
475 assert!(
476 text.contains(&format!("version: {version}")),
477 "{name}@{version}: collected manifest must carry the requested version"
478 );
479 }
480
481 let ghost = SchemaRef::new("planning", semver::Version::new(9, 9, 9));
484 assert!(matches!(
485 collect_schema_source(None, None, &ghost),
486 Err(SchemaSourceError::NotFound { .. })
487 ));
488 }
489
490 #[test]
491 fn workspace_schema_wins_over_builtin() {
492 let tmp = TempDir::new().unwrap();
493 let ws_dir = tmp.path().join("schemas");
494 let schema_dir = ws_dir.join("default");
495 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
496 write_schema(&schema_dir, "default", "1.0.0", &["spec"]);
499 let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
500 let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
501 let type_count = files
502 .iter()
503 .filter(|f| f.archive_path.starts_with("types/"))
504 .count();
505 assert_eq!(type_count, 1, "workspace override takes priority");
506 }
507
508 #[test]
509 fn workspace_mismatched_version_errors() {
510 let tmp = TempDir::new().unwrap();
511 let ws_dir = tmp.path().join("schemas");
512 let schema_dir = ws_dir.join("recipe");
513 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
514 write_schema(&schema_dir, "recipe", "1.0.0", &["spec"]);
515 let schema_ref = SchemaRef::new("recipe", semver::Version::new(2, 0, 0));
516 let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
517 assert!(matches!(err, SchemaSourceError::VersionMismatch { .. }));
518 }
519
520 #[test]
525 fn legacy_cache_directory_is_not_a_source() {
526 let tmp = TempDir::new().unwrap();
527 let dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
528 std::fs::create_dir_all(dir.join("types")).unwrap();
529 write_schema(&dir, "recipe", "1.0.0", &["spec"]);
530 let schema_ref = SchemaRef::new("recipe", semver::Version::new(1, 0, 0));
531 let err = collect_schema_source(Some(tmp.path()), None, &schema_ref).unwrap_err();
532 assert!(
533 matches!(err, SchemaSourceError::NotFound { .. }),
534 "got {err:?}"
535 );
536 }
537
538 #[test]
539 fn unknown_schema_returns_not_found() {
540 let schema_ref = SchemaRef::new("nonexistent", semver::Version::new(1, 0, 0));
541 let err = collect_schema_source(None, None, &schema_ref).unwrap_err();
542 assert!(matches!(err, SchemaSourceError::NotFound { .. }));
543 }
544
545 #[test]
549 fn versioned_workspace_shape_wins_over_the_bare_one() {
550 let tmp = TempDir::new().unwrap();
551 let ws_dir = tmp.path().join("schemas");
552 let bare = ws_dir.join("software");
554 std::fs::create_dir_all(bare.join("types")).unwrap();
555 write_schema(&bare, "software", "1.0.0", &["spec", "memo"]);
556 let versioned = ws_dir.join("software@1.0.0");
558 std::fs::create_dir_all(versioned.join("types")).unwrap();
559 write_schema(&versioned, "software", "1.0.0", &["spec"]);
560
561 let schema_ref = SchemaRef::new("software", semver::Version::new(1, 0, 0));
562 let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
563 let type_count = files
564 .iter()
565 .filter(|f| f.archive_path.starts_with("types/"))
566 .count();
567 assert_eq!(type_count, 1, "the versioned install shape must win");
568 }
569
570 #[test]
571 fn not_found_lists_every_candidate_path() {
572 let tmp = TempDir::new().unwrap();
573 let ws_dir = tmp.path().join("schemas");
574 std::fs::create_dir_all(&ws_dir).unwrap();
575
576 let schema_ref = SchemaRef::new("missing", semver::Version::new(2, 3, 4));
577 let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
578 match err {
579 SchemaSourceError::NotFound {
580 schema_ref: name,
581 candidates,
582 } => {
583 assert_eq!(name, "missing@2.3.4");
584 assert_eq!(candidates.len(), 2, "got {candidates:?}");
588 assert!(candidates[0].ends_with("schemas/missing@2.3.4"));
589 assert!(candidates[1].ends_with("schemas/missing"));
590 }
591 other => panic!("expected NotFound, got {other:?}"),
592 }
593 }
594}