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