anya_core/tools/
markdown.rs

1// [AIR-3][AIS-3][BPC-3][AIT-3] Markdown Documentation Validation Module
2// AI-Readable: Enhanced with standardized markdown processing capabilities
3// AI-Secure: Validates document structure and prevents malformed content
4// Bitcoin-Protocol-Compliant: Ensures documentation meets BDF v2.5 standards
5// AI-Testable: Comprehensive test coverage for document validation
6
7use regex::Regex;
8use std::fs::File;
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12use walkdir::WalkDir;
13
14/// Required compliance labels for documentation
15const REQUIRED_LABELS: [&str; 3] = ["AIS-3", "BPC-3", "DAO-4"];
16const MAX_LINE_LENGTH: usize = 100;
17
18#[derive(Debug, Error)]
19pub enum DocError {
20    #[error("IO error: {0}")]
21    IoError(#[from] io::Error),
22
23    #[error("Regex error: {0}")]
24    RegexError(#[from] regex::Error),
25
26    #[error("Missing compliance labels: {0}")]
27    MissingLabels(String),
28
29    #[error("Style violation: {0}")]
30    StyleViolation(String),
31}
32
33/// Represents a markdown document with validation capabilities
34pub struct MarkdownDocument {
35    path: PathBuf,
36    content: String,
37}
38
39impl MarkdownDocument {
40    /// Load a markdown document from a file
41    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, DocError> {
42        let path = path.as_ref().to_path_buf();
43        let mut file = File::open(&path)?;
44        let mut content = String::new();
45        file.read_to_string(&mut content)?;
46
47        Ok(Self { path, content })
48    }
49
50    /// Check if document has all required compliance labels
51    pub fn has_compliance_labels(&self) -> bool {
52        for label in REQUIRED_LABELS {
53            if !self.content.contains(&format!("[{label}]")) {
54                return false;
55            }
56        }
57        true
58    }
59
60    /// Get missing compliance labels
61    pub fn missing_labels(&self) -> Vec<String> {
62        REQUIRED_LABELS
63            .iter()
64            .filter(|&&label| !self.content.contains(&format!("[{label}]")))
65            .map(|&label| label.to_string())
66            .collect()
67    }
68
69    /// Fix compliance labels by adding missing ones
70    pub fn fix_compliance_labels(&mut self) -> Result<bool, DocError> {
71        let missing = self.missing_labels();
72        if missing.is_empty() {
73            return Ok(false);
74        }
75
76        let labels_to_add = missing
77            .iter()
78            .map(|label| format!("[{label}]"))
79            .collect::<Vec<_>>()
80            .join("");
81
82        let lines: Vec<&str> = self.content.lines().collect();
83        if !lines.is_empty() && lines[0].starts_with("# ") {
84            // Add to heading
85            let new_heading = format!("{} {}", lines[0], labels_to_add);
86            let mut new_content = new_heading;
87            for line in &lines[1..] {
88                new_content.push('\n');
89                new_content.push_str(line);
90            }
91            self.content = new_content;
92        } else {
93            // Add to top of file
94            self.content = format!("{}\n\n{}", labels_to_add, self.content);
95        }
96
97        Ok(true)
98    }
99
100    /// Check for trailing whitespace
101    pub fn has_trailing_whitespace(&self) -> bool {
102        match Regex::new(r"[ \t]+$") {
103            Ok(re) => {
104                for line in self.content.lines() {
105                    if re.is_match(line) {
106                        return true;
107                    }
108                }
109                false
110            }
111            Err(_) => false, // If regex compilation fails, assume no whitespace
112        }
113    }
114
115    /// Fix trailing whitespace
116    pub fn fix_trailing_whitespace(&mut self) -> Result<bool, DocError> {
117        let re = Regex::new(r"[ \t]+$")?;
118        let original = self.content.clone();
119        self.content = re.replace_all(&self.content, "").to_string();
120
121        Ok(self.content != original)
122    }
123
124    /// Check for long lines
125    pub fn has_long_lines(&self) -> bool {
126        for line in self.content.lines() {
127            // Skip headings, code blocks, tables, and links
128            if line.starts_with('#')
129                || line.starts_with("```")
130                || line.starts_with('|')
131                || line.starts_with('[')
132                || line.trim().is_empty()
133            {
134                continue;
135            }
136
137            if line.len() > MAX_LINE_LENGTH {
138                return true;
139            }
140        }
141        false
142    }
143
144    /// Fix long lines by wrapping them
145    pub fn fix_long_lines(&mut self) -> Result<bool, DocError> {
146        let lines: Vec<&str> = self.content.lines().collect();
147        let mut new_lines = Vec::new();
148        let mut changed = false;
149
150        for line in lines {
151            // Skip headings, code blocks, tables, and links
152            if line.starts_with('#')
153                || line.starts_with("```")
154                || line.starts_with('|')
155                || line.starts_with('[')
156                || line.trim().is_empty()
157            {
158                new_lines.push(line.to_string());
159                continue;
160            }
161
162            if line.len() <= MAX_LINE_LENGTH {
163                new_lines.push(line.to_string());
164                continue;
165            }
166
167            // Wrap the line
168            changed = true;
169            let words: Vec<&str> = line.split_whitespace().collect();
170            let mut current_line = String::new();
171
172            for word in words {
173                if current_line.is_empty() {
174                    current_line.push_str(word);
175                } else if current_line.len() + word.len() < MAX_LINE_LENGTH {
176                    current_line.push(' ');
177                    current_line.push_str(word);
178                } else {
179                    new_lines.push(current_line);
180                    current_line = word.to_string();
181                }
182            }
183
184            if !current_line.is_empty() {
185                new_lines.push(current_line);
186            }
187        }
188
189        if changed {
190            self.content = new_lines.join("\n");
191        }
192
193        Ok(changed)
194    }
195
196    /// Save changes back to file
197    pub fn save(&self) -> Result<(), DocError> {
198        let mut file = File::create(&self.path)?;
199        file.write_all(self.content.as_bytes())?;
200        Ok(())
201    }
202
203    /// Fix all style issues
204    pub fn fix_all(&mut self) -> Result<bool, DocError> {
205        let mut changed = false;
206        changed |= self.fix_compliance_labels()?;
207        changed |= self.fix_trailing_whitespace()?;
208        changed |= self.fix_long_lines()?;
209        Ok(changed)
210    }
211}
212
213/// Documentation validator
214pub struct DocumentationValidator {
215    root_dir: PathBuf,
216}
217
218impl DocumentationValidator {
219    /// Create a new validator for the given directory
220    pub fn new<P: AsRef<Path>>(root_dir: P) -> Self {
221        Self {
222            root_dir: root_dir.as_ref().to_path_buf(),
223        }
224    }
225
226    /// Validate all markdown files
227    pub fn validate_all(&self, fix_issues: bool) -> Result<ValidationReport, DocError> {
228        let mut report = ValidationReport::new();
229
230        for entry in WalkDir::new(&self.root_dir)
231            .into_iter()
232            .filter_map(Result::ok)
233            .filter(|e| {
234                e.file_type().is_file() && e.path().extension().map_or(false, |ext| ext == "md")
235            })
236        {
237            let path = entry.path();
238            let mut doc = MarkdownDocument::load(path)?;
239            let mut file_report = FileReport {
240                path: path.to_string_lossy().to_string(),
241                issues: Vec::new(),
242                fixed: false,
243            };
244
245            // Check compliance labels
246            if !doc.has_compliance_labels() {
247                let missing = doc.missing_labels();
248                file_report
249                    .issues
250                    .push(format!("Missing compliance labels: {}", missing.join(", ")));
251            }
252
253            // Check trailing whitespace
254            if doc.has_trailing_whitespace() {
255                file_report
256                    .issues
257                    .push("Contains trailing whitespace".to_string());
258            }
259
260            // Check line length
261            if doc.has_long_lines() {
262                file_report.issues.push(format!(
263                    "Contains lines longer than {MAX_LINE_LENGTH} characters"
264                ));
265            }
266
267            // Fix issues if requested
268            if fix_issues && !file_report.issues.is_empty() && doc.fix_all()? {
269                doc.save()?;
270                file_report.fixed = true;
271            }
272
273            if !file_report.issues.is_empty() {
274                report.files.push(file_report);
275            }
276        }
277
278        Ok(report)
279    }
280}
281
282/// Report of validation issues
283#[derive(Debug)]
284pub struct ValidationReport {
285    pub files: Vec<FileReport>,
286}
287
288impl ValidationReport {
289    fn new() -> Self {
290        Self { files: Vec::new() }
291    }
292
293    /// Get the number of files with issues
294    pub fn issue_count(&self) -> usize {
295        self.files.len()
296    }
297
298    /// Get the number of fixed files
299    pub fn fixed_count(&self) -> usize {
300        self.files.iter().filter(|f| f.fixed).count()
301    }
302
303    /// Print report to console
304    pub fn print(&self) {
305        println!("Documentation Validation Report:");
306        println!("===============================");
307
308        if self.files.is_empty() {
309            println!("āœ… All documentation files pass validation!");
310            return;
311        }
312
313        println!("Found issues in {} files:", self.files.len());
314        for file in &self.files {
315            println!("\nšŸ“„ File: {}", file.path);
316            for issue in &file.issues {
317                println!("  āŒ {issue}");
318            }
319            if file.fixed {
320                println!("  āœ… Issues fixed automatically");
321            }
322        }
323
324        println!(
325            "\nSummary: {} issues found, {} files fixed",
326            self.files.iter().map(|f| f.issues.len()).sum::<usize>(),
327            self.fixed_count()
328        );
329    }
330}
331
332/// Report for a single file
333#[derive(Debug)]
334pub struct FileReport {
335    pub path: String,
336    pub issues: Vec<String>,
337    pub fixed: bool,
338}