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    pub dev_deps: IndexMap<String, DepSpec>,
32
33    /// `[groups.NAME]` — bundler-style arbitrary groups (e.g. `groups.bench`).
34    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
35    pub groups: IndexMap<String, IndexMap<String, DepSpec>>,
36
37    /// `[features]` — feature flags. Per-package scoped (no workspace unification).
38    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
39    pub features: IndexMap<String, Vec<String>>,
40
41    /// `[scripts]` — npm-style task runner aliases (run via `s run <name>`).
42    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
43    pub scripts: IndexMap<String, String>,
44
45    /// `[bin]` — explicit binary entry points. Auto-discovery from `bin/` happens
46    /// at build time when this map is empty.
47    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
48    pub bin: IndexMap<String, String>,
49
50    /// `[workspace]` — workspace root configuration.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub workspace: Option<WorkspaceConfig>,
53}
54
55/// `[package]` table.
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct PackageMeta {
58    pub name: String,
59    pub version: String,
60    #[serde(default, skip_serializing_if = "String::is_empty")]
61    pub description: String,
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub authors: Vec<String>,
64    #[serde(default, skip_serializing_if = "String::is_empty")]
65    pub license: String,
66    #[serde(default, skip_serializing_if = "String::is_empty")]
67    pub repository: String,
68    /// Language edition pin (e.g. `"2026"`). Defaults are inferred at build time.
69    #[serde(default, skip_serializing_if = "String::is_empty")]
70    pub edition: String,
71}
72
73/// One dep spec: either `"1.0"` (shorthand for `{ version = "1.0" }`) or a
74/// fully-expanded inline table (`{ version, features, path, git, ... }`).
75///
76/// On serialize, simple version-only specs round-trip back to the shorthand form
77/// for cleaner manifests.
78#[derive(Debug, Clone, Serialize, Deserialize, Default)]
79#[serde(untagged)]
80pub enum DepSpec {
81    /// `http = "1.0"` — bare version requirement.
82    Version(String),
83    /// `crypto = { version = "0.5", features = [...], path = ..., git = ..., ... }`.
84    Detailed(DetailedDep),
85    /// Empty placeholder so [`Default`] can construct a valid value.
86    #[default]
87    #[serde(skip)]
88    Placeholder,
89}
90
91/// Inline-table form of a dep spec.
92#[derive(Debug, Clone, Serialize, Deserialize, Default)]
93pub struct DetailedDep {
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub version: Option<String>,
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub features: Vec<String>,
98    /// `path = "../mylib"` — local path dependency.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub path: Option<String>,
101    /// `git = "https://..."` — git dependency. Combined with `branch`/`tag`/`rev`.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub git: Option<String>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub branch: Option<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub tag: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub rev: Option<String>,
110    /// `registry = "https://..."` — alternate registry for this dep.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub registry: Option<String>,
113    /// `optional = true` — only pulled in when a feature flag enables it.
114    #[serde(default, skip_serializing_if = "is_false")]
115    pub optional: bool,
116    /// `default-features = false` — opt out of the dep's default features.
117    #[serde(
118        rename = "default-features",
119        default = "default_true",
120        skip_serializing_if = "is_true_default"
121    )]
122    pub default_features: bool,
123    /// `workspace = true` — inherit version/features from workspace root.
124    #[serde(default, skip_serializing_if = "is_false")]
125    pub workspace: bool,
126}
127
128fn is_false(b: &bool) -> bool {
129    !*b
130}
131fn default_true() -> bool {
132    true
133}
134fn is_true_default(b: &bool) -> bool {
135    *b
136}
137
138/// `[workspace]` table.
139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct WorkspaceConfig {
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub members: Vec<String>,
143    #[serde(rename = "deps", default, skip_serializing_if = "IndexMap::is_empty")]
144    pub deps: IndexMap<String, DepSpec>,
145    /// `[workspace.package]` — metadata defaults inherited by member packages.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub package: Option<PackageMeta>,
148}
149
150impl Manifest {
151    /// Parse a `stryke.toml` from a string. Returns a structured diagnostic on
152    /// failure (line numbers when the underlying TOML parser provides them).
153    /// Kept as an inherent method (rather than `impl FromStr`) so callers see
154    /// the rich `PkgError::Manifest` variant directly.
155    #[allow(clippy::should_implement_trait)]
156    pub fn from_str(s: &str) -> PkgResult<Manifest> {
157        toml::from_str::<Manifest>(s)
158            .map_err(|e| PkgError::Manifest(format!("stryke.toml: {}", e.message())))
159    }
160
161    /// Parse from a path, treating any I/O error as `PkgError::Io` and any TOML
162    /// error as `PkgError::Manifest`.
163    pub fn from_path(path: &Path) -> PkgResult<Manifest> {
164        let s = std::fs::read_to_string(path)
165            .map_err(|e| PkgError::Io(format!("read {}: {}", path.display(), e)))?;
166        Manifest::from_str(&s)
167    }
168
169    /// Serialize back to TOML. The serializer drops comments and reorders some
170    /// tables (`serde` + `toml` is not a comment-preserving round-trip), but
171    /// `IndexMap`-backed sections preserve insertion order so dep lists stay
172    /// stable across `s add`/`s remove`.
173    pub fn to_toml_string(&self) -> PkgResult<String> {
174        toml::to_string_pretty(self)
175            .map_err(|e| PkgError::Manifest(format!("serialize stryke.toml: {}", e)))
176    }
177
178    /// Validate semantic invariants on top of TOML schema (cheap fast fails).
179    pub fn validate(&self) -> PkgResult<()> {
180        if let Some(pkg) = &self.package {
181            if pkg.name.is_empty() {
182                return Err(PkgError::Manifest("[package].name is required".into()));
183            }
184            if pkg.version.is_empty() {
185                return Err(PkgError::Manifest(format!(
186                    "[package].version is required for `{}`",
187                    pkg.name
188                )));
189            }
190        } else if self.workspace.is_none() {
191            return Err(PkgError::Manifest(
192                "stryke.toml needs either [package] or [workspace]".into(),
193            ));
194        }
195        Ok(())
196    }
197}
198
199impl DepSpec {
200    /// Normalized version requirement (or `None` for path/git deps).
201    pub fn version_req(&self) -> Option<&str> {
202        match self {
203            DepSpec::Version(v) => Some(v),
204            DepSpec::Detailed(d) => d.version.as_deref(),
205            DepSpec::Placeholder => None,
206        }
207    }
208
209    /// Path of the dep on disk, if this is a `path = "..."` spec.
210    pub fn path(&self) -> Option<&str> {
211        match self {
212            DepSpec::Detailed(d) => d.path.as_deref(),
213            _ => None,
214        }
215    }
216
217    /// Git URL, if this is a `git = "..."` spec.
218    pub fn git(&self) -> Option<&str> {
219        match self {
220            DepSpec::Detailed(d) => d.git.as_deref(),
221            _ => None,
222        }
223    }
224
225    /// Convenience: build a bare version-string spec.
226    pub fn version(s: impl Into<String>) -> DepSpec {
227        DepSpec::Version(s.into())
228    }
229
230    /// Convenience: build a `path = "..."` spec.
231    pub fn path_dep(p: impl Into<String>) -> DepSpec {
232        DepSpec::Detailed(DetailedDep {
233            path: Some(p.into()),
234            default_features: true,
235            ..DetailedDep::default()
236        })
237    }
238
239    /// What kind of source this dep points at — drives resolver dispatch.
240    pub fn source(&self) -> DepSource {
241        match self {
242            DepSpec::Detailed(d) if d.path.is_some() => DepSource::Path,
243            DepSpec::Detailed(d) if d.git.is_some() => DepSource::Git,
244            _ => DepSource::Registry,
245        }
246    }
247}
248
249/// Where a dep's source code lives. Drives which resolver branch handles it.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum DepSource {
252    Registry,
253    Path,
254    Git,
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn parses_minimal_manifest() {
263        let m = Manifest::from_str(
264            r#"
265[package]
266name = "myapp"
267version = "0.1.0"
268"#,
269        )
270        .unwrap();
271        let pkg = m.package.unwrap();
272        assert_eq!(pkg.name, "myapp");
273        assert_eq!(pkg.version, "0.1.0");
274    }
275
276    #[test]
277    fn parses_full_manifest_shape() {
278        let src = r#"
279[package]
280name = "myapp"
281version = "0.1.0"
282edition = "2026"
283
284[deps]
285http = "1.0"
286crypto = { version = "0.5", features = ["aes"] }
287local-lib = { path = "../mylib" }
288git-lib = { git = "https://github.com/u/lib", tag = "v1.0.0" }
289
290[dev-deps]
291test-utils = "1.0"
292
293[scripts]
294test = "s test t/"
295
296[bin]
297myapp = "main.stk"
298"#;
299        let m = Manifest::from_str(src).unwrap();
300        assert_eq!(m.deps.len(), 4);
301        assert_eq!(m.deps.get("http").unwrap().version_req(), Some("1.0"));
302        assert_eq!(m.deps.get("local-lib").unwrap().source(), DepSource::Path);
303        assert_eq!(m.deps.get("git-lib").unwrap().source(), DepSource::Git);
304        assert_eq!(m.bin.get("myapp").unwrap(), "main.stk");
305    }
306
307    #[test]
308    fn requires_package_or_workspace() {
309        let m = Manifest::from_str("").unwrap();
310        assert!(m.validate().is_err());
311    }
312
313    #[test]
314    fn round_trip_preserves_dep_set() {
315        let src = r#"[package]
316name = "x"
317version = "0.1.0"
318
319[deps]
320a = "1.0"
321b = "2.0"
322"#;
323        let m = Manifest::from_str(src).unwrap();
324        let out = m.to_toml_string().unwrap();
325        let m2 = Manifest::from_str(&out).unwrap();
326        assert_eq!(m2.deps.len(), 2);
327        assert_eq!(m2.deps.get("a").unwrap().version_req(), Some("1.0"));
328    }
329}