Skip to main content

bphelper_manifest/
lib.rs

1//! Battery pack manifest parsing and drift detection.
2//!
3//! Parses battery pack Cargo.toml files to extract dev-dependencies,
4//! sets, and default configuration. Also parses user manifests to
5//! detect drift between expected and actual dependencies.
6
7use serde::Deserialize;
8use std::collections::BTreeMap;
9
10// ============================================================================
11// Battery pack manifest types
12// ============================================================================
13
14/// Parsed battery pack specification.
15#[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/// A dependency specification (version + features).
25#[derive(Debug, Clone)]
26pub struct DepSpec {
27    pub version: String,
28    pub features: Vec<String>,
29}
30
31/// A named set of crates with optional feature augmentation.
32#[derive(Debug, Clone)]
33pub struct SetSpec {
34    pub crates: BTreeMap<String, SetCrateSpec>,
35}
36
37/// A crate entry within a set — may specify additional features.
38#[derive(Debug, Clone)]
39pub struct SetCrateSpec {
40    pub features: Vec<String>,
41}
42
43impl BatteryPackSpec {
44    /// Resolve a list of active set names into a merged list of (crate, version, features).
45    ///
46    /// Starts with crates from `default_crates` (looked up in dev_dependencies),
47    /// then layers on additional sets. Feature merging is always additive.
48    pub fn resolve_crates(&self, active_sets: &[String]) -> BTreeMap<String, DepSpec> {
49        let mut result: BTreeMap<String, DepSpec> = BTreeMap::new();
50
51        // Start with default crates
52        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        // Layer on each active set (skip "default" — already handled)
59        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                        // Crate already present — merge features additively
67                        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                        // New crate from this set — start with base features, add set features
74                        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    /// Resolve all dev-dependencies (for --all flag).
96    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        // Apply all set feature augmentations
104        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// ============================================================================
121// User manifest types
122// ============================================================================
123
124/// Parsed user manifest (the parts we care about for validation).
125#[derive(Debug)]
126pub struct UserManifest {
127    pub dependencies: BTreeMap<String, DepSpec>,
128    /// Active sets per battery pack: battery-pack-name -> list of set names
129    pub battery_pack_sets: BTreeMap<String, Vec<String>>,
130}
131
132// ============================================================================
133// Raw deserialization types (internal)
134// ============================================================================
135
136#[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
166// ============================================================================
167// Parsing functions
168// ============================================================================
169
170/// Parse a battery pack's Cargo.toml into a BatteryPackSpec.
171pub 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    // Parse dev-dependencies
180    let dev_dependencies = parse_dep_map(&raw.dev_dependencies);
181
182    // Parse metadata
183    let bp_meta = package.metadata.and_then(|m| m.battery_pack);
184
185    // Default set: if specified, use it; otherwise all dev-deps
186    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    // Parse sets
192    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
206/// Parse a user's Cargo.toml to extract dependencies and battery pack metadata.
207pub 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    // Parse battery pack sets from [package.metadata.battery-pack.<bp-name>]
214    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    // Parse just the metadata section to extract per-battery-pack sets
224    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
320// ============================================================================
321// Drift detection
322// ============================================================================
323
324/// Check for drift between a battery pack's spec and the user's manifest.
325/// Emits `cargo:warning` for any issues found.
326pub fn check_drift(bp: &BatteryPackSpec, user: &UserManifest) {
327    // Find which sets the user has active for this battery pack
328    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                // Check version (simple string comparison for now — could do semver later)
349                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                // Check missing features
361                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
382/// Assert that a battery pack manifest has no regular dependencies other than `battery-pack`.
383///
384/// Battery packs should only declare curated crates as `[dev-dependencies]`.
385/// Call this from a `#[test]` in your battery pack's lib.rs:
386///
387/// ```rust,ignore
388/// #[test]
389/// fn no_regular_deps() {
390///     battery_pack::assert_no_regular_deps(
391///         include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"))
392///     );
393/// }
394/// ```
395pub 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// ============================================================================
410// Tests
411// ============================================================================
412
413#[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        // All dev-deps are the default set (no explicit default)
435        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        // All three dev-deps should be present
596        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}