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>,
32
33 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
35 pub groups: IndexMap<String, IndexMap<String, DepSpec>>,
36
37 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
39 pub features: IndexMap<String, Vec<String>>,
40
41 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
43 pub scripts: IndexMap<String, String>,
44
45 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
48 pub bin: IndexMap<String, String>,
49
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub workspace: Option<WorkspaceConfig>,
53}
54
55#[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 #[serde(default, skip_serializing_if = "String::is_empty")]
70 pub edition: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, Default)]
79#[serde(untagged)]
80pub enum DepSpec {
81 Version(String),
83 Detailed(DetailedDep),
85 #[default]
87 #[serde(skip)]
88 Placeholder,
89}
90
91#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub path: Option<String>,
101 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub registry: Option<String>,
113 #[serde(default, skip_serializing_if = "is_false")]
115 pub optional: bool,
116 #[serde(
118 rename = "default-features",
119 default = "default_true",
120 skip_serializing_if = "is_true_default"
121 )]
122 pub default_features: bool,
123 #[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub package: Option<PackageMeta>,
148}
149
150impl Manifest {
151 #[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 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 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 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 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 pub fn path(&self) -> Option<&str> {
211 match self {
212 DepSpec::Detailed(d) => d.path.as_deref(),
213 _ => None,
214 }
215 }
216
217 pub fn git(&self) -> Option<&str> {
219 match self {
220 DepSpec::Detailed(d) => d.git.as_deref(),
221 _ => None,
222 }
223 }
224
225 pub fn version(s: impl Into<String>) -> DepSpec {
227 DepSpec::Version(s.into())
228 }
229
230 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 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#[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}