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    /// Normalized non-compliant date/datetime string to explicit ISO-8601 UTC format.
57    NormalizedDate(String),
58    /// Quoted unquoted `okf_version` in root `index.md` (L13).
59    QuotedOkfVersion,
60}
61
62/// A single remediation action applied to a document or file.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct Remediation {
65    /// The rule or action kind.
66    pub kind: RemediationKind,
67    /// A human-readable description of the fix applied.
68    pub description: String,
69}
70
71/// Options controlling automated remediation and migration.
72#[allow(clippy::struct_excessive_bools)]
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct FixOptions {
75    /// Default author attribution string (e.g. "human:alice").
76    pub author: Option<String>,
77    /// Whether to insert missing titles (L1).
78    pub add_missing_title: bool,
79    /// Whether to insert missing `generated` blocks (L3).
80    pub add_missing_generated: bool,
81    /// Whether to insert missing top-level `# Heading` (L8).
82    pub add_missing_top_heading: bool,
83    /// Whether to migrate legacy v0.1 timestamp (L5).
84    pub migrate_legacy_timestamp: bool,
85    /// Whether to migrate legacy v0.1 Citations (L6).
86    pub migrate_legacy_citations: bool,
87    /// Whether to normalize date/datetime strings lacking explicit UTC offset.
88    pub normalize_date_formats: bool,
89    /// Whether to reorder frontmatter keys canonically (L18).
90    pub reorder_keys: bool,
91    /// Whether to tag unlabeled computation blocks (L26).
92    pub tag_computation_blocks: bool,
93    /// Whether to consolidate duplicate date headings in log.md (L27).
94    pub fix_log_duplicates: bool,
95    /// Whether to strip trailing whitespace and excess blank lines (L8).
96    pub clean_whitespace: bool,
97    /// Whether to regenerate index files (L16).
98    pub regenerate_indexes: bool,
99    /// Whether to quote unquoted `okf_version` in root index.md (L13).
100    pub quote_okf_version: bool,
101}
102
103impl FixOptions {
104    /// Creates options that only fix strict spec conformance and migration issues (for `okf validate --fix`).
105    #[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/// Remediates and migrates a single [`Document`].
146///
147/// Returns the updated document and the list of applied remediations.
148#[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    // 1. Missing title (L1)
159    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    // 2. Legacy timestamp (L5)
177    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    // 3. Missing generated (L3)
207    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    // 4. Legacy citations (L6)
226    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    // 5. Missing top heading (L8)
278    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    // 6. Unlabeled computation block (L26)
298    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
490/// Checks if frontmatter keys deviate from the canonical preferred order.
491fn 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
510/// Removes the legacy `# Citations` section and converts citation references `[n]` to `[^n]`.
511fn 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    // Trim trailing empty lines, keeping a clean single trailing newline
545    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
555/// Replaces `[n]` citation markers with footnote markers `[^n]` in prose lines.
556fn 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            // Check if preceded by `^` or `!`
564            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
594/// Tags untagged code blocks under `# Computation` with the given runtime.
595fn 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                    // Opening untagged fence
613                    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
639/// Strips trailing whitespace on each line and collapses excess consecutive blank lines in markdown body.
640fn 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/// Remediates a parsed `log.md` file (consolidating duplicate date headings).
682#[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/// Remediation report for a single file.
720#[derive(Clone, Debug, PartialEq, Eq)]
721pub struct FileFixReport {
722    /// File path.
723    pub path: PathBuf,
724    /// Remediations applied.
725    pub remediations: Vec<Remediation>,
726    /// Content before remediation.
727    pub original_content: String,
728    /// Content after remediation.
729    pub remediated_content: String,
730    /// Whether any content changed.
731    pub changed: bool,
732}
733
734/// Remediation report for a whole bundle.
735#[derive(Clone, Debug, Default, PartialEq, Eq)]
736pub struct BundleFixReport {
737    /// Bundle root directory.
738    pub bundle_root: PathBuf,
739    /// Reports for individual files.
740    pub files: Vec<FileFixReport>,
741    /// Index files that are out of sync or would be regenerated.
742    pub index_files_to_regenerate: Vec<PathBuf>,
743    /// Options used.
744    pub options: FixOptions,
745}
746
747impl BundleFixReport {
748    /// Writes all modified files to disk and regenerates index files if needed.
749    ///
750    /// Returns a tuple of `(files_written_count, regenerated_indexes)`.
751    ///
752    /// # Errors
753    ///
754    /// Returns [`io::Error`] if writing any file fails.
755    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    /// Total number of remediation actions applied across all files.
774    #[must_use]
775    pub fn total_remediations(&self) -> usize {
776        self.files.iter().map(|f| f.remediations.len()).sum()
777    }
778
779    /// All files that were or would be modified.
780    pub fn changed_files(&self) -> impl Iterator<Item = &FileFixReport> {
781        self.files.iter().filter(|f| f.changed)
782    }
783
784    /// `true` if no files need fixes.
785    #[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
791/// Remediates a single file on disk.
792///
793/// # Errors
794///
795/// Returns [`io::Error`] on unreadable file or parse failures.
796pub 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
863/// Remediates an entire bundle directory tree.
864///
865/// # Errors
866///
867/// Returns [`io::Error`] on filesystem read errors.
868pub 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}