Skip to main content

ox_content_docs/
generator.rs

1//! Documentation site generator.
2
3use std::path::Path;
4
5use crate::config::DocsConfig;
6use crate::extractor::{DocExtractor, DocItem, ExtractResult};
7
8use thiserror::Error;
9
10/// Result type for generation operations.
11pub type GenerateResult<T> = Result<T, GenerateError>;
12
13/// Errors during documentation generation.
14#[derive(Debug, Error)]
15pub enum GenerateError {
16    /// IO error.
17    #[error("IO error: {0}")]
18    Io(#[from] std::io::Error),
19
20    /// Extraction error.
21    #[error("Extraction error: {0}")]
22    Extract(#[from] crate::extractor::ExtractError),
23
24    /// Template error.
25    #[error("Template error: {0}")]
26    Template(String),
27}
28
29/// Documentation generator.
30pub struct DocsGenerator {
31    config: DocsConfig,
32    extractor: DocExtractor,
33}
34
35impl DocsGenerator {
36    /// Creates a new documentation generator.
37    #[must_use]
38    pub fn new(config: DocsConfig) -> Self {
39        let extractor = DocExtractor::with_private(config.document_private);
40        Self { config, extractor }
41    }
42
43    /// Returns the configuration.
44    #[must_use]
45    pub fn config(&self) -> &DocsConfig {
46        &self.config
47    }
48
49    /// Generates documentation for all source files.
50    pub fn generate(&self) -> GenerateResult<()> {
51        let items = self.extract_all()?;
52        self.render(&items)?;
53        Ok(())
54    }
55
56    /// Extracts documentation from all source files.
57    pub fn extract_all(&self) -> ExtractResult<Vec<DocItem>> {
58        let mut all_items = Vec::new();
59
60        for src_dir in &self.config.src_dirs {
61            let items = self.extract_dir(Path::new(src_dir))?;
62            all_items.extend(items);
63        }
64
65        Ok(all_items)
66    }
67
68    /// Extracts documentation from a directory.
69    fn extract_dir(&self, dir: &Path) -> ExtractResult<Vec<DocItem>> {
70        let mut items = Vec::new();
71
72        if !dir.is_dir() {
73            return Ok(items);
74        }
75
76        for entry in std::fs::read_dir(dir)? {
77            let entry = entry?;
78            let path = entry.path();
79
80            if path.is_dir() {
81                items.extend(self.extract_dir(&path)?);
82            } else if self.should_include(&path) {
83                if let Ok(file_items) = self.extractor.extract_file(&path) {
84                    items.extend(file_items);
85                }
86            }
87        }
88
89        Ok(items)
90    }
91
92    /// Checks if a file should be included.
93    fn should_include(&self, path: &Path) -> bool {
94        let path_str = path.to_string_lossy();
95
96        // Check excludes first
97        for pattern in &self.config.exclude {
98            if glob_match(pattern, &path_str) {
99                return false;
100            }
101        }
102
103        // Check includes
104        for pattern in &self.config.include {
105            if glob_match(pattern, &path_str) {
106                return true;
107            }
108        }
109
110        false
111    }
112
113    /// Renders documentation items to HTML.
114    fn render(&self, items: &[DocItem]) -> GenerateResult<()> {
115        let out_dir = Path::new(&self.config.out_dir);
116        std::fs::create_dir_all(out_dir)?;
117
118        if self.config.json {
119            let json = serde_json::to_string_pretty(items)
120                .map_err(|e| GenerateError::Template(e.to_string()))?;
121            std::fs::write(out_dir.join("docs.json"), json)?;
122        }
123
124        // TODO: Generate HTML pages
125        // For now, just create the output directory
126
127        Ok(())
128    }
129}
130
131/// Simple glob matching (** and * patterns).
132fn glob_match(pattern: &str, path: &str) -> bool {
133    // Very simplified glob matching
134    // TODO: Use a proper glob library
135    if pattern.contains("**") {
136        let parts: Vec<&str> = pattern.split("**").collect();
137        if parts.len() == 2 {
138            let prefix = parts[0].trim_end_matches('/');
139            let suffix = parts[1].trim_start_matches('/');
140
141            // For **, we just check the suffix pattern
142            if !suffix.is_empty() {
143                // Handle *.ext suffix
144                if let Some(ext) = suffix.strip_prefix('*') {
145                    return path.ends_with(ext);
146                }
147                return path.ends_with(suffix);
148            }
149            if !prefix.is_empty() && !path.starts_with(prefix) {
150                return false;
151            }
152            return true;
153        }
154    }
155
156    if pattern.contains('*') {
157        let parts: Vec<&str> = pattern.split('*').collect();
158        if parts.len() == 2 {
159            return path.starts_with(parts[0]) && path.ends_with(parts[1]);
160        }
161    }
162
163    pattern == path
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_glob_match() {
172        // ** with *.ext suffix (matches any path ending with .ts)
173        assert!(glob_match("**/*.ts", "src/foo/bar.ts"));
174        assert!(glob_match("**/*.ts", "bar.ts"));
175        assert!(!glob_match("**/*.ts", "bar.js"));
176        // Single * pattern (prefix + suffix matching)
177        assert!(glob_match("*.ts", "foo.ts"));
178        // Note: our simple glob treats *.ts as "starts with '' and ends with .ts"
179        // so src/foo.ts also matches (this is a limitation of the simple implementation)
180        assert!(glob_match("*.ts", "src/foo.ts"));
181        // Exact match
182        assert!(glob_match("foo.ts", "foo.ts"));
183        assert!(!glob_match("foo.ts", "bar.ts"));
184    }
185}