use crate::cache::constants::*;
use crate::cache::storage::CacheStorage;
use crate::cache::workspace::WorkspaceHandler;
use crate::rustdoc;
use crate::search::indexer::SearchIndexer;
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct DocGenerator {
storage: CacheStorage,
}
impl DocGenerator {
pub fn new(storage: CacheStorage) -> Self {
Self { storage }
}
fn cleanup_target_directory(&self, source_path: &Path) -> Result<()> {
let target_dir = source_path.join(TARGET_DIR);
if target_dir.exists() {
std::fs::remove_dir_all(&target_dir).with_context(|| {
format!(
"Failed to clean up target directory: {}",
target_dir.display()
)
})?;
tracing::info!("Cleaned up target directory to save disk space");
}
Ok(())
}
pub async fn generate_docs(&self, name: &str, version: &str) -> Result<PathBuf> {
tracing::info!(
"DocGenerator::generate_docs starting for {}-{}",
name,
version
);
let source_path = self.storage.source_path(name, version)?;
let docs_path = self.storage.docs_path(name, version, None)?;
if docs_path.exists() {
tracing::info!(
"Docs already exist for {}-{}, skipping generation",
name,
version
);
return Ok(docs_path);
}
if !source_path.exists() {
bail!(
"Source not found for {}-{}. Download it first.",
name,
version
);
}
tracing::info!("Generating documentation for {}-{}", name, version);
rustdoc::run_cargo_rustdoc_json(&source_path, None).await?;
let doc_dir = source_path.join(TARGET_DIR).join(DOC_DIR);
let json_file = self.find_json_doc(&doc_dir, name)?;
std::fs::copy(&json_file, &docs_path).context("Failed to copy documentation to cache")?;
self.generate_dependencies(name, version).await?;
self.storage.save_metadata(name, version)?;
self.create_search_index(name, version, None)
.await
.context("Failed to create search index")?;
self.cleanup_target_directory(&source_path)?;
tracing::info!(
"Successfully generated documentation for {}-{}",
name,
version
);
tracing::info!(
"DocGenerator::generate_docs completed for {}-{}",
name,
version
);
Ok(docs_path)
}
pub async fn generate_workspace_member_docs(
&self,
name: &str,
version: &str,
member_path: &str,
) -> Result<PathBuf> {
let source_path = self.storage.source_path(name, version)?;
let member_full_path = source_path.join(member_path);
if !source_path.exists() {
bail!(
"Source not found for {}-{}. Download it first.",
name,
version
);
}
if !member_full_path.exists() {
bail!(
"Workspace member not found at path: {}",
member_full_path.display()
);
}
let member_cargo_toml = member_full_path.join(CARGO_TOML);
let package_name = WorkspaceHandler::get_package_name(&member_cargo_toml)?;
let docs_path = self.storage.docs_path(name, version, Some(member_path))?;
tracing::info!(
"Generating documentation for workspace member {} (package: {}) in {}-{}",
member_path,
package_name,
name,
version
);
rustdoc::run_cargo_rustdoc_json(&source_path, Some(&package_name)).await?;
let doc_dir = source_path.join(TARGET_DIR).join(DOC_DIR);
let json_file = self.find_json_doc(&doc_dir, &package_name)?;
if let Some(parent) = docs_path.parent() {
self.storage.ensure_dir(parent)?;
} else {
bail!(
"Invalid docs path: no parent directory for {}",
docs_path.display()
);
}
std::fs::copy(&json_file, &docs_path)
.context("Failed to copy workspace member documentation to cache")?;
self.generate_workspace_member_dependencies(name, version, member_path)
.await?;
self.create_search_index(name, version, Some(member_path))
.await
.context("Failed to create search index for workspace member")?;
self.cleanup_target_directory(&source_path)?;
tracing::info!(
"Successfully generated documentation for workspace member {} in {}-{}",
member_path,
name,
version
);
Ok(docs_path)
}
fn find_json_doc(&self, doc_dir: &Path, crate_name: &str) -> Result<PathBuf> {
let json_name = crate_name.replace('-', "_");
let json_file = doc_dir.join(format!("{json_name}.json"));
if json_file.exists() {
return Ok(json_file);
}
let entries = std::fs::read_dir(doc_dir)
.with_context(|| format!("Failed to read doc directory: {}", doc_dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
return Ok(path);
}
}
bail!(
"No JSON documentation file found for crate '{}' in {}",
crate_name,
doc_dir.display()
);
}
async fn generate_dependencies(&self, name: &str, version: &str) -> Result<()> {
let source_path = self.storage.source_path(name, version)?;
let deps_path = self.storage.dependencies_path(name, version, None)?;
tracing::info!("Generating dependency information for {}-{}", name, version);
let output = Command::new("cargo")
.args(["metadata", "--format-version", "1"])
.current_dir(&source_path)
.output()
.context("Failed to run cargo metadata")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to generate dependency metadata: {}", stderr);
}
tokio::fs::write(&deps_path, &output.stdout)
.await
.context("Failed to write dependencies to cache")?;
Ok(())
}
async fn generate_workspace_member_dependencies(
&self,
name: &str,
version: &str,
member_path: &str,
) -> Result<()> {
let source_path = self.storage.source_path(name, version)?;
let deps_path = self
.storage
.member_path(name, version, member_path)?
.join(DEPENDENCIES_FILE);
tracing::info!(
"Generating dependency information for workspace member {} in {}-{}",
member_path,
name,
version
);
let member_cargo_toml = source_path.join(member_path).join(CARGO_TOML);
let output = Command::new("cargo")
.args([
"metadata",
"--format-version",
"1",
"--manifest-path",
&member_cargo_toml.to_string_lossy(),
])
.output()
.context("Failed to run cargo metadata")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to generate dependency metadata: {}", stderr);
}
if let Some(parent) = deps_path.parent() {
self.storage.ensure_dir(parent)?;
} else {
bail!(
"Invalid deps path: no parent directory for {}",
deps_path.display()
);
}
tokio::fs::write(&deps_path, &output.stdout)
.await
.context("Failed to write dependencies to cache")?;
Ok(())
}
pub async fn load_dependencies(&self, name: &str, version: &str) -> Result<serde_json::Value> {
let deps_path = self.storage.dependencies_path(name, version, None)?;
if !deps_path.exists() {
bail!("Dependencies not found for {}-{}", name, version);
}
let json_string = tokio::fs::read_to_string(&deps_path)
.await
.context("Failed to read dependencies file")?;
let deps: serde_json::Value =
serde_json::from_str(&json_string).context("Failed to parse dependencies JSON")?;
Ok(deps)
}
pub async fn load_docs(
&self,
name: &str,
version: &str,
member_name: Option<&str>,
) -> Result<serde_json::Value> {
let docs_path = self.storage.docs_path(name, version, member_name)?;
if !docs_path.exists() {
if let Some(member) = member_name {
bail!(
"Documentation not found for workspace member {} in {}-{}",
member,
name,
version
);
} else {
bail!("Documentation not found for {}-{}", name, version);
}
}
let json_string = tokio::fs::read_to_string(&docs_path)
.await
.context("Failed to read documentation file")?;
let docs: serde_json::Value =
serde_json::from_str(&json_string).context("Failed to parse documentation JSON")?;
Ok(docs)
}
pub async fn create_search_index(
&self,
name: &str,
version: &str,
member_name: Option<&str>,
) -> Result<()> {
let log_prefix = if let Some(member) = member_name {
format!("workspace member {member} in")
} else {
String::new()
};
tracing::info!(
"Creating search index for {}{}-{}",
log_prefix,
name,
version
);
let docs_path = self.storage.docs_path(name, version, member_name)?;
let docs_json = tokio::fs::read_to_string(&docs_path)
.await
.context("Failed to read documentation for indexing")?;
let crate_data: rustdoc_types::Crate = serde_json::from_str(&docs_json)
.context("Failed to parse documentation JSON for indexing")?;
let mut indexer = SearchIndexer::new_for_crate(name, version, &self.storage, member_name)?;
indexer.add_crate_items(name, version, &crate_data)?;
tracing::info!(
"Successfully created search index for {}{}-{}",
log_prefix,
name,
version
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_docgen_creation() {
let temp_dir = TempDir::new().unwrap();
let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap();
let docgen = DocGenerator::new(storage);
assert!(format!("{docgen:?}").contains("DocGenerator"));
}
#[test]
fn test_find_json_doc_not_found() {
let temp_dir = TempDir::new().unwrap();
let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap();
let docgen = DocGenerator::new(storage);
let doc_dir = temp_dir.path().join(DOC_DIR);
fs::create_dir_all(&doc_dir).unwrap();
let result = docgen.find_json_doc(&doc_dir, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_find_json_doc_found() {
let temp_dir = TempDir::new().unwrap();
let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap();
let docgen = DocGenerator::new(storage);
let doc_dir = temp_dir.path().join(DOC_DIR);
fs::create_dir_all(&doc_dir).unwrap();
let json_file = doc_dir.join("test_crate.json");
fs::write(&json_file, "{}").unwrap();
let result = docgen.find_json_doc(&doc_dir, "test_crate").unwrap();
assert_eq!(result, json_file);
}
#[test]
fn test_find_json_doc_with_underscore_conversion() {
let temp_dir = TempDir::new().unwrap();
let storage = CacheStorage::new(Some(temp_dir.path().to_path_buf())).unwrap();
let docgen = DocGenerator::new(storage);
let doc_dir = temp_dir.path().join(DOC_DIR);
fs::create_dir_all(&doc_dir).unwrap();
let json_file = doc_dir.join("test_crate.json");
fs::write(&json_file, "{}").unwrap();
let result = docgen.find_json_doc(&doc_dir, "test-crate").unwrap();
assert_eq!(result, json_file);
}
}