rust_docs_mcp/
rustdoc.rs

1//! Unified rustdoc JSON generation functionality
2//!
3//! Provides consistent rustdoc JSON generation across the application,
4//! including toolchain validation and command execution.
5
6use anyhow::{Context, Result, bail};
7use std::path::Path;
8use std::process::Command;
9
10/// The pinned nightly toolchain version compatible with rustdoc-types 0.53.0
11pub const REQUIRED_TOOLCHAIN: &str = "nightly-2025-06-23";
12
13/// Check if the required nightly toolchain is available
14pub async fn validate_toolchain() -> Result<()> {
15    let output = Command::new("rustup")
16        .args(["toolchain", "list"])
17        .output()
18        .context("Failed to run rustup toolchain list")?;
19
20    if !output.status.success() {
21        bail!("Failed to check available toolchains");
22    }
23
24    let toolchains = String::from_utf8_lossy(&output.stdout);
25    if !toolchains.contains(REQUIRED_TOOLCHAIN) {
26        bail!(
27            "Required toolchain {} is not installed. Please run: rustup toolchain install {}",
28            REQUIRED_TOOLCHAIN,
29            REQUIRED_TOOLCHAIN
30        );
31    }
32
33    tracing::debug!("Validated toolchain {} is available", REQUIRED_TOOLCHAIN);
34    Ok(())
35}
36
37/// Test rustdoc JSON functionality with a simple test file
38pub async fn test_rustdoc_json() -> Result<()> {
39    // First validate the toolchain
40    validate_toolchain().await?;
41
42    // Create a temporary directory and test file
43    let temp_dir =
44        tempfile::tempdir().context("Failed to create temporary directory for testing")?;
45
46    let test_file = temp_dir.path().join("lib.rs");
47    std::fs::write(&test_file, "//! Test crate\npub fn test() {}")
48        .context("Failed to create test file")?;
49
50    let test_file_str = test_file
51        .to_str()
52        .ok_or_else(|| anyhow::anyhow!("Test file path contains invalid UTF-8"))?;
53
54    tracing::debug!(
55        "Testing rustdoc JSON generation with {}",
56        REQUIRED_TOOLCHAIN
57    );
58
59    // Try to generate JSON documentation using the pinned toolchain
60    let output = Command::new("rustdoc")
61        .args([
62            &format!("+{REQUIRED_TOOLCHAIN}"),
63            "-Z",
64            "unstable-options",
65            "--output-format",
66            "json",
67            "--crate-name",
68            "test",
69            test_file_str,
70        ])
71        .output()
72        .context("Failed to run rustdoc")?;
73
74    if !output.status.success() {
75        let stderr = String::from_utf8_lossy(&output.stderr);
76        bail!("JSON generation failed: {}", stderr);
77    }
78
79    tracing::debug!("Successfully tested rustdoc JSON generation");
80    Ok(())
81}
82
83/// Get rustdoc version information
84pub async fn get_rustdoc_version() -> Result<String> {
85    let output = Command::new("rustdoc")
86        .arg("--version")
87        .output()
88        .context("Failed to run rustdoc --version")?;
89
90    if !output.status.success() {
91        bail!("rustdoc command failed");
92    }
93
94    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
95}
96
97/// Run cargo rustdoc with JSON output for a crate or specific package
98pub async fn run_cargo_rustdoc_json(source_path: &Path, package: Option<&str>) -> Result<()> {
99    validate_toolchain().await?;
100
101    let log_msg = match package {
102        Some(pkg) => format!(
103            "Running cargo rustdoc with JSON output for package {} in {}",
104            pkg,
105            source_path.display()
106        ),
107        None => format!(
108            "Running cargo rustdoc with JSON output in {}",
109            source_path.display()
110        ),
111    };
112    tracing::debug!("{}", log_msg);
113
114    let mut base_args = vec![format!("+{}", REQUIRED_TOOLCHAIN), "rustdoc".to_string()];
115
116    // Add package-specific arguments if provided
117    if let Some(pkg) = package {
118        base_args.push("-p".to_string());
119        base_args.push(pkg.to_string());
120    }
121
122    // Add remaining arguments
123    let rustdoc_args = vec![
124        "--all-features".to_string(),
125        "--".to_string(),
126        "--output-format".to_string(),
127        "json".to_string(),
128        "-Z".to_string(),
129        "unstable-options".to_string(),
130    ];
131
132    // First try without --lib to support crates that have a single target
133    let mut args = base_args.clone();
134    args.extend_from_slice(&rustdoc_args);
135
136    let output = Command::new("cargo")
137        .args(&args)
138        .current_dir(source_path)
139        .output()
140        .context("Failed to run cargo rustdoc")?;
141
142    if !output.status.success() {
143        let stderr = String::from_utf8_lossy(&output.stderr);
144
145        // If we get the multiple targets error, try again with --lib
146        if stderr.contains("extra arguments to `rustdoc` can only be passed to one target") {
147            tracing::debug!("Multiple targets detected, retrying with --lib flag");
148
149            // Try again with --lib flag
150            let mut args_with_lib = base_args;
151            args_with_lib.push("--lib".to_string());
152            args_with_lib.extend_from_slice(&rustdoc_args);
153
154            let output_with_lib = Command::new("cargo")
155                .args(&args_with_lib)
156                .current_dir(source_path)
157                .output()
158                .context("Failed to run cargo rustdoc with --lib")?;
159
160            if !output_with_lib.status.success() {
161                let stderr_with_lib = String::from_utf8_lossy(&output_with_lib.stderr);
162
163                if stderr_with_lib.contains("no library targets found") {
164                    bail!("This is a binary-only package");
165                }
166
167                bail!("Failed to generate documentation: {}", stderr_with_lib);
168            }
169
170            // Success with --lib
171            return Ok(());
172        }
173
174        // Check for workspace error
175        if stderr.contains("could not find `Cargo.toml` in") || stderr.contains("workspace") {
176            bail!(
177                "This appears to be a workspace. Please use workspace member caching instead of trying to cache the root workspace."
178            );
179        }
180
181        bail!("Failed to generate documentation: {}", stderr);
182    }
183
184    Ok(())
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[tokio::test]
192    async fn test_get_rustdoc_version() {
193        // This test will pass if rustdoc is installed
194        let result = get_rustdoc_version().await;
195        // We can't guarantee the success state in all environments
196        // but we can verify it returns a valid result
197        assert!(result.is_ok() || result.is_err());
198    }
199
200    #[tokio::test]
201    async fn test_validate_toolchain() {
202        // This test will pass if rustup is installed
203        let result = validate_toolchain().await;
204        // We can't guarantee the toolchain is installed in all environments
205        // but we can verify it returns a valid result
206        assert!(result.is_ok() || result.is_err());
207    }
208}