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 result
279 }
280
281 fn add_default_crates(&self, result: &mut BTreeMap<String, CrateSpec>) {
284 if let Some(default_crate_names) = self.features.get("default") {
285 self.add_feature_crates(default_crate_names, result);
287 } else {
288 for (name, spec) in &self.crates {
290 if !spec.optional {
291 result.insert(name.clone(), spec.clone());
292 }
293 }
294 }
295 }
296
297 fn add_feature_crates(
302 &self,
303 crate_names: &BTreeSet<String>,
304 result: &mut BTreeMap<String, CrateSpec>,
305 ) {
306 for crate_name in crate_names {
307 if let Some(spec) = self.crates.get(crate_name) {
308 if let Some(existing) = result.get_mut(crate_name) {
309 existing.features.extend(spec.features.iter().cloned());
311 } else {
312 result.insert(crate_name.clone(), spec.clone());
313 }
314 }
315 }
316 }
317
318 pub fn resolve_all(&self) -> BTreeMap<String, CrateSpec> {
320 self.crates.clone()
321 }
322
323 pub fn resolve_all_visible(&self) -> BTreeMap<String, CrateSpec> {
326 self.crates
327 .iter()
328 .filter(|(name, _)| !self.is_hidden(name))
329 .map(|(name, spec)| (name.clone(), spec.clone()))
330 .collect()
331 }
332
333 pub fn resolve_for_features(
339 &self,
340 active_features: &BTreeSet<String>,
341 ) -> BTreeMap<String, CrateSpec> {
342 if active_features.iter().any(|s| s == "all") {
343 self.resolve_all_visible()
344 } else {
345 let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
346 self.resolve_crates(&str_features)
347 }
348 }
349
350 pub fn is_hidden(&self, crate_name: &str) -> bool {
353 self.hidden
354 .iter()
355 .any(|pattern| glob_match(pattern, crate_name))
356 }
357
358 pub fn visible_crates(&self) -> BTreeMap<&str, &CrateSpec> {
360 self.crates
361 .iter()
362 .filter(|(name, _)| !self.is_hidden(name))
363 .map(|(name, spec)| (name.as_str(), spec))
364 .collect()
365 }
366
367 pub fn all_crates_with_grouping(&self) -> Vec<(String, String, &CrateSpec, bool)> {
376 let default_crates = self.resolve_crates(&[]);
377 let mut result = Vec::new();
378 let mut seen = std::collections::BTreeSet::new();
379
380 for (feature_name, crate_names) in &self.features {
382 for crate_name in crate_names {
383 if self.is_hidden(crate_name) {
384 continue;
385 }
386 if let Some(spec) = self.crates.get(crate_name)
387 && seen.insert(crate_name.clone())
388 {
389 let is_default = default_crates.contains_key(crate_name);
390 result.push((feature_name.clone(), crate_name.clone(), spec, is_default));
391 }
392 }
393 }
394
395 for (crate_name, spec) in &self.crates {
397 if self.is_hidden(crate_name) {
398 continue;
399 }
400 if seen.insert(crate_name.clone()) {
401 let is_default = default_crates.contains_key(crate_name);
402 result.push(("default".to_string(), crate_name.clone(), spec, is_default));
403 }
404 }
405
406 result
407 }
408
409 pub fn has_meaningful_choices(&self) -> bool {
412 let non_default_features = self
413 .features
414 .keys()
415 .filter(|k| k.as_str() != "default")
416 .count();
417 non_default_features > 0 || self.crates.len() > 3
418 }
419}
420
421fn glob_match(pattern: &str, name: &str) -> bool {
434 let pat: Vec<char> = pattern.chars().collect();
435 let txt: Vec<char> = name.chars().collect();
436 glob_match_inner(&pat, &txt)
437}
438
439fn glob_match_inner(pat: &[char], txt: &[char]) -> bool {
440 match (pat.first(), txt.first()) {
441 (None, None) => true,
442 (Some('*'), _) => {
443 glob_match_inner(&pat[1..], txt)
445 || (!txt.is_empty() && glob_match_inner(pat, &txt[1..]))
446 }
447 (Some('?'), Some(_)) => glob_match_inner(&pat[1..], &txt[1..]),
448 (Some(a), Some(b)) if a == b => glob_match_inner(&pat[1..], &txt[1..]),
449 _ => false,
450 }
451}
452
453#[derive(Debug, Clone)]
463pub struct MergedCrateSpec {
464 pub version: String,
466 pub features: BTreeSet<String>,
468 pub dep_kinds: Vec<DepKind>,
472 pub optional: bool,
474}
475
476pub fn merge_crate_specs(
488 specs: &[BTreeMap<String, CrateSpec>],
489) -> BTreeMap<String, MergedCrateSpec> {
490 let mut merged: BTreeMap<String, MergedCrateSpec> = BTreeMap::new();
491
492 for pack in specs {
493 for (name, spec) in pack {
494 match merged.get_mut(name) {
495 Some(existing) => {
496 if compare_versions(&spec.version, &existing.version)
498 == std::cmp::Ordering::Greater
499 {
500 existing.version = spec.version.clone();
501 }
502
503 existing.features.extend(spec.features.iter().cloned());
505
506 existing.dep_kinds = merge_dep_kinds(&existing.dep_kinds, spec.dep_kind);
508
509 if !spec.optional {
511 existing.optional = false;
512 }
513 }
514 None => {
515 merged.insert(
516 name.clone(),
517 MergedCrateSpec {
518 version: spec.version.clone(),
519 features: spec.features.clone(),
520 dep_kinds: vec![spec.dep_kind],
521 optional: spec.optional,
522 },
523 );
524 }
525 }
526 }
527 }
528
529 merged
530}
531
532fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
539 let a_parts: Vec<&str> = a.split('.').collect();
540 let b_parts: Vec<&str> = b.split('.').collect();
541
542 let max_len = a_parts.len().max(b_parts.len());
543
544 for i in 0..max_len {
545 let a_part = a_parts.get(i).copied().unwrap_or("0");
546 let b_part = b_parts.get(i).copied().unwrap_or("0");
547
548 match (a_part.parse::<u64>(), b_part.parse::<u64>()) {
550 (Ok(a_num), Ok(b_num)) => {
551 let ord = a_num.cmp(&b_num);
552 if ord != std::cmp::Ordering::Equal {
553 return ord;
554 }
555 }
556 _ => {
558 let ord = a_part.cmp(b_part);
559 if ord != std::cmp::Ordering::Equal {
560 return ord;
561 }
562 }
563 }
564 }
565
566 std::cmp::Ordering::Equal
567}
568
569fn merge_dep_kinds(existing: &[DepKind], incoming: DepKind) -> Vec<DepKind> {
575 if existing.contains(&DepKind::Normal) || incoming == DepKind::Normal {
577 return vec![DepKind::Normal];
578 }
579
580 let mut kinds: Vec<DepKind> = existing.to_vec();
582 if !kinds.contains(&incoming) {
583 kinds.push(incoming);
584 }
585 kinds.sort();
586 kinds
587}
588
589#[derive(Deserialize)]
594struct RawManifest {
595 package: Option<RawPackage>,
596 #[serde(default)]
597 features: BTreeMap<String, Vec<String>>,
598 #[serde(default)]
599 dependencies: BTreeMap<String, toml::Value>,
600 #[serde(default, rename = "dev-dependencies")]
601 dev_dependencies: BTreeMap<String, toml::Value>,
602 #[serde(default, rename = "build-dependencies")]
603 build_dependencies: BTreeMap<String, toml::Value>,
604}
605
606#[derive(Deserialize)]
607struct RawPackage {
608 name: Option<String>,
609 version: Option<String>,
610 #[serde(default)]
611 description: Option<String>,
612 #[serde(default)]
613 repository: Option<String>,
614 #[serde(default)]
615 keywords: Vec<String>,
616 #[serde(default)]
617 metadata: Option<RawMetadata>,
618}
619
620#[derive(Deserialize)]
621struct RawMetadata {
622 #[serde(default, rename = "battery-pack")]
623 battery_pack: Option<RawBatteryPackMetadata>,
624 #[serde(default)]
625 battery: Option<RawBatteryMetadata>,
626}
627
628#[derive(Deserialize)]
629struct RawBatteryPackMetadata {
630 #[serde(default)]
631 hidden: Vec<String>,
632}
633
634#[derive(Deserialize)]
635struct RawBatteryMetadata {
636 #[serde(default)]
637 templates: BTreeMap<String, RawTemplateSpec>,
638}
639
640#[derive(Deserialize)]
641struct RawTemplateSpec {
642 path: String,
643 #[serde(default)]
644 description: Option<String>,
645}
646
647struct RawDep {
649 version: String,
650 features: Vec<String>,
651 optional: bool,
652}
653
654pub fn parse_battery_pack(manifest_str: &str) -> Result<BatteryPackSpec, Error> {
660 let raw: RawManifest = toml::from_str(manifest_str)?;
661
662 let package = raw
663 .package
664 .ok_or(Error::MissingField("[package] section"))?;
665 let name = package.name.ok_or(Error::MissingField("package.name"))?;
666 let version = package
667 .version
668 .ok_or(Error::MissingField("package.version"))?;
669 let description = package.description.unwrap_or_default();
670 let repository = package.repository;
671 let keywords = package.keywords;
672
673 let mut crates = BTreeMap::new();
675 parse_dep_section(&raw.dependencies, DepKind::Normal, &mut crates);
676 parse_dep_section(&raw.dev_dependencies, DepKind::Dev, &mut crates);
677 parse_dep_section(&raw.build_dependencies, DepKind::Build, &mut crates);
678
679 let features: BTreeMap<String, BTreeSet<String>> = raw
681 .features
682 .into_iter()
683 .map(|(k, v)| (k, v.into_iter().collect()))
684 .collect();
685
686 let hidden: BTreeSet<String> = package
688 .metadata
689 .as_ref()
690 .and_then(|m| m.battery_pack.as_ref())
691 .map(|bp| bp.hidden.iter().cloned().collect())
692 .unwrap_or_default();
693
694 let templates = package
697 .metadata
698 .as_ref()
699 .and_then(|m| m.battery.as_ref())
700 .map(|b| {
701 b.templates
702 .iter()
703 .map(|(name, raw)| {
704 (
705 name.clone(),
706 TemplateSpec {
707 path: raw.path.clone(),
708 description: raw.description.clone(),
709 },
710 )
711 })
712 .collect()
713 })
714 .unwrap_or_default();
715
716 Ok(BatteryPackSpec {
717 name,
718 version,
719 description,
720 repository,
721 keywords,
722 crates,
723 features,
724 hidden,
725 templates,
726 })
727}
728
729fn parse_dep_section(
731 raw: &BTreeMap<String, toml::Value>,
732 kind: DepKind,
733 crates: &mut BTreeMap<String, CrateSpec>,
734) {
735 for (name, value) in raw {
736 let dep = parse_single_dep(value);
737 crates.insert(
738 name.clone(),
739 CrateSpec {
740 version: dep.version,
741 features: dep.features.into_iter().collect(),
742 dep_kind: kind,
743 optional: dep.optional,
744 },
745 );
746 }
747}
748
749fn parse_single_dep(value: &toml::Value) -> RawDep {
751 match value {
752 toml::Value::String(version) => RawDep {
753 version: version.clone(),
754 features: Vec::new(),
755 optional: false,
756 },
757 toml::Value::Table(table) => {
758 let version = table
759 .get("version")
760 .and_then(|v| v.as_str())
761 .unwrap_or("")
762 .to_string();
763 let features = table
764 .get("features")
765 .and_then(|v| v.as_array())
766 .map(|arr| {
767 arr.iter()
768 .filter_map(|v| v.as_str().map(String::from))
769 .collect()
770 })
771 .unwrap_or_default();
772 let optional = table
773 .get("optional")
774 .and_then(|v| v.as_bool())
775 .unwrap_or(false);
776 RawDep {
777 version,
778 features,
779 optional,
780 }
781 }
782 _ => RawDep {
783 version: String::new(),
784 features: Vec::new(),
785 optional: false,
786 },
787 }
788}
789
790pub fn discover_battery_packs(workspace_path: &Path) -> Result<Vec<BatteryPackSpec>, Error> {
798 let workspace_toml = workspace_path.join("Cargo.toml");
799 let content = std::fs::read_to_string(&workspace_toml).map_err(|e| Error::Io {
800 path: workspace_toml.display().to_string(),
801 source: e,
802 })?;
803
804 let raw: RawWorkspace = toml::from_str(&content)?;
805
806 let members = raw
807 .workspace
808 .ok_or(Error::MissingField("[workspace] section"))?
809 .members;
810
811 let mut packs = Vec::new();
812
813 for member_path in &members {
814 let member_dir = workspace_path.join(member_path);
815 let member_toml = member_dir.join("Cargo.toml");
816
817 if !member_toml.exists() {
818 continue;
819 }
820
821 let member_content = std::fs::read_to_string(&member_toml).map_err(|e| Error::Io {
822 path: member_toml.display().to_string(),
823 source: e,
824 })?;
825
826 let spec = parse_battery_pack(&member_content)?;
829 if spec.name.ends_with("-battery-pack") {
830 packs.push(spec);
831 }
832 }
833
834 Ok(packs)
835}
836
837pub fn discover_from_crate_root(crate_root: &Path) -> Result<Vec<BatteryPackSpec>, Error> {
845 if let Ok(specs) = discover_battery_packs(crate_root) {
847 return Ok(specs);
848 }
849
850 let mut dir = crate_root.to_path_buf();
852 while dir.pop() {
853 if let Ok(specs) = discover_battery_packs(&dir) {
854 return Ok(specs);
855 }
856 }
857
858 let cargo_toml = crate_root.join("Cargo.toml");
860 let content = std::fs::read_to_string(&cargo_toml).map_err(|e| Error::Io {
861 path: cargo_toml.display().to_string(),
862 source: e,
863 })?;
864 let spec = parse_battery_pack(&content)?;
865 Ok(vec![spec])
866}
867
868#[derive(Deserialize)]
870struct RawWorkspace {
871 workspace: Option<RawWorkspaceInner>,
872}
873
874#[derive(Deserialize)]
875struct RawWorkspaceInner {
876 #[serde(default)]
877 members: Vec<String>,
878}
879
880pub fn validate_on_disk(spec: &BatteryPackSpec, crate_root: &Path) -> ValidationReport {
890 let mut report = ValidationReport::default();
891 validate_lib_rs(crate_root, &mut report);
892 validate_no_extra_code(crate_root, &mut report);
893 validate_templates_on_disk(spec, crate_root, &mut report);
894 report
895}
896
897fn validate_lib_rs(crate_root: &Path, report: &mut ValidationReport) {
901 let lib_rs = crate_root.join("src/lib.rs");
902 let content = match std::fs::read_to_string(&lib_rs) {
903 Ok(c) => c,
904 Err(_) => return, };
906
907 for line in content.lines() {
908 let trimmed = line.trim();
909 if trimmed.is_empty()
910 || trimmed.starts_with("//")
911 || trimmed.starts_with("#!")
912 || trimmed.starts_with("include!")
913 || trimmed.starts_with("include_str!")
914 {
915 continue;
916 }
917 report.warning(
918 "format.crate.lib",
919 format!(
920 "src/lib.rs contains code beyond doc-comments and includes: {}",
921 trimmed
922 ),
923 );
924 return; }
926}
927
928fn validate_no_extra_code(crate_root: &Path, report: &mut ValidationReport) {
931 let src_dir = crate_root.join("src");
932 let entries = match std::fs::read_dir(&src_dir) {
933 Ok(e) => e,
934 Err(_) => return,
935 };
936
937 for entry in entries.flatten() {
938 let path = entry.path();
939 if path.is_file()
940 && let Some(ext) = path.extension()
941 && ext == "rs"
942 && path.file_name().is_some_and(|n| n != "lib.rs")
943 {
944 report.error(
945 "format.crate.no-code",
946 format!(
947 "src/ contains '{}' — battery packs must not contain functional code",
948 path.file_name().unwrap().to_string_lossy()
949 ),
950 );
951 }
952 }
953}
954
955fn validate_templates_on_disk(
958 spec: &BatteryPackSpec,
959 crate_root: &Path,
960 report: &mut ValidationReport,
961) {
962 for (name, template) in &spec.templates {
963 let template_dir = crate_root.join(&template.path);
964 if !template_dir.is_dir() {
965 report.error(
966 "format.templates.directory",
967 format!(
968 "template '{}' path '{}' does not exist",
969 name, template.path
970 ),
971 );
972 }
973 }
974}
975
976#[cfg(test)]
981mod tests {
982 use super::*;
983
984 #[test]
987 fn parse_deps_from_all_sections() {
990 let manifest = r#"
991 [package]
992 name = "test-battery-pack"
993 version = "0.1.0"
994
995 [dependencies]
996 serde = { version = "1", features = ["derive"] }
997
998 [dev-dependencies]
999 insta = "1.34"
1000
1001 [build-dependencies]
1002 cc = "1.0"
1003 "#;
1004
1005 let spec = parse_battery_pack(manifest).unwrap();
1006 assert_eq!(spec.crates.len(), 3);
1007
1008 let serde = &spec.crates["serde"];
1009 assert_eq!(serde.dep_kind, DepKind::Normal);
1010 assert_eq!(serde.version, "1");
1011 assert_eq!(serde.features, BTreeSet::from(["derive".to_string()]));
1012
1013 let insta = &spec.crates["insta"];
1014 assert_eq!(insta.dep_kind, DepKind::Dev);
1015 assert_eq!(insta.version, "1.34");
1016
1017 let cc = &spec.crates["cc"];
1018 assert_eq!(cc.dep_kind, DepKind::Build);
1019 assert_eq!(cc.version, "1.0");
1020 }
1021
1022 #[test]
1023 fn parse_version_and_features() {
1025 let manifest = r#"
1026 [package]
1027 name = "test-battery-pack"
1028 version = "0.1.0"
1029
1030 [dependencies]
1031 tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
1032 anyhow = "1"
1033 "#;
1034
1035 let spec = parse_battery_pack(manifest).unwrap();
1036 let tokio = &spec.crates["tokio"];
1037 assert_eq!(tokio.version, "1");
1038 assert_eq!(
1039 tokio.features,
1040 BTreeSet::from(["macros".to_string(), "rt-multi-thread".to_string()])
1041 );
1042 assert!(!tokio.optional);
1043
1044 let anyhow = &spec.crates["anyhow"];
1045 assert_eq!(anyhow.version, "1");
1046 assert!(anyhow.features.is_empty());
1047 }
1048
1049 #[test]
1050 fn parse_optional_deps() {
1052 let manifest = r#"
1053 [package]
1054 name = "test-battery-pack"
1055 version = "0.1.0"
1056
1057 [dependencies]
1058 clap = { version = "4", features = ["derive"] }
1059 indicatif = { version = "0.17", optional = true }
1060 "#;
1061
1062 let spec = parse_battery_pack(manifest).unwrap();
1063 assert!(!spec.crates["clap"].optional);
1064 assert!(spec.crates["indicatif"].optional);
1065 }
1066
1067 #[test]
1068 fn parse_cargo_features() {
1070 let manifest = r#"
1071 [package]
1072 name = "test-battery-pack"
1073 version = "0.1.0"
1074
1075 [dependencies]
1076 clap = { version = "4", features = ["derive"] }
1077 dialoguer = "0.11"
1078 indicatif = { version = "0.17", optional = true }
1079 console = { version = "0.15", optional = true }
1080
1081 [features]
1082 default = ["clap", "dialoguer"]
1083 indicators = ["indicatif", "console"]
1084 "#;
1085
1086 let spec = parse_battery_pack(manifest).unwrap();
1087 assert_eq!(spec.features.len(), 2);
1088 assert_eq!(
1089 spec.features["default"],
1090 BTreeSet::from(["clap".to_string(), "dialoguer".to_string()])
1091 );
1092 assert_eq!(
1093 spec.features["indicators"],
1094 BTreeSet::from(["indicatif".to_string(), "console".to_string()])
1095 );
1096 }
1097
1098 #[test]
1099 fn parse_hidden_deps() {
1101 let manifest = r#"
1102 [package]
1103 name = "test-battery-pack"
1104 version = "0.1.0"
1105
1106 [dependencies]
1107 serde = "1"
1108 serde_json = "1"
1109 serde_derive = "1"
1110 clap = "4"
1111
1112 [package.metadata.battery-pack]
1113 hidden = ["serde*"]
1114 "#;
1115
1116 let spec = parse_battery_pack(manifest).unwrap();
1117 assert_eq!(spec.hidden, BTreeSet::from(["serde*".to_string()]));
1118 }
1119
1120 #[test]
1121 fn parse_templates() {
1122 let manifest = r#"
1123 [package]
1124 name = "test-battery-pack"
1125 version = "0.1.0"
1126
1127 [package.metadata.battery.templates]
1128 default = { path = "templates/default", description = "A basic starting point" }
1129 advanced = { path = "templates/advanced", description = "Full-featured setup" }
1130 "#;
1131
1132 let spec = parse_battery_pack(manifest).unwrap();
1133 assert_eq!(spec.templates.len(), 2);
1134 assert_eq!(spec.templates["default"].path, "templates/default");
1135 assert_eq!(
1136 spec.templates["advanced"].description.as_deref(),
1137 Some("Full-featured setup")
1138 );
1139 }
1140
1141 #[test]
1142 fn parse_description_and_repository() {
1143 let manifest = r#"
1144 [package]
1145 name = "test-battery-pack"
1146 version = "0.1.0"
1147 description = "Error handling crates"
1148 repository = "https://github.com/example/repo"
1149 "#;
1150
1151 let spec = parse_battery_pack(manifest).unwrap();
1152 assert_eq!(spec.description, "Error handling crates");
1153 assert_eq!(
1154 spec.repository.as_deref(),
1155 Some("https://github.com/example/repo")
1156 );
1157 }
1158
1159 #[test]
1162 fn validate_name() {
1164 let manifest = r#"
1165 [package]
1166 name = "test-battery-pack"
1167 version = "0.1.0"
1168 "#;
1169 let spec = parse_battery_pack(manifest).unwrap();
1170 assert!(spec.validate().is_ok());
1171
1172 let manifest_bad = r#"
1173 [package]
1174 name = "not-a-battery-pack-crate"
1175 version = "0.1.0"
1176 "#;
1177 let spec_bad = parse_battery_pack(manifest_bad).unwrap();
1178 let err = spec_bad.validate().unwrap_err();
1179 assert!(matches!(err, Error::InvalidName { .. }));
1180 }
1181
1182 #[test]
1183 fn validate_features_reference_real_crates() {
1184 let manifest = r#"
1185 [package]
1186 name = "test-battery-pack"
1187 version = "0.1.0"
1188
1189 [dependencies]
1190 clap = "4"
1191
1192 [features]
1193 default = ["clap", "nonexistent"]
1194 "#;
1195 let spec = parse_battery_pack(manifest).unwrap();
1196 let err = spec.validate().unwrap_err();
1197 assert!(matches!(err, Error::UnknownCrateInFeature { .. }));
1198
1199 let manifest_ok = r#"
1201 [package]
1202 name = "test-battery-pack"
1203 version = "0.1.0"
1204
1205 [dependencies]
1206 clap = "4"
1207 dialoguer = "0.11"
1208
1209 [features]
1210 default = ["clap", "dialoguer"]
1211 "#;
1212 let spec_ok = parse_battery_pack(manifest_ok).unwrap();
1213 assert!(spec_ok.validate().is_ok());
1214 }
1215
1216 #[test]
1219 fn resolve_default_feature() {
1221 let manifest = r#"
1222 [package]
1223 name = "test-battery-pack"
1224 version = "0.1.0"
1225
1226 [dependencies]
1227 clap = { version = "4", features = ["derive"] }
1228 dialoguer = "0.11"
1229 indicatif = { version = "0.17", optional = true }
1230
1231 [features]
1232 default = ["clap", "dialoguer"]
1233 indicators = ["indicatif"]
1234 "#;
1235
1236 let spec = parse_battery_pack(manifest).unwrap();
1237 let resolved = spec.resolve_crates(&[]);
1238
1239 assert_eq!(resolved.len(), 2);
1240 assert!(resolved.contains_key("clap"));
1241 assert!(resolved.contains_key("dialoguer"));
1242 assert!(!resolved.contains_key("indicatif"));
1243 }
1244
1245 #[test]
1246 fn resolve_no_default_feature() {
1248 let manifest = r#"
1249 [package]
1250 name = "test-battery-pack"
1251 version = "0.1.0"
1252
1253 [dependencies]
1254 clap = "4"
1255 dialoguer = "0.11"
1256 indicatif = { version = "0.17", optional = true }
1257 "#;
1258
1259 let spec = parse_battery_pack(manifest).unwrap();
1260 let resolved = spec.resolve_crates(&[]);
1262
1263 assert_eq!(resolved.len(), 2);
1265 assert!(resolved.contains_key("clap"));
1266 assert!(resolved.contains_key("dialoguer"));
1267 assert!(!resolved.contains_key("indicatif"));
1268 }
1269
1270 #[test]
1271 fn resolve_additive_features() {
1273 let manifest = r#"
1274 [package]
1275 name = "test-battery-pack"
1276 version = "0.1.0"
1277
1278 [dependencies]
1279 clap = "4"
1280 dialoguer = "0.11"
1281 indicatif = { version = "0.17", optional = true }
1282 console = { version = "0.15", optional = true }
1283
1284 [features]
1285 default = ["clap", "dialoguer"]
1286 indicators = ["indicatif", "console"]
1287 "#;
1288
1289 let spec = parse_battery_pack(manifest).unwrap();
1290 let resolved = spec.resolve_crates(&["default", "indicators"]);
1291
1292 assert_eq!(resolved.len(), 4);
1293 assert!(resolved.contains_key("clap"));
1294 assert!(resolved.contains_key("dialoguer"));
1295 assert!(resolved.contains_key("indicatif"));
1296 assert!(resolved.contains_key("console"));
1297 }
1298
1299 #[test]
1300 fn resolve_feature_without_default() {
1301 let manifest = r#"
1302 [package]
1303 name = "test-battery-pack"
1304 version = "0.1.0"
1305
1306 [dependencies]
1307 clap = "4"
1308 dialoguer = "0.11"
1309 indicatif = { version = "0.17", optional = true }
1310
1311 [features]
1312 default = ["clap", "dialoguer"]
1313 indicators = ["indicatif"]
1314 "#;
1315
1316 let spec = parse_battery_pack(manifest).unwrap();
1317 let resolved = spec.resolve_crates(&["indicators"]);
1319
1320 assert_eq!(resolved.len(), 1);
1321 assert!(resolved.contains_key("indicatif"));
1322 assert!(!resolved.contains_key("clap"));
1323 }
1324
1325 #[test]
1326 fn resolve_feature_augmentation() {
1328 let manifest = r#"
1329 [package]
1330 name = "test-battery-pack"
1331 version = "0.1.0"
1332
1333 [dependencies]
1334 tokio = { version = "1", features = ["macros", "rt"] }
1335
1336 [features]
1337 default = ["tokio"]
1338 full = ["tokio"]
1339 "#;
1340
1341 let spec = parse_battery_pack(manifest).unwrap();
1342 let resolved = spec.resolve_crates(&["default", "full"]);
1344
1345 assert_eq!(resolved.len(), 1);
1346 let tokio = &resolved["tokio"];
1347 assert!(tokio.features.contains("macros"));
1348 assert!(tokio.features.contains("rt"));
1349 }
1350
1351 #[test]
1352 fn resolve_all() {
1353 let manifest = r#"
1354 [package]
1355 name = "test-battery-pack"
1356 version = "0.1.0"
1357
1358 [dependencies]
1359 clap = "4"
1360 indicatif = { version = "0.17", optional = true }
1361
1362 [dev-dependencies]
1363 insta = "1.34"
1364
1365 [features]
1366 default = ["clap"]
1367 "#;
1368
1369 let spec = parse_battery_pack(manifest).unwrap();
1370 let all = spec.resolve_all();
1371
1372 assert_eq!(all.len(), 3);
1374 assert!(all.contains_key("clap"));
1375 assert!(all.contains_key("indicatif"));
1376 assert!(all.contains_key("insta"));
1377 }
1378
1379 #[test]
1382 fn hidden_exact_match() {
1384 let manifest = r#"
1385 [package]
1386 name = "test-battery-pack"
1387 version = "0.1.0"
1388
1389 [dependencies]
1390 serde = "1"
1391 clap = "4"
1392
1393 [package.metadata.battery-pack]
1394 hidden = ["serde"]
1395 "#;
1396
1397 let spec = parse_battery_pack(manifest).unwrap();
1398 assert!(spec.is_hidden("serde"));
1399 assert!(!spec.is_hidden("clap"));
1400 }
1401
1402 #[test]
1403 fn hidden_glob_pattern() {
1405 let manifest = r#"
1406 [package]
1407 name = "test-battery-pack"
1408 version = "0.1.0"
1409
1410 [dependencies]
1411 serde = "1"
1412 serde_json = "1"
1413 serde_derive = "1"
1414 clap = "4"
1415
1416 [package.metadata.battery-pack]
1417 hidden = ["serde*"]
1418 "#;
1419
1420 let spec = parse_battery_pack(manifest).unwrap();
1421 assert!(spec.is_hidden("serde"));
1422 assert!(spec.is_hidden("serde_json"));
1423 assert!(spec.is_hidden("serde_derive"));
1424 assert!(!spec.is_hidden("clap"));
1425 }
1426
1427 #[test]
1428 fn hidden_wildcard_all() {
1430 let manifest = r#"
1431 [package]
1432 name = "test-battery-pack"
1433 version = "0.1.0"
1434
1435 [dependencies]
1436 serde = "1"
1437 clap = "4"
1438
1439 [package.metadata.battery-pack]
1440 hidden = ["*"]
1441 "#;
1442
1443 let spec = parse_battery_pack(manifest).unwrap();
1444 assert!(spec.is_hidden("serde"));
1445 assert!(spec.is_hidden("clap"));
1446 assert!(spec.is_hidden("anything"));
1447 }
1448
1449 #[test]
1450 fn visible_crates_filters_hidden() {
1451 let manifest = r#"
1452 [package]
1453 name = "test-battery-pack"
1454 version = "0.1.0"
1455
1456 [dependencies]
1457 serde = "1"
1458 serde_json = "1"
1459 clap = "4"
1460 anyhow = "1"
1461
1462 [package.metadata.battery-pack]
1463 hidden = ["serde*"]
1464 "#;
1465
1466 let spec = parse_battery_pack(manifest).unwrap();
1467 let visible = spec.visible_crates();
1468
1469 assert_eq!(visible.len(), 2);
1470 assert!(visible.contains_key("clap"));
1471 assert!(visible.contains_key("anyhow"));
1472 assert!(!visible.contains_key("serde"));
1473 assert!(!visible.contains_key("serde_json"));
1474 }
1475
1476 #[test]
1479 fn all_crates_with_grouping_filters_hidden() {
1480 let manifest = r#"
1481 [package]
1482 name = "test-battery-pack"
1483 version = "0.1.0"
1484
1485 [dependencies]
1486 serde = "1"
1487 serde_json = "1"
1488 clap = "4"
1489 anyhow = "1"
1490
1491 [package.metadata.battery-pack]
1492 hidden = ["serde*"]
1493 "#;
1494
1495 let spec = parse_battery_pack(manifest).unwrap();
1496 let grouped = spec.all_crates_with_grouping();
1497 let names: Vec<&str> = grouped.iter().map(|(_, n, _, _)| n.as_str()).collect();
1498 assert!(names.contains(&"clap"));
1499 assert!(names.contains(&"anyhow"));
1500 assert!(!names.contains(&"serde"), "hidden crate must be excluded");
1501 assert!(
1502 !names.contains(&"serde_json"),
1503 "hidden crate must be excluded"
1504 );
1505 }
1506
1507 #[test]
1510 fn glob_match_basics() {
1511 assert!(glob_match("*", "anything"));
1512 assert!(glob_match("serde*", "serde"));
1513 assert!(glob_match("serde*", "serde_json"));
1514 assert!(glob_match("serde*", "serde_derive"));
1515 assert!(!glob_match("serde*", "clap"));
1516
1517 assert!(glob_match("*-sys", "openssl-sys"));
1518 assert!(!glob_match("*-sys", "openssl"));
1519
1520 assert!(glob_match("?lap", "clap"));
1521 assert!(!glob_match("?lap", "claps"));
1522
1523 assert!(glob_match("exact", "exact"));
1524 assert!(!glob_match("exact", "exacto"));
1525 }
1526
1527 #[test]
1530 fn error_on_invalid_toml() {
1531 let result = parse_battery_pack("not valid toml [[[");
1532 assert!(matches!(result, Err(Error::Toml(_))));
1533 }
1534
1535 #[test]
1536 fn error_on_missing_package() {
1537 let result = parse_battery_pack("[dependencies]\nfoo = \"1\"");
1538 assert!(matches!(result, Err(Error::MissingField(_))));
1539 }
1540
1541 #[test]
1544 fn full_battery_pack_parse() {
1545 let manifest = r#"
1546 [package]
1547 name = "cli-battery-pack"
1548 version = "0.3.0"
1549 description = "CLI essentials for Rust applications"
1550 repository = "https://github.com/battery-pack-rs/battery-pack"
1551 keywords = ["battery-pack"]
1552
1553 [dependencies]
1554 clap = { version = "4", features = ["derive"] }
1555 dialoguer = "0.11"
1556 indicatif = { version = "0.17", optional = true }
1557 console = { version = "0.15", optional = true }
1558
1559 [dev-dependencies]
1560 assert_cmd = "2.0"
1561
1562 [build-dependencies]
1563 cc = "1.0"
1564
1565 [features]
1566 default = ["clap", "dialoguer"]
1567 indicators = ["indicatif", "console"]
1568 fancy = ["clap", "indicatif", "console"]
1569
1570 [package.metadata.battery-pack]
1571 hidden = ["cc"]
1572
1573 [package.metadata.battery.templates]
1574 default = { path = "templates/default", description = "Basic CLI app" }
1575 "#;
1576
1577 let spec = parse_battery_pack(manifest).unwrap();
1578 assert!(spec.validate().is_ok());
1579
1580 assert_eq!(spec.name, "cli-battery-pack");
1582 assert_eq!(spec.version, "0.3.0");
1583 assert_eq!(spec.description, "CLI essentials for Rust applications");
1584
1585 assert_eq!(spec.crates.len(), 6);
1587 assert_eq!(spec.crates["clap"].dep_kind, DepKind::Normal);
1588 assert_eq!(spec.crates["assert_cmd"].dep_kind, DepKind::Dev);
1589 assert_eq!(spec.crates["cc"].dep_kind, DepKind::Build);
1590
1591 assert!(spec.crates["indicatif"].optional);
1593 assert!(!spec.crates["clap"].optional);
1594
1595 assert_eq!(spec.features.len(), 3);
1597
1598 assert!(spec.is_hidden("cc"));
1600 assert!(!spec.is_hidden("clap"));
1601
1602 let visible = spec.visible_crates();
1604 assert_eq!(visible.len(), 5); assert_eq!(spec.templates.len(), 1);
1608
1609 let default = spec.resolve_crates(&[]);
1611 assert_eq!(default.len(), 2);
1612 assert!(default.contains_key("clap"));
1613 assert!(default.contains_key("dialoguer"));
1614
1615 let with_indicators = spec.resolve_crates(&["default", "indicators"]);
1617 assert_eq!(with_indicators.len(), 4);
1618
1619 let only_indicators = spec.resolve_crates(&["indicators"]);
1621 assert_eq!(only_indicators.len(), 2);
1622 assert!(only_indicators.contains_key("indicatif"));
1623 assert!(only_indicators.contains_key("console"));
1624
1625 let all = spec.resolve_all();
1627 assert_eq!(all.len(), 6);
1628 }
1629
1630 #[test]
1633 fn discover_battery_packs_in_fixture_workspace() {
1635 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1637 let workspace_root = manifest_dir
1638 .parent()
1639 .unwrap()
1640 .parent()
1641 .unwrap()
1642 .parent()
1643 .unwrap();
1644 let fixtures_dir = workspace_root.join("tests/fixtures");
1645
1646 let packs = discover_battery_packs(&fixtures_dir).unwrap();
1647
1648 assert_eq!(packs.len(), 4);
1649
1650 let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
1651 assert!(names.contains(&"basic-battery-pack"));
1652 assert!(names.contains(&"fancy-battery-pack"));
1653 assert!(names.contains(&"broken-battery-pack"));
1654 assert!(names.contains(&"managed-battery-pack"));
1655
1656 let basic = packs
1658 .iter()
1659 .find(|p| p.name == "basic-battery-pack")
1660 .unwrap();
1661 assert_eq!(basic.version, "0.1.0");
1662 assert_eq!(basic.crates.len(), 3); assert!(basic.crates["eyre"].optional);
1664 assert!(basic.crates["anyhow"].optional);
1665
1666 let fancy = packs
1668 .iter()
1669 .find(|p| p.name == "fancy-battery-pack")
1670 .unwrap();
1671 assert_eq!(fancy.version, "0.2.0");
1672 assert!(fancy.is_hidden("serde"));
1673 assert!(fancy.is_hidden("serde_json"));
1674 assert!(fancy.is_hidden("cc"));
1675 assert!(!fancy.is_hidden("clap"));
1676 assert_eq!(fancy.templates.len(), 2);
1677
1678 let default = fancy.resolve_crates(&[]);
1680 assert_eq!(default.len(), 2);
1681 assert!(default.contains_key("clap"));
1682 assert!(default.contains_key("dialoguer"));
1683
1684 let visible = fancy.visible_crates();
1686 assert!(!visible.contains_key("serde"));
1687 assert!(!visible.contains_key("serde_json"));
1688 assert!(!visible.contains_key("cc"));
1689 assert!(visible.contains_key("clap"));
1690
1691 let managed = packs
1693 .iter()
1694 .find(|p| p.name == "managed-battery-pack")
1695 .unwrap();
1696 assert_eq!(managed.version, "0.2.0");
1697 assert_eq!(managed.crates.len(), 2); assert!(managed.crates["anyhow"].optional);
1699 assert!(managed.crates["clap"].optional);
1700 assert_eq!(managed.templates.len(), 1);
1701 let default = managed.resolve_crates(&[]);
1702 assert_eq!(default.len(), 2);
1703 assert!(default.contains_key("anyhow"));
1704 assert!(default.contains_key("clap"));
1705 }
1706
1707 #[test]
1708 fn discover_from_crate_root_finds_workspace() {
1710 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1711 let workspace_root = manifest_dir
1712 .parent()
1713 .unwrap()
1714 .parent()
1715 .unwrap()
1716 .parent()
1717 .unwrap();
1718 let member = workspace_root.join("tests/fixtures/basic-battery-pack");
1719
1720 let packs = discover_from_crate_root(&member).unwrap();
1721 assert_eq!(packs.len(), 4);
1722 let names: Vec<&str> = packs.iter().map(|p| p.name.as_str()).collect();
1723 assert!(names.contains(&"basic-battery-pack"));
1724 assert!(names.contains(&"fancy-battery-pack"));
1725 }
1726
1727 #[test]
1728 fn discover_from_crate_root_standalone() {
1730 let tmp = tempfile::tempdir().unwrap();
1731 std::fs::write(
1732 tmp.path().join("Cargo.toml"),
1733 r#"
1734[package]
1735name = "solo-battery-pack"
1736version = "1.0.0"
1737
1738[features]
1739default = ["dep:tokio"]
1740
1741[dependencies]
1742tokio = { version = "1", optional = true }
1743"#,
1744 )
1745 .unwrap();
1746
1747 let packs = discover_from_crate_root(tmp.path()).unwrap();
1748 assert_eq!(packs.len(), 1);
1749 assert_eq!(packs[0].name, "solo-battery-pack");
1750 assert_eq!(packs[0].version, "1.0.0");
1751 }
1752
1753 #[test]
1756 fn validate_spec_name() {
1758 let good = parse_battery_pack(
1759 r#"
1760 [package]
1761 name = "test-battery-pack"
1762 version = "0.1.0"
1763 repository = "https://github.com/example/test"
1764 keywords = ["battery-pack"]
1765 "#,
1766 )
1767 .unwrap();
1768 assert!(good.validate_spec().is_clean());
1769
1770 let exact = parse_battery_pack(
1771 r#"
1772 [package]
1773 name = "battery-pack"
1774 version = "0.1.0"
1775 repository = "https://github.com/example/test"
1776 keywords = ["battery-pack"]
1777 "#,
1778 )
1779 .unwrap();
1780 assert!(exact.validate_spec().is_clean());
1781
1782 let bad = parse_battery_pack(
1783 r#"
1784 [package]
1785 name = "not-a-pack"
1786 version = "0.1.0"
1787 keywords = ["battery-pack"]
1788 "#,
1789 )
1790 .unwrap();
1791 let report = bad.validate_spec();
1792 assert!(report.has_errors());
1793 assert!(
1794 report
1795 .diagnostics
1796 .iter()
1797 .any(|d| d.rule == "format.crate.name")
1798 );
1799 }
1800
1801 #[test]
1802 fn validate_spec_keyword() {
1804 let good = parse_battery_pack(
1805 r#"
1806 [package]
1807 name = "test-battery-pack"
1808 version = "0.1.0"
1809 repository = "https://github.com/example/test"
1810 keywords = ["battery-pack", "helpers"]
1811 "#,
1812 )
1813 .unwrap();
1814 assert!(good.validate_spec().is_clean());
1815
1816 let missing = parse_battery_pack(
1817 r#"
1818 [package]
1819 name = "test-battery-pack"
1820 version = "0.1.0"
1821 "#,
1822 )
1823 .unwrap();
1824 let report = missing.validate_spec();
1825 assert!(report.has_errors());
1826 assert!(
1827 report
1828 .diagnostics
1829 .iter()
1830 .any(|d| d.rule == "format.crate.keyword")
1831 );
1832
1833 let wrong = parse_battery_pack(
1834 r#"
1835 [package]
1836 name = "test-battery-pack"
1837 version = "0.1.0"
1838 keywords = ["cli", "helpers"]
1839 "#,
1840 )
1841 .unwrap();
1842 let report = wrong.validate_spec();
1843 assert!(report.has_errors());
1844 assert!(
1845 report
1846 .diagnostics
1847 .iter()
1848 .any(|d| d.rule == "format.crate.keyword")
1849 );
1850 }
1851
1852 #[test]
1853 fn validate_spec_features() {
1855 let good = parse_battery_pack(
1856 r#"
1857 [package]
1858 name = "test-battery-pack"
1859 version = "0.1.0"
1860 repository = "https://github.com/example/test"
1861 keywords = ["battery-pack"]
1862
1863 [dependencies]
1864 clap = "4"
1865
1866 [features]
1867 default = ["clap"]
1868 "#,
1869 )
1870 .unwrap();
1871 assert!(good.validate_spec().is_clean());
1872
1873 let bad = parse_battery_pack(
1874 r#"
1875 [package]
1876 name = "test-battery-pack"
1877 version = "0.1.0"
1878 keywords = ["battery-pack"]
1879
1880 [dependencies]
1881 clap = "4"
1882
1883 [features]
1884 default = ["clap", "ghost"]
1885 "#,
1886 )
1887 .unwrap();
1888 let report = bad.validate_spec();
1889 assert!(report.has_errors());
1890 assert!(
1891 report
1892 .diagnostics
1893 .iter()
1894 .any(|d| d.rule == "format.features.grouping" && d.message.contains("ghost"))
1895 );
1896 }
1897
1898 #[test]
1901 fn validate_lib_rs_clean() {
1903 let dir = tempfile::tempdir().unwrap();
1904 let src = dir.path().join("src");
1905 std::fs::create_dir(&src).unwrap();
1906 std::fs::write(
1907 src.join("lib.rs"),
1908 "//! Doc comment\n\n// Regular comment\n",
1909 )
1910 .unwrap();
1911
1912 let spec = parse_battery_pack(
1913 r#"
1914 [package]
1915 name = "test-battery-pack"
1916 version = "0.1.0"
1917 keywords = ["battery-pack"]
1918 "#,
1919 )
1920 .unwrap();
1921
1922 let report = validate_on_disk(&spec, dir.path());
1923 assert!(report.is_clean());
1924 }
1925
1926 #[test]
1927 fn validate_lib_rs_with_code() {
1929 let dir = tempfile::tempdir().unwrap();
1930 let src = dir.path().join("src");
1931 std::fs::create_dir(&src).unwrap();
1932 std::fs::write(src.join("lib.rs"), "//! Doc comment\npub fn hello() {}\n").unwrap();
1933
1934 let spec = parse_battery_pack(
1935 r#"
1936 [package]
1937 name = "test-battery-pack"
1938 version = "0.1.0"
1939 keywords = ["battery-pack"]
1940 "#,
1941 )
1942 .unwrap();
1943
1944 let report = validate_on_disk(&spec, dir.path());
1945 assert!(!report.is_clean());
1946 assert!(!report.has_errors()); assert!(
1948 report
1949 .diagnostics
1950 .iter()
1951 .any(|d| d.rule == "format.crate.lib" && d.severity == Severity::Warning)
1952 );
1953 }
1954
1955 #[test]
1956 fn validate_no_extra_rs_files() {
1958 let dir = tempfile::tempdir().unwrap();
1959 let src = dir.path().join("src");
1960 std::fs::create_dir(&src).unwrap();
1961 std::fs::write(src.join("lib.rs"), "//! Doc\n").unwrap();
1962
1963 let spec = parse_battery_pack(
1964 r#"
1965 [package]
1966 name = "test-battery-pack"
1967 version = "0.1.0"
1968 keywords = ["battery-pack"]
1969 "#,
1970 )
1971 .unwrap();
1972
1973 let report = validate_on_disk(&spec, dir.path());
1975 assert!(report.is_clean());
1976
1977 std::fs::write(src.join("helper.rs"), "pub fn help() {}\n").unwrap();
1979 let report = validate_on_disk(&spec, dir.path());
1980 assert!(report.has_errors());
1981 assert!(
1982 report
1983 .diagnostics
1984 .iter()
1985 .any(|d| d.rule == "format.crate.no-code" && d.message.contains("helper.rs"))
1986 );
1987 }
1988
1989 #[test]
1990 fn validate_templates_exist() {
1992 let dir = tempfile::tempdir().unwrap();
1993 let src = dir.path().join("src");
1994 std::fs::create_dir(&src).unwrap();
1995 std::fs::write(src.join("lib.rs"), "//! Doc\n").unwrap();
1996
1997 let spec = parse_battery_pack(
1998 r#"
1999 [package]
2000 name = "test-battery-pack"
2001 version = "0.1.0"
2002 keywords = ["battery-pack"]
2003
2004 [package.metadata.battery.templates]
2005 default = { path = "templates/default", description = "Basic" }
2006 "#,
2007 )
2008 .unwrap();
2009
2010 let report = validate_on_disk(&spec, dir.path());
2012 assert!(report.has_errors());
2013 assert!(
2014 report
2015 .diagnostics
2016 .iter()
2017 .any(|d| d.rule == "format.templates.directory")
2018 );
2019
2020 let tmpl = dir.path().join("templates/default");
2022 std::fs::create_dir_all(&tmpl).unwrap();
2023 let report = validate_on_disk(&spec, dir.path());
2024 let template_errors: Vec<_> = report
2025 .diagnostics
2026 .iter()
2027 .filter(|d| d.rule.starts_with("format.templates."))
2028 .collect();
2029 assert!(template_errors.is_empty());
2030 }
2031
2032 #[test]
2035 fn validate_warns_on_missing_repository() {
2037 let spec = parse_battery_pack(
2038 r#"
2039 [package]
2040 name = "test-battery-pack"
2041 version = "0.1.0"
2042 keywords = ["battery-pack"]
2043 "#,
2044 )
2045 .unwrap();
2046 let report = spec.validate_spec();
2047 assert!(
2048 !report.has_errors(),
2049 "missing repository should not be an error"
2050 );
2051 assert!(
2052 report
2053 .diagnostics
2054 .iter()
2055 .any(|d| d.rule == "format.crate.repository" && d.severity == Severity::Warning),
2056 "should warn when repository is missing"
2057 );
2058 }
2059
2060 #[test]
2061 fn validate_no_warning_when_repository_present() {
2063 let spec = parse_battery_pack(
2064 r#"
2065 [package]
2066 name = "test-battery-pack"
2067 version = "0.1.0"
2068 repository = "https://github.com/example/repo"
2069 keywords = ["battery-pack"]
2070 "#,
2071 )
2072 .unwrap();
2073 let report = spec.validate_spec();
2074 assert!(
2075 !report
2076 .diagnostics
2077 .iter()
2078 .any(|d| d.rule == "format.crate.repository"),
2079 "should not warn when repository is present"
2080 );
2081 }
2082
2083 #[test]
2086 fn validate_fixture_basic_battery_pack() {
2087 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2088 let workspace_root = manifest_dir
2089 .parent()
2090 .unwrap()
2091 .parent()
2092 .unwrap()
2093 .parent()
2094 .unwrap();
2095 let fixture = workspace_root.join("tests/fixtures/basic-battery-pack");
2096
2097 let content = std::fs::read_to_string(fixture.join("Cargo.toml")).unwrap();
2098 let spec = parse_battery_pack(&content).unwrap();
2099
2100 let mut report = spec.validate_spec();
2101 report.merge(validate_on_disk(&spec, &fixture));
2102 assert!(
2104 !report.has_errors(),
2105 "basic-battery-pack should have no errors: {:?}",
2106 report.diagnostics
2107 );
2108 assert!(
2109 report
2110 .diagnostics
2111 .iter()
2112 .any(|d| d.rule == "format.crate.repository" && d.severity == Severity::Warning),
2113 "basic-battery-pack should warn about missing repository"
2114 );
2115 }
2116
2117 #[test]
2118 fn validate_fixture_fancy_battery_pack() {
2119 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2120 let workspace_root = manifest_dir
2121 .parent()
2122 .unwrap()
2123 .parent()
2124 .unwrap()
2125 .parent()
2126 .unwrap();
2127 let fixture = workspace_root.join("tests/fixtures/fancy-battery-pack");
2128
2129 let content = std::fs::read_to_string(fixture.join("Cargo.toml")).unwrap();
2130 let spec = parse_battery_pack(&content).unwrap();
2131
2132 let mut report = spec.validate_spec();
2133 report.merge(validate_on_disk(&spec, &fixture));
2134 assert!(
2135 report.is_clean(),
2136 "fancy-battery-pack should be clean: {:?}",
2137 report.diagnostics
2138 );
2139 }
2140
2141 #[test]
2142 fn validate_fixture_broken_battery_pack() {
2143 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2144 let workspace_root = manifest_dir
2145 .parent()
2146 .unwrap()
2147 .parent()
2148 .unwrap()
2149 .parent()
2150 .unwrap();
2151 let fixture = workspace_root.join("tests/fixtures/broken-battery-pack");
2152
2153 let content = std::fs::read_to_string(fixture.join("Cargo.toml")).unwrap();
2154 let spec = parse_battery_pack(&content).unwrap();
2155
2156 let mut report = spec.validate_spec();
2157 report.merge(validate_on_disk(&spec, &fixture));
2158
2159 assert!(report.has_errors());
2160
2161 let rules: Vec<&str> = report.diagnostics.iter().map(|d| d.rule).collect();
2162 assert!(
2163 rules.contains(&"format.crate.keyword"),
2164 "missing keyword error"
2165 );
2166 assert!(
2169 rules.contains(&"format.crate.no-code"),
2170 "missing no-code error"
2171 );
2172 assert!(
2173 rules.contains(&"format.templates.directory"),
2174 "missing template dir error"
2175 );
2176
2177 assert!(
2179 report
2180 .diagnostics
2181 .iter()
2182 .any(|d| d.rule == "format.crate.lib" && d.severity == Severity::Warning)
2183 );
2184 }
2185
2186 #[test]
2187 fn validate_fixture_managed_battery_pack() {
2188 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
2189 let workspace_root = manifest_dir
2190 .parent()
2191 .unwrap()
2192 .parent()
2193 .unwrap()
2194 .parent()
2195 .unwrap();
2196 let fixture = workspace_root.join("tests/fixtures/managed-battery-pack");
2197
2198 let content = std::fs::read_to_string(fixture.join("Cargo.toml")).unwrap();
2199 let spec = parse_battery_pack(&content).unwrap();
2200
2201 let mut report = spec.validate_spec();
2202 report.merge(validate_on_disk(&spec, &fixture));
2203 assert!(
2204 report.is_clean(),
2205 "managed-battery-pack should be clean: {:?}",
2206 report.diagnostics
2207 );
2208 }
2209
2210 fn crate_spec(version: &str, features: &[&str], dep_kind: DepKind) -> CrateSpec {
2214 CrateSpec {
2215 version: version.to_string(),
2216 features: features
2217 .iter()
2218 .map(|s| s.to_string())
2219 .collect::<BTreeSet<_>>(),
2220 dep_kind,
2221 optional: false,
2222 }
2223 }
2224
2225 #[test]
2226 fn merge_version_newest_wins() {
2228 let pack_a = BTreeMap::from([(
2229 "serde".to_string(),
2230 crate_spec("1.0.100", &["derive"], DepKind::Normal),
2231 )]);
2232 let pack_b = BTreeMap::from([(
2233 "serde".to_string(),
2234 crate_spec("1.0.210", &["derive"], DepKind::Normal),
2235 )]);
2236
2237 let merged = merge_crate_specs(&[pack_a, pack_b]);
2238 assert_eq!(merged["serde"].version, "1.0.210");
2239 }
2240
2241 #[test]
2242 fn merge_version_across_major() {
2244 let pack_a = BTreeMap::from([(
2245 "clap".to_string(),
2246 crate_spec("3.4.0", &[], DepKind::Normal),
2247 )]);
2248 let pack_b = BTreeMap::from([(
2249 "clap".to_string(),
2250 crate_spec("4.5.0", &[], DepKind::Normal),
2251 )]);
2252
2253 let merged = merge_crate_specs(&[pack_a, pack_b]);
2254 assert_eq!(merged["clap"].version, "4.5.0");
2255 }
2256
2257 #[test]
2258 fn merge_version_same_version_no_conflict() {
2260 let pack_a = BTreeMap::from([(
2261 "anyhow".to_string(),
2262 crate_spec("1.0.80", &[], DepKind::Normal),
2263 )]);
2264 let pack_b = BTreeMap::from([(
2265 "anyhow".to_string(),
2266 crate_spec("1.0.80", &[], DepKind::Normal),
2267 )]);
2268
2269 let merged = merge_crate_specs(&[pack_a, pack_b]);
2270 assert_eq!(merged["anyhow"].version, "1.0.80");
2271 }
2272
2273 #[test]
2274 fn merge_features_union() {
2276 let pack_a = BTreeMap::from([(
2277 "tokio".to_string(),
2278 crate_spec("1", &["macros", "rt"], DepKind::Normal),
2279 )]);
2280 let pack_b = BTreeMap::from([(
2281 "tokio".to_string(),
2282 crate_spec("1", &["rt", "net", "io-util"], DepKind::Normal),
2283 )]);
2284
2285 let merged = merge_crate_specs(&[pack_a, pack_b]);
2286 let features = &merged["tokio"].features;
2287 assert!(features.contains(&"macros".to_string()));
2288 assert!(features.contains(&"rt".to_string()));
2289 assert!(features.contains(&"net".to_string()));
2290 assert!(features.contains(&"io-util".to_string()));
2291 assert_eq!(features.iter().filter(|f| f.as_str() == "rt").count(), 1);
2293 }
2294
2295 #[test]
2296 fn merge_dep_kind_normal_wins_over_dev() {
2298 let pack_a = BTreeMap::from([("serde".to_string(), crate_spec("1", &[], DepKind::Normal))]);
2299 let pack_b = BTreeMap::from([("serde".to_string(), crate_spec("1", &[], DepKind::Dev))]);
2300
2301 let merged = merge_crate_specs(&[pack_a, pack_b]);
2302 assert_eq!(merged["serde"].dep_kinds, vec![DepKind::Normal]);
2303 }
2304
2305 #[test]
2306 fn merge_dep_kind_normal_wins_over_build() {
2308 let pack_a = BTreeMap::from([("cc".to_string(), crate_spec("1", &[], DepKind::Build))]);
2309 let pack_b = BTreeMap::from([("cc".to_string(), crate_spec("1", &[], DepKind::Normal))]);
2310
2311 let merged = merge_crate_specs(&[pack_a, pack_b]);
2312 assert_eq!(merged["cc"].dep_kinds, vec![DepKind::Normal]);
2313 }
2314
2315 #[test]
2316 fn merge_dep_kind_dev_and_build_yields_both() {
2318 let pack_a = BTreeMap::from([("serde".to_string(), crate_spec("1", &[], DepKind::Dev))]);
2319 let pack_b = BTreeMap::from([("serde".to_string(), crate_spec("1", &[], DepKind::Build))]);
2320
2321 let merged = merge_crate_specs(&[pack_a, pack_b]);
2322 let kinds = &merged["serde"].dep_kinds;
2323 assert_eq!(kinds.len(), 2);
2324 assert!(kinds.contains(&DepKind::Dev));
2325 assert!(kinds.contains(&DepKind::Build));
2326 }
2327
2328 #[test]
2329 fn merge_three_packs_all_rules() {
2333 let pack_a = BTreeMap::from([
2334 (
2335 "tokio".to_string(),
2336 crate_spec("1.35.0", &["macros"], DepKind::Normal),
2337 ),
2338 (
2339 "serde".to_string(),
2340 crate_spec("1.0.100", &["derive"], DepKind::Dev),
2341 ),
2342 ]);
2343 let pack_b = BTreeMap::from([
2344 (
2345 "tokio".to_string(),
2346 crate_spec("1.38.0", &["rt"], DepKind::Dev),
2347 ),
2348 (
2349 "serde".to_string(),
2350 crate_spec("1.0.210", &["alloc"], DepKind::Build),
2351 ),
2352 ]);
2353 let pack_c = BTreeMap::from([
2354 (
2355 "tokio".to_string(),
2356 crate_spec("1.36.0", &["net", "macros"], DepKind::Normal),
2357 ),
2358 (
2359 "anyhow".to_string(),
2360 crate_spec("1.0.80", &[], DepKind::Normal),
2361 ),
2362 ]);
2363
2364 let merged = merge_crate_specs(&[pack_a, pack_b, pack_c]);
2365
2366 let tokio = &merged["tokio"];
2368 assert_eq!(tokio.version, "1.38.0");
2369 assert!(tokio.features.contains("macros"));
2370 assert!(tokio.features.contains("rt"));
2371 assert!(tokio.features.contains("net"));
2372 assert_eq!(tokio.dep_kinds, vec![DepKind::Normal]);
2373
2374 let serde = &merged["serde"];
2376 assert_eq!(serde.version, "1.0.210");
2377 assert!(serde.features.contains("derive"));
2378 assert!(serde.features.contains("alloc"));
2379 assert_eq!(serde.dep_kinds.len(), 2);
2380 assert!(serde.dep_kinds.contains(&DepKind::Dev));
2381 assert!(serde.dep_kinds.contains(&DepKind::Build));
2382
2383 let anyhow = &merged["anyhow"];
2385 assert_eq!(anyhow.version, "1.0.80");
2386 assert_eq!(anyhow.dep_kinds, vec![DepKind::Normal]);
2387 }
2388
2389 #[test]
2390 fn merge_non_overlapping_crates() {
2393 let pack_a = BTreeMap::from([(
2394 "serde".to_string(),
2395 crate_spec("1.0.210", &["derive"], DepKind::Normal),
2396 )]);
2397 let pack_b = BTreeMap::from([(
2398 "clap".to_string(),
2399 crate_spec("4.5.0", &["derive"], DepKind::Normal),
2400 )]);
2401
2402 let merged = merge_crate_specs(&[pack_a, pack_b]);
2403 assert_eq!(merged.len(), 2);
2404 assert_eq!(merged["serde"].version, "1.0.210");
2405 assert_eq!(merged["clap"].version, "4.5.0");
2406 }
2407
2408 #[test]
2409 fn merge_empty_input() {
2410 let merged = merge_crate_specs(&[]);
2411 assert!(merged.is_empty());
2412 }
2413
2414 #[test]
2415 fn merge_single_pack() {
2416 let pack = BTreeMap::from([
2417 (
2418 "serde".to_string(),
2419 crate_spec("1", &["derive"], DepKind::Normal),
2420 ),
2421 ("clap".to_string(), crate_spec("4", &[], DepKind::Normal)),
2422 ]);
2423
2424 let merged = merge_crate_specs(&[pack]);
2425 assert_eq!(merged.len(), 2);
2426 assert_eq!(merged["serde"].version, "1");
2427 assert_eq!(
2428 merged["serde"].features,
2429 BTreeSet::from(["derive".to_string()])
2430 );
2431 assert_eq!(merged["serde"].dep_kinds, vec![DepKind::Normal]);
2432 }
2433
2434 #[test]
2437 fn compare_versions_basic() {
2438 use std::cmp::Ordering;
2439 assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal);
2440 assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater);
2441 assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less);
2442 assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
2443 assert_eq!(compare_versions("1", "1.0"), Ordering::Equal);
2444 assert_eq!(compare_versions("1", "2"), Ordering::Less);
2445 assert_eq!(compare_versions("1.0.210", "1.0.100"), Ordering::Greater);
2446 }
2447}