1use crate::format::{ZSTD_LEVEL, flatten_whitespace, folded_cmp, rank_from_downloads};
4use std::collections::HashMap;
5use std::fmt;
6use std::io::Read;
7
8#[derive(Debug)]
10pub enum BuildError {
11 Io(std::io::Error),
13 Csv(csv::Error),
15 MissingTable(&'static str),
17 MissingColumn(&'static str, &'static str),
19 InvalidName(String),
22 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#[derive(Debug)]
62pub struct BuildOutput {
63 pub names_tsv: String,
65 pub descriptions_tsv: String,
67 pub facets_tsv: String,
69 pub crate_count: usize,
71 pub skipped_no_version: usize,
73}
74
75impl BuildOutput {
76 pub fn names_zst(&self) -> std::io::Result<Vec<u8>> {
78 zstd::encode_all(self.names_tsv.as_bytes(), ZSTD_LEVEL)
79 }
80
81 pub fn descriptions_zst(&self) -> std::io::Result<Vec<u8>> {
83 zstd::encode_all(self.descriptions_tsv.as_bytes(), ZSTD_LEVEL)
84 }
85
86 pub fn facets_zst(&self) -> std::io::Result<Vec<u8>> {
88 zstd::encode_all(self.facets_tsv.as_bytes(), ZSTD_LEVEL)
89 }
90}
91
92fn 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
110pub fn build_from_dump(reader: impl Read) -> Result<BuildOutput, BuildError> {
117 let mut downloads: HashMap<u64, u64> = HashMap::new();
119 let mut crates: Vec<(String, u64, String)> = Vec::new();
121 let mut version_num: HashMap<u64, String> = HashMap::new();
123 let mut default_version: HashMap<u64, u64> = HashMap::new();
125 let mut keyword_text: HashMap<u64, String> = HashMap::new();
127 let mut category_slug: HashMap<u64, String> = HashMap::new();
129 let mut crate_keywords: HashMap<u64, Vec<u64>> = HashMap::new();
131 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 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 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 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}