Skip to main content

crate_names/
build.rs

1//! Build artifacts from a crates.io database dump tarball.
2
3use crate::format::{ZSTD_LEVEL, flatten_whitespace, folded_cmp, rank_from_downloads};
4use std::collections::HashMap;
5use std::fmt;
6use std::io::Read;
7
8/// Error returned when building artifacts from a dump.
9#[derive(Debug)]
10pub enum BuildError {
11    /// io error reading the tarball
12    Io(std::io::Error),
13    /// a csv file in the dump could not be parsed
14    Csv(csv::Error),
15    /// an expected csv file was absent from the tarball
16    MissingTable(&'static str),
17    /// an expected column was absent from a csv header
18    MissingColumn(&'static str, &'static str),
19    /// a crate name contained bytes outside the crates.io charset,
20    /// which would corrupt the line-oriented format
21    InvalidName(String),
22    /// two crate names folded to the same key, which crates.io should have
23    /// prevented and which the reader's binary search relies on being
24    /// impossible
25    DuplicateName(String, String),
26}
27
28impl fmt::Display for BuildError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            BuildError::Io(e) => write!(f, "io error reading dump: {e}"),
32            BuildError::Csv(e) => write!(f, "csv error reading dump: {e}"),
33            BuildError::MissingTable(t) => write!(f, "dump did not contain {t}"),
34            BuildError::MissingColumn(t, c) => write!(f, "{t} did not contain column {c}"),
35            BuildError::InvalidName(n) => write!(f, "unexpected crate name {n:?}"),
36            BuildError::DuplicateName(a, b) => {
37                write!(
38                    f,
39                    "crate names {a:?} and {b:?} are not distinct when folded"
40                )
41            }
42        }
43    }
44}
45
46impl std::error::Error for BuildError {}
47
48impl From<std::io::Error> for BuildError {
49    fn from(e: std::io::Error) -> Self {
50        BuildError::Io(e)
51    }
52}
53
54impl From<csv::Error> for BuildError {
55    fn from(e: csv::Error) -> Self {
56        BuildError::Csv(e)
57    }
58}
59
60/// The uncompressed artifacts plus build statistics.
61#[derive(Debug)]
62pub struct BuildOutput {
63    /// the names artifact, uncompressed
64    pub names_tsv: String,
65    /// the descriptions artifact, uncompressed
66    pub descriptions_tsv: String,
67    /// the facets artifact, uncompressed
68    pub facets_tsv: String,
69    /// crates included in the names artifact
70    pub crate_count: usize,
71    /// crates skipped because no default version could be resolved
72    pub skipped_no_version: usize,
73}
74
75impl BuildOutput {
76    /// The names artifact, compressed for publication.
77    pub fn names_zst(&self) -> std::io::Result<Vec<u8>> {
78        zstd::encode_all(self.names_tsv.as_bytes(), ZSTD_LEVEL)
79    }
80
81    /// The descriptions artifact, compressed for publication.
82    pub fn descriptions_zst(&self) -> std::io::Result<Vec<u8>> {
83        zstd::encode_all(self.descriptions_tsv.as_bytes(), ZSTD_LEVEL)
84    }
85
86    /// The facets artifact, compressed for publication.
87    pub fn facets_zst(&self) -> std::io::Result<Vec<u8>> {
88        zstd::encode_all(self.facets_tsv.as_bytes(), ZSTD_LEVEL)
89    }
90}
91
92/// Locates needed columns in a csv header by name, so we tolerate column
93/// additions and reorderings in future dumps.
94fn column_indexes<const N: usize>(
95    table: &'static str,
96    reader: &mut csv::Reader<impl Read>,
97    columns: [&'static str; N],
98) -> Result<[usize; N], BuildError> {
99    let headers = reader.headers()?.clone();
100    let mut indexes = [0; N];
101    for (index, column) in columns.into_iter().enumerate() {
102        indexes[index] = headers
103            .iter()
104            .position(|header| header == column)
105            .ok_or(BuildError::MissingColumn(table, column))?;
106    }
107    Ok(indexes)
108}
109
110/// Stream a crates.io database dump tarball (`db-dump.tar.gz` as
111/// downloaded, still gzipped) and produce the v2 artifacts.
112///
113/// Single pass; does not require seeking, so the tarball can be piped in
114/// without touching disk. Buffers one version string per published
115/// version, on the order of a few hundred MB for the 2026 registry.
116pub fn build_from_dump(reader: impl Read) -> Result<BuildOutput, BuildError> {
117    // crate_id -> all-time downloads
118    let mut downloads: HashMap<u64, u64> = HashMap::new();
119    // (name, crate_id, description)
120    let mut crates: Vec<(String, u64, String)> = Vec::new();
121    // version_id -> version string
122    let mut version_num: HashMap<u64, String> = HashMap::new();
123    // crate_id -> default version_id
124    let mut default_version: HashMap<u64, u64> = HashMap::new();
125    // keyword_id -> keyword text
126    let mut keyword_text: HashMap<u64, String> = HashMap::new();
127    // category_id -> category slug
128    let mut category_slug: HashMap<u64, String> = HashMap::new();
129    // crate_id -> keyword_ids
130    let mut crate_keywords: HashMap<u64, Vec<u64>> = HashMap::new();
131    // crate_id -> category_ids
132    let mut crate_categories: HashMap<u64, Vec<u64>> = HashMap::new();
133    let mut seen = [None::<&'static str>; 8];
134
135    let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(reader));
136    for entry in archive.entries()? {
137        let entry = entry?;
138        let path = entry.path()?;
139        let Some(file_name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
140            continue;
141        };
142
143        match file_name.as_str() {
144            "crate_downloads.csv" => {
145                seen[0] = Some("crate_downloads.csv");
146                let mut csv = csv::Reader::from_reader(entry);
147                let [crate_id, downloads_col] =
148                    column_indexes("crate_downloads.csv", &mut csv, ["crate_id", "downloads"])?;
149                for record in csv.records() {
150                    let record = record?;
151                    if let (Some(id), Some(count)) = (
152                        record.get(crate_id).and_then(|f| f.parse().ok()),
153                        record.get(downloads_col).and_then(|f| f.parse().ok()),
154                    ) {
155                        downloads.insert(id, count);
156                    }
157                }
158            }
159            "crates.csv" => {
160                seen[1] = Some("crates.csv");
161                let mut csv = csv::Reader::from_reader(entry);
162                let [id, name, description] =
163                    column_indexes("crates.csv", &mut csv, ["id", "name", "description"])?;
164                for record in csv.records() {
165                    let record = record?;
166                    let (Some(id), Some(name)) = (
167                        record.get(id).and_then(|f| f.parse().ok()),
168                        record.get(name),
169                    ) else {
170                        continue;
171                    };
172                    if !name
173                        .bytes()
174                        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
175                    {
176                        return Err(BuildError::InvalidName(name.to_owned()));
177                    }
178                    let description = record.get(description).unwrap_or_default();
179                    crates.push((name.to_owned(), id, flatten_whitespace(description)));
180                }
181            }
182            "versions.csv" => {
183                seen[2] = Some("versions.csv");
184                let mut csv = csv::Reader::from_reader(entry);
185                let [id, num] = column_indexes("versions.csv", &mut csv, ["id", "num"])?;
186                for record in csv.records() {
187                    let record = record?;
188                    if let (Some(id), Some(num)) =
189                        (record.get(id).and_then(|f| f.parse().ok()), record.get(num))
190                    {
191                        version_num.insert(id, num.to_owned());
192                    }
193                }
194            }
195            "default_versions.csv" => {
196                seen[3] = Some("default_versions.csv");
197                let mut csv = csv::Reader::from_reader(entry);
198                let [crate_id, version_id] =
199                    column_indexes("default_versions.csv", &mut csv, ["crate_id", "version_id"])?;
200                for record in csv.records() {
201                    let record = record?;
202                    if let (Some(crate_id), Some(version_id)) = (
203                        record.get(crate_id).and_then(|f| f.parse().ok()),
204                        record.get(version_id).and_then(|f| f.parse().ok()),
205                    ) {
206                        default_version.insert(crate_id, version_id);
207                    }
208                }
209            }
210            "keywords.csv" => {
211                seen[4] = Some("keywords.csv");
212                let mut csv = csv::Reader::from_reader(entry);
213                let [id, keyword] = column_indexes("keywords.csv", &mut csv, ["id", "keyword"])?;
214                for record in csv.records() {
215                    let record = record?;
216                    // a keyword containing whitespace would corrupt the
217                    // space-separated field; none should exist, but the
218                    // dump is external input
219                    if let (Some(id), Some(keyword)) = (
220                        record.get(id).and_then(|f| f.parse().ok()),
221                        record.get(keyword),
222                    ) && !keyword.is_empty()
223                        && !keyword.contains(char::is_whitespace)
224                    {
225                        keyword_text.insert(id, keyword.to_owned());
226                    }
227                }
228            }
229            "categories.csv" => {
230                seen[5] = Some("categories.csv");
231                let mut csv = csv::Reader::from_reader(entry);
232                let [id, slug] = column_indexes("categories.csv", &mut csv, ["id", "slug"])?;
233                for record in csv.records() {
234                    let record = record?;
235                    if let (Some(id), Some(slug)) = (
236                        record.get(id).and_then(|f| f.parse().ok()),
237                        record.get(slug),
238                    ) && !slug.is_empty()
239                        && !slug.contains(char::is_whitespace)
240                    {
241                        category_slug.insert(id, slug.to_owned());
242                    }
243                }
244            }
245            "crates_keywords.csv" => {
246                seen[6] = Some("crates_keywords.csv");
247                let mut csv = csv::Reader::from_reader(entry);
248                let [crate_id, keyword_id] =
249                    column_indexes("crates_keywords.csv", &mut csv, ["crate_id", "keyword_id"])?;
250                for record in csv.records() {
251                    let record = record?;
252                    if let (Some(crate_id), Some(keyword_id)) = (
253                        record.get(crate_id).and_then(|f| f.parse().ok()),
254                        record.get(keyword_id).and_then(|f| f.parse().ok()),
255                    ) {
256                        crate_keywords.entry(crate_id).or_default().push(keyword_id);
257                    }
258                }
259            }
260            "crates_categories.csv" => {
261                seen[7] = Some("crates_categories.csv");
262                let mut csv = csv::Reader::from_reader(entry);
263                let [crate_id, category_id] = column_indexes(
264                    "crates_categories.csv",
265                    &mut csv,
266                    ["crate_id", "category_id"],
267                )?;
268                for record in csv.records() {
269                    let record = record?;
270                    if let (Some(crate_id), Some(category_id)) = (
271                        record.get(crate_id).and_then(|f| f.parse().ok()),
272                        record.get(category_id).and_then(|f| f.parse().ok()),
273                    ) {
274                        crate_categories
275                            .entry(crate_id)
276                            .or_default()
277                            .push(category_id);
278                    }
279                }
280            }
281            _ => {}
282        }
283    }
284
285    for (index, table) in [
286        "crate_downloads.csv",
287        "crates.csv",
288        "versions.csv",
289        "default_versions.csv",
290        "keywords.csv",
291        "categories.csv",
292        "crates_keywords.csv",
293        "crates_categories.csv",
294    ]
295    .into_iter()
296    .enumerate()
297    {
298        if seen[index].is_none() {
299            return Err(BuildError::MissingTable(table));
300        }
301    }
302
303    // Sorted by folded name, which is the key the reader binary-searches. The
304    // fold is only an ordering: each line still carries the name as spelled.
305    crates.sort_unstable_by(|a, b| folded_cmp(&a.0, &b.0));
306    if let Some([a, b]) = crates
307        .windows(2)
308        .find(|pair| folded_cmp(&pair[0].0, &pair[1].0).is_eq())
309    {
310        return Err(BuildError::DuplicateName(a.0.clone(), b.0.clone()));
311    }
312
313    // Resolve one crate's facet ids to their sorted, deduped, space-joined
314    // texts. Ids whose text was absent (or filtered) are dropped.
315    let joined = |ids: Option<&Vec<u64>>, texts: &HashMap<u64, String>| -> String {
316        let mut items: Vec<&str> = ids
317            .into_iter()
318            .flatten()
319            .filter_map(|id| texts.get(id))
320            .map(String::as_str)
321            .collect();
322        items.sort_unstable();
323        items.dedup();
324        items.join(" ")
325    };
326
327    let mut names_tsv = String::new();
328    let mut descriptions_tsv = String::new();
329    let mut facets_tsv = String::new();
330    let mut skipped_no_version = 0;
331    let mut crate_count = 0;
332    for (name, id, description) in &crates {
333        let Some(version) = default_version.get(id).and_then(|vid| version_num.get(vid)) else {
334            skipped_no_version += 1;
335            continue;
336        };
337        let rank = rank_from_downloads(downloads.get(id).copied().unwrap_or_default());
338        names_tsv.push_str(&format!("{name}\t{version}\t{rank}\n"));
339        if !description.is_empty() {
340            descriptions_tsv.push_str(&format!("{name}\t{description}\n"));
341        }
342        let keywords = joined(crate_keywords.get(id), &keyword_text);
343        let categories = joined(crate_categories.get(id), &category_slug);
344        if !keywords.is_empty() || !categories.is_empty() {
345            facets_tsv.push_str(&format!("{name}\t{keywords}\t{categories}\n"));
346        }
347        crate_count += 1;
348    }
349
350    Ok(BuildOutput {
351        names_tsv,
352        descriptions_tsv,
353        facets_tsv,
354        crate_count,
355        skipped_no_version,
356    })
357}