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