1use std::{
35 collections::HashMap,
36 fs,
37 path::{Path, PathBuf},
38};
39
40use serde::{Deserialize, Serialize};
41
42use crate::PkgError;
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Package {
54 pub name: String,
56
57 pub version: String,
59
60 pub entry: Option<PathBuf>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct Dep {
77 pub git: String,
79
80 pub tag: Option<String>,
82
83 pub rev: Option<String>,
85
86 pub branch: Option<String>,
88
89 pub entry: Option<PathBuf>,
94
95 pub target_dir: Option<PathBuf>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub patch_dir: Option<PathBuf>,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub patch_drift: Option<PatchDrift>,
119}
120
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum PatchDrift {
125 #[default]
128 Warn,
129 Error,
132}
133
134impl Dep {
135 fn validate_ref_exclusivity(&self, dep_name: &str) -> Result<(), PkgError> {
137 let count = [
138 self.tag.is_some(),
139 self.rev.is_some(),
140 self.branch.is_some(),
141 ]
142 .into_iter()
143 .filter(|&b| b)
144 .count();
145 if let (Some(p), Some(t)) = (&self.patch_dir, &self.target_dir) {
146 if normalize(p) == normalize(t) {
147 return Err(PkgError::Validation {
148 message: format!(
149 "dependency '{dep_name}': patch_dir and target_dir are the same \
150 directory ({}); install would overwrite the patch",
151 p.display()
152 ),
153 });
154 }
155 }
156 if count > 1 {
157 return Err(PkgError::Validation {
158 message: format!(
159 "dep '{dep_name}': only one of `tag`, `rev`, `branch` may be specified, \
160 but multiple are set"
161 ),
162 });
163 }
164 Ok(())
165 }
166}
167
168fn normalize(p: &Path) -> PathBuf {
171 p.components()
172 .filter(|c| !matches!(c, std::path::Component::CurDir))
173 .collect()
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct Manifest {
188 pub package: Package,
190
191 #[serde(default)]
195 pub deps: HashMap<String, Dep>,
196}
197
198impl Manifest {
199 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, PkgError> {
213 let content = fs::read_to_string(path)?;
214 Self::from_toml_str(&content)
215 }
216
217 pub fn from_toml_str(content: &str) -> Result<Self, PkgError> {
230 let manifest: Self = toml::from_str(content)?;
231 manifest.validate()?;
232 Ok(manifest)
233 }
234
235 fn validate(&self) -> Result<(), PkgError> {
237 for (name, dep) in &self.deps {
238 dep.validate_ref_exclusivity(name)?;
239 }
240 Ok(())
241 }
242}
243
244#[cfg(test)]
247mod tests {
248 use super::*;
249 use std::io::Write as _;
250
251 fn temp_manifest(content: &str) -> tempfile::NamedTempFile {
254 let mut f = tempfile::NamedTempFile::new().unwrap();
255 f.write_all(content.as_bytes()).unwrap();
256 f
257 }
258
259 #[test]
262 fn consumer_happy_path() {
263 let toml = r#"
264[package]
265name = "my-app"
266version = "0.1.0"
267
268[deps]
269foo = { git = "https://github.com/x/foo", tag = "v1.2.0" }
270bar = { git = "https://github.com/y/bar", rev = "abc123" }
271baz = { git = "https://github.com/z/baz", branch = "main" }
272
273[deps.qux]
274git = "https://github.com/q/qux"
275tag = "v2.0.0"
276entry = "lib"
277"#;
278 let f = temp_manifest(toml);
279 let m = Manifest::from_path(f.path()).unwrap();
280
281 assert_eq!(m.package.name, "my-app");
282 assert_eq!(m.package.version, "0.1.0");
283 assert!(m.package.entry.is_none());
284 assert_eq!(m.deps.len(), 4);
285
286 let foo = &m.deps["foo"];
287 assert_eq!(foo.git, "https://github.com/x/foo");
288 assert_eq!(foo.tag.as_deref(), Some("v1.2.0"));
289 assert!(foo.rev.is_none());
290 assert!(foo.branch.is_none());
291 assert!(foo.entry.is_none());
292
293 let bar = &m.deps["bar"];
294 assert_eq!(bar.rev.as_deref(), Some("abc123"));
295 assert!(bar.tag.is_none());
296
297 let baz = &m.deps["baz"];
298 assert_eq!(baz.branch.as_deref(), Some("main"));
299 assert!(baz.tag.is_none());
300
301 let qux = &m.deps["qux"];
302 assert_eq!(qux.tag.as_deref(), Some("v2.0.0"));
303 assert_eq!(qux.entry, Some(PathBuf::from("lib")));
304 }
305
306 #[test]
309 fn author_happy_path() {
310 let toml = r#"
311[package]
312name = "foo"
313version = "1.2.0"
314entry = "src"
315"#;
316 let f = temp_manifest(toml);
317 let m = Manifest::from_path(f.path()).unwrap();
318
319 assert_eq!(m.package.name, "foo");
320 assert_eq!(m.package.version, "1.2.0");
321 assert_eq!(m.package.entry, Some(PathBuf::from("src")));
322 assert!(m.deps.is_empty());
323 }
324
325 #[test]
328 fn tag_and_rev_mutually_exclusive() {
329 let toml = r#"
330[package]
331name = "my-app"
332version = "0.1.0"
333
334[deps.bad]
335git = "https://github.com/x/bad"
336tag = "v1.0.0"
337rev = "abc123"
338"#;
339 let f = temp_manifest(toml);
340 let err = Manifest::from_path(f.path()).unwrap_err();
341 assert!(
342 matches!(err, PkgError::Validation { .. }),
343 "expected Validation error, got: {err}"
344 );
345 assert!(err.to_string().contains("bad"));
346 }
347
348 #[test]
351 fn invalid_toml_returns_parse_error() {
352 let toml = "this is not valid = [ toml";
353 let f = temp_manifest(toml);
354 let err = Manifest::from_path(f.path()).unwrap_err();
355 assert!(
356 matches!(err, PkgError::ManifestParse { .. }),
357 "expected ManifestParse error, got: {err}"
358 );
359 }
360
361 #[test]
364 fn missing_package_section_returns_parse_error() {
365 let toml = r#"
366[deps]
367foo = { git = "https://github.com/x/foo", tag = "v1.0.0" }
368"#;
369 let f = temp_manifest(toml);
370 let err = Manifest::from_path(f.path()).unwrap_err();
371 assert!(
372 matches!(err, PkgError::ManifestParse { .. }),
373 "expected ManifestParse error for missing [package], got: {err}"
374 );
375 }
376
377 #[test]
380 fn round_trip_serialize_deserialize() {
381 let original = Manifest {
382 package: Package {
383 name: "roundtrip".into(),
384 version: "0.1.0".into(),
385 entry: None,
386 },
387 deps: {
388 let mut m = HashMap::new();
389 m.insert(
390 "lib".into(),
391 Dep {
392 git: "https://github.com/x/lib".into(),
393 tag: Some("v1.0.0".into()),
394 rev: None,
395 branch: None,
396 entry: None,
397 target_dir: None,
398 patch_dir: None,
399 patch_drift: None,
400 },
401 );
402 m
403 },
404 };
405
406 let serialized = toml::to_string(&original).unwrap();
407 let deserialized: Manifest = toml::from_str(&serialized).unwrap();
408 assert_eq!(original, deserialized);
409 }
410
411 #[test]
414 fn all_three_ref_fields_is_validation_error() {
415 let toml = r#"
416[package]
417name = "my-app"
418version = "0.1.0"
419
420[deps.oops]
421git = "https://github.com/x/oops"
422tag = "v1.0.0"
423rev = "abc123"
424branch = "main"
425"#;
426 let f = temp_manifest(toml);
427 let err = Manifest::from_path(f.path()).unwrap_err();
428 assert!(matches!(err, PkgError::Validation { .. }));
429 }
430
431 #[test]
432 fn patch_dir_parses_and_must_differ_from_target_dir() {
433 let ok = r#"
434[package]
435name = "my-app"
436version = "0.1.0"
437
438[deps.lib]
439git = "https://github.com/x/lib"
440tag = "v1.0.0"
441patch_dir = "patches/lib"
442target_dir = "lua/lib"
443"#;
444 let f = temp_manifest(ok);
445 let m = Manifest::from_path(f.path()).unwrap();
446 assert_eq!(m.deps["lib"].patch_dir, Some(PathBuf::from("patches/lib")));
447 assert_eq!(m.deps["lib"].patch_drift, None);
448
449 let strict = r#"
450[package]
451name = "my-app"
452version = "0.1.0"
453
454[deps.lib]
455git = "https://github.com/x/lib"
456patch_dir = "patches/lib"
457patch_drift = "error"
458"#;
459 let f = temp_manifest(strict);
460 let m = Manifest::from_path(f.path()).unwrap();
461 assert_eq!(m.deps["lib"].patch_drift, Some(PatchDrift::Error));
462
463 let bogus = strict.replace("\"error\"", "\"panic\"");
464 let f = temp_manifest(&bogus);
465 assert!(matches!(
466 Manifest::from_path(f.path()).unwrap_err(),
467 PkgError::ManifestParse { .. }
468 ));
469
470 let same = r#"
471[package]
472name = "my-app"
473version = "0.1.0"
474
475[deps.lib]
476git = "https://github.com/x/lib"
477patch_dir = "./patches/lib"
478target_dir = "patches/lib/"
479"#;
480 let f = temp_manifest(same);
481 let err = Manifest::from_path(f.path()).unwrap_err();
482 assert!(
483 matches!(&err, PkgError::Validation { message } if message.contains("patch_dir")),
484 "{err}"
485 );
486 }
487
488 #[test]
491 fn unknown_field_in_package_is_rejected() {
492 let toml = r#"
493[package]
494name = "my-app"
495version = "0.1.0"
496unknown = "should-fail"
497"#;
498 let f = temp_manifest(toml);
499 let err = Manifest::from_path(f.path()).unwrap_err();
500 assert!(
501 matches!(err, PkgError::ManifestParse { .. }),
502 "expected ManifestParse error for unknown field, got: {err}"
503 );
504 }
505}