///|
pub(all) struct ProjectMutantResult {
  mutation : ProjectMutation
  outcome : MutantOutcome
  detail : String
} derive(Debug, Eq, ToJson)

///|
pub(all) struct FileRunReport {
  file : String
  total : Int
  killed : Int
  survived : Int
  compile_error : Int
  timeout : Int
  skipped : Int
  equivalent : Int
  score_percent : Int
} derive(Debug, Eq, ToJson)

///|
pub(all) struct ProjectRunReport {
  file_count : Int
  mutation_count : Int
  killed : Int
  survived : Int
  compile_error : Int
  timeout : Int
  skipped : Int
  equivalent : Int
  score_percent : Int
  files : Array[FileRunReport]
  results : Array[ProjectMutantResult]
} derive(Debug, Eq, ToJson)

///|
pub fn project_result(
  mutation : ProjectMutation,
  outcome : MutantOutcome,
  detail? : String = "",
) -> ProjectMutantResult {
  { mutation, outcome, detail }
}

///|
pub fn classify_project_sequence(
  mutation : ProjectMutation,
  commands : ArrayView[CommandResult],
) -> ProjectMutantResult {
  let classified = classify_sequence(mutation.candidate, commands)
  project_result(mutation, classified.outcome, detail=classified.detail)
}

///|
pub fn summarize_project_run(
  plan : ProjectMutationPlan,
  results : ArrayView[ProjectMutantResult],
) -> ProjectRunReport {
  let files : Array[FileRunReport] = []
  for file in plan.files {
    files.push(summarize_file_run(file.file, results))
  }
  let totals = summarize_project_totals(results)
  {
    file_count: plan.file_count,
    mutation_count: plan.mutation_count,
    killed: totals.killed,
    survived: totals.survived,
    compile_error: totals.compile_error,
    timeout: totals.timeout,
    skipped: totals.skipped,
    equivalent: totals.equivalent,
    score_percent: totals.score_percent,
    files,
    results: results.to_owned(),
  }
}

///|
pub fn mutation_risk_level(report : ProjectRunReport) -> String {
  if report.survived > 0 || report.score_percent < 50 {
    "high"
  } else if report.compile_error > 0 ||
    report.timeout > 0 ||
    report.skipped > 0 ||
    report.score_percent < 80 {
    "medium"
  } else {
    "low"
  }
}

///|
pub fn file_risk_level(file : FileRunReport) -> String {
  let scored = file.killed + file.survived
  if file.survived > 0 || (scored > 0 && file.score_percent < 50) {
    "high"
  } else if file.compile_error > 0 ||
    file.timeout > 0 ||
    file.skipped > 0 ||
    (scored > 0 && file.score_percent < 80) {
    "medium"
  } else {
    "low"
  }
}

///|
pub fn risk_ranked_files(report : ProjectRunReport) -> Array[FileRunReport] {
  let files = [ for file in report.files => file ]
  for i in 0.. file_risk_score(files[i]) {
        let tmp = files[i]
        files[i] = files[j]
        files[j] = tmp
      }
    }
  }
  files
}

///|
pub fn mutation_risk_summary(report : ProjectRunReport) -> String {
  match mutation_risk_level(report) {
    "high" =>
      "high: survived mutants or very low score indicate weak assertions"
    "medium" =>
      "medium: review compile errors, timeouts, skipped mutants, or score gaps"
    _ =>
      "low: selected mutants were killed and the run produced a useful signal"
  }
}

///|
fn file_risk_score(file : FileRunReport) -> Int {
  let scored = file.killed + file.survived
  let score_gap = if scored == 0 { 0 } else { 100 - file.score_percent }
  file.survived * 10000 +
  score_gap * 100 +
  file.compile_error * 50 +
  file.timeout * 50 +
  file.skipped * 25
}

///|
pub fn diagnose_mutant_result(result : ProjectMutantResult) -> String {
  let candidate = result.mutation.candidate
  match candidate.rule.label {
    "eq-to-ne" | "ne-to-eq" =>
      "add assertions for both equal and non-equal cases around this branch"
    "and-to-or" | "or-to-and" =>
      "add tests where each side of the boolean expression independently changes the result"
    "true-to-false" | "false-to-true" =>
      "assert the observable behavior of this boolean decision, not only that the path runs"
    "add-to-sub" | "sub-to-add" | "mul-to-div" | "div-to-mul" =>
      "assert exact numeric results and include boundary values for this calculation"
    "lt-to-le" | "le-to-lt" | "gt-to-ge" | "ge-to-gt" =>
      "add boundary tests for values exactly at and just outside this comparison limit"
    "zero-to-one" | "one-to-zero" | "minus-one-to-zero" =>
      "add tests for numeric sentinel and boundary values near this literal"
    _ =>
      "add a focused assertion that would fail if this replacement changed behavior"
  }
}

///|
pub fn format_survived_diagnostics(report : ProjectRunReport) -> String {
  let lines : Array[String] = ["Survived mutant diagnostics"]
  for result in report.results {
    if result.outcome == Survived {
      let mutation = result.mutation
      let candidate = mutation.candidate
      lines.push(
        "- #\{mutation.global_id} \{mutation.file}:\{candidate.span.line} " +
        "\{candidate.rule.label} `\{candidate.original}` -> " +
        "`\{candidate.replacement}`: \{diagnose_mutant_result(result)}",
      )
    }
  }
  if lines.length() == 1 {
    lines.push("none")
  }
  lines.join("\n")
}

///|
pub fn format_html_run_report(report : ProjectRunReport) -> String {
  let lines : Array[String] = [
    "",
    "",
    "",
    "",
    "",
    "moon_mutest report",
    html_styles(),
    "",
    "",
    "
", "
", "

moon_mutest

", "

Mutation Testing Report

", "

\{escape_html(mutation_risk_summary(report))}

", "
", "
", html_metric_card( "Score", "\{report.score_percent}%", mutation_risk_level(report), ), html_metric_card("Killed", report.killed.to_string(), "low"), html_metric_card( "Survived", report.survived.to_string(), if report.survived > 0 { "high" } else { "low" }, ), html_metric_card("Files", report.file_count.to_string(), "neutral"), "
", "
", "

Overall Signal

\{mutation_risk_level(report)}
", "
", "", "", html_pair_row("Mutations", report.mutation_count.to_string()), html_pair_row("Executed results", report.results.length().to_string()), html_pair_row("Compile errors", report.compile_error.to_string()), html_pair_row("Timeouts", report.timeout.to_string()), html_pair_row("Skipped", report.skipped.to_string()), html_pair_row("Equivalent", report.equivalent.to_string()), "", "
", "
", ] append_html_file_ranking(lines, report) append_html_survived_diagnostics(lines, report) append_html_mutant_results(lines, report) lines.push("
") lines.push("") lines.push("") lines.join("\n") } ///| fn summarize_file_run( file : String, results : ArrayView[ProjectMutantResult], ) -> FileRunReport { let file_results = [ for result in results if result.mutation.file == file => result ] let totals = summarize_project_totals(file_results) { file, total: file_results.length(), killed: totals.killed, survived: totals.survived, compile_error: totals.compile_error, timeout: totals.timeout, skipped: totals.skipped, equivalent: totals.equivalent, score_percent: totals.score_percent, } } ///| fn summarize_project_totals( results : ArrayView[ProjectMutantResult], ) -> MutationRunReport { let mut killed = 0 let mut survived = 0 let mut compile_error = 0 let mut timeout = 0 let mut skipped = 0 let mut equivalent = 0 for result in results { match result.outcome { Killed => killed += 1 Survived => survived += 1 CompileError => compile_error += 1 Timeout => timeout += 1 Skipped => skipped += 1 Equivalent => equivalent += 1 } } let scored = killed + survived { file: "", total: results.length(), killed, survived, compile_error, timeout, skipped, equivalent, score_percent: if scored == 0 { 0 } else { killed * 100 / scored }, } } ///| pub fn format_markdown_project_plan(plan : ProjectMutationPlan) -> String { let lines : Array[String] = [ "# Mutation Plan", "", "| Metric | Value |", "| --- | ---: |", "| Files | \{plan.file_count} |", "| Mutations | \{plan.mutation_count} |", "", "## Files", "", "| File | Candidates | ID Range |", "| --- | ---: | --- |", ] for file in plan.files { lines.push( "| \{file.file} | \{file.summary.candidate_count} | " + "\{file.start_id}..\{file.end_id} |", ) } lines.push("") lines.push("## Mutations") lines.push("") lines.push("| ID | File | Line | Rule | Change |") lines.push("| ---: | --- | ---: | --- | --- |") for mutation in plan.mutations { lines.push( "| \{mutation.global_id} | \{mutation.file} | " + "\{mutation.candidate.span.line} | \{mutation.candidate.rule.label} | " + "`\{mutation.candidate.original}` -> `\{mutation.candidate.replacement}` |", ) } lines.join("\n") } ///| fn append_html_file_ranking( lines : Array[String], report : ProjectRunReport, ) -> Unit { lines.push("
") lines.push("

File Risk Ranking

") lines.push("") lines.push( "", ) lines.push("") for index, file in risk_ranked_files(report) { let risk = file_risk_level(file) lines.push( "" + "" + "" + "", ) } if report.files.is_empty() { lines.push("") } lines.push("") lines.push("
#FileRiskTotalKilledSurvivedScore
\{index + 1}\{escape_html(file.file)}\{risk}\{file.total}\{file.killed}\{file.survived}\{file.score_percent}%
No files were reported.
") lines.push("
") } ///| fn append_html_survived_diagnostics( lines : Array[String], report : ProjectRunReport, ) -> Unit { lines.push("
") lines.push("

Survived Diagnostics

") if report.survived == 0 { lines.push( "

No survived mutants in the selected run.

", ) } else { lines.push("
    ") for result in report.results { if result.outcome == Survived { let mutation = result.mutation let candidate = mutation.candidate lines.push( "
  • #\{mutation.global_id} " + "\{escape_html(mutation.file)}:\{candidate.span.line} " + "\{escape_html(candidate.rule.label)} " + "\{escape_html(candidate.original)} -> " + "\{escape_html(candidate.replacement)}
    " + "\{escape_html(diagnose_mutant_result(result))}
  • ", ) } } lines.push("
") } lines.push("
") } ///| fn append_html_mutant_results( lines : Array[String], report : ProjectRunReport, ) -> Unit { lines.push("
") lines.push("

Mutant Results

") lines.push("") lines.push( "", ) lines.push("") for result in report.results { let mutation = result.mutation let candidate = mutation.candidate lines.push( "" + "" + "" + "" + "" + "" + "", ) } if report.results.is_empty() { lines.push("") } lines.push("") lines.push("
IDOutcomeFileLineRuleChangeDetail
\{mutation.global_id}\{escape_html(outcome_label(result.outcome))}\{escape_html(mutation.file)}\{candidate.span.line}\{escape_html(candidate.rule.label)}\{escape_html(candidate.original)} -> \{escape_html(candidate.replacement)}\{escape_html(result.detail)}
No mutants were executed.
") lines.push("
") } ///| fn html_metric_card(label : String, value : String, risk : String) -> String { "
\{escape_html(label)}\{escape_html(value)}\{escape_html(risk)}
" } ///| fn html_pair_row(label : String, value : String) -> String { "\{escape_html(label)}\{escape_html(value)}" } ///| fn html_percent(value : Int) -> Int { if value < 0 { 0 } else if value > 100 { 100 } else { value } } ///| fn outcome_html_class(outcome : MutantOutcome) -> String { match outcome { Killed => "low" Survived => "high" CompileError | Timeout | Skipped => "medium" Equivalent => "neutral" } } ///| fn escape_html(text : String) -> String { text .replace(old="&", new="&") .replace(old="<", new="<") .replace(old=">", new=">") .replace(old="\"", new=""") } ///| fn html_styles() -> String { ( #| ) } ///| pub fn format_markdown_run_report(report : ProjectRunReport) -> String { let lines : Array[String] = [ "# Mutation Testing Report", "", "| Metric | Value |", "| --- | ---: |", "| Files | \{report.file_count} |", "| Mutations | \{report.mutation_count} |", "| Killed | \{report.killed} |", "| Survived | \{report.survived} |", "| Compile errors | \{report.compile_error} |", "| Timeouts | \{report.timeout} |", "| Skipped | \{report.skipped} |", "| Equivalent | \{report.equivalent} |", "| Score | \{report.score_percent}% |", "| Risk | \{mutation_risk_level(report)} |", "", "## File Summary", "", "| File | Total | Killed | Survived | Score |", "| --- | ---: | ---: | ---: | ---: |", ] for file in report.files { lines.push( "| \{file.file} | \{file.total} | \{file.killed} | " + "\{file.survived} | \{file.score_percent}% |", ) } lines.push("") lines.push("## Mutant Results") lines.push("") lines.push("| ID | Outcome | File | Line | Rule | Detail |") lines.push("| ---: | --- | --- | ---: | --- | --- |") for result in report.results { let mutation = result.mutation lines.push( "| \{mutation.global_id} | \{outcome_label(result.outcome)} | " + "\{mutation.file} | \{mutation.candidate.span.line} | " + "\{mutation.candidate.rule.label} | \{escape_markdown_cell(result.detail)} |", ) } lines.push("") lines.push("## Survived Diagnostics") lines.push("") for result in report.results { if result.outcome == Survived { let mutation = result.mutation let candidate = mutation.candidate lines.push( "- `#\{mutation.global_id}` \{mutation.file}:\{candidate.span.line} " + "\{candidate.rule.label}: \{escape_markdown_cell(diagnose_mutant_result(result))}", ) } } if report.survived == 0 { lines.push("- none") } lines.join("\n") } ///| fn escape_markdown_cell(text : String) -> String { if text == "" { "" } else { text.replace(old="|", new="\\|").replace(old="\n", new=" ") } }