use std::path::Path;
use crate::model::{CaptureSpec, Step, TestFile};
use crate::parser;
pub mod tl001_positional_capture;
pub mod tl002_shared_list_capture;
pub mod tl003_weak_polling;
pub mod tl004_missing_status_on_mutation;
pub mod tl005_weak_status_assertion;
pub mod tl006_capture_without_body_assertion;
pub mod tl007_duplicate_test_name;
pub mod tl008_hardcoded_absolute_url;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warning,
Error,
}
impl Severity {
pub fn as_str(&self) -> &'static str {
match self {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
}
}
pub fn parse_threshold(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"error" => Some(Severity::Error),
"warning" | "warn" => Some(Severity::Warning),
"info" => Some(Severity::Info),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub rule_id: &'static str,
pub severity: Severity,
pub file: String,
pub line: Option<usize>,
pub column: Option<usize>,
pub step_path: Option<String>,
pub message: String,
pub hint: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct LintOptions {
pub allow_absolute_urls: bool,
}
pub(crate) fn walk_steps<'a>(file: &'a TestFile, file_path: &str) -> Vec<(String, &'a Step)> {
let mut out = Vec::new();
for step in &file.setup {
out.push((format!("{}::setup::{}", file_path, step.name), step));
}
for step in &file.steps {
out.push((format!("{}::{}", file_path, step.name), step));
}
for (test_name, group) in &file.tests {
for step in &group.steps {
out.push((format!("{}::{}::{}", file_path, test_name, step.name), step));
}
}
for step in &file.teardown {
out.push((format!("{}::teardown::{}", file_path, step.name), step));
}
out
}
pub(crate) fn capture_jsonpath(spec: &CaptureSpec) -> Option<&str> {
match spec {
CaptureSpec::JsonPath(s) => Some(s.as_str()),
CaptureSpec::Extended(ext) => ext.jsonpath.as_deref(),
}
}
pub(crate) fn finding_from_step(
rule_id: &'static str,
severity: Severity,
file: &str,
step_path: Option<String>,
step: &Step,
message: String,
hint: Option<String>,
) -> Finding {
Finding {
rule_id,
severity,
file: file.to_string(),
line: step.location.as_ref().map(|l| l.line),
column: step.location.as_ref().map(|l| l.column),
step_path,
message,
hint,
}
}
pub fn lint_file(file: &TestFile, path: &str, opts: &LintOptions) -> Vec<Finding> {
let mut findings = Vec::new();
findings.extend(tl001_positional_capture::lint(file, path));
findings.extend(tl002_shared_list_capture::lint(file, path));
findings.extend(tl003_weak_polling::lint(file, path));
findings.extend(tl004_missing_status_on_mutation::lint(file, path));
findings.extend(tl005_weak_status_assertion::lint(file, path));
findings.extend(tl006_capture_without_body_assertion::lint(file, path));
findings.extend(tl007_duplicate_test_name::lint(file, path));
findings.extend(tl008_hardcoded_absolute_url::lint(file, path, opts));
findings.sort_by(|a, b| {
a.line
.unwrap_or(0)
.cmp(&b.line.unwrap_or(0))
.then_with(|| a.rule_id.cmp(b.rule_id))
});
findings
}
pub fn lint_path(path: &Path, opts: &LintOptions) -> Result<Vec<Finding>, String> {
let source = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let file = parser::parse_str(&source, path).map_err(|e| e.to_string())?;
Ok(lint_file(&file, &path.display().to_string(), opts))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_ordering_lets_threshold_filter_correctly() {
assert!(Severity::Error > Severity::Warning);
assert!(Severity::Warning > Severity::Info);
}
#[test]
fn parse_threshold_accepts_canonical_forms() {
assert_eq!(Severity::parse_threshold("error"), Some(Severity::Error));
assert_eq!(
Severity::parse_threshold("WARNING"),
Some(Severity::Warning)
);
assert_eq!(Severity::parse_threshold("warn"), Some(Severity::Warning));
assert_eq!(Severity::parse_threshold("info"), Some(Severity::Info));
assert_eq!(Severity::parse_threshold("nonsense"), None);
}
}