use crate::commands::common::OutputFormat;
use crate::commands::plan::{PlanOptions, build_plan_output};
use crate::error::{RailError, RailResult};
use crate::utils::fnv1a64;
use crate::workspace::WorkspaceContext;
use serde_json::Value;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
pub struct HashOptions {
pub since: Option<String>,
pub from: Option<String>,
pub to: Option<String>,
pub merge_base: bool,
pub confidence_profile: Option<String>,
pub format: OutputFormat,
}
pub fn run_hash(ctx: &WorkspaceContext, opts: HashOptions) -> RailResult<()> {
if opts.format.is_json_like() {
crate::output::set_json_mode(true);
}
let plan_opts = PlanOptions {
since: opts.since,
from: opts.from,
to: opts.to,
merge_base: opts.merge_base,
format: crate::commands::common::PlanOutputFormat::Json,
output: None,
explain: false,
confidence_profile: opts.confidence_profile,
};
let plan = build_plan_output(ctx, &plan_opts)?;
let plan_json = serde_json::to_value(&plan)
.map_err(|e| RailError::message(format!("failed to serialize plan for hashing: {}", e)))?;
let canonical = canonical_json(&plan_json);
let hash = format!("fnv1a64:{:016x}", fnv1a64(canonical.as_bytes()));
if opts.format.is_json() {
let payload = serde_json::json!({
"command": "hash",
"algorithm": "fnv1a64",
"hash": hash,
"inputs_fingerprint": plan.inputs.config_fingerprint.clone(),
"plan_contract_version": plan.plan_contract_version,
"refs": plan.inputs.refs,
});
let out = crate::output::machine_json_envelope("hash", "inspect", "success", 0, payload);
println!(
"{}",
serde_json::to_string_pretty(&out).map_err(|e| RailError::message(format!("failed to render JSON: {}", e)))?
);
} else {
println!("{}", hash);
}
Ok(())
}
pub fn run_diff_hash(a: PathBuf, b: PathBuf, format: OutputFormat) -> RailResult<()> {
if format.is_json_like() {
crate::output::set_json_mode(true);
}
let a_json = read_json_file(&a)?;
let b_json = read_json_file(&b)?;
let a_hash = format!("fnv1a64:{:016x}", fnv1a64(canonical_json(&a_json).as_bytes()));
let b_hash = format!("fnv1a64:{:016x}", fnv1a64(canonical_json(&b_json).as_bytes()));
let mut changes = Vec::new();
collect_diffs(&a_json, &b_json, "$", &mut changes, 64);
let equal = a_hash == b_hash;
if format.is_json() {
let payload = serde_json::json!({
"command": "diff-hash",
"a": a.display().to_string(),
"b": b.display().to_string(),
"hash_a": a_hash,
"hash_b": b_hash,
"equal": equal,
"changes": changes,
});
let out = crate::output::machine_json_envelope("diff-hash", "inspect", "success", 0, payload);
println!(
"{}",
serde_json::to_string_pretty(&out).map_err(|e| RailError::message(format!("failed to render JSON: {}", e)))?
);
} else if equal {
println!("hashes match");
println!(" {}", a_hash);
} else {
println!("hash mismatch");
println!(" a: {}", a_hash);
println!(" b: {}", b_hash);
if changes.is_empty() {
println!(" no structural diff paths found");
} else {
println!(" changed paths:");
for change in &changes {
println!(" {}", change);
}
}
}
Ok(())
}
fn read_json_file(path: &Path) -> RailResult<Value> {
let content = std::fs::read_to_string(path)
.map_err(|e| RailError::message(format!("failed to read '{}': {}", path.display(), e)))?;
serde_json::from_str(&content).map_err(|e| RailError::message(format!("invalid JSON '{}': {}", path.display(), e)))
}
fn canonical_json(value: &Value) -> String {
let canonical = canonicalize_value(value);
serde_json::to_string(&canonical).unwrap_or_else(|_| "null".to_string())
}
fn canonicalize_value(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
let mut out = serde_json::Map::new();
for key in keys {
if let Some(v) = map.get(key) {
out.insert(key.clone(), canonicalize_value(v));
}
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_value).collect()),
_ => value.clone(),
}
}
fn collect_diffs(a: &Value, b: &Value, path: &str, out: &mut Vec<String>, limit: usize) {
if out.len() >= limit {
return;
}
match (a, b) {
(Value::Object(a_map), Value::Object(b_map)) => {
let mut keys = BTreeSet::new();
keys.extend(a_map.keys().cloned());
keys.extend(b_map.keys().cloned());
for key in keys {
let child_path = format!("{}.{}", path, key);
match (a_map.get(&key), b_map.get(&key)) {
(Some(va), Some(vb)) => collect_diffs(va, vb, &child_path, out, limit),
(Some(_), None) => out.push(format!("{} removed", child_path)),
(None, Some(_)) => out.push(format!("{} added", child_path)),
(None, None) => {}
}
if out.len() >= limit {
return;
}
}
}
(Value::Array(a_arr), Value::Array(b_arr)) => {
if a_arr.len() != b_arr.len() {
out.push(format!("{} length {} -> {}", path, a_arr.len(), b_arr.len()));
}
let max = a_arr.len().min(b_arr.len());
for idx in 0..max {
collect_diffs(&a_arr[idx], &b_arr[idx], &format!("{}[{}]", path, idx), out, limit);
if out.len() >= limit {
return;
}
}
}
_ => {
if a != b {
out.push(path.to_string());
}
}
}
}