1use std::path::{Path, PathBuf};
18
19use serde::Deserialize;
20
21#[derive(Debug, Clone)]
23pub struct Manifest {
24 pub name: String,
26 pub version: semver::Version,
28 pub target: PackageTarget,
30 pub manifest_dir: PathBuf,
32 pub manifest_path: PathBuf,
34}
35
36#[derive(Debug, Clone)]
39pub enum PackageTarget {
40 Binary(TargetSpec),
42 Library(TargetSpec),
45}
46
47impl PackageTarget {
48 pub fn root(&self) -> &Path {
50 match self {
51 PackageTarget::Binary(spec) | PackageTarget::Library(spec) => &spec.root,
52 }
53 }
54
55 pub fn is_binary(&self) -> bool {
56 matches!(self, PackageTarget::Binary(_))
57 }
58
59 pub fn is_library(&self) -> bool {
60 matches!(self, PackageTarget::Library(_))
61 }
62}
63
64#[derive(Debug, Clone)]
67pub struct TargetSpec {
68 pub root: PathBuf,
70}
71
72#[derive(Debug, thiserror::Error)]
74pub enum ManifestError {
75 #[error("failed to read manifest at {path}: {source}")]
76 Io {
77 path: PathBuf,
78 #[source]
79 source: std::io::Error,
80 },
81
82 #[error("failed to parse manifest at {path}: {source}")]
83 Parse {
84 path: PathBuf,
85 #[source]
86 source: serde_json::Error,
87 },
88
89 #[error("invalid manifest at {path}: missing required field '{field}'")]
90 MissingField { path: PathBuf, field: &'static str },
91
92 #[error(
93 "invalid manifest at {path}: no target specified — exactly one of 'bin' or 'lib' is required"
94 )]
95 MissingTarget { path: PathBuf },
96
97 #[error(
98 "invalid manifest at {path}: conflicting targets — only one of 'bin' or 'lib' may be present"
99 )]
100 ConflictingTargets { path: PathBuf },
101
102 #[error("invalid manifest at {path}: bad 'version' value '{value}': {source}")]
103 BadVersion {
104 path: PathBuf,
105 value: String,
106 #[source]
107 source: semver::Error,
108 },
109
110 #[error("invalid manifest at {path}: bad 'root' value '{value}': {reason}")]
111 BadRoot {
112 path: PathBuf,
113 value: String,
114 reason: String,
115 },
116
117 #[error("invalid manifest at {path}: 'root' file not found at {resolved}")]
118 RootNotFound { path: PathBuf, resolved: PathBuf },
119}
120
121impl ManifestError {
122 pub fn path(&self) -> &Path {
124 match self {
125 ManifestError::Io { path, .. }
126 | ManifestError::Parse { path, .. }
127 | ManifestError::MissingField { path, .. }
128 | ManifestError::MissingTarget { path }
129 | ManifestError::ConflictingTargets { path }
130 | ManifestError::BadVersion { path, .. }
131 | ManifestError::BadRoot { path, .. }
132 | ManifestError::RootNotFound { path, .. } => path,
133 }
134 }
135}
136
137#[derive(Debug, Deserialize)]
142#[serde(deny_unknown_fields)]
143struct RawManifest {
144 #[serde(default)]
145 name: Option<String>,
146 #[serde(default)]
147 version: Option<String>,
148 #[serde(default)]
149 bin: Option<RawTarget>,
150 #[serde(default)]
151 lib: Option<RawTarget>,
152}
153
154#[derive(Debug, Deserialize)]
155#[serde(deny_unknown_fields)]
156struct RawTarget {
157 root: String,
158}
159
160pub fn load_at(manifest_path: &Path) -> Result<Manifest, ManifestError> {
170 let canonical_path = match manifest_path.canonicalize() {
171 Ok(p) => p,
172 Err(err) => {
173 return Err(ManifestError::Io {
174 path: manifest_path.to_path_buf(),
175 source: err,
176 });
177 }
178 };
179
180 let manifest_dir = canonical_path
181 .parent()
182 .map(Path::to_path_buf)
183 .unwrap_or_else(|| PathBuf::from("."));
184
185 let bytes = std::fs::read(&canonical_path).map_err(|err| ManifestError::Io {
186 path: canonical_path.clone(),
187 source: err,
188 })?;
189
190 let raw: RawManifest = serde_json::from_slice(&bytes).map_err(|err| ManifestError::Parse {
191 path: canonical_path.clone(),
192 source: err,
193 })?;
194
195 parse_raw(raw, canonical_path, manifest_dir)
196}
197
198pub fn discover_upward(start: &Path) -> Option<PathBuf> {
201 let absolute = match start.canonicalize() {
202 Ok(p) => p,
203 Err(_) => start.to_path_buf(),
204 };
205 for ancestor in absolute.ancestors() {
206 let candidate = ancestor.join("gruel.json");
207 if candidate.is_file() {
208 return Some(candidate);
209 }
210 }
211 None
212}
213
214pub fn discover_at_root(root: &Path) -> Option<PathBuf> {
217 let candidate = root.join("gruel.json");
218 candidate.is_file().then_some(candidate)
219}
220
221fn parse_raw(
226 raw: RawManifest,
227 manifest_path: PathBuf,
228 manifest_dir: PathBuf,
229) -> Result<Manifest, ManifestError> {
230 let name = match raw.name {
231 Some(n) if !n.is_empty() => n,
232 Some(_) => {
233 return Err(ManifestError::BadRoot {
234 path: manifest_path,
235 value: String::new(),
236 reason: "'name' must be a non-empty string".to_string(),
237 });
238 }
239 None => {
240 return Err(ManifestError::MissingField {
241 path: manifest_path,
242 field: "name",
243 });
244 }
245 };
246
247 let version_str = raw.version.ok_or_else(|| ManifestError::MissingField {
248 path: manifest_path.clone(),
249 field: "version",
250 })?;
251 let version = semver::Version::parse(&version_str).map_err(|err| ManifestError::BadVersion {
252 path: manifest_path.clone(),
253 value: version_str.clone(),
254 source: err,
255 })?;
256
257 let target = match (raw.bin, raw.lib) {
258 (Some(_), Some(_)) => {
259 return Err(ManifestError::ConflictingTargets {
260 path: manifest_path,
261 });
262 }
263 (Some(bin), None) => {
264 let spec = resolve_target(bin, &manifest_path, &manifest_dir)?;
265 PackageTarget::Binary(spec)
266 }
267 (None, Some(lib)) => {
268 let spec = resolve_target(lib, &manifest_path, &manifest_dir)?;
269 PackageTarget::Library(spec)
270 }
271 (None, None) => {
272 return Err(ManifestError::MissingTarget {
273 path: manifest_path,
274 });
275 }
276 };
277
278 Ok(Manifest {
279 name,
280 version,
281 target,
282 manifest_dir,
283 manifest_path,
284 })
285}
286
287fn resolve_target(
288 raw: RawTarget,
289 manifest_path: &Path,
290 manifest_dir: &Path,
291) -> Result<TargetSpec, ManifestError> {
292 let root_str = raw.root;
293
294 if root_str.is_empty() {
295 return Err(ManifestError::BadRoot {
296 path: manifest_path.to_path_buf(),
297 value: root_str,
298 reason: "'root' must be a non-empty path".to_string(),
299 });
300 }
301
302 let root_path = Path::new(&root_str);
303 if root_path.is_absolute() {
304 return Err(ManifestError::BadRoot {
305 path: manifest_path.to_path_buf(),
306 value: root_str,
307 reason: "'root' must be a relative path".to_string(),
308 });
309 }
310
311 if root_path.extension().and_then(|s| s.to_str()) != Some("gruel") {
312 return Err(ManifestError::BadRoot {
313 path: manifest_path.to_path_buf(),
314 value: root_str,
315 reason: "'root' must point to a .gruel file".to_string(),
316 });
317 }
318
319 let candidate = manifest_dir.join(root_path);
320 let resolved = match candidate.canonicalize() {
321 Ok(p) => p,
322 Err(_) => {
323 return Err(ManifestError::RootNotFound {
324 path: manifest_path.to_path_buf(),
325 resolved: candidate,
326 });
327 }
328 };
329
330 if !resolved.starts_with(manifest_dir) {
331 return Err(ManifestError::BadRoot {
332 path: manifest_path.to_path_buf(),
333 value: root_str,
334 reason: "'root' must resolve to a path inside the manifest's directory".to_string(),
335 });
336 }
337
338 if !resolved.is_file() {
339 return Err(ManifestError::RootNotFound {
340 path: manifest_path.to_path_buf(),
341 resolved,
342 });
343 }
344
345 Ok(TargetSpec { root: resolved })
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use std::fs;
352 use tempfile::TempDir;
353
354 fn write_manifest(dir: &Path, contents: &str) -> PathBuf {
355 let path = dir.join("gruel.json");
356 fs::write(&path, contents).unwrap();
357 path
358 }
359
360 fn touch_gruel(dir: &Path, rel: &str) -> PathBuf {
361 let path = dir.join(rel);
362 if let Some(parent) = path.parent() {
363 fs::create_dir_all(parent).unwrap();
364 }
365 fs::write(&path, "// stub\n").unwrap();
366 path
367 }
368
369 #[test]
370 fn load_binary_manifest_happy_path() {
371 let tmp = TempDir::new().unwrap();
372 let root = tmp.path().canonicalize().unwrap();
373 let entry = touch_gruel(&root, "src/main.gruel");
374 let path = write_manifest(
375 &root,
376 r#"{
377 "name": "hello",
378 "version": "0.1.0",
379 "bin": { "root": "src/main.gruel" }
380 }"#,
381 );
382
383 let manifest = load_at(&path).expect("load should succeed");
384 assert_eq!(manifest.name, "hello");
385 assert_eq!(manifest.version, semver::Version::parse("0.1.0").unwrap());
386 assert!(manifest.target.is_binary());
387 assert_eq!(manifest.target.root(), entry.canonicalize().unwrap());
388 assert_eq!(manifest.manifest_dir, root);
389 assert_eq!(manifest.manifest_path, path.canonicalize().unwrap());
390 }
391
392 #[test]
393 fn load_library_manifest_happy_path() {
394 let tmp = TempDir::new().unwrap();
395 let root = tmp.path().canonicalize().unwrap();
396 touch_gruel(&root, "src/lib.gruel");
397 let path = write_manifest(
398 &root,
399 r#"{
400 "name": "math",
401 "version": "0.2.3-rc.1",
402 "lib": { "root": "src/lib.gruel" }
403 }"#,
404 );
405
406 let manifest = load_at(&path).unwrap();
407 assert_eq!(manifest.name, "math");
408 assert_eq!(
409 manifest.version,
410 semver::Version::parse("0.2.3-rc.1").unwrap()
411 );
412 assert!(manifest.target.is_library());
413 }
414
415 #[test]
416 fn missing_target_rejected() {
417 let tmp = TempDir::new().unwrap();
418 let path = write_manifest(
419 tmp.path(),
420 r#"{ "name": "x", "version": "0.1.0" }"#,
421 );
422 let err = load_at(&path).unwrap_err();
423 assert!(matches!(err, ManifestError::MissingTarget { .. }), "got {err:?}");
424 }
425
426 #[test]
427 fn conflicting_targets_rejected() {
428 let tmp = TempDir::new().unwrap();
429 let root = tmp.path().canonicalize().unwrap();
430 touch_gruel(&root, "a.gruel");
431 touch_gruel(&root, "b.gruel");
432 let path = write_manifest(
433 &root,
434 r#"{
435 "name": "x",
436 "version": "0.1.0",
437 "bin": { "root": "a.gruel" },
438 "lib": { "root": "b.gruel" }
439 }"#,
440 );
441 let err = load_at(&path).unwrap_err();
442 assert!(matches!(err, ManifestError::ConflictingTargets { .. }), "got {err:?}");
443 }
444
445 #[test]
446 fn unknown_top_level_field_rejected() {
447 let tmp = TempDir::new().unwrap();
448 let root = tmp.path().canonicalize().unwrap();
449 touch_gruel(&root, "main.gruel");
450 let path = write_manifest(
451 &root,
452 r#"{
453 "name": "x",
454 "version": "0.1.0",
455 "bin": { "root": "main.gruel" },
456 "license": "MIT"
457 }"#,
458 );
459 let err = load_at(&path).unwrap_err();
460 assert!(matches!(err, ManifestError::Parse { .. }), "got {err:?}");
461 }
462
463 #[test]
464 fn unknown_target_field_rejected() {
465 let tmp = TempDir::new().unwrap();
466 let root = tmp.path().canonicalize().unwrap();
467 touch_gruel(&root, "main.gruel");
468 let path = write_manifest(
469 &root,
470 r#"{
471 "name": "x",
472 "version": "0.1.0",
473 "bin": { "root": "main.gruel", "extra": true }
474 }"#,
475 );
476 let err = load_at(&path).unwrap_err();
477 assert!(matches!(err, ManifestError::Parse { .. }), "got {err:?}");
478 }
479
480 #[test]
481 fn missing_name_rejected() {
482 let tmp = TempDir::new().unwrap();
483 let root = tmp.path().canonicalize().unwrap();
484 touch_gruel(&root, "main.gruel");
485 let path = write_manifest(
486 &root,
487 r#"{ "version": "0.1.0", "bin": { "root": "main.gruel" } }"#,
488 );
489 let err = load_at(&path).unwrap_err();
490 match err {
491 ManifestError::MissingField { field, .. } => assert_eq!(field, "name"),
492 other => panic!("expected MissingField, got {other:?}"),
493 }
494 }
495
496 #[test]
497 fn missing_version_rejected() {
498 let tmp = TempDir::new().unwrap();
499 let root = tmp.path().canonicalize().unwrap();
500 touch_gruel(&root, "main.gruel");
501 let path = write_manifest(
502 &root,
503 r#"{ "name": "x", "bin": { "root": "main.gruel" } }"#,
504 );
505 let err = load_at(&path).unwrap_err();
506 match err {
507 ManifestError::MissingField { field, .. } => assert_eq!(field, "version"),
508 other => panic!("expected MissingField, got {other:?}"),
509 }
510 }
511
512 #[test]
513 fn bad_version_rejected() {
514 let tmp = TempDir::new().unwrap();
515 let root = tmp.path().canonicalize().unwrap();
516 touch_gruel(&root, "main.gruel");
517 let path = write_manifest(
518 &root,
519 r#"{ "name": "x", "version": "not-semver", "bin": { "root": "main.gruel" } }"#,
520 );
521 let err = load_at(&path).unwrap_err();
522 assert!(matches!(err, ManifestError::BadVersion { .. }), "got {err:?}");
523 }
524
525 #[test]
526 fn absolute_root_rejected() {
527 let tmp = TempDir::new().unwrap();
528 let root = tmp.path().canonicalize().unwrap();
529 touch_gruel(&root, "main.gruel");
530 let path = write_manifest(
531 &root,
532 r#"{ "name": "x", "version": "0.1.0", "bin": { "root": "/abs/main.gruel" } }"#,
533 );
534 let err = load_at(&path).unwrap_err();
535 assert!(matches!(err, ManifestError::BadRoot { .. }), "got {err:?}");
536 }
537
538 #[test]
539 fn root_wrong_extension_rejected() {
540 let tmp = TempDir::new().unwrap();
541 let root = tmp.path().canonicalize().unwrap();
542 touch_gruel(&root, "main.gruel");
543 fs::write(root.join("main.txt"), "").unwrap();
544 let path = write_manifest(
545 &root,
546 r#"{ "name": "x", "version": "0.1.0", "bin": { "root": "main.txt" } }"#,
547 );
548 let err = load_at(&path).unwrap_err();
549 assert!(matches!(err, ManifestError::BadRoot { .. }), "got {err:?}");
550 }
551
552 #[test]
553 fn root_missing_rejected() {
554 let tmp = TempDir::new().unwrap();
555 let root = tmp.path().canonicalize().unwrap();
556 let path = write_manifest(
557 &root,
558 r#"{ "name": "x", "version": "0.1.0", "bin": { "root": "does-not-exist.gruel" } }"#,
559 );
560 let err = load_at(&path).unwrap_err();
561 assert!(matches!(err, ManifestError::RootNotFound { .. }), "got {err:?}");
562 }
563
564 #[test]
565 fn root_outside_manifest_dir_rejected() {
566 let outer = TempDir::new().unwrap();
567 let outer_canon = outer.path().canonicalize().unwrap();
568 touch_gruel(&outer_canon, "outside.gruel");
569 let inner = outer_canon.join("pkg");
570 fs::create_dir(&inner).unwrap();
571 let path = write_manifest(
572 &inner,
573 r#"{ "name": "x", "version": "0.1.0", "bin": { "root": "../outside.gruel" } }"#,
574 );
575 let err = load_at(&path).unwrap_err();
576 assert!(matches!(err, ManifestError::BadRoot { .. }), "got {err:?}");
577 }
578
579 #[test]
580 fn manifest_io_error_when_file_missing() {
581 let tmp = TempDir::new().unwrap();
582 let missing = tmp.path().join("gruel.json");
583 let err = load_at(&missing).unwrap_err();
584 assert!(matches!(err, ManifestError::Io { .. }), "got {err:?}");
585 }
586
587 #[test]
588 fn discover_upward_finds_manifest() {
589 let tmp = TempDir::new().unwrap();
590 let root = tmp.path().canonicalize().unwrap();
591 touch_gruel(&root, "src/main.gruel");
592 let manifest_path = write_manifest(
593 &root,
594 r#"{ "name": "x", "version": "0.1.0", "bin": { "root": "src/main.gruel" } }"#,
595 );
596 let sub = root.join("src");
597 let found = discover_upward(&sub).expect("should walk up and find manifest");
598 assert_eq!(found.canonicalize().unwrap(), manifest_path.canonicalize().unwrap());
599 }
600
601 #[test]
602 fn discover_upward_returns_none_when_absent() {
603 let tmp = TempDir::new().unwrap();
604 let nested = tmp.path().join("a").join("b");
605 fs::create_dir_all(&nested).unwrap();
606 let result = discover_upward(&nested);
610 if let Some(path) = result {
612 assert!(path.is_file(), "discover returned non-file path: {path:?}");
613 }
614 }
615
616 #[test]
617 fn discover_at_root_finds_manifest() {
618 let tmp = TempDir::new().unwrap();
619 let root = tmp.path().canonicalize().unwrap();
620 touch_gruel(&root, "src/main.gruel");
621 write_manifest(
622 &root,
623 r#"{ "name": "x", "version": "0.1.0", "bin": { "root": "src/main.gruel" } }"#,
624 );
625 let found = discover_at_root(&root).expect("should find manifest at root");
626 assert!(found.file_name().map(|n| n == "gruel.json").unwrap_or(false));
627 }
628
629 #[test]
630 fn discover_at_root_does_not_walk_up() {
631 let tmp = TempDir::new().unwrap();
632 let root = tmp.path().canonicalize().unwrap();
633 touch_gruel(&root, "src/main.gruel");
634 write_manifest(
635 &root,
636 r#"{ "name": "x", "version": "0.1.0", "bin": { "root": "src/main.gruel" } }"#,
637 );
638 let sub = root.join("src");
639 assert!(discover_at_root(&sub).is_none());
640 }
641
642 #[test]
643 fn version_accepts_prerelease_and_build_metadata() {
644 let tmp = TempDir::new().unwrap();
645 let root = tmp.path().canonicalize().unwrap();
646 touch_gruel(&root, "main.gruel");
647 let path = write_manifest(
648 &root,
649 r#"{ "name": "x", "version": "0.1.0-alpha.1+build.7", "bin": { "root": "main.gruel" } }"#,
650 );
651 let manifest = load_at(&path).unwrap();
652 assert_eq!(
653 manifest.version,
654 semver::Version::parse("0.1.0-alpha.1+build.7").unwrap()
655 );
656 }
657
658 #[test]
659 fn empty_name_rejected() {
660 let tmp = TempDir::new().unwrap();
661 let root = tmp.path().canonicalize().unwrap();
662 touch_gruel(&root, "main.gruel");
663 let path = write_manifest(
664 &root,
665 r#"{ "name": "", "version": "0.1.0", "bin": { "root": "main.gruel" } }"#,
666 );
667 let err = load_at(&path).unwrap_err();
668 assert!(matches!(err, ManifestError::BadRoot { .. }), "got {err:?}");
669 }
670}