1use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20
21use serde::Deserialize;
22
23use super::name::Name;
24use super::{PackageName, PhaseError};
25
26pub const MANIFEST_FILE_NAME: &str = "zelkova.toml";
28
29#[derive(Debug, Clone)]
37pub struct Manifest {
38 pub name: PackageName,
39 pub version: Version,
40 pub main: Option<Name>,
41 pub private_modules: Vec<Name>,
42 pub dependencies: HashMap<PackageName, Dependency>,
43 pub test_dependencies: HashMap<PackageName, Dependency>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Version {
50 pub major: u64,
51 pub minor: u64,
52 pub patch: u64,
53}
54
55impl std::fmt::Display for Version {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
60 }
61}
62
63impl Version {
64 fn parse(raw: &str) -> Option<Version> {
65 let mut parts = raw.split('.');
66 let major = parts.next()?.parse().ok()?;
67 let minor = parts.next()?.parse().ok()?;
68 let patch = parts.next()?.parse().ok()?;
69 if parts.next().is_some() {
70 return None;
71 }
72 Some(Version {
73 major,
74 minor,
75 patch,
76 })
77 }
78}
79
80#[derive(Debug, Clone)]
83pub struct Dependency {
84 pub source: Source,
85 pub wrapped: bool,
86}
87
88#[derive(Debug, Clone)]
91pub enum Source {
92 Git { url: String, pin: GitPin },
93 Path { path: String },
94}
95
96#[derive(Debug, Clone)]
99pub enum GitPin {
100 Version(String),
101 Rev(String),
102}
103
104#[derive(Debug, Deserialize)]
115#[serde(deny_unknown_fields)]
116struct RawManifest {
117 name: String,
118 version: String,
119 #[serde(default)]
120 main: Option<String>,
121 #[serde(rename = "private-modules")]
122 private_modules: Vec<String>,
123 dependencies: HashMap<String, RawDependency>,
124 #[serde(rename = "test-dependencies")]
125 test_dependencies: HashMap<String, RawDependency>,
126}
127
128#[derive(Debug, Deserialize)]
129struct RawDependency {
130 #[serde(default)]
131 version: Option<String>,
132 #[serde(default)]
133 git: Option<String>,
134 #[serde(default)]
135 path: Option<String>,
136 #[serde(default)]
137 rev: Option<String>,
138 #[serde(default = "wrapped_by_default")]
139 wrapped: bool,
140}
141
142fn wrapped_by_default() -> bool {
143 true
144}
145
146#[derive(Debug)]
158pub enum ManifestError {
159 Missing { manifest_path: PathBuf },
161 Unreadable {
163 manifest_path: PathBuf,
164 message: String,
165 },
166 Malformed {
169 manifest_path: PathBuf,
170 message: String,
171 },
172 InvalidName {
175 manifest_path: PathBuf,
176 name: String,
177 },
178 InvalidVersion {
180 manifest_path: PathBuf,
181 version: String,
182 },
183 InvalidDependencyName {
185 manifest_path: PathBuf,
186 table: &'static str,
187 name: String,
188 },
189 InvalidDependencyEntry {
192 manifest_path: PathBuf,
193 table: &'static str,
194 package: String,
195 reason: String,
196 },
197 DependencyListedTwice {
200 manifest_path: PathBuf,
201 name: String,
202 },
203 PrivateModuleNotFound { manifest_path: PathBuf, name: Name },
211 MainModuleNotFound { manifest_path: PathBuf, name: Name },
219}
220
221impl PhaseError for ManifestError {
222 fn message(&self) -> String {
223 match self {
224 ManifestError::Missing { manifest_path } => {
225 format!("`{}` has no manifest", manifest_path.display())
226 }
227 ManifestError::Unreadable {
228 manifest_path,
229 message,
230 } => format!(
231 "`{}` could not be read: {}",
232 manifest_path.display(),
233 message
234 ),
235 ManifestError::Malformed {
236 manifest_path,
237 message,
238 } => format!(
239 "`{}` is not a valid manifest: {}",
240 manifest_path.display(),
241 message
242 ),
243 ManifestError::InvalidName {
244 manifest_path,
245 name,
246 } => format!(
247 "`{}` declares the name `{}`, which is not a legal package name: a package name \
248 is ASCII lowercase letters, digits and hyphens, starting with a letter, with \
249 every hyphen followed by a letter",
250 manifest_path.display(),
251 name
252 ),
253 ManifestError::InvalidVersion {
254 manifest_path,
255 version,
256 } => format!(
257 "`{}` declares the version `{}`, which is not a legal version: a version is \
258 exactly three non-negative integers separated by dots",
259 manifest_path.display(),
260 version
261 ),
262 ManifestError::InvalidDependencyName {
263 manifest_path,
264 table,
265 name,
266 } => format!(
267 "`{}` in `[{}]` of `{}` is not a legal package name",
268 name,
269 table,
270 manifest_path.display()
271 ),
272 ManifestError::InvalidDependencyEntry {
273 manifest_path,
274 table,
275 package,
276 reason,
277 } => format!(
278 "`{}` in `[{}]` of `{}` {}",
279 package,
280 table,
281 manifest_path.display(),
282 reason
283 ),
284 ManifestError::DependencyListedTwice {
285 manifest_path,
286 name,
287 } => format!(
288 "`{}` lists `{}` in both `dependencies` and `test-dependencies`",
289 manifest_path.display(),
290 name
291 ),
292 ManifestError::PrivateModuleNotFound {
293 manifest_path,
294 name,
295 } => format!(
296 "`private-modules` in `{}` names `{}`, which this package does not hold",
297 manifest_path.display(),
298 name
299 ),
300 ManifestError::MainModuleNotFound {
301 manifest_path,
302 name,
303 } => format!(
304 "`main` in `{}` names `{}`, which is not a module under this package's `src/`",
305 manifest_path.display(),
306 name
307 ),
308 }
309 }
310
311 fn notes(&self) -> Vec<String> {
312 match self {
313 ManifestError::MainModuleNotFound { .. } => vec![
314 "`main` is set in the manifest, and the manifest is not a source file a \
315 diagnostic can point into, so this error has no location"
316 .to_string(),
317 "`main` names a module by its name within the package, and that module must \
318 be under `src/`"
319 .to_string(),
320 ],
321 _ => Vec::new(),
322 }
323 }
324}
325
326pub fn load(package_dir: &Path) -> Result<Manifest, Vec<ManifestError>> {
332 let manifest_path = package_dir.join(MANIFEST_FILE_NAME);
333
334 let text = match std::fs::read_to_string(&manifest_path) {
335 Ok(text) => text,
336 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
337 return Err(vec![ManifestError::Missing { manifest_path }]);
338 }
339 Err(err) => {
340 return Err(vec![ManifestError::Unreadable {
341 manifest_path,
342 message: err.to_string(),
343 }]);
344 }
345 };
346
347 let raw: RawManifest = match toml::from_str(&text) {
348 Ok(raw) => raw,
349 Err(err) => {
350 return Err(vec![ManifestError::Malformed {
351 manifest_path,
352 message: err.to_string(),
353 }]);
354 }
355 };
356
357 let mut errors = Vec::new();
358
359 let name = PackageName::new(raw.name.clone());
360 if name.is_err() {
361 errors.push(ManifestError::InvalidName {
362 manifest_path: manifest_path.clone(),
363 name: raw.name,
364 });
365 }
366
367 let version = Version::parse(&raw.version);
368 if version.is_none() {
369 errors.push(ManifestError::InvalidVersion {
370 manifest_path: manifest_path.clone(),
371 version: raw.version,
372 });
373 }
374
375 let private_modules: Vec<Name> = raw.private_modules.iter().map(Name::new).collect();
376 let main = raw.main.as_deref().map(Name::new);
377
378 let mut dependencies = HashMap::new();
379 for (package, raw_dep) in raw.dependencies {
380 match validate_dependency(&manifest_path, "dependencies", package, raw_dep) {
381 Ok((name, dep)) => {
382 dependencies.insert(name, dep);
383 }
384 Err(err) => errors.push(err),
385 }
386 }
387
388 let mut test_dependencies = HashMap::new();
389 for (package, raw_dep) in raw.test_dependencies {
390 match validate_dependency(&manifest_path, "test-dependencies", package, raw_dep) {
391 Ok((name, dep)) => {
392 test_dependencies.insert(name, dep);
393 }
394 Err(err) => errors.push(err),
395 }
396 }
397
398 for shared in dependencies.keys() {
399 if test_dependencies.contains_key(shared) {
400 errors.push(ManifestError::DependencyListedTwice {
401 manifest_path: manifest_path.clone(),
402 name: shared.as_str().to_string(),
403 });
404 }
405 }
406
407 match (name, version) {
408 (Ok(name), Some(version)) if errors.is_empty() => Ok(Manifest {
409 name,
410 version,
411 main,
412 private_modules,
413 dependencies,
414 test_dependencies,
415 }),
416 _ => Err(errors),
417 }
418}
419
420fn validate_dependency(
424 manifest_path: &Path,
425 table: &'static str,
426 package: String,
427 raw: RawDependency,
428) -> Result<(PackageName, Dependency), ManifestError> {
429 let name =
430 PackageName::new(package.clone()).map_err(|_| ManifestError::InvalidDependencyName {
431 manifest_path: manifest_path.to_path_buf(),
432 table,
433 name: package.clone(),
434 })?;
435
436 let entry_error = |reason: &str| ManifestError::InvalidDependencyEntry {
437 manifest_path: manifest_path.to_path_buf(),
438 table,
439 package: package.clone(),
440 reason: reason.to_string(),
441 };
442
443 let dependency = match (raw.git, raw.path) {
444 (Some(_), Some(_)) => {
445 return Err(entry_error(
446 "names both `git` and `path`; an entry must name exactly one source",
447 ))
448 }
449 (None, None) => {
450 return Err(entry_error(
451 "names neither `git` nor `path`; a bare version constraint names no place a \
452 package could be found",
453 ))
454 }
455 (Some(url), None) => match (raw.version, raw.rev) {
456 (Some(version), None) => Dependency {
457 source: Source::Git {
458 url,
459 pin: GitPin::Version(version),
460 },
461 wrapped: raw.wrapped,
462 },
463 (None, Some(rev)) => Dependency {
464 source: Source::Git {
465 url,
466 pin: GitPin::Rev(rev),
467 },
468 wrapped: raw.wrapped,
469 },
470 (None, None) => {
471 return Err(entry_error(
472 "names a `git` source with neither a `version` nor a `rev`",
473 ))
474 }
475 (Some(_), Some(_)) => {
476 return Err(entry_error(
477 "names a `git` source with both a `version` and a `rev`; a `git` entry \
478 asks for one or the other",
479 ))
480 }
481 },
482 (None, Some(path)) => {
483 if raw.version.is_some() || raw.rev.is_some() {
484 return Err(entry_error(
485 "names a `path` source alongside a `version` or a `rev`; a `path` source \
486 carries neither",
487 ));
488 }
489 Dependency {
490 source: Source::Path { path },
491 wrapped: raw.wrapped,
492 }
493 }
494 };
495
496 Ok((name, dependency))
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 fn write_manifest(dir: &Path, contents: &str) {
504 std::fs::write(dir.join(MANIFEST_FILE_NAME), contents).unwrap();
505 }
506
507 #[test]
509 fn missing_manifest_names_the_file() {
510 let dir = tempdir();
511
512 let errors = load(dir.path()).expect_err("no zelkova.toml was written");
513
514 assert_eq!(errors.len(), 1, "got {:?}", errors);
515 match &errors[0] {
516 ManifestError::Missing { manifest_path } => {
517 assert_eq!(manifest_path, &dir.path().join("zelkova.toml"));
518 }
519 other => panic!("expected Missing, got {:?}", other),
520 }
521 }
522
523 #[test]
526 fn illegal_name_is_rejected() {
527 let dir = tempdir();
528 write_manifest(
529 dir.path(),
530 r#"
531 name = "Not_Legal"
532 version = "0.1.0"
533 private-modules = []
534
535 [dependencies]
536
537 [test-dependencies]
538 "#,
539 );
540
541 let errors = load(dir.path()).expect_err("`Not_Legal` is not a legal package name");
542
543 assert_eq!(errors.len(), 1, "got {:?}", errors);
544 match &errors[0] {
545 ManifestError::InvalidName {
546 manifest_path,
547 name,
548 } => {
549 assert_eq!(name, "Not_Legal");
550 assert_eq!(manifest_path, &dir.path().join(MANIFEST_FILE_NAME));
551 }
552 other => panic!("expected InvalidName, got {:?}", other),
553 }
554
555 let message = errors[0].message();
558 assert!(
559 message.contains("Not_Legal") && message.contains(MANIFEST_FILE_NAME),
560 "expected the name and the manifest path in the message, got {:?}",
561 message
562 );
563 }
564
565 #[test]
569 fn an_unknown_key_is_rejected() {
570 let dir = tempdir();
571 write_manifest(
572 dir.path(),
573 r#"
574 name = "todo"
575 version = "0.1.0"
576 mian = "App"
577 private-modules = []
578
579 [dependencies]
580
581 [test-dependencies]
582 "#,
583 );
584
585 let errors = load(dir.path()).expect_err("`mian` is not a manifest field");
586
587 assert_eq!(errors.len(), 1, "got {:?}", errors);
588 match &errors[0] {
589 ManifestError::Malformed { message, .. } => assert!(
590 message.contains("mian"),
591 "expected the unknown key to be named, got {:?}",
592 message
593 ),
594 other => panic!("expected Malformed, got {:?}", other),
595 }
596 }
597
598 #[test]
600 fn a_well_formed_manifest_loads() {
601 let dir = tempdir();
602 write_manifest(
603 dir.path(),
604 r#"
605 name = "todo"
606 version = "0.4.1"
607 main = "App"
608 private-modules = ["Model.Internal"]
609
610 [dependencies.acme-widgets]
611 version = "^1.2.0"
612 git = "https://github.com/acme/widgets"
613
614 [dependencies.acme-parser]
615 path = "../acme-parser"
616 wrapped = false
617
618 [test-dependencies.acme-expect]
619 version = "^2.0.0"
620 git = "https://github.com/acme/expect"
621 "#,
622 );
623
624 let manifest = load(dir.path()).expect("manifest is well-formed");
625
626 assert_eq!(manifest.name.as_str(), "todo");
627 assert_eq!(
628 manifest.version,
629 Version {
630 major: 0,
631 minor: 4,
632 patch: 1
633 }
634 );
635 assert_eq!(manifest.main, Some(Name::new("App")));
636 assert_eq!(manifest.private_modules, vec![Name::new("Model.Internal")]);
637 assert_eq!(manifest.dependencies.len(), 2);
638 assert_eq!(manifest.test_dependencies.len(), 1);
639
640 let widgets = &manifest.dependencies[&PackageName::new("acme-widgets").unwrap()];
641 assert!(widgets.wrapped);
642 match &widgets.source {
643 Source::Git {
644 url,
645 pin: GitPin::Version(v),
646 } => {
647 assert_eq!(url, "https://github.com/acme/widgets");
648 assert_eq!(v, "^1.2.0");
649 }
650 other => panic!("expected a Git/Version source, got {:?}", other),
651 }
652
653 let parser = &manifest.dependencies[&PackageName::new("acme-parser").unwrap()];
654 assert!(!parser.wrapped);
655 match &parser.source {
656 Source::Path { path } => assert_eq!(path, "../acme-parser"),
657 other => panic!("expected a Path source, got {:?}", other),
658 }
659 }
660
661 #[test]
667 fn a_dependency_with_no_source_is_rejected() {
668 let dir = tempdir();
669 write_manifest(
670 dir.path(),
671 r#"
672 name = "todo"
673 version = "0.1.0"
674 private-modules = []
675
676 [dependencies.acme-widgets]
677 version = "^1.2.0"
678
679 [test-dependencies]
680 "#,
681 );
682
683 let errors = load(dir.path()).expect_err("a dependency with no source must be rejected");
684
685 assert_eq!(errors.len(), 1, "got {:?}", errors);
686 match &errors[0] {
687 ManifestError::InvalidDependencyEntry { table, package, .. } => {
688 assert_eq!(*table, "dependencies");
689 assert_eq!(package, "acme-widgets");
690 }
691 other => panic!("expected InvalidDependencyEntry, got {:?}", other),
692 }
693 }
694
695 #[test]
698 fn a_bare_version_string_is_malformed() {
699 let dir = tempdir();
700 write_manifest(
701 dir.path(),
702 r#"
703 name = "todo"
704 version = "0.1.0"
705 private-modules = []
706
707 [dependencies]
708 acme-widgets = "^1.2.0"
709
710 [test-dependencies]
711 "#,
712 );
713
714 let errors = load(dir.path()).expect_err("a bare version string is not a legal entry");
715
716 assert_eq!(errors.len(), 1, "got {:?}", errors);
717 assert!(
718 matches!(errors[0], ManifestError::Malformed { .. }),
719 "expected Malformed, got {:?}",
720 errors[0]
721 );
722 }
723
724 struct TempDir(PathBuf);
728
729 impl TempDir {
730 fn path(&self) -> &Path {
731 &self.0
732 }
733 }
734
735 impl Drop for TempDir {
736 fn drop(&mut self) {
737 let _ = std::fs::remove_dir_all(&self.0);
738 }
739 }
740
741 fn tempdir() -> TempDir {
742 let mut path = std::env::temp_dir();
743 let unique = format!(
744 "zelkova-manifest-test-{}-{}",
745 std::process::id(),
746 std::time::SystemTime::now()
747 .duration_since(std::time::UNIX_EPOCH)
748 .unwrap()
749 .as_nanos()
750 );
751 path.push(unique);
752 std::fs::create_dir_all(&path).unwrap();
753 TempDir(path)
754 }
755}