ndg_commonmark/processor/
process.rs

1//! Main processing functions for Markdown content.
2use log::error;
3
4use super::types::{MarkdownOptions, MarkdownProcessor, TabStyle};
5use crate::types::MarkdownResult;
6
7/// Process markdown content with error recovery.
8///
9/// Attempts to process the markdown content and falls back to
10/// safe alternatives if processing fails at any stage.
11///
12/// # Arguments
13///
14/// * `processor` - The configured markdown processor
15/// * `content` - The raw markdown content to process
16///
17/// # Returns
18///
19/// A `MarkdownResult` with processed HTML, headers, and title
20#[must_use]
21pub fn process_with_recovery(
22  processor: &MarkdownProcessor,
23  content: &str,
24) -> MarkdownResult {
25  match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
26    processor.render(content)
27  })) {
28    Ok(result) => result,
29    Err(panic_err) => {
30      error!("Panic during markdown processing: {panic_err:?}");
31      MarkdownResult {
32        html: "<div class=\"error\">Critical error processing markdown \
33               content</div>"
34          .to_string(),
35
36        headers:        Vec::new(),
37        title:          None,
38        included_files: Vec::new(),
39      }
40    },
41  }
42}
43
44/// Safely process markup content with error recovery.
45///
46/// Provides a safe wrapper around markup processing operations
47/// that may fail, and ensures that partial or fallback content is returned
48/// rather than complete failure.
49///
50/// # Arguments
51///
52/// * `content` - The content to process
53/// * `processor_fn` - The processing function to apply
54/// * `fallback` - Fallback content to use if processing fails
55///
56/// # Returns
57///
58/// The processed content or fallback on error
59pub fn process_safe<F>(content: &str, processor_fn: F, fallback: &str) -> String
60where
61  F: FnOnce(&str) -> String,
62{
63  // Avoid processing empty strings
64  if content.is_empty() {
65    return String::new();
66  }
67
68  // Catch any potential panics caused by malformed input or processing errors
69  let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
70    processor_fn(content)
71  }));
72
73  match result {
74    Ok(processed_text) => processed_text,
75    Err(e) => {
76      // Log the error but allow the program to continue
77      if let Some(error_msg) = e.downcast_ref::<String>() {
78        log::error!("Error processing markup: {error_msg}");
79      } else if let Some(error_msg) = e.downcast_ref::<&str>() {
80        log::error!("Error processing markup: {error_msg}");
81      } else {
82        log::error!("Unknown error occurred while processing markup");
83      }
84
85      // Return the original text or default value to prevent breaking the
86      // entire document
87      if fallback.is_empty() {
88        content.to_string()
89      } else {
90        fallback.to_string()
91      }
92    },
93  }
94}
95
96/// Process a batch of markdown files with consistent error handling.
97///
98/// This function processes multiple markdown files using the same processor
99/// configuration, collecting results and handling errors gracefully.
100///
101/// # Arguments
102/// * `processor` - The configured markdown processor
103/// * `files` - Iterator of file paths to process
104/// * `read_file_fn` - Function to read file content from path
105///
106/// # Returns
107/// Vector of tuples containing (`file_path`, `processing_result`)
108pub fn process_batch<I, F>(
109  processor: &MarkdownProcessor,
110  files: I,
111  read_file_fn: F,
112) -> Vec<(String, Result<MarkdownResult, String>)>
113where
114  I: Iterator<Item = std::path::PathBuf>,
115  F: Fn(&std::path::Path) -> Result<String, std::io::Error>,
116{
117  files
118    .map(|path| {
119      let path_str = path.display().to_string();
120      let result = match read_file_fn(&path) {
121        Ok(content) => Ok(process_with_recovery(processor, &content)),
122        Err(e) => Err(format!("Failed to read file: {e}")),
123      };
124      (path_str, result)
125    })
126    .collect()
127}
128
129/// Create a processor with sensible defaults for library usage.
130///
131/// Provides a convenient way to create a processor with
132/// commonly used settings for different use cases.
133///
134/// # Arguments
135///
136/// * `preset` - The preset configuration to use
137///
138/// # Returns
139///
140/// A configured `MarkdownProcessor`
141#[must_use]
142pub fn create_processor(preset: ProcessorPreset) -> MarkdownProcessor {
143  let options = match preset {
144    ProcessorPreset::Basic => {
145      MarkdownOptions {
146        gfm:               true,
147        nixpkgs:           false,
148        highlight_code:    true,
149        highlight_theme:   None,
150        manpage_urls_path: None,
151        auto_link_options: true,
152        tab_style:         TabStyle::None,
153        valid_options:     None,
154      }
155    },
156    ProcessorPreset::Ndg => {
157      MarkdownOptions {
158        gfm:               true,
159        nixpkgs:           false,
160        highlight_code:    true,
161        highlight_theme:   Some("github".to_string()),
162        manpage_urls_path: None,
163        auto_link_options: true,
164        tab_style:         TabStyle::None,
165        valid_options:     None,
166      }
167    },
168    ProcessorPreset::Nixpkgs => {
169      MarkdownOptions {
170        gfm:               true,
171        nixpkgs:           true,
172        highlight_code:    true,
173        highlight_theme:   Some("github".to_string()),
174        manpage_urls_path: None,
175        auto_link_options: true,
176        tab_style:         TabStyle::None,
177        valid_options:     None,
178      }
179    },
180  };
181
182  MarkdownProcessor::new(options)
183}
184
185/// Preset configurations for common use cases. In some cases those presets will
186/// require certain feature flags to be enabled.
187#[derive(Debug, Clone, Copy)]
188pub enum ProcessorPreset {
189  /// Markdown processing with only Github Flavored Markdown (GFM) support
190  Basic,
191  /// Markdown processing with only Nixpkgs-flavored `CommonMark` support
192  Nixpkgs,
193  /// Enhanced Markdown processing with support for GFM and Nixpkgs-flavored
194  /// `CommonMark` support
195  Ndg,
196}
197
198/// Process markdown content from a string with error recovery.
199///
200/// This is a convenience function that combines processor creation and
201/// content processing in a single call.
202///
203/// # Arguments
204/// * `content` - The markdown content to process
205/// * `preset` - The processor preset to use
206///
207/// # Returns
208/// A `MarkdownResult` with processed content
209#[must_use]
210pub fn process_markdown_string(
211  content: &str,
212  preset: ProcessorPreset,
213) -> MarkdownResult {
214  let processor = create_processor(preset);
215  process_with_recovery(&processor, content)
216}
217
218/// Process markdown content from a file with error recovery.
219///
220/// This function reads a markdown file and processes it with the specified
221/// configuration, handling both file I/O and processing errors.
222///
223/// # Arguments
224/// * `file_path` - Path to the markdown file
225/// * `preset` - The processor preset to use
226///
227/// # Returns
228/// A `Result` containing the `MarkdownResult` or an error message
229///
230/// # Errors
231///
232/// Returns an error if the file cannot be read.
233pub fn process_markdown_file(
234  file_path: &std::path::Path,
235  preset: ProcessorPreset,
236) -> Result<MarkdownResult, String> {
237  let content = std::fs::read_to_string(file_path).map_err(|e| {
238    format!("Failed to read file {}: {}", file_path.display(), e)
239  })?;
240
241  let base_dir = file_path
242    .parent()
243    .unwrap_or_else(|| std::path::Path::new("."));
244  let processor = create_processor(preset).with_base_dir(base_dir);
245  Ok(process_with_recovery(&processor, &content))
246}
247
248/// Process markdown content from a file with custom base directory.
249///
250/// This function reads a markdown file and processes it with the specified
251/// configuration and base directory for resolving includes.
252///
253/// # Arguments
254///
255/// * `file_path` - Path to the markdown file
256/// * `base_dir` - Base directory for resolving relative includes
257/// * `preset` - The processor preset to use
258///
259/// # Returns
260///
261/// A `Result` containing the `MarkdownResult` or an error message
262///
263/// # Errors
264///
265/// Returns an error if the file cannot be read.
266pub fn process_markdown_file_with_basedir(
267  file_path: &std::path::Path,
268  base_dir: &std::path::Path,
269  preset: ProcessorPreset,
270) -> Result<MarkdownResult, String> {
271  let content = std::fs::read_to_string(file_path).map_err(|e| {
272    format!("Failed to read file {}: {}", file_path.display(), e)
273  })?;
274
275  let processor = create_processor(preset).with_base_dir(base_dir);
276  Ok(process_with_recovery(&processor, &content))
277}
278
279#[cfg(test)]
280mod tests {
281  use std::path::Path;
282
283  use super::*;
284
285  #[test]
286  fn test_safely_process_markup_success() {
287    let content = "test content";
288    let result =
289      process_safe(content, |s| format!("processed: {}", s), "fallback");
290    assert_eq!(result, "processed: test content");
291  }
292
293  #[test]
294  #[allow(clippy::panic)]
295  fn test_safely_process_markup_fallback() {
296    let content = "test content";
297    let result = process_safe(content, |_| panic!("test panic"), "fallback");
298    assert_eq!(result, "fallback");
299  }
300
301  #[test]
302  fn test_process_markdown_string() {
303    let content = "# Test Header\n\nSome content.";
304    let result = process_markdown_string(content, ProcessorPreset::Basic);
305
306    assert!(result.html.contains("<h1"));
307    assert!(result.html.contains("Test Header"));
308    assert_eq!(result.title, Some("Test Header".to_string()));
309    assert_eq!(result.headers.len(), 1);
310  }
311
312  #[test]
313  fn test_create_processor_presets() {
314    let basic = create_processor(ProcessorPreset::Basic);
315    assert!(basic.options.gfm);
316    assert!(!basic.options.nixpkgs);
317    assert!(basic.options.highlight_code);
318
319    let enhanced = create_processor(ProcessorPreset::Ndg);
320    assert!(enhanced.options.gfm);
321    assert!(!enhanced.options.nixpkgs);
322    assert!(enhanced.options.highlight_code);
323
324    let nixpkgs = create_processor(ProcessorPreset::Nixpkgs);
325    assert!(nixpkgs.options.gfm);
326    assert!(nixpkgs.options.nixpkgs);
327    assert!(nixpkgs.options.highlight_code);
328  }
329
330  #[test]
331  fn test_process_batch() {
332    let processor = create_processor(ProcessorPreset::Basic);
333    let paths = vec![Path::new("test1.md"), Path::new("test2.md")];
334
335    let read_fn = |path: &Path| -> Result<String, std::io::Error> {
336      match path.file_name().and_then(|n| n.to_str()) {
337        Some("test1.md") => Ok("# Test 1".to_string()),
338        Some("test2.md") => Ok("# Test 2".to_string()),
339        _ => {
340          Err(std::io::Error::new(
341            std::io::ErrorKind::NotFound,
342            "File not found",
343          ))
344        },
345      }
346    };
347
348    let results = process_batch(
349      &processor,
350      paths.into_iter().map(|p| p.to_path_buf()),
351      read_fn,
352    );
353    assert_eq!(results.len(), 2);
354
355    for (path, result) in results {
356      match result {
357        Ok(markdown_result) => {
358          assert!(markdown_result.html.contains("<h1"));
359          if path.contains("test1") {
360            assert!(markdown_result.html.contains("Test 1"));
361          } else {
362            assert!(markdown_result.html.contains("Test 2"));
363          }
364        },
365        Err(e) => assert!(false, "Unexpected error for path {}: {}", path, e),
366      }
367    }
368  }
369}