1use serde::Deserialize;
8use std::collections::BTreeMap;
9
10#[derive(Debug, Clone)]
16pub struct BatteryPackSpec {
17 pub name: String,
18 pub version: String,
19 pub dev_dependencies: BTreeMap<String, DepSpec>,
20 pub default_crates: Vec<String>,
21 pub sets: BTreeMap<String, SetSpec>,
22}
23
24#[derive(Debug, Clone)]
26pub struct DepSpec {
27 pub version: String,
28 pub features: Vec<String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct SetSpec {
34 pub crates: BTreeMap<String, SetCrateSpec>,
35}
36
37#[derive(Debug, Clone)]
39pub struct SetCrateSpec {
40 pub features: Vec<String>,
41}
42
43impl BatteryPackSpec {
44 pub fn resolve_crates(&self, active_sets: &[String]) -> BTreeMap<String, DepSpec> {
49 let mut result: BTreeMap<String, DepSpec> = BTreeMap::new();
50
51 for crate_name in &self.default_crates {
53 if let Some(dep) = self.dev_dependencies.get(crate_name) {
54 result.insert(crate_name.clone(), dep.clone());
55 }
56 }
57
58 for set_name in active_sets {
60 if set_name == "default" {
61 continue;
62 }
63 if let Some(set) = self.sets.get(set_name) {
64 for (crate_name, set_crate) in &set.crates {
65 if let Some(existing) = result.get_mut(crate_name) {
66 for feat in &set_crate.features {
68 if !existing.features.contains(feat) {
69 existing.features.push(feat.clone());
70 }
71 }
72 } else if let Some(dep) = self.dev_dependencies.get(crate_name) {
73 let mut features = dep.features.clone();
75 for feat in &set_crate.features {
76 if !features.contains(feat) {
77 features.push(feat.clone());
78 }
79 }
80 result.insert(
81 crate_name.clone(),
82 DepSpec {
83 version: dep.version.clone(),
84 features,
85 },
86 );
87 }
88 }
89 }
90 }
91
92 result
93 }
94
95 pub fn resolve_all(&self) -> BTreeMap<String, DepSpec> {
97 let mut result: BTreeMap<String, DepSpec> = BTreeMap::new();
98
99 for (name, dep) in &self.dev_dependencies {
100 result.insert(name.clone(), dep.clone());
101 }
102
103 for set in self.sets.values() {
105 for (crate_name, set_crate) in &set.crates {
106 if let Some(existing) = result.get_mut(crate_name) {
107 for feat in &set_crate.features {
108 if !existing.features.contains(feat) {
109 existing.features.push(feat.clone());
110 }
111 }
112 }
113 }
114 }
115
116 result
117 }
118}
119
120#[derive(Debug)]
126pub struct UserManifest {
127 pub dependencies: BTreeMap<String, DepSpec>,
128 pub battery_pack_sets: BTreeMap<String, Vec<String>>,
130}
131
132#[derive(Deserialize)]
137struct RawManifest {
138 package: Option<RawPackage>,
139 #[serde(default, rename = "dev-dependencies")]
140 dev_dependencies: BTreeMap<String, toml::Value>,
141 #[serde(default)]
142 dependencies: BTreeMap<String, toml::Value>,
143}
144
145#[derive(Deserialize)]
146struct RawPackage {
147 name: Option<String>,
148 version: Option<String>,
149 #[serde(default)]
150 metadata: Option<RawMetadata>,
151}
152
153#[derive(Deserialize)]
154struct RawMetadata {
155 #[serde(default, rename = "battery-pack")]
156 battery_pack: Option<RawBatteryPackMetadata>,
157}
158
159#[derive(Deserialize)]
160struct RawBatteryPackMetadata {
161 default: Option<Vec<String>>,
162 #[serde(default)]
163 sets: BTreeMap<String, BTreeMap<String, toml::Value>>,
164}
165
166pub fn parse_battery_pack(manifest_str: &str) -> Result<BatteryPackSpec, String> {
172 let raw: RawManifest =
173 toml::from_str(manifest_str).map_err(|e| format!("TOML parse error: {e}"))?;
174
175 let package = raw.package.ok_or("missing [package] section")?;
176 let name = package.name.ok_or("missing package.name")?;
177 let version = package.version.ok_or("missing package.version")?;
178
179 let dev_dependencies = parse_dep_map(&raw.dev_dependencies);
181
182 let bp_meta = package.metadata.and_then(|m| m.battery_pack);
184
185 let default_crates = match bp_meta.as_ref().and_then(|m| m.default.as_ref()) {
187 Some(explicit) => explicit.clone(),
188 None => dev_dependencies.keys().cloned().collect(),
189 };
190
191 let sets = match bp_meta.as_ref() {
193 Some(meta) => parse_sets(&meta.sets),
194 None => BTreeMap::new(),
195 };
196
197 Ok(BatteryPackSpec {
198 name,
199 version,
200 dev_dependencies,
201 default_crates,
202 sets,
203 })
204}
205
206pub fn parse_user_manifest(manifest_str: &str) -> Result<UserManifest, String> {
208 let raw: RawManifest =
209 toml::from_str(manifest_str).map_err(|e| format!("TOML parse error: {e}"))?;
210
211 let dependencies = parse_dep_map(&raw.dependencies);
212
213 let battery_pack_sets = parse_user_bp_sets(manifest_str);
215
216 Ok(UserManifest {
217 dependencies,
218 battery_pack_sets,
219 })
220}
221
222fn parse_user_bp_sets(manifest_str: &str) -> BTreeMap<String, Vec<String>> {
223 let raw: toml::Value = match toml::from_str(manifest_str) {
225 Ok(v) => v,
226 Err(_) => return BTreeMap::new(),
227 };
228
229 let bp_table = raw
230 .get("package")
231 .and_then(|p| p.get("metadata"))
232 .and_then(|m| m.get("battery-pack"))
233 .and_then(|bp| bp.as_table());
234
235 let Some(bp_table) = bp_table else {
236 return BTreeMap::new();
237 };
238
239 let mut result = BTreeMap::new();
240 for (bp_name, entry) in bp_table {
241 if let Some(sets) = entry.get("sets").and_then(|s| s.as_array()) {
242 let set_names: Vec<String> = sets
243 .iter()
244 .filter_map(|v| v.as_str().map(String::from))
245 .collect();
246 result.insert(bp_name.clone(), set_names);
247 }
248 }
249
250 result
251}
252
253fn parse_dep_map(raw: &BTreeMap<String, toml::Value>) -> BTreeMap<String, DepSpec> {
254 let mut deps = BTreeMap::new();
255
256 for (name, value) in raw {
257 let dep = parse_single_dep(value);
258 deps.insert(name.clone(), dep);
259 }
260
261 deps
262}
263
264fn parse_single_dep(value: &toml::Value) -> DepSpec {
265 match value {
266 toml::Value::String(version) => DepSpec {
267 version: version.clone(),
268 features: Vec::new(),
269 },
270 toml::Value::Table(table) => {
271 let version = table
272 .get("version")
273 .and_then(|v| v.as_str())
274 .unwrap_or("")
275 .to_string();
276 let features = table
277 .get("features")
278 .and_then(|v| v.as_array())
279 .map(|arr| {
280 arr.iter()
281 .filter_map(|v| v.as_str().map(String::from))
282 .collect()
283 })
284 .unwrap_or_default();
285 DepSpec { version, features }
286 }
287 _ => DepSpec {
288 version: String::new(),
289 features: Vec::new(),
290 },
291 }
292}
293
294fn parse_sets(raw: &BTreeMap<String, BTreeMap<String, toml::Value>>) -> BTreeMap<String, SetSpec> {
295 let mut sets = BTreeMap::new();
296
297 for (set_name, crates_map) in raw {
298 let mut crates = BTreeMap::new();
299 for (crate_name, value) in crates_map {
300 let features = match value {
301 toml::Value::Table(table) => table
302 .get("features")
303 .and_then(|v| v.as_array())
304 .map(|arr| {
305 arr.iter()
306 .filter_map(|v| v.as_str().map(String::from))
307 .collect()
308 })
309 .unwrap_or_default(),
310 _ => Vec::new(),
311 };
312 crates.insert(crate_name.clone(), SetCrateSpec { features });
313 }
314 sets.insert(set_name.clone(), SetSpec { crates });
315 }
316
317 sets
318}
319
320pub fn check_drift(bp: &BatteryPackSpec, user: &UserManifest) {
327 let active_sets = user
329 .battery_pack_sets
330 .get(&bp.name)
331 .cloned()
332 .unwrap_or_else(|| vec!["default".to_string()]);
333
334 let expected = bp.resolve_crates(&active_sets);
335
336 let mut has_drift = false;
337
338 for (crate_name, expected_dep) in &expected {
339 match user.dependencies.get(crate_name) {
340 None => {
341 println!(
342 "cargo:warning=battery-pack({}): missing dependency '{}' (expected {})",
343 bp.name, crate_name, expected_dep.version
344 );
345 has_drift = true;
346 }
347 Some(user_dep) => {
348 if !expected_dep.version.is_empty()
350 && !user_dep.version.is_empty()
351 && user_dep.version != expected_dep.version
352 {
353 println!(
354 "cargo:warning=battery-pack({}): '{}' version is '{}', battery pack recommends '{}'",
355 bp.name, crate_name, user_dep.version, expected_dep.version
356 );
357 has_drift = true;
358 }
359
360 for feat in &expected_dep.features {
362 if !user_dep.features.contains(feat) {
363 println!(
364 "cargo:warning=battery-pack({}): '{}' is missing feature '{}'",
365 bp.name, crate_name, feat
366 );
367 has_drift = true;
368 }
369 }
370 }
371 }
372 }
373
374 if has_drift {
375 println!(
376 "cargo:warning=battery-pack({}): run `cargo bp sync` to update dependencies",
377 bp.name
378 );
379 }
380}
381
382pub fn assert_no_regular_deps(manifest: &str) {
396 let raw: RawManifest = toml::from_str(manifest).expect("failed to parse Cargo.toml");
397 let non_bp_deps: Vec<_> = raw
398 .dependencies
399 .keys()
400 .filter(|k| *k != "battery-pack")
401 .collect();
402 assert!(
403 non_bp_deps.is_empty(),
404 "Battery packs must only use [dev-dependencies] (plus battery-pack). Found: {:?}",
405 non_bp_deps
406 );
407}
408
409#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn test_parse_minimal_battery_pack() {
419 let manifest = r#"
420 [package]
421 name = "cli-battery-pack"
422 version = "0.3.0"
423
424 [dev-dependencies]
425 clap = { version = "4", features = ["derive"] }
426 dialoguer = "0.11"
427 "#;
428
429 let spec = parse_battery_pack(manifest).unwrap();
430 assert_eq!(spec.name, "cli-battery-pack");
431 assert_eq!(spec.version, "0.3.0");
432 assert_eq!(spec.dev_dependencies.len(), 2);
433
434 assert_eq!(spec.default_crates.len(), 2);
436 assert!(spec.default_crates.contains(&"clap".to_string()));
437 assert!(spec.default_crates.contains(&"dialoguer".to_string()));
438 assert!(spec.sets.is_empty());
439 }
440
441 #[test]
442 fn test_parse_with_explicit_default_and_sets() {
443 let manifest = r#"
444 [package]
445 name = "cli-battery-pack"
446 version = "0.3.0"
447
448 [dev-dependencies]
449 clap = { version = "4", features = ["derive"] }
450 dialoguer = "0.11"
451 indicatif = "0.17"
452 console = "0.15"
453
454 [package.metadata.battery-pack]
455 default = ["clap", "dialoguer"]
456
457 [package.metadata.battery-pack.sets]
458 indicators = { indicatif = {}, console = {} }
459 "#;
460
461 let spec = parse_battery_pack(manifest).unwrap();
462 assert_eq!(spec.default_crates, vec!["clap", "dialoguer"]);
463 assert_eq!(spec.sets.len(), 1);
464
465 let indicators = &spec.sets["indicators"];
466 assert!(indicators.crates.contains_key("indicatif"));
467 assert!(indicators.crates.contains_key("console"));
468 }
469
470 #[test]
471 fn test_parse_sets_with_feature_augmentation() {
472 let manifest = r#"
473 [package]
474 name = "async-battery-pack"
475 version = "0.1.0"
476
477 [dev-dependencies]
478 tokio = { version = "1", features = ["macros", "rt"] }
479 clap = { version = "4", features = ["derive"] }
480
481 [package.metadata.battery-pack]
482 default = ["clap", "tokio"]
483
484 [package.metadata.battery-pack.sets]
485 tokio-full = { tokio = { features = ["full"] } }
486 "#;
487
488 let spec = parse_battery_pack(manifest).unwrap();
489 let tokio_full = &spec.sets["tokio-full"];
490 assert_eq!(tokio_full.crates["tokio"].features, vec!["full"]);
491 }
492
493 #[test]
494 fn test_resolve_default_only() {
495 let manifest = r#"
496 [package]
497 name = "cli-battery-pack"
498 version = "0.3.0"
499
500 [dev-dependencies]
501 clap = { version = "4", features = ["derive"] }
502 dialoguer = "0.11"
503 indicatif = "0.17"
504
505 [package.metadata.battery-pack]
506 default = ["clap", "dialoguer"]
507
508 [package.metadata.battery-pack.sets]
509 indicators = { indicatif = {} }
510 "#;
511
512 let spec = parse_battery_pack(manifest).unwrap();
513 let resolved = spec.resolve_crates(&["default".to_string()]);
514
515 assert_eq!(resolved.len(), 2);
516 assert!(resolved.contains_key("clap"));
517 assert!(resolved.contains_key("dialoguer"));
518 assert!(!resolved.contains_key("indicatif"));
519 }
520
521 #[test]
522 fn test_resolve_with_set() {
523 let manifest = r#"
524 [package]
525 name = "cli-battery-pack"
526 version = "0.3.0"
527
528 [dev-dependencies]
529 clap = { version = "4", features = ["derive"] }
530 dialoguer = "0.11"
531 indicatif = "0.17"
532
533 [package.metadata.battery-pack]
534 default = ["clap", "dialoguer"]
535
536 [package.metadata.battery-pack.sets]
537 indicators = { indicatif = {} }
538 "#;
539
540 let spec = parse_battery_pack(manifest).unwrap();
541 let resolved = spec.resolve_crates(&["default".to_string(), "indicators".to_string()]);
542
543 assert_eq!(resolved.len(), 3);
544 assert!(resolved.contains_key("indicatif"));
545 }
546
547 #[test]
548 fn test_resolve_feature_augmentation() {
549 let manifest = r#"
550 [package]
551 name = "async-battery-pack"
552 version = "0.1.0"
553
554 [dev-dependencies]
555 tokio = { version = "1", features = ["macros", "rt"] }
556
557 [package.metadata.battery-pack]
558 default = ["tokio"]
559
560 [package.metadata.battery-pack.sets]
561 tokio-full = { tokio = { features = ["full"] } }
562 "#;
563
564 let spec = parse_battery_pack(manifest).unwrap();
565 let resolved = spec.resolve_crates(&["default".to_string(), "tokio-full".to_string()]);
566
567 let tokio = &resolved["tokio"];
568 assert!(tokio.features.contains(&"macros".to_string()));
569 assert!(tokio.features.contains(&"rt".to_string()));
570 assert!(tokio.features.contains(&"full".to_string()));
571 }
572
573 #[test]
574 fn test_resolve_all() {
575 let manifest = r#"
576 [package]
577 name = "cli-battery-pack"
578 version = "0.3.0"
579
580 [dev-dependencies]
581 clap = { version = "4", features = ["derive"] }
582 dialoguer = "0.11"
583 indicatif = "0.17"
584
585 [package.metadata.battery-pack]
586 default = ["clap"]
587
588 [package.metadata.battery-pack.sets]
589 indicators = { indicatif = {} }
590 "#;
591
592 let spec = parse_battery_pack(manifest).unwrap();
593 let resolved = spec.resolve_all();
594
595 assert_eq!(resolved.len(), 3);
597 assert!(resolved.contains_key("clap"));
598 assert!(resolved.contains_key("dialoguer"));
599 assert!(resolved.contains_key("indicatif"));
600 }
601
602 #[test]
603 fn test_parse_user_manifest() {
604 let manifest = r#"
605 [package]
606 name = "my-app"
607 version = "0.1.0"
608
609 [dependencies]
610 clap = { version = "4", features = ["derive"] }
611 dialoguer = "0.11"
612
613 [build-dependencies]
614 cli-battery-pack = "0.3.0"
615
616 [package.metadata.battery-pack.cli-battery-pack]
617 sets = ["default", "indicators"]
618 "#;
619
620 let user = parse_user_manifest(manifest).unwrap();
621 assert_eq!(user.dependencies.len(), 2);
622 assert_eq!(
623 user.battery_pack_sets["cli-battery-pack"],
624 vec!["default", "indicators"]
625 );
626 }
627}