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 as a dependency
57    Add {
58        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
59        battery_pack: String,
60
61        /// Features to enable
62        #[arg(long, short = 'F')]
63        features: Vec<String>,
64    },
65
66    /// List available battery packs on crates.io
67    List {
68        /// Filter by name (omit to list all battery packs)
69        filter: Option<String>,
70
71        /// Disable interactive TUI mode
72        #[arg(long)]
73        non_interactive: bool,
74    },
75
76    /// Show detailed information about a battery pack
77    Show {
78        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
79        battery_pack: String,
80
81        /// Use a local path instead of downloading from crates.io
82        #[arg(long)]
83        path: Option<String>,
84
85        /// Disable interactive TUI mode
86        #[arg(long)]
87        non_interactive: bool,
88    },
89}
90
91/// Main entry point for the CLI.
92pub fn main() -> Result<()> {
93    let cli = Cli::parse();
94
95    match cli.command {
96        Commands::Bp { command } => match command {
97            BpCommands::New {
98                battery_pack,
99                name,
100                template,
101                path,
102            } => new_from_battery_pack(&battery_pack, name, template, path),
103            BpCommands::Add {
104                battery_pack,
105                features,
106            } => add_battery_pack(&battery_pack, &features),
107            BpCommands::List {
108                filter,
109                non_interactive,
110            } => {
111                if !non_interactive && std::io::stdout().is_terminal() {
112                    tui::run_list(filter)
113                } else {
114                    print_battery_pack_list(filter.as_deref())
115                }
116            }
117            BpCommands::Show {
118                battery_pack,
119                path,
120                non_interactive,
121            } => {
122                if !non_interactive && std::io::stdout().is_terminal() {
123                    tui::run_show(&battery_pack, path.as_deref())
124                } else {
125                    print_battery_pack_detail(&battery_pack, path.as_deref())
126                }
127            }
128        },
129    }
130}
131
132// ============================================================================
133// crates.io API types
134// ============================================================================
135
136#[derive(Deserialize)]
137struct CratesIoResponse {
138    versions: Vec<VersionInfo>,
139}
140
141#[derive(Deserialize)]
142struct VersionInfo {
143    num: String,
144    yanked: bool,
145}
146
147#[derive(Deserialize)]
148struct SearchResponse {
149    crates: Vec<SearchCrate>,
150}
151
152#[derive(Deserialize)]
153struct SearchCrate {
154    name: String,
155    max_version: String,
156    description: Option<String>,
157}
158
159// ============================================================================
160// Battery pack metadata types (from Cargo.toml)
161// ============================================================================
162
163#[derive(Deserialize, Default)]
164struct CargoManifest {
165    package: Option<PackageSection>,
166    #[serde(default)]
167    dependencies: BTreeMap<String, toml::Value>,
168}
169
170#[derive(Deserialize, Default)]
171struct PackageSection {
172    name: Option<String>,
173    version: Option<String>,
174    description: Option<String>,
175    repository: Option<String>,
176    metadata: Option<PackageMetadata>,
177}
178
179#[derive(Deserialize, Default)]
180struct PackageMetadata {
181    battery: Option<BatteryMetadata>,
182}
183
184#[derive(Deserialize, Default)]
185struct BatteryMetadata {
186    #[serde(default)]
187    templates: BTreeMap<String, TemplateConfig>,
188}
189
190#[derive(Deserialize)]
191struct TemplateConfig {
192    path: String,
193    #[serde(default)]
194    description: Option<String>,
195}
196
197// ============================================================================
198// crates.io owner types
199// ============================================================================
200
201#[derive(Deserialize)]
202struct OwnersResponse {
203    users: Vec<Owner>,
204}
205
206#[derive(Deserialize, Clone)]
207struct Owner {
208    login: String,
209    name: Option<String>,
210}
211
212// ============================================================================
213// Shared data types (used by both TUI and text output)
214// ============================================================================
215
216/// Summary info for displaying in a list
217#[derive(Clone)]
218pub struct BatteryPackSummary {
219    pub name: String,
220    pub short_name: String,
221    pub version: String,
222    pub description: String,
223}
224
225/// Detailed battery pack info
226#[derive(Clone)]
227pub struct BatteryPackDetail {
228    pub name: String,
229    pub short_name: String,
230    pub version: String,
231    pub description: String,
232    pub repository: Option<String>,
233    pub owners: Vec<OwnerInfo>,
234    pub crates: Vec<String>,
235    pub extends: Vec<String>,
236    pub templates: Vec<TemplateInfo>,
237    pub examples: Vec<ExampleInfo>,
238}
239
240#[derive(Clone)]
241pub struct OwnerInfo {
242    pub login: String,
243    pub name: Option<String>,
244}
245
246impl From<Owner> for OwnerInfo {
247    fn from(o: Owner) -> Self {
248        Self {
249            login: o.login,
250            name: o.name,
251        }
252    }
253}
254
255#[derive(Clone)]
256pub struct TemplateInfo {
257    pub name: String,
258    pub path: String,
259    pub description: Option<String>,
260}
261
262#[derive(Clone)]
263pub struct ExampleInfo {
264    pub name: String,
265    pub description: Option<String>,
266}
267
268// ============================================================================
269// Implementation
270// ============================================================================
271
272fn new_from_battery_pack(
273    battery_pack: &str,
274    name: Option<String>,
275    template: Option<String>,
276    path_override: Option<String>,
277) -> Result<()> {
278    // If using local path, generate directly from there
279    if let Some(path) = path_override {
280        return generate_from_local(&path, name, template);
281    }
282
283    // Resolve the crate name (add -battery-pack suffix if needed)
284    let crate_name = resolve_crate_name(battery_pack);
285
286    // Look up the crate on crates.io and get the latest version
287    let crate_info = lookup_crate(&crate_name)?;
288
289    // Download and extract the crate to a temp directory
290    let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
291    let crate_dir = temp_dir
292        .path()
293        .join(format!("{}-{}", crate_name, crate_info.version));
294
295    // Read template metadata from the extracted Cargo.toml
296    let manifest_path = crate_dir.join("Cargo.toml");
297    let manifest_content = std::fs::read_to_string(&manifest_path)
298        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
299    let templates = parse_template_metadata(&manifest_content, &crate_name)?;
300
301    // Resolve which template to use
302    let template_path = resolve_template(&templates, template.as_deref())?;
303
304    // Generate the project from the extracted crate
305    generate_from_path(&crate_dir, &template_path, name)
306}
307
308fn add_battery_pack(name: &str, features: &[String]) -> Result<()> {
309    let crate_name = resolve_crate_name(name);
310    let short = short_name(&crate_name);
311
312    // Verify the crate exists on crates.io
313    lookup_crate(&crate_name)?;
314
315    // Build cargo add command: cargo add cli-battery-pack --rename cli
316    let mut cmd = std::process::Command::new("cargo");
317    cmd.arg("add").arg(&crate_name);
318
319    // Rename to the short name (e.g., cli-battery-pack -> cli)
320    cmd.arg("--rename").arg(short);
321
322    // Add features if specified
323    for feature in features {
324        cmd.arg("--features").arg(feature);
325    }
326
327    let status = cmd.status().context("Failed to run cargo add")?;
328
329    if !status.success() {
330        bail!("cargo add failed");
331    }
332
333    Ok(())
334}
335
336fn generate_from_local(
337    local_path: &str,
338    name: Option<String>,
339    template: Option<String>,
340) -> Result<()> {
341    let local_path = Path::new(local_path);
342
343    // Read local Cargo.toml
344    let manifest_path = local_path.join("Cargo.toml");
345    let manifest_content = std::fs::read_to_string(&manifest_path)
346        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
347
348    let crate_name = local_path
349        .file_name()
350        .and_then(|s| s.to_str())
351        .unwrap_or("unknown");
352    let templates = parse_template_metadata(&manifest_content, crate_name)?;
353    let template_path = resolve_template(&templates, template.as_deref())?;
354
355    generate_from_path(local_path, &template_path, name)
356}
357
358fn generate_from_path(crate_path: &Path, template_path: &str, name: Option<String>) -> Result<()> {
359    let args = GenerateArgs {
360        template_path: TemplatePath {
361            path: Some(crate_path.to_string_lossy().into_owned()),
362            auto_path: Some(template_path.to_string()),
363            ..Default::default()
364        },
365        name,
366        vcs: Some(Vcs::Git),
367        ..Default::default()
368    };
369
370    cargo_generate::generate(args)?;
371
372    Ok(())
373}
374
375/// Info about a crate from crates.io
376struct CrateMetadata {
377    version: String,
378}
379
380/// Look up a crate on crates.io and return its metadata
381fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
382    let client = reqwest::blocking::Client::builder()
383        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
384        .build()?;
385
386    let url = format!("{}/{}", CRATES_IO_API, crate_name);
387    let response = client
388        .get(&url)
389        .send()
390        .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
391
392    if !response.status().is_success() {
393        bail!(
394            "Crate '{}' not found on crates.io (status: {})",
395            crate_name,
396            response.status()
397        );
398    }
399
400    let parsed: CratesIoResponse = response
401        .json()
402        .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
403
404    // Find the latest non-yanked version
405    let version = parsed
406        .versions
407        .iter()
408        .find(|v| !v.yanked)
409        .map(|v| v.num.clone())
410        .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
411
412    Ok(CrateMetadata { version })
413}
414
415/// Download a crate tarball and extract it to a temp directory
416fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
417    let client = reqwest::blocking::Client::builder()
418        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
419        .build()?;
420
421    // Download from CDN: https://static.crates.io/crates/{name}/{name}-{version}.crate
422    let url = format!(
423        "{}/{}/{}-{}.crate",
424        CRATES_IO_CDN, crate_name, crate_name, version
425    );
426
427    let response = client
428        .get(&url)
429        .send()
430        .with_context(|| format!("Failed to download crate from {}", url))?;
431
432    if !response.status().is_success() {
433        bail!(
434            "Failed to download '{}' version {} (status: {})",
435            crate_name,
436            version,
437            response.status()
438        );
439    }
440
441    let bytes = response
442        .bytes()
443        .with_context(|| "Failed to read crate tarball")?;
444
445    // Create temp directory and extract
446    let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
447
448    let decoder = GzDecoder::new(&bytes[..]);
449    let mut archive = Archive::new(decoder);
450    archive
451        .unpack(temp_dir.path())
452        .with_context(|| "Failed to extract crate tarball")?;
453
454    Ok(temp_dir)
455}
456
457fn parse_template_metadata(
458    manifest_content: &str,
459    crate_name: &str,
460) -> Result<BTreeMap<String, TemplateConfig>> {
461    let manifest: CargoManifest =
462        toml::from_str(manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
463
464    let templates = manifest
465        .package
466        .and_then(|p| p.metadata)
467        .and_then(|m| m.battery)
468        .map(|b| b.templates)
469        .unwrap_or_default();
470
471    if templates.is_empty() {
472        bail!(
473            "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
474            crate_name
475        );
476    }
477
478    Ok(templates)
479}
480
481fn resolve_template(
482    templates: &BTreeMap<String, TemplateConfig>,
483    requested: Option<&str>,
484) -> Result<String> {
485    match requested {
486        Some(name) => {
487            let config = templates.get(name).ok_or_else(|| {
488                let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
489                anyhow::anyhow!(
490                    "Template '{}' not found. Available templates: {}",
491                    name,
492                    available.join(", ")
493                )
494            })?;
495            Ok(config.path.clone())
496        }
497        None => {
498            if templates.len() == 1 {
499                // Only one template, use it
500                let (_, config) = templates.iter().next().unwrap();
501                Ok(config.path.clone())
502            } else if let Some(config) = templates.get("default") {
503                // Multiple templates, but there's a 'default'
504                Ok(config.path.clone())
505            } else {
506                // Multiple templates, no default - prompt user to pick
507                prompt_for_template(templates)
508            }
509        }
510    }
511}
512
513fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
514    use dialoguer::{Select, theme::ColorfulTheme};
515
516    // Build display items with descriptions
517    let items: Vec<String> = templates
518        .iter()
519        .map(|(name, config)| {
520            if let Some(desc) = &config.description {
521                format!("{} - {}", name, desc)
522            } else {
523                name.clone()
524            }
525        })
526        .collect();
527
528    // Check if we're in a TTY for interactive mode
529    if !std::io::stdout().is_terminal() {
530        // Non-interactive: list templates and bail
531        println!("Available templates:");
532        for item in &items {
533            println!("  {}", item);
534        }
535        bail!("Multiple templates available. Please specify one with --template <name>");
536    }
537
538    // Interactive: show selector
539    let selection = Select::with_theme(&ColorfulTheme::default())
540        .with_prompt("Select a template")
541        .items(&items)
542        .default(0)
543        .interact()
544        .context("Failed to select template")?;
545
546    // Get the selected template's path
547    let (_, config) = templates.iter().nth(selection).unwrap();
548    Ok(config.path.clone())
549}
550
551/// Fetch battery pack list from crates.io
552pub fn fetch_battery_pack_list(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
553    let client = reqwest::blocking::Client::builder()
554        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
555        .build()?;
556
557    // Build the search URL with keyword filter
558    let url = match filter {
559        Some(q) => format!(
560            "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
561            urlencoding::encode(q)
562        ),
563        None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
564    };
565
566    let response = client
567        .get(&url)
568        .send()
569        .context("Failed to query crates.io")?;
570
571    if !response.status().is_success() {
572        bail!(
573            "Failed to list battery packs (status: {})",
574            response.status()
575        );
576    }
577
578    let parsed: SearchResponse = response.json().context("Failed to parse response")?;
579
580    // Filter to only crates whose name ends with "-battery-pack"
581    let battery_packs = parsed
582        .crates
583        .into_iter()
584        .filter(|c| c.name.ends_with("-battery-pack"))
585        .map(|c| BatteryPackSummary {
586            short_name: short_name(&c.name).to_string(),
587            name: c.name,
588            version: c.max_version,
589            description: c.description.unwrap_or_default(),
590        })
591        .collect();
592
593    Ok(battery_packs)
594}
595
596fn print_battery_pack_list(filter: Option<&str>) -> Result<()> {
597    use console::style;
598
599    let battery_packs = fetch_battery_pack_list(filter)?;
600
601    if battery_packs.is_empty() {
602        match filter {
603            Some(q) => println!("No battery packs found matching '{}'", q),
604            None => println!("No battery packs found"),
605        }
606        return Ok(());
607    }
608
609    // Find the longest name for alignment
610    let max_name_len = battery_packs
611        .iter()
612        .map(|c| c.short_name.len())
613        .max()
614        .unwrap_or(0);
615
616    let max_version_len = battery_packs
617        .iter()
618        .map(|c| c.version.len())
619        .max()
620        .unwrap_or(0);
621
622    println!();
623    for bp in &battery_packs {
624        let desc = bp.description.lines().next().unwrap_or("");
625
626        // Pad strings manually, then apply colors (ANSI codes break width formatting)
627        let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
628        let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
629
630        println!(
631            "  {}  {}  {}",
632            style(name_padded).green().bold(),
633            style(ver_padded).dim(),
634            desc,
635        );
636    }
637    println!();
638
639    println!(
640        "{}",
641        style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
642    );
643
644    Ok(())
645}
646
647/// Convert "cli-battery-pack" to "cli" for display
648fn short_name(crate_name: &str) -> &str {
649    crate_name
650        .strip_suffix("-battery-pack")
651        .unwrap_or(crate_name)
652}
653
654/// Convert "cli" to "cli-battery-pack" (adds suffix if not already present)
655fn resolve_crate_name(name: &str) -> String {
656    if name.ends_with("-battery-pack") {
657        name.to_string()
658    } else {
659        format!("{}-battery-pack", name)
660    }
661}
662
663/// Fetch detailed battery pack info from crates.io or a local path
664pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
665    // If path is provided, use local directory
666    if let Some(local_path) = path {
667        return fetch_battery_pack_detail_from_path(local_path);
668    }
669
670    let crate_name = resolve_crate_name(name);
671
672    // Look up crate info and download
673    let crate_info = lookup_crate(&crate_name)?;
674    let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
675    let crate_dir = temp_dir
676        .path()
677        .join(format!("{}-{}", crate_name, crate_info.version));
678
679    // Read and parse Cargo.toml
680    let manifest_path = crate_dir.join("Cargo.toml");
681    let manifest_content = std::fs::read_to_string(&manifest_path)
682        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
683    let manifest: CargoManifest =
684        toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
685
686    // Fetch owners from crates.io
687    let owners = fetch_owners(&crate_name)?;
688
689    // Extract info
690    let package = manifest.package.unwrap_or_default();
691    let description = package.description.clone().unwrap_or_default();
692    let repository = package.repository.clone();
693    let battery = package.metadata.and_then(|m| m.battery).unwrap_or_default();
694
695    // Split dependencies into battery packs and regular crates
696    let mut extends = Vec::new();
697    let mut crates = Vec::new();
698
699    for dep_name in manifest.dependencies.keys() {
700        if dep_name.ends_with("-battery-pack") {
701            extends.push(short_name(dep_name).to_string());
702        } else if dep_name != "battery-pack" {
703            crates.push(dep_name.clone());
704        }
705    }
706
707    // Convert templates
708    let templates = battery
709        .templates
710        .into_iter()
711        .map(|(name, config)| TemplateInfo {
712            name,
713            path: config.path,
714            description: config.description,
715        })
716        .collect();
717
718    // Scan examples directory
719    let examples = scan_examples(&crate_dir);
720
721    Ok(BatteryPackDetail {
722        short_name: short_name(&crate_name).to_string(),
723        name: crate_name,
724        version: crate_info.version,
725        description,
726        repository,
727        owners: owners.into_iter().map(OwnerInfo::from).collect(),
728        crates,
729        extends,
730        templates,
731        examples,
732    })
733}
734
735/// Fetch detailed battery pack info from a local path
736fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
737    let crate_dir = std::path::Path::new(path);
738
739    // Read and parse Cargo.toml
740    let manifest_path = crate_dir.join("Cargo.toml");
741    let manifest_content = std::fs::read_to_string(&manifest_path)
742        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
743    let manifest: CargoManifest =
744        toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
745
746    // Extract info
747    let package = manifest.package.unwrap_or_default();
748    let crate_name = package
749        .name
750        .clone()
751        .unwrap_or_else(|| "unknown".to_string());
752    let version = package
753        .version
754        .clone()
755        .unwrap_or_else(|| "0.0.0".to_string());
756    let description = package.description.clone().unwrap_or_default();
757    let repository = package.repository.clone();
758    let battery = package.metadata.and_then(|m| m.battery).unwrap_or_default();
759
760    // Split dependencies into battery packs and regular crates
761    let mut extends = Vec::new();
762    let mut crates = Vec::new();
763
764    for dep_name in manifest.dependencies.keys() {
765        if dep_name.ends_with("-battery-pack") {
766            extends.push(short_name(dep_name).to_string());
767        } else if dep_name != "battery-pack" {
768            crates.push(dep_name.clone());
769        }
770    }
771
772    // Convert templates
773    let templates = battery
774        .templates
775        .into_iter()
776        .map(|(name, config)| TemplateInfo {
777            name,
778            path: config.path,
779            description: config.description,
780        })
781        .collect();
782
783    // Scan examples directory
784    let examples = scan_examples(crate_dir);
785
786    Ok(BatteryPackDetail {
787        short_name: short_name(&crate_name).to_string(),
788        name: crate_name,
789        version,
790        description,
791        repository,
792        owners: Vec::new(), // No owners for local path
793        crates,
794        extends,
795        templates,
796        examples,
797    })
798}
799
800fn print_battery_pack_detail(name: &str, path: Option<&str>) -> Result<()> {
801    use console::style;
802
803    let detail = fetch_battery_pack_detail(name, path)?;
804
805    // Header
806    println!();
807    println!(
808        "{} {}",
809        style(&detail.name).green().bold(),
810        style(&detail.version).dim()
811    );
812    if !detail.description.is_empty() {
813        println!("{}", detail.description);
814    }
815
816    // Authors
817    if !detail.owners.is_empty() {
818        println!();
819        println!("{}", style("Authors:").bold());
820        for owner in &detail.owners {
821            if let Some(name) = &owner.name {
822                println!("  {} ({})", name, owner.login);
823            } else {
824                println!("  {}", owner.login);
825            }
826        }
827    }
828
829    // Crates
830    if !detail.crates.is_empty() {
831        println!();
832        println!("{}", style("Crates:").bold());
833        for dep in &detail.crates {
834            println!("  {}", dep);
835        }
836    }
837
838    // Extends
839    if !detail.extends.is_empty() {
840        println!();
841        println!("{}", style("Extends:").bold());
842        for dep in &detail.extends {
843            println!("  {}", dep);
844        }
845    }
846
847    // Templates
848    if !detail.templates.is_empty() {
849        println!();
850        println!("{}", style("Templates:").bold());
851        let max_name_len = detail
852            .templates
853            .iter()
854            .map(|t| t.name.len())
855            .max()
856            .unwrap_or(0);
857        for tmpl in &detail.templates {
858            let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
859            if let Some(desc) = &tmpl.description {
860                println!("  {}  {}", style(name_padded).cyan(), desc);
861            } else {
862                println!("  {}", style(name_padded).cyan());
863            }
864        }
865    }
866
867    // Examples
868    if !detail.examples.is_empty() {
869        println!();
870        println!("{}", style("Examples:").bold());
871        let max_name_len = detail
872            .examples
873            .iter()
874            .map(|e| e.name.len())
875            .max()
876            .unwrap_or(0);
877        for example in &detail.examples {
878            let name_padded = format!("{:<width$}", example.name, width = max_name_len);
879            if let Some(desc) = &example.description {
880                println!("  {}  {}", style(name_padded).magenta(), desc);
881            } else {
882                println!("  {}", style(name_padded).magenta());
883            }
884        }
885    }
886
887    // Install hints
888    println!();
889    println!("{}", style("Install:").bold());
890    println!("  cargo bp add {}", detail.short_name);
891    println!("  cargo bp new {}", detail.short_name);
892    println!();
893
894    Ok(())
895}
896
897fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
898    let client = reqwest::blocking::Client::builder()
899        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
900        .build()?;
901
902    let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
903    let response = client
904        .get(&url)
905        .send()
906        .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
907
908    if !response.status().is_success() {
909        // Not fatal - just return empty
910        return Ok(Vec::new());
911    }
912
913    let parsed: OwnersResponse = response
914        .json()
915        .with_context(|| "Failed to parse owners response")?;
916
917    Ok(parsed.users)
918}
919
920/// Scan the examples directory and extract example info
921fn scan_examples(crate_dir: &std::path::Path) -> Vec<ExampleInfo> {
922    let examples_dir = crate_dir.join("examples");
923    if !examples_dir.exists() {
924        return Vec::new();
925    }
926
927    let mut examples = Vec::new();
928
929    if let Ok(entries) = std::fs::read_dir(&examples_dir) {
930        for entry in entries.flatten() {
931            let path = entry.path();
932            if path.extension().is_some_and(|ext| ext == "rs") {
933                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
934                    let description = extract_example_description(&path);
935                    examples.push(ExampleInfo {
936                        name: name.to_string(),
937                        description,
938                    });
939                }
940            }
941        }
942    }
943
944    // Sort by name
945    examples.sort_by(|a, b| a.name.cmp(&b.name));
946    examples
947}
948
949/// Extract description from the first doc comment in an example file
950fn extract_example_description(path: &std::path::Path) -> Option<String> {
951    let content = std::fs::read_to_string(path).ok()?;
952
953    // Look for //! doc comments at the start
954    for line in content.lines() {
955        let trimmed = line.trim();
956        if trimmed.starts_with("//!") {
957            let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
958            if !desc.is_empty() {
959                return Some(desc.to_string());
960            }
961        } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
962            // Stop at first non-comment, non-empty line
963            break;
964        }
965    }
966    None
967}