use std::collections::{BTreeMap, BTreeSet};
use crate::envfile;
use crate::schema::{self, LoadOptions};
use serde::Serialize;
#[derive(Serialize)]
struct DiffOutput {
file_a: String,
file_b: String,
only_in_a: Vec<KeyValue>,
only_in_b: Vec<KeyValue>,
different_values: Vec<ValueDiff>,
#[serde(skip_serializing_if = "Option::is_none")]
schema_compliance: Option<SchemaCompliance>,
identical: bool,
}
#[derive(Serialize)]
struct KeyValue {
key: String,
value: String,
}
#[derive(Serialize)]
struct ValueDiff {
key: String,
value_a: String,
value_b: String,
}
#[derive(Serialize)]
struct SchemaCompliance {
schema_path: String,
file_a: FileCompliance,
file_b: FileCompliance,
}
#[derive(Serialize)]
struct FileCompliance {
missing_required: Vec<String>,
unknown_keys: Vec<String>,
}
pub fn run(
env_a: &str,
env_b: &str,
schema_path: Option<&str>,
format: &str,
no_cache: bool,
verify_hash: Option<&str>,
ca_cert: Option<&str>,
) -> Result<(), String> {
let map_a = envfile::parse_env_file(env_a).map_err(|e| format!("Error reading {}: {}", env_a, e))?;
let map_b = envfile::parse_env_file(env_b).map_err(|e| format!("Error reading {}: {}", env_b, e))?;
let map_a: BTreeMap<String, String> = map_a.into_iter().collect();
let map_b: BTreeMap<String, String> = map_b.into_iter().collect();
let keys_a: BTreeSet<&String> = map_a.keys().collect();
let keys_b: BTreeSet<&String> = map_b.keys().collect();
let only_in_a: Vec<&String> = keys_a.difference(&keys_b).copied().collect();
let only_in_b: Vec<&String> = keys_b.difference(&keys_a).copied().collect();
let in_both: Vec<&String> = keys_a.intersection(&keys_b).copied().collect();
let mut different_values: Vec<(&String, &String, &String)> = Vec::new();
for key in &in_both {
let val_a = map_a.get(*key).unwrap();
let val_b = map_b.get(*key).unwrap();
if val_a != val_b {
different_values.push((key, val_a, val_b));
}
}
if format == "json" {
return output_json(env_a, env_b, &map_a, &map_b, &only_in_a, &only_in_b, &different_values, schema_path, no_cache, verify_hash, ca_cert);
}
if format != "text" {
return Err(format!("unknown format '{}'. Use 'text' or 'json'", format));
}
println!("Comparing {} vs {}\n", env_a, env_b);
let mut has_diff = false;
if !only_in_a.is_empty() {
has_diff = true;
println!("Only in {}:", env_a);
for key in &only_in_a {
let val = map_a.get(*key).unwrap();
println!(" + {}={}", key, truncate_value(val, 50));
}
println!();
}
if !only_in_b.is_empty() {
has_diff = true;
println!("Only in {}:", env_b);
for key in &only_in_b {
let val = map_b.get(*key).unwrap();
println!(" + {}={}", key, truncate_value(val, 50));
}
println!();
}
if !different_values.is_empty() {
has_diff = true;
println!("Different values:");
for (key, val_a, val_b) in &different_values {
println!(" {}:", key);
println!(" {} -> {}", truncate_value(val_a, 40), truncate_value(val_b, 40));
}
println!();
}
if let Some(schema_path) = schema_path {
let options = LoadOptions {
no_cache,
verify_hash: verify_hash.map(|s| s.to_string()),
ca_cert: ca_cert.map(|s| s.to_string()),
rate_limit_seconds: None,
};
match schema::load_schema_with_options(schema_path, &options) {
Ok(schema) => {
println!("Schema compliance ({}):", schema_path);
let missing_a = check_missing_required(&map_a, &schema);
let unknown_a = check_unknown_keys(&map_a, &schema);
let missing_b = check_missing_required(&map_b, &schema);
let unknown_b = check_unknown_keys(&map_b, &schema);
print!(" {}: ", env_a);
if missing_a.is_empty() && unknown_a.is_empty() {
println!("OK");
} else {
let mut issues = Vec::new();
if !missing_a.is_empty() {
issues.push(format!("{} missing required", missing_a.len()));
}
if !unknown_a.is_empty() {
issues.push(format!("{} unknown", unknown_a.len()));
}
println!("{}", issues.join(", "));
}
print!(" {}: ", env_b);
if missing_b.is_empty() && unknown_b.is_empty() {
println!("OK");
} else {
let mut issues = Vec::new();
if !missing_b.is_empty() {
issues.push(format!("{} missing required", missing_b.len()));
}
if !unknown_b.is_empty() {
issues.push(format!("{} unknown", unknown_b.len()));
}
println!("{}", issues.join(", "));
}
println!();
}
Err(e) => {
eprintln!("Warning: Could not load schema: {}", e);
}
}
}
if !has_diff {
println!("Files are identical.");
}
Ok(())
}
fn truncate_value(value: &str, max_len: usize) -> String {
let display = value.replace('\n', "\\n").replace('\r', "\\r");
if display.len() <= max_len {
display
} else {
format!("{}...", &display[..max_len])
}
}
fn check_missing_required(
env_map: &BTreeMap<String, String>,
schema: &schema::Schema,
) -> Vec<String> {
let mut missing = Vec::new();
for (key, spec) in schema.iter() {
if spec.required && spec.default.is_none() && !env_map.contains_key(key) {
missing.push(key.clone());
}
}
missing
}
fn check_unknown_keys(
env_map: &BTreeMap<String, String>,
schema: &schema::Schema,
) -> Vec<String> {
let mut unknown = Vec::new();
for key in env_map.keys() {
if !schema.contains_key(key) {
unknown.push(key.clone());
}
}
unknown
}
#[allow(clippy::too_many_arguments)]
fn output_json(
env_a: &str,
env_b: &str,
map_a: &BTreeMap<String, String>,
map_b: &BTreeMap<String, String>,
only_in_a: &[&String],
only_in_b: &[&String],
different_values: &[(&String, &String, &String)],
schema_path: Option<&str>,
no_cache: bool,
verify_hash: Option<&str>,
ca_cert: Option<&str>,
) -> Result<(), String> {
let has_diff = !only_in_a.is_empty() || !only_in_b.is_empty() || !different_values.is_empty();
let schema_compliance = if let Some(schema_path) = schema_path {
let options = LoadOptions {
no_cache,
verify_hash: verify_hash.map(|s| s.to_string()),
ca_cert: ca_cert.map(|s| s.to_string()),
rate_limit_seconds: None,
};
match schema::load_schema_with_options(schema_path, &options) {
Ok(schema) => {
Some(SchemaCompliance {
schema_path: schema_path.to_string(),
file_a: FileCompliance {
missing_required: check_missing_required(map_a, &schema),
unknown_keys: check_unknown_keys(map_a, &schema),
},
file_b: FileCompliance {
missing_required: check_missing_required(map_b, &schema),
unknown_keys: check_unknown_keys(map_b, &schema),
},
})
}
Err(_) => None,
}
} else {
None
};
let output = DiffOutput {
file_a: env_a.to_string(),
file_b: env_b.to_string(),
only_in_a: only_in_a.iter().map(|k| KeyValue {
key: (*k).clone(),
value: map_a.get(*k).unwrap().clone(),
}).collect(),
only_in_b: only_in_b.iter().map(|k| KeyValue {
key: (*k).clone(),
value: map_b.get(*k).unwrap().clone(),
}).collect(),
different_values: different_values.iter().map(|(k, va, vb)| ValueDiff {
key: (*k).clone(),
value_a: (*va).clone(),
value_b: (*vb).clone(),
}).collect(),
schema_compliance,
identical: !has_diff,
};
let json = serde_json::to_string_pretty(&output).map_err(|e| e.to_string())?;
println!("{}", json);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_temp_env(dir: &TempDir, name: &str, content: &str) -> String {
let path = dir.path().join(name);
fs::write(&path, content).unwrap();
path.to_string_lossy().to_string()
}
#[test]
fn test_truncate_value_short() {
assert_eq!(truncate_value("short", 10), "short");
}
#[test]
fn test_truncate_value_long() {
assert_eq!(truncate_value("this is a very long value", 10), "this is a ...");
}
#[test]
fn test_truncate_value_newlines() {
assert_eq!(truncate_value("line1\nline2", 20), "line1\\nline2");
}
#[test]
fn test_diff_identical_files() {
let dir = TempDir::new().unwrap();
let env_a = create_temp_env(&dir, "a.env", "FOO=bar\nBAZ=qux");
let env_b = create_temp_env(&dir, "b.env", "FOO=bar\nBAZ=qux");
let result = run(&env_a, &env_b, None, "text", false, None, None);
assert!(result.is_ok());
}
#[test]
fn test_diff_different_files() {
let dir = TempDir::new().unwrap();
let env_a = create_temp_env(&dir, "a.env", "FOO=bar\nONLY_A=value");
let env_b = create_temp_env(&dir, "b.env", "FOO=different\nONLY_B=value");
let result = run(&env_a, &env_b, None, "text", false, None, None);
assert!(result.is_ok());
}
#[test]
fn test_diff_with_schema() {
let dir = TempDir::new().unwrap();
let env_a = create_temp_env(&dir, "a.env", "FOO=bar");
let env_b = create_temp_env(&dir, "b.env", "FOO=bar\nBAZ=qux");
let schema = create_temp_env(&dir, "schema.json", r#"{"FOO": {"type": "string", "required": true}}"#);
let result = run(&env_a, &env_b, Some(&schema), "text", false, None, None);
assert!(result.is_ok());
}
#[test]
fn test_diff_missing_file() {
let result = run("nonexistent_a.env", "nonexistent_b.env", None, "text", false, None, None);
assert!(result.is_err());
}
#[test]
fn test_diff_json_format() {
let dir = TempDir::new().unwrap();
let env_a = create_temp_env(&dir, "a.env", "FOO=bar\nONLY_A=value");
let env_b = create_temp_env(&dir, "b.env", "FOO=different\nONLY_B=value");
let result = run(&env_a, &env_b, None, "json", false, None, None);
assert!(result.is_ok());
}
#[test]
fn test_diff_json_with_schema() {
let dir = TempDir::new().unwrap();
let env_a = create_temp_env(&dir, "a.env", "FOO=bar");
let env_b = create_temp_env(&dir, "b.env", "FOO=bar\nBAZ=qux");
let schema = create_temp_env(&dir, "schema.json", r#"{"FOO": {"type": "string", "required": true}}"#);
let result = run(&env_a, &env_b, Some(&schema), "json", false, None, None);
assert!(result.is_ok());
}
#[test]
fn test_diff_invalid_format() {
let dir = TempDir::new().unwrap();
let env_a = create_temp_env(&dir, "a.env", "FOO=bar");
let env_b = create_temp_env(&dir, "b.env", "FOO=bar");
let result = run(&env_a, &env_b, None, "xml", false, None, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("unknown format"));
}
}