use clap::Args;
use std::path::{Path, PathBuf};
use invariant_robotics::models::error::Validate;
use invariant_robotics::models::profile::{ProximityZone, RobotProfile, WorkspaceBounds};
#[derive(Args)]
pub struct ValidateProfilesArgs {
#[arg(long, value_name = "DIR")]
pub dir: Option<PathBuf>,
#[arg(long, default_value_t = false)]
pub strict: bool,
#[arg(long, default_value_t = false)]
pub verbose: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationOutcome {
Ok,
Fail { reason: String },
}
pub fn run(args: &ValidateProfilesArgs) -> i32 {
let results = match &args.dir {
Some(dir) => validate_dir(dir, args.strict),
None => validate_builtins(args.strict),
};
let mut failures = 0usize;
let total = results.len();
for (name, outcome) in &results {
match outcome {
ValidationOutcome::Ok => {
if args.verbose {
println!("OK {name}");
}
}
ValidationOutcome::Fail { reason } => {
println!("FAIL {name}: {reason}");
failures += 1;
}
}
}
println!(
"validate-profiles: {} OK, {} failed, {} total{}",
total - failures,
failures,
total,
if args.strict { " (strict mode)" } else { "" }
);
if failures == 0 {
0
} else {
1
}
}
fn validate_dir(dir: &Path, strict: bool) -> Vec<(String, ValidationOutcome)> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
out.push((
dir.display().to_string(),
ValidationOutcome::Fail {
reason: format!("cannot read directory: {e}"),
},
));
return out;
}
};
let mut paths: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.collect();
paths.sort();
for path in paths {
let name = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
out.push((name, validate_one_file(&path, strict)));
}
out
}
fn validate_one_file(path: &Path, strict: bool) -> ValidationOutcome {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
return ValidationOutcome::Fail {
reason: format!("read: {e}"),
}
}
};
let profile: RobotProfile = match serde_json::from_str(&text) {
Ok(p) => p,
Err(e) => {
return ValidationOutcome::Fail {
reason: format!("parse: {e}"),
}
}
};
classify(&profile, strict)
}
fn validate_builtins(strict: bool) -> Vec<(String, ValidationOutcome)> {
let mut out = Vec::new();
for name in invariant_robotics::profiles::list_builtins().iter() {
let outcome = match invariant_robotics::profiles::load_builtin(name) {
Ok(p) => classify(&p, strict),
Err(e) => ValidationOutcome::Fail {
reason: format!("load: {e}"),
},
};
out.push((name.to_string(), outcome));
}
out
}
fn classify(profile: &RobotProfile, strict: bool) -> ValidationOutcome {
if let Err(e) = profile.validate() {
return ValidationOutcome::Fail {
reason: format!("validate: {e}"),
};
}
if strict {
if let Err(reason) = strict_consistency(profile) {
return ValidationOutcome::Fail { reason };
}
}
ValidationOutcome::Ok
}
pub fn strict_consistency(profile: &RobotProfile) -> Result<(), String> {
let (ws_min, ws_max) = match &profile.workspace {
WorkspaceBounds::Aabb { min, max } => (*min, *max),
};
for axis in 0..3 {
if !ws_min[axis].is_finite() || !ws_max[axis].is_finite() || ws_min[axis] >= ws_max[axis] {
return Err(format!(
"workspace axis {axis}: min={} must be strictly less than max={}",
ws_min[axis], ws_max[axis]
));
}
}
let _ = ProximityZone::Sphere {
name: String::new(),
center: [0.0; 3],
radius: 0.0,
velocity_scale: 1.0,
dynamic: false,
};
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_builtins_non_strict_passes() {
let results = validate_builtins(false);
assert!(!results.is_empty(), "must validate at least one profile");
let failures: Vec<_> = results
.iter()
.filter(|(_, o)| !matches!(o, ValidationOutcome::Ok))
.collect();
assert!(
failures.is_empty(),
"non-strict mode must pass every built-in profile; failures: {failures:?}"
);
}
#[test]
fn strict_consistency_passes_franka_panda() {
let p = invariant_robotics::profiles::load_builtin("franka_panda").unwrap();
if let Err(e) = strict_consistency(&p) {
panic!("franka_panda must satisfy strict consistency, got: {e}");
}
}
#[test]
fn strict_rejects_degenerate_workspace() {
let mut p = invariant_robotics::profiles::load_builtin("franka_panda").unwrap();
match &mut p.workspace {
WorkspaceBounds::Aabb { min, max } => {
max[2] = min[2];
}
}
let err = strict_consistency(&p).unwrap_err();
assert!(err.contains("workspace axis 2"), "got: {err}");
}
#[test]
fn run_without_dir_uses_builtins() {
let args = ValidateProfilesArgs {
dir: None,
strict: false,
verbose: false,
};
assert_eq!(run(&args), 0);
}
#[test]
fn run_strict_succeeds_on_builtins() {
let args = ValidateProfilesArgs {
dir: None,
strict: true,
verbose: false,
};
assert_eq!(run(&args), 0);
}
#[test]
fn run_failing_dir_returns_one() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("broken.json"), b"{not json").unwrap();
let args = ValidateProfilesArgs {
dir: Some(dir.path().to_path_buf()),
strict: false,
verbose: false,
};
assert_eq!(run(&args), 1);
}
#[test]
fn run_empty_dir_returns_zero() {
let dir = tempfile::tempdir().unwrap();
let args = ValidateProfilesArgs {
dir: Some(dir.path().to_path_buf()),
strict: false,
verbose: false,
};
assert_eq!(run(&args), 0);
}
}