Skip to main content

bphelper_cli/
lib.rs

1//! CLI for battery-pack: create and manage battery packs.
2
3use anyhow::{Context, Result, bail};
4use cargo_generate::{GenerateArgs, TemplatePath, Vcs};
5use clap::{Parser, Subcommand};
6use flate2::read::GzDecoder;
7use serde::Deserialize;
8use std::collections::{BTreeMap, BTreeSet};
9use std::io::IsTerminal;
10use std::path::{Path, PathBuf};
11use tar::Archive;
12
13mod tui;
14
15const CRATES_IO_API: &str = "https://crates.io/api/v1/crates";
16const CRATES_IO_CDN: &str = "https://static.crates.io/crates";
17
18fn http_client() -> &'static reqwest::blocking::Client {
19    static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
20    CLIENT.get_or_init(|| {
21        reqwest::blocking::Client::builder()
22            .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
23            .build()
24            .expect("failed to build HTTP client")
25    })
26}
27
28// [impl cli.source.flag]
29// [impl cli.source.replace]
30#[derive(Debug, Clone)]
31pub enum CrateSource {
32    Registry,
33    Local(PathBuf),
34}
35
36// [impl cli.bare.help]
37#[derive(Parser)]
38#[command(name = "cargo-bp")]
39#[command(bin_name = "cargo")]
40#[command(version, about = "Create and manage battery packs", long_about = None)]
41pub struct Cli {
42    #[command(subcommand)]
43    pub command: Commands,
44}
45
46#[derive(Subcommand)]
47pub enum Commands {
48    /// Battery pack commands
49    Bp {
50        // [impl cli.source.subcommands]
51        /// Use a local workspace as the battery pack source (replaces crates.io)
52        #[arg(long)]
53        crate_source: Option<PathBuf>,
54
55        #[command(subcommand)]
56        command: Option<BpCommands>,
57    },
58}
59
60#[derive(Subcommand)]
61pub enum BpCommands {
62    /// Create a new project from a battery pack template
63    New {
64        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
65        battery_pack: String,
66
67        /// Name for the new project (prompted interactively if not provided)
68        #[arg(long, short = 'n')]
69        name: Option<String>,
70
71        /// Which template to use (defaults to first available, or prompts if multiple)
72        // [impl cli.new.template-flag]
73        #[arg(long, short = 't')]
74        template: Option<String>,
75
76        /// Use a local path instead of downloading from crates.io
77        #[arg(long)]
78        path: Option<String>,
79    },
80
81    /// Add a battery pack and sync its dependencies.
82    ///
83    /// Without arguments, opens an interactive TUI for managing all battery packs.
84    /// With a battery pack name, adds that specific pack (with an interactive picker
85    /// for choosing crates if the pack has features or many dependencies).
86    Add {
87        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack").
88        /// Omit to open the interactive manager.
89        battery_pack: Option<String>,
90
91        /// Specific crates to add from the battery pack (ignores defaults/features)
92        crates: Vec<String>,
93
94        // [impl cli.add.features]
95        // [impl cli.add.features-multiple]
96        /// Named features to enable (comma-separated or repeated)
97        #[arg(long = "features", short = 'F', value_delimiter = ',')]
98        features: Vec<String>,
99
100        // [impl cli.add.no-default-features]
101        /// Skip the default crates; only add crates from named features
102        #[arg(long)]
103        no_default_features: bool,
104
105        // [impl cli.add.all-features]
106        /// Add every crate the battery pack offers
107        #[arg(long)]
108        all_features: bool,
109
110        // [impl cli.add.target]
111        /// Where to store the battery pack registration
112        /// (workspace, package, or default)
113        #[arg(long)]
114        target: Option<AddTarget>,
115
116        /// Use a local path instead of downloading from crates.io
117        #[arg(long)]
118        path: Option<String>,
119    },
120
121    /// Update dependencies from installed battery packs
122    Sync {
123        // [impl cli.path.subcommands]
124        /// Use a local path instead of downloading from crates.io
125        #[arg(long)]
126        path: Option<String>,
127    },
128
129    /// Enable a named feature from a battery pack
130    Enable {
131        /// Name of the feature to enable
132        feature_name: String,
133
134        /// Battery pack to search (optional — searches all installed if omitted)
135        #[arg(long)]
136        battery_pack: Option<String>,
137    },
138
139    /// List available battery packs on crates.io
140    #[command(visible_alias = "ls")]
141    List {
142        /// Filter by name (omit to list all battery packs)
143        filter: Option<String>,
144
145        /// Disable interactive TUI mode
146        #[arg(long)]
147        non_interactive: bool,
148    },
149
150    /// Show detailed information about a battery pack
151    #[command(visible_alias = "info")]
152    Show {
153        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
154        battery_pack: String,
155
156        /// Use a local path instead of downloading from crates.io
157        #[arg(long)]
158        path: Option<String>,
159
160        /// Disable interactive TUI mode
161        #[arg(long)]
162        non_interactive: bool,
163    },
164
165    /// Show status of installed battery packs and version warnings
166    #[command(visible_alias = "stat")]
167    Status {
168        // [impl cli.path.subcommands]
169        /// Use a local path instead of downloading from crates.io
170        #[arg(long)]
171        path: Option<String>,
172    },
173
174    /// Validate that the current battery pack is well-formed
175    Validate {
176        /// Path to the battery pack crate (defaults to current directory)
177        #[arg(long)]
178        path: Option<String>,
179    },
180}
181
182// [impl cli.add.target]
183#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
184pub enum AddTarget {
185    /// Register in `workspace.metadata.battery-pack`.
186    Workspace,
187    /// Register in `package.metadata.battery-pack`.
188    Package,
189    /// Use workspace if a workspace root exists, otherwise package
190    Default,
191}
192
193/// Main entry point for the CLI.
194pub fn main() -> Result<()> {
195    let cli = Cli::parse();
196    let project_dir = std::env::current_dir().context("Failed to get current directory")?;
197    let interactive = std::io::stdout().is_terminal();
198
199    match cli.command {
200        Commands::Bp {
201            crate_source,
202            command,
203        } => {
204            let source = match crate_source {
205                Some(path) => CrateSource::Local(path),
206                None => CrateSource::Registry,
207            };
208            // [impl cli.bare.tui]
209            let Some(command) = command else {
210                if interactive {
211                    return tui::run_add(source);
212                } else {
213                    bail!(
214                        "No subcommand specified. Use `cargo bp --help` or run interactively in a terminal."
215                    );
216                }
217            };
218            match command {
219                BpCommands::New {
220                    battery_pack,
221                    name,
222                    template,
223                    path,
224                } => new_from_battery_pack(&battery_pack, name, template, path, &source),
225                BpCommands::Add {
226                    battery_pack,
227                    crates,
228                    features,
229                    no_default_features,
230                    all_features,
231                    target,
232                    path,
233                } => match battery_pack {
234                    Some(name) => add_battery_pack(
235                        &name,
236                        &features,
237                        no_default_features,
238                        all_features,
239                        &crates,
240                        target,
241                        path.as_deref(),
242                        &source,
243                        &project_dir,
244                    ),
245                    None if interactive => tui::run_add(source),
246                    None => {
247                        bail!(
248                            "No battery pack specified. Use `cargo bp add <name>` or run interactively in a terminal."
249                        )
250                    }
251                },
252                BpCommands::Sync { path } => {
253                    sync_battery_packs(&project_dir, path.as_deref(), &source)
254                }
255                BpCommands::Enable {
256                    feature_name,
257                    battery_pack,
258                } => enable_feature(&feature_name, battery_pack.as_deref(), &project_dir),
259                BpCommands::List {
260                    filter,
261                    non_interactive,
262                } => {
263                    // [impl cli.list.interactive]
264                    // [impl cli.list.non-interactive]
265                    if !non_interactive && interactive {
266                        tui::run_list(source, filter)
267                    } else {
268                        // [impl cli.list.query]
269                        // [impl cli.list.filter]
270                        print_battery_pack_list(&source, filter.as_deref())
271                    }
272                }
273                BpCommands::Show {
274                    battery_pack,
275                    path,
276                    non_interactive,
277                } => {
278                    // [impl cli.show.interactive]
279                    // [impl cli.show.non-interactive]
280                    if !non_interactive && interactive {
281                        tui::run_show(&battery_pack, path.as_deref(), source)
282                    } else {
283                        print_battery_pack_detail(&battery_pack, path.as_deref(), &source)
284                    }
285                }
286                BpCommands::Status { path } => {
287                    status_battery_packs(&project_dir, path.as_deref(), &source)
288                }
289                BpCommands::Validate { path } => validate_battery_pack_cmd(path.as_deref()),
290            }
291        }
292    }
293}
294
295// ============================================================================
296// crates.io API types
297// ============================================================================
298
299#[derive(Deserialize)]
300struct CratesIoResponse {
301    versions: Vec<VersionInfo>,
302}
303
304#[derive(Deserialize)]
305struct VersionInfo {
306    num: String,
307    yanked: bool,
308}
309
310#[derive(Deserialize)]
311struct SearchResponse {
312    crates: Vec<SearchCrate>,
313}
314
315#[derive(Deserialize)]
316struct SearchCrate {
317    name: String,
318    max_version: String,
319    description: Option<String>,
320}
321
322/// Backward-compatible alias for `bphelper_manifest::TemplateSpec`.
323pub type TemplateConfig = bphelper_manifest::TemplateSpec;
324
325// ============================================================================
326// crates.io owner types
327// ============================================================================
328
329#[derive(Deserialize)]
330struct OwnersResponse {
331    users: Vec<Owner>,
332}
333
334#[derive(Deserialize, Clone)]
335struct Owner {
336    login: String,
337    name: Option<String>,
338}
339
340// ============================================================================
341// GitHub API types
342// ============================================================================
343
344#[derive(Deserialize)]
345struct GitHubTreeResponse {
346    tree: Vec<GitHubTreeEntry>,
347    #[serde(default)]
348    #[allow(dead_code)]
349    truncated: bool,
350}
351
352#[derive(Deserialize)]
353struct GitHubTreeEntry {
354    path: String,
355}
356
357// ============================================================================
358// Shared data types (used by both TUI and text output)
359// ============================================================================
360
361/// Summary info for displaying in a list
362#[derive(Clone)]
363pub struct BatteryPackSummary {
364    pub name: String,
365    pub short_name: String,
366    pub version: String,
367    pub description: String,
368}
369
370/// Detailed battery pack info
371#[derive(Clone)]
372pub struct BatteryPackDetail {
373    pub name: String,
374    pub short_name: String,
375    pub version: String,
376    pub description: String,
377    pub repository: Option<String>,
378    pub owners: Vec<OwnerInfo>,
379    pub crates: Vec<String>,
380    pub extends: Vec<String>,
381    pub templates: Vec<TemplateInfo>,
382    pub examples: Vec<ExampleInfo>,
383}
384
385#[derive(Clone)]
386pub struct OwnerInfo {
387    pub login: String,
388    pub name: Option<String>,
389}
390
391impl From<Owner> for OwnerInfo {
392    fn from(o: Owner) -> Self {
393        Self {
394            login: o.login,
395            name: o.name,
396        }
397    }
398}
399
400#[derive(Clone)]
401pub struct TemplateInfo {
402    pub name: String,
403    pub path: String,
404    pub description: Option<String>,
405    /// Full path in the repository (e.g., "src/cli-battery-pack/templates/simple")
406    /// Resolved by searching the GitHub tree API
407    pub repo_path: Option<String>,
408}
409
410#[derive(Clone)]
411pub struct ExampleInfo {
412    pub name: String,
413    pub description: Option<String>,
414    /// Full path in the repository (e.g., "src/cli-battery-pack/examples/mini-grep.rs")
415    /// Resolved by searching the GitHub tree API
416    pub repo_path: Option<String>,
417}
418
419// ============================================================================
420// Implementation
421// ============================================================================
422
423// [impl cli.new.template]
424// [impl cli.new.name-flag]
425// [impl cli.new.name-prompt]
426// [impl cli.path.flag]
427// [impl cli.source.replace]
428fn new_from_battery_pack(
429    battery_pack: &str,
430    name: Option<String>,
431    template: Option<String>,
432    path_override: Option<String>,
433    source: &CrateSource,
434) -> Result<()> {
435    // --path takes precedence over --crate-source
436    if let Some(path) = path_override {
437        return generate_from_local(&path, name, template);
438    }
439
440    let crate_name = resolve_crate_name(battery_pack);
441
442    // Locate the crate directory based on source
443    let crate_dir: PathBuf;
444    let _temp_dir: Option<tempfile::TempDir>; // keep alive for Registry
445    match source {
446        CrateSource::Registry => {
447            let crate_info = lookup_crate(&crate_name)?;
448            let temp = download_and_extract_crate(&crate_name, &crate_info.version)?;
449            crate_dir = temp
450                .path()
451                .join(format!("{}-{}", crate_name, crate_info.version));
452            _temp_dir = Some(temp);
453        }
454        CrateSource::Local(workspace_dir) => {
455            crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
456            _temp_dir = None;
457        }
458    }
459
460    // Read template metadata from the Cargo.toml
461    let manifest_path = crate_dir.join("Cargo.toml");
462    let manifest_content = std::fs::read_to_string(&manifest_path)
463        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
464    let templates = parse_template_metadata(&manifest_content, &crate_name)?;
465
466    // Resolve which template to use
467    let template_path = resolve_template(&templates, template.as_deref())?;
468
469    // Generate the project from the crate directory
470    generate_from_path(&crate_dir, &template_path, name)
471}
472
473/// Result of resolving which crates to add from a battery pack.
474pub enum ResolvedAdd {
475    /// Resolved to a concrete set of crates (no interactive picker needed).
476    Crates {
477        active_features: BTreeSet<String>,
478        crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
479    },
480    /// The caller should show the interactive picker.
481    Interactive,
482}
483
484/// Pure resolution logic for `cargo bp add` flags.
485///
486/// Given the battery pack spec and the CLI flags, determines which crates
487/// to install. Returns `ResolvedAdd::Interactive` when the picker should
488/// be shown (no explicit flags, TTY, meaningful choices).
489///
490/// When `specific_crates` is non-empty, unknown crate names are reported
491/// to stderr and skipped; valid ones proceed.
492// [impl cli.add.specific-crates]
493// [impl cli.add.unknown-crate]
494// [impl cli.add.default-crates]
495// [impl cli.add.features]
496// [impl cli.add.no-default-features]
497// [impl cli.add.all-features]
498pub fn resolve_add_crates(
499    bp_spec: &bphelper_manifest::BatteryPackSpec,
500    bp_name: &str,
501    with_features: &[String],
502    no_default_features: bool,
503    all_features: bool,
504    specific_crates: &[String],
505) -> ResolvedAdd {
506    if !specific_crates.is_empty() {
507        // Explicit crate selection — ignores defaults and features.
508        let mut selected = BTreeMap::new();
509        for crate_name_arg in specific_crates {
510            if let Some(spec) = bp_spec.crates.get(crate_name_arg.as_str()) {
511                selected.insert(crate_name_arg.clone(), spec.clone());
512            } else {
513                eprintln!(
514                    "error: crate '{}' not found in battery pack '{}'",
515                    crate_name_arg, bp_name
516                );
517            }
518        }
519        return ResolvedAdd::Crates {
520            active_features: BTreeSet::new(),
521            crates: selected,
522        };
523    }
524
525    if all_features {
526        // [impl format.hidden.effect]
527        return ResolvedAdd::Crates {
528            active_features: BTreeSet::from(["all".to_string()]),
529            crates: bp_spec.resolve_all_visible(),
530        };
531    }
532
533    // When no explicit flags narrow the selection and the pack has
534    // meaningful choices, signal that the caller may want to show
535    // the interactive picker.
536    if !no_default_features && with_features.is_empty() && bp_spec.has_meaningful_choices() {
537        return ResolvedAdd::Interactive;
538    }
539
540    let mut features: BTreeSet<String> = if no_default_features {
541        BTreeSet::new()
542    } else {
543        BTreeSet::from(["default".to_string()])
544    };
545    features.extend(with_features.iter().cloned());
546
547    // When no features are active (--no-default-features with no -F),
548    // return empty rather than calling resolve_crates(&[]) which
549    // falls back to defaults.
550    if features.is_empty() {
551        return ResolvedAdd::Crates {
552            active_features: features,
553            crates: BTreeMap::new(),
554        };
555    }
556
557    let str_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
558    let crates = bp_spec.resolve_crates(&str_features);
559    ResolvedAdd::Crates {
560        active_features: features,
561        crates,
562    }
563}
564
565// [impl cli.add.register]
566// [impl cli.add.dep-kind]
567// [impl cli.add.specific-crates]
568// [impl cli.add.unknown-crate]
569// [impl manifest.register.location]
570// [impl manifest.register.format]
571// [impl manifest.features.storage]
572// [impl manifest.deps.add]
573// [impl manifest.deps.version-features]
574#[allow(clippy::too_many_arguments)]
575pub fn add_battery_pack(
576    name: &str,
577    with_features: &[String],
578    no_default_features: bool,
579    all_features: bool,
580    specific_crates: &[String],
581    target: Option<AddTarget>,
582    path: Option<&str>,
583    source: &CrateSource,
584    project_dir: &Path,
585) -> Result<()> {
586    let crate_name = resolve_crate_name(name);
587
588    // Step 1: Read the battery pack spec WITHOUT modifying any manifests.
589    // --path takes precedence over --crate-source.
590    // [impl cli.path.flag]
591    // [impl cli.path.no-resolve]
592    // [impl cli.source.replace]
593    let (bp_version, bp_spec) = if let Some(local_path) = path {
594        let manifest_path = Path::new(local_path).join("Cargo.toml");
595        let manifest_content = std::fs::read_to_string(&manifest_path)
596            .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
597        let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
598            .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
599        (None, spec)
600    } else {
601        fetch_bp_spec(source, name)?
602    };
603
604    // Step 2: Determine which crates to install — interactive picker, explicit flags, or defaults.
605    // No manifest changes have been made yet, so cancellation is free.
606    let resolved = resolve_add_crates(
607        &bp_spec,
608        &crate_name,
609        with_features,
610        no_default_features,
611        all_features,
612        specific_crates,
613    );
614    let (active_features, crates_to_sync) = match resolved {
615        ResolvedAdd::Crates {
616            active_features,
617            crates,
618        } => (active_features, crates),
619        ResolvedAdd::Interactive if std::io::stdout().is_terminal() => {
620            match pick_crates_interactive(&bp_spec)? {
621                Some(result) => (result.active_features, result.crates),
622                None => {
623                    println!("Cancelled.");
624                    return Ok(());
625                }
626            }
627        }
628        ResolvedAdd::Interactive => {
629            // Non-interactive fallback: use defaults
630            let crates = bp_spec.resolve_crates(&["default"]);
631            (BTreeSet::from(["default".to_string()]), crates)
632        }
633    };
634
635    if crates_to_sync.is_empty() {
636        println!("No crates selected.");
637        return Ok(());
638    }
639
640    // Step 3: Now write everything — build-dep, workspace deps, crate deps, metadata.
641    let user_manifest_path = find_user_manifest(project_dir)?;
642    let user_manifest_content =
643        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
644    // [impl manifest.toml.preserve]
645    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
646        .parse()
647        .context("Failed to parse Cargo.toml")?;
648
649    // [impl manifest.register.workspace-default]
650    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
651
652    // Add battery pack to [build-dependencies]
653    let build_deps =
654        user_doc["build-dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
655    if let Some(table) = build_deps.as_table_mut() {
656        if let Some(local_path) = path {
657            let mut dep = toml_edit::InlineTable::new();
658            dep.insert("path", toml_edit::Value::from(local_path));
659            table.insert(
660                &crate_name,
661                toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
662            );
663        } else if workspace_manifest.is_some() {
664            let mut dep = toml_edit::InlineTable::new();
665            dep.insert("workspace", toml_edit::Value::from(true));
666            table.insert(
667                &crate_name,
668                toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
669            );
670        } else {
671            let version = bp_version
672                .as_ref()
673                .context("battery pack version not available (--path without workspace)")?;
674            table.insert(&crate_name, toml_edit::value(version));
675        }
676    }
677
678    // [impl manifest.deps.workspace]
679    // Add crate dependencies + workspace deps (including the battery pack itself).
680    // Load workspace doc once; both deps and metadata are written to it before a
681    // single flush at the end (avoids a double read-modify-write).
682    let mut ws_doc: Option<toml_edit::DocumentMut> = if let Some(ref ws_path) = workspace_manifest {
683        let ws_content =
684            std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
685        Some(
686            ws_content
687                .parse()
688                .context("Failed to parse workspace Cargo.toml")?,
689        )
690    } else {
691        None
692    };
693
694    if let Some(ref mut doc) = ws_doc {
695        let ws_deps = doc["workspace"]["dependencies"]
696            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
697        if let Some(ws_table) = ws_deps.as_table_mut() {
698            // Add the battery pack itself to workspace deps
699            if let Some(local_path) = path {
700                let mut dep = toml_edit::InlineTable::new();
701                dep.insert("path", toml_edit::Value::from(local_path));
702                ws_table.insert(
703                    &crate_name,
704                    toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
705                );
706            } else {
707                let version = bp_version
708                    .as_ref()
709                    .context("battery pack version not available (--path without workspace)")?;
710                ws_table.insert(&crate_name, toml_edit::value(version));
711            }
712            // Add the resolved crate dependencies
713            for (dep_name, dep_spec) in &crates_to_sync {
714                add_dep_to_table(ws_table, dep_name, dep_spec);
715            }
716        }
717
718        // [impl cli.add.dep-kind]
719        write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, false);
720    } else {
721        // [impl manifest.deps.no-workspace]
722        // [impl cli.add.dep-kind]
723        write_deps_by_kind(&mut user_doc, &crates_to_sync, false);
724    }
725
726    // [impl manifest.register.location]
727    // [impl manifest.register.format]
728    // [impl manifest.features.storage]
729    // [impl cli.add.target]
730    // Record active features — location depends on --target flag
731    let use_workspace_metadata = match target {
732        Some(AddTarget::Workspace) => true,
733        Some(AddTarget::Package) => false,
734        Some(AddTarget::Default) | None => workspace_manifest.is_some(),
735    };
736
737    if use_workspace_metadata {
738        if let Some(ref mut doc) = ws_doc {
739            write_bp_features_to_doc(
740                doc,
741                &["workspace", "metadata"],
742                &crate_name,
743                &active_features,
744            );
745        } else {
746            bail!("--target=workspace requires a workspace, but none was found");
747        }
748    } else {
749        write_bp_features_to_doc(
750            &mut user_doc,
751            &["package", "metadata"],
752            &crate_name,
753            &active_features,
754        );
755    }
756
757    // Write workspace Cargo.toml once (deps + metadata combined)
758    if let (Some(ws_path), Some(doc)) = (&workspace_manifest, &ws_doc) {
759        // [impl manifest.toml.preserve]
760        std::fs::write(ws_path, doc.to_string()).context("Failed to write workspace Cargo.toml")?;
761    }
762
763    // Write the final Cargo.toml
764    // [impl manifest.toml.preserve]
765    std::fs::write(&user_manifest_path, user_doc.to_string())
766        .context("Failed to write Cargo.toml")?;
767
768    // Create/modify build.rs
769    let build_rs_path = user_manifest_path
770        .parent()
771        .unwrap_or(Path::new("."))
772        .join("build.rs");
773    update_build_rs(&build_rs_path, &crate_name)?;
774
775    println!(
776        "Added {} with {} crate(s)",
777        crate_name,
778        crates_to_sync.len()
779    );
780    for dep_name in crates_to_sync.keys() {
781        println!("  + {}", dep_name);
782    }
783
784    Ok(())
785}
786
787// [impl cli.sync.update-versions]
788// [impl cli.sync.add-features]
789// [impl cli.sync.add-crates]
790// [impl cli.source.subcommands]
791// [impl cli.path.subcommands]
792fn sync_battery_packs(project_dir: &Path, path: Option<&str>, source: &CrateSource) -> Result<()> {
793    let user_manifest_path = find_user_manifest(project_dir)?;
794    let user_manifest_content =
795        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
796
797    let bp_names = find_installed_bp_names(&user_manifest_content)?;
798
799    if bp_names.is_empty() {
800        println!("No battery packs installed.");
801        return Ok(());
802    }
803
804    // [impl manifest.toml.preserve]
805    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
806        .parse()
807        .context("Failed to parse Cargo.toml")?;
808
809    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
810    let metadata_location = resolve_metadata_location(&user_manifest_path)?;
811    let mut total_changes = 0;
812
813    for bp_name in &bp_names {
814        // Get the battery pack spec
815        let bp_spec = load_installed_bp_spec(bp_name, path, source)?;
816
817        // Read active features from the correct metadata location
818        let active_features =
819            read_active_features_from(&metadata_location, &user_manifest_content, bp_name);
820
821        // [impl format.hidden.effect]
822        let expected = bp_spec.resolve_for_features(&active_features);
823
824        // [impl manifest.deps.workspace]
825        // Sync each crate
826        if let Some(ref ws_path) = workspace_manifest {
827            let ws_content =
828                std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
829            // [impl manifest.toml.preserve]
830            let mut ws_doc: toml_edit::DocumentMut = ws_content
831                .parse()
832                .context("Failed to parse workspace Cargo.toml")?;
833
834            let ws_deps = ws_doc["workspace"]["dependencies"]
835                .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
836            if let Some(ws_table) = ws_deps.as_table_mut() {
837                for (dep_name, dep_spec) in &expected {
838                    if sync_dep_in_table(ws_table, dep_name, dep_spec) {
839                        total_changes += 1;
840                        println!("  ~ {} (updated in workspace)", dep_name);
841                    }
842                }
843            }
844            // [impl manifest.toml.preserve]
845            std::fs::write(ws_path, ws_doc.to_string())
846                .context("Failed to write workspace Cargo.toml")?;
847
848            // Ensure crate-level references exist in the correct sections
849            // [impl cli.add.dep-kind]
850            let refs_added = write_workspace_refs_by_kind(&mut user_doc, &expected, true);
851            total_changes += refs_added;
852        } else {
853            // [impl manifest.deps.no-workspace]
854            // [impl cli.add.dep-kind]
855            for (dep_name, dep_spec) in &expected {
856                let section = dep_kind_section(dep_spec.dep_kind);
857                let table =
858                    user_doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
859                if let Some(table) = table.as_table_mut() {
860                    if !table.contains_key(dep_name) {
861                        add_dep_to_table(table, dep_name, dep_spec);
862                        total_changes += 1;
863                        println!("  + {}", dep_name);
864                    } else if sync_dep_in_table(table, dep_name, dep_spec) {
865                        total_changes += 1;
866                        println!("  ~ {}", dep_name);
867                    }
868                }
869            }
870        }
871    }
872
873    // [impl manifest.toml.preserve]
874    std::fs::write(&user_manifest_path, user_doc.to_string())
875        .context("Failed to write Cargo.toml")?;
876
877    if total_changes == 0 {
878        println!("All dependencies are up to date.");
879    } else {
880        println!("Synced {} change(s).", total_changes);
881    }
882
883    Ok(())
884}
885
886fn enable_feature(
887    feature_name: &str,
888    battery_pack: Option<&str>,
889    project_dir: &Path,
890) -> Result<()> {
891    let user_manifest_path = find_user_manifest(project_dir)?;
892    let user_manifest_content =
893        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
894
895    // Find which battery pack has this feature
896    let bp_name = if let Some(name) = battery_pack {
897        resolve_crate_name(name)
898    } else {
899        // Search all installed battery packs
900        let bp_names = find_installed_bp_names(&user_manifest_content)?;
901
902        let mut found = None;
903        for name in &bp_names {
904            let spec = fetch_battery_pack_spec(name)?;
905            if spec.features.contains_key(feature_name) {
906                found = Some(name.clone());
907                break;
908            }
909        }
910        found.ok_or_else(|| {
911            anyhow::anyhow!(
912                "No installed battery pack defines feature '{}'",
913                feature_name
914            )
915        })?
916    };
917
918    let bp_spec = fetch_battery_pack_spec(&bp_name)?;
919
920    if !bp_spec.features.contains_key(feature_name) {
921        let available: Vec<_> = bp_spec.features.keys().collect();
922        bail!(
923            "Battery pack '{}' has no feature '{}'. Available: {:?}",
924            bp_name,
925            feature_name,
926            available
927        );
928    }
929
930    // Add feature to active features
931    let metadata_location = resolve_metadata_location(&user_manifest_path)?;
932    let mut active_features =
933        read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
934    if active_features.contains(feature_name) {
935        println!(
936            "Feature '{}' is already active for {}.",
937            feature_name, bp_name
938        );
939        return Ok(());
940    }
941    active_features.insert(feature_name.to_string());
942
943    // Resolve what this changes
944    let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
945    let crates_to_sync = bp_spec.resolve_crates(&str_features);
946
947    // Update user manifest
948    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
949        .parse()
950        .context("Failed to parse Cargo.toml")?;
951
952    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
953
954    // Sync the new crates and update active features
955    if let Some(ref ws_path) = workspace_manifest {
956        let ws_content =
957            std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
958        let mut ws_doc: toml_edit::DocumentMut = ws_content
959            .parse()
960            .context("Failed to parse workspace Cargo.toml")?;
961
962        let ws_deps = ws_doc["workspace"]["dependencies"]
963            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
964        if let Some(ws_table) = ws_deps.as_table_mut() {
965            for (dep_name, dep_spec) in &crates_to_sync {
966                add_dep_to_table(ws_table, dep_name, dep_spec);
967            }
968        }
969
970        // If metadata lives in the workspace manifest, write features there too
971        if matches!(metadata_location, MetadataLocation::Workspace { .. }) {
972            write_bp_features_to_doc(
973                &mut ws_doc,
974                &["workspace", "metadata"],
975                &bp_name,
976                &active_features,
977            );
978        }
979
980        std::fs::write(ws_path, ws_doc.to_string())
981            .context("Failed to write workspace Cargo.toml")?;
982
983        // [impl cli.add.dep-kind]
984        write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, true);
985    } else {
986        // [impl cli.add.dep-kind]
987        write_deps_by_kind(&mut user_doc, &crates_to_sync, true);
988    }
989
990    // If metadata lives in the package manifest, write features there
991    if matches!(metadata_location, MetadataLocation::Package) {
992        write_bp_features_to_doc(
993            &mut user_doc,
994            &["package", "metadata"],
995            &bp_name,
996            &active_features,
997        );
998    }
999
1000    std::fs::write(&user_manifest_path, user_doc.to_string())
1001        .context("Failed to write Cargo.toml")?;
1002
1003    println!("Enabled feature '{}' from {}", feature_name, bp_name);
1004    Ok(())
1005}
1006
1007// ============================================================================
1008// Interactive crate picker
1009// ============================================================================
1010
1011/// Represents the result of an interactive crate selection.
1012struct PickerResult {
1013    /// The resolved crates to install (name -> dep spec with merged features).
1014    crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
1015    /// Which feature names are fully selected (for metadata recording).
1016    active_features: BTreeSet<String>,
1017}
1018
1019/// Show an interactive multi-select picker for choosing which crates to install.
1020///
1021/// Returns `None` if the user cancels. Returns `Some(PickerResult)` with the
1022/// selected crates and which sets are fully active.
1023fn pick_crates_interactive(
1024    bp_spec: &bphelper_manifest::BatteryPackSpec,
1025) -> Result<Option<PickerResult>> {
1026    use console::style;
1027    use dialoguer::MultiSelect;
1028
1029    let grouped = bp_spec.all_crates_with_grouping();
1030    if grouped.is_empty() {
1031        bail!("Battery pack has no crates to add");
1032    }
1033
1034    // Build display items and track which group each belongs to
1035    let mut labels = Vec::new();
1036    let mut defaults = Vec::new();
1037
1038    for (group, crate_name, dep, is_default) in &grouped {
1039        let version_info = if dep.features.is_empty() {
1040            format!("({})", dep.version)
1041        } else {
1042            format!(
1043                "({}, features: {})",
1044                dep.version,
1045                dep.features
1046                    .iter()
1047                    .map(|s| s.as_str())
1048                    .collect::<Vec<_>>()
1049                    .join(", ")
1050            )
1051        };
1052
1053        let group_label = if group == "default" {
1054            String::new()
1055        } else {
1056            format!(" [{}]", group)
1057        };
1058
1059        labels.push(format!(
1060            "{} {}{}",
1061            crate_name,
1062            style(&version_info).dim(),
1063            style(&group_label).cyan()
1064        ));
1065        defaults.push(*is_default);
1066    }
1067
1068    // Show the picker
1069    println!();
1070    println!(
1071        "  {} v{}",
1072        style(&bp_spec.name).green().bold(),
1073        style(&bp_spec.version).dim()
1074    );
1075    println!();
1076
1077    let selections = MultiSelect::new()
1078        .with_prompt("Select crates to add")
1079        .items(&labels)
1080        .defaults(&defaults)
1081        .interact_opt()
1082        .context("Failed to show crate picker")?;
1083
1084    let Some(selected_indices) = selections else {
1085        return Ok(None); // User cancelled
1086    };
1087
1088    // Build the result: resolve selected crates with proper feature merging
1089    let mut crates = BTreeMap::new();
1090
1091    for idx in &selected_indices {
1092        let (_group, crate_name, dep, _) = &grouped[*idx];
1093        // Start with base dep spec
1094        let merged = (*dep).clone();
1095
1096        crates.insert(crate_name.clone(), merged);
1097    }
1098
1099    // Determine which features are "fully selected" for metadata
1100    let mut active_features = BTreeSet::from(["default".to_string()]);
1101    for (feature_name, feature_crates) in &bp_spec.features {
1102        if feature_name == "default" {
1103            continue;
1104        }
1105        let all_selected = feature_crates.iter().all(|c| crates.contains_key(c));
1106        if all_selected {
1107            active_features.insert(feature_name.clone());
1108        }
1109    }
1110
1111    Ok(Some(PickerResult {
1112        crates,
1113        active_features,
1114    }))
1115}
1116
1117// ============================================================================
1118// Cargo.toml manipulation helpers
1119// ============================================================================
1120
1121/// Find the user's Cargo.toml in the given directory.
1122fn find_user_manifest(project_dir: &Path) -> Result<std::path::PathBuf> {
1123    let path = project_dir.join("Cargo.toml");
1124    if path.exists() {
1125        Ok(path)
1126    } else {
1127        bail!("No Cargo.toml found in {}", project_dir.display());
1128    }
1129}
1130
1131/// Extract battery pack crate names from a parsed Cargo.toml.
1132///
1133/// Filters `[build-dependencies]` for entries ending in `-battery-pack` or equal to `"battery-pack"`.
1134// [impl manifest.register.location]
1135pub fn find_installed_bp_names(manifest_content: &str) -> Result<Vec<String>> {
1136    let raw: toml::Value =
1137        toml::from_str(manifest_content).context("Failed to parse Cargo.toml")?;
1138
1139    let build_deps = raw
1140        .get("build-dependencies")
1141        .and_then(|bd| bd.as_table())
1142        .cloned()
1143        .unwrap_or_default();
1144
1145    Ok(build_deps
1146        .keys()
1147        .filter(|k| k.ends_with("-battery-pack") || *k == "battery-pack")
1148        .cloned()
1149        .collect())
1150}
1151
1152/// Find the workspace root Cargo.toml, if any.
1153/// Returns None if the crate is not in a workspace.
1154// [impl manifest.register.workspace-default]
1155// [impl manifest.register.both-levels]
1156fn find_workspace_manifest(crate_manifest: &Path) -> Result<Option<std::path::PathBuf>> {
1157    let parent = crate_manifest.parent().unwrap_or(Path::new("."));
1158    let parent = if parent.as_os_str().is_empty() {
1159        Path::new(".")
1160    } else {
1161        parent
1162    };
1163    let crate_dir = parent
1164        .canonicalize()
1165        .context("Failed to resolve crate directory")?;
1166
1167    // Walk up from the crate directory looking for a workspace root
1168    let mut dir = crate_dir.clone();
1169    loop {
1170        let candidate = dir.join("Cargo.toml");
1171        if candidate.exists() && candidate != crate_dir.join("Cargo.toml") {
1172            let content = std::fs::read_to_string(&candidate)?;
1173            if content.contains("[workspace]") {
1174                return Ok(Some(candidate));
1175            }
1176        }
1177        if !dir.pop() {
1178            break;
1179        }
1180    }
1181
1182    // Also check if the crate's own Cargo.toml has a [workspace] section
1183    // (single-crate workspace) — in that case we don't use workspace deps
1184    Ok(None)
1185}
1186
1187/// Return the TOML section name for a dependency kind.
1188fn dep_kind_section(kind: bphelper_manifest::DepKind) -> &'static str {
1189    match kind {
1190        bphelper_manifest::DepKind::Normal => "dependencies",
1191        bphelper_manifest::DepKind::Dev => "dev-dependencies",
1192        bphelper_manifest::DepKind::Build => "build-dependencies",
1193    }
1194}
1195
1196/// Write dependencies (with full version+features) to the correct sections by `dep_kind`.
1197///
1198/// When `if_missing` is true, only inserts crates that don't already exist in
1199/// the target section. Returns the number of crates actually written.
1200// [impl cli.add.dep-kind]
1201fn write_deps_by_kind(
1202    doc: &mut toml_edit::DocumentMut,
1203    crates: &BTreeMap<String, bphelper_manifest::CrateSpec>,
1204    if_missing: bool,
1205) -> usize {
1206    let mut written = 0;
1207    for (dep_name, dep_spec) in crates {
1208        let section = dep_kind_section(dep_spec.dep_kind);
1209        let table = doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1210        if let Some(table) = table.as_table_mut()
1211            && (!if_missing || !table.contains_key(dep_name))
1212        {
1213            add_dep_to_table(table, dep_name, dep_spec);
1214            written += 1;
1215        }
1216    }
1217    written
1218}
1219
1220/// Write workspace references (`{ workspace = true }`) to the correct
1221/// dependency sections based on each crate's `dep_kind`.
1222///
1223/// When `if_missing` is true, only inserts references for crates that don't
1224/// already exist in the target section. Returns the number of refs written.
1225// [impl cli.add.dep-kind]
1226fn write_workspace_refs_by_kind(
1227    doc: &mut toml_edit::DocumentMut,
1228    crates: &BTreeMap<String, bphelper_manifest::CrateSpec>,
1229    if_missing: bool,
1230) -> usize {
1231    let mut written = 0;
1232    for (dep_name, dep_spec) in crates {
1233        let section = dep_kind_section(dep_spec.dep_kind);
1234        let table = doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1235        if let Some(table) = table.as_table_mut()
1236            && (!if_missing || !table.contains_key(dep_name))
1237        {
1238            let mut dep = toml_edit::InlineTable::new();
1239            dep.insert("workspace", toml_edit::Value::from(true));
1240            table.insert(
1241                dep_name,
1242                toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
1243            );
1244            written += 1;
1245        }
1246    }
1247    written
1248}
1249
1250/// Add a dependency to a toml_edit table (non-workspace mode).
1251// [impl manifest.deps.add]
1252// [impl manifest.deps.version-features]
1253// [impl manifest.toml.style]
1254// [impl cli.add.idempotent]
1255pub fn add_dep_to_table(
1256    table: &mut toml_edit::Table,
1257    name: &str,
1258    spec: &bphelper_manifest::CrateSpec,
1259) {
1260    if spec.features.is_empty() {
1261        table.insert(name, toml_edit::value(&spec.version));
1262    } else {
1263        let mut dep = toml_edit::InlineTable::new();
1264        dep.insert("version", toml_edit::Value::from(spec.version.as_str()));
1265        let mut features = toml_edit::Array::new();
1266        for feat in &spec.features {
1267            features.push(feat.as_str());
1268        }
1269        dep.insert("features", toml_edit::Value::Array(features));
1270        table.insert(
1271            name,
1272            toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
1273        );
1274    }
1275}
1276
1277/// Return true when `recommended` is strictly newer than `current` (semver).
1278///
1279/// Falls back to string equality when either side is not a valid semver
1280/// version, so non-standard version strings still get updated when they
1281/// differ.
1282fn should_upgrade_version(current: &str, recommended: &str) -> bool {
1283    match (
1284        semver::Version::parse(current)
1285            .or_else(|_| semver::Version::parse(&format!("{}.0", current)))
1286            .or_else(|_| semver::Version::parse(&format!("{}.0.0", current))),
1287        semver::Version::parse(recommended)
1288            .or_else(|_| semver::Version::parse(&format!("{}.0", recommended)))
1289            .or_else(|_| semver::Version::parse(&format!("{}.0.0", recommended))),
1290    ) {
1291        // [impl manifest.sync.version-bump]
1292        (Ok(cur), Ok(rec)) => rec > cur,
1293        // Non-parsable: fall back to "update if different"
1294        _ => current != recommended,
1295    }
1296}
1297
1298/// Sync a dependency in-place: update version if behind, add missing features.
1299/// Returns true if changes were made.
1300// [impl manifest.deps.existing]
1301// [impl manifest.toml.style]
1302pub fn sync_dep_in_table(
1303    table: &mut toml_edit::Table,
1304    name: &str,
1305    spec: &bphelper_manifest::CrateSpec,
1306) -> bool {
1307    let Some(existing) = table.get_mut(name) else {
1308        // Not present — add it
1309        add_dep_to_table(table, name, spec);
1310        return true;
1311    };
1312
1313    let mut changed = false;
1314
1315    match existing {
1316        toml_edit::Item::Value(toml_edit::Value::String(version_str)) => {
1317            // Simple version string — check if we need to upgrade
1318            let current = version_str.value().to_string();
1319            // [impl manifest.sync.version-bump]
1320            if !spec.version.is_empty() && should_upgrade_version(&current, &spec.version) {
1321                *version_str = toml_edit::Formatted::new(spec.version.clone());
1322                changed = true;
1323            }
1324            // [impl manifest.sync.feature-add]
1325            if !spec.features.is_empty() {
1326                // Need to convert from simple string to table format;
1327                // use the higher of the two versions so we never downgrade.
1328                let keep_version = if !spec.version.is_empty()
1329                    && should_upgrade_version(&current, &spec.version)
1330                {
1331                    spec.version.clone()
1332                } else {
1333                    current.clone()
1334                };
1335                let patched = bphelper_manifest::CrateSpec {
1336                    version: keep_version,
1337                    features: spec.features.clone(),
1338                    dep_kind: spec.dep_kind,
1339                    optional: spec.optional,
1340                };
1341                add_dep_to_table(table, name, &patched);
1342                changed = true;
1343            }
1344        }
1345        toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)) => {
1346            // Check version
1347            // [impl manifest.sync.version-bump]
1348            if let Some(toml_edit::Value::String(v)) = inline.get_mut("version")
1349                && !spec.version.is_empty()
1350                && should_upgrade_version(v.value(), &spec.version)
1351            {
1352                *v = toml_edit::Formatted::new(spec.version.clone());
1353                changed = true;
1354            }
1355            // [impl manifest.sync.feature-add]
1356            // Check features — add missing ones, never remove existing
1357            if !spec.features.is_empty() {
1358                let existing_features: Vec<String> = inline
1359                    .get("features")
1360                    .and_then(|f| f.as_array())
1361                    .map(|arr| {
1362                        arr.iter()
1363                            .filter_map(|v| v.as_str().map(String::from))
1364                            .collect()
1365                    })
1366                    .unwrap_or_default();
1367
1368                let mut needs_update = false;
1369                let existing_set: BTreeSet<&str> =
1370                    existing_features.iter().map(|s| s.as_str()).collect();
1371                let mut all_features = existing_features.clone();
1372                for feat in &spec.features {
1373                    if !existing_set.contains(feat.as_str()) {
1374                        all_features.push(feat.clone());
1375                        needs_update = true;
1376                    }
1377                }
1378
1379                if needs_update {
1380                    let mut arr = toml_edit::Array::new();
1381                    for f in &all_features {
1382                        arr.push(f.as_str());
1383                    }
1384                    inline.insert("features", toml_edit::Value::Array(arr));
1385                    changed = true;
1386                }
1387            }
1388        }
1389        toml_edit::Item::Table(tbl) => {
1390            // Multi-line `dependencies.name` table
1391            // [impl manifest.sync.version-bump]
1392            if let Some(toml_edit::Item::Value(toml_edit::Value::String(v))) =
1393                tbl.get_mut("version")
1394                && !spec.version.is_empty()
1395                && should_upgrade_version(v.value(), &spec.version)
1396            {
1397                *v = toml_edit::Formatted::new(spec.version.clone());
1398                changed = true;
1399            }
1400            // [impl manifest.sync.feature-add]
1401            if !spec.features.is_empty() {
1402                let existing_features: Vec<String> = tbl
1403                    .get("features")
1404                    .and_then(|f| f.as_value())
1405                    .and_then(|v| v.as_array())
1406                    .map(|arr| {
1407                        arr.iter()
1408                            .filter_map(|v| v.as_str().map(String::from))
1409                            .collect()
1410                    })
1411                    .unwrap_or_default();
1412
1413                let existing_set: BTreeSet<&str> =
1414                    existing_features.iter().map(|s| s.as_str()).collect();
1415                let mut all_features = existing_features.clone();
1416                let mut needs_update = false;
1417                for feat in &spec.features {
1418                    if !existing_set.contains(feat.as_str()) {
1419                        all_features.push(feat.clone());
1420                        needs_update = true;
1421                    }
1422                }
1423
1424                if needs_update {
1425                    let mut arr = toml_edit::Array::new();
1426                    for f in &all_features {
1427                        arr.push(f.as_str());
1428                    }
1429                    tbl.insert(
1430                        "features",
1431                        toml_edit::Item::Value(toml_edit::Value::Array(arr)),
1432                    );
1433                    changed = true;
1434                }
1435            }
1436        }
1437        _ => {}
1438    }
1439
1440    changed
1441}
1442
1443/// Read active features from a parsed TOML value at a given path prefix.
1444///
1445/// `prefix` is `&["package", "metadata"]` for package metadata or
1446/// `&["workspace", "metadata"]` for workspace metadata.
1447// [impl manifest.features.storage]
1448fn read_features_at(raw: &toml::Value, prefix: &[&str], bp_name: &str) -> BTreeSet<String> {
1449    let mut node = Some(raw);
1450    for key in prefix {
1451        node = node.and_then(|n| n.get(key));
1452    }
1453    node.and_then(|m| m.get("battery-pack"))
1454        .and_then(|bp| bp.get(bp_name))
1455        .and_then(|entry| entry.get("features"))
1456        .and_then(|sets| sets.as_array())
1457        .map(|arr| {
1458            arr.iter()
1459                .filter_map(|v| v.as_str().map(String::from))
1460                .collect()
1461        })
1462        .unwrap_or_else(|| BTreeSet::from(["default".to_string()]))
1463}
1464
1465/// Read active features for a battery pack from user's package metadata.
1466pub fn read_active_features(manifest_content: &str, bp_name: &str) -> BTreeSet<String> {
1467    let raw: toml::Value = match toml::from_str(manifest_content) {
1468        Ok(v) => v,
1469        Err(_) => return BTreeSet::from(["default".to_string()]),
1470    };
1471    read_features_at(&raw, &["package", "metadata"], bp_name)
1472}
1473
1474// ============================================================================
1475// Metadata location abstraction
1476// ============================================================================
1477
1478/// Where battery-pack metadata (registrations, active features) is stored.
1479///
1480/// `add_battery_pack` writes to either `package.metadata` or `workspace.metadata`
1481/// depending on the `--target` flag. All other commands (sync, enable, load) must
1482/// read from the same location, so they use `resolve_metadata_location` to detect
1483/// where metadata currently lives.
1484#[derive(Debug, Clone)]
1485enum MetadataLocation {
1486    /// `package.metadata.battery-pack` in the user manifest.
1487    Package,
1488    /// `workspace.metadata.battery-pack` in the workspace manifest.
1489    Workspace { ws_manifest_path: PathBuf },
1490}
1491
1492/// Determine where battery-pack metadata lives for this project.
1493///
1494/// If a workspace manifest exists AND already contains
1495/// `workspace.metadata.battery-pack`, returns `Workspace`.
1496/// Otherwise returns `Package`.
1497fn resolve_metadata_location(user_manifest_path: &Path) -> Result<MetadataLocation> {
1498    if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
1499        let ws_content =
1500            std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
1501        let raw: toml::Value =
1502            toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
1503        if raw
1504            .get("workspace")
1505            .and_then(|w| w.get("metadata"))
1506            .and_then(|m| m.get("battery-pack"))
1507            .is_some()
1508        {
1509            return Ok(MetadataLocation::Workspace {
1510                ws_manifest_path: ws_path,
1511            });
1512        }
1513    }
1514    Ok(MetadataLocation::Package)
1515}
1516
1517/// Read active features for a battery pack, respecting metadata location.
1518///
1519/// Dispatches to `read_active_features` (package) or `read_active_features_ws`
1520/// (workspace) based on the resolved location.
1521fn read_active_features_from(
1522    location: &MetadataLocation,
1523    user_manifest_content: &str,
1524    bp_name: &str,
1525) -> BTreeSet<String> {
1526    match location {
1527        MetadataLocation::Package => read_active_features(user_manifest_content, bp_name),
1528        MetadataLocation::Workspace { ws_manifest_path } => {
1529            let ws_content = match std::fs::read_to_string(ws_manifest_path) {
1530                Ok(c) => c,
1531                Err(_) => return BTreeSet::from(["default".to_string()]),
1532            };
1533            read_active_features_ws(&ws_content, bp_name)
1534        }
1535    }
1536}
1537
1538/// Read active features from `workspace.metadata.battery-pack[bp_name].features`.
1539pub fn read_active_features_ws(ws_content: &str, bp_name: &str) -> BTreeSet<String> {
1540    let raw: toml::Value = match toml::from_str(ws_content) {
1541        Ok(v) => v,
1542        Err(_) => return BTreeSet::from(["default".to_string()]),
1543    };
1544    read_features_at(&raw, &["workspace", "metadata"], bp_name)
1545}
1546
1547/// Write a features array into a `toml_edit::DocumentMut` at a given path prefix.
1548///
1549/// `path_prefix` is `["package", "metadata"]` for package metadata or
1550/// `["workspace", "metadata"]` for workspace metadata.
1551fn write_bp_features_to_doc(
1552    doc: &mut toml_edit::DocumentMut,
1553    path_prefix: &[&str],
1554    bp_name: &str,
1555    active_features: &BTreeSet<String>,
1556) {
1557    let mut features_array = toml_edit::Array::new();
1558    for feature in active_features {
1559        features_array.push(feature.as_str());
1560    }
1561
1562    // Ensure intermediate tables exist (nested, not top-level)
1563    // path_prefix is e.g. ["package", "metadata"] or ["workspace", "metadata"]
1564    doc[path_prefix[0]].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1565    doc[path_prefix[0]][path_prefix[1]].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1566    doc[path_prefix[0]][path_prefix[1]]["battery-pack"]
1567        .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1568
1569    let bp_meta = &mut doc[path_prefix[0]][path_prefix[1]]["battery-pack"][bp_name];
1570    *bp_meta = toml_edit::Item::Table(toml_edit::Table::new());
1571    bp_meta["features"] = toml_edit::value(features_array);
1572}
1573
1574/// Resolve the manifest path for a battery pack using `cargo metadata`.
1575///
1576/// Works for any dependency source: path deps, registry deps, git deps.
1577/// The battery pack must already be in [build-dependencies].
1578fn resolve_battery_pack_manifest(bp_name: &str) -> Result<std::path::PathBuf> {
1579    let metadata = cargo_metadata::MetadataCommand::new()
1580        .exec()
1581        .context("Failed to run `cargo metadata`")?;
1582
1583    let package = metadata
1584        .packages
1585        .iter()
1586        .find(|p| p.name == bp_name)
1587        .ok_or_else(|| {
1588            anyhow::anyhow!(
1589                "Battery pack '{}' not found in dependency graph. Is it in [build-dependencies]?",
1590                bp_name
1591            )
1592        })?;
1593
1594    Ok(package.manifest_path.clone().into())
1595}
1596
1597/// Fetch battery pack spec, respecting `--path` and `--crate-source`.
1598///
1599/// - `--path` loads directly from the given directory (no name resolution).
1600/// - `--crate-source` resolves the name within the local workspace.
1601/// - Default (Registry) uses `cargo metadata` to find the already-installed crate.
1602// [impl cli.path.flag]
1603// [impl cli.path.no-resolve]
1604// [impl cli.source.replace]
1605fn load_installed_bp_spec(
1606    bp_name: &str,
1607    path: Option<&str>,
1608    source: &CrateSource,
1609) -> Result<bphelper_manifest::BatteryPackSpec> {
1610    if let Some(local_path) = path {
1611        let manifest_path = Path::new(local_path).join("Cargo.toml");
1612        let manifest_content = std::fs::read_to_string(&manifest_path)
1613            .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1614        return bphelper_manifest::parse_battery_pack(&manifest_content)
1615            .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e));
1616    }
1617    match source {
1618        CrateSource::Registry => fetch_battery_pack_spec(bp_name),
1619        CrateSource::Local(_) => {
1620            let (_version, spec) = fetch_bp_spec(source, bp_name)?;
1621            Ok(spec)
1622        }
1623    }
1624}
1625
1626/// Fetch the battery pack spec using `cargo metadata` to locate the manifest.
1627fn fetch_battery_pack_spec(bp_name: &str) -> Result<bphelper_manifest::BatteryPackSpec> {
1628    let manifest_path = resolve_battery_pack_manifest(bp_name)?;
1629    let manifest_content = std::fs::read_to_string(&manifest_path)
1630        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1631
1632    bphelper_manifest::parse_battery_pack(&manifest_content)
1633        .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e))
1634}
1635
1636/// Download a battery pack from crates.io and parse its spec.
1637///
1638/// Unlike `fetch_battery_pack_spec` (which uses cargo metadata and requires the
1639/// crate to already be a build-dependency), this downloads from the registry
1640/// directly. Returns `(version, spec)`.
1641pub(crate) fn fetch_bp_spec_from_registry(
1642    crate_name: &str,
1643) -> Result<(String, bphelper_manifest::BatteryPackSpec)> {
1644    let crate_info = lookup_crate(crate_name)?;
1645    let temp_dir = download_and_extract_crate(crate_name, &crate_info.version)?;
1646    let crate_dir = temp_dir
1647        .path()
1648        .join(format!("{}-{}", crate_name, crate_info.version));
1649
1650    let manifest_path = crate_dir.join("Cargo.toml");
1651    let manifest_content = std::fs::read_to_string(&manifest_path)
1652        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1653
1654    let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
1655        .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
1656
1657    Ok((crate_info.version, spec))
1658}
1659
1660// ============================================================================
1661// build.rs manipulation
1662// ============================================================================
1663
1664/// Update or create build.rs to include a validate() call.
1665fn update_build_rs(build_rs_path: &Path, crate_name: &str) -> Result<()> {
1666    let crate_ident = crate_name.replace('-', "_");
1667    let validate_call = format!("{}::validate();", crate_ident);
1668
1669    if build_rs_path.exists() {
1670        let content = std::fs::read_to_string(build_rs_path).context("Failed to read build.rs")?;
1671
1672        // Check if validate call is already present
1673        if content.contains(&validate_call) {
1674            return Ok(());
1675        }
1676
1677        // Verify the file parses as valid Rust with syn
1678        let file: syn::File = syn::parse_str(&content).context("Failed to parse build.rs")?;
1679
1680        // Check that a main function exists
1681        let has_main = file
1682            .items
1683            .iter()
1684            .any(|item| matches!(item, syn::Item::Fn(func) if func.sig.ident == "main"));
1685
1686        if has_main {
1687            // Find the closing brace of main using string manipulation
1688            let lines: Vec<&str> = content.lines().collect();
1689            let mut insert_line = None;
1690            let mut brace_depth: i32 = 0;
1691            let mut in_main = false;
1692
1693            for (i, line) in lines.iter().enumerate() {
1694                if line.contains("fn main") {
1695                    in_main = true;
1696                    brace_depth = 0;
1697                }
1698                if in_main {
1699                    for ch in line.chars() {
1700                        if ch == '{' {
1701                            brace_depth += 1;
1702                        } else if ch == '}' {
1703                            brace_depth -= 1;
1704                            if brace_depth == 0 {
1705                                insert_line = Some(i);
1706                                in_main = false;
1707                                break;
1708                            }
1709                        }
1710                    }
1711                }
1712            }
1713
1714            if let Some(line_idx) = insert_line {
1715                let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1716                new_lines.insert(line_idx, format!("    {}", validate_call));
1717                std::fs::write(build_rs_path, new_lines.join("\n") + "\n")
1718                    .context("Failed to write build.rs")?;
1719                return Ok(());
1720            }
1721        }
1722
1723        // Fallback: no main function found or couldn't locate closing brace
1724        bail!(
1725            "Could not find fn main() in build.rs. Please add `{}` manually.",
1726            validate_call
1727        );
1728    } else {
1729        // Create new build.rs
1730        let content = format!("fn main() {{\n    {}\n}}\n", validate_call);
1731        std::fs::write(build_rs_path, content).context("Failed to create build.rs")?;
1732    }
1733
1734    Ok(())
1735}
1736
1737fn generate_from_local(
1738    local_path: &str,
1739    name: Option<String>,
1740    template: Option<String>,
1741) -> Result<()> {
1742    let local_path = Path::new(local_path);
1743
1744    // Read local Cargo.toml
1745    let manifest_path = local_path.join("Cargo.toml");
1746    let manifest_content = std::fs::read_to_string(&manifest_path)
1747        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1748
1749    let crate_name = local_path
1750        .file_name()
1751        .and_then(|s| s.to_str())
1752        .unwrap_or("unknown");
1753    let templates = parse_template_metadata(&manifest_content, crate_name)?;
1754    let template_path = resolve_template(&templates, template.as_deref())?;
1755
1756    generate_from_path(local_path, &template_path, name)
1757}
1758
1759/// Resolve the project name, prompting interactively if not provided, and
1760/// ensure it ends with `-battery-pack`.
1761fn ensure_battery_pack_suffix(name: Option<String>) -> Result<String> {
1762    let raw = match name {
1763        Some(n) => n,
1764        None => dialoguer::Input::<String>::new()
1765            .with_prompt("Project name")
1766            .interact_text()
1767            .context("Failed to read project name")?,
1768    };
1769    if raw.ends_with("-battery-pack") {
1770        Ok(raw)
1771    } else {
1772        let fixed = format!("{}-battery-pack", raw);
1773        println!("Renaming project to: {}", fixed);
1774        Ok(fixed)
1775    }
1776}
1777
1778fn generate_from_path(crate_path: &Path, template_path: &str, name: Option<String>) -> Result<()> {
1779    // Ensure the project name ends with -battery-pack.
1780    // We always resolve the name before calling cargo-generate so the suffix
1781    // applies to both the directory name and the project-name variable.
1782    let name = Some(ensure_battery_pack_suffix(name)?);
1783
1784    // In non-interactive mode, provide defaults for placeholders
1785    let define = if !std::io::stdout().is_terminal() {
1786        vec!["description=A battery pack for ...".to_string()]
1787    } else {
1788        vec![]
1789    };
1790
1791    let args = GenerateArgs {
1792        template_path: TemplatePath {
1793            path: Some(crate_path.to_string_lossy().into_owned()),
1794            auto_path: Some(template_path.to_string()),
1795            ..Default::default()
1796        },
1797        name,
1798        vcs: Some(Vcs::Git),
1799        define,
1800        ..Default::default()
1801    };
1802
1803    cargo_generate::generate(args)?;
1804
1805    Ok(())
1806}
1807
1808/// Info about a crate from crates.io
1809struct CrateMetadata {
1810    version: String,
1811}
1812
1813/// Look up a crate on crates.io and return its metadata
1814fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
1815    let client = http_client();
1816
1817    let url = format!("{}/{}", CRATES_IO_API, crate_name);
1818    let response = client
1819        .get(&url)
1820        .send()
1821        .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
1822
1823    if !response.status().is_success() {
1824        bail!(
1825            "Crate '{}' not found on crates.io (status: {})",
1826            crate_name,
1827            response.status()
1828        );
1829    }
1830
1831    let parsed: CratesIoResponse = response
1832        .json()
1833        .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
1834
1835    // Find the latest non-yanked version
1836    let version = parsed
1837        .versions
1838        .iter()
1839        .find(|v| !v.yanked)
1840        .map(|v| v.num.clone())
1841        .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
1842
1843    Ok(CrateMetadata { version })
1844}
1845
1846/// Download a crate tarball and extract it to a temp directory
1847fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
1848    let client = http_client();
1849
1850    // Download from CDN: https://static.crates.io/crates/{name}/{name}-{version}.crate
1851    let url = format!(
1852        "{}/{}/{}-{}.crate",
1853        CRATES_IO_CDN, crate_name, crate_name, version
1854    );
1855
1856    let response = client
1857        .get(&url)
1858        .send()
1859        .with_context(|| format!("Failed to download crate from {}", url))?;
1860
1861    if !response.status().is_success() {
1862        bail!(
1863            "Failed to download '{}' version {} (status: {})",
1864            crate_name,
1865            version,
1866            response.status()
1867        );
1868    }
1869
1870    let bytes = response
1871        .bytes()
1872        .with_context(|| "Failed to read crate tarball")?;
1873
1874    // Create temp directory and extract
1875    let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
1876
1877    let decoder = GzDecoder::new(&bytes[..]);
1878    let mut archive = Archive::new(decoder);
1879    archive
1880        .unpack(temp_dir.path())
1881        .with_context(|| "Failed to extract crate tarball")?;
1882
1883    Ok(temp_dir)
1884}
1885
1886fn parse_template_metadata(
1887    manifest_content: &str,
1888    crate_name: &str,
1889) -> Result<BTreeMap<String, TemplateConfig>> {
1890    let spec = bphelper_manifest::parse_battery_pack(manifest_content)
1891        .map_err(|e| anyhow::anyhow!("Failed to parse Cargo.toml: {}", e))?;
1892
1893    if spec.templates.is_empty() {
1894        bail!(
1895            "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
1896            crate_name
1897        );
1898    }
1899
1900    Ok(spec.templates)
1901}
1902
1903// [impl format.templates.selection]
1904// [impl cli.new.template-select]
1905pub fn resolve_template(
1906    templates: &BTreeMap<String, TemplateConfig>,
1907    requested: Option<&str>,
1908) -> Result<String> {
1909    match requested {
1910        Some(name) => {
1911            let config = templates.get(name).ok_or_else(|| {
1912                let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
1913                anyhow::anyhow!(
1914                    "Template '{}' not found. Available templates: {}",
1915                    name,
1916                    available.join(", ")
1917                )
1918            })?;
1919            Ok(config.path.clone())
1920        }
1921        None => {
1922            if templates.len() == 1 {
1923                // Only one template, use it
1924                let (_, config) = templates.iter().next().unwrap();
1925                Ok(config.path.clone())
1926            } else if let Some(config) = templates.get("default") {
1927                // Multiple templates, but there's a 'default'
1928                Ok(config.path.clone())
1929            } else {
1930                // Multiple templates, no default - prompt user to pick
1931                prompt_for_template(templates)
1932            }
1933        }
1934    }
1935}
1936
1937fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
1938    use dialoguer::{Select, theme::ColorfulTheme};
1939
1940    // Build display items with descriptions
1941    let items: Vec<String> = templates
1942        .iter()
1943        .map(|(name, config)| {
1944            if let Some(desc) = &config.description {
1945                format!("{} - {}", name, desc)
1946            } else {
1947                name.clone()
1948            }
1949        })
1950        .collect();
1951
1952    // Check if we're in a TTY for interactive mode
1953    if !std::io::stdout().is_terminal() {
1954        // Non-interactive: list templates and bail
1955        println!("Available templates:");
1956        for item in &items {
1957            println!("  {}", item);
1958        }
1959        bail!("Multiple templates available. Please specify one with --template <name>");
1960    }
1961
1962    // Interactive: show selector
1963    let selection = Select::with_theme(&ColorfulTheme::default())
1964        .with_prompt("Select a template")
1965        .items(&items)
1966        .default(0)
1967        .interact()
1968        .context("Failed to select template")?;
1969
1970    // Get the selected template's path
1971    let (_, config) = templates
1972        .iter()
1973        .nth(selection)
1974        .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
1975    Ok(config.path.clone())
1976}
1977
1978/// Info about an installed battery pack — its spec plus which crates are currently enabled.
1979pub struct InstalledPack {
1980    pub name: String,
1981    pub short_name: String,
1982    pub version: String,
1983    pub spec: bphelper_manifest::BatteryPackSpec,
1984    pub active_features: BTreeSet<String>,
1985}
1986
1987/// Load all installed battery packs with their specs and active features.
1988///
1989/// Reads `[build-dependencies]` from the user's Cargo.toml, fetches each
1990/// battery pack's spec via cargo metadata, and reads active features from
1991/// `package.metadata.battery-pack`.
1992pub fn load_installed_packs(project_dir: &Path) -> Result<Vec<InstalledPack>> {
1993    let user_manifest_path = find_user_manifest(project_dir)?;
1994    let user_manifest_content =
1995        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
1996
1997    let bp_names = find_installed_bp_names(&user_manifest_content)?;
1998    let metadata_location = resolve_metadata_location(&user_manifest_path)?;
1999
2000    let mut packs = Vec::new();
2001    for bp_name in bp_names {
2002        let spec = fetch_battery_pack_spec(&bp_name)?;
2003        let active_features =
2004            read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
2005        packs.push(InstalledPack {
2006            short_name: short_name(&bp_name).to_string(),
2007            version: spec.version.clone(),
2008            spec,
2009            name: bp_name,
2010            active_features,
2011        });
2012    }
2013
2014    Ok(packs)
2015}
2016
2017/// Fetch battery pack list, dispatching based on source.
2018pub fn fetch_battery_pack_list(
2019    source: &CrateSource,
2020    filter: Option<&str>,
2021) -> Result<Vec<BatteryPackSummary>> {
2022    match source {
2023        CrateSource::Registry => fetch_battery_pack_list_from_registry(filter),
2024        CrateSource::Local(path) => discover_local_battery_packs(path, filter),
2025    }
2026}
2027
2028fn fetch_battery_pack_list_from_registry(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
2029    let client = http_client();
2030
2031    // Build the search URL with keyword filter
2032    let url = match filter {
2033        Some(q) => format!(
2034            "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
2035            urlencoding::encode(q)
2036        ),
2037        None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
2038    };
2039
2040    let response = client
2041        .get(&url)
2042        .send()
2043        .context("Failed to query crates.io")?;
2044
2045    if !response.status().is_success() {
2046        bail!(
2047            "Failed to list battery packs (status: {})",
2048            response.status()
2049        );
2050    }
2051
2052    let parsed: SearchResponse = response.json().context("Failed to parse response")?;
2053
2054    // Filter to only crates whose name ends with "-battery-pack"
2055    let battery_packs = parsed
2056        .crates
2057        .into_iter()
2058        .filter(|c| c.name.ends_with("-battery-pack"))
2059        .map(|c| BatteryPackSummary {
2060            short_name: short_name(&c.name).to_string(),
2061            name: c.name,
2062            version: c.max_version,
2063            description: c.description.unwrap_or_default(),
2064        })
2065        .collect();
2066
2067    Ok(battery_packs)
2068}
2069
2070// [impl cli.source.discover]
2071fn discover_local_battery_packs(
2072    workspace_dir: &Path,
2073    filter: Option<&str>,
2074) -> Result<Vec<BatteryPackSummary>> {
2075    let manifest_path = workspace_dir.join("Cargo.toml");
2076    let metadata = cargo_metadata::MetadataCommand::new()
2077        .manifest_path(&manifest_path)
2078        .no_deps()
2079        .exec()
2080        .with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
2081
2082    let mut battery_packs: Vec<BatteryPackSummary> = metadata
2083        .packages
2084        .iter()
2085        .filter(|pkg| pkg.name.ends_with("-battery-pack"))
2086        .filter(|pkg| {
2087            if let Some(q) = filter {
2088                short_name(&pkg.name).contains(q)
2089            } else {
2090                true
2091            }
2092        })
2093        .map(|pkg| BatteryPackSummary {
2094            short_name: short_name(&pkg.name).to_string(),
2095            name: pkg.name.to_string(),
2096            version: pkg.version.to_string(),
2097            description: pkg.description.clone().unwrap_or_default(),
2098        })
2099        .collect();
2100
2101    battery_packs.sort_by(|a, b| a.name.cmp(&b.name));
2102    Ok(battery_packs)
2103}
2104
2105/// Find a specific battery pack's directory within a local workspace.
2106fn find_local_battery_pack_dir(workspace_dir: &Path, crate_name: &str) -> Result<PathBuf> {
2107    let manifest_path = workspace_dir.join("Cargo.toml");
2108    let metadata = cargo_metadata::MetadataCommand::new()
2109        .manifest_path(&manifest_path)
2110        .no_deps()
2111        .exec()
2112        .with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
2113
2114    let package = metadata
2115        .packages
2116        .iter()
2117        .find(|p| p.name == crate_name)
2118        .ok_or_else(|| {
2119            anyhow::anyhow!(
2120                "Battery pack '{}' not found in workspace at {}",
2121                crate_name,
2122                workspace_dir.display()
2123            )
2124        })?;
2125
2126    Ok(package
2127        .manifest_path
2128        .parent()
2129        .expect("manifest path should have a parent")
2130        .into())
2131}
2132
2133/// Fetch a battery pack's spec, dispatching based on source.
2134///
2135/// Returns `(version, spec)` — version is `None` for local sources.
2136// [impl cli.source.replace]
2137pub(crate) fn fetch_bp_spec(
2138    source: &CrateSource,
2139    name: &str,
2140) -> Result<(Option<String>, bphelper_manifest::BatteryPackSpec)> {
2141    let crate_name = resolve_crate_name(name);
2142    match source {
2143        CrateSource::Registry => {
2144            let (version, spec) = fetch_bp_spec_from_registry(&crate_name)?;
2145            Ok((Some(version), spec))
2146        }
2147        CrateSource::Local(workspace_dir) => {
2148            let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
2149            let manifest_path = crate_dir.join("Cargo.toml");
2150            let manifest_content = std::fs::read_to_string(&manifest_path)
2151                .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2152            let spec = bphelper_manifest::parse_battery_pack(&manifest_content).map_err(|e| {
2153                anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e)
2154            })?;
2155            Ok((None, spec))
2156        }
2157    }
2158}
2159
2160/// Fetch detailed battery pack info, dispatching based on source.
2161// [impl cli.source.replace]
2162pub(crate) fn fetch_battery_pack_detail_from_source(
2163    source: &CrateSource,
2164    name: &str,
2165) -> Result<BatteryPackDetail> {
2166    match source {
2167        CrateSource::Registry => fetch_battery_pack_detail(name, None),
2168        CrateSource::Local(workspace_dir) => {
2169            let crate_name = resolve_crate_name(name);
2170            let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
2171            fetch_battery_pack_detail_from_path(&crate_dir.to_string_lossy())
2172        }
2173    }
2174}
2175
2176fn print_battery_pack_list(source: &CrateSource, filter: Option<&str>) -> Result<()> {
2177    use console::style;
2178
2179    let battery_packs = fetch_battery_pack_list(source, filter)?;
2180
2181    if battery_packs.is_empty() {
2182        match filter {
2183            Some(q) => println!("No battery packs found matching '{}'", q),
2184            None => println!("No battery packs found"),
2185        }
2186        return Ok(());
2187    }
2188
2189    // Find the longest name for alignment
2190    let max_name_len = battery_packs
2191        .iter()
2192        .map(|c| c.short_name.len())
2193        .max()
2194        .unwrap_or(0);
2195
2196    let max_version_len = battery_packs
2197        .iter()
2198        .map(|c| c.version.len())
2199        .max()
2200        .unwrap_or(0);
2201
2202    println!();
2203    for bp in &battery_packs {
2204        let desc = bp.description.lines().next().unwrap_or("");
2205
2206        // Pad strings manually, then apply colors (ANSI codes break width formatting)
2207        let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
2208        let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
2209
2210        println!(
2211            "  {}  {}  {}",
2212            style(name_padded).green().bold(),
2213            style(ver_padded).dim(),
2214            desc,
2215        );
2216    }
2217    println!();
2218
2219    println!(
2220        "{}",
2221        style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
2222    );
2223
2224    Ok(())
2225}
2226
2227/// Convert "cli-battery-pack" to "cli" for display
2228fn short_name(crate_name: &str) -> &str {
2229    crate_name
2230        .strip_suffix("-battery-pack")
2231        .unwrap_or(crate_name)
2232}
2233
2234/// Convert "cli" to "cli-battery-pack" (adds suffix if not already present)
2235/// Special case: "battery-pack" stays as "battery-pack" (not "battery-pack-battery-pack")
2236// [impl cli.name.resolve]
2237// [impl cli.name.exact]
2238fn resolve_crate_name(name: &str) -> String {
2239    if name == "battery-pack" || name.ends_with("-battery-pack") {
2240        name.to_string()
2241    } else {
2242        format!("{}-battery-pack", name)
2243    }
2244}
2245
2246/// Fetch detailed battery pack info from crates.io or a local path
2247pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
2248    // If path is provided, use local directory
2249    if let Some(local_path) = path {
2250        return fetch_battery_pack_detail_from_path(local_path);
2251    }
2252
2253    let crate_name = resolve_crate_name(name);
2254
2255    // Look up crate info and download
2256    let crate_info = lookup_crate(&crate_name)?;
2257    let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
2258    let crate_dir = temp_dir
2259        .path()
2260        .join(format!("{}-{}", crate_name, crate_info.version));
2261
2262    // Parse the battery pack spec
2263    let manifest_path = crate_dir.join("Cargo.toml");
2264    let manifest_content = std::fs::read_to_string(&manifest_path)
2265        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2266    let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
2267        .map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
2268
2269    // Fetch owners from crates.io
2270    let owners = fetch_owners(&crate_name)?;
2271
2272    build_battery_pack_detail(&crate_dir, &spec, owners)
2273}
2274
2275/// Fetch detailed battery pack info from a local path
2276fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
2277    let crate_dir = std::path::Path::new(path);
2278    let manifest_path = crate_dir.join("Cargo.toml");
2279    let manifest_content = std::fs::read_to_string(&manifest_path)
2280        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2281
2282    let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
2283        .map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
2284
2285    build_battery_pack_detail(crate_dir, &spec, Vec::new())
2286}
2287
2288/// Build `BatteryPackDetail` from a parsed `BatteryPackSpec`.
2289///
2290/// Derives extends/crates from the spec's crate keys, fetches repo tree for
2291/// template path resolution, and scans for examples.
2292fn build_battery_pack_detail(
2293    crate_dir: &Path,
2294    spec: &bphelper_manifest::BatteryPackSpec,
2295    owners: Vec<Owner>,
2296) -> Result<BatteryPackDetail> {
2297    // Split visible (non-hidden) crate keys into battery packs (extends) and regular crates
2298    // [impl format.hidden.effect]
2299    let (extends_raw, crates_raw): (Vec<_>, Vec<_>) = spec
2300        .visible_crates()
2301        .into_keys()
2302        .partition(|d| d.ends_with("-battery-pack"));
2303
2304    let extends: Vec<String> = extends_raw
2305        .into_iter()
2306        .map(|d| short_name(d).to_string())
2307        .collect();
2308    let crates: Vec<String> = crates_raw.into_iter().map(|s| s.to_string()).collect();
2309
2310    // Fetch the GitHub repository tree to resolve paths
2311    let repo_tree = spec.repository.as_ref().and_then(|r| fetch_github_tree(r));
2312
2313    // Convert templates with resolved repo paths
2314    let templates = spec
2315        .templates
2316        .iter()
2317        .map(|(name, tmpl)| {
2318            let repo_path = repo_tree
2319                .as_ref()
2320                .and_then(|tree| find_template_path(tree, &tmpl.path));
2321            TemplateInfo {
2322                name: name.clone(),
2323                path: tmpl.path.clone(),
2324                description: tmpl.description.clone(),
2325                repo_path,
2326            }
2327        })
2328        .collect();
2329
2330    // Scan examples directory
2331    let examples = scan_examples(crate_dir, repo_tree.as_deref());
2332
2333    Ok(BatteryPackDetail {
2334        short_name: short_name(&spec.name).to_string(),
2335        name: spec.name.clone(),
2336        version: spec.version.clone(),
2337        description: spec.description.clone(),
2338        repository: spec.repository.clone(),
2339        owners: owners.into_iter().map(OwnerInfo::from).collect(),
2340        crates,
2341        extends,
2342        templates,
2343        examples,
2344    })
2345}
2346
2347// [impl cli.show.details]
2348// [impl cli.show.hidden]
2349// [impl cli.source.replace]
2350fn print_battery_pack_detail(name: &str, path: Option<&str>, source: &CrateSource) -> Result<()> {
2351    use console::style;
2352
2353    // --path takes precedence over --crate-source
2354    let detail = if path.is_some() {
2355        fetch_battery_pack_detail(name, path)?
2356    } else {
2357        fetch_battery_pack_detail_from_source(source, name)?
2358    };
2359
2360    // Header
2361    println!();
2362    println!(
2363        "{} {}",
2364        style(&detail.name).green().bold(),
2365        style(&detail.version).dim()
2366    );
2367    if !detail.description.is_empty() {
2368        println!("{}", detail.description);
2369    }
2370
2371    // Authors
2372    if !detail.owners.is_empty() {
2373        println!();
2374        println!("{}", style("Authors:").bold());
2375        for owner in &detail.owners {
2376            if let Some(name) = &owner.name {
2377                println!("  {} ({})", name, owner.login);
2378            } else {
2379                println!("  {}", owner.login);
2380            }
2381        }
2382    }
2383
2384    // Crates
2385    if !detail.crates.is_empty() {
2386        println!();
2387        println!("{}", style("Crates:").bold());
2388        for dep in &detail.crates {
2389            println!("  {}", dep);
2390        }
2391    }
2392
2393    // Extends
2394    if !detail.extends.is_empty() {
2395        println!();
2396        println!("{}", style("Extends:").bold());
2397        for dep in &detail.extends {
2398            println!("  {}", dep);
2399        }
2400    }
2401
2402    // Templates
2403    if !detail.templates.is_empty() {
2404        println!();
2405        println!("{}", style("Templates:").bold());
2406        let max_name_len = detail
2407            .templates
2408            .iter()
2409            .map(|t| t.name.len())
2410            .max()
2411            .unwrap_or(0);
2412        for tmpl in &detail.templates {
2413            let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
2414            if let Some(desc) = &tmpl.description {
2415                println!("  {}  {}", style(name_padded).cyan(), desc);
2416            } else {
2417                println!("  {}", style(name_padded).cyan());
2418            }
2419        }
2420    }
2421
2422    // [impl format.examples.browsable]
2423    // Examples
2424    if !detail.examples.is_empty() {
2425        println!();
2426        println!("{}", style("Examples:").bold());
2427        let max_name_len = detail
2428            .examples
2429            .iter()
2430            .map(|e| e.name.len())
2431            .max()
2432            .unwrap_or(0);
2433        for example in &detail.examples {
2434            let name_padded = format!("{:<width$}", example.name, width = max_name_len);
2435            if let Some(desc) = &example.description {
2436                println!("  {}  {}", style(name_padded).magenta(), desc);
2437            } else {
2438                println!("  {}", style(name_padded).magenta());
2439            }
2440        }
2441    }
2442
2443    // Install hints
2444    println!();
2445    println!("{}", style("Install:").bold());
2446    println!("  cargo bp add {}", detail.short_name);
2447    println!("  cargo bp new {}", detail.short_name);
2448    println!();
2449
2450    Ok(())
2451}
2452
2453fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
2454    let client = http_client();
2455
2456    let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
2457    let response = client
2458        .get(&url)
2459        .send()
2460        .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
2461
2462    if !response.status().is_success() {
2463        // Not fatal - just return empty
2464        return Ok(Vec::new());
2465    }
2466
2467    let parsed: OwnersResponse = response
2468        .json()
2469        .with_context(|| "Failed to parse owners response")?;
2470
2471    Ok(parsed.users)
2472}
2473
2474/// Scan the examples directory and extract example info.
2475/// If a GitHub tree is provided, resolves the full repository path for each example.
2476// [impl format.examples.standard]
2477fn scan_examples(crate_dir: &std::path::Path, repo_tree: Option<&[String]>) -> Vec<ExampleInfo> {
2478    let examples_dir = crate_dir.join("examples");
2479    if !examples_dir.exists() {
2480        return Vec::new();
2481    }
2482
2483    let mut examples = Vec::new();
2484
2485    if let Ok(entries) = std::fs::read_dir(&examples_dir) {
2486        for entry in entries.flatten() {
2487            let path = entry.path();
2488            if path.extension().is_some_and(|ext| ext == "rs")
2489                && let Some(name) = path.file_stem().and_then(|s| s.to_str())
2490            {
2491                let description = extract_example_description(&path);
2492                let repo_path = repo_tree.and_then(|tree| find_example_path(tree, name));
2493                examples.push(ExampleInfo {
2494                    name: name.to_string(),
2495                    description,
2496                    repo_path,
2497                });
2498            }
2499        }
2500    }
2501
2502    // Sort by name
2503    examples.sort_by(|a, b| a.name.cmp(&b.name));
2504    examples
2505}
2506
2507/// Extract description from the first doc comment in an example file
2508fn extract_example_description(path: &std::path::Path) -> Option<String> {
2509    let content = std::fs::read_to_string(path).ok()?;
2510
2511    // Look for //! doc comments at the start
2512    for line in content.lines() {
2513        let trimmed = line.trim();
2514        if trimmed.starts_with("//!") {
2515            let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
2516            if !desc.is_empty() {
2517                return Some(desc.to_string());
2518            }
2519        } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
2520            // Stop at first non-comment, non-empty line
2521            break;
2522        }
2523    }
2524    None
2525}
2526
2527/// Fetch the repository tree from GitHub API.
2528/// Returns a list of all file paths in the repository.
2529fn fetch_github_tree(repository: &str) -> Option<Vec<String>> {
2530    // Parse GitHub URL: https://github.com/owner/repo
2531    let gh_path = repository
2532        .strip_prefix("https://github.com/")
2533        .or_else(|| repository.strip_prefix("http://github.com/"))?;
2534    let gh_path = gh_path.strip_suffix(".git").unwrap_or(gh_path);
2535    let gh_path = gh_path.trim_end_matches('/');
2536
2537    let client = http_client();
2538
2539    // Fetch the tree recursively using the main branch
2540    let url = format!(
2541        "https://api.github.com/repos/{}/git/trees/main?recursive=1",
2542        gh_path
2543    );
2544
2545    let response = client.get(&url).send().ok()?;
2546    if !response.status().is_success() {
2547        return None;
2548    }
2549
2550    let tree_response: GitHubTreeResponse = response.json().ok()?;
2551
2552    // Extract all paths (both blobs/files and trees/directories)
2553    Some(tree_response.tree.into_iter().map(|e| e.path).collect())
2554}
2555
2556/// Find the full repository path for an example file.
2557/// Searches the tree for a file matching "examples/{name}.rs".
2558fn find_example_path(tree: &[String], example_name: &str) -> Option<String> {
2559    let suffix = format!("examples/{}.rs", example_name);
2560    tree.iter().find(|path| path.ends_with(&suffix)).cloned()
2561}
2562
2563/// Find the full repository path for a template directory.
2564/// Searches the tree for a path matching "templates/{name}" or "{name}".
2565fn find_template_path(tree: &[String], template_path: &str) -> Option<String> {
2566    // The template path from config might be "templates/simple" or just the relative path
2567    tree.iter()
2568        .find(|path| path.ends_with(template_path))
2569        .cloned()
2570}
2571
2572// ============================================================================
2573// Status command
2574// ============================================================================
2575
2576// [impl cli.status.list]
2577// [impl cli.status.version-warn]
2578// [impl cli.status.no-project]
2579// [impl cli.source.subcommands]
2580// [impl cli.path.subcommands]
2581fn status_battery_packs(
2582    project_dir: &Path,
2583    path: Option<&str>,
2584    source: &CrateSource,
2585) -> Result<()> {
2586    use console::style;
2587
2588    // [impl cli.status.no-project]
2589    let user_manifest_path =
2590        find_user_manifest(project_dir).context("are you inside a Rust project?")?;
2591    let user_manifest_content =
2592        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
2593
2594    // Inline the load_installed_packs logic to avoid re-reading the manifest.
2595    let bp_names = find_installed_bp_names(&user_manifest_content)?;
2596    let metadata_location = resolve_metadata_location(&user_manifest_path)?;
2597    let packs: Vec<InstalledPack> = bp_names
2598        .into_iter()
2599        .map(|bp_name| {
2600            let spec = load_installed_bp_spec(&bp_name, path, source)?;
2601            let active_features =
2602                read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
2603            Ok(InstalledPack {
2604                short_name: short_name(&bp_name).to_string(),
2605                version: spec.version.clone(),
2606                spec,
2607                name: bp_name,
2608                active_features,
2609            })
2610        })
2611        .collect::<Result<_>>()?;
2612
2613    if packs.is_empty() {
2614        println!("No battery packs installed.");
2615        return Ok(());
2616    }
2617
2618    // Build a map of the user's actual dependency versions so we can compare.
2619    let user_versions = collect_user_dep_versions(&user_manifest_path, &user_manifest_content)?;
2620
2621    let mut any_warnings = false;
2622
2623    for pack in &packs {
2624        // [impl cli.status.list]
2625        println!(
2626            "{} ({})",
2627            style(&pack.short_name).bold(),
2628            style(&pack.version).dim(),
2629        );
2630
2631        // Resolve which crates are expected for this pack's active features.
2632        let expected = pack.spec.resolve_for_features(&pack.active_features);
2633
2634        let mut pack_warnings = Vec::new();
2635        for (dep_name, dep_spec) in &expected {
2636            if dep_spec.version.is_empty() {
2637                continue;
2638            }
2639            if let Some(user_version) = user_versions.get(dep_name.as_str()) {
2640                // [impl cli.status.version-warn]
2641                if should_upgrade_version(user_version, &dep_spec.version) {
2642                    pack_warnings.push((
2643                        dep_name.as_str(),
2644                        user_version.as_str(),
2645                        dep_spec.version.as_str(),
2646                    ));
2647                }
2648            }
2649        }
2650
2651        if pack_warnings.is_empty() {
2652            println!("  {} all dependencies up to date", style("✓").green());
2653        } else {
2654            any_warnings = true;
2655            for (dep, current, recommended) in &pack_warnings {
2656                println!(
2657                    "  {} {}: {} → {} recommended",
2658                    style("⚠").yellow(),
2659                    dep,
2660                    style(current).red(),
2661                    style(recommended).green(),
2662                );
2663            }
2664        }
2665    }
2666
2667    if any_warnings {
2668        println!();
2669        println!("Run {} to update.", style("cargo bp sync").bold());
2670    }
2671
2672    Ok(())
2673}
2674
2675/// Collect the user's actual dependency versions from Cargo.toml (and workspace deps if applicable).
2676///
2677/// Returns a map of `crate_name → version_string`.
2678pub fn collect_user_dep_versions(
2679    user_manifest_path: &Path,
2680    user_manifest_content: &str,
2681) -> Result<BTreeMap<String, String>> {
2682    let raw: toml::Value =
2683        toml::from_str(user_manifest_content).context("Failed to parse Cargo.toml")?;
2684
2685    let mut versions = BTreeMap::new();
2686
2687    // Read workspace dependency versions (if applicable).
2688    let ws_versions = if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
2689        let ws_content =
2690            std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
2691        let ws_raw: toml::Value =
2692            toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
2693        extract_versions_from_table(
2694            ws_raw
2695                .get("workspace")
2696                .and_then(|w| w.get("dependencies"))
2697                .and_then(|d| d.as_table()),
2698        )
2699    } else {
2700        BTreeMap::new()
2701    };
2702
2703    // Collect from each dependency section.
2704    for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
2705        let table = raw.get(section).and_then(|d| d.as_table());
2706        let Some(table) = table else { continue };
2707        for (name, value) in table {
2708            if versions.contains_key(name) {
2709                continue; // first section wins
2710            }
2711            if let Some(version) = extract_version_from_dep(value) {
2712                versions.insert(name.clone(), version);
2713            } else if is_workspace_ref(value) {
2714                // Resolve from workspace deps.
2715                if let Some(ws_ver) = ws_versions.get(name) {
2716                    versions.insert(name.clone(), ws_ver.clone());
2717                }
2718            }
2719        }
2720    }
2721
2722    Ok(versions)
2723}
2724
2725/// Extract version strings from a TOML dependency table.
2726fn extract_versions_from_table(
2727    table: Option<&toml::map::Map<String, toml::Value>>,
2728) -> BTreeMap<String, String> {
2729    let Some(table) = table else {
2730        return BTreeMap::new();
2731    };
2732    let mut versions = BTreeMap::new();
2733    for (name, value) in table {
2734        if let Some(version) = extract_version_from_dep(value) {
2735            versions.insert(name.clone(), version);
2736        }
2737    }
2738    versions
2739}
2740
2741/// Extract the version string from a single dependency value.
2742///
2743/// Handles both `crate = "1.0"` and `crate = { version = "1.0", ... }`.
2744fn extract_version_from_dep(value: &toml::Value) -> Option<String> {
2745    match value {
2746        toml::Value::String(s) => Some(s.clone()),
2747        toml::Value::Table(t) => t
2748            .get("version")
2749            .and_then(|v| v.as_str())
2750            .map(|s| s.to_string()),
2751        _ => None,
2752    }
2753}
2754
2755/// Check if a dependency entry is a workspace reference (`{ workspace = true }`).
2756fn is_workspace_ref(value: &toml::Value) -> bool {
2757    match value {
2758        toml::Value::Table(t) => t
2759            .get("workspace")
2760            .and_then(|v| v.as_bool())
2761            .unwrap_or(false),
2762        _ => false,
2763    }
2764}
2765
2766// ============================================================================
2767// Validate command
2768// ============================================================================
2769
2770// [impl cli.validate.purpose]
2771// [impl cli.validate.default-path]
2772pub fn validate_battery_pack_cmd(path: Option<&str>) -> Result<()> {
2773    let crate_root = match path {
2774        Some(p) => std::path::PathBuf::from(p),
2775        None => std::env::current_dir().context("failed to get current directory")?,
2776    };
2777
2778    let cargo_toml = crate_root.join("Cargo.toml");
2779    let content = std::fs::read_to_string(&cargo_toml)
2780        .with_context(|| format!("failed to read {}", cargo_toml.display()))?;
2781
2782    // Check for virtual/workspace manifest before attempting battery pack parse
2783    let raw: toml::Value = toml::from_str(&content)
2784        .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
2785    if raw.get("package").is_none() {
2786        if raw.get("workspace").is_some() {
2787            // [impl cli.validate.workspace-error]
2788            bail!(
2789                "{} is a workspace manifest, not a battery pack crate.\n\
2790                 Run this from a battery pack crate directory, or use --path to point to one.",
2791                cargo_toml.display()
2792            );
2793        } else {
2794            // [impl cli.validate.no-package]
2795            bail!(
2796                "{} has no [package] section — is this a battery pack crate?",
2797                cargo_toml.display()
2798            );
2799        }
2800    }
2801
2802    let spec = bphelper_manifest::parse_battery_pack(&content)
2803        .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
2804
2805    // [impl cli.validate.checks]
2806    let mut report = spec.validate_spec();
2807    report.merge(bphelper_manifest::validate_on_disk(&spec, &crate_root));
2808
2809    // [impl cli.validate.clean]
2810    if report.is_clean() {
2811        println!("{} is valid", spec.name);
2812        return Ok(());
2813    }
2814
2815    // [impl cli.validate.severity]
2816    // [impl cli.validate.rule-id]
2817    let mut errors = 0;
2818    let mut warnings = 0;
2819    for diag in &report.diagnostics {
2820        match diag.severity {
2821            bphelper_manifest::Severity::Error => {
2822                eprintln!("error[{}]: {}", diag.rule, diag.message);
2823                errors += 1;
2824            }
2825            bphelper_manifest::Severity::Warning => {
2826                eprintln!("warning[{}]: {}", diag.rule, diag.message);
2827                warnings += 1;
2828            }
2829        }
2830    }
2831
2832    // [impl cli.validate.errors]
2833    if errors > 0 {
2834        bail!(
2835            "validation failed: {} error(s), {} warning(s)",
2836            errors,
2837            warnings
2838        );
2839    }
2840
2841    // [impl cli.validate.warnings-only]
2842    // Warnings only — still succeeds
2843    println!("{} is valid ({} warning(s))", spec.name, warnings);
2844    Ok(())
2845}