cargo_doc_docusaurus/
writer.rs

1//! Markdown file writer.
2
3use crate::converter::MarkdownOutput;
4use anyhow::{Context, Result};
5use std::fs;
6use std::path::Path;
7
8/// Write markdown content to a file in the specified directory.
9pub fn write_markdown(output_dir: &Path, content: &str) -> Result<()> {
10  fs::create_dir_all(output_dir).with_context(|| {
11    format!(
12      "Failed to create output directory: {}",
13      output_dir.display()
14    )
15  })?;
16
17  let output_file = output_dir.join("index.md");
18
19  fs::write(&output_file, content)
20    .with_context(|| format!("Failed to write file: {}", output_file.display()))?;
21
22  Ok(())
23}
24
25/// Write multi-file markdown output to the specified directory.
26pub fn write_markdown_multifile(output_dir: &Path, output: &MarkdownOutput) -> Result<()> {
27  write_markdown_multifile_with_sidebar_path(output_dir, output, None)
28}
29
30/// Write multi-file markdown output with custom sidebar path.
31pub fn write_markdown_multifile_with_sidebar_path(
32  output_dir: &Path,
33  output: &MarkdownOutput,
34  custom_sidebar_path: Option<&Path>,
35) -> Result<()> {
36  fs::create_dir_all(output_dir).with_context(|| {
37    format!(
38      "Failed to create output directory: {}",
39      output_dir.display()
40    )
41  })?;
42
43  for (file_path, content) in &output.files {
44    let full_path = output_dir.join(file_path);
45
46    // Create parent directories if needed
47    if let Some(parent) = full_path.parent() {
48      fs::create_dir_all(parent)
49        .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
50    }
51
52    fs::write(&full_path, content)
53      .with_context(|| format!("Failed to write file: {}", full_path.display()))?;
54  }
55
56  // Write sidebar configuration if present
57  if let Some(sidebar_content) = &output.sidebar {
58    let sidebar_path = if let Some(custom_path) = custom_sidebar_path {
59      custom_path.to_path_buf()
60    } else {
61      // Default behavior: Write to docs parent directory (project root for Docusaurus)
62      // If output_dir is "example-docs/docs/test_crate", parent.parent gives us "example-docs"
63      if let Some(parent) = output_dir.parent() {
64        if let Some(grandparent) = parent.parent() {
65          grandparent.join("sidebars-rust.ts")
66        } else {
67          parent.join("sidebars-rust.ts")
68        }
69      } else {
70        output_dir.join("sidebars-rust.ts")
71      }
72    };
73
74    // Check if sidebar already exists for append mode
75    let final_content = if sidebar_path.exists() {
76      merge_sidebar_content(&sidebar_path, sidebar_content, &output.crate_name)?
77    } else {
78      sidebar_content.clone()
79    };
80
81    // Create parent directories if needed
82    if let Some(parent) = sidebar_path.parent() {
83      fs::create_dir_all(parent)
84        .with_context(|| format!("Failed to create directory: {}", parent.display()))?;
85    }
86
87    fs::write(&sidebar_path, final_content)
88      .with_context(|| format!("Failed to write sidebar file: {}", sidebar_path.display()))?;
89
90    println!(
91      "✓ Generated sidebar configuration: {}",
92      sidebar_path.display()
93    );
94    println!("  Import it in your sidebars.ts file:");
95    println!("  import {{rustApiCategory}} from './sidebars-rust';");
96  }
97
98  Ok(())
99}
100
101/// Merge sidebar content when appending to existing sidebar
102fn merge_sidebar_content(
103  existing_path: &Path,
104  new_content: &str,
105  _crate_name: &str,
106) -> Result<String> {
107  // With the new multiple-sidebar format (rustSidebars object), we need to merge
108  // the new sidebars into the existing object
109  let existing_content = fs::read_to_string(existing_path).with_context(|| {
110    format!(
111      "Failed to read existing sidebar: {}",
112      existing_path.display()
113    )
114  })?;
115
116  // Extract rustSidebars from both files and merge them
117  merge_rust_sidebars(&existing_content, new_content)
118}
119
120/// Merge rustSidebars objects from existing and new content
121fn merge_rust_sidebars(existing: &str, new_content: &str) -> Result<String> {
122  // Find the rustSidebars object in existing content
123  let existing_start = "export const rustSidebars: Record<string, any[]> = {";
124  let existing_end = "};";
125
126  // Extract existing entries
127  let existing_entries = if let Some(start_pos) = existing.find(existing_start) {
128    let start = start_pos + existing_start.len();
129    if let Some(end_pos) = existing[start..].find(existing_end) {
130      &existing[start..start + end_pos]
131    } else {
132      ""
133    }
134  } else {
135    ""
136  };
137
138  // Extract new entries
139  let new_start = "export const rustSidebars: Record<string, any[]> = {";
140  let new_end = "};";
141
142  let new_entries = if let Some(start_pos) = new_content.find(new_start) {
143    let start = start_pos + new_start.len();
144    if let Some(end_pos) = new_content[start..].find(new_end) {
145      &new_content[start..start + end_pos]
146    } else {
147      anyhow::bail!("Could not find rustSidebars object end in new content");
148    }
149  } else {
150    anyhow::bail!("Could not find rustSidebars object in new content");
151  };
152
153  // Extract the header and footer from new_content (everything before and after rustSidebars)
154  let header_end = new_content.find(new_start).unwrap() + new_start.len();
155  let header = &new_content[..header_end];
156
157  // Find where the footer starts (after the rustSidebars closing brace)
158  let footer_start = header_end + new_entries.len() + new_end.len();
159  let footer = &new_content[footer_start..];
160
161  // Merge: combine existing entries with new entries, avoiding duplicates
162  // Parse entries into a map to avoid duplicates
163  let mut entries_map: std::collections::HashMap<String, String> = std::collections::HashMap::new();
164
165  // Helper function to parse sidebar entries
166  let parse_entries = |content: &str| -> Vec<(String, String)> {
167    let mut results = Vec::new();
168    let mut current_key = String::new();
169    let mut current_value = String::new();
170    let mut brace_depth = 0;
171    let mut in_value = false;
172
173    for line in content.lines() {
174      let trimmed = line.trim();
175
176      // Start of a new entry
177      if trimmed.starts_with('\'') && trimmed.contains("': [") {
178        // Save previous entry if exists
179        if !current_key.is_empty() {
180          results.push((current_key.clone(), current_value.clone()));
181        }
182
183        // Extract key
184        if let Some(end_quote) = trimmed[1..].find('\'') {
185          current_key = trimmed[1..=end_quote].to_string();
186          current_value = line.to_string() + "\n";
187          in_value = true;
188          brace_depth = 1; // Count the opening [
189        }
190      } else if in_value {
191        current_value.push_str(line);
192        current_value.push('\n');
193
194        // Count braces to detect end of entry
195        for ch in line.chars() {
196          match ch {
197            '[' | '{' => brace_depth += 1,
198            ']' | '}' => {
199              brace_depth -= 1;
200              if brace_depth == 0 {
201                // Entry complete
202                results.push((current_key.clone(), current_value.clone()));
203                current_key.clear();
204                current_value.clear();
205                in_value = false;
206              }
207            }
208            _ => {}
209          }
210        }
211      }
212    }
213
214    // Save last entry if exists
215    if !current_key.is_empty() {
216      results.push((current_key, current_value));
217    }
218
219    results
220  };
221
222  // Parse existing entries
223  for (key, value) in parse_entries(existing_entries) {
224    entries_map.insert(key, value);
225  }
226
227  // Parse and add/overwrite with new entries
228  for (key, value) in parse_entries(new_entries) {
229    entries_map.insert(key, value); // This will overwrite duplicates
230  }
231
232  // Sort keys and build merged content
233  let mut sorted_keys: Vec<_> = entries_map.keys().collect();
234  sorted_keys.sort();
235
236  let mut merged_entries = String::new();
237  for (i, key) in sorted_keys.iter().enumerate() {
238    if let Some(value) = entries_map.get(*key) {
239      merged_entries.push_str(value);
240      // Add comma between entries (but not after the last one)
241      if i < sorted_keys.len() - 1 && !value.trim_end().ends_with(',') {
242        merged_entries.push(',');
243      }
244    }
245  }
246
247  // Generate rootRustSidebar with all workspace crates
248  let root_sidebar = generate_root_sidebar(&merged_entries);
249
250  // Construct the final output
251  let mut result = String::new();
252  result.push_str(header);
253  result.push_str(&merged_entries);
254  result.push_str(new_end);
255  result.push_str(footer);
256
257  // Append the root sidebar export
258  result.push_str("\n\n// Root sidebar with links to all crates (for main navigation)\n");
259  result.push_str("export const rootRustSidebar = [\n");
260  result.push_str(&root_sidebar);
261  result.push_str("];\n");
262
263  Ok(result)
264}
265
266/// Generate a root sidebar that includes all crates with their content
267/// This function parses the merged sidebar content to extract root crate information
268fn generate_root_sidebar(merged_entries: &str) -> String {
269  let mut output = String::new();
270
271  // Find all lines with rustCrateTitle: true to identify root crates
272  // These lines have the format:
273  // { type: 'doc', id: 'runtime/rust/crate_name/index', label: 'crate_name', customProps: { rustCrateTitle: true, ... } }
274
275  use std::collections::HashSet;
276  let mut seen_crates: HashSet<String> = HashSet::new();
277  let mut crate_entries: Vec<(String, String)> = Vec::new(); // (doc_id, label)
278
279  for line in merged_entries.lines() {
280    let trimmed = line.trim();
281
282    // Look for lines containing rustCrateTitle: true
283    if trimmed.contains("rustCrateTitle: true") && trimmed.contains("type: 'doc'") {
284      // Extract the doc id and label from the line
285      // Format: { type: 'doc', id: 'path/to/crate/index', label: 'crate_name', customProps: ...
286
287      if let Some(id_start) = trimmed.find("id: '") {
288        if let Some(id_end) = trimmed[id_start + 5..].find('\'') {
289          let doc_id = &trimmed[id_start + 5..id_start + 5 + id_end];
290
291          // Extract label
292          if let Some(label_start) = trimmed.find("label: '") {
293            if let Some(label_end) = trimmed[label_start + 8..].find('\'') {
294              let label = &trimmed[label_start + 8..label_start + 8 + label_end];
295
296              // Only add each crate once (in case it appears in multiple sidebars)
297              if seen_crates.insert(doc_id.to_string()) {
298                crate_entries.push((doc_id.to_string(), label.to_string()));
299              }
300            }
301          }
302        }
303      }
304    }
305  }
306
307  // Sort by doc_id for consistent output
308  crate_entries.sort_by(|a, b| a.0.cmp(&b.0));
309
310  for (doc_id, label) in crate_entries {
311    output.push_str(&format!(
312      "  {{ type: 'doc', id: '{}', label: '{}', className: 'rust-mod' }},\n",
313      doc_id, label
314    ));
315  }
316
317  output
318}