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(&current_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}