use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::Serialize;
use crate::config::Severity;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Location {
#[serde(serialize_with = "serialize_path")]
pub file: PathBuf,
pub line: usize,
pub column: usize,
}
fn serialize_path<S: serde::Serializer>(path: &std::path::Path, out: S) -> Result<S::Ok, S::Error> {
let text = path.to_string_lossy();
if std::path::MAIN_SEPARATOR == '/' {
out.serialize_str(&text)
} else {
out.serialize_str(&text.replace(std::path::MAIN_SEPARATOR, "/"))
}
}
impl std::fmt::Display for Location {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}:{}", self.file.display(), self.line, self.column)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum UsageType {
Template,
Rust,
}
impl UsageType {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Template => "template",
Self::Rust => "rust",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct KeyUsage {
pub key: String,
pub at: Location,
pub kind: UsageType,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct KeyDefinition {
pub at: Location,
}
#[derive(Debug, Clone, Default)]
pub struct LocaleKeys {
pub locale: String,
pub keys: BTreeMap<String, Vec<KeyDefinition>>,
pub terms: std::collections::BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MissingKey {
pub key: String,
pub usages: Vec<KeyUsage>,
pub missing_in: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct InconsistentKey {
pub key: String,
pub present_in: Vec<String>,
pub missing_in: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DuplicateKey {
pub key: String,
pub locale: String,
pub definitions: Vec<KeyDefinition>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UnusedKey {
pub key: String,
pub locale: String,
pub definition: KeyDefinition,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ParseError {
pub at: Location,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Summary {
pub used: usize,
pub defined: usize,
pub defined_per_locale: BTreeMap<String, usize>,
pub locales: Vec<String>,
pub reference_locale: String,
pub files_scanned: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub schema_version: u32,
pub summary: Summary,
pub missing_keys: Vec<MissingKey>,
pub inconsistent_keys: Vec<InconsistentKey>,
pub duplicate_keys: Vec<DuplicateKey>,
pub unused_keys: Vec<UnusedKey>,
pub parse_errors: Vec<ParseError>,
#[serde(skip)]
pub unused_severity: Severity,
}
pub const SCHEMA_VERSION: u32 = 1;
impl Report {
#[must_use]
pub fn has_errors(&self) -> bool {
!self.missing_keys.is_empty()
|| !self.inconsistent_keys.is_empty()
|| !self.duplicate_keys.is_empty()
|| !self.parse_errors.is_empty()
|| (self.unused_severity == Severity::Error && !self.unused_keys.is_empty())
}
#[must_use]
pub fn has_warnings(&self) -> bool {
self.is_vacuous()
|| (self.unused_severity == Severity::Warn && !self.unused_keys.is_empty())
}
#[must_use]
pub fn is_vacuous(&self) -> bool {
self.summary.locales.is_empty()
}
#[must_use]
pub fn exit_code(&self) -> u8 {
u8::from(self.has_errors())
}
#[must_use]
pub fn finding_count(&self) -> usize {
self.missing_keys.len()
+ self.inconsistent_keys.len()
+ self.duplicate_keys.len()
+ self.unused_keys.len()
+ self.parse_errors.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn report(unused_severity: Severity, unused: usize) -> Report {
Report {
schema_version: SCHEMA_VERSION,
summary: Summary {
used: 0,
defined: 0,
defined_per_locale: BTreeMap::from([("en".to_owned(), 0)]),
locales: vec!["en".to_owned()],
reference_locale: "en".to_owned(),
files_scanned: 0,
},
missing_keys: Vec::new(),
inconsistent_keys: Vec::new(),
duplicate_keys: Vec::new(),
unused_keys: (0..unused)
.map(|i| UnusedKey {
key: format!("k{i}"),
locale: "en".to_owned(),
definition: KeyDefinition {
at: Location {
file: PathBuf::from("locales/en/a.ftl"),
line: 1,
column: 1,
},
},
})
.collect(),
parse_errors: Vec::new(),
unused_severity,
}
}
#[test]
fn clean_report_exits_zero() {
let r = report(Severity::Warn, 0);
assert!(!r.has_errors());
assert!(!r.has_warnings());
assert_eq!(r.exit_code(), 0);
}
#[test]
fn unused_severity_decides_the_exit_code() {
let warn = report(Severity::Warn, 3);
assert!(!warn.has_errors());
assert!(warn.has_warnings());
assert_eq!(warn.exit_code(), 0);
let deny = report(Severity::Error, 3);
assert!(deny.has_errors());
assert!(!deny.has_warnings());
assert_eq!(deny.exit_code(), 1);
let allow = report(Severity::Allow, 3);
assert!(!allow.has_errors());
assert!(!allow.has_warnings());
}
#[test]
fn a_run_with_no_locale_is_never_reported_as_clean() {
let mut r = report(Severity::Warn, 0);
r.summary.locales.clear();
assert!(r.is_vacuous());
assert!(r.has_warnings(), "an inapplicable run must not look clean");
assert!(!r.has_errors());
assert_eq!(r.exit_code(), 0);
}
#[test]
fn location_displays_as_file_line_column() {
let at = Location {
file: PathBuf::from("types/templates/home.html"),
line: 42,
column: 8,
};
assert_eq!(at.to_string(), "types/templates/home.html:42:8");
}
#[test]
fn a_serialized_path_always_uses_forward_slashes() {
let at = Location {
file: PathBuf::from("templates").join("home.html"),
line: 1,
column: 1,
};
let json = serde_json::to_value(&at).expect("the location serializes");
assert_eq!(json["file"], "templates/home.html");
}
}