Skip to main content

bphelper_cli/
lib.rs

1//! CLI for battery-pack: create and manage battery packs.
2
3use anyhow::{Context, Result, bail};
4use cargo_generate::{GenerateArgs, TemplatePath, Vcs};
5use clap::{Parser, Subcommand};
6use flate2::read::GzDecoder;
7use serde::Deserialize;
8use std::collections::BTreeMap;
9use std::io::IsTerminal;
10use std::path::Path;
11use tar::Archive;
12
13mod tui;
14
15const CRATES_IO_API: &str = "https://crates.io/api/v1/crates";
16const CRATES_IO_CDN: &str = "https://static.crates.io/crates";
17
18#[derive(Parser)]
19#[command(name = "cargo-bp")]
20#[command(bin_name = "cargo")]
21#[command(version, about = "Create and manage battery packs", long_about = None)]
22pub struct Cli {
23    #[command(subcommand)]
24    pub command: Commands,
25}
26
27#[derive(Subcommand)]
28pub enum Commands {
29    /// Battery pack commands
30    Bp {
31        #[command(subcommand)]
32        command: BpCommands,
33    },
34}
35
36#[derive(Subcommand)]
37pub enum BpCommands {
38    /// Create a new project from a battery pack template
39    New {
40        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
41        battery_pack: String,
42
43        /// Name for the new project (prompted interactively if not provided)
44        #[arg(long, short = 'n')]
45        name: Option<String>,
46
47        /// Which template to use (defaults to first available, or prompts if multiple)
48        #[arg(long, short = 't')]
49        template: Option<String>,
50
51        /// Use a local path instead of downloading from crates.io
52        #[arg(long)]
53        path: Option<String>,
54    },
55
56    /// Add a battery pack and sync its dependencies
57    Add {
58        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
59        battery_pack: String,
60
61        /// Named sets to enable (in addition to the default set)
62        #[arg(long, short = 'w')]
63        with: Vec<String>,
64
65        /// Sync all dev-dependencies regardless of default set
66        #[arg(long)]
67        all: bool,
68
69        /// Use a local path instead of downloading from crates.io
70        #[arg(long)]
71        path: Option<String>,
72    },
73
74    /// Update dependencies from installed battery packs
75    Sync,
76
77    /// Enable a named set from a battery pack
78    Enable {
79        /// Name of the set to enable
80        set_name: String,
81
82        /// Battery pack to search (optional — searches all installed if omitted)
83        #[arg(long)]
84        battery_pack: Option<String>,
85    },
86
87    /// List available battery packs on crates.io
88    List {
89        /// Filter by name (omit to list all battery packs)
90        filter: Option<String>,
91
92        /// Disable interactive TUI mode
93        #[arg(long)]
94        non_interactive: bool,
95    },
96
97    /// Show detailed information about a battery pack
98    Show {
99        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
100        battery_pack: String,
101
102        /// Use a local path instead of downloading from crates.io
103        #[arg(long)]
104        path: Option<String>,
105
106        /// Disable interactive TUI mode
107        #[arg(long)]
108        non_interactive: bool,
109    },
110}
111
112/// Main entry point for the CLI.
113pub fn main() -> Result<()> {
114    let cli = Cli::parse();
115
116    match cli.command {
117        Commands::Bp { command } => match command {
118            BpCommands::New {
119                battery_pack,
120                name,
121                template,
122                path,
123            } => new_from_battery_pack(&battery_pack, name, template, path),
124            BpCommands::Add {
125                battery_pack,
126                with,
127                all,
128                path,
129            } => add_battery_pack(&battery_pack, &with, all, path.as_deref()),
130            BpCommands::Sync => sync_battery_packs(),
131            BpCommands::Enable {
132                set_name,
133                battery_pack,
134            } => enable_set(&set_name, battery_pack.as_deref()),
135            BpCommands::List {
136                filter,
137                non_interactive,
138            } => {
139                if !non_interactive && std::io::stdout().is_terminal() {
140                    tui::run_list(filter)
141                } else {
142                    print_battery_pack_list(filter.as_deref())
143                }
144            }
145            BpCommands::Show {
146                battery_pack,
147                path,
148                non_interactive,
149            } => {
150                if !non_interactive && std::io::stdout().is_terminal() {
151                    tui::run_show(&battery_pack, path.as_deref())
152                } else {
153                    print_battery_pack_detail(&battery_pack, path.as_deref())
154                }
155            }
156        },
157    }
158}
159
160// ============================================================================
161// crates.io API types
162// ============================================================================
163
164#[derive(Deserialize)]
165struct CratesIoResponse {
166    versions: Vec<VersionInfo>,
167}
168
169#[derive(Deserialize)]
170struct VersionInfo {
171    num: String,
172    yanked: bool,
173}
174
175#[derive(Deserialize)]
176struct SearchResponse {
177    crates: Vec<SearchCrate>,
178}
179
180#[derive(Deserialize)]
181struct SearchCrate {
182    name: String,
183    max_version: String,
184    description: Option<String>,
185}
186
187// ============================================================================
188// Battery pack metadata types (from Cargo.toml)
189// ============================================================================
190
191#[derive(Deserialize, Default)]
192struct CargoManifest {
193    package: Option<PackageSection>,
194    #[serde(default)]
195    dependencies: BTreeMap<String, toml::Value>,
196}
197
198#[derive(Deserialize, Default)]
199struct PackageSection {
200    name: Option<String>,
201    version: Option<String>,
202    description: Option<String>,
203    repository: Option<String>,
204    metadata: Option<PackageMetadata>,
205}
206
207#[derive(Deserialize, Default)]
208struct PackageMetadata {
209    battery: Option<BatteryMetadata>,
210}
211
212#[derive(Deserialize, Default)]
213struct BatteryMetadata {
214    #[serde(default)]
215    templates: BTreeMap<String, TemplateConfig>,
216}
217
218#[derive(Deserialize)]
219struct TemplateConfig {
220    path: String,
221    #[serde(default)]
222    description: Option<String>,
223}
224
225// ============================================================================
226// crates.io owner types
227// ============================================================================
228
229#[derive(Deserialize)]
230struct OwnersResponse {
231    users: Vec<Owner>,
232}
233
234#[derive(Deserialize, Clone)]
235struct Owner {
236    login: String,
237    name: Option<String>,
238}
239
240// ============================================================================
241// GitHub API types
242// ============================================================================
243
244#[derive(Deserialize)]
245struct GitHubTreeResponse {
246    tree: Vec<GitHubTreeEntry>,
247    #[serde(default)]
248    #[allow(dead_code)]
249    truncated: bool,
250}
251
252#[derive(Deserialize)]
253struct GitHubTreeEntry {
254    path: String,
255}
256
257// ============================================================================
258// Shared data types (used by both TUI and text output)
259// ============================================================================
260
261/// Summary info for displaying in a list
262#[derive(Clone)]
263pub struct BatteryPackSummary {
264    pub name: String,
265    pub short_name: String,
266    pub version: String,
267    pub description: String,
268}
269
270/// Detailed battery pack info
271#[derive(Clone)]
272pub struct BatteryPackDetail {
273    pub name: String,
274    pub short_name: String,
275    pub version: String,
276    pub description: String,
277    pub repository: Option<String>,
278    pub owners: Vec<OwnerInfo>,
279    pub crates: Vec<String>,
280    pub extends: Vec<String>,
281    pub templates: Vec<TemplateInfo>,
282    pub examples: Vec<ExampleInfo>,
283}
284
285#[derive(Clone)]
286pub struct OwnerInfo {
287    pub login: String,
288    pub name: Option<String>,
289}
290
291impl From<Owner> for OwnerInfo {
292    fn from(o: Owner) -> Self {
293        Self {
294            login: o.login,
295            name: o.name,
296        }
297    }
298}
299
300#[derive(Clone)]
301pub struct TemplateInfo {
302    pub name: String,
303    pub path: String,
304    pub description: Option<String>,
305    /// Full path in the repository (e.g., "src/cli-battery-pack/templates/simple")
306    /// Resolved by searching the GitHub tree API
307    pub repo_path: Option<String>,
308}
309
310#[derive(Clone)]
311pub struct ExampleInfo {
312    pub name: String,
313    pub description: Option<String>,
314    /// Full path in the repository (e.g., "src/cli-battery-pack/examples/mini-grep.rs")
315    /// Resolved by searching the GitHub tree API
316    pub repo_path: Option<String>,
317}
318
319// ============================================================================
320// Implementation
321// ============================================================================
322
323fn new_from_battery_pack(
324    battery_pack: &str,
325    name: Option<String>,
326    template: Option<String>,
327    path_override: Option<String>,
328) -> Result<()> {
329    // If using local path, generate directly from there
330    if let Some(path) = path_override {
331        return generate_from_local(&path, name, template);
332    }
333
334    // Resolve the crate name (add -battery-pack suffix if needed)
335    let crate_name = resolve_crate_name(battery_pack);
336
337    // Look up the crate on crates.io and get the latest version
338    let crate_info = lookup_crate(&crate_name)?;
339
340    // Download and extract the crate to a temp directory
341    let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
342    let crate_dir = temp_dir
343        .path()
344        .join(format!("{}-{}", crate_name, crate_info.version));
345
346    // Read template metadata from the extracted Cargo.toml
347    let manifest_path = crate_dir.join("Cargo.toml");
348    let manifest_content = std::fs::read_to_string(&manifest_path)
349        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
350    let templates = parse_template_metadata(&manifest_content, &crate_name)?;
351
352    // Resolve which template to use
353    let template_path = resolve_template(&templates, template.as_deref())?;
354
355    // Generate the project from the extracted crate
356    generate_from_path(&crate_dir, &template_path, name)
357}
358
359fn add_battery_pack(name: &str, with_sets: &[String], all: bool, path: Option<&str>) -> Result<()> {
360    let crate_name = resolve_crate_name(name);
361
362    // For registry deps, look up the version first (we need it for the build-dep entry)
363    let bp_version = if path.is_some() {
364        None
365    } else {
366        Some(lookup_crate(&crate_name)?.version)
367    };
368
369    // Step 1: Add battery pack to [build-dependencies] so cargo can resolve it
370    let user_manifest_path = find_user_manifest()?;
371    let user_manifest_content =
372        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
373    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
374        .parse()
375        .context("Failed to parse Cargo.toml")?;
376
377    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
378
379    let build_deps =
380        user_doc["build-dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
381    if let Some(table) = build_deps.as_table_mut() {
382        if let Some(local_path) = path {
383            let mut dep = toml_edit::InlineTable::new();
384            dep.insert("path", toml_edit::Value::from(local_path));
385            table.insert(
386                &crate_name,
387                toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
388            );
389        } else if workspace_manifest.is_some() {
390            let mut dep = toml_edit::InlineTable::new();
391            dep.insert("workspace", toml_edit::Value::from(true));
392            table.insert(
393                &crate_name,
394                toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
395            );
396        } else {
397            table.insert(&crate_name, toml_edit::value(bp_version.as_ref().unwrap()));
398        }
399    }
400
401    // Also add to workspace deps if applicable
402    if let Some(ref ws_path) = workspace_manifest {
403        let ws_content =
404            std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
405        let mut ws_doc: toml_edit::DocumentMut = ws_content
406            .parse()
407            .context("Failed to parse workspace Cargo.toml")?;
408
409        let ws_deps = ws_doc["workspace"]["dependencies"]
410            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
411        if let Some(ws_table) = ws_deps.as_table_mut() {
412            if let Some(local_path) = path {
413                let mut dep = toml_edit::InlineTable::new();
414                dep.insert("path", toml_edit::Value::from(local_path));
415                ws_table.insert(
416                    &crate_name,
417                    toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
418                );
419            } else {
420                ws_table.insert(&crate_name, toml_edit::value(bp_version.as_ref().unwrap()));
421            }
422        }
423        std::fs::write(ws_path, ws_doc.to_string())
424            .context("Failed to write workspace Cargo.toml")?;
425    }
426
427    // Write the Cargo.toml with the build-dep so cargo metadata can resolve it
428    std::fs::write(&user_manifest_path, user_doc.to_string())
429        .context("Failed to write Cargo.toml")?;
430
431    // Step 2: Use cargo metadata to read the battery pack spec
432    let bp_spec = fetch_battery_pack_spec(&crate_name)?;
433
434    // Determine which crates to sync
435    let active_sets = if all {
436        vec!["default".to_string()]
437    } else {
438        let mut sets = vec!["default".to_string()];
439        sets.extend(with_sets.iter().cloned());
440        sets
441    };
442
443    let crates_to_sync = if all {
444        bp_spec.resolve_all()
445    } else {
446        bp_spec.resolve_crates(&active_sets)
447    };
448
449    // Step 3: Re-read and update Cargo.toml with the resolved crate dependencies
450    let user_manifest_content =
451        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
452    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
453        .parse()
454        .context("Failed to parse Cargo.toml")?;
455
456    if let Some(ref ws_path) = workspace_manifest {
457        let ws_content =
458            std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
459        let mut ws_doc: toml_edit::DocumentMut = ws_content
460            .parse()
461            .context("Failed to parse workspace Cargo.toml")?;
462
463        let ws_deps = ws_doc["workspace"]["dependencies"]
464            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
465        if let Some(ws_table) = ws_deps.as_table_mut() {
466            for (dep_name, dep_spec) in &crates_to_sync {
467                add_dep_to_table(ws_table, dep_name, dep_spec);
468            }
469        }
470        std::fs::write(ws_path, ws_doc.to_string())
471            .context("Failed to write workspace Cargo.toml")?;
472
473        let deps =
474            user_doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
475        if let Some(table) = deps.as_table_mut() {
476            for dep_name in crates_to_sync.keys() {
477                let mut dep = toml_edit::InlineTable::new();
478                dep.insert("workspace", toml_edit::Value::from(true));
479                table.insert(
480                    dep_name,
481                    toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
482                );
483            }
484        }
485    } else {
486        let deps =
487            user_doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
488        if let Some(table) = deps.as_table_mut() {
489            for (dep_name, dep_spec) in &crates_to_sync {
490                add_dep_to_table(table, dep_name, dep_spec);
491            }
492        }
493    }
494
495    // Record active sets in [package.metadata.battery-pack.<crate-name>]
496    let bp_meta = &mut user_doc["package"]["metadata"]["battery-pack"][&crate_name];
497    let mut sets_array = toml_edit::Array::new();
498    if all {
499        sets_array.push("all");
500    } else {
501        for set in &active_sets {
502            sets_array.push(set.as_str());
503        }
504    }
505    *bp_meta = toml_edit::Item::Table(toml_edit::Table::new());
506    bp_meta["sets"] = toml_edit::value(sets_array);
507
508    // Write the final Cargo.toml
509    std::fs::write(&user_manifest_path, user_doc.to_string())
510        .context("Failed to write Cargo.toml")?;
511
512    // Create/modify build.rs
513    let build_rs_path = user_manifest_path
514        .parent()
515        .unwrap_or(Path::new("."))
516        .join("build.rs");
517    update_build_rs(&build_rs_path, &crate_name)?;
518
519    println!(
520        "Added {} with {} crate(s)",
521        crate_name,
522        crates_to_sync.len()
523    );
524    for dep_name in crates_to_sync.keys() {
525        println!("  + {}", dep_name);
526    }
527
528    Ok(())
529}
530
531fn sync_battery_packs() -> Result<()> {
532    let user_manifest_path = find_user_manifest()?;
533    let user_manifest_content =
534        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
535
536    // Find installed battery packs from [build-dependencies]
537    let raw: toml::Value =
538        toml::from_str(&user_manifest_content).context("Failed to parse Cargo.toml")?;
539
540    let build_deps = raw
541        .get("build-dependencies")
542        .and_then(|bd| bd.as_table())
543        .cloned()
544        .unwrap_or_default();
545
546    let bp_names: Vec<String> = build_deps
547        .keys()
548        .filter(|k| k.ends_with("-battery-pack") || *k == "battery-pack")
549        .cloned()
550        .collect();
551
552    if bp_names.is_empty() {
553        println!("No battery packs installed.");
554        return Ok(());
555    }
556
557    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
558        .parse()
559        .context("Failed to parse Cargo.toml")?;
560
561    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
562    let mut total_changes = 0;
563
564    for bp_name in &bp_names {
565        // Get the battery pack spec
566        let bp_spec = fetch_battery_pack_spec(bp_name)?;
567
568        // Read active sets from user metadata
569        let active_sets = read_active_sets(&user_manifest_content, bp_name);
570
571        let expected = if active_sets.iter().any(|s| s == "all") {
572            bp_spec.resolve_all()
573        } else {
574            bp_spec.resolve_crates(&active_sets)
575        };
576
577        // Sync each crate
578        if let Some(ref ws_path) = workspace_manifest {
579            let ws_content =
580                std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
581            let mut ws_doc: toml_edit::DocumentMut = ws_content
582                .parse()
583                .context("Failed to parse workspace Cargo.toml")?;
584
585            let ws_deps = ws_doc["workspace"]["dependencies"]
586                .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
587            if let Some(ws_table) = ws_deps.as_table_mut() {
588                for (dep_name, dep_spec) in &expected {
589                    if sync_dep_in_table(ws_table, dep_name, dep_spec) {
590                        total_changes += 1;
591                        println!("  ~ {} (updated in workspace)", dep_name);
592                    }
593                }
594            }
595            std::fs::write(ws_path, ws_doc.to_string())
596                .context("Failed to write workspace Cargo.toml")?;
597
598            // Ensure crate-level references exist
599            let deps =
600                user_doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
601            if let Some(table) = deps.as_table_mut() {
602                for dep_name in expected.keys() {
603                    if !table.contains_key(dep_name) {
604                        let mut dep = toml_edit::InlineTable::new();
605                        dep.insert("workspace", toml_edit::Value::from(true));
606                        table.insert(
607                            dep_name,
608                            toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
609                        );
610                        total_changes += 1;
611                        println!("  + {} (added workspace reference)", dep_name);
612                    }
613                }
614            }
615        } else {
616            let deps =
617                user_doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
618            if let Some(table) = deps.as_table_mut() {
619                for (dep_name, dep_spec) in &expected {
620                    if !table.contains_key(dep_name) {
621                        add_dep_to_table(table, dep_name, dep_spec);
622                        total_changes += 1;
623                        println!("  + {}", dep_name);
624                    } else if sync_dep_in_table(table, dep_name, dep_spec) {
625                        total_changes += 1;
626                        println!("  ~ {}", dep_name);
627                    }
628                }
629            }
630        }
631    }
632
633    std::fs::write(&user_manifest_path, user_doc.to_string())
634        .context("Failed to write Cargo.toml")?;
635
636    if total_changes == 0 {
637        println!("All dependencies are up to date.");
638    } else {
639        println!("Synced {} change(s).", total_changes);
640    }
641
642    Ok(())
643}
644
645fn enable_set(set_name: &str, battery_pack: Option<&str>) -> Result<()> {
646    let user_manifest_path = find_user_manifest()?;
647    let user_manifest_content =
648        std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
649
650    let raw: toml::Value =
651        toml::from_str(&user_manifest_content).context("Failed to parse Cargo.toml")?;
652
653    let build_deps = raw
654        .get("build-dependencies")
655        .and_then(|bd| bd.as_table())
656        .cloned()
657        .unwrap_or_default();
658
659    // Find which battery pack has this set
660    let bp_name = if let Some(name) = battery_pack {
661        resolve_crate_name(name)
662    } else {
663        // Search all installed battery packs
664        let bp_names: Vec<String> = build_deps
665            .keys()
666            .filter(|k| k.ends_with("-battery-pack") || *k == "battery-pack")
667            .cloned()
668            .collect();
669
670        let mut found = None;
671        for name in &bp_names {
672            let spec = fetch_battery_pack_spec(name)?;
673            if spec.sets.contains_key(set_name) {
674                found = Some(name.clone());
675                break;
676            }
677        }
678        found.ok_or_else(|| {
679            anyhow::anyhow!("No installed battery pack defines set '{}'", set_name)
680        })?
681    };
682
683    let bp_spec = fetch_battery_pack_spec(&bp_name)?;
684
685    if !bp_spec.sets.contains_key(set_name) {
686        let available: Vec<_> = bp_spec.sets.keys().collect();
687        bail!(
688            "Battery pack '{}' has no set '{}'. Available: {:?}",
689            bp_name,
690            set_name,
691            available
692        );
693    }
694
695    // Add set to active sets
696    let mut active_sets = read_active_sets(&user_manifest_content, &bp_name);
697    if active_sets.contains(&set_name.to_string()) {
698        println!("Set '{}' is already active for {}.", set_name, bp_name);
699        return Ok(());
700    }
701    active_sets.push(set_name.to_string());
702
703    // Resolve what this changes
704    let crates_to_sync = bp_spec.resolve_crates(&active_sets);
705
706    // Update user manifest
707    let mut user_doc: toml_edit::DocumentMut = user_manifest_content
708        .parse()
709        .context("Failed to parse Cargo.toml")?;
710
711    let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
712
713    // Sync the new crates
714    if let Some(ref ws_path) = workspace_manifest {
715        let ws_content =
716            std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
717        let mut ws_doc: toml_edit::DocumentMut = ws_content
718            .parse()
719            .context("Failed to parse workspace Cargo.toml")?;
720
721        let ws_deps = ws_doc["workspace"]["dependencies"]
722            .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
723        if let Some(ws_table) = ws_deps.as_table_mut() {
724            for (dep_name, dep_spec) in &crates_to_sync {
725                add_dep_to_table(ws_table, dep_name, dep_spec);
726            }
727        }
728        std::fs::write(ws_path, ws_doc.to_string())
729            .context("Failed to write workspace Cargo.toml")?;
730
731        let deps =
732            user_doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
733        if let Some(table) = deps.as_table_mut() {
734            for dep_name in crates_to_sync.keys() {
735                if !table.contains_key(dep_name) {
736                    let mut dep = toml_edit::InlineTable::new();
737                    dep.insert("workspace", toml_edit::Value::from(true));
738                    table.insert(
739                        dep_name,
740                        toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
741                    );
742                }
743            }
744        }
745    } else {
746        let deps =
747            user_doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
748        if let Some(table) = deps.as_table_mut() {
749            for (dep_name, dep_spec) in &crates_to_sync {
750                if !table.contains_key(dep_name) {
751                    add_dep_to_table(table, dep_name, dep_spec);
752                }
753            }
754        }
755    }
756
757    // Update active sets in metadata
758    let bp_meta = &mut user_doc["package"]["metadata"]["battery-pack"][&bp_name];
759    let mut sets_array = toml_edit::Array::new();
760    for set in &active_sets {
761        sets_array.push(set.as_str());
762    }
763    *bp_meta = toml_edit::Item::Table(toml_edit::Table::new());
764    bp_meta["sets"] = toml_edit::value(sets_array);
765
766    std::fs::write(&user_manifest_path, user_doc.to_string())
767        .context("Failed to write Cargo.toml")?;
768
769    println!("Enabled set '{}' from {}", set_name, bp_name);
770    Ok(())
771}
772
773// ============================================================================
774// Cargo.toml manipulation helpers
775// ============================================================================
776
777/// Find the user's Cargo.toml in the current directory.
778fn find_user_manifest() -> Result<std::path::PathBuf> {
779    let path = std::path::PathBuf::from("Cargo.toml");
780    if path.exists() {
781        Ok(path)
782    } else {
783        bail!("No Cargo.toml found in the current directory");
784    }
785}
786
787/// Find the workspace root Cargo.toml, if any.
788/// Returns None if the crate is not in a workspace.
789fn find_workspace_manifest(crate_manifest: &Path) -> Result<Option<std::path::PathBuf>> {
790    let crate_dir = crate_manifest
791        .parent()
792        .unwrap_or(Path::new("."))
793        .canonicalize()
794        .context("Failed to resolve crate directory")?;
795
796    // Walk up from the crate directory looking for a workspace root
797    let mut dir = crate_dir.clone();
798    loop {
799        let candidate = dir.join("Cargo.toml");
800        if candidate.exists() && candidate != crate_dir.join("Cargo.toml") {
801            let content = std::fs::read_to_string(&candidate)?;
802            if content.contains("[workspace]") {
803                return Ok(Some(candidate));
804            }
805        }
806        if !dir.pop() {
807            break;
808        }
809    }
810
811    // Also check if the crate's own Cargo.toml has a [workspace] section
812    // (single-crate workspace) — in that case we don't use workspace deps
813    Ok(None)
814}
815
816/// Add a dependency to a toml_edit table (non-workspace mode).
817fn add_dep_to_table(table: &mut toml_edit::Table, name: &str, spec: &bphelper_manifest::DepSpec) {
818    if spec.features.is_empty() {
819        table.insert(name, toml_edit::value(&spec.version));
820    } else {
821        let mut dep = toml_edit::InlineTable::new();
822        dep.insert("version", toml_edit::Value::from(spec.version.as_str()));
823        let mut features = toml_edit::Array::new();
824        for feat in &spec.features {
825            features.push(feat.as_str());
826        }
827        dep.insert("features", toml_edit::Value::Array(features));
828        table.insert(
829            name,
830            toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
831        );
832    }
833}
834
835/// Sync a dependency in-place: update version if behind, add missing features.
836/// Returns true if changes were made.
837fn sync_dep_in_table(
838    table: &mut toml_edit::Table,
839    name: &str,
840    spec: &bphelper_manifest::DepSpec,
841) -> bool {
842    let Some(existing) = table.get_mut(name) else {
843        // Not present — add it
844        add_dep_to_table(table, name, spec);
845        return true;
846    };
847
848    let mut changed = false;
849
850    match existing {
851        toml_edit::Item::Value(toml_edit::Value::String(version_str)) => {
852            // Simple version string — check if we need to upgrade or add features
853            let current = version_str.value().to_string();
854            if !spec.version.is_empty() && current != spec.version {
855                *version_str = toml_edit::Formatted::new(spec.version.clone());
856                changed = true;
857            }
858            if !spec.features.is_empty() {
859                // Need to convert from simple string to table format
860                add_dep_to_table(table, name, spec);
861                changed = true;
862            }
863        }
864        toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)) => {
865            // Check version
866            if let Some(toml_edit::Value::String(v)) = inline.get_mut("version") {
867                if !spec.version.is_empty() && v.value() != &spec.version {
868                    *v = toml_edit::Formatted::new(spec.version.clone());
869                    changed = true;
870                }
871            }
872            // Check features — add missing ones
873            if !spec.features.is_empty() {
874                let existing_features: Vec<String> = inline
875                    .get("features")
876                    .and_then(|f| f.as_array())
877                    .map(|arr| {
878                        arr.iter()
879                            .filter_map(|v| v.as_str().map(String::from))
880                            .collect()
881                    })
882                    .unwrap_or_default();
883
884                let mut needs_update = false;
885                let mut all_features = existing_features.clone();
886                for feat in &spec.features {
887                    if !existing_features.contains(feat) {
888                        all_features.push(feat.clone());
889                        needs_update = true;
890                    }
891                }
892
893                if needs_update {
894                    let mut arr = toml_edit::Array::new();
895                    for f in &all_features {
896                        arr.push(f.as_str());
897                    }
898                    inline.insert("features", toml_edit::Value::Array(arr));
899                    changed = true;
900                }
901            }
902        }
903        _ => {}
904    }
905
906    changed
907}
908
909/// Read active sets for a battery pack from user's metadata.
910fn read_active_sets(manifest_content: &str, bp_name: &str) -> Vec<String> {
911    let raw: toml::Value = match toml::from_str(manifest_content) {
912        Ok(v) => v,
913        Err(_) => return vec!["default".to_string()],
914    };
915
916    raw.get("package")
917        .and_then(|p| p.get("metadata"))
918        .and_then(|m| m.get("battery-pack"))
919        .and_then(|bp| bp.get(bp_name))
920        .and_then(|entry| entry.get("sets"))
921        .and_then(|sets| sets.as_array())
922        .map(|arr| {
923            arr.iter()
924                .filter_map(|v| v.as_str().map(String::from))
925                .collect()
926        })
927        .unwrap_or_else(|| vec!["default".to_string()])
928}
929
930/// Resolve the manifest path for a battery pack using `cargo metadata`.
931///
932/// Works for any dependency source: path deps, registry deps, git deps.
933/// The battery pack must already be in [build-dependencies].
934fn resolve_battery_pack_manifest(bp_name: &str) -> Result<std::path::PathBuf> {
935    let metadata = cargo_metadata::MetadataCommand::new()
936        .exec()
937        .context("Failed to run `cargo metadata`")?;
938
939    let package = metadata
940        .packages
941        .iter()
942        .find(|p| p.name == bp_name)
943        .ok_or_else(|| {
944            anyhow::anyhow!(
945                "Battery pack '{}' not found in dependency graph. Is it in [build-dependencies]?",
946                bp_name
947            )
948        })?;
949
950    Ok(package.manifest_path.clone().into())
951}
952
953/// Fetch the battery pack spec using `cargo metadata` to locate the manifest.
954fn fetch_battery_pack_spec(bp_name: &str) -> Result<bphelper_manifest::BatteryPackSpec> {
955    let manifest_path = resolve_battery_pack_manifest(bp_name)?;
956    let manifest_content = std::fs::read_to_string(&manifest_path)
957        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
958
959    bphelper_manifest::parse_battery_pack(&manifest_content)
960        .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e))
961}
962
963// ============================================================================
964// build.rs manipulation
965// ============================================================================
966
967/// Update or create build.rs to include a validate() call.
968fn update_build_rs(build_rs_path: &Path, crate_name: &str) -> Result<()> {
969    let crate_ident = crate_name.replace('-', "_");
970    let validate_call = format!("{}::validate();", crate_ident);
971
972    if build_rs_path.exists() {
973        let content = std::fs::read_to_string(build_rs_path).context("Failed to read build.rs")?;
974
975        // Check if validate call is already present
976        if content.contains(&validate_call) {
977            return Ok(());
978        }
979
980        // Verify the file parses as valid Rust with syn
981        let file: syn::File = syn::parse_str(&content).context("Failed to parse build.rs")?;
982
983        // Check that a main function exists
984        let has_main = file
985            .items
986            .iter()
987            .any(|item| matches!(item, syn::Item::Fn(func) if func.sig.ident == "main"));
988
989        if has_main {
990            // Find the closing brace of main using string manipulation
991            let lines: Vec<&str> = content.lines().collect();
992            let mut insert_line = None;
993            let mut brace_depth: i32 = 0;
994            let mut in_main = false;
995
996            for (i, line) in lines.iter().enumerate() {
997                if line.contains("fn main") {
998                    in_main = true;
999                    brace_depth = 0;
1000                }
1001                if in_main {
1002                    for ch in line.chars() {
1003                        if ch == '{' {
1004                            brace_depth += 1;
1005                        } else if ch == '}' {
1006                            brace_depth -= 1;
1007                            if brace_depth == 0 {
1008                                insert_line = Some(i);
1009                                in_main = false;
1010                                break;
1011                            }
1012                        }
1013                    }
1014                }
1015            }
1016
1017            if let Some(line_idx) = insert_line {
1018                let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1019                new_lines.insert(line_idx, format!("    {}", validate_call));
1020                std::fs::write(build_rs_path, new_lines.join("\n") + "\n")
1021                    .context("Failed to write build.rs")?;
1022                return Ok(());
1023            }
1024        }
1025
1026        // Fallback: no main function found or couldn't locate closing brace
1027        bail!(
1028            "Could not find fn main() in build.rs. Please add `{}` manually.",
1029            validate_call
1030        );
1031    } else {
1032        // Create new build.rs
1033        let content = format!("fn main() {{\n    {}\n}}\n", validate_call);
1034        std::fs::write(build_rs_path, content).context("Failed to create build.rs")?;
1035    }
1036
1037    Ok(())
1038}
1039
1040fn generate_from_local(
1041    local_path: &str,
1042    name: Option<String>,
1043    template: Option<String>,
1044) -> Result<()> {
1045    let local_path = Path::new(local_path);
1046
1047    // Read local Cargo.toml
1048    let manifest_path = local_path.join("Cargo.toml");
1049    let manifest_content = std::fs::read_to_string(&manifest_path)
1050        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1051
1052    let crate_name = local_path
1053        .file_name()
1054        .and_then(|s| s.to_str())
1055        .unwrap_or("unknown");
1056    let templates = parse_template_metadata(&manifest_content, crate_name)?;
1057    let template_path = resolve_template(&templates, template.as_deref())?;
1058
1059    generate_from_path(local_path, &template_path, name)
1060}
1061
1062fn generate_from_path(crate_path: &Path, template_path: &str, name: Option<String>) -> Result<()> {
1063    // In non-interactive mode, provide defaults for placeholders
1064    let define = if !std::io::stdout().is_terminal() {
1065        vec!["description=A battery pack for ...".to_string()]
1066    } else {
1067        vec![]
1068    };
1069
1070    let args = GenerateArgs {
1071        template_path: TemplatePath {
1072            path: Some(crate_path.to_string_lossy().into_owned()),
1073            auto_path: Some(template_path.to_string()),
1074            ..Default::default()
1075        },
1076        name,
1077        vcs: Some(Vcs::Git),
1078        define,
1079        ..Default::default()
1080    };
1081
1082    cargo_generate::generate(args)?;
1083
1084    Ok(())
1085}
1086
1087/// Info about a crate from crates.io
1088struct CrateMetadata {
1089    version: String,
1090}
1091
1092/// Look up a crate on crates.io and return its metadata
1093fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
1094    let client = reqwest::blocking::Client::builder()
1095        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
1096        .build()?;
1097
1098    let url = format!("{}/{}", CRATES_IO_API, crate_name);
1099    let response = client
1100        .get(&url)
1101        .send()
1102        .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
1103
1104    if !response.status().is_success() {
1105        bail!(
1106            "Crate '{}' not found on crates.io (status: {})",
1107            crate_name,
1108            response.status()
1109        );
1110    }
1111
1112    let parsed: CratesIoResponse = response
1113        .json()
1114        .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
1115
1116    // Find the latest non-yanked version
1117    let version = parsed
1118        .versions
1119        .iter()
1120        .find(|v| !v.yanked)
1121        .map(|v| v.num.clone())
1122        .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
1123
1124    Ok(CrateMetadata { version })
1125}
1126
1127/// Download a crate tarball and extract it to a temp directory
1128fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
1129    let client = reqwest::blocking::Client::builder()
1130        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
1131        .build()?;
1132
1133    // Download from CDN: https://static.crates.io/crates/{name}/{name}-{version}.crate
1134    let url = format!(
1135        "{}/{}/{}-{}.crate",
1136        CRATES_IO_CDN, crate_name, crate_name, version
1137    );
1138
1139    let response = client
1140        .get(&url)
1141        .send()
1142        .with_context(|| format!("Failed to download crate from {}", url))?;
1143
1144    if !response.status().is_success() {
1145        bail!(
1146            "Failed to download '{}' version {} (status: {})",
1147            crate_name,
1148            version,
1149            response.status()
1150        );
1151    }
1152
1153    let bytes = response
1154        .bytes()
1155        .with_context(|| "Failed to read crate tarball")?;
1156
1157    // Create temp directory and extract
1158    let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
1159
1160    let decoder = GzDecoder::new(&bytes[..]);
1161    let mut archive = Archive::new(decoder);
1162    archive
1163        .unpack(temp_dir.path())
1164        .with_context(|| "Failed to extract crate tarball")?;
1165
1166    Ok(temp_dir)
1167}
1168
1169fn parse_template_metadata(
1170    manifest_content: &str,
1171    crate_name: &str,
1172) -> Result<BTreeMap<String, TemplateConfig>> {
1173    let manifest: CargoManifest =
1174        toml::from_str(manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
1175
1176    let templates = manifest
1177        .package
1178        .and_then(|p| p.metadata)
1179        .and_then(|m| m.battery)
1180        .map(|b| b.templates)
1181        .unwrap_or_default();
1182
1183    if templates.is_empty() {
1184        bail!(
1185            "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
1186            crate_name
1187        );
1188    }
1189
1190    Ok(templates)
1191}
1192
1193fn resolve_template(
1194    templates: &BTreeMap<String, TemplateConfig>,
1195    requested: Option<&str>,
1196) -> Result<String> {
1197    match requested {
1198        Some(name) => {
1199            let config = templates.get(name).ok_or_else(|| {
1200                let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
1201                anyhow::anyhow!(
1202                    "Template '{}' not found. Available templates: {}",
1203                    name,
1204                    available.join(", ")
1205                )
1206            })?;
1207            Ok(config.path.clone())
1208        }
1209        None => {
1210            if templates.len() == 1 {
1211                // Only one template, use it
1212                let (_, config) = templates.iter().next().unwrap();
1213                Ok(config.path.clone())
1214            } else if let Some(config) = templates.get("default") {
1215                // Multiple templates, but there's a 'default'
1216                Ok(config.path.clone())
1217            } else {
1218                // Multiple templates, no default - prompt user to pick
1219                prompt_for_template(templates)
1220            }
1221        }
1222    }
1223}
1224
1225fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
1226    use dialoguer::{Select, theme::ColorfulTheme};
1227
1228    // Build display items with descriptions
1229    let items: Vec<String> = templates
1230        .iter()
1231        .map(|(name, config)| {
1232            if let Some(desc) = &config.description {
1233                format!("{} - {}", name, desc)
1234            } else {
1235                name.clone()
1236            }
1237        })
1238        .collect();
1239
1240    // Check if we're in a TTY for interactive mode
1241    if !std::io::stdout().is_terminal() {
1242        // Non-interactive: list templates and bail
1243        println!("Available templates:");
1244        for item in &items {
1245            println!("  {}", item);
1246        }
1247        bail!("Multiple templates available. Please specify one with --template <name>");
1248    }
1249
1250    // Interactive: show selector
1251    let selection = Select::with_theme(&ColorfulTheme::default())
1252        .with_prompt("Select a template")
1253        .items(&items)
1254        .default(0)
1255        .interact()
1256        .context("Failed to select template")?;
1257
1258    // Get the selected template's path
1259    let (_, config) = templates
1260        .iter()
1261        .nth(selection)
1262        .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
1263    Ok(config.path.clone())
1264}
1265
1266/// Fetch battery pack list from crates.io
1267pub fn fetch_battery_pack_list(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
1268    let client = reqwest::blocking::Client::builder()
1269        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
1270        .build()?;
1271
1272    // Build the search URL with keyword filter
1273    let url = match filter {
1274        Some(q) => format!(
1275            "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
1276            urlencoding::encode(q)
1277        ),
1278        None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
1279    };
1280
1281    let response = client
1282        .get(&url)
1283        .send()
1284        .context("Failed to query crates.io")?;
1285
1286    if !response.status().is_success() {
1287        bail!(
1288            "Failed to list battery packs (status: {})",
1289            response.status()
1290        );
1291    }
1292
1293    let parsed: SearchResponse = response.json().context("Failed to parse response")?;
1294
1295    // Filter to only crates whose name ends with "-battery-pack"
1296    let battery_packs = parsed
1297        .crates
1298        .into_iter()
1299        .filter(|c| c.name.ends_with("-battery-pack"))
1300        .map(|c| BatteryPackSummary {
1301            short_name: short_name(&c.name).to_string(),
1302            name: c.name,
1303            version: c.max_version,
1304            description: c.description.unwrap_or_default(),
1305        })
1306        .collect();
1307
1308    Ok(battery_packs)
1309}
1310
1311fn print_battery_pack_list(filter: Option<&str>) -> Result<()> {
1312    use console::style;
1313
1314    let battery_packs = fetch_battery_pack_list(filter)?;
1315
1316    if battery_packs.is_empty() {
1317        match filter {
1318            Some(q) => println!("No battery packs found matching '{}'", q),
1319            None => println!("No battery packs found"),
1320        }
1321        return Ok(());
1322    }
1323
1324    // Find the longest name for alignment
1325    let max_name_len = battery_packs
1326        .iter()
1327        .map(|c| c.short_name.len())
1328        .max()
1329        .unwrap_or(0);
1330
1331    let max_version_len = battery_packs
1332        .iter()
1333        .map(|c| c.version.len())
1334        .max()
1335        .unwrap_or(0);
1336
1337    println!();
1338    for bp in &battery_packs {
1339        let desc = bp.description.lines().next().unwrap_or("");
1340
1341        // Pad strings manually, then apply colors (ANSI codes break width formatting)
1342        let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
1343        let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
1344
1345        println!(
1346            "  {}  {}  {}",
1347            style(name_padded).green().bold(),
1348            style(ver_padded).dim(),
1349            desc,
1350        );
1351    }
1352    println!();
1353
1354    println!(
1355        "{}",
1356        style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
1357    );
1358
1359    Ok(())
1360}
1361
1362/// Convert "cli-battery-pack" to "cli" for display
1363fn short_name(crate_name: &str) -> &str {
1364    crate_name
1365        .strip_suffix("-battery-pack")
1366        .unwrap_or(crate_name)
1367}
1368
1369/// Convert "cli" to "cli-battery-pack" (adds suffix if not already present)
1370/// Special case: "battery-pack" stays as "battery-pack" (not "battery-pack-battery-pack")
1371fn resolve_crate_name(name: &str) -> String {
1372    if name == "battery-pack" || name.ends_with("-battery-pack") {
1373        name.to_string()
1374    } else {
1375        format!("{}-battery-pack", name)
1376    }
1377}
1378
1379/// Fetch detailed battery pack info from crates.io or a local path
1380pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
1381    // If path is provided, use local directory
1382    if let Some(local_path) = path {
1383        return fetch_battery_pack_detail_from_path(local_path);
1384    }
1385
1386    let crate_name = resolve_crate_name(name);
1387
1388    // Look up crate info and download
1389    let crate_info = lookup_crate(&crate_name)?;
1390    let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
1391    let crate_dir = temp_dir
1392        .path()
1393        .join(format!("{}-{}", crate_name, crate_info.version));
1394
1395    // Fetch owners from crates.io
1396    let owners = fetch_owners(&crate_name)?;
1397
1398    build_battery_pack_detail(&crate_dir, crate_name, crate_info.version, owners)
1399}
1400
1401/// Fetch detailed battery pack info from a local path
1402fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
1403    let crate_dir = std::path::Path::new(path);
1404
1405    // Read Cargo.toml to extract name and version
1406    let manifest_path = crate_dir.join("Cargo.toml");
1407    let manifest_content = std::fs::read_to_string(&manifest_path)
1408        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1409    let manifest: CargoManifest =
1410        toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
1411
1412    let package = manifest.package.unwrap_or_default();
1413    let crate_name = package
1414        .name
1415        .clone()
1416        .unwrap_or_else(|| "unknown".to_string());
1417    let version = package
1418        .version
1419        .clone()
1420        .unwrap_or_else(|| "0.0.0".to_string());
1421
1422    build_battery_pack_detail(
1423        crate_dir,
1424        crate_name,
1425        version,
1426        Vec::new(), // No owners for local path
1427    )
1428}
1429
1430/// Helper function to build BatteryPackDetail from already-resolved parameters.
1431/// Contains shared logic for both crates.io and local path sources.
1432fn build_battery_pack_detail(
1433    crate_dir: &Path,
1434    crate_name: String,
1435    version: String,
1436    owners: Vec<Owner>,
1437) -> Result<BatteryPackDetail> {
1438    // Read and parse Cargo.toml
1439    let manifest_path = crate_dir.join("Cargo.toml");
1440    let manifest_content = std::fs::read_to_string(&manifest_path)
1441        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1442    let manifest: CargoManifest =
1443        toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
1444
1445    // Extract info
1446    let package = manifest.package.unwrap_or_default();
1447    let description = package.description.clone().unwrap_or_default();
1448    let repository = package.repository.clone();
1449    let battery = package.metadata.and_then(|m| m.battery).unwrap_or_default();
1450
1451    // Split dependencies into battery packs and regular crates
1452    let (extends_raw, crates_raw): (Vec<_>, Vec<_>) = manifest
1453        .dependencies
1454        .keys()
1455        .filter(|d| *d != "battery-pack")
1456        .partition(|d| d.ends_with("-battery-pack"));
1457
1458    let extends: Vec<String> = extends_raw
1459        .into_iter()
1460        .map(|d| short_name(d).to_string())
1461        .collect();
1462    let crates: Vec<String> = crates_raw.into_iter().cloned().collect();
1463
1464    // Fetch the GitHub repository tree to resolve paths
1465    let repo_tree = repository.as_ref().and_then(|r| fetch_github_tree(r));
1466
1467    // Convert templates with resolved repo paths
1468    let templates = battery
1469        .templates
1470        .into_iter()
1471        .map(|(name, config)| {
1472            let repo_path = repo_tree
1473                .as_ref()
1474                .and_then(|tree| find_template_path(tree, &config.path));
1475            TemplateInfo {
1476                name,
1477                path: config.path,
1478                description: config.description,
1479                repo_path,
1480            }
1481        })
1482        .collect();
1483
1484    // Scan examples directory
1485    let examples = scan_examples(crate_dir, repo_tree.as_deref());
1486
1487    Ok(BatteryPackDetail {
1488        short_name: short_name(&crate_name).to_string(),
1489        name: crate_name,
1490        version,
1491        description,
1492        repository,
1493        owners: owners.into_iter().map(OwnerInfo::from).collect(),
1494        crates,
1495        extends,
1496        templates,
1497        examples,
1498    })
1499}
1500
1501fn print_battery_pack_detail(name: &str, path: Option<&str>) -> Result<()> {
1502    use console::style;
1503
1504    let detail = fetch_battery_pack_detail(name, path)?;
1505
1506    // Header
1507    println!();
1508    println!(
1509        "{} {}",
1510        style(&detail.name).green().bold(),
1511        style(&detail.version).dim()
1512    );
1513    if !detail.description.is_empty() {
1514        println!("{}", detail.description);
1515    }
1516
1517    // Authors
1518    if !detail.owners.is_empty() {
1519        println!();
1520        println!("{}", style("Authors:").bold());
1521        for owner in &detail.owners {
1522            if let Some(name) = &owner.name {
1523                println!("  {} ({})", name, owner.login);
1524            } else {
1525                println!("  {}", owner.login);
1526            }
1527        }
1528    }
1529
1530    // Crates
1531    if !detail.crates.is_empty() {
1532        println!();
1533        println!("{}", style("Crates:").bold());
1534        for dep in &detail.crates {
1535            println!("  {}", dep);
1536        }
1537    }
1538
1539    // Extends
1540    if !detail.extends.is_empty() {
1541        println!();
1542        println!("{}", style("Extends:").bold());
1543        for dep in &detail.extends {
1544            println!("  {}", dep);
1545        }
1546    }
1547
1548    // Templates
1549    if !detail.templates.is_empty() {
1550        println!();
1551        println!("{}", style("Templates:").bold());
1552        let max_name_len = detail
1553            .templates
1554            .iter()
1555            .map(|t| t.name.len())
1556            .max()
1557            .unwrap_or(0);
1558        for tmpl in &detail.templates {
1559            let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
1560            if let Some(desc) = &tmpl.description {
1561                println!("  {}  {}", style(name_padded).cyan(), desc);
1562            } else {
1563                println!("  {}", style(name_padded).cyan());
1564            }
1565        }
1566    }
1567
1568    // Examples
1569    if !detail.examples.is_empty() {
1570        println!();
1571        println!("{}", style("Examples:").bold());
1572        let max_name_len = detail
1573            .examples
1574            .iter()
1575            .map(|e| e.name.len())
1576            .max()
1577            .unwrap_or(0);
1578        for example in &detail.examples {
1579            let name_padded = format!("{:<width$}", example.name, width = max_name_len);
1580            if let Some(desc) = &example.description {
1581                println!("  {}  {}", style(name_padded).magenta(), desc);
1582            } else {
1583                println!("  {}", style(name_padded).magenta());
1584            }
1585        }
1586    }
1587
1588    // Install hints
1589    println!();
1590    println!("{}", style("Install:").bold());
1591    println!("  cargo bp add {}", detail.short_name);
1592    println!("  cargo bp new {}", detail.short_name);
1593    println!();
1594
1595    Ok(())
1596}
1597
1598fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
1599    let client = reqwest::blocking::Client::builder()
1600        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
1601        .build()?;
1602
1603    let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
1604    let response = client
1605        .get(&url)
1606        .send()
1607        .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
1608
1609    if !response.status().is_success() {
1610        // Not fatal - just return empty
1611        return Ok(Vec::new());
1612    }
1613
1614    let parsed: OwnersResponse = response
1615        .json()
1616        .with_context(|| "Failed to parse owners response")?;
1617
1618    Ok(parsed.users)
1619}
1620
1621/// Scan the examples directory and extract example info.
1622/// If a GitHub tree is provided, resolves the full repository path for each example.
1623fn scan_examples(crate_dir: &std::path::Path, repo_tree: Option<&[String]>) -> Vec<ExampleInfo> {
1624    let examples_dir = crate_dir.join("examples");
1625    if !examples_dir.exists() {
1626        return Vec::new();
1627    }
1628
1629    let mut examples = Vec::new();
1630
1631    if let Ok(entries) = std::fs::read_dir(&examples_dir) {
1632        for entry in entries.flatten() {
1633            let path = entry.path();
1634            if path.extension().is_some_and(|ext| ext == "rs") {
1635                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
1636                    let description = extract_example_description(&path);
1637                    let repo_path = repo_tree.and_then(|tree| find_example_path(tree, name));
1638                    examples.push(ExampleInfo {
1639                        name: name.to_string(),
1640                        description,
1641                        repo_path,
1642                    });
1643                }
1644            }
1645        }
1646    }
1647
1648    // Sort by name
1649    examples.sort_by(|a, b| a.name.cmp(&b.name));
1650    examples
1651}
1652
1653/// Extract description from the first doc comment in an example file
1654fn extract_example_description(path: &std::path::Path) -> Option<String> {
1655    let content = std::fs::read_to_string(path).ok()?;
1656
1657    // Look for //! doc comments at the start
1658    for line in content.lines() {
1659        let trimmed = line.trim();
1660        if trimmed.starts_with("//!") {
1661            let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
1662            if !desc.is_empty() {
1663                return Some(desc.to_string());
1664            }
1665        } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
1666            // Stop at first non-comment, non-empty line
1667            break;
1668        }
1669    }
1670    None
1671}
1672
1673/// Fetch the repository tree from GitHub API.
1674/// Returns a list of all file paths in the repository.
1675fn fetch_github_tree(repository: &str) -> Option<Vec<String>> {
1676    // Parse GitHub URL: https://github.com/owner/repo
1677    let gh_path = repository
1678        .strip_prefix("https://github.com/")
1679        .or_else(|| repository.strip_prefix("http://github.com/"))?;
1680    let gh_path = gh_path.strip_suffix(".git").unwrap_or(gh_path);
1681    let gh_path = gh_path.trim_end_matches('/');
1682
1683    let client = reqwest::blocking::Client::builder()
1684        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
1685        .build()
1686        .ok()?;
1687
1688    // Fetch the tree recursively using the main branch
1689    let url = format!(
1690        "https://api.github.com/repos/{}/git/trees/main?recursive=1",
1691        gh_path
1692    );
1693
1694    let response = client.get(&url).send().ok()?;
1695    if !response.status().is_success() {
1696        return None;
1697    }
1698
1699    let tree_response: GitHubTreeResponse = response.json().ok()?;
1700
1701    // Extract all paths (both blobs/files and trees/directories)
1702    Some(tree_response.tree.into_iter().map(|e| e.path).collect())
1703}
1704
1705/// Find the full repository path for an example file.
1706/// Searches the tree for a file matching "examples/{name}.rs".
1707fn find_example_path(tree: &[String], example_name: &str) -> Option<String> {
1708    let suffix = format!("examples/{}.rs", example_name);
1709    tree.iter().find(|path| path.ends_with(&suffix)).cloned()
1710}
1711
1712/// Find the full repository path for a template directory.
1713/// Searches the tree for a path matching "templates/{name}" or "{name}".
1714fn find_template_path(tree: &[String], template_path: &str) -> Option<String> {
1715    // The template path from config might be "templates/simple" or just the relative path
1716    tree.iter()
1717        .find(|path| path.ends_with(template_path))
1718        .cloned()
1719}