use std::path::PathBuf;
use std::sync::OnceLock;
use alint_core::{Context, Error, Level, Result, Rule, RuleSpec, Scope, Violation};
use jsonschema::Validator;
use serde::Deserialize;
use serde_json::Value;
use crate::structured_path::Format;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
schema_path: PathBuf,
#[serde(default)]
format: Option<String>,
}
#[derive(Debug)]
pub struct JsonSchemaPassesRule {
id: String,
level: Level,
policy_url: Option<String>,
message: Option<String>,
scope: Scope,
schema_path: PathBuf,
format_override: Option<Format>,
compiled: OnceLock<std::result::Result<Validator, String>>,
}
impl Rule for JsonSchemaPassesRule {
alint_core::rule_common_impl!();
fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let schema_abs = ctx.root.join(&self.schema_path);
let validator_res = self.compiled.get_or_init(|| compile_schema(&schema_abs));
let validator = match validator_res {
Ok(v) => v,
Err(msg) => {
violations.push(Violation::new(msg.clone()));
return Ok(violations);
}
};
for entry in ctx.index.files() {
if !self.scope.matches(&entry.path, ctx.index) {
continue;
}
let full = ctx.root.join(&entry.path);
let text = match crate::io::read_capped(&full) {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(crate::io::ReadCapError::TooLarge(n)) => {
violations.push(
Violation::new(format!(
"file is too large to analyze ({n} bytes; {} MiB cap)",
crate::io::MAX_ANALYZE_BYTES / (1024 * 1024),
))
.with_path(entry.path.clone()),
);
continue;
}
Err(crate::io::ReadCapError::Io(_)) => {
continue;
}
};
let Some(format) = self
.format_override
.or_else(|| Format::detect_from_path(&entry.path))
else {
violations.push(
Violation::new(
"could not detect format from extension; pass `format:` \
(`json` / `yaml` / `toml`) on the rule",
)
.with_path(entry.path.clone()),
);
continue;
};
let parsed = match format.parse(&text) {
Ok(v) => v,
Err(err) => {
violations.push(
Violation::new(format!("not a valid {} document: {err}", format.label()))
.with_path(entry.path.clone()),
);
continue;
}
};
for error in validator.iter_errors(&parsed) {
let detail = format!("schema violation at `{}`: {error}", error.instance_path());
let msg = self.message.clone().unwrap_or(detail);
violations.push(Violation::new(msg).with_path(entry.path.clone()));
}
}
Ok(violations)
}
}
fn compile_schema(schema_abs: &std::path::Path) -> std::result::Result<Validator, String> {
let bytes = crate::io::read_capped(schema_abs).map_err(|e| match e {
crate::io::ReadCapError::TooLarge(n) => format!(
"schema {} is too large to read ({n} bytes; {} MiB cap)",
schema_abs.display(),
crate::io::MAX_ANALYZE_BYTES / (1024 * 1024),
),
crate::io::ReadCapError::Io(e) => {
format!("could not read schema {}: {e}", schema_abs.display())
}
})?;
let schema_value: Value = serde_json::from_slice(&bytes)
.map_err(|e| format!("schema {} is not valid JSON: {e}", schema_abs.display()))?;
jsonschema::validator_for(&schema_value).map_err(|e| {
format!(
"schema {} is not a valid JSON Schema: {e}",
schema_abs.display()
)
})
}
pub fn build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
let _paths = spec.paths.as_ref().ok_or_else(|| {
Error::rule_config(&spec.id, "json_schema_passes requires a `paths` field")
})?;
let opts: Options = spec
.deserialize_options()
.map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
let format_override = match opts.format.as_deref() {
None => None,
Some("json") => Some(Format::Json),
Some("yaml" | "yml") => Some(Format::Yaml),
Some("toml") => Some(Format::Toml),
Some(other) => {
return Err(Error::rule_config(
&spec.id,
format!("unknown format `{other}`; expected json | yaml | toml"),
));
}
};
if spec.fix.is_some() {
return Err(Error::rule_config(
&spec.id,
"json_schema_passes has no fix op — alint can't synthesize correct content",
));
}
Ok(Box::new(JsonSchemaPassesRule {
id: spec.id.clone(),
level: spec.level,
policy_url: spec.policy_url.clone(),
message: spec.message.clone(),
scope: Scope::from_spec(spec)?,
schema_path: opts.schema_path,
format_override,
compiled: OnceLock::new(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn compile(schema: &Value) -> Validator {
jsonschema::validator_for(schema).unwrap()
}
#[test]
fn passing_value_produces_no_errors() {
let v = compile(&json!({
"type": "object",
"required": ["name"],
"properties": { "name": { "type": "string" } }
}));
let instance = json!({ "name": "alint" });
let errors: Vec<_> = v.iter_errors(&instance).collect();
assert!(errors.is_empty());
}
#[test]
fn missing_required_field_yields_error() {
let v = compile(&json!({
"type": "object",
"required": ["name"],
}));
let instance = json!({});
let errors: Vec<_> = v.iter_errors(&instance).collect();
assert_eq!(errors.len(), 1);
}
#[test]
fn type_mismatch_yields_error() {
let v = compile(&json!({
"type": "object",
"properties": { "n": { "type": "integer" } },
"required": ["n"]
}));
let instance = json!({ "n": "not an integer" });
let errors: Vec<_> = v.iter_errors(&instance).collect();
assert!(!errors.is_empty());
}
#[test]
fn yaml_value_round_trips_through_validator() {
let v = compile(&json!({
"type": "object",
"required": ["name"],
"properties": { "name": { "type": "string" } }
}));
let yaml = "name: from-yaml\n";
let instance = Format::Yaml.parse(yaml).unwrap();
let errors: Vec<_> = v.iter_errors(&instance).collect();
assert!(errors.is_empty());
}
#[test]
fn toml_value_round_trips_through_validator() {
let v = compile(&json!({
"type": "object",
"required": ["name"],
"properties": { "name": { "type": "string" } }
}));
let toml_text = "name = \"from-toml\"\n";
let instance = Format::Toml.parse(toml_text).unwrap();
let errors: Vec<_> = v.iter_errors(&instance).collect();
assert!(errors.is_empty());
}
#[test]
fn compile_fails_loudly_on_missing_file() {
let bogus = std::path::PathBuf::from("/nonexistent/schema.json");
let res = compile_schema(&bogus);
assert!(res.is_err());
assert!(res.unwrap_err().contains("could not read schema"));
}
#[test]
fn compile_fails_loudly_on_invalid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("schema.json");
std::fs::write(&path, "{ this is not json").unwrap();
let res = compile_schema(&path);
assert!(res.is_err());
assert!(res.unwrap_err().contains("not valid JSON"));
}
#[test]
fn compile_fails_loudly_on_invalid_schema() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("schema.json");
std::fs::write(&path, r#"{"type": 12345}"#).unwrap();
let res = compile_schema(&path);
assert!(res.is_err());
assert!(res.unwrap_err().contains("not a valid JSON Schema"));
}
#[test]
fn detect_from_path_handles_standard_extensions() {
assert_eq!(
Format::detect_from_path(std::path::Path::new("a.json")),
Some(Format::Json)
);
assert_eq!(
Format::detect_from_path(std::path::Path::new("a.yaml")),
Some(Format::Yaml)
);
assert_eq!(
Format::detect_from_path(std::path::Path::new("a.yml")),
Some(Format::Yaml)
);
assert_eq!(
Format::detect_from_path(std::path::Path::new("a.toml")),
Some(Format::Toml)
);
assert_eq!(
Format::detect_from_path(std::path::Path::new("a.txt")),
None
);
assert_eq!(
Format::detect_from_path(std::path::Path::new("Makefile")),
None
);
}
#[test]
fn scope_filter_narrows() {
use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
let (tmp, idx) = tempdir_with_files(&[
("schema.json", br#"{"type":"object","required":["x"]}"#),
("pkg/marker.lock", b""),
("pkg/bad.json", b"{}"),
("other/bad.json", b"{}"),
]);
let spec = spec_yaml(
"id: t\n\
kind: json_schema_passes\n\
paths: \"**/bad.json\"\n\
schema_path: schema.json\n\
scope_filter:\n \
has_ancestor: marker.lock\n\
level: warning\n",
);
let rule = build(&spec).unwrap();
let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
assert_eq!(v.len(), 1, "only in-scope file should fire: {v:?}");
assert_eq!(
v[0].path.as_deref(),
Some(std::path::Path::new("pkg/bad.json"))
);
}
}