1use std::path::{Path, PathBuf};
22
23use crate::builtins::builtin_schemas_dir;
24use crate::config::SchemaRef;
25
26#[derive(Debug, Clone)]
34pub struct SchemaSourceFile {
35 pub archive_path: String,
36 pub bytes: Vec<u8>,
37}
38
39#[derive(Debug, thiserror::Error)]
40pub enum SchemaSourceError {
41 #[error(
42 "schema {schema_ref} not found — candidate paths tried: [{}]",
43 .candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
44 )]
45 NotFound {
46 schema_ref: String,
47 candidates: Vec<PathBuf>,
52 },
53
54 #[error("i/o error reading schema source at {}: {source}", .path.display())]
55 Io {
56 path: PathBuf,
57 #[source]
58 source: std::io::Error,
59 },
60
61 #[error(
65 "schema cache collision: '{name}-{version}' has more than one source directory ({} and {})",
66 .first.display(),
67 .second.display()
68 )]
69 CacheCollision {
70 name: String,
71 version: String,
72 first: PathBuf,
73 second: PathBuf,
74 },
75
76 #[error(
77 "schema manifest at {} does not declare version '{expected}' (found '{found}')",
78 .path.display()
79 )]
80 VersionMismatch {
81 path: PathBuf,
82 expected: String,
83 found: String,
84 },
85
86 #[error("schema manifest at {} is malformed: {reason}", .path.display())]
87 MalformedManifest { path: PathBuf, reason: String },
88}
89
90pub fn collect_schema_source(
106 workspace_root: Option<&Path>,
107 workspace_schemas_dir: Option<&Path>,
108 schema_ref: &SchemaRef,
109) -> Result<Vec<SchemaSourceFile>, SchemaSourceError> {
110 let mut candidates: Vec<PathBuf> = Vec::new();
111
112 if let Some(ws_dir) = workspace_schemas_dir {
113 let versioned_dir = ws_dir.join(format!("{}@{}", schema_ref.name, schema_ref.version));
117 candidates.push(versioned_dir.clone());
118 if versioned_dir.is_dir()
119 && let Some(files) = try_collect_dir(&versioned_dir, schema_ref)?
120 {
121 return Ok(files);
122 }
123 let ws_schema_dir = ws_dir.join(&schema_ref.name);
124 candidates.push(ws_schema_dir.clone());
125 if ws_schema_dir.is_dir()
126 && let Some(files) = try_collect_dir(&ws_schema_dir, schema_ref)?
127 {
128 return Ok(files);
129 }
130 }
131
132 if let Some(ws_root) = workspace_root {
133 let cache_dir = ws_root
134 .join(".memstead.cache/schemas")
135 .join(format!("{}-{}", schema_ref.name, schema_ref.version));
136 candidates.push(cache_dir.clone());
137 if cache_dir.is_dir()
138 && let Some(files) = try_collect_dir(&cache_dir, schema_ref)?
139 {
140 return Ok(files);
141 }
142 }
143
144 if let Some(files) = collect_builtin_source(schema_ref)? {
145 return Ok(files);
146 }
147
148 Err(SchemaSourceError::NotFound {
149 schema_ref: schema_ref.as_display(),
150 candidates,
151 })
152}
153
154fn try_collect_dir(
159 dir: &Path,
160 schema_ref: &SchemaRef,
161) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
162 let manifest_path = dir.join("schema.yaml");
163 let manifest_bytes = std::fs::read(&manifest_path).map_err(|e| SchemaSourceError::Io {
164 path: manifest_path.clone(),
165 source: e,
166 })?;
167
168 if !manifest_matches(&manifest_bytes, schema_ref, &manifest_path)? {
169 return Ok(None);
170 }
171
172 let mut out = vec![SchemaSourceFile {
173 archive_path: "schema.yaml".to_string(),
174 bytes: manifest_bytes,
175 }];
176
177 let types_dir = dir.join("types");
178 if types_dir.is_dir() {
179 let entries = std::fs::read_dir(&types_dir).map_err(|e| SchemaSourceError::Io {
180 path: types_dir.clone(),
181 source: e,
182 })?;
183 for entry in entries {
184 let entry = entry.map_err(|e| SchemaSourceError::Io {
185 path: types_dir.clone(),
186 source: e,
187 })?;
188 let path = entry.path();
189 if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
190 continue;
191 }
192 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
193 continue;
194 };
195 let bytes = std::fs::read(&path).map_err(|e| SchemaSourceError::Io {
196 path: path.clone(),
197 source: e,
198 })?;
199 out.push(SchemaSourceFile {
200 archive_path: format!("types/{stem}.yaml"),
201 bytes,
202 });
203 }
204 }
205
206 out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
207 Ok(Some(out))
208}
209
210fn collect_builtin_source(
211 schema_ref: &SchemaRef,
212) -> Result<Option<Vec<SchemaSourceFile>>, SchemaSourceError> {
213 let Some(schema_dir) = builtin_schemas_dir().get_dir(schema_ref.name.as_str()) else {
214 return Ok(None);
215 };
216
217 let schema_name = schema_ref.name.as_str();
221 let manifest_key = format!("{schema_name}/schema.yaml");
222 let manifest_file = schema_dir.get_file(manifest_key.as_str()).ok_or_else(|| {
223 SchemaSourceError::MalformedManifest {
224 path: PathBuf::from(&manifest_key),
225 reason: "embedded schema directory is missing schema.yaml".into(),
226 }
227 })?;
228 let manifest_bytes = manifest_file.contents().to_vec();
229 if !manifest_matches(
230 &manifest_bytes,
231 schema_ref,
232 &PathBuf::from(format!("<builtin:{schema_name}>/schema.yaml")),
233 )? {
234 return Ok(None);
235 }
236
237 let mut out = vec![SchemaSourceFile {
238 archive_path: "schema.yaml".to_string(),
239 bytes: manifest_bytes,
240 }];
241
242 let types_key = format!("{schema_name}/types");
243 if let Some(types_dir) = schema_dir.get_dir(types_key.as_str()) {
244 for file in types_dir.files() {
245 if file.path().extension().and_then(|s| s.to_str()) != Some("yaml") {
246 continue;
247 }
248 let Some(stem) = file.path().file_stem().and_then(|s| s.to_str()) else {
249 continue;
250 };
251 out.push(SchemaSourceFile {
252 archive_path: format!("types/{stem}.yaml"),
253 bytes: file.contents().to_vec(),
254 });
255 }
256 }
257
258 out.sort_by(|a, b| a.archive_path.cmp(&b.archive_path));
259 Ok(Some(out))
260}
261
262fn manifest_matches(
269 manifest_bytes: &[u8],
270 schema_ref: &SchemaRef,
271 source_path: &Path,
272) -> Result<bool, SchemaSourceError> {
273 #[derive(serde::Deserialize)]
274 struct ManifestId {
275 name: String,
276 version: String,
277 }
278 let id: ManifestId = serde_yaml_ng::from_slice(manifest_bytes).map_err(|e| {
279 SchemaSourceError::MalformedManifest {
280 path: source_path.to_path_buf(),
281 reason: e.to_string(),
282 }
283 })?;
284 if id.name != schema_ref.name {
285 return Ok(false);
286 }
287 let declared =
288 semver::Version::parse(&id.version).map_err(|e| SchemaSourceError::MalformedManifest {
289 path: source_path.to_path_buf(),
290 reason: format!("invalid semver '{}': {e}", id.version),
291 })?;
292 if declared != schema_ref.version {
293 if source_path.starts_with("<builtin:") {
301 return Ok(false);
302 }
303 return Err(SchemaSourceError::VersionMismatch {
304 path: source_path.to_path_buf(),
305 expected: schema_ref.version.to_string(),
306 found: declared.to_string(),
307 });
308 }
309 Ok(true)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use tempfile::TempDir;
316
317 fn write_schema(dir: &Path, name: &str, version: &str, types: &[&str]) {
318 let manifest = format!(
319 r#"name: {name}
320version: {version}
321description: test
322when_to_use: test
323types:
324 - {type_list}
325relationships:
326 mode: strict
327 definitions:
328 - name: _default
329 description: default
330 default_weight: 1.0
331 - name: PART_OF
332 description: hier
333 default_weight: 3.0
334community:
335 resolution: 1.0
336 seed: 42
337"#,
338 name = name,
339 version = version,
340 type_list = types.join("\n - "),
341 );
342 std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
343 for t in types {
344 let td = format!(
345 r#"name: {t}
346description: test
347when_to_use: test
348sections:
349 - key: body
350 heading: Body
351 required: true
352 search_weight: 10.0
353 catch_all: true
354metadata_fields: []
355title_weight: 1.0
356text_fields: [body]
357hierarchy_relationship: PART_OF
358propagating_relationships: []
359updatable_fields: [title, body]
360health_required_fields: [body]
361staleness_threshold_days: 30
362write_rules: []
363"#
364 );
365 std::fs::write(dir.join(format!("types/{t}.yaml")), td).unwrap();
366 }
367 }
368
369 #[test]
370 fn collects_builtin_default_source() {
371 let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
372 let files = collect_schema_source(None, None, &schema_ref).unwrap();
373
374 assert!(
375 files.iter().any(|f| f.archive_path == "schema.yaml"),
376 "embedded builtin must expose schema.yaml"
377 );
378 let type_count = files
379 .iter()
380 .filter(|f| f.archive_path.starts_with("types/"))
381 .count();
382 assert_eq!(type_count, 10, "default schema has 10 types");
383 for pair in files.windows(2) {
384 assert!(pair[0].archive_path < pair[1].archive_path, "sorted");
385 }
386 }
387
388 #[test]
389 fn workspace_schema_wins_over_builtin() {
390 let tmp = TempDir::new().unwrap();
391 let ws_dir = tmp.path().join("schemas");
392 let schema_dir = ws_dir.join("default");
393 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
394 write_schema(&schema_dir, "default", "1.0.0", &["spec"]);
397 let schema_ref = SchemaRef::new("default", semver::Version::new(1, 0, 0));
398 let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
399 let type_count = files
400 .iter()
401 .filter(|f| f.archive_path.starts_with("types/"))
402 .count();
403 assert_eq!(type_count, 1, "workspace override takes priority");
404 }
405
406 #[test]
407 fn workspace_mismatched_version_errors() {
408 let tmp = TempDir::new().unwrap();
409 let ws_dir = tmp.path().join("schemas");
410 let schema_dir = ws_dir.join("recipe");
411 std::fs::create_dir_all(schema_dir.join("types")).unwrap();
412 write_schema(&schema_dir, "recipe", "1.0.0", &["spec"]);
413 let schema_ref = SchemaRef::new("recipe", semver::Version::new(2, 0, 0));
414 let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
415 assert!(matches!(err, SchemaSourceError::VersionMismatch { .. }));
416 }
417
418 #[test]
419 fn cache_schema_resolves_when_workspace_layer_absent() {
420 let tmp = TempDir::new().unwrap();
421 let dir = tmp.path().join(".memstead.cache/schemas/recipe-1.0.0");
423 std::fs::create_dir_all(dir.join("types")).unwrap();
424 write_schema(&dir, "recipe", "1.0.0", &["spec"]);
425 let schema_ref = SchemaRef::new("recipe", semver::Version::new(1, 0, 0));
426 let files = collect_schema_source(Some(tmp.path()), None, &schema_ref).unwrap();
427 assert!(files.iter().any(|f| f.archive_path == "schema.yaml"));
428 }
429
430 #[test]
431 fn unknown_schema_returns_not_found() {
432 let schema_ref = SchemaRef::new("nonexistent", semver::Version::new(1, 0, 0));
433 let err = collect_schema_source(None, None, &schema_ref).unwrap_err();
434 assert!(matches!(err, SchemaSourceError::NotFound { .. }));
435 }
436
437 #[test]
438 fn workspace_schema_wins_over_cache() {
439 let tmp = TempDir::new().unwrap();
440 let cache_dir = tmp.path().join(".memstead.cache/schemas/software-1.0.0");
442 std::fs::create_dir_all(cache_dir.join("types")).unwrap();
443 write_schema(&cache_dir, "software", "1.0.0", &["spec", "memo"]);
444 let ws_dir = tmp.path().join("schemas");
446 let ws_schema = ws_dir.join("software");
447 std::fs::create_dir_all(ws_schema.join("types")).unwrap();
448 write_schema(&ws_schema, "software", "1.0.0", &["spec"]);
449
450 let schema_ref = SchemaRef::new("software", semver::Version::new(1, 0, 0));
451 let files = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap();
452 let type_count = files
453 .iter()
454 .filter(|f| f.archive_path.starts_with("types/"))
455 .count();
456 assert_eq!(type_count, 1, "workspace layer must win over cache");
457 }
458
459 #[test]
460 fn not_found_lists_every_candidate_path() {
461 let tmp = TempDir::new().unwrap();
462 let ws_dir = tmp.path().join("schemas");
463 std::fs::create_dir_all(&ws_dir).unwrap();
464
465 let schema_ref = SchemaRef::new("missing", semver::Version::new(2, 3, 4));
466 let err = collect_schema_source(Some(tmp.path()), Some(&ws_dir), &schema_ref).unwrap_err();
467 match err {
468 SchemaSourceError::NotFound {
469 schema_ref: name,
470 candidates,
471 } => {
472 assert_eq!(name, "missing@2.3.4");
473 assert_eq!(candidates.len(), 3);
477 assert!(candidates[0].ends_with("schemas/missing@2.3.4"));
478 assert!(candidates[1].ends_with("schemas/missing"));
479 assert!(
480 candidates[2]
481 .to_string_lossy()
482 .contains(".memstead.cache/schemas/missing-2.3.4")
483 );
484 }
485 other => panic!("expected NotFound, got {other:?}"),
486 }
487 }
488}