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}