Skip to main content

bphelper_cli/commands/
mod.rs

1//! Subcommand implementations, CLI arg types, and interactive picker.
2//!
3//! This module contains the `main()` entry point and all subcommand handlers.
4//! Depends on `registry` and `manifest`.
5
6use anyhow::{Context, Result, bail};
7use clap::{Parser, Subcommand};
8use std::collections::{BTreeMap, BTreeSet};
9use std::io::IsTerminal;
10use std::path::{Path, PathBuf};
11
12use crate::manifest::{
13    MetadataLocation, add_dep_to_table, dep_kind_section, find_installed_bp_names,
14    find_user_manifest, find_workspace_manifest, read_active_features_from,
15    resolve_metadata_location, should_upgrade_version, sync_dep_in_table, write_bp_features_to_doc,
16    write_deps_by_kind, write_workspace_refs_by_kind,
17};
18use crate::registry::{
19    CrateSource, InstalledPack, TemplateConfig, download_and_extract_crate,
20    fetch_battery_pack_detail, fetch_battery_pack_detail_from_source, fetch_battery_pack_list,
21    fetch_battery_pack_spec, fetch_bp_spec, find_local_battery_pack_dir, load_installed_bp_spec,
22    lookup_crate, resolve_crate_name, short_name,
23};
24
25// [impl cli.bare.help]
26#[derive(Parser)]
27#[command(name = "cargo-bp")]
28#[command(bin_name = "cargo")]
29#[command(version, about = "Create and manage battery packs", long_about = None)]
30pub(crate) struct Cli {
31    #[command(subcommand)]
32    pub command: Commands,
33}
34
35#[derive(Subcommand)]
36pub(crate) enum Commands {
37    /// Battery pack commands
38    Bp {
39        // [impl cli.source.subcommands]
40        /// Use a local workspace as the battery pack source (replaces crates.io)
41        #[arg(long)]
42        crate_source: Option<PathBuf>,
43
44        #[command(subcommand)]
45        command: Option<BpCommands>,
46    },
47}
48
49#[derive(Subcommand)]
50pub(crate) enum BpCommands {
51    /// Create a new project from a battery pack template
52    New {
53        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
54        battery_pack: String,
55
56        /// Name for the new project (prompted interactively if not provided)
57        #[arg(long, short = 'n')]
58        name: Option<String>,
59
60        /// Which template to use (defaults to first available, or prompts if multiple)
61        // [impl cli.new.template-flag]
62        #[arg(long, short = 't')]
63        template: Option<String>,
64
65        /// Use a local path instead of downloading from crates.io
66        #[arg(long)]
67        path: Option<String>,
68
69        /// Set a template placeholder value (e.g., -d description="My project")
70        #[arg(long = "define", short = 'd', value_parser = parse_define)]
71        define: Vec<(String, String)>,
72    },
73
74    /// Add a battery pack and sync its dependencies.
75    ///
76    /// Without arguments, opens an interactive TUI for managing all battery packs.
77    /// With a battery pack name, adds that specific pack (with an interactive picker
78    /// for choosing crates if the pack has features or many dependencies).
79    Add {
80        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack").
81        /// Omit to open the interactive manager.
82        battery_pack: Option<String>,
83
84        /// Specific crates to add from the battery pack (ignores defaults/features)
85        crates: Vec<String>,
86
87        // [impl cli.add.features]
88        // [impl cli.add.features-multiple]
89        /// Named features to enable (comma-separated or repeated)
90        #[arg(long = "features", short = 'F', value_delimiter = ',')]
91        features: Vec<String>,
92
93        // [impl cli.add.no-default-features]
94        /// Skip the default crates; only add crates from named features
95        #[arg(long)]
96        no_default_features: bool,
97
98        // [impl cli.add.all-features]
99        /// Add every crate the battery pack offers
100        #[arg(long)]
101        all_features: bool,
102
103        // [impl cli.add.target]
104        /// Where to store the battery pack registration
105        /// (workspace, package, or default)
106        #[arg(long)]
107        target: Option<AddTarget>,
108
109        /// Use a local path instead of downloading from crates.io
110        #[arg(long)]
111        path: Option<String>,
112    },
113
114    /// Update dependencies from installed battery packs
115    Sync {
116        // [impl cli.path.subcommands]
117        /// Use a local path instead of downloading from crates.io
118        #[arg(long)]
119        path: Option<String>,
120    },
121
122    /// Enable a named feature from a battery pack
123    Enable {
124        /// Name of the feature to enable
125        feature_name: String,
126
127        /// Battery pack to search (optional — searches all installed if omitted)
128        #[arg(long)]
129        battery_pack: Option<String>,
130    },
131
132    /// List available battery packs on crates.io
133    #[command(visible_alias = "ls")]
134    List {
135        /// Filter by name (omit to list all battery packs)
136        filter: Option<String>,
137
138        /// Disable interactive TUI mode
139        #[arg(long)]
140        non_interactive: bool,
141    },
142
143    /// Show detailed information about a battery pack
144    #[command(visible_alias = "info")]
145    Show {
146        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
147        battery_pack: String,
148
149        /// Use a local path instead of downloading from crates.io
150        #[arg(long)]
151        path: Option<String>,
152
153        /// Disable interactive TUI mode
154        #[arg(long)]
155        non_interactive: bool,
156    },
157
158    /// Show status of installed battery packs and version warnings
159    #[command(visible_alias = "stat")]
160    Status {
161        // [impl cli.path.subcommands]
162        /// Use a local path instead of downloading from crates.io
163        #[arg(long)]
164        path: Option<String>,
165    },
166
167    /// Validate that the current battery pack is well-formed
168    Validate {
169        /// Path to the battery pack crate (defaults to current directory)
170        #[arg(long)]
171        path: Option<String>,
172    },
173}
174
175// [impl cli.add.target]
176#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
177pub(crate) enum AddTarget {
178    /// Register in `workspace.metadata.battery-pack`.
179    Workspace,
180    /// Register in `package.metadata.battery-pack`.
181    Package,
182    /// Use workspace if a workspace root exists, otherwise package
183    Default,
184}
185
186pub fn main() -> Result<()> {
187    let cli = Cli::parse();
188    let project_dir = std::env::current_dir().context("Failed to get current directory")?;
189    let interactive = std::io::stdout().is_terminal();
190
191    match cli.command {
192        Commands::Bp {
193            crate_source,
194            command,
195        } => {
196            let source = match crate_source {
197                Some(path) => CrateSource::Local(path),
198                None => CrateSource::Registry,
199            };
200            // [impl cli.bare.tui]
201            let Some(command) = command else {
202                if interactive {
203                    return crate::tui::run_add(source);
204                } else {
205                    bail!(
206                        "No subcommand specified. Use `cargo bp --help` or run interactively in a terminal."
207                    );
208                }
209            };
210            match command {
211                BpCommands::New {
212                    battery_pack,
213                    name,
214                    template,
215                    path,
216                    define,
217                } => new_from_battery_pack(&battery_pack, name, template, path, &source, &define),
218                BpCommands::Add {
219                    battery_pack,
220                    crates,
221                    features,
222                    no_default_features,
223                    all_features,
224                    target,
225                    path,
226                } => match battery_pack {
227                    Some(name) => add_battery_pack(
228                        &name,
229                        &features,
230                        no_default_features,
231                        all_features,
232                        &crates,
233                        target,
234                        path.as_deref(),
235                        &source,
236                        &project_dir,
237                    ),
238                    None if interactive => crate::tui::run_add(source),
239                    None => {
240                        bail!(
241                            "No battery pack specified. Use `cargo bp add <name>` or run interactively in a terminal."
242                        )
243                    }
244                },
245                BpCommands::Sync { path } => {
246                    sync_battery_packs(&project_dir, path.as_deref(), &source)
247                }
248                BpCommands::Enable {
249                    feature_name,
250                    battery_pack,
251                } => enable_feature(&feature_name, battery_pack.as_deref(), &project_dir),
252                BpCommands::List {
253                    filter,
254                    non_interactive,
255                } => {
256                    // [impl cli.list.interactive]
257                    // [impl cli.list.non-interactive]
258                    if !non_interactive && interactive {
259                        crate::tui::run_list(source, filter)
260                    } else {
261                        // [impl cli.list.query]
262                        // [impl cli.list.filter]
263                        print_battery_pack_list(&source, filter.as_deref())
264                    }
265                }
266                BpCommands::Show {
267                    battery_pack,
268                    path,
269                    non_interactive,
270                } => {
271                    // [impl cli.show.interactive]
272                    // [impl cli.show.non-interactive]
273                    if !non_interactive && interactive {
274                        crate::tui::run_show(&battery_pack, path.as_deref(), source)
275                    } else {
276                        print_battery_pack_detail(&battery_pack, path.as_deref(), &source)
277                    }
278                }
279                BpCommands::Status { path } => {
280                    status_battery_packs(&project_dir, path.as_deref(), &source)
281                }
282                BpCommands::Validate { path } => {
283                    crate::validate::validate_battery_pack_cmd(path.as_deref())
284                }
285            }
286        }
287    }
288}
289
290// ============================================================================
291// Implementation
292// ============================================================================
293
294// [impl cli.new.template]
295// [impl cli.new.name-flag]
296// [impl cli.new.name-prompt]
297// [impl cli.path.flag]
298// [impl cli.source.replace]
299fn new_from_battery_pack(
300    battery_pack: &str,
301    name: Option<String>,
302    template: Option<String>,
303    path_override: Option<String>,
304    source: &CrateSource,
305    define: &[(String, String)],
306) -> Result<()> {
307    let defines: std::collections::BTreeMap<String, String> = define.iter().cloned().collect();
308
309    // --path takes precedence over --crate-source
310    if let Some(path) = path_override {
311        return generate_from_local(battery_pack, &path, name, template, defines);
312    }
313
314    let crate_name = resolve_crate_name(battery_pack);
315
316    // Locate the crate directory based on source
317    let crate_dir: PathBuf;
318    let _temp_dir: Option<tempfile::TempDir>; // keep alive for Registry
319    match source {
320        CrateSource::Registry => {
321            let crate_info = lookup_crate(&crate_name)?;
322            let temp = download_and_extract_crate(&crate_name, &crate_info.version)?;
323            crate_dir = temp
324                .path()
325                .join(format!("{}-{}", crate_name, crate_info.version));
326            _temp_dir = Some(temp);
327        }
328        CrateSource::Local(workspace_dir) => {
329            crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
330            _temp_dir = None;
331        }
332    }
333
334    // Read template metadata from the Cargo.toml
335    let manifest_path = crate_dir.join("Cargo.toml");
336    let manifest_content = std::fs::read_to_string(&manifest_path)
337        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
338    let templates = parse_template_metadata(&manifest_content, &crate_name)?;
339
340    // Resolve which template to use
341    let template_path = resolve_template(&templates, template.as_deref())?;
342
343    // Generate the project from the crate directory
344    generate_from_path(battery_pack, &crate_dir, &template_path, name, defines)
345}
346
347/// Result of resolving which crates to add from a battery pack.
348pub(crate) enum ResolvedAdd {
349    /// Resolved to a concrete set of crates (no interactive picker needed).
350    Crates {
351        active_features: BTreeSet<String>,
352        crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
353    },
354    /// The caller should show the interactive picker.
355    Interactive,
356}
357
358/// Pure resolution logic for `cargo bp add` flags.
359///
360/// Given the battery pack spec and the CLI flags, determines which crates
361/// to install. Returns `ResolvedAdd::Interactive` when the picker should
362/// be shown (no explicit flags, TTY, meaningful choices).
363///
364/// When `specific_crates` is non-empty, unknown crate names are reported
365/// to stderr and skipped; valid ones proceed.
366// [impl cli.add.specific-crates]
367// [impl cli.add.unknown-crate]
368// [impl cli.add.default-crates]
369// [impl cli.add.features]
370// [impl cli.add.no-default-features]
371// [impl cli.add.all-features]
372pub(crate) fn resolve_add_crates(
373    bp_spec: &bphelper_manifest::BatteryPackSpec,
374    bp_name: &str,
375    with_features: &[String],
376    no_default_features: bool,
377    all_features: bool,
378    specific_crates: &[String],
379) -> ResolvedAdd {
380    if !specific_crates.is_empty() {
381        // Explicit crate selection — ignores defaults and features.
382        let mut selected = BTreeMap::new();
383        for crate_name_arg in specific_crates {
384            if let Some(spec) = bp_spec.crates.get(crate_name_arg.as_str()) {
385                selected.insert(crate_name_arg.clone(), spec.clone());
386            } else {
387                eprintln!(
388                    "error: crate '{}' not found in battery pack '{}'",
389                    crate_name_arg, bp_name
390                );
391            }
392        }
393        return ResolvedAdd::Crates {
394            active_features: BTreeSet::new(),
395            crates: selected,
396        };
397    }
398
399    if all_features {
400        // [impl format.hidden.effect]
401        return ResolvedAdd::Crates {
402            active_features: BTreeSet::from(["all".to_string()]),
403            crates: bp_spec.resolve_all_visible(),
404        };
405    }
406
407    // When no explicit flags narrow the selection and the pack has
408    // meaningful choices, signal that the caller may want to show
409    // the interactive picker.
410    if !no_default_features && with_features.is_empty() && bp_spec.has_meaningful_choices() {
411        return ResolvedAdd::Interactive;
412    }
413
414    let mut features: BTreeSet<String> = if no_default_features {
415        BTreeSet::new()
416    } else {
417        BTreeSet::from(["default".to_string()])
418    };
419    features.extend(with_features.iter().cloned());
420
421    // When no features are active (--no-default-features with no -F),
422    // return empty rather than calling resolve_crates(&[]) which
423    // falls back to defaults.
424    if features.is_empty() {
425        return ResolvedAdd::Crates {
426            active_features: features,
427            crates: BTreeMap::new(),
428        };
429    }
430
431    let str_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
432    let crates = bp_spec.resolve_crates(&str_features);
433    ResolvedAdd::Crates {
434        active_features: features,
435        crates,
436    }
437}
438
439// [impl cli.add.register]
440// [impl cli.add.dep-kind]
441// [impl cli.add.specific-crates]
442// [impl cli.add.unknown-crate]
443// [impl manifest.register.location]
444// [impl manifest.register.format]
445// [impl manifest.features.storage]
446// [impl manifest.deps.add]
447// [impl manifest.deps.version-features]
448#[allow(clippy::too_many_arguments)]
449pub(crate) fn add_battery_pack(
450    name: &str,
451    with_features: &[String],
452    no_default_features: bool,
453    all_features: bool,
454    specific_crates: &[String],
455    target: Option<AddTarget>,
456    path: Option<&str>,
457    source: &CrateSource,
458    project_dir: &Path,
459) -> Result<()> {
460    let crate_name = resolve_crate_name(name);
461
462    // Step 1: Read the battery pack spec WITHOUT modifying any manifests.
463    // --path takes precedence over --crate-source.
464    // [impl cli.path.flag]
465    // [impl cli.path.no-resolve]
466    // [impl cli.source.replace]
467    let (bp_version, bp_spec) = if let Some(local_path) = path {
468        let manifest_path = Path::new(local_path).join("Cargo.toml");
469        let manifest_content = std::fs::read_to_string(&manifest_path)
470            .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
471        let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
472            .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
473        (None, spec)
474    } else {
475        fetch_bp_spec(source, name)?
476    };
477
478    // Step 2: Determine which crates to install — interactive picker, explicit flags, or defaults.
479    // No manifest changes have been made yet, so cancellation is free.
480    let resolved = resolve_add_crates(
481        &bp_spec,
482        &crate_name,
483        with_features,
484        no_default_features,
485        all_features,
486        specific_crates,
487    );
488    let (active_features, crates_to_sync) = match resolved {
489        ResolvedAdd::Crates {
490            active_features,
491            crates,
492        } => (active_features, crates),
493        ResolvedAdd::Interactive if std::io::stdout().is_terminal() => {
494            match pick_crates_interactive(&bp_spec)? {
495                Some(result) => (result.active_features, result.crates),
496                None => {
497                    println!("Cancelled.");
498                    return Ok(());
499                }
500            }
501        }
502        ResolvedAdd::Interactive => {
503            // Non-interactive fallback: use defaults
504            let crates = bp_spec.resolve_crates(&["default"]);
505            (BTreeSet::from(["default".to_string()]), crates)
506        }
507    };
508
509    if crates_to_sync.is_empty() {
510        println!("No crates selected.");
511        return Ok(());
512    }
513
514    // Step 3: Now write everything — build-dep, workspace deps, crate deps, metadata.
515    let user_manifest_path = find_user_manifest(project_dir)?;
516    let user_manifest_content =
517        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
518    // [impl manifest.toml.preserve]
519    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
520        .parse()
521        .context("Failed to parse Cargo.toml")?;
522
523    // [impl manifest.register.workspace-default]
524    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
525
526    // Add battery pack to [build-dependencies]
527    let build_deps =
528        user_doc["build-dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
529    if let Some(table) = build_deps.as_table_mut() {
530        if let Some(local_path) = path {
531            let mut dep = toml_edit::InlineTable::new();
532            dep.insert("path", toml_edit::Value::from(local_path));
533            table.insert(
534                &crate_name,
535                toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
536            );
537        } else if workspace_manifest.is_some() {
538            let mut dep = toml_edit::InlineTable::new();
539            dep.insert("workspace", toml_edit::Value::from(true));
540            table.insert(
541                &crate_name,
542                toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
543            );
544        } else {
545            let version = bp_version
546                .as_ref()
547                .context("battery pack version not available (--path without workspace)")?;
548            table.insert(&crate_name, toml_edit::value(version));
549        }
550    }
551
552    // [impl manifest.deps.workspace]
553    // Add crate dependencies + workspace deps (including the battery pack itself).
554    // Load workspace doc once; both deps and metadata are written to it before a
555    // single flush at the end (avoids a double read-modify-write).
556    let mut ws_doc: Option<toml_edit::DocumentMut> = if let Some(ref ws_path) = workspace_manifest {
557        let ws_content =
558            std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
559        Some(
560            ws_content
561                .parse()
562                .context("Failed to parse workspace Cargo.toml")?,
563        )
564    } else {
565        None
566    };
567
568    if let Some(ref mut doc) = ws_doc {
569        let ws_deps = doc["workspace"]["dependencies"]
570            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
571        if let Some(ws_table) = ws_deps.as_table_mut() {
572            // Add the battery pack itself to workspace deps
573            if let Some(local_path) = path {
574                let mut dep = toml_edit::InlineTable::new();
575                dep.insert("path", toml_edit::Value::from(local_path));
576                ws_table.insert(
577                    &crate_name,
578                    toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
579                );
580            } else {
581                let version = bp_version
582                    .as_ref()
583                    .context("battery pack version not available (--path without workspace)")?;
584                ws_table.insert(&crate_name, toml_edit::value(version));
585            }
586            // Add the resolved crate dependencies
587            for (dep_name, dep_spec) in &crates_to_sync {
588                add_dep_to_table(ws_table, dep_name, dep_spec);
589            }
590        }
591
592        // [impl cli.add.dep-kind]
593        write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, false);
594    } else {
595        // [impl manifest.deps.no-workspace]
596        // [impl cli.add.dep-kind]
597        write_deps_by_kind(&mut user_doc, &crates_to_sync, false);
598    }
599
600    // [impl manifest.register.location]
601    // [impl manifest.register.format]
602    // [impl manifest.features.storage]
603    // [impl cli.add.target]
604    // Record active features — location depends on --target flag
605    let use_workspace_metadata = match target {
606        Some(AddTarget::Workspace) => true,
607        Some(AddTarget::Package) => false,
608        Some(AddTarget::Default) | None => workspace_manifest.is_some(),
609    };
610
611    if use_workspace_metadata {
612        if let Some(ref mut doc) = ws_doc {
613            write_bp_features_to_doc(
614                doc,
615                &["workspace", "metadata"],
616                &crate_name,
617                &active_features,
618            );
619        } else {
620            bail!("--target=workspace requires a workspace, but none was found");
621        }
622    } else {
623        write_bp_features_to_doc(
624            &mut user_doc,
625            &["package", "metadata"],
626            &crate_name,
627            &active_features,
628        );
629    }
630
631    // Write workspace Cargo.toml once (deps + metadata combined)
632    if let (Some(ws_path), Some(doc)) = (&workspace_manifest, &ws_doc) {
633        // [impl manifest.toml.preserve]
634        std::fs::write(ws_path, doc.to_string()).context("Failed to write workspace Cargo.toml")?;
635    }
636
637    // Write the final Cargo.toml
638    // [impl manifest.toml.preserve]
639    std::fs::write(&user_manifest_path, user_doc.to_string())
640        .context("Failed to write Cargo.toml")?;
641
642    // Create/modify build.rs
643    let build_rs_path = user_manifest_path
644        .parent()
645        .unwrap_or(Path::new("."))
646        .join("build.rs");
647    update_build_rs(&build_rs_path, &crate_name)?;
648
649    println!(
650        "Added {} with {} crate(s)",
651        crate_name,
652        crates_to_sync.len()
653    );
654    for dep_name in crates_to_sync.keys() {
655        println!("  + {}", dep_name);
656    }
657
658    Ok(())
659}
660
661// [impl cli.sync.update-versions]
662// [impl cli.sync.add-features]
663// [impl cli.sync.add-crates]
664// [impl cli.source.subcommands]
665
666fn sync_battery_packs(project_dir: &Path, path: Option<&str>, source: &CrateSource) -> Result<()> {
667    let user_manifest_path = find_user_manifest(project_dir)?;
668    let user_manifest_content =
669        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
670
671    let bp_names = find_installed_bp_names(&user_manifest_content)?;
672
673    if bp_names.is_empty() {
674        println!("No battery packs installed.");
675        return Ok(());
676    }
677
678    // [impl manifest.toml.preserve]
679    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
680        .parse()
681        .context("Failed to parse Cargo.toml")?;
682
683    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
684    let metadata_location = resolve_metadata_location(&user_manifest_path)?;
685    let mut total_changes = 0;
686
687    for bp_name in &bp_names {
688        // Get the battery pack spec
689        let bp_spec = load_installed_bp_spec(bp_name, path, source)?;
690
691        // Read active features from the correct metadata location
692        let active_features =
693            read_active_features_from(&metadata_location, &user_manifest_content, bp_name);
694
695        // [impl format.hidden.effect]
696        let expected = bp_spec.resolve_for_features(&active_features);
697
698        // [impl manifest.deps.workspace]
699        // Sync each crate
700        if let Some(ref ws_path) = workspace_manifest {
701            let ws_content =
702                std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
703            // [impl manifest.toml.preserve]
704            let mut ws_doc: toml_edit::DocumentMut = ws_content
705                .parse()
706                .context("Failed to parse workspace Cargo.toml")?;
707
708            let ws_deps = ws_doc["workspace"]["dependencies"]
709                .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
710            if let Some(ws_table) = ws_deps.as_table_mut() {
711                for (dep_name, dep_spec) in &expected {
712                    if sync_dep_in_table(ws_table, dep_name, dep_spec) {
713                        total_changes += 1;
714                        println!("  ~ {} (updated in workspace)", dep_name);
715                    }
716                }
717            }
718            // [impl manifest.toml.preserve]
719            std::fs::write(ws_path, ws_doc.to_string())
720                .context("Failed to write workspace Cargo.toml")?;
721
722            // Ensure crate-level references exist in the correct sections
723            // [impl cli.add.dep-kind]
724            let refs_added = write_workspace_refs_by_kind(&mut user_doc, &expected, true);
725            total_changes += refs_added;
726        } else {
727            // [impl manifest.deps.no-workspace]
728            // [impl cli.add.dep-kind]
729            for (dep_name, dep_spec) in &expected {
730                let section = dep_kind_section(dep_spec.dep_kind);
731                let table =
732                    user_doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
733                if let Some(table) = table.as_table_mut() {
734                    if !table.contains_key(dep_name) {
735                        add_dep_to_table(table, dep_name, dep_spec);
736                        total_changes += 1;
737                        println!("  + {}", dep_name);
738                    } else if sync_dep_in_table(table, dep_name, dep_spec) {
739                        total_changes += 1;
740                        println!("  ~ {}", dep_name);
741                    }
742                }
743            }
744        }
745    }
746
747    // [impl manifest.toml.preserve]
748    std::fs::write(&user_manifest_path, user_doc.to_string())
749        .context("Failed to write Cargo.toml")?;
750
751    if total_changes == 0 {
752        println!("All dependencies are up to date.");
753    } else {
754        println!("Synced {} change(s).", total_changes);
755    }
756
757    Ok(())
758}
759
760fn enable_feature(
761    feature_name: &str,
762    battery_pack: Option<&str>,
763    project_dir: &Path,
764) -> Result<()> {
765    let user_manifest_path = find_user_manifest(project_dir)?;
766    let user_manifest_content =
767        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
768
769    // Find which battery pack has this feature
770    let bp_name = if let Some(name) = battery_pack {
771        resolve_crate_name(name)
772    } else {
773        // Search all installed battery packs
774        let bp_names = find_installed_bp_names(&user_manifest_content)?;
775
776        let mut found = None;
777        for name in &bp_names {
778            let spec = fetch_battery_pack_spec(name)?;
779            if spec.features.contains_key(feature_name) {
780                found = Some(name.clone());
781                break;
782            }
783        }
784        found.ok_or_else(|| {
785            anyhow::anyhow!(
786                "No installed battery pack defines feature '{}'",
787                feature_name
788            )
789        })?
790    };
791
792    let bp_spec = fetch_battery_pack_spec(&bp_name)?;
793
794    if !bp_spec.features.contains_key(feature_name) {
795        let available: Vec<_> = bp_spec.features.keys().collect();
796        bail!(
797            "Battery pack '{}' has no feature '{}'. Available: {:?}",
798            bp_name,
799            feature_name,
800            available
801        );
802    }
803
804    // Add feature to active features
805    let metadata_location = resolve_metadata_location(&user_manifest_path)?;
806    let mut active_features =
807        read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
808    if active_features.contains(feature_name) {
809        println!(
810            "Feature '{}' is already active for {}.",
811            feature_name, bp_name
812        );
813        return Ok(());
814    }
815    active_features.insert(feature_name.to_string());
816
817    // Resolve what this changes
818    let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
819    let crates_to_sync = bp_spec.resolve_crates(&str_features);
820
821    // Update user manifest
822    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
823        .parse()
824        .context("Failed to parse Cargo.toml")?;
825
826    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
827
828    // Sync the new crates and update active features
829    if let Some(ref ws_path) = workspace_manifest {
830        let ws_content =
831            std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
832        let mut ws_doc: toml_edit::DocumentMut = ws_content
833            .parse()
834            .context("Failed to parse workspace Cargo.toml")?;
835
836        let ws_deps = ws_doc["workspace"]["dependencies"]
837            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
838        if let Some(ws_table) = ws_deps.as_table_mut() {
839            for (dep_name, dep_spec) in &crates_to_sync {
840                add_dep_to_table(ws_table, dep_name, dep_spec);
841            }
842        }
843
844        // If metadata lives in the workspace manifest, write features there too
845        if matches!(metadata_location, MetadataLocation::Workspace { .. }) {
846            write_bp_features_to_doc(
847                &mut ws_doc,
848                &["workspace", "metadata"],
849                &bp_name,
850                &active_features,
851            );
852        }
853
854        std::fs::write(ws_path, ws_doc.to_string())
855            .context("Failed to write workspace Cargo.toml")?;
856
857        // [impl cli.add.dep-kind]
858        write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, true);
859    } else {
860        // [impl cli.add.dep-kind]
861        write_deps_by_kind(&mut user_doc, &crates_to_sync, true);
862    }
863
864    // If metadata lives in the package manifest, write features there
865    if matches!(metadata_location, MetadataLocation::Package) {
866        write_bp_features_to_doc(
867            &mut user_doc,
868            &["package", "metadata"],
869            &bp_name,
870            &active_features,
871        );
872    }
873
874    std::fs::write(&user_manifest_path, user_doc.to_string())
875        .context("Failed to write Cargo.toml")?;
876
877    println!("Enabled feature '{}' from {}", feature_name, bp_name);
878    Ok(())
879}
880
881// ============================================================================
882// Interactive crate picker
883// ============================================================================
884
885/// Represents the result of an interactive crate selection.
886struct PickerResult {
887    /// The resolved crates to install (name -> dep spec with merged features).
888    crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
889    /// Which feature names are fully selected (for metadata recording).
890    active_features: BTreeSet<String>,
891}
892
893/// Show an interactive multi-select picker for choosing which crates to install.
894///
895/// Returns `None` if the user cancels. Returns `Some(PickerResult)` with the
896/// selected crates and which sets are fully active.
897fn pick_crates_interactive(
898    bp_spec: &bphelper_manifest::BatteryPackSpec,
899) -> Result<Option<PickerResult>> {
900    use console::style;
901    use dialoguer::MultiSelect;
902
903    let grouped = bp_spec.all_crates_with_grouping();
904    if grouped.is_empty() {
905        bail!("Battery pack has no crates to add");
906    }
907
908    // Build display items and track which group each belongs to
909    let mut labels = Vec::new();
910    let mut defaults = Vec::new();
911
912    for (group, crate_name, dep, is_default) in &grouped {
913        let version_info = if dep.features.is_empty() {
914            format!("({})", dep.version)
915        } else {
916            format!(
917                "({}, features: {})",
918                dep.version,
919                dep.features
920                    .iter()
921                    .map(|s| s.as_str())
922                    .collect::<Vec<_>>()
923                    .join(", ")
924            )
925        };
926
927        let group_label = if group == "default" {
928            String::new()
929        } else {
930            format!(" [{}]", group)
931        };
932
933        labels.push(format!(
934            "{} {}{}",
935            crate_name,
936            style(&version_info).dim(),
937            style(&group_label).cyan()
938        ));
939        defaults.push(*is_default);
940    }
941
942    // Show the picker
943    println!();
944    println!(
945        "  {} v{}",
946        style(&bp_spec.name).green().bold(),
947        style(&bp_spec.version).dim()
948    );
949    println!();
950
951    let selections = MultiSelect::new()
952        .with_prompt("Select crates to add")
953        .items(&labels)
954        .defaults(&defaults)
955        .interact_opt()
956        .context("Failed to show crate picker")?;
957
958    let Some(selected_indices) = selections else {
959        return Ok(None); // User cancelled
960    };
961
962    // Build the result: resolve selected crates with proper feature merging
963    let mut crates = BTreeMap::new();
964
965    for idx in &selected_indices {
966        let (_group, crate_name, dep, _) = &grouped[*idx];
967        // Start with base dep spec
968        let merged = (*dep).clone();
969
970        crates.insert(crate_name.clone(), merged);
971    }
972
973    // Determine which features are "fully selected" for metadata
974    let mut active_features = BTreeSet::from(["default".to_string()]);
975    for (feature_name, feature_crates) in &bp_spec.features {
976        if feature_name == "default" {
977            continue;
978        }
979        let all_selected = feature_crates.iter().all(|c| crates.contains_key(c));
980        if all_selected {
981            active_features.insert(feature_name.clone());
982        }
983    }
984
985    Ok(Some(PickerResult {
986        crates,
987        active_features,
988    }))
989}
990
991// ============================================================================
992// build.rs manipulation
993// ============================================================================
994
995/// Update or create build.rs to include a validate() call.
996fn update_build_rs(build_rs_path: &Path, crate_name: &str) -> Result<()> {
997    let crate_ident = crate_name.replace('-', "_");
998    let validate_call = format!("{}::validate();", crate_ident);
999
1000    if build_rs_path.exists() {
1001        let content = std::fs::read_to_string(build_rs_path).context("Failed to read build.rs")?;
1002
1003        // Check if validate call is already present
1004        if content.contains(&validate_call) {
1005            return Ok(());
1006        }
1007
1008        // Verify the file parses as valid Rust with syn
1009        let file: syn::File = syn::parse_str(&content).context("Failed to parse build.rs")?;
1010
1011        // Check that a main function exists
1012        let has_main = file
1013            .items
1014            .iter()
1015            .any(|item| matches!(item, syn::Item::Fn(func) if func.sig.ident == "main"));
1016
1017        if has_main {
1018            // Find the closing brace of main using string manipulation
1019            let lines: Vec<&str> = content.lines().collect();
1020            let mut insert_line = None;
1021            let mut brace_depth: i32 = 0;
1022            let mut in_main = false;
1023
1024            for (i, line) in lines.iter().enumerate() {
1025                if line.contains("fn main") {
1026                    in_main = true;
1027                    brace_depth = 0;
1028                }
1029                if in_main {
1030                    for ch in line.chars() {
1031                        if ch == '{' {
1032                            brace_depth += 1;
1033                        } else if ch == '}' {
1034                            brace_depth -= 1;
1035                            if brace_depth == 0 {
1036                                insert_line = Some(i);
1037                                in_main = false;
1038                                break;
1039                            }
1040                        }
1041                    }
1042                }
1043            }
1044
1045            if let Some(line_idx) = insert_line {
1046                let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1047                new_lines.insert(line_idx, format!("    {}", validate_call));
1048                std::fs::write(build_rs_path, new_lines.join("\n") + "\n")
1049                    .context("Failed to write build.rs")?;
1050                return Ok(());
1051            }
1052        }
1053
1054        // Fallback: no main function found or couldn't locate closing brace
1055        bail!(
1056            "Could not find fn main() in build.rs. Please add `{}` manually.",
1057            validate_call
1058        );
1059    } else {
1060        // Create new build.rs
1061        let content = format!("fn main() {{\n    {}\n}}\n", validate_call);
1062        std::fs::write(build_rs_path, content).context("Failed to create build.rs")?;
1063    }
1064
1065    Ok(())
1066}
1067
1068fn generate_from_local(
1069    battery_pack: &str,
1070    local_path: &str,
1071    name: Option<String>,
1072    template: Option<String>,
1073    defines: std::collections::BTreeMap<String, String>,
1074) -> Result<()> {
1075    let local_path = Path::new(local_path);
1076
1077    // Read local Cargo.toml
1078    let manifest_path = local_path.join("Cargo.toml");
1079    let manifest_content = std::fs::read_to_string(&manifest_path)
1080        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1081
1082    let crate_name = local_path
1083        .file_name()
1084        .and_then(|s| s.to_str())
1085        .unwrap_or("unknown");
1086    let templates = parse_template_metadata(&manifest_content, crate_name)?;
1087    let template_path = resolve_template(&templates, template.as_deref())?;
1088
1089    generate_from_path(battery_pack, local_path, &template_path, name, defines)
1090}
1091
1092/// Prompt for a project name if not provided.
1093fn prompt_project_name(name: Option<String>) -> Result<String> {
1094    match name {
1095        Some(n) => Ok(n),
1096        None => dialoguer::Input::<String>::new()
1097            .with_prompt("Project name")
1098            .interact_text()
1099            .context("Failed to read project name"),
1100    }
1101}
1102
1103/// Ensure a project name ends with `-battery-pack`.
1104fn ensure_battery_pack_suffix(name: String) -> String {
1105    if name.ends_with("-battery-pack") {
1106        name
1107    } else {
1108        let fixed = format!("{}-battery-pack", name);
1109        println!("Renaming project to: {}", fixed);
1110        fixed
1111    }
1112}
1113
1114fn generate_from_path(
1115    battery_pack: &str,
1116    crate_path: &Path,
1117    template_path: &str,
1118    name: Option<String>,
1119    defines: std::collections::BTreeMap<String, String>,
1120) -> Result<()> {
1121    let raw = prompt_project_name(name)?;
1122    let project_name = if battery_pack == "battery-pack" {
1123        ensure_battery_pack_suffix(raw)
1124    } else {
1125        raw
1126    };
1127
1128    let opts = crate::template_engine::GenerateOpts {
1129        render: crate::template_engine::RenderOpts {
1130            crate_root: crate_path.to_path_buf(),
1131            template_path: template_path.to_string(),
1132            project_name,
1133            defines,
1134            interactive_override: None,
1135        },
1136        destination: None,
1137        git_init: true,
1138    };
1139
1140    crate::template_engine::generate(opts)?;
1141
1142    Ok(())
1143}
1144
1145/// Parse a `key=value` string for clap's `value_parser`.
1146fn parse_define(s: &str) -> Result<(String, String), String> {
1147    let (key, value) = s
1148        .split_once('=')
1149        .ok_or_else(|| format!("invalid define '{s}': expected key=value"))?;
1150    Ok((key.to_string(), value.to_string()))
1151}
1152
1153fn parse_template_metadata(
1154    manifest_content: &str,
1155    crate_name: &str,
1156) -> Result<BTreeMap<String, TemplateConfig>> {
1157    let spec = bphelper_manifest::parse_battery_pack(manifest_content)
1158        .map_err(|e| anyhow::anyhow!("Failed to parse Cargo.toml: {}", e))?;
1159
1160    if spec.templates.is_empty() {
1161        bail!(
1162            "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
1163            crate_name
1164        );
1165    }
1166
1167    Ok(spec.templates)
1168}
1169
1170// [impl format.templates.selection]
1171// [impl cli.new.template-select]
1172pub(crate) fn resolve_template(
1173    templates: &BTreeMap<String, TemplateConfig>,
1174    requested: Option<&str>,
1175) -> Result<String> {
1176    match requested {
1177        Some(name) => {
1178            let config = templates.get(name).ok_or_else(|| {
1179                let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
1180                anyhow::anyhow!(
1181                    "Template '{}' not found. Available templates: {}",
1182                    name,
1183                    available.join(", ")
1184                )
1185            })?;
1186            Ok(config.path.clone())
1187        }
1188        None => {
1189            if templates.len() == 1 {
1190                // Only one template, use it
1191                let (_, config) = templates.iter().next().unwrap();
1192                Ok(config.path.clone())
1193            } else if let Some(config) = templates.get("default") {
1194                // Multiple templates, but there's a 'default'
1195                Ok(config.path.clone())
1196            } else {
1197                // Multiple templates, no default - prompt user to pick
1198                prompt_for_template(templates)
1199            }
1200        }
1201    }
1202}
1203
1204fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
1205    use dialoguer::{Select, theme::ColorfulTheme};
1206
1207    // Build display items with descriptions
1208    let items: Vec<String> = templates
1209        .iter()
1210        .map(|(name, config)| {
1211            if let Some(desc) = &config.description {
1212                format!("{} - {}", name, desc)
1213            } else {
1214                name.clone()
1215            }
1216        })
1217        .collect();
1218
1219    // Check if we're in a TTY for interactive mode
1220    if !std::io::stdout().is_terminal() {
1221        // Non-interactive: list templates and bail
1222        println!("Available templates:");
1223        for item in &items {
1224            println!("  {}", item);
1225        }
1226        bail!("Multiple templates available. Please specify one with --template <name>");
1227    }
1228
1229    // Interactive: show selector
1230    let selection = Select::with_theme(&ColorfulTheme::default())
1231        .with_prompt("Select a template")
1232        .items(&items)
1233        .default(0)
1234        .interact()
1235        .context("Failed to select template")?;
1236
1237    // Get the selected template's path
1238    let (_, config) = templates
1239        .iter()
1240        .nth(selection)
1241        .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
1242    Ok(config.path.clone())
1243}
1244
1245fn print_battery_pack_list(source: &CrateSource, filter: Option<&str>) -> Result<()> {
1246    use console::style;
1247
1248    let battery_packs = fetch_battery_pack_list(source, filter)?;
1249
1250    if battery_packs.is_empty() {
1251        match filter {
1252            Some(q) => println!("No battery packs found matching '{}'", q),
1253            None => println!("No battery packs found"),
1254        }
1255        return Ok(());
1256    }
1257
1258    // Find the longest name for alignment
1259    let max_name_len = battery_packs
1260        .iter()
1261        .map(|c| c.short_name.len())
1262        .max()
1263        .unwrap_or(0);
1264
1265    let max_version_len = battery_packs
1266        .iter()
1267        .map(|c| c.version.len())
1268        .max()
1269        .unwrap_or(0);
1270
1271    println!();
1272    for bp in &battery_packs {
1273        let desc = bp.description.lines().next().unwrap_or("");
1274
1275        // Pad strings manually, then apply colors (ANSI codes break width formatting)
1276        let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
1277        let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
1278
1279        println!(
1280            "  {}  {}  {}",
1281            style(name_padded).green().bold(),
1282            style(ver_padded).dim(),
1283            desc,
1284        );
1285    }
1286    println!();
1287
1288    println!(
1289        "{}",
1290        style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
1291    );
1292
1293    Ok(())
1294}
1295
1296fn print_battery_pack_detail(name: &str, path: Option<&str>, source: &CrateSource) -> Result<()> {
1297    use console::style;
1298
1299    // --path takes precedence over --crate-source
1300    let detail = if path.is_some() {
1301        fetch_battery_pack_detail(name, path)?
1302    } else {
1303        fetch_battery_pack_detail_from_source(source, name)?
1304    };
1305
1306    // Header
1307    println!();
1308    println!(
1309        "{} {}",
1310        style(&detail.name).green().bold(),
1311        style(&detail.version).dim()
1312    );
1313    if !detail.description.is_empty() {
1314        println!("{}", detail.description);
1315    }
1316
1317    // Authors
1318    if !detail.owners.is_empty() {
1319        println!();
1320        println!("{}", style("Authors:").bold());
1321        for owner in &detail.owners {
1322            if let Some(name) = &owner.name {
1323                println!("  {} ({})", name, owner.login);
1324            } else {
1325                println!("  {}", owner.login);
1326            }
1327        }
1328    }
1329
1330    // Crates
1331    if !detail.crates.is_empty() {
1332        println!();
1333        println!("{}", style("Crates:").bold());
1334        for dep in &detail.crates {
1335            println!("  {}", dep);
1336        }
1337    }
1338
1339    // Extends
1340    if !detail.extends.is_empty() {
1341        println!();
1342        println!("{}", style("Extends:").bold());
1343        for dep in &detail.extends {
1344            println!("  {}", dep);
1345        }
1346    }
1347
1348    // Templates
1349    if !detail.templates.is_empty() {
1350        println!();
1351        println!("{}", style("Templates:").bold());
1352        let max_name_len = detail
1353            .templates
1354            .iter()
1355            .map(|t| t.name.len())
1356            .max()
1357            .unwrap_or(0);
1358        for tmpl in &detail.templates {
1359            let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
1360            if let Some(desc) = &tmpl.description {
1361                println!("  {}  {}", style(name_padded).cyan(), desc);
1362            } else {
1363                println!("  {}", style(name_padded).cyan());
1364            }
1365        }
1366    }
1367
1368    // [impl format.examples.browsable]
1369    // Examples
1370    if !detail.examples.is_empty() {
1371        println!();
1372        println!("{}", style("Examples:").bold());
1373        let max_name_len = detail
1374            .examples
1375            .iter()
1376            .map(|e| e.name.len())
1377            .max()
1378            .unwrap_or(0);
1379        for example in &detail.examples {
1380            let name_padded = format!("{:<width$}", example.name, width = max_name_len);
1381            if let Some(desc) = &example.description {
1382                println!("  {}  {}", style(name_padded).magenta(), desc);
1383            } else {
1384                println!("  {}", style(name_padded).magenta());
1385            }
1386        }
1387    }
1388
1389    // Install hints
1390    println!();
1391    println!("{}", style("Install:").bold());
1392    println!("  cargo bp add {}", detail.short_name);
1393    println!("  cargo bp new {}", detail.short_name);
1394    println!();
1395
1396    Ok(())
1397}
1398
1399// ============================================================================
1400// Status command
1401// ============================================================================
1402
1403// [impl cli.status.list]
1404// [impl cli.status.version-warn]
1405// [impl cli.status.no-project]
1406// [impl cli.source.subcommands]
1407// [impl cli.path.subcommands]
1408fn status_battery_packs(
1409    project_dir: &Path,
1410    path: Option<&str>,
1411    source: &CrateSource,
1412) -> Result<()> {
1413    use console::style;
1414
1415    // [impl cli.status.no-project]
1416    let user_manifest_path =
1417        find_user_manifest(project_dir).context("are you inside a Rust project?")?;
1418    let user_manifest_content =
1419        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
1420
1421    // Inline the load_installed_packs logic to avoid re-reading the manifest.
1422    let bp_names = find_installed_bp_names(&user_manifest_content)?;
1423    let metadata_location = resolve_metadata_location(&user_manifest_path)?;
1424    let packs: Vec<InstalledPack> = bp_names
1425        .into_iter()
1426        .map(|bp_name| {
1427            let spec = load_installed_bp_spec(&bp_name, path, source)?;
1428            let active_features =
1429                read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
1430            Ok(InstalledPack {
1431                short_name: short_name(&bp_name).to_string(),
1432                version: spec.version.clone(),
1433                spec,
1434                name: bp_name,
1435                active_features,
1436            })
1437        })
1438        .collect::<Result<_>>()?;
1439
1440    if packs.is_empty() {
1441        println!("No battery packs installed.");
1442        return Ok(());
1443    }
1444
1445    // Build a map of the user's actual dependency versions so we can compare.
1446    let user_versions = collect_user_dep_versions(&user_manifest_path, &user_manifest_content)?;
1447
1448    let mut any_warnings = false;
1449
1450    for pack in &packs {
1451        // [impl cli.status.list]
1452        println!(
1453            "{} ({})",
1454            style(&pack.short_name).bold(),
1455            style(&pack.version).dim(),
1456        );
1457
1458        // Resolve which crates are expected for this pack's active features.
1459        let expected = pack.spec.resolve_for_features(&pack.active_features);
1460
1461        let mut pack_warnings = Vec::new();
1462        for (dep_name, dep_spec) in &expected {
1463            if dep_spec.version.is_empty() {
1464                continue;
1465            }
1466            if let Some(user_version) = user_versions.get(dep_name.as_str()) {
1467                // [impl cli.status.version-warn]
1468                if should_upgrade_version(user_version, &dep_spec.version) {
1469                    pack_warnings.push((
1470                        dep_name.as_str(),
1471                        user_version.as_str(),
1472                        dep_spec.version.as_str(),
1473                    ));
1474                }
1475            }
1476        }
1477
1478        if pack_warnings.is_empty() {
1479            println!("  {} all dependencies up to date", style("✓").green());
1480        } else {
1481            any_warnings = true;
1482            for (dep, current, recommended) in &pack_warnings {
1483                println!(
1484                    "  {} {}: {} → {} recommended",
1485                    style("⚠").yellow(),
1486                    dep,
1487                    style(current).red(),
1488                    style(recommended).green(),
1489                );
1490            }
1491        }
1492    }
1493
1494    if any_warnings {
1495        println!();
1496        println!("Run {} to update.", style("cargo bp sync").bold());
1497    }
1498
1499    Ok(())
1500}
1501
1502/// Collect the user's actual dependency versions from Cargo.toml (and workspace deps if applicable).
1503///
1504/// Returns a map of `crate_name → version_string`.
1505pub(crate) fn collect_user_dep_versions(
1506    user_manifest_path: &Path,
1507    user_manifest_content: &str,
1508) -> Result<BTreeMap<String, String>> {
1509    let raw: toml::Value =
1510        toml::from_str(user_manifest_content).context("Failed to parse Cargo.toml")?;
1511
1512    let mut versions = BTreeMap::new();
1513
1514    // Read workspace dependency versions (if applicable).
1515    let ws_versions = if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
1516        let ws_content =
1517            std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
1518        let ws_raw: toml::Value =
1519            toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
1520        extract_versions_from_table(
1521            ws_raw
1522                .get("workspace")
1523                .and_then(|w| w.get("dependencies"))
1524                .and_then(|d| d.as_table()),
1525        )
1526    } else {
1527        BTreeMap::new()
1528    };
1529
1530    // Collect from each dependency section.
1531    for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
1532        let table = raw.get(section).and_then(|d| d.as_table());
1533        let Some(table) = table else { continue };
1534        for (name, value) in table {
1535            if versions.contains_key(name) {
1536                continue; // first section wins
1537            }
1538            if let Some(version) = extract_version_from_dep(value) {
1539                versions.insert(name.clone(), version);
1540            } else if is_workspace_ref(value) {
1541                // Resolve from workspace deps.
1542                if let Some(ws_ver) = ws_versions.get(name) {
1543                    versions.insert(name.clone(), ws_ver.clone());
1544                }
1545            }
1546        }
1547    }
1548
1549    Ok(versions)
1550}
1551
1552/// Extract version strings from a TOML dependency table.
1553fn extract_versions_from_table(
1554    table: Option<&toml::map::Map<String, toml::Value>>,
1555) -> BTreeMap<String, String> {
1556    let Some(table) = table else {
1557        return BTreeMap::new();
1558    };
1559    let mut versions = BTreeMap::new();
1560    for (name, value) in table {
1561        if let Some(version) = extract_version_from_dep(value) {
1562            versions.insert(name.clone(), version);
1563        }
1564    }
1565    versions
1566}
1567
1568/// Extract the version string from a single dependency value.
1569///
1570/// Handles both `crate = "1.0"` and `crate = { version = "1.0", ... }`.
1571fn extract_version_from_dep(value: &toml::Value) -> Option<String> {
1572    match value {
1573        toml::Value::String(s) => Some(s.clone()),
1574        toml::Value::Table(t) => t
1575            .get("version")
1576            .and_then(|v| v.as_str())
1577            .map(|s| s.to_string()),
1578        _ => None,
1579    }
1580}
1581
1582/// Check if a dependency entry is a workspace reference (`{ workspace = true }`).
1583fn is_workspace_ref(value: &toml::Value) -> bool {
1584    match value {
1585        toml::Value::Table(t) => t
1586            .get("workspace")
1587            .and_then(|v| v.as_bool())
1588            .unwrap_or(false),
1589        _ => false,
1590    }
1591}
1592
1593#[cfg(test)]
1594mod tests;