Skip to main content

okf_core/
fix.rs

1//! Automated remediation and migrations for OKF concepts, bundles, and logs.
2//!
3//! Continuous authoring by agents and humans naturally introduces hygiene issues
4//! or leaves documents in older specification versions (such as v0.1 `timestamp`
5//! and body `# Citations`). This module provides deterministic, safe, automated
6//! remediation and migration transformations:
7//!
8//! - **Frontmatter key ordering** (`L18`): Normalizes frontmatter keys to the
9//!   canonical [`PREFERRED_KEY_ORDER`].
10//! - **v0.1 `timestamp` migration** (`L5`): Migrates legacy `timestamp` into a
11//!   structured `generated: { by, at }` block (or removes redundant timestamp).
12//! - **v0.1 `# Citations` migration** (`L6`): Converts legacy body citations to
13//!   frontmatter `sources` entries and turns inline references into footnotes.
14//! - **Missing title** (`L1`): Derives and inserts human-readable `title` from filename stem.
15//! - **Missing generated** (`L3`): Inserts `generated: { by, at }` attribution.
16//! - **Missing top heading** (`L8`): Prepends `# <title>` heading to document body.
17//! - **Computation syntax tagging** (`L26`): Adds language syntax tag to `# Computation` code blocks.
18//! - **Duplicate log dates** (`L27`): Consolidates duplicate `## YYYY-MM-DD` headings in `log.md`.
19//! - **Index sync** (`L16`): Re-indexes all bundle directories.
20
21use 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/// What kind of remediation or migration was applied.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub enum RemediationKind {
38    /// Reordered frontmatter keys to canonical order (L18).
39    KeyOrder,
40    /// Migrated legacy v0.1 `timestamp` to `generated` or removed redundant `timestamp` (L5).
41    MigratedTimestamp,
42    /// Migrated legacy v0.1 `# Citations` body section to frontmatter `sources` (L6).
43    MigratedCitations,
44    /// Added missing `title` inferred from filename (L1).
45    AddedTitle(String),
46    /// Added missing `generated` block (L3).
47    AddedGenerated,
48    /// Added missing top-level `# <title>` heading to document body (L8).
49    AddedTopHeading(String),
50    /// Tagged unlabeled `# Computation` code block with runtime language (L26).
51    AddedComputationLanguage(String),
52    /// Consolidated duplicate date headings in `log.md` (L27).
53    ConsolidatedLogDates(String),
54    /// Stripped trailing whitespace and normalized excess blank lines (L8).
55    CleanedWhitespace,
56}
57
58/// A single remediation action applied to a document or file.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Remediation {
61    /// The rule or action kind.
62    pub kind: RemediationKind,
63    /// A human-readable description of the fix applied.
64    pub description: String,
65}
66
67/// Options controlling automated remediation and migration.
68#[allow(clippy::struct_excessive_bools)]
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct FixOptions {
71    /// Default author attribution string (e.g. "human:alice").
72    pub author: Option<String>,
73    /// Whether to insert missing titles (L1).
74    pub add_missing_title: bool,
75    /// Whether to insert missing `generated` blocks (L3).
76    pub add_missing_generated: bool,
77    /// Whether to insert missing top-level `# Heading` (L8).
78    pub add_missing_top_heading: bool,
79    /// Whether to migrate legacy v0.1 timestamp (L5).
80    pub migrate_legacy_timestamp: bool,
81    /// Whether to migrate legacy v0.1 Citations (L6).
82    pub migrate_legacy_citations: bool,
83    /// Whether to reorder frontmatter keys canonically (L18).
84    pub reorder_keys: bool,
85    /// Whether to tag unlabeled computation blocks (L26).
86    pub tag_computation_blocks: bool,
87    /// Whether to consolidate duplicate date headings in log.md (L27).
88    pub fix_log_duplicates: bool,
89    /// Whether to strip trailing whitespace and excess blank lines (L8).
90    pub clean_whitespace: bool,
91    /// Whether to regenerate index files (L16).
92    pub regenerate_indexes: bool,
93}
94
95impl FixOptions {
96    /// Creates options that only fix strict spec conformance and migration issues (for `okf validate --fix`).
97    #[must_use]
98    pub const fn validation_only(author: Option<String>) -> Self {
99        Self {
100            author,
101            add_missing_title: true,
102            add_missing_generated: true,
103            add_missing_top_heading: false,
104            migrate_legacy_timestamp: true,
105            migrate_legacy_citations: true,
106            reorder_keys: false,
107            tag_computation_blocks: false,
108            fix_log_duplicates: false,
109            clean_whitespace: false,
110            regenerate_indexes: false,
111        }
112    }
113}
114
115impl Default for FixOptions {
116    fn default() -> Self {
117        Self {
118            author: None,
119            add_missing_title: true,
120            add_missing_generated: true,
121            add_missing_top_heading: true,
122            migrate_legacy_timestamp: true,
123            migrate_legacy_citations: true,
124            reorder_keys: true,
125            tag_computation_blocks: true,
126            fix_log_duplicates: true,
127            clean_whitespace: true,
128            regenerate_indexes: true,
129        }
130    }
131}
132
133/// Remediates and migrates a single [`Document`].
134///
135/// Returns the updated document and the list of applied remediations.
136#[allow(clippy::too_many_lines)]
137#[must_use]
138pub fn remediate_document(
139    doc: &Document,
140    filename_stem: Option<&str>,
141    options: &FixOptions,
142) -> (Document, Vec<Remediation>) {
143    let mut new_doc = doc.clone();
144    let mut remediations = Vec::new();
145
146    // 1. Missing title (L1)
147    if options.add_missing_title {
148        let has_title = new_doc
149            .frontmatter
150            .title()
151            .is_some_and(|t| !t.trim().is_empty());
152        if !has_title && let Some(stem) = filename_stem {
153            let title = title_from_name(stem);
154            new_doc
155                .frontmatter
156                .set("title", Value::String(title.clone()));
157            remediations.push(Remediation {
158                kind: RemediationKind::AddedTitle(title.clone()),
159                description: format!("added missing title `{title}`"),
160            });
161        }
162    }
163
164    // 2. Legacy timestamp (L5)
165    if options.migrate_legacy_timestamp && new_doc.frontmatter.get("timestamp").is_some() {
166        let raw_ts = new_doc
167            .frontmatter
168            .get("timestamp")
169            .and_then(Value::as_display_string);
170        new_doc.frontmatter.remove("timestamp");
171
172        if new_doc.frontmatter.get("generated").is_none() {
173            if let Some(ts) = raw_ts {
174                let author = options.author.clone().unwrap_or_else(default_author);
175                let mut gen_map = Mapping::new();
176                gen_map.insert("by", Value::String(author));
177                gen_map.insert("at", Value::String(ts.clone()));
178                new_doc
179                    .frontmatter
180                    .set("generated", Value::Mapping(gen_map));
181                remediations.push(Remediation {
182                    kind: RemediationKind::MigratedTimestamp,
183                    description: format!("migrated legacy timestamp `{ts}` to generated block"),
184                });
185            }
186        } else {
187            remediations.push(Remediation {
188                kind: RemediationKind::MigratedTimestamp,
189                description: "removed redundant legacy timestamp".to_string(),
190            });
191        }
192    }
193
194    // 3. Missing generated (L3)
195    if options.add_missing_generated
196        && new_doc.frontmatter.get("generated").is_none()
197        && new_doc.frontmatter.get("timestamp").is_none()
198    {
199        let author = options.author.clone().unwrap_or_else(default_author);
200        let at = current_iso_timestamp();
201        let mut gen_map = Mapping::new();
202        gen_map.insert("by", Value::String(author));
203        gen_map.insert("at", Value::String(at));
204        new_doc
205            .frontmatter
206            .set("generated", Value::Mapping(gen_map));
207        remediations.push(Remediation {
208            kind: RemediationKind::AddedGenerated,
209            description: "added missing generated metadata block".to_string(),
210        });
211    }
212
213    // 4. Legacy citations (L6)
214    if options.migrate_legacy_citations {
215        let citations = new_doc.citations();
216        if !citations.is_empty() {
217            let mut existing_ids: HashSet<String> = HashSet::new();
218            let mut sources_vec: Vec<Value> = Vec::new();
219
220            if let Some(Value::Sequence(seq)) = new_doc.frontmatter.get("sources") {
221                for item in seq {
222                    if let Some(map) = item.as_mapping() {
223                        if let Some(id) = map.get("id").and_then(Value::as_str) {
224                            existing_ids.insert(id.to_string());
225                        }
226                        sources_vec.push(item.clone());
227                    }
228                }
229            } else if let Some(Value::Mapping(map)) = new_doc.frontmatter.get("sources") {
230                if let Some(id) = map.get("id").and_then(Value::as_str) {
231                    existing_ids.insert(id.to_string());
232                }
233                sources_vec.push(Value::Mapping(map.clone()));
234            }
235
236            for cit in &citations {
237                let id_str = cit.number.to_string();
238                if existing_ids.insert(id_str.clone()) {
239                    let mut s_map = Mapping::new();
240                    s_map.insert("id", Value::String(id_str));
241                    let resource_val = cit.target.as_ref().unwrap_or(&cit.raw);
242                    s_map.insert("resource", Value::String(resource_val.clone()));
243                    if let Some(title) = &cit.text {
244                        s_map.insert("title", Value::String(title.clone()));
245                    }
246                    sources_vec.push(Value::Mapping(s_map));
247                }
248            }
249
250            new_doc
251                .frontmatter
252                .set("sources", Value::Sequence(sources_vec));
253            new_doc.body = migrate_body_citations(&new_doc.body, &citations);
254
255            remediations.push(Remediation {
256                kind: RemediationKind::MigratedCitations,
257                description: format!(
258                    "migrated {} legacy citation(s) to frontmatter sources",
259                    citations.len()
260                ),
261            });
262        }
263    }
264
265    // 5. Missing top heading (L8)
266    if options.add_missing_top_heading {
267        let trimmed = new_doc.body.trim();
268        let has_top_heading = new_doc
269            .body
270            .lines()
271            .any(|l| l.trim_start().starts_with("# "));
272        if !trimmed.is_empty() && !has_top_heading {
273            let title = new_doc.frontmatter.title().map_or_else(
274                || filename_stem.map_or_else(|| "Concept".to_string(), title_from_name),
275                |t| t.to_string(),
276            );
277            new_doc.body = format!("# {title}\n\n{}", new_doc.body.trim_start());
278            remediations.push(Remediation {
279                kind: RemediationKind::AddedTopHeading(title.clone()),
280                description: format!("added missing top-level heading `# {title}`"),
281            });
282        }
283    }
284
285    // 6. Unlabeled computation block (L26)
286    if options.tag_computation_blocks
287        && (new_doc.frontmatter.is_attested_computation()
288            || new_doc.frontmatter.type_().as_deref() == Some(ATTESTED_COMPUTATION_TYPE))
289        && let Some(runtime) = new_doc.frontmatter.runtime()
290    {
291        let runtime_str = runtime.trim();
292        if !runtime_str.is_empty() {
293            let (tagged_body, changed) = tag_computation_block(&new_doc.body, runtime_str);
294            if changed {
295                new_doc.body = tagged_body;
296                remediations.push(Remediation {
297                    kind: RemediationKind::AddedComputationLanguage(runtime_str.to_string()),
298                    description: format!(
299                        "tagged computation code block with runtime `{runtime_str}`"
300                    ),
301                });
302            }
303        }
304    }
305
306    // 7. Canonical key order (L18)
307    if options.reorder_keys && is_key_order_remediation_needed(&new_doc.frontmatter) {
308        new_doc.frontmatter.reorder_preferred();
309        remediations.push(Remediation {
310            kind: RemediationKind::KeyOrder,
311            description: "reordered frontmatter keys to canonical order".to_string(),
312        });
313    }
314
315    // 8. Trailing whitespace and excess blank lines (L8)
316    if options.clean_whitespace {
317        let (cleaned_body, whitespace_changed) = clean_body_whitespace(&new_doc.body);
318        if whitespace_changed {
319            new_doc.body = cleaned_body;
320            remediations.push(Remediation {
321                kind: RemediationKind::CleanedWhitespace,
322                description: "stripped trailing whitespace and normalized excess blank lines"
323                    .to_string(),
324            });
325        }
326    }
327
328    (new_doc, remediations)
329}
330
331/// Checks if frontmatter keys deviate from the canonical preferred order.
332fn is_key_order_remediation_needed(fm: &Frontmatter) -> bool {
333    let keys: Vec<&str> = fm.as_mapping().keys().collect();
334    if keys.len() < 2 {
335        return false;
336    }
337    let mut last_rank = None;
338    for key in keys {
339        if let Some(rank) = PREFERRED_KEY_ORDER.iter().position(|&k| k == key) {
340            if let Some(prev) = last_rank
341                && rank < prev
342            {
343                return true;
344            }
345            last_rank = Some(rank);
346        }
347    }
348    false
349}
350
351/// Removes the legacy `# Citations` section and converts citation references `[n]` to `[^n]`.
352fn migrate_body_citations(body: &str, citations: &[Citation]) -> String {
353    let lines: Vec<&str> = body.lines().collect();
354    let mut out_lines: Vec<String> = Vec::new();
355    let mut in_citations = false;
356    let mut citations_heading_level = 1;
357
358    let cit_numbers: Vec<u32> = citations.iter().map(|c| c.number).collect();
359
360    for line in lines {
361        let trimmed = line.trim();
362        if let Some(heading) = trimmed.strip_prefix('#') {
363            let count = trimmed.chars().take_while(|&c| c == '#').count();
364            let title = heading.trim_start_matches('#').trim();
365            if in_citations {
366                if count <= citations_heading_level {
367                    in_citations = false;
368                } else {
369                    continue;
370                }
371            }
372            if title.eq_ignore_ascii_case("citations") {
373                in_citations = true;
374                citations_heading_level = count;
375                continue;
376            }
377        }
378        if in_citations {
379            continue;
380        }
381
382        out_lines.push(replace_citation_refs_in_line(line, &cit_numbers));
383    }
384
385    // Trim trailing empty lines, keeping a clean single trailing newline
386    while out_lines.last().is_some_and(|l| l.trim().is_empty()) {
387        out_lines.pop();
388    }
389    let mut result = out_lines.join("\n");
390    if !result.is_empty() {
391        result.push('\n');
392    }
393    result
394}
395
396/// Replaces `[n]` citation markers with footnote markers `[^n]` in prose lines.
397fn replace_citation_refs_in_line(line: &str, cit_numbers: &[u32]) -> String {
398    let mut out = String::with_capacity(line.len() + 8);
399    let bytes = line.as_bytes();
400    let mut i = 0;
401
402    while i < bytes.len() {
403        if bytes[i] == b'[' {
404            // Check if preceded by `^` or `!`
405            let prev_char = if i > 0 { Some(bytes[i - 1]) } else { None };
406            if prev_char == Some(b'^') || prev_char == Some(b'!') {
407                out.push(bytes[i] as char);
408                i += 1;
409                continue;
410            }
411
412            if let Some(close_rel) = line[i + 1..].find(']') {
413                let close_idx = i + 1 + close_rel;
414                let inside = &line[i + 1..close_idx].trim();
415                let is_number = inside.parse::<u32>().ok();
416
417                if let Some(num) = is_number
418                    && cit_numbers.contains(&num)
419                {
420                    let next_char = line.as_bytes().get(close_idx + 1).copied();
421                    if next_char != Some(b'(') && next_char != Some(b'[') {
422                        let _ = write!(out, "[^{num}]");
423                        i = close_idx + 1;
424                        continue;
425                    }
426                }
427            }
428        }
429        out.push(bytes[i] as char);
430        i += 1;
431    }
432    out
433}
434
435/// Tags untagged code blocks under `# Computation` with the given runtime.
436fn tag_computation_block(body: &str, runtime: &str) -> (String, bool) {
437    let mut out_lines = Vec::new();
438    let mut in_computation_section = false;
439    let mut changed = false;
440    let mut in_code_block = false;
441
442    for line in body.lines() {
443        let trimmed = line.trim();
444        if trimmed.starts_with("# ") {
445            in_computation_section = trimmed == "# Computation";
446            out_lines.push(line.to_string());
447            continue;
448        }
449
450        if in_computation_section {
451            if trimmed == "```" {
452                if !in_code_block {
453                    // Opening untagged fence
454                    let leading_spaces = line.len() - line.trim_start().len();
455                    let indent = " ".repeat(leading_spaces);
456                    out_lines.push(format!("{indent}```{runtime}"));
457                    changed = true;
458                    in_code_block = true;
459                    continue;
460                }
461                in_code_block = false;
462            } else if trimmed == "~~~" {
463                if !in_code_block {
464                    let leading_spaces = line.len() - line.trim_start().len();
465                    let indent = " ".repeat(leading_spaces);
466                    out_lines.push(format!("{indent}~~~{runtime}"));
467                    changed = true;
468                    in_code_block = true;
469                    continue;
470                }
471                in_code_block = false;
472            }
473        }
474        out_lines.push(line.to_string());
475    }
476
477    (out_lines.join("\n"), changed)
478}
479
480/// Strips trailing whitespace on each line and collapses excess consecutive blank lines in markdown body.
481fn clean_body_whitespace(body: &str) -> (String, bool) {
482    let mut out_lines: Vec<String> = Vec::new();
483    let mut consecutive_empty = 0;
484
485    for line in body.lines() {
486        let trimmed_cr = line.trim_end_matches('\r');
487        let trimmed_end = trimmed_cr.trim_end();
488
489        if trimmed_end.is_empty() {
490            consecutive_empty += 1;
491            if consecutive_empty > 2 {
492                continue;
493            }
494            out_lines.push(String::new());
495        } else {
496            consecutive_empty = 0;
497            out_lines.push(trimmed_end.to_string());
498        }
499    }
500
501    while out_lines.last().is_some_and(String::is_empty) {
502        out_lines.pop();
503    }
504
505    let mut result = out_lines.join("\n");
506    if !result.is_empty() {
507        result.push('\n');
508    }
509
510    let normalized_input = if body.is_empty() {
511        String::new()
512    } else if body.ends_with('\n') {
513        body.to_string()
514    } else {
515        format!("{body}\n")
516    };
517
518    let changed = result != normalized_input;
519    (result, changed)
520}
521
522/// Remediates a parsed `log.md` file (consolidating duplicate date headings).
523#[must_use]
524pub fn remediate_log(text: &str, options: &FixOptions) -> (String, Vec<Remediation>) {
525    let log = Log::parse(text);
526    let mut remediations = Vec::new();
527
528    if !options.fix_log_duplicates {
529        return (text.to_string(), remediations);
530    }
531
532    let mut consolidated_days: Vec<LogDay> = Vec::new();
533    let mut seen_dates: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
534
535    for day in log.days {
536        if let Some(&existing_idx) = seen_dates.get(&day.date) {
537            consolidated_days[existing_idx].entries.extend(day.entries);
538            remediations.push(Remediation {
539                kind: RemediationKind::ConsolidatedLogDates(day.date.clone()),
540                description: format!("consolidated duplicate date headings for `## {}`", day.date),
541            });
542        } else {
543            seen_dates.insert(day.date.clone(), consolidated_days.len());
544            consolidated_days.push(day);
545        }
546    }
547
548    if remediations.is_empty() {
549        (text.to_string(), remediations)
550    } else {
551        let new_log = Log {
552            frontmatter: log.frontmatter,
553            title: log.title,
554            days: consolidated_days,
555        };
556        (new_log.to_markdown(), remediations)
557    }
558}
559
560/// Remediation report for a single file.
561#[derive(Clone, Debug, PartialEq, Eq)]
562pub struct FileFixReport {
563    /// File path.
564    pub path: PathBuf,
565    /// Remediations applied.
566    pub remediations: Vec<Remediation>,
567    /// Content before remediation.
568    pub original_content: String,
569    /// Content after remediation.
570    pub remediated_content: String,
571    /// Whether any content changed.
572    pub changed: bool,
573}
574
575/// Remediation report for a whole bundle.
576#[derive(Clone, Debug, Default, PartialEq, Eq)]
577pub struct BundleFixReport {
578    /// Bundle root directory.
579    pub bundle_root: PathBuf,
580    /// Reports for individual files.
581    pub files: Vec<FileFixReport>,
582    /// Index files that are out of sync or would be regenerated.
583    pub index_files_to_regenerate: Vec<PathBuf>,
584    /// Options used.
585    pub options: FixOptions,
586}
587
588impl BundleFixReport {
589    /// Writes all modified files to disk and regenerates index files if needed.
590    ///
591    /// Returns a tuple of `(files_written_count, regenerated_indexes)`.
592    ///
593    /// # Errors
594    ///
595    /// Returns [`io::Error`] if writing any file fails.
596    pub fn apply(&self) -> io::Result<(usize, Vec<PathBuf>)> {
597        let mut count = 0;
598        for file in &self.files {
599            if file.changed {
600                fs::write(&file.path, &file.remediated_content)?;
601                count += 1;
602            }
603        }
604        let regenerated = if self.options.regenerate_indexes
605            && (count > 0 || !self.index_files_to_regenerate.is_empty())
606        {
607            regenerate_indexes(&self.bundle_root)?
608        } else {
609            Vec::new()
610        };
611        Ok((count, regenerated))
612    }
613
614    /// Total number of remediation actions applied across all files.
615    #[must_use]
616    pub fn total_remediations(&self) -> usize {
617        self.files.iter().map(|f| f.remediations.len()).sum()
618    }
619
620    /// All files that were or would be modified.
621    pub fn changed_files(&self) -> impl Iterator<Item = &FileFixReport> {
622        self.files.iter().filter(|f| f.changed)
623    }
624
625    /// `true` if no files need fixes.
626    #[must_use]
627    pub fn is_empty(&self) -> bool {
628        self.files.iter().all(|f| !f.changed) && self.index_files_to_regenerate.is_empty()
629    }
630}
631
632/// Remediates a single file on disk.
633///
634/// # Errors
635///
636/// Returns [`io::Error`] on unreadable file or parse failures.
637pub fn remediate_file(path: impl AsRef<Path>, options: &FixOptions) -> io::Result<FileFixReport> {
638    let path = path.as_ref().to_path_buf();
639    let original = fs::read_to_string(&path)?;
640    let filename = path
641        .file_name()
642        .and_then(|n| n.to_str())
643        .unwrap_or_default();
644
645    if filename == "log.md" {
646        let (remediated_content, remediations) = remediate_log(&original, options);
647        let changed = remediated_content != original;
648        return Ok(FileFixReport {
649            path,
650            remediations,
651            original_content: original,
652            remediated_content,
653            changed,
654        });
655    }
656
657    if filename == "index.md" {
658        return Ok(FileFixReport {
659            path,
660            remediations: Vec::new(),
661            original_content: original.clone(),
662            remediated_content: original,
663            changed: false,
664        });
665    }
666
667    let doc = Document::parse(&original).map_err(|e| {
668        io::Error::new(
669            io::ErrorKind::InvalidData,
670            format!("could not parse {}: {e}", path.display()),
671        )
672    })?;
673    let stem = path.file_stem().and_then(|s| s.to_str());
674    let (remediated_doc, remediations) = remediate_document(&doc, stem, options);
675    let remediated_content = remediated_doc.serialize();
676    let changed = remediated_content != original || !remediations.is_empty();
677
678    Ok(FileFixReport {
679        path,
680        remediations,
681        original_content: original,
682        remediated_content,
683        changed,
684    })
685}
686
687/// Remediates an entire bundle directory tree.
688///
689/// # Errors
690///
691/// Returns [`io::Error`] on filesystem read errors.
692pub fn remediate_bundle(
693    bundle_root: impl AsRef<Path>,
694    options: &FixOptions,
695) -> io::Result<BundleFixReport> {
696    let bundle_root = bundle_root.as_ref().to_path_buf();
697    let mut files = Vec::new();
698    let mut md_paths = Vec::new();
699
700    collect_md_files(&bundle_root, &mut md_paths)?;
701    md_paths.sort();
702
703    for path in &md_paths {
704        let filename = path
705            .file_name()
706            .and_then(|n| n.to_str())
707            .unwrap_or_default();
708        if filename == "index.md" {
709            continue;
710        }
711        if let Ok(report) = remediate_file(path, options) {
712            files.push(report);
713        }
714    }
715
716    let mut index_files_to_regenerate = Vec::new();
717    if options.regenerate_indexes {
718        let any_file_changed = files.iter().any(|f| f.changed);
719        if any_file_changed {
720            let mut all_dirs = std::collections::BTreeSet::new();
721            for md in &md_paths {
722                if let Some(parent) = md.parent() {
723                    all_dirs.insert(parent.to_path_buf());
724                }
725            }
726            index_files_to_regenerate = all_dirs.into_iter().map(|d| d.join("index.md")).collect();
727        } else if let Ok(bundle) = crate::bundle::Bundle::load(&bundle_root) {
728            let mut all_dirs = std::collections::BTreeSet::new();
729            for c in bundle.concepts() {
730                if let Some(parent) = c.path.parent() {
731                    all_dirs.insert(parent.to_path_buf());
732                }
733            }
734            for dir in all_dirs {
735                let idx = dir.join("index.md");
736                if !idx.exists() {
737                    index_files_to_regenerate.push(idx);
738                }
739            }
740        }
741    }
742
743    Ok(BundleFixReport {
744        bundle_root,
745        files,
746        index_files_to_regenerate,
747        options: options.clone(),
748    })
749}
750
751fn collect_md_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
752    for entry in fs::read_dir(dir)? {
753        let entry = entry?;
754        let path = entry.path();
755        if path.is_dir() {
756            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
757            if !name.starts_with('.') && name != "target" && name != "node_modules" {
758                collect_md_files(&path, out)?;
759            }
760        } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
761            out.push(path);
762        }
763    }
764    Ok(())
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    #[test]
772    fn remediates_missing_title_and_generated_and_heading() {
773        let input = "---\ntype: Concept\n---\nBody text.\n";
774        let doc = Document::parse(input).unwrap();
775        let opts = FixOptions {
776            author: Some("human:alice".to_string()),
777            ..Default::default()
778        };
779
780        let (fixed_doc, remediations) = remediate_document(&doc, Some("revenue_stream"), &opts);
781
782        assert_eq!(
783            fixed_doc.frontmatter.title().as_deref(),
784            Some("Revenue Stream")
785        );
786        assert_eq!(
787            fixed_doc
788                .frontmatter
789                .generated()
790                .unwrap()
791                .by
792                .unwrap()
793                .as_str(),
794            "human:alice"
795        );
796        assert!(fixed_doc.body.starts_with("# Revenue Stream\n\n"));
797        assert_eq!(remediations.len(), 3);
798    }
799
800    #[test]
801    fn migrates_legacy_timestamp_and_citations() {
802        let input = "---\n\
803                     type: Concept\n\
804                     title: Revenue\n\
805                     timestamp: 2026-05-01T00:00:00Z\n\
806                     ---\n\n\
807                     # Definition\n\
808                     Defined in [1] and [2].\n\n\
809                     # Citations\n\
810                     [1] [GAAP Standards](https://example.com/gaap)\n\
811                     [2] https://example.com/sec\n";
812        let doc = Document::parse(input).unwrap();
813        let opts = FixOptions {
814            author: Some("human:bob".to_string()),
815            ..Default::default()
816        };
817
818        let (fixed_doc, remediations) = remediate_document(&doc, Some("revenue"), &opts);
819
820        assert!(fixed_doc.frontmatter.get("timestamp").is_none());
821        assert_eq!(
822            fixed_doc.frontmatter.generated().unwrap().at.unwrap().raw,
823            "2026-05-01T00:00:00Z"
824        );
825        assert_eq!(
826            fixed_doc
827                .frontmatter
828                .generated()
829                .unwrap()
830                .by
831                .unwrap()
832                .as_str(),
833            "human:bob"
834        );
835
836        let sources = fixed_doc.frontmatter.sources();
837        assert_eq!(sources.len(), 2);
838        assert_eq!(sources[0].id.as_deref(), Some("1"));
839        assert_eq!(
840            sources[0].resource.as_deref(),
841            Some("https://example.com/gaap")
842        );
843        assert_eq!(sources[0].title.as_deref(), Some("GAAP Standards"));
844
845        assert!(!fixed_doc.body.contains("# Citations"));
846        assert!(fixed_doc.body.contains("Defined in [^1] and [^2]."));
847        assert!(
848            remediations
849                .iter()
850                .any(|r| matches!(r.kind, RemediationKind::MigratedTimestamp))
851        );
852        assert!(
853            remediations
854                .iter()
855                .any(|r| matches!(r.kind, RemediationKind::MigratedCitations))
856        );
857    }
858
859    #[test]
860    fn tags_computation_code_block() {
861        let input = "---\n\
862                     type: Attested Computation\n\
863                     runtime: python\n\
864                     ---\n\n\
865                     # Computation\n\n\
866                     ```\n\
867                     def compute():\n\
868                         return 42\n\
869                     ```\n";
870        let doc = Document::parse(input).unwrap();
871        let opts = FixOptions::default();
872
873        let (fixed_doc, remediations) = remediate_document(&doc, Some("calc"), &opts);
874        assert!(fixed_doc.body.contains("```python\n"));
875        assert!(
876            remediations
877                .iter()
878                .any(|r| matches!(r.kind, RemediationKind::AddedComputationLanguage(_)))
879        );
880    }
881
882    #[test]
883    fn consolidates_duplicate_log_dates() {
884        let input = "# Update Log\n\n\
885                     ## 2026-06-01\n\
886                     * **Update**: Changed formula.\n\n\
887                     ## 2026-06-01\n\
888                     * **Creation**: Added concept.\n";
889        let (fixed, remediations) = remediate_log(input, &FixOptions::default());
890        assert_eq!(remediations.len(), 1);
891        assert_eq!(fixed.matches("## 2026-06-01").count(), 1);
892        assert!(fixed.contains("* **Update**: Changed formula."));
893        assert!(fixed.contains("* **Creation**: Added concept."));
894    }
895}