///|
fn json_escape(s : String) -> String {
  let mut out = ""
  for c in s {
    match c {
      '"' => out = out + "\\\""
      '\\' => out = out + "\\\\"
      '\n' => out = out + "\\n"
      '\r' => out = out + "\\r"
      '\t' => out = out + "\\t"
      _ => out = out + c.to_string()
    }
  }
  out
}

///|
fn html_escape(s : String) -> String {
  let mut out = ""
  for c in s {
    match c {
      '&' => out = out + "&"
      '<' => out = out + "<"
      '>' => out = out + ">"
      '"' => out = out + """
      _ => out = out + c.to_string()
    }
  }
  out
}

///|
fn finding_json(f : Finding) -> String {
  let formula = match f.formula {
    Some(text) => "\"formula\":\"\{json_escape(text)}\""
    None => "\"formula\":null"
  }
  let related = "[" + f.related.map(fn(x) { "\"" + json_escape(x) + "\"" }).join(",") + "]"
  "{\"rule_id\":\"\{f.rule_id}\",\"severity\":\"\{f.severity.label()}\",\"sheet\":\"\{json_escape(f.sheet)}\",\"cell\":\"\{json_escape(f.cell)}\",\{formula},\"message\":\"\{json_escape(f.message)}\",\"related\":\{related}}"
}

///|
pub fn to_json(report : AuditReport) -> String {
  let findings = "[" + report.findings.map(finding_json).join(",") + "]"
  let suppressed = "[" + report.suppressed.map(finding_json).join(",") + "]"
  let sheets = "[" + report.sheets.map(fn(x) { "\"" + json_escape(x) + "\"" }).join(",") + "]"
  "{\"tool\":\"sheetsentry\",\"version\":\"\{tool_version()}\",\"file\":\"\{json_escape(report.file)}\",\"sheets\":\{sheets},\"formula_cells\":\{report.formula_cells},\"findings\":\{findings},\"suppressed\":\{suppressed}}"
}

///|
fn sarif_level(severity : Severity) -> String {
  match severity {
    High => "error"
    Medium => "warning"
    Low => "note"
  }
}

///|
fn sarif_result(f : Finding) -> String {
  let related = match f.related {
    [] => ""
    list => {
      let refs = "[" + list.map(fn(x) { "{\"logicalLocations\":[{\"fullyQualifiedName\":\"" + json_escape(x) + "\"}]}" }).join(",") + "]"
      ",\"relatedLocations\":\{refs}"
    }
  }
  "{\"ruleId\":\"\{f.rule_id}\",\"level\":\"\{sarif_level(f.severity)}\",\"message\":{\"text\":\"\{json_escape(f.message)}\"},\"locations\":[{\"logicalLocations\":[{\"name\":\"\{json_escape(f.sheet)}!\{json_escape(f.cell)}\",\"fullyQualifiedName\":\"\{json_escape(f.sheet)}!\{json_escape(f.cell)}\",\"kind\":\"member\"}]}]\{related}}"
}

///|
pub fn to_sarif(report : AuditReport) -> String {
  let results = "[" + report.findings.map(sarif_result).join(",") + "]"
  let rules = "[{\"id\":\"SS001\",\"name\":\"ErrorValue\",\"shortDescription\":{\"text\":\"Cell contains an Excel error value\"}},{\"id\":\"SS002\",\"name\":\"FormulaInconsistency\",\"shortDescription\":{\"text\":\"Formula differs from its row or column pattern\"}},{\"id\":\"SS003\",\"name\":\"HardcodedConstant\",\"shortDescription\":{\"text\":\"Formula embeds a numeric constant\"}},{\"id\":\"SS004\",\"name\":\"VolatileFunction\",\"shortDescription\":{\"text\":\"Formula uses a volatile function\"}},{\"id\":\"SS005\",\"name\":\"HiddenFormula\",\"shortDescription\":{\"text\":\"Formula lives on a hidden sheet or row\"}},{\"id\":\"SS006\",\"name\":\"CircularReference\",\"shortDescription\":{\"text\":\"Custom dependency cycle\"}},{\"id\":\"SS007\",\"name\":\"DynamicReference\",\"shortDescription\":{\"text\":\"Formula uses INDIRECT/OFFSET\"}},{\"id\":\"SS008\",\"name\":\"ExternalWorkbook\",\"shortDescription\":{\"text\":\"Formula references an external workbook\"}}]"
  "{\"$schema\":\"https://json.schemastore.org/sarif-2.1.0.json\",\"version\":\"2.1.0\",\"runs\":[{\"tool\":{\"driver\":{\"name\":\"SheetSentry\",\"version\":\"\{tool_version()}\",\"informationUri\":\"https://github.com/mhlagent/sheetsentry\",\"rules\":\{rules}}},\"results\":\{results}}]}"
}

///|
fn severity_class(severity : Severity) -> String {
  match severity {
    High => "high"
    Medium => "medium"
    Low => "low"
  }
}

///|
fn same_severity(a : Severity, b : Severity) -> Bool {
  match (a, b) {
    (High, High) => true
    (Medium, Medium) => true
    (Low, Low) => true
    _ => false
  }
}

///|
fn count_severity(report : AuditReport, severity : Severity) -> Int {
  let mut count = 0
  for finding in report.findings {
    if same_severity(finding.severity, severity) {
      count = count + 1
    }
  }
  count
}

///|
pub fn to_html(report : AuditReport) -> String {
  let mut rows = ""
  for finding in report.findings {
    let formula = match finding.formula {
      Some(text) => html_escape(text)
      None => ""
    }
    let related = finding.related.map(fn(x) { html_escape(x) }).join(", ")
    rows = rows + "\{finding.rule_id}\{finding.severity.label()}\{html_escape(finding.sheet)}!\{html_escape(finding.cell)}\{html_escape(finding.message)}\{formula}\{related}"
  }
  let high = count_severity(report, High)
  let medium = count_severity(report, Medium)
  let low = count_severity(report, Low)
  let sheet_count = report.sheets.length()
  "SheetSentry report

SheetSentry report

\{html_escape(report.file)} · \{sheet_count} sheets · \{report.formula_cells} formula cells
\{report.findings.length()}findings
\{high}high
\{medium}medium
\{low}low
\{rows}
RuleSeverityCellMessageFormulaRelated
" } ///| pub fn to_terminal(report : AuditReport) -> String { let mut out = "SheetSentry: \{report.file}\n" out = out + " sheets: \{report.sheets.length()} formula cells: \{report.formula_cells} findings: \{report.findings.length()}\n" out = out + " high: \{count_severity(report, High)} medium: \{count_severity(report, Medium)} low: \{count_severity(report, Low)} suppressed: \{report.suppressed.length()}\n" for finding in report.findings { out = out + " [\{finding.severity.label()}] \{finding.rule_id} \{finding.sheet}!\{finding.cell} \{finding.message}\n" } if report.findings.length() == 0 { out = out + " No findings.\n" } out } ///| pub fn rule_catalog() -> String { "SS001 high Excel error value\nSS002 medium Formula differs from row/column pattern\nSS003 medium Hardcoded numeric constant\nSS004 low Volatile function\nSS005 medium Formula lives on hidden sheet/row\nSS006 high Circular reference\nSS007 low Dynamic reference (INDIRECT/OFFSET)\nSS008 medium External workbook reference" }