Skip to main content

bphelper_manifest/
lib.rs

1//! Battery pack manifest parsing and resolution.
2//!
3//! Parses battery pack Cargo.toml files to extract curated crates,
4//! features, hidden dependencies, and templates. Provides resolution
5//! logic to determine which crates to install based on active features.
6
7use serde::Deserialize;
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::Path;
10
11// ============================================================================
12// Error type
13// ============================================================================
14
15/// Errors that can occur when parsing or discovering battery packs.
16#[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// ============================================================================
39// Validation diagnostics
40// ============================================================================
41
42/// Severity level for a validation diagnostic.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Severity {
45    /// Violation of a MUST rule in the spec.
46    Error,
47    /// Violation of a SHOULD rule in the spec.
48    Warning,
49}
50
51/// A single validation finding, tied to a spec rule.
52#[derive(Debug, Clone)]
53pub struct Diagnostic {
54    pub severity: Severity,
55    /// Spec rule ID (e.g., `"format.crate.keyword"`).
56    pub rule: &'static str,
57    pub message: String,
58}
59
60/// Collected validation results from checking a battery pack.
61#[derive(Debug, Default)]
62pub struct ValidationReport {
63    pub diagnostics: Vec<Diagnostic>,
64}
65
66impl ValidationReport {
67    /// True if any diagnostic is an error.
68    pub fn has_errors(&self) -> bool {
69        self.diagnostics
70            .iter()
71            .any(|d| d.severity == Severity::Error)
72    }
73
74    /// True if there are no diagnostics at all.
75    pub fn is_clean(&self) -> bool {
76        self.diagnostics.is_empty()
77    }
78
79    /// Merge another report into this one.
80    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// ============================================================================
102// Battery pack types
103// ============================================================================
104
105/// The dependency kind, determined by which section of the battery pack's
106/// Cargo.toml the crate appears in.
107// [impl format.deps.kind-mapping]
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
109pub enum DepKind {
110    /// `[dependencies]` — becomes a regular dependency for the user.
111    Normal,
112    /// `[dev-dependencies]` — becomes a dev-dependency for the user.
113    Dev,
114    /// `[build-dependencies]` — becomes a build-dependency for the user.
115    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/// A curated crate within a battery pack.
129// [impl format.deps.version-features]
130#[derive(Debug, Clone)]
131pub struct CrateSpec {
132    /// Recommended version.
133    pub version: String,
134    /// Recommended Cargo features.
135    pub features: BTreeSet<String>,
136    /// Which dependency section this crate comes from.
137    pub dep_kind: DepKind,
138    /// Whether this crate is marked `optional = true`.
139    // [impl format.features.optional]
140    pub optional: bool,
141}
142
143/// Template metadata for project scaffolding.
144#[derive(Debug, Clone)]
145pub struct TemplateSpec {
146    pub path: String,
147    pub description: Option<String>,
148}
149
150/// Parsed battery pack specification.
151///
152/// This is the core data model extracted from a battery pack's Cargo.toml.
153/// All curated crates, features, hidden deps, and templates are represented here.
154#[derive(Debug, Clone)]
155pub struct BatteryPackSpec {
156    /// Crate name (e.g., `cli-battery-pack`).
157    pub name: String,
158    /// Version string.
159    pub version: String,
160    /// Package description.
161    pub description: String,
162    /// Repository URL.
163    pub repository: Option<String>,
164    /// Package keywords.
165    pub keywords: Vec<String>,
166    /// All curated crates, keyed by crate name.
167    // [impl format.deps.source-of-truth]
168    pub crates: BTreeMap<String, CrateSpec>,
169    /// Named features from `[features]`, mapping feature name to crate names.
170    // [impl format.features.grouping]
171    pub features: BTreeMap<String, BTreeSet<String>>,
172    /// Hidden dependency patterns (may include globs).
173    // [impl format.hidden.metadata]
174    pub hidden: BTreeSet<String>,
175    /// Templates registered in metadata.
176    pub templates: BTreeMap<String, TemplateSpec>,
177}
178
179impl BatteryPackSpec {
180    /// Validate that this looks like a valid battery pack.
181    // [impl format.crate.name]
182    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    /// Check that all feature entries reference crates that actually exist.
193    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    /// Comprehensive spec validation — collects all issues rather than
208    /// failing on the first one. Checks data-only rules from the spec.
209    pub fn validate_spec(&self) -> ValidationReport {
210        let mut report = ValidationReport::default();
211
212        // [impl format.crate.name]
213        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        // [impl format.crate.keyword]
221        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        // [impl format.crate.repository]
229        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        // [impl format.features.grouping]
237        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    /// Resolve which crates should be installed for the given active features.
255    ///
256    /// With no features specified (empty slice), returns the default set:
257    /// crates from the `default` feature, or all non-optional crates if
258    /// no `default` feature exists.
259    ///
260    /// Features are additive — each named feature adds its crates on top.
261    // [impl format.features.additive]
262    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            // Default resolution
267            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    /// Add the default set of crates to the result map.
282    // [impl format.features.default]
283    fn add_default_crates(&self, result: &mut BTreeMap<String, CrateSpec>) {
284        if let Some(default_crate_names) = self.features.get("default") {
285            // Explicit default feature exists — use it
286            self.add_feature_crates(default_crate_names, result);
287        } else {
288            // No default feature — all non-optional crates
289            for (name, spec) in &self.crates {
290                if !spec.optional {
291                    result.insert(name.clone(), spec.clone());
292                }
293            }
294        }
295    }
296
297    /// Add crates from a feature's crate list to the result map.
298    ///
299    /// If a crate is already present, its Cargo features are merged additively.
300    // [impl format.features.augment]
301    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                    // Already present — merge features additively
310                    existing.features.extend(spec.features.iter().cloned());
311                } else {
312                    result.insert(crate_name.clone(), spec.clone());
313                }
314            }
315        }
316    }
317
318    /// Resolve all crates regardless of features or optional status.
319    pub fn resolve_all(&self) -> BTreeMap<String, CrateSpec> {
320        self.crates.clone()
321    }
322
323    /// Resolve all visible (non-hidden) crates regardless of features or optional status.
324    // [impl format.hidden.effect]
325    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    /// Resolve crates for a set of active features, handling the "all" sentinel.
334    ///
335    /// If `active_features` contains `"all"`, returns all visible crates.
336    /// Otherwise delegates to `resolve_crates`.
337    // [impl format.hidden.effect]
338    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    /// Check whether a crate name matches the hidden patterns.
351    // [impl format.hidden.effect]
352    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    /// Return all non-hidden crates.
359    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    /// Return all visible (non-hidden) crates grouped by feature, with a flag
368    /// indicating whether each crate is in the default set.
369    ///
370    /// Returns `Vec<(group_name, crate_name, &CrateSpec, is_default)>`.
371    /// Crates not in any feature are grouped under `"default"`.
372    // [impl format.hidden.effect]
373    // [impl tui.installed.hidden]
374    // [impl tui.browse.hidden]
375    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        // First, emit crates grouped by features
381        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        // Then, emit any crates not covered by a feature (grouped as "default")
396        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    /// Returns true if this battery pack has meaningful choices for the user
410    /// (more than 3 crates or has named features beyond default).
411    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
421// ============================================================================
422// Glob matching (minimal, for hidden dep patterns)
423// ============================================================================
424
425/// Simple glob matching for crate name patterns.
426///
427/// Supports:
428/// - `*` matches any sequence of characters
429/// - `?` matches any single character
430/// - Literal characters match exactly
431// [impl format.hidden.glob]
432// [impl format.hidden.wildcard]
433fn 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            // * matches zero chars (skip the *) or one char (consume from txt)
444            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// ============================================================================
454// Cross-pack merging
455// ============================================================================
456
457/// A crate spec produced by merging the same crate across multiple battery packs.
458///
459/// Unlike `CrateSpec` which has a single `dep_kind`, a merged spec may need to
460/// appear in multiple dependency sections (e.g., both `[dev-dependencies]` and
461/// `[build-dependencies]`).
462#[derive(Debug, Clone)]
463pub struct MergedCrateSpec {
464    /// Recommended version (highest wins across all packs).
465    pub version: String,
466    /// Union of all recommended Cargo features.
467    pub features: BTreeSet<String>,
468    /// Which dependency sections this crate should be added to.
469    /// Usually contains a single element. Contains two elements
470    /// when one pack lists it as dev and another as build.
471    pub dep_kinds: Vec<DepKind>,
472    /// Whether this crate is optional.
473    pub optional: bool,
474}
475
476/// Merge crate specs from multiple battery packs.
477///
478/// When the same crate appears in multiple packs, applies merging rules:
479/// - Version: highest wins, even across major versions
480///   (`manifest.merge.version`)
481/// - Features: union all (`manifest.merge.features`)
482/// - Dep kind: Normal wins (widest scope); if dev vs build conflict,
483///   adds to both sections (`manifest.merge.dep-kind`)
484// [impl manifest.merge.version]
485// [impl manifest.merge.features]
486// [impl manifest.merge.dep-kind]
487pub 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                    // Version: highest wins
497                    if compare_versions(&spec.version, &existing.version)
498                        == std::cmp::Ordering::Greater
499                    {
500                        existing.version = spec.version.clone();
501                    }
502
503                    // Features: union
504                    existing.features.extend(spec.features.iter().cloned());
505
506                    // Dep kind: merge
507                    existing.dep_kinds = merge_dep_kinds(&existing.dep_kinds, spec.dep_kind);
508
509                    // Optional: if any pack makes it non-optional, it's non-optional
510                    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
532/// Compare two version strings using semver-like ordering.
533///
534/// Parses dot-separated numeric components (e.g., "1.2.3") and compares
535/// them left-to-right. Non-numeric or missing components are compared
536/// as strings as a fallback. The highest version wins, even across
537/// major versions.
538fn 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        // Try numeric comparison first
549        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            // Fallback to string comparison for non-numeric parts
557            _ => {
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
569/// Merge dependency kinds according to the spec rules.
570///
571/// - If any side includes `Normal`, the result is `[Normal]` (widest scope).
572/// - If one side is `Dev` and the other is `Build`, the result is `[Dev, Build]`.
573/// - Otherwise, the existing set is returned unchanged.
574fn merge_dep_kinds(existing: &[DepKind], incoming: DepKind) -> Vec<DepKind> {
575    // If Normal is already present or incoming, Normal wins
576    if existing.contains(&DepKind::Normal) || incoming == DepKind::Normal {
577        return vec![DepKind::Normal];
578    }
579
580    // Build the combined set
581    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// ============================================================================
590// Raw deserialization types (internal)
591// ============================================================================
592
593#[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
647/// Parsed fields from a single dependency entry.
648struct RawDep {
649    version: String,
650    features: Vec<String>,
651    optional: bool,
652}
653
654// ============================================================================
655// Parsing
656// ============================================================================
657
658/// Parse a battery pack's Cargo.toml into a `BatteryPackSpec`.
659pub 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    // Parse crates from all three dependency sections
674    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    // Parse features (standard Cargo features)
680    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    // Parse hidden deps from package.metadata.battery-pack
687    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    // [impl format.templates.metadata]
695    // Parse templates from package.metadata.battery.templates
696    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
729/// Parse a single dependency section into the crates map.
730fn 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
749/// Extract version, features, and optional flag from a dependency value.
750fn 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
790// ============================================================================
791// Source discovery
792// ============================================================================
793
794/// Discover battery packs in a workspace by scanning members for
795/// crates whose names end in `-battery-pack`.
796// [impl cli.source.discover]
797pub 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        // Parse once, check name, keep if it's a battery pack
827        // [impl format.crate.name]
828        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
837/// Discover battery packs reachable from a crate root.
838///
839/// Walks up from `crate_root` looking for a workspace, then discovers
840/// battery packs within it. Falls back to parsing `crate_root` itself
841/// as a standalone battery pack if no workspace is found.
842// TODO: Replace with `cargo_metadata` when available (#13).
843// [impl cli.source.discover]
844pub fn discover_from_crate_root(crate_root: &Path) -> Result<Vec<BatteryPackSpec>, Error> {
845    // Try the crate root itself (it may be a workspace root).
846    if let Ok(specs) = discover_battery_packs(crate_root) {
847        return Ok(specs);
848    }
849
850    // Walk up looking for a workspace.
851    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    // Standalone battery pack — parse it directly.
859    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/// Minimal workspace-level deserialization for member discovery.
869#[derive(Deserialize)]
870struct RawWorkspace {
871    workspace: Option<RawWorkspaceInner>,
872}
873
874#[derive(Deserialize)]
875struct RawWorkspaceInner {
876    #[serde(default)]
877    members: Vec<String>,
878}
879
880// ============================================================================
881// On-disk validation
882// ============================================================================
883
884/// Validate a battery pack's on-disk structure against the spec.
885///
886/// `crate_root` is the directory containing the battery pack's `Cargo.toml`.
887/// This checks filesystem-level rules that can't be verified from the parsed
888/// manifest alone.
889pub 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
897/// Check that `src/lib.rs` contains only doc-comments, whitespace, and
898/// include directives — no functional code.
899// [impl format.crate.lib]
900fn 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, // Missing lib.rs is a different problem
905    };
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; // One warning is enough
925    }
926}
927
928/// Check that `src/` contains no `.rs` files beyond `lib.rs`.
929// [impl format.crate.no-code]
930fn 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
955/// Check that each template declared in metadata exists on disk.
956// [impl format.templates.directory]
957fn 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// ============================================================================
977// Tests
978// ============================================================================
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983
984    // -- Parsing tests --
985
986    #[test]
987    // [verify format.deps.source-of-truth]
988    // [verify format.deps.kind-mapping]
989    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    // [verify format.deps.version-features]
1024    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    // [verify format.features.optional]
1051    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    // [verify format.features.grouping]
1069    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    // [verify format.hidden.metadata]
1100    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    // -- Validation tests --
1160
1161    #[test]
1162    // [verify format.crate.name]
1163    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        // Valid case
1200        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    // -- Resolution tests --
1217
1218    #[test]
1219    // [verify format.features.default]
1220    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    // [verify format.features.default]
1247    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        // No features section at all
1261        let resolved = spec.resolve_crates(&[]);
1262
1263        // All non-optional crates
1264        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    // [verify format.features.additive]
1272    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        // Only indicators, no default
1318        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    // [verify format.features.augment]
1327    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        // Both default and full reference tokio — features should be merged
1343        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        // Everything including optional and dev-deps
1373        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    // -- Hidden dep tests --
1380
1381    #[test]
1382    // [verify format.hidden.effect]
1383    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    // [verify format.hidden.glob]
1404    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    // [verify format.hidden.wildcard]
1429    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    // [verify tui.installed.hidden]
1477    // [verify tui.browse.hidden]
1478    #[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    // -- Glob matching unit tests --
1508
1509    #[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    // -- Error type tests --
1528
1529    #[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    // -- Comprehensive battery pack test --
1542
1543    #[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        // Basic fields
1581        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        // Crates from all sections
1586        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        // Optional
1592        assert!(spec.crates["indicatif"].optional);
1593        assert!(!spec.crates["clap"].optional);
1594
1595        // Features
1596        assert_eq!(spec.features.len(), 3);
1597
1598        // Hidden
1599        assert!(spec.is_hidden("cc"));
1600        assert!(!spec.is_hidden("clap"));
1601
1602        // Visible
1603        let visible = spec.visible_crates();
1604        assert_eq!(visible.len(), 5); // 6 total - 1 hidden (cc)
1605
1606        // Templates
1607        assert_eq!(spec.templates.len(), 1);
1608
1609        // Resolution: default
1610        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        // Resolution: default + indicators
1616        let with_indicators = spec.resolve_crates(&["default", "indicators"]);
1617        assert_eq!(with_indicators.len(), 4);
1618
1619        // Resolution: only indicators (no default)
1620        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        // Resolution: all
1626        let all = spec.resolve_all();
1627        assert_eq!(all.len(), 6);
1628    }
1629
1630    // -- Discovery tests --
1631
1632    #[test]
1633    // [verify cli.source.discover]
1634    fn discover_battery_packs_in_fixture_workspace() {
1635        // Find the fixtures directory relative to the workspace root
1636        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        // Verify basic-battery-pack
1657        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); // anyhow, thiserror, eyre
1663        assert!(basic.crates["eyre"].optional);
1664        assert!(basic.crates["anyhow"].optional);
1665
1666        // Verify fancy-battery-pack
1667        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        // fancy default resolution
1679        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        // fancy visible crates (hidden: serde, serde_json, cc)
1685        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        // Verify managed-battery-pack
1692        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); // anyhow, clap
1698        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    // [verify cli.source.discover] workspace case — member crate discovers siblings
1709    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    // [verify cli.source.discover] standalone case — no workspace, parses crate directly
1729    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    // -- validate_spec tests --
1754
1755    #[test]
1756    // [verify format.crate.name]
1757    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    // [verify format.crate.keyword]
1803    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    // [verify format.features.grouping]
1854    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    // -- validate_on_disk tests --
1899
1900    #[test]
1901    // [verify format.crate.lib]
1902    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    // [verify format.crate.lib]
1928    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()); // It's a warning, not an error
1947        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    // [verify format.crate.no-code]
1957    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        // Clean case — only lib.rs
1974        let report = validate_on_disk(&spec, dir.path());
1975        assert!(report.is_clean());
1976
1977        // Add an extra .rs file
1978        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    // [verify format.templates.directory]
1991    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        // Missing template directory
2011        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        // Create the directory — should now be clean
2021        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    // -- Repository warning tests --
2033
2034    #[test]
2035    // [verify format.crate.repository]
2036    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    // [verify format.crate.repository]
2062    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    // -- Fixture integration tests --
2084
2085    #[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        // basic-battery-pack has no repository — expect a warning but no errors
2103        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        // Note: format.features.grouping can't be triggered from a fixture because
2167        // cargo itself rejects features that reference nonexistent dependencies.
2168        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        // lib.rs has code — should be a warning
2178        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    // -- Cross-pack merging tests --
2211
2212    /// Helper to build a CrateSpec quickly in tests.
2213    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    // [verify manifest.merge.version]
2227    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    // [verify manifest.merge.version]
2243    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    // [verify manifest.merge.version]
2259    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    // [verify manifest.merge.features]
2275    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        // "rt" should not be duplicated
2292        assert_eq!(features.iter().filter(|f| f.as_str() == "rt").count(), 1);
2293    }
2294
2295    #[test]
2296    // [verify manifest.merge.dep-kind]
2297    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    // [verify manifest.merge.dep-kind]
2307    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    // [verify manifest.merge.dep-kind]
2317    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    // [verify manifest.merge.version]
2330    // [verify manifest.merge.features]
2331    // [verify manifest.merge.dep-kind]
2332    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        // tokio: version 1.38.0 (highest), features union, Normal wins
2367        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        // serde: version 1.0.210 (highest), features union, dev+build = both
2375        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        // anyhow: only in pack_c, should appear as-is
2384        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    // [verify manifest.merge.version]
2391    // [verify manifest.merge.features]
2392    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    // -- Version comparison unit tests --
2435
2436    #[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}