Skip to main content

stryke/pkg/
manifest.rs

1//! `stryke.toml` parser and serializer. Backed by `serde` + `toml` so round-tripping
2//! preserves table ordering, comments are dropped (TOML comment preservation is not
3//! a `serde`-friendly use case — round-trip is for in-place edits via `s add`/`s remove`,
4//! not human-authored comment retention).
5//!
6//! Schema: see RFC §"Manifest" (`docs/PACKAGE_REGISTRY.md` lines 75–124).
7
8use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12use super::{PkgError, PkgResult};
13
14/// Top-level `stryke.toml` manifest.
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct Manifest {
17    /// `[package]` — present for normal packages; absent for pure workspace roots.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub package: Option<PackageMeta>,
20
21    /// `[deps]` — runtime dependencies.
22    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
23    pub deps: IndexMap<String, DepSpec>,
24
25    /// `[dev-deps]` — only present when running tests/benches.
26    #[serde(
27        rename = "dev-deps",
28        default,
29        skip_serializing_if = "IndexMap::is_empty"
30    )]
31    /// `dev_deps` field.
32    pub dev_deps: IndexMap<String, DepSpec>,
33
34    /// `[groups.NAME]` — bundler-style arbitrary groups (e.g. `groups.bench`).
35    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
36    pub groups: IndexMap<String, IndexMap<String, DepSpec>>,
37
38    /// `[features]` — feature flags. Per-package scoped (no workspace unification).
39    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
40    pub features: IndexMap<String, Vec<String>>,
41
42    /// `[scripts]` — npm-style task runner aliases (run via `s run <name>`).
43    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
44    pub scripts: IndexMap<String, String>,
45
46    /// `[bin]` — explicit binary entry points. Auto-discovery from `bin/` happens
47    /// at build time when this map is empty.
48    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
49    pub bin: IndexMap<String, String>,
50
51    /// `[workspace]` — workspace root configuration.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub workspace: Option<WorkspaceConfig>,
54
55    /// `[ffi]` — opt-in native cdylib package. When present, the package ships
56    /// a `lib/lib<lib_name>.{dylib,so,dll}` whose `extern "C"` exports are
57    /// dlopened and registered as stryke functions on first `use <namespace>`.
58    /// Replaces the per-package helper-binary + JSON-over-pipe pattern that
59    /// every pre-FFI stryke package used.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub ffi: Option<FfiManifest>,
62}
63
64/// `[ffi]` table — declares a precompiled cdylib shipped alongside the .stk lib.
65///
66/// All exports follow the JSON-string-in / JSON-string-out shape, matching
67/// `rust_ffi.rs`'s existing `FfiSig::StrToStr` arm:
68///
69/// ```ignore
70/// #[no_mangle]
71/// pub extern "C" fn <export>(args_json: *const c_char) -> *const c_char
72/// ```
73///
74/// The cdylib must also export `stryke_free_cstring(*mut c_char)` so the
75/// stryke side can free returned strings without leaking. Loading is wired
76/// in [`crate::pkg::commands::resolve_module`].
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct FfiManifest {
79    /// Library file stem — produces `lib<lib_name>.{dylib,so}` (unix) or
80    /// `<lib_name>.dll` (windows). Conventionally `stryke_<package_name>`.
81    #[serde(rename = "lib-name")]
82    pub lib_name: String,
83
84    /// Stryke-side namespace the exports register under. The .stk wrapper
85    /// declares `package <namespace>` and calls the exports as
86    /// `<namespace>::<export_minus_prefix>` (the bare symbol name lives in
87    /// `exports`).
88    #[serde(default, skip_serializing_if = "String::is_empty")]
89    pub namespace: String,
90
91    /// Bare `extern "C"` symbol names to resolve from the dlopened cdylib.
92    /// Each becomes a stryke-callable function registered under its full
93    /// symbol name (the .stk wrapper invokes it directly).
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub exports: Vec<String>,
96}
97
98/// `[package]` table.
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct PackageMeta {
101    /// `name` field.
102    pub name: String,
103    /// `version` field.
104    pub version: String,
105    /// `description` field.
106    #[serde(default, skip_serializing_if = "String::is_empty")]
107    pub description: String,
108    /// `authors` field.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub authors: Vec<String>,
111    /// `license` field.
112    #[serde(default, skip_serializing_if = "String::is_empty")]
113    pub license: String,
114    /// `repository` field.
115    #[serde(default, skip_serializing_if = "String::is_empty")]
116    pub repository: String,
117    /// Language edition pin (e.g. `"2026"`). Defaults are inferred at build time.
118    #[serde(default, skip_serializing_if = "String::is_empty")]
119    pub edition: String,
120}
121
122/// One dep spec: either `"1.0"` (shorthand for `{ version = "1.0" }`) or a
123/// fully-expanded inline table (`{ version, features, path, git, ... }`).
124///
125/// On serialize, simple version-only specs round-trip back to the shorthand form
126/// for cleaner manifests.
127#[derive(Debug, Clone, Serialize, Deserialize, Default)]
128#[serde(untagged)]
129pub enum DepSpec {
130    /// `http = "1.0"` — bare version requirement.
131    Version(String),
132    /// `crypto = { version = "0.5", features = [...], path = ..., git = ..., ... }`.
133    Detailed(DetailedDep),
134    /// Empty placeholder so [`Default`] can construct a valid value.
135    #[default]
136    #[serde(skip)]
137    Placeholder,
138}
139
140/// Inline-table form of a dep spec.
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142pub struct DetailedDep {
143    /// `version` field.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub version: Option<String>,
146    /// `features` field.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub features: Vec<String>,
149    /// `path = "../mylib"` — local path dependency.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub path: Option<String>,
152    /// `git = "https://..."` — git dependency. Combined with `branch`/`tag`/`rev`.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub git: Option<String>,
155    /// `github = "OWNER/REPO"` — GitHub-release dependency. The resolver
156    /// downloads the prebuilt tarball for the host triple from the
157    /// repo's GitHub Releases (latest tag when `version` is unset),
158    /// SHA-256 verifies it, and extracts into the store. Used for FFI
159    /// cdylib packages (stryke-arrow, stryke-aws, ...) whose binary
160    /// can't be reproduced from a source clone without the platform
161    /// toolchain. Distinct from `git`, which clones the source tree.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub github: Option<String>,
164    /// `branch` field.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub branch: Option<String>,
167    /// `tag` field.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub tag: Option<String>,
170    /// `rev` field.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub rev: Option<String>,
173    /// `registry = "https://..."` — alternate registry for this dep.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub registry: Option<String>,
176    /// `optional = true` — only pulled in when a feature flag enables it.
177    #[serde(default, skip_serializing_if = "is_false")]
178    pub optional: bool,
179    /// `default-features = false` — opt out of the dep's default features.
180    #[serde(
181        rename = "default-features",
182        default = "default_true",
183        skip_serializing_if = "is_true_default"
184    )]
185    /// `default_features` field.
186    pub default_features: bool,
187    /// `workspace = true` — inherit version/features from workspace root.
188    #[serde(default, skip_serializing_if = "is_false")]
189    pub workspace: bool,
190}
191
192fn is_false(b: &bool) -> bool {
193    !*b
194}
195fn default_true() -> bool {
196    true
197}
198fn is_true_default(b: &bool) -> bool {
199    *b
200}
201
202/// `[workspace]` table.
203#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204pub struct WorkspaceConfig {
205    /// `members` field.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub members: Vec<String>,
208    /// `deps` field.
209    #[serde(rename = "deps", default, skip_serializing_if = "IndexMap::is_empty")]
210    pub deps: IndexMap<String, DepSpec>,
211    /// `[workspace.package]` — metadata defaults inherited by member packages.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub package: Option<PackageMeta>,
214}
215
216impl Manifest {
217    /// Parse a `stryke.toml` from a string. Returns a structured diagnostic on
218    /// failure (line numbers when the underlying TOML parser provides them).
219    /// Kept as an inherent method (rather than `impl FromStr`) so callers see
220    /// the rich `PkgError::Manifest` variant directly.
221    #[allow(clippy::should_implement_trait)]
222    pub fn from_str(s: &str) -> PkgResult<Manifest> {
223        toml::from_str::<Manifest>(s)
224            .map_err(|e| PkgError::Manifest(format!("stryke.toml: {}", e.message())))
225    }
226
227    /// Parse from a path, treating any I/O error as `PkgError::Io` and any TOML
228    /// error as `PkgError::Manifest`.
229    pub fn from_path(path: &Path) -> PkgResult<Manifest> {
230        let s = std::fs::read_to_string(path)
231            .map_err(|e| PkgError::Io(format!("read {}: {}", path.display(), e)))?;
232        Manifest::from_str(&s)
233    }
234
235    /// Serialize back to TOML. The serializer drops comments and reorders some
236    /// tables (`serde` + `toml` is not a comment-preserving round-trip), but
237    /// `IndexMap`-backed sections preserve insertion order so dep lists stay
238    /// stable across `s add`/`s remove`.
239    pub fn to_toml_string(&self) -> PkgResult<String> {
240        toml::to_string_pretty(self)
241            .map_err(|e| PkgError::Manifest(format!("serialize stryke.toml: {}", e)))
242    }
243
244    /// Validate semantic invariants on top of TOML schema (cheap fast fails).
245    pub fn validate(&self) -> PkgResult<()> {
246        if let Some(pkg) = &self.package {
247            if pkg.name.is_empty() {
248                return Err(PkgError::Manifest("[package].name is required".into()));
249            }
250            if pkg.version.is_empty() {
251                return Err(PkgError::Manifest(format!(
252                    "[package].version is required for `{}`",
253                    pkg.name
254                )));
255            }
256        } else if self.workspace.is_none() {
257            return Err(PkgError::Manifest(
258                "stryke.toml needs either [package] or [workspace]".into(),
259            ));
260        }
261        Ok(())
262    }
263}
264
265impl DepSpec {
266    /// Normalized version requirement (or `None` for path/git deps).
267    pub fn version_req(&self) -> Option<&str> {
268        match self {
269            DepSpec::Version(v) => Some(v),
270            DepSpec::Detailed(d) => d.version.as_deref(),
271            DepSpec::Placeholder => None,
272        }
273    }
274
275    /// Path of the dep on disk, if this is a `path = "..."` spec.
276    pub fn path(&self) -> Option<&str> {
277        match self {
278            DepSpec::Detailed(d) => d.path.as_deref(),
279            _ => None,
280        }
281    }
282
283    /// Git URL, if this is a `git = "..."` spec.
284    pub fn git(&self) -> Option<&str> {
285        match self {
286            DepSpec::Detailed(d) => d.git.as_deref(),
287            _ => None,
288        }
289    }
290
291    /// `OWNER/REPO`, if this is a `github = "OWNER/REPO"` spec.
292    pub fn github(&self) -> Option<&str> {
293        match self {
294            DepSpec::Detailed(d) => d.github.as_deref(),
295            _ => None,
296        }
297    }
298
299    /// `tag = "v1.0"` or `version = "1.0"` value, used by github + git deps
300    /// to pin a release. github deps prefer `version` (matches the release
301    /// tarball filename's version segment); git deps prefer `tag`.
302    pub fn pinned_release_version(&self) -> Option<&str> {
303        match self {
304            DepSpec::Detailed(d) => d.version.as_deref().or(d.tag.as_deref()),
305            DepSpec::Version(v) => Some(v),
306            _ => None,
307        }
308    }
309
310    /// Convenience: build a bare version-string spec.
311    pub fn version(s: impl Into<String>) -> DepSpec {
312        DepSpec::Version(s.into())
313    }
314
315    /// Convenience: build a `path = "..."` spec.
316    pub fn path_dep(p: impl Into<String>) -> DepSpec {
317        DepSpec::Detailed(DetailedDep {
318            path: Some(p.into()),
319            default_features: true,
320            ..DetailedDep::default()
321        })
322    }
323
324    /// What kind of source this dep points at — drives resolver dispatch.
325    pub fn source(&self) -> DepSource {
326        match self {
327            DepSpec::Detailed(d) if d.path.is_some() => DepSource::Path,
328            DepSpec::Detailed(d) if d.github.is_some() => DepSource::GitHub,
329            DepSpec::Detailed(d) if d.git.is_some() => DepSource::Git,
330            _ => DepSource::Registry,
331        }
332    }
333}
334
335/// Where a dep's source code lives. Drives which resolver branch handles it.
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum DepSource {
338    /// `Registry` variant.
339    Registry,
340    /// `Path` variant.
341    Path,
342    /// `Git` variant — `git = "https://..."` source clone.
343    Git,
344    /// `GitHub` variant — `github = "OWNER/REPO"` prebuilt release tarball
345    /// fetched from `https://github.com/OWNER/REPO/releases/download/...`
346    /// for the host triple. Used for binary distributions like the
347    /// `stryke-*` FFI cdylib packages.
348    GitHub,
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn parses_minimal_manifest() {
357        let m = Manifest::from_str(
358            r#"
359[package]
360name = "myapp"
361version = "0.1.0"
362"#,
363        )
364        .unwrap();
365        let pkg = m.package.unwrap();
366        assert_eq!(pkg.name, "myapp");
367        assert_eq!(pkg.version, "0.1.0");
368    }
369
370    #[test]
371    fn parses_full_manifest_shape() {
372        let src = r#"
373[package]
374name = "myapp"
375version = "0.1.0"
376edition = "2026"
377
378[deps]
379http = "1.0"
380crypto = { version = "0.5", features = ["aes"] }
381local-lib = { path = "../mylib" }
382git-lib = { git = "https://github.com/u/lib", tag = "v1.0.0" }
383
384[dev-deps]
385test-utils = "1.0"
386
387[scripts]
388test = "s test t/"
389
390[bin]
391myapp = "main.stk"
392"#;
393        let m = Manifest::from_str(src).unwrap();
394        assert_eq!(m.deps.len(), 4);
395        assert_eq!(m.deps.get("http").unwrap().version_req(), Some("1.0"));
396        assert_eq!(m.deps.get("local-lib").unwrap().source(), DepSource::Path);
397        assert_eq!(m.deps.get("git-lib").unwrap().source(), DepSource::Git);
398        assert_eq!(m.bin.get("myapp").unwrap(), "main.stk");
399    }
400
401    #[test]
402    fn requires_package_or_workspace() {
403        let m = Manifest::from_str("").unwrap();
404        assert!(m.validate().is_err());
405    }
406
407    #[test]
408    fn parses_ffi_section() {
409        let m = Manifest::from_str(
410            r#"
411[package]
412name = "gui"
413version = "0.1.0"
414
415[ffi]
416lib-name = "stryke_gui"
417namespace = "GUI"
418exports = ["gui__mouse_pos", "gui__screen_size"]
419"#,
420        )
421        .unwrap();
422        let ffi = m.ffi.unwrap();
423        assert_eq!(ffi.lib_name, "stryke_gui");
424        assert_eq!(ffi.namespace, "GUI");
425        assert_eq!(ffi.exports, vec!["gui__mouse_pos", "gui__screen_size"]);
426    }
427
428    #[test]
429    fn ffi_section_is_optional() {
430        let m = Manifest::from_str(
431            r#"
432[package]
433name = "plain"
434version = "0.1.0"
435"#,
436        )
437        .unwrap();
438        assert!(m.ffi.is_none());
439    }
440
441    #[test]
442    fn ffi_round_trip_preserves_exports() {
443        let src = r#"[package]
444name = "gui"
445version = "0.1.0"
446
447[ffi]
448lib-name = "stryke_gui"
449namespace = "GUI"
450exports = ["a", "b"]
451"#;
452        let m = Manifest::from_str(src).unwrap();
453        let out = m.to_toml_string().unwrap();
454        let m2 = Manifest::from_str(&out).unwrap();
455        let ffi = m2.ffi.unwrap();
456        assert_eq!(ffi.lib_name, "stryke_gui");
457        assert_eq!(ffi.exports, vec!["a", "b"]);
458    }
459
460    #[test]
461    fn round_trip_preserves_dep_set() {
462        let src = r#"[package]
463name = "x"
464version = "0.1.0"
465
466[deps]
467a = "1.0"
468b = "2.0"
469"#;
470        let m = Manifest::from_str(src).unwrap();
471        let out = m.to_toml_string().unwrap();
472        let m2 = Manifest::from_str(&out).unwrap();
473        assert_eq!(m2.deps.len(), 2);
474        assert_eq!(m2.deps.get("a").unwrap().version_req(), Some("1.0"));
475    }
476}