cargo-copter 0.3.0

Test dependents against multiple versions of your crate (or your local WIP before publishing). Inspired by the cargo-crusader
/// API module for interacting with crates.io
///
/// This module provides functions for fetching reverse dependencies,
/// resolving versions, and downloading crate files.
use crates_io_api::SyncClient;
use log::debug;
use std::time::Duration;

const USER_AGENT: &str = "cargo-copter/0.3.0 (https://github.com/imazen/cargo-copter)";
const CRATES_IO_PAGE_SIZE: usize = 100;
const MAX_API_PAGES: usize = 100; // Safety limit: don't fetch more than 10,000 deps

lazy_static::lazy_static! {
    static ref CRATES_IO_CLIENT: SyncClient = {
        SyncClient::new(USER_AGENT, Duration::from_millis(1000))
            .expect("Failed to create crates.io API client")
    };
}

/// Get the shared crates.io API client
pub fn get_client() -> &'static SyncClient {
    &CRATES_IO_CLIENT
}

/// A reverse dependency (crate that depends on our crate)
#[derive(Debug, Clone)]
pub struct ReverseDependency {
    pub name: String,
    pub downloads: u64,
}

/// Get reverse dependencies with pagination and optional limiting
///
/// This uses the paginated API to avoid downloading all reverse deps at once.
/// Results are sorted by download count descending and limited to the requested amount.
///
/// # Arguments
/// * `crate_name` - The crate to find reverse dependencies for
/// * `limit` - Maximum number of dependents to return (default: all)
pub fn get_reverse_dependencies(crate_name: &str, limit: Option<usize>) -> Result<Vec<ReverseDependency>, String> {
    debug!("fetching reverse dependencies for {}", crate_name);

    let mut all_deps = Vec::new();

    // Determine how many pages we need
    let max_pages = match limit {
        Some(lim) => lim.div_ceil(CRATES_IO_PAGE_SIZE),
        None => MAX_API_PAGES,
    };

    for page in 1..=max_pages {
        debug!("fetching page {} of reverse dependencies", page);

        let deps = CRATES_IO_CLIENT
            .crate_reverse_dependencies_page(crate_name, page as u64)
            .map_err(|e| format!("Failed to fetch reverse dependencies: {}", e))?;

        let page_size = deps.dependencies.len();
        debug!("got {} dependencies on page {}", page_size, page);

        // Extract dependency info
        for dep in deps.dependencies {
            all_deps.push(ReverseDependency {
                name: dep.crate_version.crate_name.clone(),
                downloads: dep.crate_version.downloads,
            });
        }

        // If we got less than expected, we've reached the end
        if page_size < CRATES_IO_PAGE_SIZE {
            break;
        }

        // If we have enough, stop
        if let Some(lim) = limit
            && all_deps.len() >= lim
        {
            break;
        }
    }

    // Sort by downloads descending
    all_deps.sort_by_key(|d| std::cmp::Reverse(d.downloads));

    // Apply limit
    if let Some(lim) = limit {
        all_deps.truncate(lim);
    }

    debug!("found {} reverse dependencies for {}", all_deps.len(), crate_name);

    Ok(all_deps)
}

/// Get top N reverse dependencies sorted by download count
///
/// # Arguments
/// * `crate_name` - The crate to find reverse dependencies for
/// * `limit` - Number of top dependents to return
pub fn get_top_dependents(crate_name: &str, limit: usize) -> Result<Vec<ReverseDependency>, String> {
    get_reverse_dependencies(crate_name, Some(limit))
}

/// A version with its download count
#[derive(Debug, Clone)]
pub struct VersionDownloads {
    pub version: String,
    pub downloads: u64,
    pub yanked: bool,
}

/// Get all versions of a crate with download counts, sorted by downloads descending
///
/// Excludes yanked and pre-release versions by default.
pub fn get_version_downloads(crate_name: &str) -> Result<Vec<VersionDownloads>, String> {
    debug!("fetching version downloads for {}", crate_name);

    let krate = CRATES_IO_CLIENT
        .get_crate(crate_name)
        .map_err(|e| format!("Failed to fetch crate info for {}: {}", crate_name, e))?;

    let mut versions: Vec<VersionDownloads> = krate
        .versions
        .iter()
        .filter(|v| !v.yanked)
        .filter(|v| {
            // Exclude pre-release versions
            semver::Version::parse(&v.num).map(|sv| sv.pre.is_empty()).unwrap_or(false)
        })
        .map(|v| VersionDownloads { version: v.num.clone(), downloads: v.downloads, yanked: v.yanked })
        .collect();

    versions.sort_by_key(|v| std::cmp::Reverse(v.downloads));

    debug!("found {} versions for {}", versions.len(), crate_name);
    Ok(versions)
}

#[cfg(test)]
mod tests {
    use super::*;

    // Note: These tests require network access and hit the real crates.io API
    // They are here to verify the API works but should not be run in CI

    #[test]
    #[ignore] // Requires network access
    fn test_get_top_dependents() {
        let deps = get_top_dependents("serde", 5).unwrap();
        assert_eq!(deps.len(), 5);

        // Should be sorted by downloads descending
        for i in 1..deps.len() {
            assert!(deps[i - 1].downloads >= deps[i].downloads);
        }
    }

    #[test]
    #[ignore] // Requires network access
    fn test_get_reverse_dependencies_with_limit() {
        let deps = get_reverse_dependencies("log", Some(10)).unwrap();
        assert_eq!(deps.len(), 10);
    }

    #[test]
    fn test_reverse_dependency_structure() {
        let dep = ReverseDependency { name: "test-crate".to_string(), downloads: 1000 };
        assert_eq!(dep.name, "test-crate");
        assert_eq!(dep.downloads, 1000);
    }
}