Skip to main content

ndg_commonmark/processor/
process.rs

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