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 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// GitHub API types
214// ============================================================================
215
216#[derive(Deserialize)]
217struct GitHubTreeResponse {
218    tree: Vec<GitHubTreeEntry>,
219    #[serde(default)]
220    #[allow(dead_code)]
221    truncated: bool,
222}
223
224#[derive(Deserialize)]
225struct GitHubTreeEntry {
226    path: String,
227}
228
229// ============================================================================
230// Shared data types (used by both TUI and text output)
231// ============================================================================
232
233/// Summary info for displaying in a list
234#[derive(Clone)]
235pub struct BatteryPackSummary {
236    pub name: String,
237    pub short_name: String,
238    pub version: String,
239    pub description: String,
240}
241
242/// Detailed battery pack info
243#[derive(Clone)]
244pub struct BatteryPackDetail {
245    pub name: String,
246    pub short_name: String,
247    pub version: String,
248    pub description: String,
249    pub repository: Option<String>,
250    pub owners: Vec<OwnerInfo>,
251    pub crates: Vec<String>,
252    pub extends: Vec<String>,
253    pub templates: Vec<TemplateInfo>,
254    pub examples: Vec<ExampleInfo>,
255}
256
257#[derive(Clone)]
258pub struct OwnerInfo {
259    pub login: String,
260    pub name: Option<String>,
261}
262
263impl From<Owner> for OwnerInfo {
264    fn from(o: Owner) -> Self {
265        Self {
266            login: o.login,
267            name: o.name,
268        }
269    }
270}
271
272#[derive(Clone)]
273pub struct TemplateInfo {
274    pub name: String,
275    pub path: String,
276    pub description: Option<String>,
277    /// Full path in the repository (e.g., "src/cli-battery-pack/templates/simple")
278    /// Resolved by searching the GitHub tree API
279    pub repo_path: Option<String>,
280}
281
282#[derive(Clone)]
283pub struct ExampleInfo {
284    pub name: String,
285    pub description: Option<String>,
286    /// Full path in the repository (e.g., "src/cli-battery-pack/examples/mini-grep.rs")
287    /// Resolved by searching the GitHub tree API
288    pub repo_path: Option<String>,
289}
290
291// ============================================================================
292// Implementation
293// ============================================================================
294
295fn new_from_battery_pack(
296    battery_pack: &str,
297    name: Option<String>,
298    template: Option<String>,
299    path_override: Option<String>,
300) -> Result<()> {
301    // If using local path, generate directly from there
302    if let Some(path) = path_override {
303        return generate_from_local(&path, name, template);
304    }
305
306    // Resolve the crate name (add -battery-pack suffix if needed)
307    let crate_name = resolve_crate_name(battery_pack);
308
309    // Look up the crate on crates.io and get the latest version
310    let crate_info = lookup_crate(&crate_name)?;
311
312    // Download and extract the crate to a temp directory
313    let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
314    let crate_dir = temp_dir
315        .path()
316        .join(format!("{}-{}", crate_name, crate_info.version));
317
318    // Read template metadata from the extracted Cargo.toml
319    let manifest_path = crate_dir.join("Cargo.toml");
320    let manifest_content = std::fs::read_to_string(&manifest_path)
321        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
322    let templates = parse_template_metadata(&manifest_content, &crate_name)?;
323
324    // Resolve which template to use
325    let template_path = resolve_template(&templates, template.as_deref())?;
326
327    // Generate the project from the extracted crate
328    generate_from_path(&crate_dir, &template_path, name)
329}
330
331fn add_battery_pack(name: &str, features: &[String]) -> Result<()> {
332    let crate_name = resolve_crate_name(name);
333    let short = short_name(&crate_name);
334
335    // Verify the crate exists on crates.io
336    lookup_crate(&crate_name)?;
337
338    // Build cargo add command: cargo add cli-battery-pack --rename cli
339    let mut cmd = std::process::Command::new("cargo");
340    cmd.arg("add").arg(&crate_name);
341
342    // Rename to the short name (e.g., cli-battery-pack -> cli)
343    cmd.arg("--rename").arg(short);
344
345    // Add features if specified
346    for feature in features {
347        cmd.arg("--features").arg(feature);
348    }
349
350    let status = cmd.status().context("Failed to run cargo add")?;
351
352    if !status.success() {
353        bail!("cargo add failed");
354    }
355
356    Ok(())
357}
358
359fn generate_from_local(
360    local_path: &str,
361    name: Option<String>,
362    template: Option<String>,
363) -> Result<()> {
364    let local_path = Path::new(local_path);
365
366    // Read local Cargo.toml
367    let manifest_path = local_path.join("Cargo.toml");
368    let manifest_content = std::fs::read_to_string(&manifest_path)
369        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
370
371    let crate_name = local_path
372        .file_name()
373        .and_then(|s| s.to_str())
374        .unwrap_or("unknown");
375    let templates = parse_template_metadata(&manifest_content, crate_name)?;
376    let template_path = resolve_template(&templates, template.as_deref())?;
377
378    generate_from_path(local_path, &template_path, name)
379}
380
381fn generate_from_path(crate_path: &Path, template_path: &str, name: Option<String>) -> Result<()> {
382    let args = GenerateArgs {
383        template_path: TemplatePath {
384            path: Some(crate_path.to_string_lossy().into_owned()),
385            auto_path: Some(template_path.to_string()),
386            ..Default::default()
387        },
388        name,
389        vcs: Some(Vcs::Git),
390        ..Default::default()
391    };
392
393    cargo_generate::generate(args)?;
394
395    Ok(())
396}
397
398/// Info about a crate from crates.io
399struct CrateMetadata {
400    version: String,
401}
402
403/// Look up a crate on crates.io and return its metadata
404fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
405    let client = reqwest::blocking::Client::builder()
406        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
407        .build()?;
408
409    let url = format!("{}/{}", CRATES_IO_API, crate_name);
410    let response = client
411        .get(&url)
412        .send()
413        .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
414
415    if !response.status().is_success() {
416        bail!(
417            "Crate '{}' not found on crates.io (status: {})",
418            crate_name,
419            response.status()
420        );
421    }
422
423    let parsed: CratesIoResponse = response
424        .json()
425        .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
426
427    // Find the latest non-yanked version
428    let version = parsed
429        .versions
430        .iter()
431        .find(|v| !v.yanked)
432        .map(|v| v.num.clone())
433        .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
434
435    Ok(CrateMetadata { version })
436}
437
438/// Download a crate tarball and extract it to a temp directory
439fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
440    let client = reqwest::blocking::Client::builder()
441        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
442        .build()?;
443
444    // Download from CDN: https://static.crates.io/crates/{name}/{name}-{version}.crate
445    let url = format!(
446        "{}/{}/{}-{}.crate",
447        CRATES_IO_CDN, crate_name, crate_name, version
448    );
449
450    let response = client
451        .get(&url)
452        .send()
453        .with_context(|| format!("Failed to download crate from {}", url))?;
454
455    if !response.status().is_success() {
456        bail!(
457            "Failed to download '{}' version {} (status: {})",
458            crate_name,
459            version,
460            response.status()
461        );
462    }
463
464    let bytes = response
465        .bytes()
466        .with_context(|| "Failed to read crate tarball")?;
467
468    // Create temp directory and extract
469    let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
470
471    let decoder = GzDecoder::new(&bytes[..]);
472    let mut archive = Archive::new(decoder);
473    archive
474        .unpack(temp_dir.path())
475        .with_context(|| "Failed to extract crate tarball")?;
476
477    Ok(temp_dir)
478}
479
480fn parse_template_metadata(
481    manifest_content: &str,
482    crate_name: &str,
483) -> Result<BTreeMap<String, TemplateConfig>> {
484    let manifest: CargoManifest =
485        toml::from_str(manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
486
487    let templates = manifest
488        .package
489        .and_then(|p| p.metadata)
490        .and_then(|m| m.battery)
491        .map(|b| b.templates)
492        .unwrap_or_default();
493
494    if templates.is_empty() {
495        bail!(
496            "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
497            crate_name
498        );
499    }
500
501    Ok(templates)
502}
503
504fn resolve_template(
505    templates: &BTreeMap<String, TemplateConfig>,
506    requested: Option<&str>,
507) -> Result<String> {
508    match requested {
509        Some(name) => {
510            let config = templates.get(name).ok_or_else(|| {
511                let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
512                anyhow::anyhow!(
513                    "Template '{}' not found. Available templates: {}",
514                    name,
515                    available.join(", ")
516                )
517            })?;
518            Ok(config.path.clone())
519        }
520        None => {
521            if templates.len() == 1 {
522                // Only one template, use it
523                let (_, config) = templates.iter().next().unwrap();
524                Ok(config.path.clone())
525            } else if let Some(config) = templates.get("default") {
526                // Multiple templates, but there's a 'default'
527                Ok(config.path.clone())
528            } else {
529                // Multiple templates, no default - prompt user to pick
530                prompt_for_template(templates)
531            }
532        }
533    }
534}
535
536fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
537    use dialoguer::{Select, theme::ColorfulTheme};
538
539    // Build display items with descriptions
540    let items: Vec<String> = templates
541        .iter()
542        .map(|(name, config)| {
543            if let Some(desc) = &config.description {
544                format!("{} - {}", name, desc)
545            } else {
546                name.clone()
547            }
548        })
549        .collect();
550
551    // Check if we're in a TTY for interactive mode
552    if !std::io::stdout().is_terminal() {
553        // Non-interactive: list templates and bail
554        println!("Available templates:");
555        for item in &items {
556            println!("  {}", item);
557        }
558        bail!("Multiple templates available. Please specify one with --template <name>");
559    }
560
561    // Interactive: show selector
562    let selection = Select::with_theme(&ColorfulTheme::default())
563        .with_prompt("Select a template")
564        .items(&items)
565        .default(0)
566        .interact()
567        .context("Failed to select template")?;
568
569    // Get the selected template's path
570    let (_, config) = templates
571        .iter()
572        .nth(selection)
573        .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
574    Ok(config.path.clone())
575}
576
577/// Fetch battery pack list from crates.io
578pub fn fetch_battery_pack_list(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
579    let client = reqwest::blocking::Client::builder()
580        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
581        .build()?;
582
583    // Build the search URL with keyword filter
584    let url = match filter {
585        Some(q) => format!(
586            "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
587            urlencoding::encode(q)
588        ),
589        None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
590    };
591
592    let response = client
593        .get(&url)
594        .send()
595        .context("Failed to query crates.io")?;
596
597    if !response.status().is_success() {
598        bail!(
599            "Failed to list battery packs (status: {})",
600            response.status()
601        );
602    }
603
604    let parsed: SearchResponse = response.json().context("Failed to parse response")?;
605
606    // Filter to only crates whose name ends with "-battery-pack"
607    let battery_packs = parsed
608        .crates
609        .into_iter()
610        .filter(|c| c.name.ends_with("-battery-pack"))
611        .map(|c| BatteryPackSummary {
612            short_name: short_name(&c.name).to_string(),
613            name: c.name,
614            version: c.max_version,
615            description: c.description.unwrap_or_default(),
616        })
617        .collect();
618
619    Ok(battery_packs)
620}
621
622fn print_battery_pack_list(filter: Option<&str>) -> Result<()> {
623    use console::style;
624
625    let battery_packs = fetch_battery_pack_list(filter)?;
626
627    if battery_packs.is_empty() {
628        match filter {
629            Some(q) => println!("No battery packs found matching '{}'", q),
630            None => println!("No battery packs found"),
631        }
632        return Ok(());
633    }
634
635    // Find the longest name for alignment
636    let max_name_len = battery_packs
637        .iter()
638        .map(|c| c.short_name.len())
639        .max()
640        .unwrap_or(0);
641
642    let max_version_len = battery_packs
643        .iter()
644        .map(|c| c.version.len())
645        .max()
646        .unwrap_or(0);
647
648    println!();
649    for bp in &battery_packs {
650        let desc = bp.description.lines().next().unwrap_or("");
651
652        // Pad strings manually, then apply colors (ANSI codes break width formatting)
653        let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
654        let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
655
656        println!(
657            "  {}  {}  {}",
658            style(name_padded).green().bold(),
659            style(ver_padded).dim(),
660            desc,
661        );
662    }
663    println!();
664
665    println!(
666        "{}",
667        style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
668    );
669
670    Ok(())
671}
672
673/// Convert "cli-battery-pack" to "cli" for display
674fn short_name(crate_name: &str) -> &str {
675    crate_name
676        .strip_suffix("-battery-pack")
677        .unwrap_or(crate_name)
678}
679
680/// Convert "cli" to "cli-battery-pack" (adds suffix if not already present)
681fn resolve_crate_name(name: &str) -> String {
682    if name.ends_with("-battery-pack") {
683        name.to_string()
684    } else {
685        format!("{}-battery-pack", name)
686    }
687}
688
689/// Fetch detailed battery pack info from crates.io or a local path
690pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
691    // If path is provided, use local directory
692    if let Some(local_path) = path {
693        return fetch_battery_pack_detail_from_path(local_path);
694    }
695
696    let crate_name = resolve_crate_name(name);
697
698    // Look up crate info and download
699    let crate_info = lookup_crate(&crate_name)?;
700    let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
701    let crate_dir = temp_dir
702        .path()
703        .join(format!("{}-{}", crate_name, crate_info.version));
704
705    // Fetch owners from crates.io
706    let owners = fetch_owners(&crate_name)?;
707
708    build_battery_pack_detail(
709        &crate_dir,
710        crate_name,
711        crate_info.version,
712        owners,
713    )
714}
715
716/// Fetch detailed battery pack info from a local path
717fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
718    let crate_dir = std::path::Path::new(path);
719
720    // Read Cargo.toml to extract name and version
721    let manifest_path = crate_dir.join("Cargo.toml");
722    let manifest_content = std::fs::read_to_string(&manifest_path)
723        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
724    let manifest: CargoManifest =
725        toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
726
727    let package = manifest.package.unwrap_or_default();
728    let crate_name = package
729        .name
730        .clone()
731        .unwrap_or_else(|| "unknown".to_string());
732    let version = package
733        .version
734        .clone()
735        .unwrap_or_else(|| "0.0.0".to_string());
736
737    build_battery_pack_detail(
738        crate_dir,
739        crate_name,
740        version,
741        Vec::new(), // No owners for local path
742    )
743}
744
745/// Helper function to build BatteryPackDetail from already-resolved parameters.
746/// Contains shared logic for both crates.io and local path sources.
747fn build_battery_pack_detail(
748    crate_dir: &Path,
749    crate_name: String,
750    version: String,
751    owners: Vec<Owner>,
752) -> Result<BatteryPackDetail> {
753    // Read and parse Cargo.toml
754    let manifest_path = crate_dir.join("Cargo.toml");
755    let manifest_content = std::fs::read_to_string(&manifest_path)
756        .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
757    let manifest: CargoManifest =
758        toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
759
760    // Extract info
761    let package = manifest.package.unwrap_or_default();
762    let description = package.description.clone().unwrap_or_default();
763    let repository = package.repository.clone();
764    let battery = package.metadata.and_then(|m| m.battery).unwrap_or_default();
765
766    // Split dependencies into battery packs and regular crates
767    let (extends_raw, crates_raw): (Vec<_>, Vec<_>) = manifest
768        .dependencies
769        .keys()
770        .filter(|d| *d != "battery-pack")
771        .partition(|d| d.ends_with("-battery-pack"));
772
773    let extends: Vec<String> = extends_raw
774        .into_iter()
775        .map(|d| short_name(d).to_string())
776        .collect();
777    let crates: Vec<String> = crates_raw.into_iter().cloned().collect();
778
779    // Fetch the GitHub repository tree to resolve paths
780    let repo_tree = repository.as_ref().and_then(|r| fetch_github_tree(r));
781
782    // Convert templates with resolved repo paths
783    let templates = battery
784        .templates
785        .into_iter()
786        .map(|(name, config)| {
787            let repo_path = repo_tree
788                .as_ref()
789                .and_then(|tree| find_template_path(tree, &config.path));
790            TemplateInfo {
791                name,
792                path: config.path,
793                description: config.description,
794                repo_path,
795            }
796        })
797        .collect();
798
799    // Scan examples directory
800    let examples = scan_examples(crate_dir, repo_tree.as_deref());
801
802    Ok(BatteryPackDetail {
803        short_name: short_name(&crate_name).to_string(),
804        name: crate_name,
805        version,
806        description,
807        repository,
808        owners: owners.into_iter().map(OwnerInfo::from).collect(),
809        crates,
810        extends,
811        templates,
812        examples,
813    })
814}
815
816fn print_battery_pack_detail(name: &str, path: Option<&str>) -> Result<()> {
817    use console::style;
818
819    let detail = fetch_battery_pack_detail(name, path)?;
820
821    // Header
822    println!();
823    println!(
824        "{} {}",
825        style(&detail.name).green().bold(),
826        style(&detail.version).dim()
827    );
828    if !detail.description.is_empty() {
829        println!("{}", detail.description);
830    }
831
832    // Authors
833    if !detail.owners.is_empty() {
834        println!();
835        println!("{}", style("Authors:").bold());
836        for owner in &detail.owners {
837            if let Some(name) = &owner.name {
838                println!("  {} ({})", name, owner.login);
839            } else {
840                println!("  {}", owner.login);
841            }
842        }
843    }
844
845    // Crates
846    if !detail.crates.is_empty() {
847        println!();
848        println!("{}", style("Crates:").bold());
849        for dep in &detail.crates {
850            println!("  {}", dep);
851        }
852    }
853
854    // Extends
855    if !detail.extends.is_empty() {
856        println!();
857        println!("{}", style("Extends:").bold());
858        for dep in &detail.extends {
859            println!("  {}", dep);
860        }
861    }
862
863    // Templates
864    if !detail.templates.is_empty() {
865        println!();
866        println!("{}", style("Templates:").bold());
867        let max_name_len = detail
868            .templates
869            .iter()
870            .map(|t| t.name.len())
871            .max()
872            .unwrap_or(0);
873        for tmpl in &detail.templates {
874            let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
875            if let Some(desc) = &tmpl.description {
876                println!("  {}  {}", style(name_padded).cyan(), desc);
877            } else {
878                println!("  {}", style(name_padded).cyan());
879            }
880        }
881    }
882
883    // Examples
884    if !detail.examples.is_empty() {
885        println!();
886        println!("{}", style("Examples:").bold());
887        let max_name_len = detail
888            .examples
889            .iter()
890            .map(|e| e.name.len())
891            .max()
892            .unwrap_or(0);
893        for example in &detail.examples {
894            let name_padded = format!("{:<width$}", example.name, width = max_name_len);
895            if let Some(desc) = &example.description {
896                println!("  {}  {}", style(name_padded).magenta(), desc);
897            } else {
898                println!("  {}", style(name_padded).magenta());
899            }
900        }
901    }
902
903    // Install hints
904    println!();
905    println!("{}", style("Install:").bold());
906    println!("  cargo bp add {}", detail.short_name);
907    println!("  cargo bp new {}", detail.short_name);
908    println!();
909
910    Ok(())
911}
912
913fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
914    let client = reqwest::blocking::Client::builder()
915        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
916        .build()?;
917
918    let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
919    let response = client
920        .get(&url)
921        .send()
922        .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
923
924    if !response.status().is_success() {
925        // Not fatal - just return empty
926        return Ok(Vec::new());
927    }
928
929    let parsed: OwnersResponse = response
930        .json()
931        .with_context(|| "Failed to parse owners response")?;
932
933    Ok(parsed.users)
934}
935
936/// Scan the examples directory and extract example info.
937/// If a GitHub tree is provided, resolves the full repository path for each example.
938fn scan_examples(crate_dir: &std::path::Path, repo_tree: Option<&[String]>) -> Vec<ExampleInfo> {
939    let examples_dir = crate_dir.join("examples");
940    if !examples_dir.exists() {
941        return Vec::new();
942    }
943
944    let mut examples = Vec::new();
945
946    if let Ok(entries) = std::fs::read_dir(&examples_dir) {
947        for entry in entries.flatten() {
948            let path = entry.path();
949            if path.extension().is_some_and(|ext| ext == "rs") {
950                if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
951                    let description = extract_example_description(&path);
952                    let repo_path = repo_tree.and_then(|tree| find_example_path(tree, name));
953                    examples.push(ExampleInfo {
954                        name: name.to_string(),
955                        description,
956                        repo_path,
957                    });
958                }
959            }
960        }
961    }
962
963    // Sort by name
964    examples.sort_by(|a, b| a.name.cmp(&b.name));
965    examples
966}
967
968/// Extract description from the first doc comment in an example file
969fn extract_example_description(path: &std::path::Path) -> Option<String> {
970    let content = std::fs::read_to_string(path).ok()?;
971
972    // Look for //! doc comments at the start
973    for line in content.lines() {
974        let trimmed = line.trim();
975        if trimmed.starts_with("//!") {
976            let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
977            if !desc.is_empty() {
978                return Some(desc.to_string());
979            }
980        } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
981            // Stop at first non-comment, non-empty line
982            break;
983        }
984    }
985    None
986}
987
988/// Fetch the repository tree from GitHub API.
989/// Returns a list of all file paths in the repository.
990fn fetch_github_tree(repository: &str) -> Option<Vec<String>> {
991    // Parse GitHub URL: https://github.com/owner/repo
992    let gh_path = repository
993        .strip_prefix("https://github.com/")
994        .or_else(|| repository.strip_prefix("http://github.com/"))?;
995    let gh_path = gh_path.strip_suffix(".git").unwrap_or(gh_path);
996    let gh_path = gh_path.trim_end_matches('/');
997
998    let client = reqwest::blocking::Client::builder()
999        .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
1000        .build()
1001        .ok()?;
1002
1003    // Fetch the tree recursively using the main branch
1004    let url = format!(
1005        "https://api.github.com/repos/{}/git/trees/main?recursive=1",
1006        gh_path
1007    );
1008
1009    let response = client.get(&url).send().ok()?;
1010    if !response.status().is_success() {
1011        return None;
1012    }
1013
1014    let tree_response: GitHubTreeResponse = response.json().ok()?;
1015
1016    // Extract all paths (both blobs/files and trees/directories)
1017    Some(
1018        tree_response
1019            .tree
1020            .into_iter()
1021            .map(|e| e.path)
1022            .collect(),
1023    )
1024}
1025
1026/// Find the full repository path for an example file.
1027/// Searches the tree for a file matching "examples/{name}.rs".
1028fn find_example_path(tree: &[String], example_name: &str) -> Option<String> {
1029    let suffix = format!("examples/{}.rs", example_name);
1030    tree.iter()
1031        .find(|path| path.ends_with(&suffix))
1032        .cloned()
1033}
1034
1035/// Find the full repository path for a template directory.
1036/// Searches the tree for a path matching "templates/{name}" or "{name}".
1037fn find_template_path(tree: &[String], template_path: &str) -> Option<String> {
1038    // The template path from config might be "templates/simple" or just the relative path
1039    tree.iter()
1040        .find(|path| path.ends_with(template_path))
1041        .cloned()
1042}