cargo_docs_md/multi_crate/parser.rs
1//! Multi-crate JSON parser.
2//!
3//! This module provides [`MultiCrateParser`] which scans a directory for
4//! rustdoc JSON files and parses them into a [`CrateCollection`].
5
6use std::path::Path;
7
8use rustdoc_types::ItemEnum;
9use walkdir::WalkDir;
10
11use crate::error::Error;
12use crate::multi_crate::CrateCollection;
13use crate::parser::Parser;
14
15/// Parser for multiple rustdoc JSON files in a directory.
16///
17/// Discovers JSON files and parses each one, extracting the crate name
18/// from the root module item.
19///
20/// # Example
21///
22/// ```ignore
23/// let crates = MultiCrateParser::parse_directory(Path::new("target/doc"))?;
24/// println!("Found {} crates", crates.len());
25/// ```
26pub struct MultiCrateParser;
27
28impl MultiCrateParser {
29 /// Parse all rustdoc JSON files in a directory.
30 ///
31 /// Scans the top level of the directory for `*.json` files and
32 /// attempts to parse each one as rustdoc JSON. Files that aren't
33 /// valid rustdoc JSON (e.g., search indices) are silently skipped.
34 ///
35 /// # Arguments
36 ///
37 /// * `dir` - Path to directory containing JSON files
38 ///
39 /// # Returns
40 ///
41 /// A `CrateCollection` containing all successfully parsed crates.
42 ///
43 /// # Errors
44 ///
45 /// - [`Error::InvalidDirectory`] if the path is invalid
46 /// - [`Error::NoJsonFiles`] if no valid JSON files found
47 /// - [`Error::DuplicateCrate`] if multiple files define the same crate
48 /// - [`Error::NoCrateName`] if a JSON file has no root module
49 pub fn parse_directory(dir: &Path) -> Result<CrateCollection, Error> {
50 // Validate directory exists and is readable
51 if !dir.is_dir() {
52 return Err(Error::InvalidDirectory(dir.display().to_string()));
53 }
54
55 let mut collection = CrateCollection::new();
56
57 // Walk only the top level (max_depth 1 = directory itself + immediate children)
58 for entry in WalkDir::new(dir)
59 .max_depth(1)
60 .into_iter()
61 .filter_map(Result::ok)
62 {
63 let path = entry.path();
64
65 // Skip non-JSON files
66 if path.extension().is_some_and(|ext| ext != "json") {
67 continue;
68 }
69
70 // Skip directories
71 if path.is_dir() {
72 continue;
73 }
74
75 // Try to parse as rustdoc JSON
76 let Ok(krate) = Parser::parse_file(path) else {
77 continue;
78 };
79
80 // Validate it's actually rustdoc JSON by checking for root module
81 if !krate.index.contains_key(&krate.root) {
82 continue;
83 }
84
85 // Extract crate name from root item
86 let crate_name = Self::extract_crate_name(&krate, path)?;
87
88 // Check for duplicates
89 if collection.contains(&crate_name) {
90 return Err(Error::DuplicateCrate(crate_name));
91 }
92
93 collection.insert(crate_name, krate);
94 }
95
96 // Ensure we found at least one crate
97 if collection.is_empty() {
98 return Err(Error::NoJsonFiles(dir.to_path_buf()));
99 }
100
101 Ok(collection)
102 }
103
104 /// Extract the crate name from a parsed Crate.
105 ///
106 /// The crate name is stored in the root item's `name` field.
107 fn extract_crate_name(krate: &rustdoc_types::Crate, path: &Path) -> Result<String, Error> {
108 let root_item = krate
109 .index
110 .get(&krate.root)
111 .ok_or_else(|| Error::NoCrateName(path.to_path_buf()))?;
112
113 // The root should be a Module
114 if !matches!(&root_item.inner, ItemEnum::Module(_)) {
115 return Err(Error::NoCrateName(path.to_path_buf()));
116 }
117
118 root_item
119 .name
120 .clone()
121 .ok_or_else(|| Error::NoCrateName(path.to_path_buf()))
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn test_invalid_directory() {
131 let result = MultiCrateParser::parse_directory(Path::new("/nonexistent/path"));
132 assert!(matches!(result, Err(Error::InvalidDirectory(_))));
133 }
134
135 #[test]
136 fn test_empty_directory() {
137 // Create a temp directory with no JSON files
138 let temp_dir = std::env::temp_dir().join("docs_md_test_empty");
139 let _ = std::fs::create_dir_all(&temp_dir);
140
141 let result = MultiCrateParser::parse_directory(&temp_dir);
142
143 // Should fail with NoJsonFiles
144 assert!(matches!(result, Err(Error::NoJsonFiles(_))));
145
146 let _ = std::fs::remove_dir_all(&temp_dir);
147 }
148}