anya_core/tools/
markdown.rs1use regex::Regex;
8use std::fs::File;
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12use walkdir::WalkDir;
13
14const 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
33pub struct MarkdownDocument {
35 path: PathBuf,
36 content: String,
37}
38
39impl MarkdownDocument {
40 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 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 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 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 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 self.content = format!("{}\n\n{}", labels_to_add, self.content);
95 }
96
97 Ok(true)
98 }
99
100 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, }
113 }
114
115 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 pub fn has_long_lines(&self) -> bool {
126 for line in self.content.lines() {
127 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 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 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 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 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 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
213pub struct DocumentationValidator {
215 root_dir: PathBuf,
216}
217
218impl DocumentationValidator {
219 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 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 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 if doc.has_trailing_whitespace() {
255 file_report
256 .issues
257 .push("Contains trailing whitespace".to_string());
258 }
259
260 if doc.has_long_lines() {
262 file_report.issues.push(format!(
263 "Contains lines longer than {MAX_LINE_LENGTH} characters"
264 ));
265 }
266
267 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#[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 pub fn issue_count(&self) -> usize {
295 self.files.len()
296 }
297
298 pub fn fixed_count(&self) -> usize {
300 self.files.iter().filter(|f| f.fixed).count()
301 }
302
303 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#[derive(Debug)]
334pub struct FileReport {
335 pub path: String,
336 pub issues: Vec<String>,
337 pub fixed: bool,
338}