Skip to main content

dotenv_space/commands/
diff.rs

1/// Diff command - compare .env and .env.example
2///
3/// Shows missing, extra, and different variables between two env files
4use anyhow::{Context, Result};
5use colored::*;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8
9use crate::core::Parser;
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct DiffResult {
13    pub missing: Vec<String>,
14    pub extra: Vec<String>,
15    pub different: Vec<DiffItem>,
16}
17
18#[derive(Debug, Serialize, Deserialize)]
19pub struct DiffItem {
20    pub key: String,
21    pub example_value: String,
22    pub env_value: String,
23}
24
25pub fn run(
26    env: String,
27    example: String,
28    show_values: bool,
29    format: String,
30    reverse: bool,
31    verbose: bool,
32) -> Result<()> {
33    if verbose {
34        println!("{}", "Running diff in verbose mode".dimmed());
35    }
36
37    println!(
38        "\n{}",
39        "┌─ Comparing .env ↔ .env.example ─────────────────────┐".cyan()
40    );
41    println!(
42        "{}\n",
43        "└──────────────────────────────────────────────────────┘".cyan()
44    );
45
46    let parser = Parser::default();
47
48    let env_file = parser
49        .parse_file(&env)
50        .with_context(|| format!("Failed to parse {}", env))?;
51
52    let example_file = parser
53        .parse_file(&example)
54        .with_context(|| format!("Failed to parse {}", example))?;
55
56    let (left, right, left_name, right_name) = if reverse {
57        (&example_file.vars, &env_file.vars, &example, &env)
58    } else {
59        (&env_file.vars, &example_file.vars, &env, &example)
60    };
61
62    let diff = compute_diff(left, right);
63
64    match format.as_str() {
65        "json" => output_json(&diff)?,
66        "patch" => output_patch(&diff, left, right)?,
67        _ => output_pretty(&diff, left, right, left_name, right_name, show_values)?,
68    }
69
70    Ok(())
71}
72
73fn compute_diff(left: &HashMap<String, String>, right: &HashMap<String, String>) -> DiffResult {
74    let left_keys: HashSet<_> = left.keys().cloned().collect();
75    let right_keys: HashSet<_> = right.keys().cloned().collect();
76
77    let missing: Vec<String> = right_keys.difference(&left_keys).cloned().collect();
78
79    let extra: Vec<String> = left_keys.difference(&right_keys).cloned().collect();
80
81    let mut different = Vec::new();
82    for key in left_keys.intersection(&right_keys) {
83        let left_val = left.get(key).unwrap();
84        let right_val = right.get(key).unwrap();
85
86        if left_val != right_val {
87            different.push(DiffItem {
88                key: key.clone(),
89                example_value: right_val.clone(),
90                env_value: left_val.clone(),
91            });
92        }
93    }
94
95    DiffResult {
96        missing,
97        extra,
98        different,
99    }
100}
101
102fn output_pretty(
103    diff: &DiffResult,
104    left: &HashMap<String, String>,
105    right: &HashMap<String, String>,
106    left_name: &str,
107    right_name: &str,
108    show_values: bool,
109) -> Result<()> {
110    let has_changes =
111        !diff.missing.is_empty() || !diff.extra.is_empty() || !diff.different.is_empty();
112
113    if !has_changes {
114        println!("{} Files are identical", "✓".green());
115        return Ok(());
116    }
117
118    if !diff.missing.is_empty() {
119        println!(
120            "{}",
121            format!("Missing from {} (present in {}):", left_name, right_name).bold()
122        );
123        for key in &diff.missing {
124            if show_values {
125                if let Some(val) = right.get(key) {
126                    println!("  {} {} = {}", "+".green(), key.bold(), val.dimmed());
127                }
128            } else {
129                println!("  {} {}", "+".green(), key.bold());
130            }
131        }
132        println!();
133    }
134
135    if !diff.extra.is_empty() {
136        println!(
137            "{}",
138            format!("Extra in {} (not in {}):", left_name, right_name).bold()
139        );
140        for key in &diff.extra {
141            if show_values {
142                if let Some(val) = left.get(key) {
143                    println!("  {} {} = {}", "-".red(), key.bold(), val.dimmed());
144                }
145            } else {
146                println!("  {} {}", "-".red(), key.bold());
147            }
148        }
149        println!();
150    }
151
152    if !diff.different.is_empty() {
153        println!("{}", "Different values:".bold());
154        for item in &diff.different {
155            println!("  {} {}", "~".yellow(), item.key.bold());
156            if show_values {
157                println!("    {}: {}", right_name, item.example_value.dimmed());
158                println!("    {}: {}", left_name, item.env_value.dimmed());
159            }
160        }
161        println!();
162    }
163
164    println!("{}", "Summary:".bold());
165    println!("  {} missing (add to {})", diff.missing.len(), left_name);
166    println!(
167        "  {} extra (consider removing or adding to {})",
168        diff.extra.len(),
169        right_name
170    );
171    println!("  {} different values", diff.different.len());
172
173    Ok(())
174}
175
176fn output_json(diff: &DiffResult) -> Result<()> {
177    let json = serde_json::to_string_pretty(diff)?;
178    println!("{}", json);
179    Ok(())
180}
181
182fn output_patch(
183    diff: &DiffResult,
184    left: &HashMap<String, String>,
185    right: &HashMap<String, String>,
186) -> Result<()> {
187    println!("# Add these to .env:");
188    for key in &diff.missing {
189        if let Some(val) = right.get(key) {
190            println!("+ {}={}", key, val);
191        }
192    }
193
194    println!("\n# Remove these from .env:");
195    for key in &diff.extra {
196        if let Some(val) = left.get(key) {
197            println!("- {}={}", key, val);
198        }
199    }
200
201    println!("\n# Update these in .env:");
202    for item in &diff.different {
203        println!("- {}={}", item.key, item.env_value);
204        println!("+ {}={}", item.key, item.example_value);
205    }
206
207    Ok(())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_compute_diff() {
216        let mut left = HashMap::new();
217        left.insert("KEY1".to_string(), "value1".to_string());
218        left.insert("KEY2".to_string(), "value2".to_string());
219        left.insert("EXTRA".to_string(), "extra".to_string());
220
221        let mut right = HashMap::new();
222        right.insert("KEY1".to_string(), "value1".to_string());
223        right.insert("KEY2".to_string(), "different".to_string());
224        right.insert("MISSING".to_string(), "missing".to_string());
225
226        let diff = compute_diff(&left, &right);
227
228        assert_eq!(diff.missing, vec!["MISSING"]);
229        assert_eq!(diff.extra, vec!["EXTRA"]);
230        assert_eq!(diff.different.len(), 1);
231        assert_eq!(diff.different[0].key, "KEY2");
232    }
233}