rustdoc_text/lib.rs
1//! # rustdoc-text
2//!
3//! A lightweight library to view Rust documentation as plain text (Markdown).
4//!
5//! This crate provides both a library and a binary for accessing Rust documentation
6//! in plain text format.
7//!
8#![doc = include_str!("../README.md")]
9
10use anyhow::{anyhow, Result};
11use htmd::HtmlToMarkdown;
12use reqwest::blocking::Client;
13use scraper::{Html, Selector};
14use std::fs;
15use std::path::PathBuf;
16use std::process::Command;
17use tempfile::tempdir;
18
19/// Fetches Rust documentation from docs.rs and converts it to Markdown.
20///
21/// # Arguments
22///
23/// * `crate_name` - The name of the crate to fetch documentation for
24/// * `item_path` - Optional path to a specific item within the crate
25///
26/// # Returns
27///
28/// The documentation as Markdown text.
29///
30/// # Examples
31///
32/// ```no_run
33/// use rustdoc_text::fetch_online_docs;
34///
35/// # fn main() -> anyhow::Result<()> {
36/// let docs = fetch_online_docs("serde", None)?;
37/// println!("{}", docs);
38/// # Ok(())
39/// # }
40/// ```
41pub fn fetch_online_docs(crate_name: &str, item_path: Option<&str>) -> Result<String> {
42 let client = Client::new();
43
44 // Construct the URL for docs.rs
45 let mut url = format!("https://docs.rs/{}", crate_name);
46 if let Some(path) = item_path {
47 url = format!("{}/{}", url, path.replace("::", "/"));
48 }
49
50 // Fetch the HTML content
51 let response = client.get(&url).send()?;
52
53 if !response.status().is_success() {
54 return Err(anyhow!(
55 "Failed to fetch documentation. Status: {}",
56 response.status()
57 ));
58 }
59
60 let html_content = response.text()?;
61 process_html_content(&html_content)
62}
63
64/// Builds and fetches Rust documentation locally and converts it to Markdown.
65///
66/// # Arguments
67///
68/// * `crate_name` - The name of the crate to fetch documentation for
69/// * `item_path` - Optional path to a specific item within the crate
70///
71/// # Returns
72///
73/// The documentation as Markdown text.
74///
75/// # Examples
76///
77/// ```no_run
78/// use rustdoc_text::fetch_local_docs;
79///
80/// # fn main() -> anyhow::Result<()> {
81/// let docs = fetch_local_docs("serde", None)?;
82/// println!("{}", docs);
83/// # Ok(())
84/// # }
85/// ```
86pub fn fetch_local_docs(crate_name: &str, item_path: Option<&str>) -> Result<String> {
87 // Create a temporary directory for the operation
88 let temp_dir = tempdir()?;
89 let temp_path = temp_dir.path();
90
91 // Check if we're in a cargo project
92 let current_dir = std::env::current_dir()?;
93 let is_cargo_project = current_dir.join("Cargo.toml").exists();
94
95 let doc_path: PathBuf;
96
97 if is_cargo_project {
98 // We're in a cargo project, build docs for the current project
99 let status = Command::new("cargo")
100 .args(["doc", "--no-deps"])
101 .current_dir(¤t_dir)
102 .status()?;
103
104 if !status.success() {
105 return Err(anyhow!("Failed to build documentation with cargo doc"));
106 }
107
108 doc_path = current_dir.join("target").join("doc");
109 } else {
110 // Try to build documentation for an external crate
111 let status = Command::new("cargo")
112 .args(["new", "--bin", "temp_project"])
113 .current_dir(temp_path)
114 .status()?;
115
116 if !status.success() {
117 return Err(anyhow!("Failed to create temporary cargo project"));
118 }
119
120 // Add the crate as a dependency
121 let temp_cargo_toml = temp_path.join("temp_project").join("Cargo.toml");
122 let mut cargo_toml_content = fs::read_to_string(&temp_cargo_toml)?;
123 cargo_toml_content.push_str(&format!("\n[dependencies]\n{} = \"*\"\n", crate_name));
124 fs::write(&temp_cargo_toml, cargo_toml_content)?;
125
126 // Build the documentation
127 let status = Command::new("cargo")
128 .args(["doc", "--no-deps"])
129 .current_dir(temp_path.join("temp_project"))
130 .status()?;
131
132 if !status.success() {
133 return Err(anyhow!(
134 "Failed to build documentation for crate: {}",
135 crate_name
136 ));
137 }
138
139 doc_path = temp_path.join("temp_project").join("target").join("doc");
140 }
141
142 // Find the HTML files
143 let crate_doc_path = doc_path.join(crate_name.replace('-', "_"));
144
145 if !crate_doc_path.exists() {
146 return Err(anyhow!("Documentation not found for crate: {}", crate_name));
147 }
148
149 let index_path = if let Some(path) = item_path {
150 crate_doc_path
151 .join(path.replace("::", "/"))
152 .join("index.html")
153 } else {
154 crate_doc_path.join("index.html")
155 };
156
157 if !index_path.exists() {
158 return Err(anyhow!("Documentation not found at path: {:?}", index_path));
159 }
160
161 let html_content = fs::read_to_string(index_path)?;
162 process_html_content(&html_content)
163}
164
165/// Process HTML content to extract and convert relevant documentation parts to Markdown.
166///
167/// # Arguments
168///
169/// * `html` - The HTML content to process
170///
171/// # Returns
172///
173/// The documentation as Markdown text.
174pub fn process_html_content(html: &str) -> Result<String> {
175 let document = Html::parse_document(html);
176
177 // Select the main content div which contains the documentation
178 let main_content_selector = Selector::parse("#main-content").unwrap();
179 let main_content = document
180 .select(&main_content_selector)
181 .next()
182 .ok_or_else(|| anyhow!("Could not find main content section"))?;
183
184 // Get HTML content
185 let html_content = main_content.inner_html();
186
187 // Convert HTML to Markdown using htmd
188 let converter = HtmlToMarkdown::builder()
189 .skip_tags(vec!["script", "style"])
190 .build();
191
192 let markdown = converter
193 .convert(&html_content)
194 .map_err(|e| anyhow!("HTML to Markdown conversion failed: {}", e))?;
195
196 // Clean up the markdown (replace multiple newlines, etc.)
197 let cleaned_text = clean_markdown(&markdown);
198
199 Ok(cleaned_text)
200}
201
202/// Clean up the markdown output to make it more readable in terminal.
203///
204/// # Arguments
205///
206/// * `markdown` - The markdown text to clean
207///
208/// # Returns
209///
210/// The cleaned markdown text.
211pub fn clean_markdown(markdown: &str) -> String {
212 // Replace 3+ consecutive newlines with 2 newlines
213 let mut result = String::new();
214 let mut last_was_newline = false;
215 let mut newline_count = 0;
216
217 for c in markdown.chars() {
218 if c == '\n' {
219 newline_count += 1;
220 if newline_count <= 2 {
221 result.push(c);
222 }
223 last_was_newline = true;
224 } else {
225 if last_was_newline {
226 newline_count = 0;
227 last_was_newline = false;
228 }
229 result.push(c);
230 }
231 }
232
233 result
234}
235
236/// Configuration options for fetching Rust documentation.
237pub struct Config {
238 /// The name of the crate to fetch documentation for.
239 pub crate_name: String,
240
241 /// Optional path to a specific item within the crate.
242 pub item_path: Option<String>,
243
244 /// Whether to fetch documentation from docs.rs instead of building locally.
245 pub online: bool,
246}
247
248impl Config {
249 /// Create a new configuration with the specified crate name.
250 ///
251 /// # Arguments
252 ///
253 /// * `crate_name` - The name of the crate to fetch documentation for
254 ///
255 /// # Examples
256 ///
257 /// ```
258 /// use rustdoc_text::Config;
259 ///
260 /// let config = Config::new("serde");
261 /// assert_eq!(config.crate_name, "serde");
262 /// assert_eq!(config.online, false);
263 /// ```
264 pub fn new<S: Into<String>>(crate_name: S) -> Self {
265 Self {
266 crate_name: crate_name.into(),
267 item_path: None,
268 online: false,
269 }
270 }
271
272 /// Set the item path for the configuration.
273 ///
274 /// # Arguments
275 ///
276 /// * `item_path` - The item path within the crate
277 ///
278 /// # Examples
279 ///
280 /// ```
281 /// use rustdoc_text::Config;
282 ///
283 /// let config = Config::new("serde").with_item_path("Deserializer");
284 /// assert_eq!(config.item_path, Some("Deserializer".to_string()));
285 /// ```
286 pub fn with_item_path<S: Into<String>>(mut self, item_path: S) -> Self {
287 self.item_path = Some(item_path.into());
288 self
289 }
290
291 /// Set whether to fetch documentation from docs.rs.
292 ///
293 /// # Arguments
294 ///
295 /// * `online` - Whether to fetch documentation from docs.rs
296 ///
297 /// # Examples
298 ///
299 /// ```
300 /// use rustdoc_text::Config;
301 ///
302 /// let config = Config::new("serde").with_online(true);
303 /// assert_eq!(config.online, true);
304 /// ```
305 pub fn with_online(mut self, online: bool) -> Self {
306 self.online = online;
307 self
308 }
309
310 /// Execute the configuration to fetch documentation.
311 ///
312 /// # Returns
313 ///
314 /// The documentation as Markdown text.
315 ///
316 /// # Examples
317 ///
318 /// ```no_run
319 /// use rustdoc_text::Config;
320 ///
321 /// # fn main() -> anyhow::Result<()> {
322 /// let docs = Config::new("serde")
323 /// .with_online(true)
324 /// .with_item_path("Deserializer")
325 /// .execute()?;
326 /// println!("{}", docs);
327 /// # Ok(())
328 /// # }
329 /// ```
330 pub fn execute(&self) -> Result<String> {
331 if self.online {
332 fetch_online_docs(&self.crate_name, self.item_path.as_deref())
333 } else {
334 fetch_local_docs(&self.crate_name, self.item_path.as_deref())
335 }
336 }
337}