1pub const INSTALL_PROVENANCE_FILE: &str = "install-provenance.json";
34
35use std::path::{Path, PathBuf};
36
37use crate::builtins::builtin_schemas_dir;
38use crate::config::SchemaRef;
39
40#[derive(Debug, Clone)]
48pub struct SchemaSourceFile {
49 pub archive_path: String,
50 pub bytes: Vec<u8>,
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum SchemaSourceError {
55 #[error(
56 "schema {schema_ref} not found — candidate paths tried: [{}]",
57 .candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
58 )]
59 NotFound {
60 schema_ref: String,
61 candidates: Vec<PathBuf>,
66 },
67
68 #[error("i/o error reading schema source at {}: {source}", .path.display())]
69 Io {
70 path: PathBuf,
71 #[source]
72 source: std::io::Error,
73 },
74
75 #[error(
79 "schema cache collision: '{name}-{version}' has more than one source directory ({} and {})",
80 .first.display(),
81 .second.display()
82 )]
83 CacheCollision {
84 name: String,
85 version: String,
86 first: PathBuf,
87 second: PathBuf,
88 },
89
90 #[error(
91 "schema manifest at {} does not declare version '{expected}' (found '{found}')",
92 .path.display()
93 )]
94 VersionMismatch {
95 path: PathBuf,
96 expected: String,
97 found: String,
98 },
99
100 #[error("schema manifest at {} is malformed: {reason}", .path.display())]
101 MalformedManifest { path: PathBuf, reason: String },
102}
103
104pub fn collect_schema_source(
120 workspace_root: Option<&Path>,
121 workspace_schemas_dir: Option<&Path>,
122 schema_ref: &SchemaRef,
123) -> Result<Vec<SchemaSourceFile>, SchemaSourceError> {
124 let mut candidates: Vec<PathBuf> = Vec::new();
125
126 if let Some(ws_dir) = workspace_schemas_dir {
127 let versioned_dir = ws_dir.join(format!("{}@{}", schema_ref.name, schema_ref.version));
131 candidates.push(versioned_dir.clone());
132 if versioned_dir.is_dir()
133 && let Some(files) = try_collect_dir(&versioned_dir, schema_ref)?
134 {
135 return Ok(files);
136 }
137 let ws_schema_dir = ws_dir.join(&schema_ref.name);
138 candidates.push(ws_schema_dir.clone());
139 if ws_schema_dir.is_dir()
140 && let Some(files) = try_collect_dir(&ws_schema_dir, schema_ref)?
141 {
142 return Ok(files);
143 }
144 }
145
146 if let Some(ws_root) = workspace_root {
147 let cache_dir = ws_root
148 .join(".memstead.cache/schemas")
149 .join(format!("{}-{}", schema_ref.name, schema_ref.version));
150 candidates.push(cache_dir.clone());
151 if cache_dir.is_dir()
152 && let Some(files) = try_collect_dir(&cache_dir, schema_ref)?
153 {
154 return Ok(files);
155 }
156 }
157
158 if let Some(files) = collect_builtin_source(schema_ref)? {
159 return Ok(files);
160 }
161
162 Err(SchemaSourceError::NotFound {
163 schema_ref: schema_ref.as_display(),
164 candidates,
165 })
166}
167
168fn try_collect_dir(
173 dir: &Path,
174 schema_ref: &SchemaRef,
175) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
176 let manifest_path = dir.join("schema.yaml");
177 let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| SchemaSourceError::Io {
178 path: manifest_path.clone(),
179 source: e,
180 })?;
181
182 if !manifest_matches(&manifest_bytes, schema_ref, &manifest_path)? {
183 return Ok(None);
184 }
185
186 let mut out = vec![SchemaSourceFile {
187 archive_path: "schema.yaml".to_string(),
188 bytes: manifest_bytes,
189 }];
190
191 let marker_path = dir.join(crate::loader::SCHEMA_FORMAT_MARKER_FILE);
195 if marker_path.is_file() {
196 let bytes = std::fs::read(&marker_path).map_err(|e| SchemaSourceError::Io {
197 path: marker_path.clone(),
198 source: e,
199 })?;
200 out.push(SchemaSourceFile {
201 archive_path: crate::loader::SCHEMA_FORMAT_MARKER_FILE.to_string(),
202 bytes,
203 });
204 }
205
206 let types_dir = dir.join("types");
207 if types_dir.is_dir() {
208 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaSourceError::Io {
209 path: types_dir.clone(),
210 source: e,
211 })?;
212 for entry in entries {
213 let entry = entry.map_err(|e| SchemaSourceError::Io {
214 path: types_dir.clone(),
215 source: e,
216 })?;
217 let path = entry.path();
218 if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
219 continue;
220 }
221 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
222 continue;
223 };
224 let bytes = std::fs::read(&path).map_err(|e| SchemaSourceError::Io {
225 path: path.clone(),
226 source: e,
227 })?;
228 out.push(SchemaSourceFile {
229 archive_path: format!("types/{stem}.yaml"),
230 bytes,
231 });
232 }
233 }
234
235 out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
236 Ok(Some(out))
237}
238
239fn collect_builtin_source(
240 schema_ref: &SchemaRef,
241) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
242 if let Some(schema_dir) = builtin_schemas_dir().get_dir(schema_ref.name.as_str())
244 && let Some(files) = collect_builtin_dir(schema_dir, schema_ref)?
245 {
246 return Ok(Some(files));
247 }
248 for schema_dir in builtin_schemas_dir().dirs() {
256 if let Some(files) = collect_builtin_dir(schema_dir, schema_ref)? {
257 return Ok(Some(files));
258 }
259 }
260 Ok(None)
261}
262
263fn collect_builtin_dir(
268 schema_dir: &include_dir::Dir<'static>,
269 schema_ref: &SchemaRef,
270) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
271 let prefix = schema_dir.path().display().to_string();
275 let manifest_key = format!("{prefix}/schema.yaml");
276 let Some(manifest_file) = schema_dir.get_file(manifest_key.as_str()) else {
277 return Ok(None);
278 };
279 let manifest_bytes = manifest_file.contents().to_vec();
280 if !manifest_matches(
281 &manifest_bytes,
282 schema_ref,
283 &PathBuf::from(format!("<builtin:{prefix}>/schema.yaml")),
284 )? {
285 return Ok(None);
286 }
287
288 let mut out = vec![SchemaSourceFile {
289 archive_path: "schema.yaml".to_string(),
290 bytes: manifest_bytes,
291 }];
292
293 let marker_key = format!("{prefix}/{}", crate::loader::SCHEMA_FORMAT_MARKER_FILE);
296 if let Some(marker) = schema_dir.get_file(marker_key.as_str()) {
297 out.push(SchemaSourceFile {
298 archive_path: crate::loader::SCHEMA_FORMAT_MARKER_FILE.to_string(),
299 bytes: marker.contents().to_vec(),
300 });
301 }
302
303 let types_key = format!("{prefix}/types");
304 if let Some(types_dir) = schema_dir.get_dir(types_key.as_str()) {
305 for file in types_dir.files() {
306 if file.path().extension().and_then(|s| s.to_str()) != Some("yaml") {
307 continue;
308 }
309 let Some(stem) = file.path().file_stem().and_then(|s| s.to_str()) else {
310 continue;
311 };
312 out.push(SchemaSourceFile {
313 archive_path: format!("types/{stem}.yaml"),
314 bytes: file.contents().to_vec(),
315 });
316 }
317 }
318
319 out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
320 Ok(Some(out))
321}
322
323fn manifest_matches(
330 manifest_bytes: &[u8],
331 schema_ref: &SchemaRef,
332 source_path: &Path,
333) -> Result<bool, SchemaSourceError> {
334 #[derive(serde::Deserialize)]
335 struct ManifestId {
336 name: String,
337 version: String,
338 }
339 let id: ManifestId = serde_yaml_ng::from_slice(manifest_bytes).map_err(|e| {
340 SchemaSourceError::MalformedManifest {
341 path: source_path.to_path_buf(),
342 reason: e.to_string(),
343 }
344 })?;
345 if id.name != schema_ref.name {
346 return Ok(false);
347 }
348 let declared =
349 semver::Version::parse(&id.version).map_err(|e| SchemaSourceError::MalformedManifest {
350 path: source_path.to_path_buf(),
351 reason: format!("invalid semver '{}': {e}", id.version),
352 })?;
353 if declared != schema_ref.version {
354 if source_path.to_string_lossy().starts_with("<builtin:") {
367 return Ok(false);
368 }
369 return Err(SchemaSourceError::VersionMismatch {
370 path: source_path.to_path_buf(),
371 expected: schema_ref.version.to_string(),
372 found: declared.to_string(),
373 });
374 }
375 Ok(true)
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use tempfile::TempDir;
382
383 fn write_schema(dir: &Path, name: &str, version: &str, types: &[&str]) {
384 let manifest = format!(
385 r#"name: {name}
386version: {version}
387description: test
388when_to_use: test
389types:
390 - {type_list}
391relationships:
392 mode: strict
393 definitions:
394 - name: _default
395 description: default
396 default_weight: 1.0
397 - name: PART_OF
398 description: hier
399 default_weight: 3.0
400community:
401 resolution: 1.0
402 seed: 42
403"#,
404 name = name,
405 version = version,
406 type_list = types.join("\n - "),
407 );
408 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
409 for t in types {
410 let td = format!(
411 r#"name: {t}
412description: test
413when_to_use: test
414sections:
415 - key: body
416 heading: Body
417 required: true
418 search_weight: 10.0
419 catch_all: true
420metadata_fields: []
421title_weight: 1.0
422text_fields: [body]
423hierarchy_relationship: PART_OF
424no_self_loop_relationships: []
425updatable_fields: [title, body]
426health_required_fields: [body]
427staleness_threshold_days: 30
428write_rules: []
429"#
430 );
431 std::fs::write(dir.join(format!("types/{t}.yaml")), td).unwrap();
432 }
433 }
434
435 #[test]
439 fn collectors_carry_format_marker_as_found() {
440 let marked: SchemaRef = "default@1.3.0".parse().unwrap();
441 let files = collect_schema_source(None, None, &marked).unwrap();
442 assert!(
443 files
444 .iter()
445 .any(|f| f.archive_path == crate::loader::SCHEMA_FORMAT_MARKER_FILE),
446 "current generation collects its marker"
447 );
448
449 let legacy: SchemaRef = "default@1.2.0".parse().unwrap();
450 let files = collect_schema_source(None, None, &legacy).unwrap();
451 assert!(
452 !files
453 .iter()
454 .any(|f| f.archive_path == crate::loader::SCHEMA_FORMAT_MARKER_FILE),
455 "retained pre-flip generation stays unmarked"
456 );
457 }
458
459 #[test]
460 fn collects_builtin_default_source() {
461 let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
462 let files = collect_schema_source(None, None, &schema_ref).unwrap();
463
464 assert!(
465 files.iter().any(|f| f.archive_path == "schema.yaml"),
466 "embedded builtin must expose schema.yaml"
467 );
468 let type_count = files
469 .iter()
470 .filter(|f| f.archive_path.starts_with("types/"))
471 .count();
472 assert_eq!(type_count, 10, "default schema has 10 types");
473 for pair in files.windows(2) {
474 assert!(pair[0].archive_path < pair[1].archive_path, "sorted");
475 }
476 }
477
478 #[test]
485 fn collects_builtin_source_for_every_retained_version() {
486 for (name, version) in [
487 ("planning", semver::Version::new(0, 2, 0)),
488 ("planning", semver::Version::new(0, 4, 0)),
489 ("ingest", semver::Version::new(0, 1, 0)),
490 ("ingest", semver::Version::new(0, 5, 0)),
491 ] {
492 let schema_ref = SchemaRef::new(name, version.clone());
493 let files = collect_schema_source(None, None, &schema_ref)
494 .unwrap_or_else(|e| panic!("{name}@{version} must resolve: {e}"));
495 let manifest = files
496 .iter()
497 .find(|f| f.archive_path == "schema.yaml")
498 .expect("manifest present");
499 let text = String::from_utf8_lossy(&manifest.bytes);
500 assert!(
501 text.contains(&format!("version: {version}")),
502 "{name}@{version}: collected manifest must carry the requested version"
503 );
504 }
505
506 let ghost = SchemaRef::new("planning", semver::Version::new(9, 9, 9));
509 assert!(matches!(
510 collect_schema_source(None, None, &ghost),
511 Err(SchemaSourceError::NotFound { .. })
512 ));
513 }
514
515 #[test]
516 fn workspace_schema_wins_over_builtin() {
517 let tmp = TempDir::new().unwrap();
518 let ws_dir = tmp.path().join("schemas");
519 let schema_dir = ws_dir.join("default");
520 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
521 write_schema(&schema_dir, "default", "1.0.0", &["spec"]);
524 let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
525 let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
526 let type_count = files
527 .iter()
528 .filter(|f| f.archive_path.starts_with("types/"))
529 .count();
530 assert_eq!(type_count, 1, "workspace override takes priority");
531 }
532
533 #[test]
534 fn workspace_mismatched_version_errors() {
535 let tmp = TempDir::new().unwrap();
536 let ws_dir = tmp.path().join("schemas");
537 let schema_dir = ws_dir.join("recipe");
538 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
539 write_schema(&schema_dir, "recipe", "1.0.0", &["spec"]);
540 let schema_ref = SchemaRef::new("recipe", semver::Version::new(2, 0, 0));
541 let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
542 assert!(matches!(err, SchemaSourceError::VersionMismatch { .. }));
543 }
544
545 #[test]
546 fn cache_schema_resolves_when_workspace_layer_absent() {
547 let tmp = TempDir::new().unwrap();
548 let dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
550 std::fs::create_dir_all(dir.join("types")).unwrap();
551 write_schema(&dir, "recipe", "1.0.0", &["spec"]);
552 let schema_ref = SchemaRef::new("recipe", semver::Version::new(1, 0, 0));
553 let files = collect_schema_source(Some(tmp.path()), None, &schema_ref).unwrap();
554 assert!(files.iter().any(|f| f.archive_path == "schema.yaml"));
555 }
556
557 #[test]
558 fn unknown_schema_returns_not_found() {
559 let schema_ref = SchemaRef::new("nonexistent", semver::Version::new(1, 0, 0));
560 let err = collect_schema_source(None, None, &schema_ref).unwrap_err();
561 assert!(matches!(err, SchemaSourceError::NotFound { .. }));
562 }
563
564 #[test]
565 fn workspace_schema_wins_over_cache() {
566 let tmp = TempDir::new().unwrap();
567 let cache_dir = tmp.path().join(".memstead.cache/schemas/software-1.0.0");
569 std::fs::create_dir_all(cache_dir.join("types")).unwrap();
570 write_schema(&cache_dir, "software", "1.0.0", &["spec", "memo"]);
571 let ws_dir = tmp.path().join("schemas");
573 let ws_schema = ws_dir.join("software");
574 std::fs::create_dir_all(ws_schema.join("types")).unwrap();
575 write_schema(&ws_schema, "software", "1.0.0", &["spec"]);
576
577 let schema_ref = SchemaRef::new("software", semver::Version::new(1, 0, 0));
578 let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
579 let type_count = files
580 .iter()
581 .filter(|f| f.archive_path.starts_with("types/"))
582 .count();
583 assert_eq!(type_count, 1, "workspace layer must win over cache");
584 }
585
586 #[test]
587 fn not_found_lists_every_candidate_path() {
588 let tmp = TempDir::new().unwrap();
589 let ws_dir = tmp.path().join("schemas");
590 std::fs::create_dir_all(&ws_dir).unwrap();
591
592 let schema_ref = SchemaRef::new("missing", semver::Version::new(2, 3, 4));
593 let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
594 match err {
595 SchemaSourceError::NotFound {
596 schema_ref: name,
597 candidates,
598 } => {
599 assert_eq!(name, "missing@2.3.4");
600 assert_eq!(candidates.len(), 3);
604 assert!(candidates[0].ends_with("schemas/missing@2.3.4"));
605 assert!(candidates[1].ends_with("schemas/missing"));
606 assert!(
607 candidates[2]
608 .to_string_lossy()
609 .contains(".memstead.cache/schemas/missing-2.3.4")
610 );
611 }
612 other => panic!("expected NotFound, got {other:?}"),
613 }
614 }
615}