1use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11
12use super::{PkgError, PkgResult};
13
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct Manifest {
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub package: Option<PackageMeta>,
20
21 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
23 pub deps: IndexMap<String, DepSpec>,
24
25 #[serde(
27 rename = "dev-deps",
28 default,
29 skip_serializing_if = "IndexMap::is_empty"
30 )]
31 pub dev_deps: IndexMap<String, DepSpec>,
33
34 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
36 pub groups: IndexMap<String, IndexMap<String, DepSpec>>,
37
38 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
40 pub features: IndexMap<String, Vec<String>>,
41
42 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
44 pub scripts: IndexMap<String, String>,
45
46 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
49 pub bin: IndexMap<String, String>,
50
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub workspace: Option<WorkspaceConfig>,
54
55 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub ffi: Option<FfiManifest>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct FfiManifest {
79 #[serde(rename = "lib-name")]
82 pub lib_name: String,
83
84 #[serde(default, skip_serializing_if = "String::is_empty")]
89 pub namespace: String,
90
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub exports: Vec<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct PackageMeta {
101 pub name: String,
103 pub version: String,
105 #[serde(default, skip_serializing_if = "String::is_empty")]
107 pub description: String,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub authors: Vec<String>,
111 #[serde(default, skip_serializing_if = "String::is_empty")]
113 pub license: String,
114 #[serde(default, skip_serializing_if = "String::is_empty")]
116 pub repository: String,
117 #[serde(default, skip_serializing_if = "String::is_empty")]
119 pub edition: String,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, Default)]
128#[serde(untagged)]
129pub enum DepSpec {
130 Version(String),
132 Detailed(DetailedDep),
134 #[default]
136 #[serde(skip)]
137 Placeholder,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142pub struct DetailedDep {
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub version: Option<String>,
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub features: Vec<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub path: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub git: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub github: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub branch: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub tag: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub rev: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub registry: Option<String>,
176 #[serde(default, skip_serializing_if = "is_false")]
178 pub optional: bool,
179 #[serde(
181 rename = "default-features",
182 default = "default_true",
183 skip_serializing_if = "is_true_default"
184 )]
185 pub default_features: bool,
187 #[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204pub struct WorkspaceConfig {
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub members: Vec<String>,
208 #[serde(rename = "deps", default, skip_serializing_if = "IndexMap::is_empty")]
210 pub deps: IndexMap<String, DepSpec>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub package: Option<PackageMeta>,
214}
215
216impl Manifest {
217 #[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 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 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 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 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 pub fn path(&self) -> Option<&str> {
277 match self {
278 DepSpec::Detailed(d) => d.path.as_deref(),
279 _ => None,
280 }
281 }
282
283 pub fn git(&self) -> Option<&str> {
285 match self {
286 DepSpec::Detailed(d) => d.git.as_deref(),
287 _ => None,
288 }
289 }
290
291 pub fn github(&self) -> Option<&str> {
293 match self {
294 DepSpec::Detailed(d) => d.github.as_deref(),
295 _ => None,
296 }
297 }
298
299 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 pub fn version(s: impl Into<String>) -> DepSpec {
312 DepSpec::Version(s.into())
313 }
314
315 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum DepSource {
338 Registry,
340 Path,
342 Git,
344 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}