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