From cad0b0a3664211960dfe6e81ff9afe91934a0eba Mon Sep 17 00:00:00 2001 From: "Alex Pooley (@zuedev)" Date: Thu, 5 Feb 2026 16:39:19 +0000 Subject: add rust validator --- validators/rust/src/main.rs | 249 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 validators/rust/src/main.rs (limited to 'validators/rust/src') diff --git a/validators/rust/src/main.rs b/validators/rust/src/main.rs new file mode 100644 index 0000000..5807da3 --- /dev/null +++ b/validators/rust/src/main.rs @@ -0,0 +1,249 @@ +use json_comments::StripComments; +use regex::Regex; +use serde_json::Value; +use std::collections::HashSet; +use std::env; +use std::fs; +use std::io::Read; +use std::path::Path; +use std::process; + +const RED: &str = "\x1b[0;31m"; +const GREEN: &str = "\x1b[0;32m"; +const NC: &str = "\x1b[0m"; + +fn main() { + let args: Vec = env::args().collect(); + let file_path = args.get(1).map(|s| s.as_str()).unwrap_or(".gitinfo"); + + // Find schema path (two levels up from validators/rust/) + let exe_path = env::current_exe().unwrap_or_default(); + let schema_path = exe_path + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .map(|p| p.join("gitinfo.schema.json")) + .unwrap_or_else(|| { + // Fallback: look relative to current directory + Path::new("../../gitinfo.schema.json").to_path_buf() + }); + + // Also try current working directory relative paths + let schema_path = if schema_path.exists() { + schema_path + } else { + let cwd_relative = Path::new("gitinfo.schema.json"); + if cwd_relative.exists() { + cwd_relative.to_path_buf() + } else { + // Try from validators/rust/ + Path::new("../../gitinfo.schema.json").to_path_buf() + } + }; + + if !Path::new(file_path).exists() { + eprintln!("{}Error: File not found: {}{}", RED, file_path, NC); + process::exit(1); + } + + if !schema_path.exists() { + eprintln!( + "{}Error: Schema not found: {}{}", + RED, + schema_path.display(), + NC + ); + process::exit(1); + } + + // Read and parse schema + let schema_content = match fs::read_to_string(&schema_path) { + Ok(c) => c, + Err(e) => { + eprintln!("{}Error reading schema: {}{}", RED, e, NC); + process::exit(1); + } + }; + let schema: Value = match serde_json::from_str(&schema_content) { + Ok(v) => v, + Err(e) => { + eprintln!("{}Error parsing schema: {}{}", RED, e, NC); + process::exit(1); + } + }; + + // Read and parse .gitinfo file (with JSONC comment stripping) + let file_content = match fs::read_to_string(file_path) { + Ok(c) => c, + Err(e) => { + eprintln!("{}Error reading file: {}{}", RED, e, NC); + process::exit(1); + } + }; + + // Strip comments and trailing commas + let stripped = StripComments::new(file_content.as_bytes()); + let mut json_str = String::new(); + std::io::BufReader::new(stripped) + .read_to_string(&mut json_str) + .unwrap(); + + // Remove trailing commas (JSONC allows them, JSON doesn't) + let trailing_comma_re = Regex::new(r",(\s*[}\]])").unwrap(); + let json_str = trailing_comma_re.replace_all(&json_str, "$1"); + + let data: Value = match serde_json::from_str(&json_str) { + Ok(v) => v, + Err(e) => { + eprintln!("{}Error parsing JSONC: {}{}", RED, e, NC); + process::exit(1); + } + }; + + // Validate + let errors = validate(&data, &schema); + + if !errors.is_empty() { + eprintln!("{}Validation failed for {}:{}", RED, file_path, NC); + for error in &errors { + eprintln!(" - {}", error); + } + process::exit(1); + } + + println!("{}✓ {} is valid{}", GREEN, file_path, NC); +} + +fn validate(data: &Value, schema: &Value) -> Vec { + let mut errors = Vec::new(); + + // Check if root is an object + if !data.is_object() { + errors.push("root: expected object".to_string()); + return errors; + } + + let data_obj = data.as_object().unwrap(); + let properties = schema + .get("properties") + .and_then(|p| p.as_object()) + .unwrap(); + + // Check additionalProperties + if schema.get("additionalProperties") == Some(&Value::Bool(false)) { + let allowed: HashSet<&str> = properties.keys().map(|k| k.as_str()).collect(); + for key in data_obj.keys() { + if !allowed.contains(key.as_str()) { + errors.push(format!("root: unknown property \"{}\"", key)); + } + } + } + + // Validate each property + for (key, prop_schema) in properties { + if let Some(value) = data_obj.get(key) { + validate_property(&mut errors, &format!(".{}", key), value, prop_schema); + } + } + + errors +} + +fn validate_property(errors: &mut Vec, path: &str, value: &Value, schema: &Value) { + let expected_type = schema.get("type").and_then(|t| t.as_str()); + + match expected_type { + Some("string") => { + if !value.is_string() { + errors.push(format!("{}: expected string", path)); + return; + } + let s = value.as_str().unwrap(); + + // Check format + if let Some(format) = schema.get("format").and_then(|f| f.as_str()) { + match format { + "uri" => { + if !is_valid_uri(s) { + errors.push(format!("{}: invalid URI \"{}\"", path, s)); + } + } + "email" => { + if !is_valid_email(s) { + errors.push(format!("{}: invalid email \"{}\"", path, s)); + } + } + _ => {} + } + } + + // Check pattern + if let Some(pattern) = schema.get("pattern").and_then(|p| p.as_str()) { + if let Ok(re) = Regex::new(pattern) { + if !re.is_match(s) { + errors.push(format!("{}: does not match pattern {}", path, pattern)); + } + } + } + + // Check minLength + if let Some(min_len) = schema.get("minLength").and_then(|m| m.as_u64()) { + if (s.len() as u64) < min_len { + errors.push(format!("{}: string too short (min {})", path, min_len)); + } + } + } + Some("array") => { + if !value.is_array() { + errors.push(format!("{}: expected array", path)); + return; + } + let arr = value.as_array().unwrap(); + + // Validate items + if let Some(items_schema) = schema.get("items") { + if items_schema.is_array() { + // Tuple validation + let items_schemas = items_schema.as_array().unwrap(); + for (i, item) in arr.iter().enumerate() { + if let Some(item_schema) = items_schemas.get(i) { + validate_property(errors, &format!("{}[{}]", path, i), item, item_schema); + } + } + // Check minItems/maxItems + if let Some(min) = schema.get("minItems").and_then(|m| m.as_u64()) { + if (arr.len() as u64) < min { + errors.push(format!("{}: expected at least {} items", path, min)); + } + } + if let Some(max) = schema.get("maxItems").and_then(|m| m.as_u64()) { + if (arr.len() as u64) > max { + errors.push(format!("{}: expected at most {} items", path, max)); + } + } + } else { + // Array of same type + for (i, item) in arr.iter().enumerate() { + validate_property(errors, &format!("{}[{}]", path, i), item, items_schema); + } + } + } + } + Some("object") => { + if !value.is_object() { + errors.push(format!("{}: expected object", path)); + } + } + _ => {} + } +} + +fn is_valid_uri(s: &str) -> bool { + s.starts_with("http://") || s.starts_with("https://") || s.starts_with("data:image/") +} + +fn is_valid_email(s: &str) -> bool { + let re = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap(); + re.is_match(s) +} -- cgit v1.2.3