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/Cargo.toml | 16 +++ validators/rust/README.md | 60 +++++++++++ validators/rust/src/main.rs | 249 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 validators/rust/Cargo.toml create mode 100644 validators/rust/README.md create mode 100644 validators/rust/src/main.rs (limited to 'validators') diff --git a/validators/rust/Cargo.toml b/validators/rust/Cargo.toml new file mode 100644 index 0000000..1515a60 --- /dev/null +++ b/validators/rust/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "gitinfo-validator" +version = "0.1.0" +edition = "2021" +description = "CLI validator for .gitinfo files" +license = "MIT" + +[[bin]] +name = "validate" +path = "src/main.rs" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +json_comments = "0.2" +regex = "1.10" diff --git a/validators/rust/README.md b/validators/rust/README.md new file mode 100644 index 0000000..ea072a2 --- /dev/null +++ b/validators/rust/README.md @@ -0,0 +1,60 @@ +# Rust Validator + +A Rust CLI tool for validating `.gitinfo` files. + +## Requirements + +- Rust 1.70+ (with Cargo) + +## Build + +```bash +cd validators/rust +cargo build --release +``` + +The binary will be at `target/release/validate` (or `validate.exe` on Windows). + +## Usage + +```bash +# Validate .gitinfo in current directory +./target/release/validate + +# Validate a specific file +./target/release/validate path/to/.gitinfo +``` + +Or run directly with Cargo: + +```bash +cargo run -- path/to/.gitinfo +``` + +## Features + +- Parses JSONC (strips `//` and `/* */` comments) +- Removes trailing commas (valid in JSONC, invalid in JSON) +- Validates against the gitinfo JSON Schema +- Checks types, formats (URI, email), and patterns +- Enforces `additionalProperties: false` +- Returns exit code 0 on success, 1 on failure +- Color-coded output (green for success, red for errors) + +## Example Output + +``` +✓ .gitinfo is valid +``` + +``` +Validation failed for .gitinfo: + - .root: invalid URI "not-a-url" + - root: unknown property "invalid_field" +``` + +## Dependencies + +- `serde` / `serde_json` - JSON parsing +- `json_comments` - JSONC comment stripping +- `regex` - Pattern matching for validation 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