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