1use crate::computation::ATTESTED_COMPUTATION_TYPE;
22use crate::document::Document;
23use crate::frontmatter::{Frontmatter, PREFERRED_KEY_ORDER};
24use crate::index::regenerate_indexes;
25use crate::links::Citation;
26use crate::log::{Log, LogDay};
27use crate::scaffold::{current_iso_timestamp, default_author, title_from_name};
28use crate::yaml::{Mapping, Value};
29use std::collections::HashSet;
30use std::fmt::Write as _;
31use std::fs;
32use std::io;
33use std::path::{Path, PathBuf};
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum RemediationKind {
38 KeyOrder,
40 MigratedTimestamp,
42 MigratedCitations,
44 AddedTitle(String),
46 AddedGenerated,
48 AddedTopHeading(String),
50 AddedComputationLanguage(String),
52 ConsolidatedLogDates(String),
54 CleanedWhitespace,
56 NormalizedDate(String),
58 QuotedOkfVersion,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct Remediation {
65 pub kind: RemediationKind,
67 pub description: String,
69}
70
71#[allow(clippy::struct_excessive_bools)]
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct FixOptions {
75 pub author: Option<String>,
77 pub add_missing_title: bool,
79 pub add_missing_generated: bool,
81 pub add_missing_top_heading: bool,
83 pub migrate_legacy_timestamp: bool,
85 pub migrate_legacy_citations: bool,
87 pub normalize_date_formats: bool,
89 pub reorder_keys: bool,
91 pub tag_computation_blocks: bool,
93 pub fix_log_duplicates: bool,
95 pub clean_whitespace: bool,
97 pub regenerate_indexes: bool,
99 pub quote_okf_version: bool,
101}
102
103impl FixOptions {
104 #[must_use]
106 pub const fn validation_only(author: Option<String>) -> Self {
107 Self {
108 author,
109 add_missing_title: true,
110 add_missing_generated: true,
111 add_missing_top_heading: false,
112 migrate_legacy_timestamp: true,
113 migrate_legacy_citations: true,
114 normalize_date_formats: true,
115 reorder_keys: false,
116 tag_computation_blocks: false,
117 fix_log_duplicates: false,
118 clean_whitespace: false,
119 regenerate_indexes: false,
120 quote_okf_version: true,
121 }
122 }
123}
124
125impl Default for FixOptions {
126 fn default() -> Self {
127 Self {
128 author: None,
129 add_missing_title: true,
130 add_missing_generated: true,
131 add_missing_top_heading: true,
132 migrate_legacy_timestamp: true,
133 migrate_legacy_citations: true,
134 normalize_date_formats: true,
135 reorder_keys: true,
136 tag_computation_blocks: true,
137 fix_log_duplicates: true,
138 clean_whitespace: true,
139 regenerate_indexes: true,
140 quote_okf_version: true,
141 }
142 }
143}
144
145#[allow(clippy::too_many_lines)]
149#[must_use]
150pub fn remediate_document(
151 doc: &Document,
152 filename_stem: Option<&str>,
153 options: &FixOptions,
154) -> (Document, Vec<Remediation>) {
155 let mut new_doc = doc.clone();
156 let mut remediations = Vec::new();
157
158 if options.add_missing_title {
160 let has_title = new_doc
161 .frontmatter
162 .title()
163 .is_some_and(|t| !t.trim().is_empty());
164 if !has_title && let Some(stem) = filename_stem {
165 let title = title_from_name(stem);
166 new_doc
167 .frontmatter
168 .set("title", Value::String(title.clone()));
169 remediations.push(Remediation {
170 kind: RemediationKind::AddedTitle(title.clone()),
171 description: format!("added missing title `{title}`"),
172 });
173 }
174 }
175
176 if options.migrate_legacy_timestamp && new_doc.frontmatter.get("timestamp").is_some() {
178 let raw_ts = new_doc
179 .frontmatter
180 .get("timestamp")
181 .and_then(Value::as_display_string);
182 new_doc.frontmatter.remove("timestamp");
183
184 if new_doc.frontmatter.get("generated").is_none() {
185 if let Some(ts) = raw_ts {
186 let author = options.author.clone().unwrap_or_else(default_author);
187 let mut gen_map = Mapping::new();
188 gen_map.insert("by", Value::String(author));
189 gen_map.insert("at", Value::String(ts.clone()));
190 new_doc
191 .frontmatter
192 .set("generated", Value::Mapping(gen_map));
193 remediations.push(Remediation {
194 kind: RemediationKind::MigratedTimestamp,
195 description: format!("migrated legacy timestamp `{ts}` to generated block"),
196 });
197 }
198 } else {
199 remediations.push(Remediation {
200 kind: RemediationKind::MigratedTimestamp,
201 description: "removed redundant legacy timestamp".to_string(),
202 });
203 }
204 }
205
206 if options.add_missing_generated
208 && new_doc.frontmatter.get("generated").is_none()
209 && new_doc.frontmatter.get("timestamp").is_none()
210 {
211 let author = options.author.clone().unwrap_or_else(default_author);
212 let at = current_iso_timestamp();
213 let mut gen_map = Mapping::new();
214 gen_map.insert("by", Value::String(author));
215 gen_map.insert("at", Value::String(at));
216 new_doc
217 .frontmatter
218 .set("generated", Value::Mapping(gen_map));
219 remediations.push(Remediation {
220 kind: RemediationKind::AddedGenerated,
221 description: "added missing generated metadata block".to_string(),
222 });
223 }
224
225 if options.migrate_legacy_citations {
227 let citations = new_doc.citations();
228 if !citations.is_empty() {
229 let mut existing_ids: HashSet<String> = HashSet::new();
230 let mut sources_vec: Vec<Value> = Vec::new();
231
232 if let Some(Value::Sequence(seq)) = new_doc.frontmatter.get("sources") {
233 for item in seq {
234 if let Some(map) = item.as_mapping() {
235 if let Some(id) = map.get("id").and_then(Value::as_str) {
236 existing_ids.insert(id.to_string());
237 }
238 sources_vec.push(item.clone());
239 }
240 }
241 } else if let Some(Value::Mapping(map)) = new_doc.frontmatter.get("sources") {
242 if let Some(id) = map.get("id").and_then(Value::as_str) {
243 existing_ids.insert(id.to_string());
244 }
245 sources_vec.push(Value::Mapping(map.clone()));
246 }
247
248 for cit in &citations {
249 let id_str = cit.number.to_string();
250 if existing_ids.insert(id_str.clone()) {
251 let mut s_map = Mapping::new();
252 s_map.insert("id", Value::String(id_str));
253 let resource_val = cit.target.as_ref().unwrap_or(&cit.raw);
254 s_map.insert("resource", Value::String(resource_val.clone()));
255 if let Some(title) = &cit.text {
256 s_map.insert("title", Value::String(title.clone()));
257 }
258 sources_vec.push(Value::Mapping(s_map));
259 }
260 }
261
262 new_doc
263 .frontmatter
264 .set("sources", Value::Sequence(sources_vec));
265 new_doc.body = migrate_body_citations(&new_doc.body, &citations);
266
267 remediations.push(Remediation {
268 kind: RemediationKind::MigratedCitations,
269 description: format!(
270 "migrated {} legacy citation(s) to frontmatter sources",
271 citations.len()
272 ),
273 });
274 }
275 }
276
277 if options.add_missing_top_heading {
279 let trimmed = new_doc.body.trim();
280 let has_top_heading = new_doc
281 .body
282 .lines()
283 .any(|l| l.trim_start().starts_with("# "));
284 if !trimmed.is_empty() && !has_top_heading {
285 let title = new_doc.frontmatter.title().map_or_else(
286 || filename_stem.map_or_else(|| "Concept".to_string(), title_from_name),
287 |t| t.to_string(),
288 );
289 new_doc.body = format!("# {title}\n\n{}", new_doc.body.trim_start());
290 remediations.push(Remediation {
291 kind: RemediationKind::AddedTopHeading(title.clone()),
292 description: format!("added missing top-level heading `# {title}`"),
293 });
294 }
295 }
296
297 if options.tag_computation_blocks
299 && (new_doc.frontmatter.is_attested_computation()
300 || new_doc.frontmatter.type_().as_deref() == Some(ATTESTED_COMPUTATION_TYPE))
301 && let Some(runtime) = new_doc.frontmatter.runtime()
302 {
303 let runtime_str = runtime.trim();
304 if !runtime_str.is_empty() {
305 let (tagged_body, changed) = tag_computation_block(&new_doc.body, runtime_str);
306 if changed {
307 new_doc.body = tagged_body;
308 remediations.push(Remediation {
309 kind: RemediationKind::AddedComputationLanguage(runtime_str.to_string()),
310 description: format!(
311 "tagged computation code block with runtime `{runtime_str}`"
312 ),
313 });
314 }
315 }
316 }
317
318 if options.normalize_date_formats {
319 remediate_date_fields(&mut new_doc.frontmatter, &mut remediations);
320 }
321
322 if options.reorder_keys && is_key_order_remediation_needed(&new_doc.frontmatter) {
323 new_doc.frontmatter.reorder_preferred();
324 remediations.push(Remediation {
325 kind: RemediationKind::KeyOrder,
326 description: "reordered frontmatter keys to canonical order".to_string(),
327 });
328 }
329
330 if options.clean_whitespace {
331 let (cleaned_body, whitespace_changed) = clean_body_whitespace(&new_doc.body);
332 if whitespace_changed {
333 new_doc.body = cleaned_body;
334 remediations.push(Remediation {
335 kind: RemediationKind::CleanedWhitespace,
336 description: "stripped trailing whitespace and normalized excess blank lines"
337 .to_string(),
338 });
339 }
340 }
341
342 (new_doc, remediations)
343}
344
345fn normalize_mapping_date_key(
346 map: &mut Mapping,
347 key: &str,
348 field_desc: &str,
349 remediations: &mut Vec<Remediation>,
350) -> bool {
351 if let Some(val) = map.get(key).and_then(Value::as_display_string)
352 && let Some(normalized) = crate::date::normalize_iso_datetime(&val)
353 && normalized != val
354 {
355 map.insert(key, Value::String(normalized.clone()));
356 remediations.push(Remediation {
357 kind: RemediationKind::NormalizedDate(field_desc.to_string()),
358 description: format!("normalized `{field_desc}` datetime to `{normalized}`"),
359 });
360 return true;
361 }
362 false
363}
364
365#[allow(clippy::too_many_lines)]
366fn remediate_date_fields(fm: &mut Frontmatter, remediations: &mut Vec<Remediation>) {
367 if let Some(Value::Mapping(mut gen_map)) = fm.get("generated").cloned()
368 && normalize_mapping_date_key(&mut gen_map, "at", "generated.at", remediations)
369 {
370 fm.set("generated", Value::Mapping(gen_map));
371 }
372
373 if let Some(v_val) = fm.get("verified").cloned() {
374 match v_val {
375 Value::Sequence(mut seq) => {
376 let mut changed = false;
377 for (i, item) in seq.iter_mut().enumerate() {
378 if let Some(map) = item.as_mapping_mut() {
379 changed |= normalize_mapping_date_key(
380 map,
381 "at",
382 &format!("verified[{i}].at"),
383 remediations,
384 );
385 }
386 }
387 if changed {
388 fm.set("verified", Value::Sequence(seq));
389 }
390 }
391 Value::Mapping(mut map) => {
392 if normalize_mapping_date_key(&mut map, "at", "verified.at", remediations) {
393 fm.set("verified", Value::Mapping(map));
394 }
395 }
396 _ => {}
397 }
398 }
399
400 if let Some(stale_val) = fm.get("stale_after").and_then(Value::as_display_string)
401 && let Some(normalized) = crate::date::normalize_iso_datetime(&stale_val)
402 && normalized != stale_val
403 {
404 fm.set("stale_after", Value::String(normalized.clone()));
405 remediations.push(Remediation {
406 kind: RemediationKind::NormalizedDate("stale_after".to_string()),
407 description: format!("normalized `stale_after` datetime to `{normalized}`"),
408 });
409 }
410
411 if let Some(Value::Mapping(mut uw_map)) = fm.get("usage_window").cloned() {
412 let mut changed = false;
413 for field in ["from", "to"] {
414 changed |= normalize_mapping_date_key(
415 &mut uw_map,
416 field,
417 &format!("usage_window.{field}"),
418 remediations,
419 );
420 }
421 if changed {
422 fm.set("usage_window", Value::Mapping(uw_map));
423 }
424 }
425
426 if let Some(s_val) = fm.get("sources").cloned() {
427 match s_val {
428 Value::Sequence(mut seq) => {
429 let mut changed = false;
430 for (i, item) in seq.iter_mut().enumerate() {
431 if let Some(map) = item.as_mapping_mut() {
432 changed |= normalize_mapping_date_key(
433 map,
434 "last_modified",
435 &format!("sources[{i}].last_modified"),
436 remediations,
437 );
438 if let Some(Value::Mapping(mut uw_map)) = map.get("usage_window").cloned() {
439 let mut uw_changed = false;
440 for field in ["from", "to"] {
441 uw_changed |= normalize_mapping_date_key(
442 &mut uw_map,
443 field,
444 &format!("sources[{i}].usage_window.{field}"),
445 remediations,
446 );
447 }
448 if uw_changed {
449 map.insert("usage_window", Value::Mapping(uw_map));
450 changed = true;
451 }
452 }
453 }
454 }
455 if changed {
456 fm.set("sources", Value::Sequence(seq));
457 }
458 }
459 Value::Mapping(mut map) => {
460 let mut changed = normalize_mapping_date_key(
461 &mut map,
462 "last_modified",
463 "sources.last_modified",
464 remediations,
465 );
466 if let Some(Value::Mapping(mut uw_map)) = map.get("usage_window").cloned() {
467 let mut uw_changed = false;
468 for field in ["from", "to"] {
469 uw_changed |= normalize_mapping_date_key(
470 &mut uw_map,
471 field,
472 &format!("sources.usage_window.{field}"),
473 remediations,
474 );
475 }
476 if uw_changed {
477 map.insert("usage_window", Value::Mapping(uw_map));
478 changed = true;
479 }
480 }
481 if changed {
482 fm.set("sources", Value::Mapping(map));
483 }
484 }
485 _ => {}
486 }
487 }
488}
489
490fn is_key_order_remediation_needed(fm: &Frontmatter) -> bool {
492 let keys: Vec<&str> = fm.as_mapping().keys().collect();
493 if keys.len() < 2 {
494 return false;
495 }
496 let mut last_rank = None;
497 for key in keys {
498 if let Some(rank) = PREFERRED_KEY_ORDER.iter().position(|&k| k == key) {
499 if let Some(prev) = last_rank
500 && rank < prev
501 {
502 return true;
503 }
504 last_rank = Some(rank);
505 }
506 }
507 false
508}
509
510fn migrate_body_citations(body: &str, citations: &[Citation]) -> String {
512 let lines: Vec<&str> = body.lines().collect();
513 let mut out_lines: Vec<String> = Vec::new();
514 let mut in_citations = false;
515 let mut citations_heading_level = 1;
516
517 let cit_numbers: Vec<u32> = citations.iter().map(|c| c.number).collect();
518
519 for line in lines {
520 let trimmed = line.trim();
521 if let Some(heading) = trimmed.strip_prefix('#') {
522 let count = trimmed.chars().take_while(|&c| c == '#').count();
523 let title = heading.trim_start_matches('#').trim();
524 if in_citations {
525 if count <= citations_heading_level {
526 in_citations = false;
527 } else {
528 continue;
529 }
530 }
531 if title.eq_ignore_ascii_case("citations") {
532 in_citations = true;
533 citations_heading_level = count;
534 continue;
535 }
536 }
537 if in_citations {
538 continue;
539 }
540
541 out_lines.push(replace_citation_refs_in_line(line, &cit_numbers));
542 }
543
544 while out_lines.last().is_some_and(|l| l.trim().is_empty()) {
546 out_lines.pop();
547 }
548 let mut result = out_lines.join("\n");
549 if !result.is_empty() {
550 result.push('\n');
551 }
552 result
553}
554
555fn replace_citation_refs_in_line(line: &str, cit_numbers: &[u32]) -> String {
557 let mut out = String::with_capacity(line.len() + 8);
558 let bytes = line.as_bytes();
559 let mut i = 0;
560
561 while i < bytes.len() {
562 if bytes[i] == b'[' {
563 let prev_char = if i > 0 { Some(bytes[i - 1]) } else { None };
565 if prev_char == Some(b'^') || prev_char == Some(b'!') {
566 out.push(bytes[i] as char);
567 i += 1;
568 continue;
569 }
570
571 if let Some(close_rel) = line[i + 1..].find(']') {
572 let close_idx = i + 1 + close_rel;
573 let inside = &line[i + 1..close_idx].trim();
574 let is_number = inside.parse::<u32>().ok();
575
576 if let Some(num) = is_number
577 && cit_numbers.contains(&num)
578 {
579 let next_char = line.as_bytes().get(close_idx + 1).copied();
580 if next_char != Some(b'(') && next_char != Some(b'[') {
581 let _ = write!(out, "[^{num}]");
582 i = close_idx + 1;
583 continue;
584 }
585 }
586 }
587 }
588 out.push(bytes[i] as char);
589 i += 1;
590 }
591 out
592}
593
594fn tag_computation_block(body: &str, runtime: &str) -> (String, bool) {
596 let mut out_lines = Vec::new();
597 let mut in_computation_section = false;
598 let mut changed = false;
599 let mut in_code_block = false;
600
601 for line in body.lines() {
602 let trimmed = line.trim();
603 if trimmed.starts_with("# ") {
604 in_computation_section = trimmed == "# Computation";
605 out_lines.push(line.to_string());
606 continue;
607 }
608
609 if in_computation_section {
610 if trimmed == "```" {
611 if !in_code_block {
612 let leading_spaces = line.len() - line.trim_start().len();
614 let indent = " ".repeat(leading_spaces);
615 out_lines.push(format!("{indent}```{runtime}"));
616 changed = true;
617 in_code_block = true;
618 continue;
619 }
620 in_code_block = false;
621 } else if trimmed == "~~~" {
622 if !in_code_block {
623 let leading_spaces = line.len() - line.trim_start().len();
624 let indent = " ".repeat(leading_spaces);
625 out_lines.push(format!("{indent}~~~{runtime}"));
626 changed = true;
627 in_code_block = true;
628 continue;
629 }
630 in_code_block = false;
631 }
632 }
633 out_lines.push(line.to_string());
634 }
635
636 (out_lines.join("\n"), changed)
637}
638
639fn clean_body_whitespace(body: &str) -> (String, bool) {
641 let mut out_lines: Vec<String> = Vec::new();
642 let mut consecutive_empty = 0;
643
644 for line in body.lines() {
645 let trimmed_cr = line.trim_end_matches('\r');
646 let trimmed_end = trimmed_cr.trim_end();
647
648 if trimmed_end.is_empty() {
649 consecutive_empty += 1;
650 if consecutive_empty > 2 {
651 continue;
652 }
653 out_lines.push(String::new());
654 } else {
655 consecutive_empty = 0;
656 out_lines.push(trimmed_end.to_string());
657 }
658 }
659
660 while out_lines.last().is_some_and(String::is_empty) {
661 out_lines.pop();
662 }
663
664 let mut result = out_lines.join("\n");
665 if !result.is_empty() {
666 result.push('\n');
667 }
668
669 let normalized_input = if body.is_empty() {
670 String::new()
671 } else if body.ends_with('\n') {
672 body.to_string()
673 } else {
674 format!("{body}\n")
675 };
676
677 let changed = result != normalized_input;
678 (result, changed)
679}
680
681#[must_use]
683pub fn remediate_log(text: &str, options: &FixOptions) -> (String, Vec<Remediation>) {
684 let log = Log::parse(text);
685 let mut remediations = Vec::new();
686
687 if !options.fix_log_duplicates {
688 return (text.to_string(), remediations);
689 }
690
691 let mut consolidated_days: Vec<LogDay> = Vec::new();
692 let mut seen_dates: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
693
694 for day in log.days {
695 if let Some(&existing_idx) = seen_dates.get(&day.date) {
696 consolidated_days[existing_idx].entries.extend(day.entries);
697 remediations.push(Remediation {
698 kind: RemediationKind::ConsolidatedLogDates(day.date.clone()),
699 description: format!("consolidated duplicate date headings for `## {}`", day.date),
700 });
701 } else {
702 seen_dates.insert(day.date.clone(), consolidated_days.len());
703 consolidated_days.push(day);
704 }
705 }
706
707 if remediations.is_empty() {
708 (text.to_string(), remediations)
709 } else {
710 let new_log = Log {
711 frontmatter: log.frontmatter,
712 title: log.title,
713 days: consolidated_days,
714 };
715 (new_log.to_markdown(), remediations)
716 }
717}
718
719#[derive(Clone, Debug, PartialEq, Eq)]
721pub struct FileFixReport {
722 pub path: PathBuf,
724 pub remediations: Vec<Remediation>,
726 pub original_content: String,
728 pub remediated_content: String,
730 pub changed: bool,
732}
733
734#[derive(Clone, Debug, Default, PartialEq, Eq)]
736pub struct BundleFixReport {
737 pub bundle_root: PathBuf,
739 pub files: Vec<FileFixReport>,
741 pub index_files_to_regenerate: Vec<PathBuf>,
743 pub options: FixOptions,
745}
746
747impl BundleFixReport {
748 pub fn apply(&self) -> io::Result<(usize, Vec<PathBuf>)> {
756 let mut count = 0;
757 for file in &self.files {
758 if file.changed {
759 fs::write(&file.path, &file.remediated_content)?;
760 count += 1;
761 }
762 }
763 let regenerated = if self.options.regenerate_indexes
764 && (count > 0 || !self.index_files_to_regenerate.is_empty())
765 {
766 regenerate_indexes(&self.bundle_root)?
767 } else {
768 Vec::new()
769 };
770 Ok((count, regenerated))
771 }
772
773 #[must_use]
775 pub fn total_remediations(&self) -> usize {
776 self.files.iter().map(|f| f.remediations.len()).sum()
777 }
778
779 pub fn changed_files(&self) -> impl Iterator<Item = &FileFixReport> {
781 self.files.iter().filter(|f| f.changed)
782 }
783
784 #[must_use]
786 pub fn is_empty(&self) -> bool {
787 self.files.iter().all(|f| !f.changed) && self.index_files_to_regenerate.is_empty()
788 }
789}
790
791pub fn remediate_file(path: impl AsRef<Path>, options: &FixOptions) -> io::Result<FileFixReport> {
797 let path = path.as_ref().to_path_buf();
798 let original = fs::read_to_string(&path)?;
799 let filename = path
800 .file_name()
801 .and_then(|n| n.to_str())
802 .unwrap_or_default();
803
804 if filename == "log.md" {
805 let (remediated_content, remediations) = remediate_log(&original, options);
806 let changed = remediated_content != original;
807 return Ok(FileFixReport {
808 path,
809 remediations,
810 original_content: original,
811 remediated_content,
812 changed,
813 });
814 }
815
816 if filename == "index.md" {
817 let mut remediations = Vec::new();
818 let (remediated_content, changed) = if options.quote_okf_version
819 && let Ok(mut doc) = Document::parse(&original)
820 && let Some(val) = doc.frontmatter.get("okf_version")
821 && !matches!(val, Value::String(_))
822 && let Some(ver_str) = val.as_display_string()
823 {
824 doc.frontmatter.set("okf_version", Value::String(ver_str));
825 remediations.push(Remediation {
826 kind: RemediationKind::QuotedOkfVersion,
827 description: "Quoted unquoted `okf_version` in root index.md (L13)".to_string(),
828 });
829 (doc.serialize(), true)
830 } else {
831 (original.clone(), false)
832 };
833
834 return Ok(FileFixReport {
835 path,
836 remediations,
837 original_content: original,
838 remediated_content,
839 changed,
840 });
841 }
842
843 let doc = Document::parse(&original).map_err(|e| {
844 io::Error::new(
845 io::ErrorKind::InvalidData,
846 format!("could not parse {}: {e}", path.display()),
847 )
848 })?;
849 let stem = path.file_stem().and_then(|s| s.to_str());
850 let (remediated_doc, remediations) = remediate_document(&doc, stem, options);
851 let remediated_content = remediated_doc.serialize();
852 let changed = remediated_content != original || !remediations.is_empty();
853
854 Ok(FileFixReport {
855 path,
856 remediations,
857 original_content: original,
858 remediated_content,
859 changed,
860 })
861}
862
863pub fn remediate_bundle(
869 bundle_root: impl AsRef<Path>,
870 options: &FixOptions,
871) -> io::Result<BundleFixReport> {
872 let bundle_root = bundle_root.as_ref().to_path_buf();
873 let mut files = Vec::new();
874 let mut md_paths = Vec::new();
875
876 collect_md_files(&bundle_root, &mut md_paths)?;
877 md_paths.sort();
878
879 for path in &md_paths {
880 let filename = path
881 .file_name()
882 .and_then(|n| n.to_str())
883 .unwrap_or_default();
884 if filename == "index.md" {
885 if path == &bundle_root.join("index.md")
886 && let Ok(report) = remediate_file(path, options)
887 && report.changed
888 {
889 files.push(report);
890 }
891 continue;
892 }
893 if let Ok(report) = remediate_file(path, options) {
894 files.push(report);
895 }
896 }
897
898 let mut index_files_to_regenerate = Vec::new();
899 if options.regenerate_indexes {
900 let any_file_changed = files.iter().any(|f| f.changed);
901 if any_file_changed {
902 let mut all_dirs = std::collections::BTreeSet::new();
903 for md in &md_paths {
904 if let Some(parent) = md.parent() {
905 all_dirs.insert(parent.to_path_buf());
906 }
907 }
908 index_files_to_regenerate = all_dirs.into_iter().map(|d| d.join("index.md")).collect();
909 } else if let Ok(bundle) = crate::bundle::Bundle::load(&bundle_root) {
910 let mut all_dirs = std::collections::BTreeSet::new();
911 for c in bundle.concepts() {
912 if let Some(parent) = c.path.parent() {
913 all_dirs.insert(parent.to_path_buf());
914 }
915 }
916 for dir in all_dirs {
917 let idx = dir.join("index.md");
918 if !idx.exists() {
919 index_files_to_regenerate.push(idx);
920 }
921 }
922 }
923 }
924
925 Ok(BundleFixReport {
926 bundle_root,
927 files,
928 index_files_to_regenerate,
929 options: options.clone(),
930 })
931}
932
933fn collect_md_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
934 for entry in fs::read_dir(dir)? {
935 let entry = entry?;
936 let path = entry.path();
937 if path.is_dir() {
938 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
939 if !name.starts_with('.') && name != "target" && name != "node_modules" {
940 collect_md_files(&path, out)?;
941 }
942 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
943 out.push(path);
944 }
945 }
946 Ok(())
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 #[test]
954 fn remediates_missing_title_and_generated_and_heading() {
955 let input = "---\ntype: Concept\n---\nBody text.\n";
956 let doc = Document::parse(input).unwrap();
957 let opts = FixOptions {
958 author: Some("human:alice".to_string()),
959 ..Default::default()
960 };
961
962 let (fixed_doc, remediations) = remediate_document(&doc, Some("revenue_stream"), &opts);
963
964 assert_eq!(
965 fixed_doc.frontmatter.title().as_deref(),
966 Some("Revenue Stream")
967 );
968 assert_eq!(
969 fixed_doc
970 .frontmatter
971 .generated()
972 .unwrap()
973 .by
974 .unwrap()
975 .as_str(),
976 "human:alice"
977 );
978 assert!(fixed_doc.body.starts_with("# Revenue Stream\n\n"));
979 assert_eq!(remediations.len(), 3);
980 }
981
982 #[test]
983 fn migrates_legacy_timestamp_and_citations() {
984 let input = "---\n\
985 type: Concept\n\
986 title: Revenue\n\
987 timestamp: 2026-05-01T00:00:00Z\n\
988 ---\n\n\
989 # Definition\n\
990 Defined in [1] and [2].\n\n\
991 # Citations\n\
992 [1] [GAAP Standards](https://example.com/gaap)\n\
993 [2] https://example.com/sec\n";
994 let doc = Document::parse(input).unwrap();
995 let opts = FixOptions {
996 author: Some("human:bob".to_string()),
997 ..Default::default()
998 };
999
1000 let (fixed_doc, remediations) = remediate_document(&doc, Some("revenue"), &opts);
1001
1002 assert!(fixed_doc.frontmatter.get("timestamp").is_none());
1003 assert_eq!(
1004 fixed_doc.frontmatter.generated().unwrap().at.unwrap().raw,
1005 "2026-05-01T00:00:00Z"
1006 );
1007 assert_eq!(
1008 fixed_doc
1009 .frontmatter
1010 .generated()
1011 .unwrap()
1012 .by
1013 .unwrap()
1014 .as_str(),
1015 "human:bob"
1016 );
1017
1018 let sources = fixed_doc.frontmatter.sources();
1019 assert_eq!(sources.len(), 2);
1020 assert_eq!(sources[0].id.as_deref(), Some("1"));
1021 assert_eq!(
1022 sources[0].resource.as_deref(),
1023 Some("https://example.com/gaap")
1024 );
1025 assert_eq!(sources[0].title.as_deref(), Some("GAAP Standards"));
1026
1027 assert!(!fixed_doc.body.contains("# Citations"));
1028 assert!(fixed_doc.body.contains("Defined in [^1] and [^2]."));
1029 assert!(
1030 remediations
1031 .iter()
1032 .any(|r| matches!(r.kind, RemediationKind::MigratedTimestamp))
1033 );
1034 assert!(
1035 remediations
1036 .iter()
1037 .any(|r| matches!(r.kind, RemediationKind::MigratedCitations))
1038 );
1039 }
1040
1041 #[test]
1042 fn tags_computation_code_block() {
1043 let input = "---\n\
1044 type: Attested Computation\n\
1045 runtime: python\n\
1046 ---\n\n\
1047 # Computation\n\n\
1048 ```\n\
1049 def compute():\n\
1050 return 42\n\
1051 ```\n";
1052 let doc = Document::parse(input).unwrap();
1053 let opts = FixOptions::default();
1054
1055 let (fixed_doc, remediations) = remediate_document(&doc, Some("calc"), &opts);
1056 assert!(fixed_doc.body.contains("```python\n"));
1057 assert!(
1058 remediations
1059 .iter()
1060 .any(|r| matches!(r.kind, RemediationKind::AddedComputationLanguage(_)))
1061 );
1062 }
1063
1064 #[test]
1065 fn consolidates_duplicate_log_dates() {
1066 let input = "# Update Log\n\n\
1067 ## 2026-06-01\n\
1068 * **Update**: Changed formula.\n\n\
1069 ## 2026-06-01\n\
1070 * **Creation**: Added concept.\n";
1071 let (fixed, remediations) = remediate_log(input, &FixOptions::default());
1072 assert_eq!(remediations.len(), 1);
1073 assert_eq!(fixed.matches("## 2026-06-01").count(), 1);
1074 assert!(fixed.contains("* **Update**: Changed formula."));
1075 assert!(fixed.contains("* **Creation**: Added concept."));
1076 }
1077}