1use serde::Deserialize;
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::Path;
10
11#[derive(Debug, thiserror::Error)]
17pub enum Error {
18 #[error("TOML parse error: {0}")]
19 Toml(#[from] toml::de::Error),
20
21 #[error("missing {0}")]
22 MissingField(&'static str),
23
24 #[error("invalid battery pack name '{name}': must end in '-battery-pack'")]
25 InvalidName { name: String },
26
27 #[error("feature '{feature}' references unknown crate '{crate_name}'")]
28 UnknownCrateInFeature { feature: String, crate_name: String },
29
30 #[error("reading {path}: {source}")]
31 Io {
32 path: String,
33 #[source]
34 source: std::io::Error,
35 },
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Severity {
45 Error,
47 Warning,
49}
50
51#[derive(Debug, Clone)]
53pub struct Diagnostic {
54 pub severity: Severity,
55 pub rule: &'static str,
57 pub message: String,
58}
59
60#[derive(Debug, Default)]
62pub struct ValidationReport {
63 pub diagnostics: Vec<Diagnostic>,
64}
65
66impl ValidationReport {
67 pub fn has_errors(&self) -> bool {
69 self.diagnostics
70 .iter()
71 .any(|d| d.severity == Severity::Error)
72 }
73
74 pub fn is_clean(&self) -> bool {
76 self.diagnostics.is_empty()
77 }
78
79 pub fn merge(&mut self, other: ValidationReport) {
81 self.diagnostics.extend(other.diagnostics);
82 }
83
84 fn error(&mut self, rule: &'static str, message: impl Into<String>) {
85 self.diagnostics.push(Diagnostic {
86 severity: Severity::Error,
87 rule,
88 message: message.into(),
89 });
90 }
91
92 fn warning(&mut self, rule: &'static str, message: impl Into<String>) {
93 self.diagnostics.push(Diagnostic {
94 severity: Severity::Warning,
95 rule,
96 message: message.into(),
97 });
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
109pub enum DepKind {
110 Normal,
112 Dev,
114 Build,
116}
117
118impl std::fmt::Display for DepKind {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 match self {
121 DepKind::Normal => write!(f, "dependencies"),
122 DepKind::Dev => write!(f, "dev-dependencies"),
123 DepKind::Build => write!(f, "build-dependencies"),
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
131pub struct CrateSpec {
132 pub version: String,
134 pub features: BTreeSet<String>,
136 pub dep_kind: DepKind,
138 pub optional: bool,
141}
142
143#[derive(Debug, Clone)]
145pub struct TemplateSpec {
146 pub path: String,
147 pub description: Option<String>,
148}
149
150#[derive(Debug, Clone)]
155pub struct BatteryPackSpec {
156 pub name: String,
158 pub version: String,
160 pub description: String,
162 pub repository: Option<String>,
164 pub keywords: Vec<String>,
166 pub crates: BTreeMap<String, CrateSpec>,
169 pub features: BTreeMap<String, BTreeSet<String>>,
172 pub hidden: BTreeSet<String>,
175 pub templates: BTreeMap<String, TemplateSpec>,
177}
178
179impl BatteryPackSpec {
180 pub fn validate(&self) -> Result<(), Error> {
183 if !self.name.ends_with("-battery-pack") {
184 return Err(Error::InvalidName {
185 name: self.name.clone(),
186 });
187 }
188 self.validate_features()?;
189 Ok(())
190 }
191
192 fn validate_features(&self) -> Result<(), Error> {
194 for (feature_name, crate_names) in &self.features {
195 for crate_name in crate_names {
196 if !self.crates.contains_key(crate_name) {
197 return Err(Error::UnknownCrateInFeature {
198 feature: feature_name.clone(),
199 crate_name: crate_name.clone(),
200 });
201 }
202 }
203 }
204 Ok(())
205 }
206
207 pub fn validate_spec(&self) -> ValidationReport {
210 let mut report = ValidationReport::default();
211
212 if self.name != "battery-pack" && !self.name.ends_with("-battery-pack") {
214 report.error(
215 "format.crate.name",
216 format!("name '{}' must end in '-battery-pack'", self.name),
217 );
218 }
219
220 if !self.keywords.iter().any(|k| k == "battery-pack") {
222 report.error(
223 "format.crate.keyword",
224 "keywords must include 'battery-pack'",
225 );
226 }
227
228 if self.repository.is_none() {
230 report.warning(
231 "format.crate.repository",
232 "battery pack should set the `repository` field for linking to examples and templates",
233 );
234 }
235
236 for (feature_name, crate_names) in &self.features {
238 for crate_name in crate_names {
239 if !self.crates.contains_key(crate_name) {
240 report.error(
241 "format.features.grouping",
242 format!(
243 "feature '{}' references unknown crate '{}'",
244 feature_name, crate_name
245 ),
246 );
247 }
248 }
249 }
250
251 report
252 }
253
254 pub fn resolve_crates(&self, active_features: &[&str]) -> BTreeMap<String, CrateSpec> {
263 let mut result: BTreeMap<String, CrateSpec> = BTreeMap::new();
264
265 if active_features.is_empty() {
266 self.add_default_crates(&mut result);
268 } else {
269 for feature_name in active_features {
270 if *feature_name == "default" {
271 self.add_default_crates(&mut result);
272 } else if let Some(crate_names) = self.features.get(*feature_name) {
273 self.add_feature_crates(crate_names, &mut result);
274 }
275 }
276 }
277
278 for (name, spec) in &self.crates {
281 if spec.dep_kind != DepKind::Normal && !self.is_hidden(name) {
282 result.entry(name.clone()).or_insert_with(|| spec.clone());
283 }
284 }
285
286 result
287 }
288
289 fn add_default_crates(&self, result: &mut BTreeMap<String, CrateSpec>) {
292 if let Some(default_crate_names) = self.features.get("default") {
293 self.add_feature_crates(default_crate_names, result);
295 } else {
296 for (name, spec) in &self.crates {
298 if !spec.optional {
299 result.insert(name.clone(), spec.clone());
300 }
301 }
302 }
303 }
304
305 fn add_feature_crates(
310 &self,
311 crate_names: &BTreeSet<String>,
312 result: &mut BTreeMap<String, CrateSpec>,
313 ) {
314 for crate_name in crate_names {
315 if let Some(spec) = self.crates.get(crate_name) {
316 if let Some(existing) = result.get_mut(crate_name) {
317 existing.features.extend(spec.features.iter().cloned());
319 } else {
320 result.insert(crate_name.clone(), spec.clone());
321 }
322 }
323 }
324 }
325
326 pub fn resolve_all(&self) -> BTreeMap<String, CrateSpec> {
328 self.crates.clone()
329 }
330
331 pub fn resolve_all_visible(&self) -> BTreeMap<String, CrateSpec> {
334 self.crates
335 .iter()
336 .filter(|(name, _)| !self.is_hidden(name))
337 .map(|(name, spec)| (name.clone(), spec.clone()))
338 .collect()
339 }
340
341 pub fn resolve_for_features(
347 &self,
348 active_features: &BTreeSet<String>,
349 ) -> BTreeMap<String, CrateSpec> {
350 if active_features.iter().any(|s| s == "all") {
351 self.resolve_all_visible()
352 } else {
353 let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
354 self.resolve_crates(&str_features)
355 }
356 }
357
358 pub fn is_hidden(&self, crate_name: &str) -> bool {
361 self.hidden
362 .iter()
363 .any(|pattern| glob_match(pattern, crate_name))
364 }
365
366 pub fn visible_crates(&self) -> BTreeMap<&str, &CrateSpec> {
368 self.crates
369 .iter()
370 .filter(|(name, _)| !self.is_hidden(name))
371 .map(|(name, spec)| (name.as_str(), spec))
372 .collect()
373 }
374
375 pub fn all_crates_with_grouping(&self) -> Vec<(String, String, &CrateSpec, bool)> {
384 let default_crates = self.resolve_crates(&[]);
385 let mut result = Vec::new();
386 let mut seen = std::collections::BTreeSet::new();
387
388 for (feature_name, crate_names) in &self.features {
390 for crate_name in crate_names {
391 if self.is_hidden(crate_name) {
392 continue;
393 }
394 if let Some(spec) = self.crates.get(crate_name)
395 && seen.insert(crate_name.clone())
396 {
397 let is_default = default_crates.contains_key(crate_name);
398 result.push((feature_name.clone(), crate_name.clone(), spec, is_default));
399 }
400 }
401 }
402
403 for (crate_name, spec) in &self.crates {
405 if self.is_hidden(crate_name) {
406 continue;
407 }
408 if seen.insert(crate_name.clone()) {
409 let is_default = default_crates.contains_key(crate_name);
410 result.push(("default".to_string(), crate_name.clone(), spec, is_default));
411 }
412 }
413
414 result
415 }
416
417 pub fn has_meaningful_choices(&self) -> bool {
420 let non_default_features = self
421 .features
422 .keys()
423 .filter(|k| k.as_str() != "default")
424 .count();
425 non_default_features > 0 || self.crates.len() > 3
426 }
427}
428
429fn glob_match(pattern: &str, name: &str) -> bool {
442 let pat: Vec<char> = pattern.chars().collect();
443 let txt: Vec<char> = name.chars().collect();
444 glob_match_inner(&pat, &txt)
445}
446
447fn glob_match_inner(pat: &[char], txt: &[char]) -> bool {
448 match (pat.first(), txt.first()) {
449 (None, None) => true,
450 (Some('*'), _) => {
451 glob_match_inner(&pat[1..], txt)
453 || (!txt.is_empty() && glob_match_inner(pat, &txt[1..]))
454 }
455 (Some('?'), Some(_)) => glob_match_inner(&pat[1..], &txt[1..]),
456 (Some(a), Some(b)) if a == b => glob_match_inner(&pat[1..], &txt[1..]),
457 _ => false,
458 }
459}
460
461#[derive(Debug, Clone)]
471pub struct MergedCrateSpec {
472 pub version: String,
474 pub features: BTreeSet<String>,
476 pub dep_kinds: Vec<DepKind>,
480 pub optional: bool,
482}
483
484pub fn merge_crate_specs(
496 specs: &[BTreeMap<String, CrateSpec>],
497) -> BTreeMap<String, MergedCrateSpec> {
498 let mut merged: BTreeMap<String, MergedCrateSpec> = BTreeMap::new();
499
500 for pack in specs {
501 for (name, spec) in pack {
502 match merged.get_mut(name) {
503 Some(existing) => {
504 if compare_versions(&spec.version, &existing.version)
506 == std::cmp::Ordering::Greater
507 {
508 existing.version = spec.version.clone();
509 }
510
511 existing.features.extend(spec.features.iter().cloned());
513
514 existing.dep_kinds = merge_dep_kinds(&existing.dep_kinds, spec.dep_kind);
516
517 if !spec.optional {
519 existing.optional = false;
520 }
521 }
522 None => {
523 merged.insert(
524 name.clone(),
525 MergedCrateSpec {
526 version: spec.version.clone(),
527 features: spec.features.clone(),
528 dep_kinds: vec![spec.dep_kind],
529 optional: spec.optional,
530 },
531 );
532 }
533 }
534 }
535 }
536
537 merged
538}
539
540fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
547 let a_parts: Vec<&str> = a.split('.').collect();
548 let b_parts: Vec<&str> = b.split('.').collect();
549
550 let max_len = a_parts.len().max(b_parts.len());
551
552 for i in 0..max_len {
553 let a_part = a_parts.get(i).copied().unwrap_or("0");
554 let b_part = b_parts.get(i).copied().unwrap_or("0");
555
556 match (a_part.parse::<u64>(), b_part.parse::<u64>()) {
558 (Ok(a_num), Ok(b_num)) => {
559 let ord = a_num.cmp(&b_num);
560 if ord != std::cmp::Ordering::Equal {
561 return ord;
562 }
563 }
564 _ => {
566 let ord = a_part.cmp(b_part);
567 if ord != std::cmp::Ordering::Equal {
568 return ord;
569 }
570 }
571 }
572 }
573
574 std::cmp::Ordering::Equal
575}
576
577fn merge_dep_kinds(existing: &[DepKind], incoming: DepKind) -> Vec<DepKind> {
583 if existing.contains(&DepKind::Normal) || incoming == DepKind::Normal {
585 return vec![DepKind::Normal];
586 }
587
588 let mut kinds: Vec<DepKind> = existing.to_vec();
590 if !kinds.contains(&incoming) {
591 kinds.push(incoming);
592 }
593 kinds.sort();
594 kinds
595}
596
597#[derive(Deserialize)]
602struct RawManifest {
603 package: Option<RawPackage>,
604 #[serde(default)]
605 features: BTreeMap<String, Vec<String>>,
606 #[serde(default)]
607 dependencies: BTreeMap<String, toml::Value>,
608 #[serde(default, rename = "dev-dependencies")]
609 dev_dependencies: BTreeMap<String, toml::Value>,
610 #[serde(default, rename = "build-dependencies")]
611 build_dependencies: BTreeMap<String, toml::Value>,
612}
613
614#[derive(Deserialize)]
615struct RawPackage {
616 name: Option<String>,
617 version: Option<String>,
618 #[serde(default)]
619 description: Option<String>,
620 #[serde(default)]
621 repository: Option<String>,
622 #[serde(default)]
623 keywords: Vec<String>,
624 #[serde(default)]
625 metadata: Option<RawMetadata>,
626}
627
628#[derive(Deserialize)]
629struct RawMetadata {
630 #[serde(default, rename = "battery-pack")]
631 battery_pack: Option<RawBatteryPackMetadata>,
632 #[serde(default)]
633 battery: Option<RawBatteryMetadata>,
634}
635
636#[derive(Deserialize)]
637struct RawBatteryPackMetadata {
638 #[serde(default)]
639 hidden: Vec<String>,
640}
641
642#[derive(Deserialize)]
643struct RawBatteryMetadata {
644 #[serde(default)]
645 templates: BTreeMap<String, RawTemplateSpec>,
646}
647
648#[derive(Deserialize)]
649struct RawTemplateSpec {
650 path: String,
651 #[serde(default)]
652 description: Option<String>,
653}
654
655struct RawDep {
657 version: String,
658 features: Vec<String>,
659 optional: bool,
660}
661
662pub fn parse_battery_pack(manifest_str: &str) -> Result<BatteryPackSpec, Error> {
668 let raw: RawManifest = toml::from_str(manifest_str)?;
669
670 let package = raw
671 .package
672 .ok_or(Error::MissingField("[package] section"))?;
673 let name = package.name.ok_or(Error::MissingField("package.name"))?;
674 let version = package
675 .version
676 .ok_or(Error::MissingField("package.version"))?;
677 let description = package.description.unwrap_or_default();
678 let repository = package.repository;
679 let keywords = package.keywords;
680
681 let mut crates = BTreeMap::new();
683 parse_dep_section(&raw.dependencies, DepKind::Normal, &mut crates);
684 parse_dep_section(&raw.dev_dependencies, DepKind::Dev, &mut crates);
685 parse_dep_section(&raw.build_dependencies, DepKind::Build, &mut crates);
686
687 let features: BTreeMap<String, BTreeSet<String>> = raw
689 .features
690 .into_iter()
691 .map(|(k, v)| (k, v.into_iter().collect()))
692 .collect();
693
694 let hidden: BTreeSet<String> = package
696 .metadata
697 .as_ref()
698 .and_then(|m| m.battery_pack.as_ref())
699 .map(|bp| bp.hidden.iter().cloned().collect())
700 .unwrap_or_default();
701
702 let templates = package
705 .metadata
706 .as_ref()
707 .and_then(|m| m.battery.as_ref())
708 .map(|b| {
709 b.templates
710 .iter()
711 .map(|(name, raw)| {
712 (
713 name.clone(),
714 TemplateSpec {
715 path: raw.path.clone(),
716 description: raw.description.clone(),
717 },
718 )
719 })
720 .collect()
721 })
722 .unwrap_or_default();
723
724 Ok(BatteryPackSpec {
725 name,
726 version,
727 description,
728 repository,
729 keywords,
730 crates,
731 features,
732 hidden,
733 templates,
734 })
735}
736
737fn parse_dep_section(
739 raw: &BTreeMap<String, toml::Value>,
740 kind: DepKind,
741 crates: &mut BTreeMap<String, CrateSpec>,
742) {
743 for (name, value) in raw {
744 let dep = parse_single_dep(value);
745 crates.insert(
746 name.clone(),
747 CrateSpec {
748 version: dep.version,
749 features: dep.features.into_iter().collect(),
750 dep_kind: kind,
751 optional: dep.optional,
752 },
753 );
754 }
755}
756
757fn parse_single_dep(value: &toml::Value) -> RawDep {
759 match value {
760 toml::Value::String(version) => RawDep {
761 version: version.clone(),
762 features: Vec::new(),
763 optional: false,
764 },
765 toml::Value::Table(table) => {
766 let version = table
767 .get("version")
768 .and_then(|v| v.as_str())
769 .unwrap_or("")
770 .to_string();
771 let features = table
772 .get("features")
773 .and_then(|v| v.as_array())
774 .map(|arr| {
775 arr.iter()
776 .filter_map(|v| v.as_str().map(String::from))
777 .collect()
778 })
779 .unwrap_or_default();
780 let optional = table
781 .get("optional")
782 .and_then(|v| v.as_bool())
783 .unwrap_or(false);
784 RawDep {
785 version,
786 features,
787 optional,
788 }
789 }
790 _ => RawDep {
791 version: String::new(),
792 features: Vec::new(),
793 optional: false,
794 },
795 }
796}
797
798pub fn discover_battery_packs(workspace_path: &Path) -> Result<Vec<BatteryPackSpec>, Error> {
806 let workspace_toml = workspace_path.join("Cargo.toml");
807 let content = std::fs::read_to_string(&workspace_toml).map_err(|e| Error::Io {
808 path: workspace_toml.display().to_string(),
809 source: e,
810 })?;
811
812 let raw: RawWorkspace = toml::from_str(&content)?;
813
814 let members = raw
815 .workspace
816 .ok_or(Error::MissingField("[workspace] section"))?
817 .members;
818
819 let mut packs = Vec::new();
820
821 for member_path in &members {
822 let member_dir = workspace_path.join(member_path);
823 let member_toml = member_dir.join("Cargo.toml");
824
825 if !member_toml.exists() {
826 continue;
827 }
828
829 let member_content = std::fs::read_to_string(&member_toml).map_err(|e| Error::Io {
830 path: member_toml.display().to_string(),
831 source: e,
832 })?;
833
834 let spec = parse_battery_pack(&member_content)?;
837 if spec.name.ends_with("-battery-pack") {
838 packs.push(spec);
839 }
840 }
841
842 Ok(packs)
843}
844
845pub fn discover_from_crate_root(crate_root: &Path) -> Result<Vec<BatteryPackSpec>, Error> {
853 if let Ok(specs) = discover_battery_packs(crate_root) {
855 return Ok(specs);
856 }
857
858 let mut dir = crate_root.to_path_buf();
860 while dir.pop() {
861 if let Ok(specs) = discover_battery_packs(&dir) {
862 return Ok(specs);
863 }
864 }
865
866 let cargo_toml = crate_root.join("Cargo.toml");
868 let content = std::fs::read_to_string(&cargo_toml).map_err(|e| Error::Io {
869 path: cargo_toml.display().to_string(),
870 source: e,
871 })?;
872 let spec = parse_battery_pack(&content)?;
873 Ok(vec![spec])
874}
875
876#[derive(Deserialize)]
878struct RawWorkspace {
879 workspace: Option<RawWorkspaceInner>,
880}
881
882#[derive(Deserialize)]
883struct RawWorkspaceInner {
884 #[serde(default)]
885 members: Vec<String>,
886}
887
888pub fn validate_on_disk(spec: &BatteryPackSpec, crate_root: &Path) -> ValidationReport {
898 let mut report = ValidationReport::default();
899 validate_lib_rs(crate_root, &mut report);
900 validate_no_extra_code(crate_root, &mut report);
901 validate_templates_on_disk(spec, crate_root, &mut report);
902 report
903}
904
905fn validate_lib_rs(crate_root: &Path, report: &mut ValidationReport) {
909 let lib_rs = crate_root.join("src/lib.rs");
910 let content = match std::fs::read_to_string(&lib_rs) {
911 Ok(c) => c,
912 Err(_) => return, };
914
915 for line in content.lines() {
916 let trimmed = line.trim();
917 if trimmed.is_empty()
918 || trimmed.starts_with("//")
919 || trimmed.starts_with("#!")
920 || trimmed.starts_with("include!")
921 || trimmed.starts_with("include_str!")
922 {
923 continue;
924 }
925 report.warning(
926 "format.crate.lib",
927 format!(
928 "src/lib.rs contains code beyond doc-comments and includes: {}",
929 trimmed
930 ),
931 );
932 return; }
934}
935
936fn validate_no_extra_code(crate_root: &Path, report: &mut ValidationReport) {
939 let src_dir = crate_root.join("src");
940 let entries = match std::fs::read_dir(&src_dir) {
941 Ok(e) => e,
942 Err(_) => return,
943 };
944
945 for entry in entries.flatten() {
946 let path = entry.path();
947 if path.is_file()
948 && let Some(ext) = path.extension()
949 && ext == "rs"
950 && path.file_name().is_some_and(|n| n != "lib.rs")
951 {
952 report.error(
953 "format.crate.no-code",
954 format!(
955 "src/ contains '{}' — battery packs must not contain functional code",
956 path.file_name().unwrap().to_string_lossy()
957 ),
958 );
959 }
960 }
961}
962
963fn validate_templates_on_disk(
966 spec: &BatteryPackSpec,
967 crate_root: &Path,
968 report: &mut ValidationReport,
969) {
970 for (name, template) in &spec.templates {
971 let template_dir = crate_root.join(&template.path);
972 if !template_dir.is_dir() {
973 report.error(
974 "format.templates.directory",
975 format!(
976 "template '{}' path '{}' does not exist",
977 name, template.path
978 ),
979 );
980 }
981 }
982}
983
984#[cfg(test)]
989mod tests {
990 use super::*;
991
992 #[test]
995 fn parse_deps_from_all_sections() {
998 let manifest = r#"
999 [package]
1000 name = "test-battery-pack"
1001 version = "0.1.0"
1002
1003 [dependencies]
1004 serde = { version = "1", features = ["derive"] }
1005
1006 [dev-dependencies]
1007 insta = "1.34"
1008
1009 [build-dependencies]
1010 cc = "1.0"
1011 "#;
1012
1013 let spec = parse_battery_pack(manifest).unwrap();
1014 assert_eq!(spec.crates.len(), 3);
1015
1016 let serde = &spec.crates["serde"];
1017 assert_eq!(serde.dep_kind, DepKind::Normal);
1018 assert_eq!(serde.version, "1");
1019 assert_eq!(serde.features, BTreeSet::from(["derive".to_string()]));
1020
1021 let insta = &spec.crates["insta"];
1022 assert_eq!(insta.dep_kind, DepKind::Dev);
1023 assert_eq!(insta.version, "1.34");
1024
1025 let cc = &spec.crates["cc"];
1026 assert_eq!(cc.dep_kind, DepKind::Build);
1027 assert_eq!(cc.version, "1.0");
1028 }
1029
1030 #[test]
1031 fn parse_version_and_features() {
1033 let manifest = r#"
1034 [package]
1035 name = "test-battery-pack"
1036 version = "0.1.0"
1037
1038 [dependencies]
1039 tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
1040 anyhow = "1"
1041 "#;
1042
1043 let spec = parse_battery_pack(manifest).unwrap();
1044 let tokio = &spec.crates["tokio"];
1045 assert_eq!(tokio.version, "1");
1046 assert_eq!(
1047 tokio.features,
1048 BTreeSet::from(["macros".to_string(), "rt-multi-thread".to_string()])
1049 );
1050 assert!(!tokio.optional);
1051
1052 let anyhow = &spec.crates["anyhow"];
1053 assert_eq!(anyhow.version, "1");
1054 assert!(anyhow.features.is_empty());
1055 }
1056
1057 #[test]
1058 fn parse_optional_deps() {
1060 let manifest = r#"
1061 [package]
1062 name = "test-battery-pack"
1063 version = "0.1.0"
1064
1065 [dependencies]
1066 clap = { version = "4", features = ["derive"] }
1067 indicatif = { version = "0.17", optional = true }
1068 "#;
1069
1070 let spec = parse_battery_pack(manifest).unwrap();
1071 assert!(!spec.crates["clap"].optional);
1072 assert!(spec.crates["indicatif"].optional);
1073 }
1074
1075 #[test]
1076 fn parse_cargo_features() {
1078 let manifest = r#"
1079 [package]
1080 name = "test-battery-pack"
1081 version = "0.1.0"
1082
1083 [dependencies]
1084 clap = { version = "4", features = ["derive"] }
1085 dialoguer = "0.11"
1086 indicatif = { version = "0.17", optional = true }
1087 console = { version = "0.15", optional = true }
1088
1089 [features]
1090 default = ["clap", "dialoguer"]
1091 indicators = ["indicatif", "console"]
1092 "#;
1093
1094 let spec = parse_battery_pack(manifest).unwrap();
1095 assert_eq!(spec.features.len(), 2);
1096 assert_eq!(
1097 spec.features["default"],
1098 BTreeSet::from(["clap".to_string(), "dialoguer".to_string()])
1099 );
1100 assert_eq!(
1101 spec.features["indicators"],
1102 BTreeSet::from(["indicatif".to_string(), "console".to_string()])
1103 );
1104 }
1105
1106 #[test]
1107 fn parse_hidden_deps() {
1109 let manifest = r#"
1110 [package]
1111 name = "test-battery-pack"
1112 version = "0.1.0"
1113
1114 [dependencies]
1115 serde = "1"
1116 serde_json = "1"
1117 serde_derive = "1"
1118 clap = "4"
1119
1120 [package.metadata.battery-pack]
1121 hidden = ["serde*"]
1122 "#;
1123
1124 let spec = parse_battery_pack(manifest).unwrap();
1125 assert_eq!(spec.hidden, BTreeSet::from(["serde*".to_string()]));
1126 }
1127
1128 #[test]
1129 fn parse_templates() {
1130 let manifest = r#"
1131 [package]
1132 name = "test-battery-pack"
1133 version = "0.1.0"
1134
1135 [package.metadata.battery.templates]
1136 default = { path = "templates/default", description = "A basic starting point" }
1137 advanced = { path = "templates/advanced", description = "Full-featured setup" }
1138 "#;
1139
1140 let spec = parse_battery_pack(manifest).unwrap();
1141 assert_eq!(spec.templates.len(), 2);
1142 assert_eq!(spec.templates["default"].path, "templates/default");
1143 assert_eq!(
1144 spec.templates["advanced"].description.as_deref(),
1145 Some("Full-featured setup")
1146 );
1147 }
1148
1149 #[test]
1150 fn parse_description_and_repository() {
1151 let manifest = r#"
1152 [package]
1153 name = "test-battery-pack"
1154 version = "0.1.0"
1155 description = "Error handling crates"
1156 repository = "https://github.com/example/repo"
1157 "#;
1158
1159 let spec = parse_battery_pack(manifest).unwrap();
1160 assert_eq!(spec.description, "Error handling crates");
1161 assert_eq!(
1162 spec.repository.as_deref(),
1163 Some("https://github.com/example/repo")
1164 );
1165 }
1166
1167 #[test]
1170 fn validate_name() {
1172 let manifest = r#"
1173 [package]
1174 name = "test-battery-pack"
1175 version = "0.1.0"
1176 "#;
1177 let spec = parse_battery_pack(manifest).unwrap();
1178 assert!(spec.validate().is_ok());
1179
1180 let manifest_bad = r#"
1181 [package]
1182 name = "not-a-battery-pack-crate"
1183 version = "0.1.0"
1184 "#;
1185 let spec_bad = parse_battery_pack(manifest_bad).unwrap();
1186 let err = spec_bad.validate().unwrap_err();
1187 assert!(matches!(err, Error::InvalidName { .. }));
1188 }
1189
1190 #[test]
1191 fn validate_features_reference_real_crates() {
1192 let manifest = r#"
1193 [package]
1194 name = "test-battery-pack"
1195 version = "0.1.0"
1196
1197 [dependencies]
1198 clap = "4"
1199
1200 [features]
1201 default = ["clap", "nonexistent"]
1202 "#;
1203 let spec = parse_battery_pack(manifest).unwrap();
1204 let err = spec.validate().unwrap_err();
1205 assert!(matches!(err, Error::UnknownCrateInFeature { .. }));
1206
1207 let manifest_ok = r#"
1209 [package]
1210 name = "test-battery-pack"
1211 version = "0.1.0"
1212
1213 [dependencies]
1214 clap = "4"
1215 dialoguer = "0.11"
1216
1217 [features]
1218 default = ["clap", "dialoguer"]
1219 "#;
1220 let spec_ok = parse_battery_pack(manifest_ok).unwrap();
1221 assert!(spec_ok.validate().is_ok());
1222 }
1223
1224 #[test]
1227 fn resolve_default_feature() {
1229 let manifest = r#"
1230 [package]
1231 name = "test-battery-pack"
1232 version = "0.1.0"
1233
1234 [dependencies]
1235 clap = { version = "4", features = ["derive"] }
1236 dialoguer = "0.11"
1237 indicatif = { version = "0.17", optional = true }
1238
1239 [features]
1240 default = ["clap", "dialoguer"]
1241 indicators = ["indicatif"]
1242 "#;
1243
1244 let spec = parse_battery_pack(manifest).unwrap();
1245 let resolved = spec.resolve_crates(&[]);
1246
1247 assert_eq!(resolved.len(), 2);
1248 assert!(resolved.contains_key("clap"));
1249 assert!(resolved.contains_key("dialoguer"));
1250 assert!(!resolved.contains_key("indicatif"));
1251 }
1252
1253 #[test]
1254 fn resolve_no_default_feature() {
1256 let manifest = r#"
1257 [package]
1258 name = "test-battery-pack"
1259 version = "0.1.0"
1260
1261 [dependencies]
1262 clap = "4"
1263 dialoguer = "0.11"
1264 indicatif = { version = "0.17", optional = true }
1265 "#;
1266
1267 let spec = parse_battery_pack(manifest).unwrap();
1268 let resolved = spec.resolve_crates(&[]);
1270
1271 assert_eq!(resolved.len(), 2);
1273 assert!(resolved.contains_key("clap"));
1274 assert!(resolved.contains_key("dialoguer"));
1275 assert!(!resolved.contains_key("indicatif"));
1276 }
1277
1278 #[test]
1279 fn resolve_additive_features() {
1281 let manifest = r#"
1282 [package]
1283 name = "test-battery-pack"
1284 version = "0.1.0"
1285
1286 [dependencies]
1287 clap = "4"
1288 dialoguer = "0.11"
1289 indicatif = { version = "0.17", optional = true }
1290 console = { version = "0.15", optional = true }
1291
1292 [features]
1293 default = ["clap", "dialoguer"]
1294 indicators = ["indicatif", "console"]
1295 "#;
1296
1297 let spec = parse_battery_pack(manifest).unwrap();
1298 let resolved = spec.resolve_crates(&["default", "indicators"]);
1299
1300 assert_eq!(resolved.len(), 4);
1301 assert!(resolved.contains_key("clap"));
1302 assert!(resolved.contains_key("dialoguer"));
1303 assert!(resolved.contains_key("indicatif"));
1304 assert!(resolved.contains_key("console"));
1305 }
1306
1307 #[test]
1308 fn resolve_feature_without_default() {
1309 let manifest = r#"
1310 [package]
1311 name = "test-battery-pack"
1312 version = "0.1.0"
1313
1314 [dependencies]
1315 clap = "4"
1316 dialoguer = "0.11"
1317 indicatif = { version = "0.17", optional = true }
1318
1319 [features]
1320 default = ["clap", "dialoguer"]
1321 indicators = ["indicatif"]
1322 "#;
1323
1324 let spec = parse_battery_pack(manifest).unwrap();
1325 let resolved = spec.resolve_crates(&["indicators"]);
1327
1328 assert_eq!(resolved.len(), 1);
1329 assert!(resolved.contains_key("indicatif"));
1330 assert!(!resolved.contains_key("clap"));
1331 }
1332
1333 #[test]
1334 fn resolve_feature_augmentation() {
1336 let manifest = r#"
1337 [package]
1338 name = "test-battery-pack"
1339 version = "0.1.0"
1340
1341 [dependencies]
1342 tokio = { version = "1", features = ["macros", "rt"] }
1343
1344 [features]
1345 default = ["tokio"]
1346 full = ["tokio"]
1347 "#;
1348
1349 let spec = parse_battery_pack(manifest).unwrap();
1350 let resolved = spec.resolve_crates(&["default", "full"]);
1352
1353 assert_eq!(resolved.len(), 1);
1354 let tokio = &resolved["tokio"];
1355 assert!(tokio.features.contains("macros"));
1356 assert!(tokio.features.contains("rt"));
1357 }
1358
1359 #[test]
1360 fn resolve_all() {
1361 let manifest = r#"
1362 [package]
1363 name = "test-battery-pack"
1364 version = "0.1.0"
1365
1366 [dependencies]
1367 clap = "4"
1368 indicatif = { version = "0.17", optional = true }
1369
1370 [dev-dependencies]
1371 insta = "1.34"
1372
1373 [features]
1374 default = ["clap"]
1375 "#;
1376
1377 let spec = parse_battery_pack(manifest).unwrap();
1378 let all = spec.resolve_all();
1379
1380 assert_eq!(all.len(), 3);
1382 assert!(all.contains_key("clap"));
1383 assert!(all.contains_key("indicatif"));
1384 assert!(all.contains_key("insta"));
1385 }
1386
1387 #[test]
1390 fn hidden_exact_match() {
1392 let manifest = r#"
1393 [package]
1394 name = "test-battery-pack"
1395 version = "0.1.0"
1396
1397 [dependencies]
1398 serde = "1"
1399 clap = "4"
1400
1401 [package.metadata.battery-pack]
1402 hidden = ["serde"]
1403 "#;
1404
1405 let spec = parse_battery_pack(manifest).unwrap();
1406 assert!(spec.is_hidden("serde"));
1407 assert!(!spec.is_hidden("clap"));
1408 }
1409
1410 #[test]
1411 fn hidden_glob_pattern() {
1413 let manifest = r#"
1414 [package]
1415 name = "test-battery-pack"
1416 version = "0.1.0"
1417
1418 [dependencies]
1419 serde = "1"
1420 serde_json = "1"
1421 serde_derive = "1"
1422 clap = "4"
1423
1424 [package.metadata.battery-pack]
1425 hidden = ["serde*"]
1426 "#;
1427
1428 let spec = parse_battery_pack(manifest).unwrap();
1429 assert!(spec.is_hidden("serde"));
1430 assert!(spec.is_hidden("serde_json"));
1431 assert!(spec.is_hidden("serde_derive"));
1432 assert!(!spec.is_hidden("clap"));
1433 }
1434
1435 #[test]
1436 fn hidden_wildcard_all() {
1438 let manifest = r#"
1439 [package]
1440 name = "test-battery-pack"
1441 version = "0.1.0"
1442
1443 [dependencies]
1444 serde = "1"
1445 clap = "4"
1446
1447 [package.metadata.battery-pack]
1448 hidden = ["*"]
1449 "#;
1450
1451 let spec = parse_battery_pack(manifest).unwrap();
1452 assert!(spec.is_hidden("serde"));
1453 assert!(spec.is_hidden("clap"));
1454 assert!(spec.is_hidden("anything"));
1455 }
1456
1457 #[test]
1458 fn visible_crates_filters_hidden() {
1459 let manifest = r#"
1460 [package]
1461 name = "test-battery-pack"
1462 version = "0.1.0"
1463
1464 [dependencies]
1465 serde = "1"
1466 serde_json = "1"
1467 clap = "4"
1468 anyhow = "1"
1469
1470 [package.metadata.battery-pack]
1471 hidden = ["serde*"]
1472 "#;
1473
1474 let spec = parse_battery_pack(manifest).unwrap();
1475 let visible = spec.visible_crates();
1476
1477 assert_eq!(visible.len(), 2);
1478 assert!(visible.contains_key("clap"));
1479 assert!(visible.contains_key("anyhow"));
1480 assert!(!visible.contains_key("serde"));
1481 assert!(!visible.contains_key("serde_json"));
1482 }
1483
1484 #[test]
1487 fn all_crates_with_grouping_filters_hidden() {
1488 let manifest = r#"
1489 [package]
1490 name = "test-battery-pack"
1491 version = "0.1.0"
1492
1493 [dependencies]
1494 serde = "1"
1495 serde_json = "1"
1496 clap = "4"
1497 anyhow = "1"
1498
1499 [package.metadata.battery-pack]
1500 hidden = ["serde*"]
1501 "#;
1502
1503 let spec = parse_battery_pack(manifest).unwrap();
1504 let grouped = spec.all_crates_with_grouping();
1505 let names: Vec<&str> = grouped.iter().map(|(_, n, _, _)| n.as_str()).collect();
1506 assert!(names.contains(&"clap"));
1507 assert!(names.contains(&"anyhow"));
1508 assert!(!names.contains(&"serde"), "hidden crate must be excluded");
1509 assert!(
1510 !names.contains(&"serde_json"),
1511 "hidden crate must be excluded"
1512 );
1513 }
1514
1515 #[test]
1518 fn glob_match_basics() {
1519 assert!(glob_match("*", "anything"));
1520 assert!(glob_match("serde*", "serde"));
1521 assert!(glob_match("serde*", "serde_json"));
1522 assert!(glob_match("serde*", "serde_derive"));
1523 assert!(!glob_match("serde*", "clap"));
1524
1525 assert!(glob_match("*-sys", "openssl-sys"));
1526 assert!(!glob_match("*-sys", "openssl"));
1527
1528 assert!(glob_match("?lap", "clap"));
1529 assert!(!glob_match("?lap", "claps"));
1530
1531 assert!(glob_match("exact", "exact"));
1532 assert!(!glob_match("exact", "exacto"));
1533 }
1534
1535 #[test]
1538 fn error_on_invalid_toml() {
1539 let result = parse_battery_pack("not valid toml [[[");
1540 assert!(matches!(result, Err(Error::Toml(_))));
1541 }
1542
1543 #[test]
1544 fn error_on_missing_package() {
1545 let result = parse_battery_pack("[dependencies]\nfoo = \"1\"");
1546 assert!(matches!(result, Err(Error::MissingField(_))));
1547 }
1548
1549 #[test]
1552 fn full_battery_pack_parse() {
1553 let manifest = r#"
1554 [package]
1555 name = "cli-battery-pack"
1556 version = "0.3.0"
1557 description = "CLI essentials for Rust applications"
1558 repository = "https://github.com/battery-pack-rs/battery-pack"
1559 keywords = ["battery-pack"]
1560
1561 [dependencies]
1562 clap = { version = "4", features = ["derive"] }
1563 dialoguer = "0.11"
1564 indicatif = { version = "0.17", optional = true }
1565 console = { version = "0.15", optional = true }
1566
1567 [dev-dependencies]
1568 assert_cmd = "2.0"
1569
1570 [build-dependencies]
1571 cc = "1.0"
1572
1573 [features]
1574 default = ["clap", "dialoguer"]
1575 indicators = ["indicatif", "console"]
1576 fancy = ["clap", "indicatif", "console"]
1577
1578 [package.metadata.battery-pack]
1579 hidden = ["cc"]
1580
1581 [package.metadata.battery.templates]
1582 default = { path = "templates/default", description = "Basic CLI app" }
1583 "#;
1584
1585 let spec = parse_battery_pack(manifest).unwrap();
1586 assert!(spec.validate().is_ok());
1587
1588 assert_eq!(spec.name, "cli-battery-pack");
1590 assert_eq!(spec.version, "0.3.0");
1591 assert_eq!(spec.description, "CLI essentials for Rust applications");
1592
1593 assert_eq!(spec.crates.len(), 6);
1595 assert_eq!(spec.crates["clap"].dep_kind, DepKind::Normal);
1596 assert_eq!(spec.crates["assert_cmd"].dep_kind, DepKind::Dev);
1597 assert_eq!(spec.crates["cc"].dep_kind, DepKind::Build);
1598
1599 assert!(spec.crates["indicatif"].optional);
1601 assert!(!spec.crates["clap"].optional);
1602
1603 assert_eq!(spec.features.len(), 3);
1605
1606 assert!(spec.is_hidden("cc"));
1608 assert!(!spec.is_hidden("clap"));
1609
1610 let visible = spec.visible_crates();
1612 assert_eq!(visible.len(), 5); assert_eq!(spec.templates.len(), 1);
1616
1617 let default = spec.resolve_crates(&[]);
1619 assert_eq!(default.len(), 3);
1620 assert!(default.contains_key("clap"));
1621 assert!(default.contains_key("dialoguer"));
1622 assert!(default.contains_key("assert_cmd"));
1623
1624 let with_indicators = spec.resolve_crates(&["default", "indicators"]);
1626 assert_eq!(with_indicators.len(), 5);
1627
1628 let only_indicators = spec.resolve_crates(&["indicators"]);
1630 assert_eq!(only_indicators.len(), 3);
1631 assert!(only_indicators.contains_key("indicatif"));
1632 assert!(only_indicators.contains_key("console"));
1633 assert!(only_indicators.contains_key("assert_cmd"));
1634
1635 let all = spec.resolve_all();
1637 assert_eq!(all.len(), 6);
1638 }
1639
1640 #[test]
1643 fn discover_battery_packs_in_fixture_workspace() {
1645 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1647 let workspace_root = manifest_dir
1648 .parent()
1649 .unwrap()
1650 .parent()
1651 .unwrap()
1652 .parent()
1653 .unwrap();
1654 let fixtures_dir = workspace_root.join("tests/fixtures");
1655
1656 let packs = discover_battery_packs(&fixtures_dir).unwrap();
1657
1658 assert_eq!(packs.len(), 4);
1659
1660 let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
1661 assert!(names.contains(&"basic-battery-pack"));
1662 assert!(names.contains(&"fancy-battery-pack"));
1663 assert!(names.contains(&"broken-battery-pack"));
1664 assert!(names.contains(&"managed-battery-pack"));
1665
1666 let basic = packs
1668 .iter()
1669 .find(|p| p.name == "basic-battery-pack")
1670 .unwrap();
1671 assert_eq!(basic.version, "0.1.0");
1672 assert_eq!(basic.crates.len(), 3); assert!(basic.crates["eyre"].optional);
1674 assert!(basic.crates["anyhow"].optional);
1675
1676 let fancy = packs
1678 .iter()
1679 .find(|p| p.name == "fancy-battery-pack")
1680 .unwrap();
1681 assert_eq!(fancy.version, "0.2.0");
1682 assert!(fancy.is_hidden("serde"));
1683 assert!(fancy.is_hidden("serde_json"));
1684 assert!(fancy.is_hidden("cc"));
1685 assert!(!fancy.is_hidden("clap"));
1686 assert_eq!(fancy.templates.len(), 2);
1687
1688 let default = fancy.resolve_crates(&[]);
1690 assert_eq!(default.len(), 4);
1691 assert!(default.contains_key("clap"));
1692 assert!(default.contains_key("dialoguer"));
1693 assert!(default.contains_key("assert_cmd"));
1694 assert!(default.contains_key("predicates"));
1695
1696 let visible = fancy.visible_crates();
1698 assert!(!visible.contains_key("serde"));
1699 assert!(!visible.contains_key("serde_json"));
1700 assert!(!visible.contains_key("cc"));
1701 assert!(visible.contains_key("clap"));
1702
1703 let managed = packs
1705 .iter()
1706 .find(|p| p.name == "managed-battery-pack")
1707 .unwrap();
1708 assert_eq!(managed.version, "0.2.0");
1709 assert_eq!(managed.crates.len(), 4); assert!(managed.crates["anyhow"].optional);
1711 assert!(managed.crates["clap"].optional);
1712 assert_eq!(managed.templates.len(), 1);
1713 let default = managed.resolve_crates(&[]);
1714 assert_eq!(default.len(), 4);
1715 assert!(default.contains_key("anyhow"));
1716 assert!(default.contains_key("clap"));
1717 assert!(default.contains_key("insta"));
1718 assert!(default.contains_key("cc"));
1719 }
1720
1721 #[test]
1722 fn discover_from_crate_root_finds_workspace() {
1724 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1725 let workspace_root = manifest_dir
1726 .parent()
1727 .unwrap()
1728 .parent()
1729 .unwrap()
1730 .parent()
1731 .unwrap();
1732 let member = workspace_root.join("tests/fixtures/basic-battery-pack");
1733
1734 let packs = discover_from_crate_root(&member).unwrap();
1735 assert_eq!(packs.len(), 4);
1736 let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
1737 assert!(names.contains(&"basic-battery-pack"));
1738 assert!(names.contains(&"fancy-battery-pack"));
1739 }
1740
1741 #[test]
1742 fn discover_from_crate_root_standalone() {
1744 let tmp = tempfile::tempdir().unwrap();
1745 std::fs::write(
1746 tmp.path().join("Cargo.toml"),
1747 r#"
1748[package]
1749name = "solo-battery-pack"
1750version = "1.0.0"
1751
1752[features]
1753default = ["dep:tokio"]
1754
1755[dependencies]
1756tokio = { version = "1", optional = true }
1757"#,
1758 )
1759 .unwrap();
1760
1761 let packs = discover_from_crate_root(tmp.path()).unwrap();
1762 assert_eq!(packs.len(), 1);
1763 assert_eq!(packs[0].name, "solo-battery-pack");
1764 assert_eq!(packs[0].version, "1.0.0");
1765 }
1766
1767 #[test]
1770 fn validate_spec_name() {
1772 let good = parse_battery_pack(
1773 r#"
1774 [package]
1775 name = "test-battery-pack"
1776 version = "0.1.0"
1777 repository = "https://github.com/example/test"
1778 keywords = ["battery-pack"]
1779 "#,
1780 )
1781 .unwrap();
1782 assert!(good.validate_spec().is_clean());
1783
1784 let exact = parse_battery_pack(
1785 r#"
1786 [package]
1787 name = "battery-pack"
1788 version = "0.1.0"
1789 repository = "https://github.com/example/test"
1790 keywords = ["battery-pack"]
1791 "#,
1792 )
1793 .unwrap();
1794 assert!(exact.validate_spec().is_clean());
1795
1796 let bad = parse_battery_pack(
1797 r#"
1798 [package]
1799 name = "not-a-pack"
1800 version = "0.1.0"
1801 keywords = ["battery-pack"]
1802 "#,
1803 )
1804 .unwrap();
1805 let report = bad.validate_spec();
1806 assert!(report.has_errors());
1807 assert!(
1808 report
1809 .diagnostics
1810 .iter()
1811 .any(|d| d.rule == "format.crate.name")
1812 );
1813 }
1814
1815 #[test]
1816 fn validate_spec_keyword() {
1818 let good = parse_battery_pack(
1819 r#"
1820 [package]
1821 name = "test-battery-pack"
1822 version = "0.1.0"
1823 repository = "https://github.com/example/test"
1824 keywords = ["battery-pack", "helpers"]
1825 "#,
1826 )
1827 .unwrap();
1828 assert!(good.validate_spec().is_clean());
1829
1830 let missing = parse_battery_pack(
1831 r#"
1832 [package]
1833 name = "test-battery-pack"
1834 version = "0.1.0"
1835 "#,
1836 )
1837 .unwrap();
1838 let report = missing.validate_spec();
1839 assert!(report.has_errors());
1840 assert!(
1841 report
1842 .diagnostics
1843 .iter()
1844 .any(|d| d.rule == "format.crate.keyword")
1845 );
1846
1847 let wrong = parse_battery_pack(
1848 r#"
1849 [package]
1850 name = "test-battery-pack"
1851 version = "0.1.0"
1852 keywords = ["cli", "helpers"]
1853 "#,
1854 )
1855 .unwrap();
1856 let report = wrong.validate_spec();
1857 assert!(report.has_errors());
1858 assert!(
1859 report
1860 .diagnostics
1861 .iter()
1862 .any(|d| d.rule == "format.crate.keyword")
1863 );
1864 }
1865
1866 #[test]
1867 fn validate_spec_features() {
1869 let good = parse_battery_pack(
1870 r#"
1871 [package]
1872 name = "test-battery-pack"
1873 version = "0.1.0"
1874 repository = "https://github.com/example/test"
1875 keywords = ["battery-pack"]
1876
1877 [dependencies]
1878 clap = "4"
1879
1880 [features]
1881 default = ["clap"]
1882 "#,
1883 )
1884 .unwrap();
1885 assert!(good.validate_spec().is_clean());
1886
1887 let bad = parse_battery_pack(
1888 r#"
1889 [package]
1890 name = "test-battery-pack"
1891 version = "0.1.0"
1892 keywords = ["battery-pack"]
1893
1894 [dependencies]
1895 clap = "4"
1896
1897 [features]
1898 default = ["clap", "ghost"]
1899 "#,
1900 )
1901 .unwrap();
1902 let report = bad.validate_spec();
1903 assert!(report.has_errors());
1904 assert!(
1905 report
1906 .diagnostics
1907 .iter()
1908 .any(|d| d.rule == "format.features.grouping" && d.message.contains("ghost"))
1909 );
1910 }
1911
1912 #[test]
1915 fn validate_lib_rs_clean() {
1917 let dir = tempfile::tempdir().unwrap();
1918 let src = dir.path().join("src");
1919 std::fs::create_dir(&src).unwrap();
1920 std::fs::write(
1921 src.join("lib.rs"),
1922 "//! Doc comment\n\n// Regular comment\n",
1923 )
1924 .unwrap();
1925
1926 let spec = parse_battery_pack(
1927 r#"
1928 [package]
1929 name = "test-battery-pack"
1930 version = "0.1.0"
1931 keywords = ["battery-pack"]
1932 "#,
1933 )
1934 .unwrap();
1935
1936 let report = validate_on_disk(&spec, dir.path());
1937 assert!(report.is_clean());
1938 }
1939
1940 #[test]
1941 fn validate_lib_rs_with_code() {
1943 let dir = tempfile::tempdir().unwrap();
1944 let src = dir.path().join("src");
1945 std::fs::create_dir(&src).unwrap();
1946 std::fs::write(src.join("lib.rs"), "//! Doc comment\npub fn hello() {}\n").unwrap();
1947
1948 let spec = parse_battery_pack(
1949 r#"
1950 [package]
1951 name = "test-battery-pack"
1952 version = "0.1.0"
1953 keywords = ["battery-pack"]
1954 "#,
1955 )
1956 .unwrap();
1957
1958 let report = validate_on_disk(&spec, dir.path());
1959 assert!(!report.is_clean());
1960 assert!(!report.has_errors()); assert!(
1962 report
1963 .diagnostics
1964 .iter()
1965 .any(|d| d.rule == "format.crate.lib" && d.severity == Severity::Warning)
1966 );
1967 }
1968
1969 #[test]
1970 fn validate_no_extra_rs_files() {
1972 let dir = tempfile::tempdir().unwrap();
1973 let src = dir.path().join("src");
1974 std::fs::create_dir(&src).unwrap();
1975 std::fs::write(src.join("lib.rs"), "//! Doc\n").unwrap();
1976
1977 let spec = parse_battery_pack(
1978 r#"
1979 [package]
1980 name = "test-battery-pack"
1981 version = "0.1.0"
1982 keywords = ["battery-pack"]
1983 "#,
1984 )
1985 .unwrap();
1986
1987 let report = validate_on_disk(&spec, dir.path());
1989 assert!(report.is_clean());
1990
1991 std::fs::write(src.join("helper.rs"), "pub fn help() {}\n").unwrap();
1993 let report = validate_on_disk(&spec, dir.path());
1994 assert!(report.has_errors());
1995 assert!(
1996 report
1997 .diagnostics
1998 .iter()
1999 .any(|d| d.rule == "format.crate.no-code" && d.message.contains("helper.rs"))
2000 );
2001 }
2002
2003 #[test]
2004 fn validate_templates_exist() {
2006 let dir = tempfile::tempdir().unwrap();
2007 let src = dir.path().join("src");
2008 std::fs::create_dir(&src).unwrap();
2009 std::fs::write(src.join("lib.rs"), "//! Doc\n").unwrap();
2010
2011 let spec = parse_battery_pack(
2012 r#"
2013 [package]
2014 name = "test-battery-pack"
2015 version = "0.1.0"
2016 keywords = ["battery-pack"]
2017
2018 [package.metadata.battery.templates]
2019 default = { path = "templates/default", description = "Basic" }
2020 "#,
2021 )
2022 .unwrap();
2023
2024 let report = validate_on_disk(&spec, dir.path());
2026 assert!(report.has_errors());
2027 assert!(
2028 report
2029 .diagnostics
2030 .iter()
2031 .any(|d| d.rule == "format.templates.directory")
2032 );
2033
2034 let tmpl = dir.path().join("templates/default");
2036 std::fs::create_dir_all(&tmpl).unwrap();
2037 let report = validate_on_disk(&spec, dir.path());
2038 let template_errors: Vec<_> = report
2039 .diagnostics
2040 .iter()
2041 .filter(|d| d.rule.starts_with("format.templates."))
2042 .collect();
2043 assert!(template_errors.is_empty());
2044 }
2045
2046 #[test]
2049 fn validate_warns_on_missing_repository() {
2051 let spec = parse_battery_pack(
2052 r#"
2053 [package]
2054 name = "test-battery-pack"
2055 version = "0.1.0"
2056 keywords = ["battery-pack"]
2057 "#,
2058 )
2059 .unwrap();
2060 let report = spec.validate_spec();
2061 assert!(
2062 !report.has_errors(),
2063 "missing repository should not be an error"
2064 );
2065 assert!(
2066 report
2067 .diagnostics
2068 .iter()
2069 .any(|d| d.rule == "format.crate.repository" && d.severity == Severity::Warning),
2070 "should warn when repository is missing"
2071 );
2072 }
2073
2074 #[test]
2075 fn validate_no_warning_when_repository_present() {
2077 let spec = parse_battery_pack(
2078 r#"
2079 [package]
2080 name = "test-battery-pack"
2081 version = "0.1.0"
2082 repository = "https://github.com/example/repo"
2083 keywords = ["battery-pack"]
2084 "#,
2085 )
2086 .unwrap();
2087 let report = spec.validate_spec();
2088 assert!(
2089 !report
2090 .diagnostics
2091 .iter()
2092 .any(|d| d.rule == "format.crate.repository"),
2093 "should not warn when repository is present"
2094 );
2095 }
2096
2097 #[test]
2100 fn validate_fixture_basic_battery_pack() {
2101 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2102 let workspace_root = manifest_dir
2103 .parent()
2104 .unwrap()
2105 .parent()
2106 .unwrap()
2107 .parent()
2108 .unwrap();
2109 let fixture = workspace_root.join("tests/fixtures/basic-battery-pack");
2110
2111 let content = std::fs::read_to_string(fixture.join("Cargo.toml")).unwrap();
2112 let spec = parse_battery_pack(&content).unwrap();
2113
2114 let mut report = spec.validate_spec();
2115 report.merge(validate_on_disk(&spec, &fixture));
2116 assert!(
2118 !report.has_errors(),
2119 "basic-battery-pack should have no errors: {:?}",
2120 report.diagnostics
2121 );
2122 assert!(
2123 report
2124 .diagnostics
2125 .iter()
2126 .any(|d| d.rule == "format.crate.repository" && d.severity == Severity::Warning),
2127 "basic-battery-pack should warn about missing repository"
2128 );
2129 }
2130
2131 #[test]
2132 fn validate_fixture_fancy_battery_pack() {
2133 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2134 let workspace_root = manifest_dir
2135 .parent()
2136 .unwrap()
2137 .parent()
2138 .unwrap()
2139 .parent()
2140 .unwrap();
2141 let fixture = workspace_root.join("tests/fixtures/fancy-battery-pack");
2142
2143 let content = std::fs::read_to_string(fixture.join("Cargo.toml")).unwrap();
2144 let spec = parse_battery_pack(&content).unwrap();
2145
2146 let mut report = spec.validate_spec();
2147 report.merge(validate_on_disk(&spec, &fixture));
2148 assert!(
2149 report.is_clean(),
2150 "fancy-battery-pack should be clean: {:?}",
2151 report.diagnostics
2152 );
2153 }
2154
2155 #[test]
2156 fn validate_fixture_broken_battery_pack() {
2157 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2158 let workspace_root = manifest_dir
2159 .parent()
2160 .unwrap()
2161 .parent()
2162 .unwrap()
2163 .parent()
2164 .unwrap();
2165 let fixture = workspace_root.join("tests/fixtures/broken-battery-pack");
2166
2167 let content = std::fs::read_to_string(fixture.join("Cargo.toml")).unwrap();
2168 let spec = parse_battery_pack(&content).unwrap();
2169
2170 let mut report = spec.validate_spec();
2171 report.merge(validate_on_disk(&spec, &fixture));
2172
2173 assert!(report.has_errors());
2174
2175 let rules: Vec<&str> = report.diagnostics.iter().map(|d| d.rule).collect();
2176 assert!(
2177 rules.contains(&"format.crate.keyword"),
2178 "missing keyword error"
2179 );
2180 assert!(
2183 rules.contains(&"format.crate.no-code"),
2184 "missing no-code error"
2185 );
2186 assert!(
2187 rules.contains(&"format.templates.directory"),
2188 "missing template dir error"
2189 );
2190
2191 assert!(
2193 report
2194 .diagnostics
2195 .iter()
2196 .any(|d| d.rule == "format.crate.lib" && d.severity == Severity::Warning)
2197 );
2198 }
2199
2200 #[test]
2201 fn validate_fixture_managed_battery_pack() {
2202 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2203 let workspace_root = manifest_dir
2204 .parent()
2205 .unwrap()
2206 .parent()
2207 .unwrap()
2208 .parent()
2209 .unwrap();
2210 let fixture = workspace_root.join("tests/fixtures/managed-battery-pack");
2211
2212 let content = std::fs::read_to_string(fixture.join("Cargo.toml")).unwrap();
2213 let spec = parse_battery_pack(&content).unwrap();
2214
2215 let mut report = spec.validate_spec();
2216 report.merge(validate_on_disk(&spec, &fixture));
2217 assert!(
2218 report.is_clean(),
2219 "managed-battery-pack should be clean: {:?}",
2220 report.diagnostics
2221 );
2222 }
2223
2224 fn crate_spec(version: &str, features: &[&str], dep_kind: DepKind) -> CrateSpec {
2228 CrateSpec {
2229 version: version.to_string(),
2230 features: features
2231 .iter()
2232 .map(|s| s.to_string())
2233 .collect::<BTreeSet<_>>(),
2234 dep_kind,
2235 optional: false,
2236 }
2237 }
2238
2239 #[test]
2240 fn merge_version_newest_wins() {
2242 let pack_a = BTreeMap::from([(
2243 "serde".to_string(),
2244 crate_spec("1.0.100", &["derive"], DepKind::Normal),
2245 )]);
2246 let pack_b = BTreeMap::from([(
2247 "serde".to_string(),
2248 crate_spec("1.0.210", &["derive"], DepKind::Normal),
2249 )]);
2250
2251 let merged = merge_crate_specs(&[pack_a, pack_b]);
2252 assert_eq!(merged["serde"].version, "1.0.210");
2253 }
2254
2255 #[test]
2256 fn merge_version_across_major() {
2258 let pack_a = BTreeMap::from([(
2259 "clap".to_string(),
2260 crate_spec("3.4.0", &[], DepKind::Normal),
2261 )]);
2262 let pack_b = BTreeMap::from([(
2263 "clap".to_string(),
2264 crate_spec("4.5.0", &[], DepKind::Normal),
2265 )]);
2266
2267 let merged = merge_crate_specs(&[pack_a, pack_b]);
2268 assert_eq!(merged["clap"].version, "4.5.0");
2269 }
2270
2271 #[test]
2272 fn merge_version_same_version_no_conflict() {
2274 let pack_a = BTreeMap::from([(
2275 "anyhow".to_string(),
2276 crate_spec("1.0.80", &[], DepKind::Normal),
2277 )]);
2278 let pack_b = BTreeMap::from([(
2279 "anyhow".to_string(),
2280 crate_spec("1.0.80", &[], DepKind::Normal),
2281 )]);
2282
2283 let merged = merge_crate_specs(&[pack_a, pack_b]);
2284 assert_eq!(merged["anyhow"].version, "1.0.80");
2285 }
2286
2287 #[test]
2288 fn merge_features_union() {
2290 let pack_a = BTreeMap::from([(
2291 "tokio".to_string(),
2292 crate_spec("1", &["macros", "rt"], DepKind::Normal),
2293 )]);
2294 let pack_b = BTreeMap::from([(
2295 "tokio".to_string(),
2296 crate_spec("1", &["rt", "net", "io-util"], DepKind::Normal),
2297 )]);
2298
2299 let merged = merge_crate_specs(&[pack_a, pack_b]);
2300 let features = &merged["tokio"].features;
2301 assert!(features.contains(&"macros".to_string()));
2302 assert!(features.contains(&"rt".to_string()));
2303 assert!(features.contains(&"net".to_string()));
2304 assert!(features.contains(&"io-util".to_string()));
2305 assert_eq!(features.iter().filter(|f| f.as_str() == "rt").count(), 1);
2307 }
2308
2309 #[test]
2310 fn merge_dep_kind_normal_wins_over_dev() {
2312 let pack_a = BTreeMap::from([("serde".to_string(), crate_spec("1", &[], DepKind::Normal))]);
2313 let pack_b = BTreeMap::from([("serde".to_string(), crate_spec("1", &[], DepKind::Dev))]);
2314
2315 let merged = merge_crate_specs(&[pack_a, pack_b]);
2316 assert_eq!(merged["serde"].dep_kinds, vec![DepKind::Normal]);
2317 }
2318
2319 #[test]
2320 fn merge_dep_kind_normal_wins_over_build() {
2322 let pack_a = BTreeMap::from([("cc".to_string(), crate_spec("1", &[], DepKind::Build))]);
2323 let pack_b = BTreeMap::from([("cc".to_string(), crate_spec("1", &[], DepKind::Normal))]);
2324
2325 let merged = merge_crate_specs(&[pack_a, pack_b]);
2326 assert_eq!(merged["cc"].dep_kinds, vec![DepKind::Normal]);
2327 }
2328
2329 #[test]
2330 fn merge_dep_kind_dev_and_build_yields_both() {
2332 let pack_a = BTreeMap::from([("serde".to_string(), crate_spec("1", &[], DepKind::Dev))]);
2333 let pack_b = BTreeMap::from([("serde".to_string(), crate_spec("1", &[], DepKind::Build))]);
2334
2335 let merged = merge_crate_specs(&[pack_a, pack_b]);
2336 let kinds = &merged["serde"].dep_kinds;
2337 assert_eq!(kinds.len(), 2);
2338 assert!(kinds.contains(&DepKind::Dev));
2339 assert!(kinds.contains(&DepKind::Build));
2340 }
2341
2342 #[test]
2343 fn merge_three_packs_all_rules() {
2347 let pack_a = BTreeMap::from([
2348 (
2349 "tokio".to_string(),
2350 crate_spec("1.35.0", &["macros"], DepKind::Normal),
2351 ),
2352 (
2353 "serde".to_string(),
2354 crate_spec("1.0.100", &["derive"], DepKind::Dev),
2355 ),
2356 ]);
2357 let pack_b = BTreeMap::from([
2358 (
2359 "tokio".to_string(),
2360 crate_spec("1.38.0", &["rt"], DepKind::Dev),
2361 ),
2362 (
2363 "serde".to_string(),
2364 crate_spec("1.0.210", &["alloc"], DepKind::Build),
2365 ),
2366 ]);
2367 let pack_c = BTreeMap::from([
2368 (
2369 "tokio".to_string(),
2370 crate_spec("1.36.0", &["net", "macros"], DepKind::Normal),
2371 ),
2372 (
2373 "anyhow".to_string(),
2374 crate_spec("1.0.80", &[], DepKind::Normal),
2375 ),
2376 ]);
2377
2378 let merged = merge_crate_specs(&[pack_a, pack_b, pack_c]);
2379
2380 let tokio = &merged["tokio"];
2382 assert_eq!(tokio.version, "1.38.0");
2383 assert!(tokio.features.contains("macros"));
2384 assert!(tokio.features.contains("rt"));
2385 assert!(tokio.features.contains("net"));
2386 assert_eq!(tokio.dep_kinds, vec![DepKind::Normal]);
2387
2388 let serde = &merged["serde"];
2390 assert_eq!(serde.version, "1.0.210");
2391 assert!(serde.features.contains("derive"));
2392 assert!(serde.features.contains("alloc"));
2393 assert_eq!(serde.dep_kinds.len(), 2);
2394 assert!(serde.dep_kinds.contains(&DepKind::Dev));
2395 assert!(serde.dep_kinds.contains(&DepKind::Build));
2396
2397 let anyhow = &merged["anyhow"];
2399 assert_eq!(anyhow.version, "1.0.80");
2400 assert_eq!(anyhow.dep_kinds, vec![DepKind::Normal]);
2401 }
2402
2403 #[test]
2404 fn merge_non_overlapping_crates() {
2407 let pack_a = BTreeMap::from([(
2408 "serde".to_string(),
2409 crate_spec("1.0.210", &["derive"], DepKind::Normal),
2410 )]);
2411 let pack_b = BTreeMap::from([(
2412 "clap".to_string(),
2413 crate_spec("4.5.0", &["derive"], DepKind::Normal),
2414 )]);
2415
2416 let merged = merge_crate_specs(&[pack_a, pack_b]);
2417 assert_eq!(merged.len(), 2);
2418 assert_eq!(merged["serde"].version, "1.0.210");
2419 assert_eq!(merged["clap"].version, "4.5.0");
2420 }
2421
2422 #[test]
2423 fn merge_empty_input() {
2424 let merged = merge_crate_specs(&[]);
2425 assert!(merged.is_empty());
2426 }
2427
2428 #[test]
2429 fn merge_single_pack() {
2430 let pack = BTreeMap::from([
2431 (
2432 "serde".to_string(),
2433 crate_spec("1", &["derive"], DepKind::Normal),
2434 ),
2435 ("clap".to_string(), crate_spec("4", &[], DepKind::Normal)),
2436 ]);
2437
2438 let merged = merge_crate_specs(&[pack]);
2439 assert_eq!(merged.len(), 2);
2440 assert_eq!(merged["serde"].version, "1");
2441 assert_eq!(
2442 merged["serde"].features,
2443 BTreeSet::from(["derive".to_string()])
2444 );
2445 assert_eq!(merged["serde"].dep_kinds, vec![DepKind::Normal]);
2446 }
2447
2448 #[test]
2451 fn compare_versions_basic() {
2452 use std::cmp::Ordering;
2453 assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal);
2454 assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater);
2455 assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less);
2456 assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
2457 assert_eq!(compare_versions("1", "1.0"), Ordering::Equal);
2458 assert_eq!(compare_versions("1", "2"), Ordering::Less);
2459 assert_eq!(compare_versions("1.0.210", "1.0.100"), Ordering::Greater);
2460 }
2461}