Skip to main content

docs_mcp/sparse_index/
types.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5/// A single entry in the crates.io sparse index (one line of NDJSON per version).
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct IndexLine {
8    /// Crate name (may be mixed-case but normalized)
9    pub name: String,
10    /// Version string
11    pub vers: String,
12    /// Dependencies
13    #[serde(default)]
14    pub deps: Vec<DepEntry>,
15    /// SHA-256 checksum of the .crate file
16    pub cksum: String,
17    /// Feature map: feature_name -> list of dep features enabled
18    #[serde(default)]
19    pub features: HashMap<String, Vec<String>>,
20    /// Whether this version is yanked
21    #[serde(default)]
22    pub yanked: bool,
23    /// Minimum Rust version (MSRV)
24    pub rust_version: Option<String>,
25    /// v2 features (merged with features)
26    pub features2: Option<HashMap<String, Vec<String>>>,
27}
28
29impl IndexLine {
30    /// Merged features (features + features2)
31    pub fn all_features(&self) -> HashMap<String, Vec<String>> {
32        let mut merged = self.features.clone();
33        if let Some(f2) = &self.features2 {
34            merged.extend(f2.clone());
35        }
36        merged
37    }
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct DepEntry {
42    pub name: String,
43    pub req: String,
44    /// The renamed package name (if any)
45    pub package: Option<String>,
46    pub kind: Option<DepKind>,
47    #[serde(default)]
48    pub optional: bool,
49    #[serde(default)]
50    pub default_features: bool,
51    #[serde(default)]
52    pub features: Vec<String>,
53    pub target: Option<String>,
54}
55
56#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum DepKind {
59    Normal,
60    Dev,
61    Build,
62}
63
64/// Compute the sparse index path for a crate name.
65///
66/// Rules:
67/// - 1 char:  `1/{name}`
68/// - 2 chars: `2/{name}`
69/// - 3 chars: `3/{first}/{name}`
70/// - 4+ chars: `{first2}/{next2}/{name}`
71pub fn compute_path(name: &str) -> String {
72    let n = name.to_lowercase();
73    match n.len() {
74        0 => panic!("empty crate name"),
75        1 => format!("1/{n}"),
76        2 => format!("2/{n}"),
77        3 => format!("3/{}/{n}", &n[0..1]),
78        _ => format!("{}/{}/{n}", &n[0..2], &n[2..4]),
79    }
80}
81
82/// Find the latest stable version from a list of index lines.
83///
84/// - Filters out yanked versions
85/// - Filters out pre-release versions (any version string containing `-`)
86/// - Returns the highest semver among the remainder
87/// - If no stable version exists, falls back to highest non-yanked version of any kind
88pub fn find_latest_stable(lines: &[IndexLine]) -> Option<&IndexLine> {
89    use semver::Version;
90
91    // Try stable first
92    let stable: Vec<&IndexLine> = lines
93        .iter()
94        .filter(|l| !l.yanked && !l.vers.contains('-'))
95        .collect();
96
97    if !stable.is_empty() {
98        return stable
99            .into_iter()
100            .max_by_key(|l| Version::parse(&l.vers).ok());
101    }
102
103    // Fall back to any non-yanked version
104    lines
105        .iter()
106        .filter(|l| !l.yanked)
107        .max_by_key(|l| Version::parse(&l.vers).ok())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn test_compute_path_1_char() {
116        assert_eq!(compute_path("a"), "1/a");
117    }
118
119    #[test]
120    fn test_compute_path_2_chars() {
121        assert_eq!(compute_path("io"), "2/io");
122    }
123
124    #[test]
125    fn test_compute_path_3_chars() {
126        assert_eq!(compute_path("url"), "3/u/url");
127    }
128
129    #[test]
130    fn test_compute_path_4_plus_chars() {
131        assert_eq!(compute_path("serde"), "se/rd/serde");
132    }
133
134    #[test]
135    fn test_compute_path_uppercase() {
136        assert_eq!(compute_path("SERDE"), "se/rd/serde");
137    }
138
139    #[test]
140    fn test_find_latest_stable_ignores_yanked() {
141        let lines = vec![
142            make_line("1.0.0", false, false),
143            make_line("1.1.0", true, false), // yanked
144            make_line("0.9.0", false, false),
145        ];
146        let latest = find_latest_stable(&lines).unwrap();
147        assert_eq!(latest.vers, "1.0.0");
148    }
149
150    #[test]
151    fn test_find_latest_stable_ignores_prerelease() {
152        let lines = vec![
153            make_line("1.0.0", false, false),
154            make_line("1.1.0-alpha.1", false, true),
155            make_line("0.9.0", false, false),
156        ];
157        let latest = find_latest_stable(&lines).unwrap();
158        assert_eq!(latest.vers, "1.0.0");
159    }
160
161    #[test]
162    fn test_find_latest_stable_fallback_to_prerelease() {
163        let lines = vec![
164            make_line("1.0.0-alpha.1", false, true),
165            make_line("0.9.0-beta.1", false, true),
166        ];
167        let latest = find_latest_stable(&lines).unwrap();
168        assert_eq!(latest.vers, "1.0.0-alpha.1");
169    }
170
171    fn make_line(vers: &str, yanked: bool, _is_pre: bool) -> IndexLine {
172        IndexLine {
173            name: "test".to_string(),
174            vers: vers.to_string(),
175            deps: vec![],
176            cksum: "abc".to_string(),
177            features: Default::default(),
178            yanked,
179            rust_version: None,
180            features2: None,
181        }
182    }
183}