Skip to main content

gruel_manifest/
lib.rs

1//! Package manifest loader for Gruel (`gruel.json`).
2//!
3//! See [ADR-0092](../../../docs/designs/0092-package-manifest.md). The manifest
4//! is deliberately tiny: a name, a version, and exactly one of `bin` or `lib`
5//! whose `root` points at the entry `.gruel` file. No dependencies, no
6//! lockfile, no registry — this ADR establishes the schema and discovery
7//! plumbing; future ADRs layer everything else on top.
8//!
9//! ```json
10//! {
11//!   "name": "hello",
12//!   "version": "0.1.0",
13//!   "bin": { "root": "src/main.gruel" }
14//! }
15//! ```
16
17use std::path::{Path, PathBuf};
18
19use serde::Deserialize;
20
21/// A successfully loaded and validated manifest.
22#[derive(Debug, Clone)]
23pub struct Manifest {
24    /// Display name (`name` field).
25    pub name: String,
26    /// Semver version (`version` field).
27    pub version: semver::Version,
28    /// The package target — either binary or library.
29    pub target: PackageTarget,
30    /// Directory containing the manifest (its parent).
31    pub manifest_dir: PathBuf,
32    /// Absolute path of the manifest file itself.
33    pub manifest_path: PathBuf,
34}
35
36/// The package's target kind. Exactly one of `bin` or `lib` is allowed
37/// per manifest (Phase 1 of ADR-0092).
38#[derive(Debug, Clone)]
39pub enum PackageTarget {
40    /// Binary target — `bin` block in JSON.
41    Binary(TargetSpec),
42    /// Library target — `lib` block in JSON. Cannot `build` or `run`
43    /// in this ADR; future ADRs will add artefact emission.
44    Library(TargetSpec),
45}
46
47impl PackageTarget {
48    /// Absolute, validated path of the entry `.gruel` file.
49    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/// Per-target configuration. Currently just `root` — additive future
65/// fields land here.
66#[derive(Debug, Clone)]
67pub struct TargetSpec {
68    /// Absolute path resolved against the manifest's directory.
69    pub root: PathBuf,
70}
71
72/// Anything that can go wrong loading a manifest.
73#[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    /// Path of the manifest that produced this error.
123    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// ---------------------------------------------------------------------------
138// Internal raw form for serde
139// ---------------------------------------------------------------------------
140
141#[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
160// ---------------------------------------------------------------------------
161// Public API
162// ---------------------------------------------------------------------------
163
164/// Load and validate a manifest at the given path.
165///
166/// `manifest_path` must point directly at the `gruel.json` file. Callers
167/// who want directory-or-file semantics should use the higher-level
168/// `discover_*` helpers (or the CLI's classification logic) first.
169pub 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
198/// Walk up from `start` (inclusive) looking for a `gruel.json` file.
199/// Used by the CLI; npm-style "first hit wins" semantics.
200pub 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
214/// Look for `gruel.json` at exactly `root` (no upward walk, no
215/// subdirectory scan). Used by the LSP.
216pub fn discover_at_root(root: &Path) -> Option<PathBuf> {
217    let candidate = root.join("gruel.json");
218    candidate.is_file().then_some(candidate)
219}
220
221// ---------------------------------------------------------------------------
222// Validation
223// ---------------------------------------------------------------------------
224
225fn 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        // Should not find anything (assumes no gruel.json above tmp dir on test host).
607        // The discovery walks all the way to /, but real systems shouldn't have a
608        // gruel.json there — we accept a small risk here.
609        let result = discover_upward(&nested);
610        // If it found one outside our control, at least it must be readable.
611        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}