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}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct Remediation {
61 pub kind: RemediationKind,
63 pub description: String,
65}
66
67#[allow(clippy::struct_excessive_bools)]
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct FixOptions {
71 pub author: Option<String>,
73 pub add_missing_title: bool,
75 pub add_missing_generated: bool,
77 pub add_missing_top_heading: bool,
79 pub migrate_legacy_timestamp: bool,
81 pub migrate_legacy_citations: bool,
83 pub reorder_keys: bool,
85 pub tag_computation_blocks: bool,
87 pub fix_log_duplicates: bool,
89 pub clean_whitespace: bool,
91 pub regenerate_indexes: bool,
93}
94
95impl FixOptions {
96 #[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#[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 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 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 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 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 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 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 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 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
331fn 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
351fn 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 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
396fn 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 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
435fn 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 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
480fn 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#[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#[derive(Clone, Debug, PartialEq, Eq)]
562pub struct FileFixReport {
563 pub path: PathBuf,
565 pub remediations: Vec<Remediation>,
567 pub original_content: String,
569 pub remediated_content: String,
571 pub changed: bool,
573}
574
575#[derive(Clone, Debug, Default, PartialEq, Eq)]
577pub struct BundleFixReport {
578 pub bundle_root: PathBuf,
580 pub files: Vec<FileFixReport>,
582 pub index_files_to_regenerate: Vec<PathBuf>,
584 pub options: FixOptions,
586}
587
588impl BundleFixReport {
589 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 #[must_use]
616 pub fn total_remediations(&self) -> usize {
617 self.files.iter().map(|f| f.remediations.len()).sum()
618 }
619
620 pub fn changed_files(&self) -> impl Iterator<Item = &FileFixReport> {
622 self.files.iter().filter(|f| f.changed)
623 }
624
625 #[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
632pub 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
687pub 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}