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