sherpack_kube/
diff.rs

1//! Diff engine for comparing releases and detecting cluster drift
2//!
3//! ## Key Features
4//!
5//! - **Release comparison**: Compare two release manifests (like `helm diff`)
6//! - **Drift detection**: Compare release manifest with actual cluster state
7//! - **Three-way merge**: Compare desired vs last-applied vs live state
8//! - **Server-managed field filtering**: Ignore K8s-managed fields like `resourceVersion`
9//! - **Structured output**: Color-coded diffs with context
10//!
11//! ## Addressing Helm Frustrations
12//!
13//! This implementation fixes several known issues with Helm's diff:
14//!
15//! 1. **helm diff only compares revisions** - We support live cluster comparison
16//! 2. **managedFields noise** - We filter server-managed fields automatically
17//! 3. **No three-way merge** - We support comparing desired/last-applied/live
18//! 4. **Poor output** - We provide grouped, color-coded, contextual diffs
19//!
20//! ## Example
21//!
22//! ```ignore
23//! let engine = DiffEngine::new();
24//!
25//! // Compare two releases
26//! let diff = engine.diff_releases(&old_release, &new_release);
27//!
28//! // Detect drift from cluster state
29//! let drift = engine.detect_drift(&release, &client).await?;
30//!
31//! // Three-way comparison
32//! let three_way = engine.three_way_diff(&desired, &last_applied, &live);
33//! ```
34
35use kube::api::{Api, DynamicObject, GroupVersionKind, ListParams};
36use kube::discovery::{ApiResource, Scope};
37use kube::{Client, Discovery};
38use serde::{Deserialize, Serialize};
39use serde_json::Value as JsonValue;
40use similar::{ChangeTag, TextDiff};
41use std::collections::{BTreeMap, HashMap, HashSet};
42
43use crate::error::{KubeError, Result};
44use crate::release::StoredRelease;
45
46/// Fields to strip when normalizing resources for comparison
47/// These are server-managed and not part of the desired state
48const SERVER_MANAGED_FIELDS: &[&str] = &[
49    "metadata.managedFields",
50    "metadata.resourceVersion",
51    "metadata.uid",
52    "metadata.generation",
53    "metadata.creationTimestamp",
54    "metadata.selfLink",
55    "metadata.deletionTimestamp",
56    "metadata.deletionGracePeriodSeconds",
57    "metadata.ownerReferences", // Often set by controllers
58];
59
60/// Annotations to strip when comparing (set by kubectl/helm)
61const IGNORED_ANNOTATIONS: &[&str] = &[
62    "kubectl.kubernetes.io/last-applied-configuration",
63    "deployment.kubernetes.io/revision",
64    "meta.helm.sh/release-name",
65    "meta.helm.sh/release-namespace",
66];
67
68/// Labels to optionally ignore when comparing
69const OPTIONALLY_IGNORED_LABELS: &[&str] = &["app.kubernetes.io/managed-by", "helm.sh/chart"];
70
71/// Diff engine for release comparison and drift detection
72pub struct DiffEngine {
73    /// Show context lines around changes
74    pub context_lines: usize,
75    /// Ignore status fields entirely
76    pub ignore_status: bool,
77    /// Additional JSON paths to ignore
78    pub ignore_paths: HashSet<String>,
79    /// Ignore label differences for managed-by labels
80    pub ignore_management_labels: bool,
81}
82
83impl DiffEngine {
84    /// Create a new diff engine with default settings
85    pub fn new() -> Self {
86        Self {
87            context_lines: 3,
88            ignore_status: true,
89            ignore_paths: HashSet::new(),
90            ignore_management_labels: true,
91        }
92    }
93
94    /// Set the number of context lines
95    pub fn with_context(mut self, lines: usize) -> Self {
96        self.context_lines = lines;
97        self
98    }
99
100    /// Include status fields in comparison
101    pub fn include_status(mut self) -> Self {
102        self.ignore_status = false;
103        self
104    }
105
106    /// Add a JSON path to ignore
107    pub fn ignore_path(mut self, path: &str) -> Self {
108        self.ignore_paths.insert(path.to_string());
109        self
110    }
111
112    /// Compare two releases
113    pub fn diff_releases(&self, old: &StoredRelease, new: &StoredRelease) -> DiffResult {
114        let old_resources = parse_manifest_resources(&old.manifest);
115        let new_resources = parse_manifest_resources(&new.manifest);
116
117        let mut changes = Vec::new();
118
119        // Find added and modified resources
120        for (key, new_content) in &new_resources {
121            match old_resources.get(key) {
122                Some(old_content) => {
123                    // Normalize both for comparison
124                    let old_normalized = self.normalize_resource(old_content);
125                    let new_normalized = self.normalize_resource(new_content);
126
127                    if old_normalized != new_normalized {
128                        changes.push(ResourceChange {
129                            kind: key.kind.clone(),
130                            api_version: key.api_version.clone(),
131                            name: key.name.clone(),
132                            namespace: key.namespace.clone(),
133                            change_type: ChangeType::Modified,
134                            diff: Some(self.compute_text_diff(&old_normalized, &new_normalized)),
135                            is_drift: false,
136                            source: DiffSource::ReleaseComparison,
137                        });
138                    }
139                }
140                None => {
141                    changes.push(ResourceChange {
142                        kind: key.kind.clone(),
143                        api_version: key.api_version.clone(),
144                        name: key.name.clone(),
145                        namespace: key.namespace.clone(),
146                        change_type: ChangeType::Added,
147                        diff: Some(DiffContent::new_addition(new_content)),
148                        is_drift: false,
149                        source: DiffSource::ReleaseComparison,
150                    });
151                }
152            }
153        }
154
155        // Find removed resources
156        for (key, old_content) in &old_resources {
157            if !new_resources.contains_key(key) {
158                changes.push(ResourceChange {
159                    kind: key.kind.clone(),
160                    api_version: key.api_version.clone(),
161                    name: key.name.clone(),
162                    namespace: key.namespace.clone(),
163                    change_type: ChangeType::Removed,
164                    diff: Some(DiffContent::new_removal(old_content)),
165                    is_drift: false,
166                    source: DiffSource::ReleaseComparison,
167                });
168            }
169        }
170
171        // Sort changes for consistent output
172        changes.sort_by(|a, b| {
173            a.kind
174                .cmp(&b.kind)
175                .then_with(|| a.namespace.cmp(&b.namespace))
176                .then_with(|| a.name.cmp(&b.name))
177        });
178
179        DiffResult {
180            old_version: old.version,
181            new_version: new.version,
182            changes,
183            has_drift: false,
184        }
185    }
186
187    /// Compare a release manifest with actual cluster state
188    ///
189    /// This addresses the Helm frustration where `helm diff` only compares
190    /// revisions, not actual cluster state. Manual changes (drift) are detected.
191    pub async fn detect_drift(
192        &self,
193        release: &StoredRelease,
194        client: &Client,
195    ) -> Result<DiffResult> {
196        let manifest_resources = parse_manifest_resources(&release.manifest);
197        let mut changes = Vec::new();
198
199        // Discover available APIs
200        let discovery = Discovery::new(client.clone())
201            .run()
202            .await
203            .map_err(|e| KubeError::Storage(format!("API discovery failed: {}", e)))?;
204
205        for (key, manifest_content) in &manifest_resources {
206            match self
207                .fetch_live_resource(client, &discovery, key, &release.namespace)
208                .await
209            {
210                Ok(Some(live_yaml)) => {
211                    // Normalize both for comparison
212                    let manifest_normalized = self.normalize_resource(manifest_content);
213                    let live_normalized = self.normalize_resource(&live_yaml);
214
215                    if manifest_normalized != live_normalized {
216                        changes.push(ResourceChange {
217                            kind: key.kind.clone(),
218                            api_version: key.api_version.clone(),
219                            name: key.name.clone(),
220                            namespace: key.namespace.clone(),
221                            change_type: ChangeType::Modified,
222                            diff: Some(
223                                self.compute_text_diff(&manifest_normalized, &live_normalized),
224                            ),
225                            is_drift: true, // This is drift - cluster differs from release
226                            source: DiffSource::ClusterDrift,
227                        });
228                    }
229                }
230                Ok(None) => {
231                    // Resource exists in manifest but not in cluster
232                    changes.push(ResourceChange {
233                        kind: key.kind.clone(),
234                        api_version: key.api_version.clone(),
235                        name: key.name.clone(),
236                        namespace: key.namespace.clone(),
237                        change_type: ChangeType::Missing,
238                        diff: Some(DiffContent::new_removal(manifest_content)),
239                        is_drift: true,
240                        source: DiffSource::ClusterDrift,
241                    });
242                }
243                Err(e) => {
244                    // API error - might be a deprecated API or permissions issue
245                    changes.push(ResourceChange {
246                        kind: key.kind.clone(),
247                        api_version: key.api_version.clone(),
248                        name: key.name.clone(),
249                        namespace: key.namespace.clone(),
250                        change_type: ChangeType::Unknown,
251                        diff: None,
252                        is_drift: false,
253                        source: DiffSource::Error(e.to_string()),
254                    });
255                }
256            }
257        }
258
259        // Check for extra resources in cluster that aren't in manifest
260        // (resources that might have been added manually)
261        let extra_resources = self
262            .find_extra_cluster_resources(client, &discovery, release, &manifest_resources)
263            .await?;
264
265        for (key, live_yaml) in extra_resources {
266            changes.push(ResourceChange {
267                kind: key.kind.clone(),
268                api_version: key.api_version.clone(),
269                name: key.name.clone(),
270                namespace: key.namespace.clone(),
271                change_type: ChangeType::Extra,
272                diff: Some(DiffContent::new_addition(&live_yaml)),
273                is_drift: true,
274                source: DiffSource::ClusterDrift,
275            });
276        }
277
278        // Sort changes
279        changes.sort_by(|a, b| {
280            a.kind
281                .cmp(&b.kind)
282                .then_with(|| a.namespace.cmp(&b.namespace))
283                .then_with(|| a.name.cmp(&b.name))
284        });
285
286        let has_drift = changes.iter().any(|c| c.is_drift);
287
288        Ok(DiffResult {
289            old_version: release.version,
290            new_version: release.version,
291            changes,
292            has_drift,
293        })
294    }
295
296    /// Three-way diff: desired vs last-applied vs live
297    ///
298    /// This provides the most complete picture:
299    /// - What you want to apply (desired)
300    /// - What was last applied (stored release)
301    /// - What's actually in the cluster (live)
302    ///
303    /// Returns both "what will change" and "what has drifted"
304    pub async fn three_way_diff(
305        &self,
306        desired_manifest: &str,
307        last_applied: &StoredRelease,
308        client: &Client,
309    ) -> Result<ThreeWayDiffResult> {
310        let desired_resources = parse_manifest_resources(desired_manifest);
311        let last_applied_resources = parse_manifest_resources(&last_applied.manifest);
312
313        let discovery = Discovery::new(client.clone())
314            .run()
315            .await
316            .map_err(|e| KubeError::Storage(format!("API discovery failed: {}", e)))?;
317
318        let mut changes = Vec::new();
319
320        // Collect all unique resource keys
321        let mut all_keys: HashSet<&ResourceKey> = HashSet::new();
322        all_keys.extend(desired_resources.keys());
323        all_keys.extend(last_applied_resources.keys());
324
325        for key in all_keys {
326            let desired = desired_resources.get(key);
327            let last = last_applied_resources.get(key);
328            let live = self
329                .fetch_live_resource(client, &discovery, key, &last_applied.namespace)
330                .await
331                .ok()
332                .flatten();
333
334            let change = self.compute_three_way_change(key, desired, last, live.as_deref());
335            if let Some(c) = change {
336                changes.push(c);
337            }
338        }
339
340        // Sort changes
341        changes.sort_by(|a, b| {
342            a.kind
343                .cmp(&b.kind)
344                .then_with(|| a.namespace.cmp(&b.namespace))
345                .then_with(|| a.name.cmp(&b.name))
346        });
347
348        let has_pending_changes = changes.iter().any(|c| {
349            matches!(
350                c.change_type,
351                ChangeType::Added | ChangeType::Modified | ChangeType::Removed
352            )
353        });
354        let has_drift = changes.iter().any(|c| c.is_drift);
355
356        Ok(ThreeWayDiffResult {
357            changes,
358            has_pending_changes,
359            has_drift,
360        })
361    }
362
363    /// Compute a three-way change for a single resource
364    fn compute_three_way_change(
365        &self,
366        key: &ResourceKey,
367        desired: Option<&String>,
368        last_applied: Option<&String>,
369        live: Option<&str>,
370    ) -> Option<ResourceChange> {
371        match (desired, last_applied, live) {
372            // New resource (in desired, not in last or live)
373            (Some(d), None, None) => Some(ResourceChange {
374                kind: key.kind.clone(),
375                api_version: key.api_version.clone(),
376                name: key.name.clone(),
377                namespace: key.namespace.clone(),
378                change_type: ChangeType::Added,
379                diff: Some(DiffContent::new_addition(d)),
380                is_drift: false,
381                source: DiffSource::ThreeWay,
382            }),
383
384            // Resource to be removed (not in desired, was in last)
385            (None, Some(l), _) => Some(ResourceChange {
386                kind: key.kind.clone(),
387                api_version: key.api_version.clone(),
388                name: key.name.clone(),
389                namespace: key.namespace.clone(),
390                change_type: ChangeType::Removed,
391                diff: Some(DiffContent::new_removal(l)),
392                is_drift: false,
393                source: DiffSource::ThreeWay,
394            }),
395
396            // Resource modified (in both desired and last)
397            (Some(d), Some(l), live_opt) => {
398                let d_norm = self.normalize_resource(d);
399                let l_norm = self.normalize_resource(l);
400
401                let will_change = d_norm != l_norm;
402                let has_drift = live_opt
403                    .map(|live| self.normalize_resource(live) != l_norm)
404                    .unwrap_or(false);
405
406                if will_change || has_drift {
407                    let diff_target = live_opt.unwrap_or(l.as_str());
408                    Some(ResourceChange {
409                        kind: key.kind.clone(),
410                        api_version: key.api_version.clone(),
411                        name: key.name.clone(),
412                        namespace: key.namespace.clone(),
413                        change_type: if will_change {
414                            ChangeType::Modified
415                        } else {
416                            ChangeType::Unchanged
417                        },
418                        diff: if will_change {
419                            Some(
420                                self.compute_text_diff(
421                                    &self.normalize_resource(diff_target),
422                                    &d_norm,
423                                ),
424                            )
425                        } else {
426                            None
427                        },
428                        is_drift: has_drift,
429                        source: DiffSource::ThreeWay,
430                    })
431                } else {
432                    None // No changes
433                }
434            }
435
436            // Extra resource in cluster (not in desired or last, but exists)
437            (None, None, Some(live)) => Some(ResourceChange {
438                kind: key.kind.clone(),
439                api_version: key.api_version.clone(),
440                name: key.name.clone(),
441                namespace: key.namespace.clone(),
442                change_type: ChangeType::Extra,
443                diff: Some(DiffContent::new_addition(live)),
444                is_drift: true,
445                source: DiffSource::ThreeWay,
446            }),
447
448            // Nothing anywhere
449            (None, None, None) => None,
450
451            // Resource only in desired (new)
452            (Some(d), None, Some(_)) => Some(ResourceChange {
453                kind: key.kind.clone(),
454                api_version: key.api_version.clone(),
455                name: key.name.clone(),
456                namespace: key.namespace.clone(),
457                change_type: ChangeType::Added,
458                diff: Some(DiffContent::new_addition(d)),
459                is_drift: false, // Adopting existing resource
460                source: DiffSource::ThreeWay,
461            }),
462        }
463    }
464
465    /// Fetch a live resource from the cluster
466    async fn fetch_live_resource(
467        &self,
468        client: &Client,
469        discovery: &Discovery,
470        key: &ResourceKey,
471        default_namespace: &str,
472    ) -> Result<Option<String>> {
473        // Parse apiVersion to get group and version
474        let (group, version) = parse_api_version(&key.api_version);
475
476        // Find the API resource in discovery
477        let gvk = GroupVersionKind::gvk(&group, &version, &key.kind);
478
479        let (ar, caps) = match discovery.resolve_gvk(&gvk) {
480            Some(r) => r,
481            None => {
482                // API not found - might be deprecated or CRD not installed
483                return Err(KubeError::Storage(format!(
484                    "API {}/{} {} not found in cluster",
485                    group, version, key.kind
486                )));
487            }
488        };
489
490        let namespace = key.namespace.as_deref().unwrap_or(default_namespace);
491
492        // Create dynamic API
493        let api: Api<DynamicObject> = if caps.scope == Scope::Cluster {
494            Api::all_with(client.clone(), &ar)
495        } else {
496            Api::namespaced_with(client.clone(), namespace, &ar)
497        };
498
499        // Fetch the resource
500        match api.get(&key.name).await {
501            Ok(obj) => {
502                // Serialize back to YAML
503                let yaml = serde_yaml::to_string(&obj)
504                    .map_err(|e| KubeError::Serialization(e.to_string()))?;
505                Ok(Some(yaml))
506            }
507            Err(kube::Error::Api(e)) if e.code == 404 => Ok(None),
508            Err(e) => Err(KubeError::Storage(format!(
509                "Failed to fetch {}/{}: {}",
510                key.kind, key.name, e
511            ))),
512        }
513    }
514
515    /// Find extra resources in cluster managed by this release but not in manifest
516    async fn find_extra_cluster_resources(
517        &self,
518        client: &Client,
519        _discovery: &Discovery,
520        release: &StoredRelease,
521        manifest_resources: &HashMap<ResourceKey, String>,
522    ) -> Result<Vec<(ResourceKey, String)>> {
523        let mut extra = Vec::new();
524
525        // Query for resources with Sherpack labels matching this release
526        let label_selector = format!(
527            "app.kubernetes.io/managed-by=sherpack,sherpack.io/release-name={}",
528            release.name
529        );
530
531        // Check common resource types for extras
532        // This is a simplified approach - a full implementation would check all types
533        let resource_types = [
534            ("v1", "ConfigMap"),
535            ("v1", "Secret"),
536            ("v1", "Service"),
537            ("apps/v1", "Deployment"),
538            ("apps/v1", "StatefulSet"),
539            ("apps/v1", "DaemonSet"),
540            ("batch/v1", "Job"),
541            ("batch/v1", "CronJob"),
542        ];
543
544        for (api_version, kind) in resource_types {
545            let ar = match kind {
546                "ConfigMap" => ApiResource::erase::<k8s_openapi::api::core::v1::ConfigMap>(&()),
547                "Secret" => ApiResource::erase::<k8s_openapi::api::core::v1::Secret>(&()),
548                "Service" => ApiResource::erase::<k8s_openapi::api::core::v1::Service>(&()),
549                "Deployment" => ApiResource::erase::<k8s_openapi::api::apps::v1::Deployment>(&()),
550                "StatefulSet" => ApiResource::erase::<k8s_openapi::api::apps::v1::StatefulSet>(&()),
551                "DaemonSet" => ApiResource::erase::<k8s_openapi::api::apps::v1::DaemonSet>(&()),
552                "Job" => ApiResource::erase::<k8s_openapi::api::batch::v1::Job>(&()),
553                "CronJob" => ApiResource::erase::<k8s_openapi::api::batch::v1::CronJob>(&()),
554                _ => continue,
555            };
556
557            let api: Api<DynamicObject> =
558                Api::namespaced_with(client.clone(), &release.namespace, &ar);
559
560            let lp = ListParams::default().labels(&label_selector);
561
562            match api.list(&lp).await {
563                Ok(list) => {
564                    for obj in list.items {
565                        let name = obj.metadata.name.clone().unwrap_or_default();
566                        let namespace = obj.metadata.namespace.clone();
567
568                        let key = ResourceKey {
569                            api_version: api_version.to_string(),
570                            kind: kind.to_string(),
571                            name: name.clone(),
572                            namespace,
573                        };
574
575                        // Check if this resource is in the manifest
576                        if !manifest_resources.contains_key(&key) {
577                            let yaml = serde_yaml::to_string(&obj).unwrap_or_default();
578                            extra.push((key, yaml));
579                        }
580                    }
581                }
582                Err(_) => continue, // Skip on error (permissions, etc.)
583            }
584        }
585
586        Ok(extra)
587    }
588
589    /// Normalize a resource for comparison by stripping server-managed fields
590    fn normalize_resource(&self, content: &str) -> String {
591        // Parse as JSON for easier manipulation
592        let mut value: JsonValue = match serde_yaml::from_str(content) {
593            Ok(v) => v,
594            Err(_) => return content.to_string(),
595        };
596
597        // Remove server-managed fields
598        self.strip_server_managed_fields(&mut value);
599
600        // Remove ignored annotations
601        self.strip_ignored_annotations(&mut value);
602
603        // Optionally remove management labels
604        if self.ignore_management_labels {
605            self.strip_management_labels(&mut value);
606        }
607
608        // Remove status if configured
609        if self.ignore_status
610            && let Some(obj) = value.as_object_mut()
611        {
612            obj.remove("status");
613        }
614
615        // Remove custom ignored paths
616        for path in &self.ignore_paths {
617            self.remove_json_path(&mut value, path);
618        }
619
620        // Re-serialize to YAML with sorted keys for consistent comparison
621        serde_yaml::to_string(&value).unwrap_or_else(|_| content.to_string())
622    }
623
624    /// Strip server-managed fields from a resource
625    fn strip_server_managed_fields(&self, value: &mut JsonValue) {
626        for path in SERVER_MANAGED_FIELDS {
627            self.remove_json_path(value, path);
628        }
629    }
630
631    /// Strip ignored annotations
632    fn strip_ignored_annotations(&self, value: &mut JsonValue) {
633        if let Some(metadata) = value.get_mut("metadata").and_then(|m| m.as_object_mut())
634            && let Some(annotations) = metadata
635                .get_mut("annotations")
636                .and_then(|a| a.as_object_mut())
637        {
638            for annotation in IGNORED_ANNOTATIONS {
639                annotations.remove(*annotation);
640            }
641            // Remove annotations entirely if empty
642            if annotations.is_empty() {
643                metadata.remove("annotations");
644            }
645        }
646    }
647
648    /// Strip management labels if configured
649    fn strip_management_labels(&self, value: &mut JsonValue) {
650        if let Some(metadata) = value.get_mut("metadata").and_then(|m| m.as_object_mut())
651            && let Some(labels) = metadata.get_mut("labels").and_then(|l| l.as_object_mut())
652        {
653            for label in OPTIONALLY_IGNORED_LABELS {
654                labels.remove(*label);
655            }
656        }
657    }
658
659    /// Remove a JSON path from a value
660    fn remove_json_path(&self, value: &mut JsonValue, path: &str) {
661        let parts: Vec<&str> = path.split('.').collect();
662        Self::remove_path_recursive(value, &parts);
663    }
664
665    fn remove_path_recursive(value: &mut JsonValue, path: &[&str]) {
666        if path.is_empty() {
667            return;
668        }
669
670        if path.len() == 1 {
671            if let Some(obj) = value.as_object_mut() {
672                obj.remove(path[0]);
673            }
674            return;
675        }
676
677        if let Some(obj) = value.as_object_mut()
678            && let Some(child) = obj.get_mut(path[0])
679        {
680            Self::remove_path_recursive(child, &path[1..]);
681        }
682    }
683
684    /// Compute a text diff between two strings
685    fn compute_text_diff(&self, old: &str, new: &str) -> DiffContent {
686        let diff = TextDiff::from_lines(old, new);
687        let mut lines = Vec::new();
688
689        for change in diff.iter_all_changes() {
690            let line_type = match change.tag() {
691                ChangeTag::Delete => LineType::Removed,
692                ChangeTag::Insert => LineType::Added,
693                ChangeTag::Equal => LineType::Context,
694            };
695
696            lines.push(DiffLine {
697                line_type,
698                content: change.value().trim_end().to_string(),
699                old_line_no: change.old_index(),
700                new_line_no: change.new_index(),
701            });
702        }
703
704        DiffContent { lines }
705    }
706
707    /// Generate a human-readable summary
708    pub fn summary(&self, result: &DiffResult) -> String {
709        let added = result
710            .changes
711            .iter()
712            .filter(|c| c.change_type == ChangeType::Added)
713            .count();
714        let modified = result
715            .changes
716            .iter()
717            .filter(|c| c.change_type == ChangeType::Modified)
718            .count();
719        let removed = result
720            .changes
721            .iter()
722            .filter(|c| c.change_type == ChangeType::Removed)
723            .count();
724        let drift = result.changes.iter().filter(|c| c.is_drift).count();
725        let missing = result
726            .changes
727            .iter()
728            .filter(|c| c.change_type == ChangeType::Missing)
729            .count();
730        let extra = result
731            .changes
732            .iter()
733            .filter(|c| c.change_type == ChangeType::Extra)
734            .count();
735
736        let mut parts = Vec::new();
737
738        if added > 0 {
739            parts.push(format!("{} added", added));
740        }
741        if modified > 0 {
742            parts.push(format!("{} modified", modified));
743        }
744        if removed > 0 {
745            parts.push(format!("{} removed", removed));
746        }
747        if missing > 0 {
748            parts.push(format!("{} missing", missing));
749        }
750        if extra > 0 {
751            parts.push(format!("{} extra", extra));
752        }
753        if drift > 0 {
754            parts.push(format!("{} drifted", drift));
755        }
756
757        if parts.is_empty() {
758            "No changes".to_string()
759        } else {
760            parts.join(", ")
761        }
762    }
763
764    /// Format diff result for terminal output with colors
765    pub fn format_colored(&self, result: &DiffResult) -> String {
766        use console::{Style, style};
767
768        let mut output = String::new();
769
770        // Header
771        if result.old_version != result.new_version {
772            output.push_str(&format!(
773                "{}\n\n",
774                style(format!(
775                    "Comparing release v{} → v{}",
776                    result.old_version, result.new_version
777                ))
778                .bold()
779            ));
780        } else if result.has_drift {
781            output.push_str(&format!(
782                "{}\n\n",
783                style(format!(
784                    "Drift detected in release v{} vs cluster state",
785                    result.old_version
786                ))
787                .bold()
788                .yellow()
789            ));
790        }
791
792        // Summary
793        output.push_str(&format!("{}\n\n", self.summary(result)));
794
795        // Group changes by type
796        let mut by_type: BTreeMap<ChangeType, Vec<&ResourceChange>> = BTreeMap::new();
797        for change in &result.changes {
798            by_type.entry(change.change_type).or_default().push(change);
799        }
800
801        // Output each group
802        for (change_type, changes) in by_type {
803            let (header_style, symbol) = match change_type {
804                ChangeType::Added => (Style::new().green().bold(), "+"),
805                ChangeType::Modified => (Style::new().yellow().bold(), "~"),
806                ChangeType::Removed => (Style::new().red().bold(), "-"),
807                ChangeType::Missing => (Style::new().red().bold(), "?"),
808                ChangeType::Extra => (Style::new().cyan().bold(), "!"),
809                ChangeType::Unchanged => (Style::new().dim(), "="),
810                ChangeType::Unknown => (Style::new().dim(), "?"),
811            };
812
813            output.push_str(&format!(
814                "{}\n",
815                header_style.apply_to(format!("=== {} ({}) ===", change_type, changes.len()))
816            ));
817
818            for change in changes {
819                let drift_marker = if change.is_drift {
820                    style(" [DRIFT]").yellow().to_string()
821                } else {
822                    String::new()
823                };
824
825                output.push_str(&format!(
826                    "  {} {}{}\n",
827                    style(symbol).bold(),
828                    change.display_name(),
829                    drift_marker
830                ));
831
832                // Show diff if available and not too large
833                if let Some(diff) = &change.diff {
834                    if diff.lines.len() <= 50 {
835                        for line in &diff.lines {
836                            let (prefix, line_style) = match line.line_type {
837                                LineType::Added => ("+", Style::new().green()),
838                                LineType::Removed => ("-", Style::new().red()),
839                                LineType::Context => (" ", Style::new().dim()),
840                            };
841                            output.push_str(&format!(
842                                "    {}{}\n",
843                                prefix,
844                                line_style.apply_to(&line.content)
845                            ));
846                        }
847                    } else {
848                        output.push_str(&format!(
849                            "    {} ({} lines, use --verbose for full diff)\n",
850                            style("...").dim(),
851                            diff.lines.len()
852                        ));
853                    }
854                }
855            }
856            output.push('\n');
857        }
858
859        output
860    }
861}
862
863impl Default for DiffEngine {
864    fn default() -> Self {
865        Self::new()
866    }
867}
868
869/// Parse apiVersion into (group, version)
870fn parse_api_version(api_version: &str) -> (String, String) {
871    if let Some((group, version)) = api_version.split_once('/') {
872        (group.to_string(), version.to_string())
873    } else {
874        // Core API (e.g., "v1")
875        (String::new(), api_version.to_string())
876    }
877}
878
879/// Result of comparing releases or detecting drift
880#[derive(Debug, Clone, Serialize, Deserialize)]
881pub struct DiffResult {
882    /// Old release version (or current for drift detection)
883    pub old_version: u32,
884
885    /// New release version (or current for drift detection)
886    pub new_version: u32,
887
888    /// List of resource changes
889    pub changes: Vec<ResourceChange>,
890
891    /// Whether any changes are drift (manual cluster modifications)
892    pub has_drift: bool,
893}
894
895impl DiffResult {
896    /// Check if there are any changes
897    pub fn has_changes(&self) -> bool {
898        !self.changes.is_empty()
899    }
900
901    /// Get changes by type
902    pub fn changes_by_type(&self, change_type: ChangeType) -> Vec<&ResourceChange> {
903        self.changes
904            .iter()
905            .filter(|c| c.change_type == change_type)
906            .collect()
907    }
908
909    /// Get drift changes only
910    pub fn drift_changes(&self) -> Vec<&ResourceChange> {
911        self.changes.iter().filter(|c| c.is_drift).collect()
912    }
913}
914
915/// Result of three-way diff
916#[derive(Debug, Clone, Serialize, Deserialize)]
917pub struct ThreeWayDiffResult {
918    /// List of resource changes
919    pub changes: Vec<ResourceChange>,
920
921    /// Whether there are pending changes to apply
922    pub has_pending_changes: bool,
923
924    /// Whether any drift was detected
925    pub has_drift: bool,
926}
927
928/// A change to a single Kubernetes resource
929#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct ResourceChange {
931    /// Resource kind (Deployment, Service, etc.)
932    pub kind: String,
933
934    /// API version (apps/v1, v1, etc.)
935    pub api_version: String,
936
937    /// Resource name
938    pub name: String,
939
940    /// Resource namespace (empty for cluster-scoped)
941    pub namespace: Option<String>,
942
943    /// Type of change
944    pub change_type: ChangeType,
945
946    /// Detailed diff (if available)
947    pub diff: Option<DiffContent>,
948
949    /// Whether this change is drift (manual cluster modification)
950    pub is_drift: bool,
951
952    /// Source of the diff
953    pub source: DiffSource,
954}
955
956impl ResourceChange {
957    /// Get a display name for the resource
958    pub fn display_name(&self) -> String {
959        match &self.namespace {
960            Some(ns) => format!("{}/{}/{}", ns, self.kind, self.name),
961            None => format!("{}/{}", self.kind, self.name),
962        }
963    }
964}
965
966/// Source of the diff comparison
967#[derive(Debug, Clone, Serialize, Deserialize)]
968pub enum DiffSource {
969    /// Comparing two releases
970    ReleaseComparison,
971    /// Detecting cluster drift
972    ClusterDrift,
973    /// Three-way comparison
974    ThreeWay,
975    /// Error during fetch
976    Error(String),
977}
978
979/// Type of resource change
980#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
981#[serde(rename_all = "lowercase")]
982pub enum ChangeType {
983    /// Resource was added
984    Added,
985
986    /// Resource was modified
987    Modified,
988
989    /// Resource was removed (scheduled for deletion)
990    Removed,
991
992    /// Resource is missing from cluster (should exist)
993    Missing,
994
995    /// Resource exists in cluster but not in manifest (drift)
996    Extra,
997
998    /// Resource is unchanged
999    Unchanged,
1000
1001    /// Status unknown (API error)
1002    Unknown,
1003}
1004
1005impl std::fmt::Display for ChangeType {
1006    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1007        match self {
1008            ChangeType::Added => write!(f, "Added"),
1009            ChangeType::Modified => write!(f, "Modified"),
1010            ChangeType::Removed => write!(f, "Removed"),
1011            ChangeType::Missing => write!(f, "Missing"),
1012            ChangeType::Extra => write!(f, "Extra"),
1013            ChangeType::Unchanged => write!(f, "Unchanged"),
1014            ChangeType::Unknown => write!(f, "Unknown"),
1015        }
1016    }
1017}
1018
1019/// Detailed diff content
1020#[derive(Debug, Clone, Serialize, Deserialize)]
1021pub struct DiffContent {
1022    /// Lines of the diff
1023    pub lines: Vec<DiffLine>,
1024}
1025
1026impl DiffContent {
1027    /// Create a diff showing all lines as additions
1028    pub fn new_addition(content: &str) -> Self {
1029        let lines = content
1030            .lines()
1031            .enumerate()
1032            .map(|(i, line)| DiffLine {
1033                line_type: LineType::Added,
1034                content: line.to_string(),
1035                old_line_no: None,
1036                new_line_no: Some(i),
1037            })
1038            .collect();
1039
1040        Self { lines }
1041    }
1042
1043    /// Create a diff showing all lines as removals
1044    pub fn new_removal(content: &str) -> Self {
1045        let lines = content
1046            .lines()
1047            .enumerate()
1048            .map(|(i, line)| DiffLine {
1049                line_type: LineType::Removed,
1050                content: line.to_string(),
1051                old_line_no: Some(i),
1052                new_line_no: None,
1053            })
1054            .collect();
1055
1056        Self { lines }
1057    }
1058
1059    /// Count added lines
1060    pub fn added_count(&self) -> usize {
1061        self.lines
1062            .iter()
1063            .filter(|l| l.line_type == LineType::Added)
1064            .count()
1065    }
1066
1067    /// Count removed lines
1068    pub fn removed_count(&self) -> usize {
1069        self.lines
1070            .iter()
1071            .filter(|l| l.line_type == LineType::Removed)
1072            .count()
1073    }
1074
1075    /// Generate a unified diff string
1076    pub fn to_unified_diff(&self) -> String {
1077        let mut output = String::new();
1078
1079        for line in &self.lines {
1080            let prefix = match line.line_type {
1081                LineType::Added => "+",
1082                LineType::Removed => "-",
1083                LineType::Context => " ",
1084            };
1085            output.push_str(prefix);
1086            output.push_str(&line.content);
1087            output.push('\n');
1088        }
1089
1090        output
1091    }
1092}
1093
1094/// A single line in a diff
1095#[derive(Debug, Clone, Serialize, Deserialize)]
1096pub struct DiffLine {
1097    /// Type of line
1098    pub line_type: LineType,
1099
1100    /// Content of the line
1101    pub content: String,
1102
1103    /// Line number in old version
1104    pub old_line_no: Option<usize>,
1105
1106    /// Line number in new version
1107    pub new_line_no: Option<usize>,
1108}
1109
1110/// Type of diff line
1111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1112#[serde(rename_all = "lowercase")]
1113pub enum LineType {
1114    /// Line was added
1115    Added,
1116
1117    /// Line was removed
1118    Removed,
1119
1120    /// Unchanged context line
1121    Context,
1122}
1123
1124/// Key for identifying a Kubernetes resource
1125#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1126pub struct ResourceKey {
1127    /// API version (e.g., "apps/v1", "v1")
1128    pub api_version: String,
1129    /// Resource kind
1130    pub kind: String,
1131    /// Resource name
1132    pub name: String,
1133    /// Resource namespace
1134    pub namespace: Option<String>,
1135}
1136
1137/// Parse a manifest into individual resources
1138pub fn parse_manifest_resources(manifest: &str) -> HashMap<ResourceKey, String> {
1139    let mut resources = HashMap::new();
1140
1141    for doc in manifest.split("---") {
1142        let doc = doc.trim();
1143        if doc.is_empty() {
1144            continue;
1145        }
1146
1147        // Parse as YAML to extract metadata
1148        let yaml: serde_yaml::Value = match serde_yaml::from_str(doc) {
1149            Ok(v) => v,
1150            Err(_) => continue,
1151        };
1152
1153        let api_version = yaml
1154            .get("apiVersion")
1155            .and_then(|v| v.as_str())
1156            .unwrap_or("v1")
1157            .to_string();
1158
1159        let kind = yaml
1160            .get("kind")
1161            .and_then(|v| v.as_str())
1162            .unwrap_or("Unknown")
1163            .to_string();
1164
1165        let name = yaml
1166            .get("metadata")
1167            .and_then(|m| m.get("name"))
1168            .and_then(|n| n.as_str())
1169            .unwrap_or("unnamed")
1170            .to_string();
1171
1172        let namespace = yaml
1173            .get("metadata")
1174            .and_then(|m| m.get("namespace"))
1175            .and_then(|n| n.as_str())
1176            .map(String::from);
1177
1178        let key = ResourceKey {
1179            api_version,
1180            kind,
1181            name,
1182            namespace,
1183        };
1184
1185        resources.insert(key, doc.to_string());
1186    }
1187
1188    resources
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194
1195    fn test_release(manifest: &str) -> StoredRelease {
1196        StoredRelease {
1197            name: "test".to_string(),
1198            namespace: "default".to_string(),
1199            version: 1,
1200            state: crate::release::ReleaseState::Deployed,
1201            pack: sherpack_core::PackMetadata {
1202                name: "test".to_string(),
1203                version: semver::Version::new(1, 0, 0),
1204                description: None,
1205                app_version: None,
1206                kube_version: None,
1207                home: None,
1208                icon: None,
1209                sources: vec![],
1210                keywords: vec![],
1211                maintainers: vec![],
1212                annotations: Default::default(),
1213            },
1214            values: sherpack_core::Values::new(),
1215            values_provenance: Default::default(),
1216            manifest: manifest.to_string(),
1217            hooks: vec![],
1218            labels: Default::default(),
1219            created_at: chrono::Utc::now(),
1220            updated_at: chrono::Utc::now(),
1221            notes: None,
1222        }
1223    }
1224
1225    #[test]
1226    fn test_parse_manifest_resources() {
1227        let manifest = r#"
1228apiVersion: v1
1229kind: ConfigMap
1230metadata:
1231  name: my-config
1232  namespace: default
1233data:
1234  key: value
1235---
1236apiVersion: apps/v1
1237kind: Deployment
1238metadata:
1239  name: my-app
1240  namespace: default
1241spec:
1242  replicas: 1
1243"#;
1244
1245        let resources = parse_manifest_resources(manifest);
1246        assert_eq!(resources.len(), 2);
1247
1248        let cm_key = ResourceKey {
1249            api_version: "v1".to_string(),
1250            kind: "ConfigMap".to_string(),
1251            name: "my-config".to_string(),
1252            namespace: Some("default".to_string()),
1253        };
1254        assert!(resources.contains_key(&cm_key));
1255
1256        let deploy_key = ResourceKey {
1257            api_version: "apps/v1".to_string(),
1258            kind: "Deployment".to_string(),
1259            name: "my-app".to_string(),
1260            namespace: Some("default".to_string()),
1261        };
1262        assert!(resources.contains_key(&deploy_key));
1263    }
1264
1265    #[test]
1266    fn test_diff_releases_addition() {
1267        let engine = DiffEngine::new();
1268
1269        let old = test_release("apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: cm1");
1270        let mut new = old.clone();
1271        new.version = 2;
1272        new.manifest = format!(
1273            "{}\n---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: cm2",
1274            old.manifest
1275        );
1276
1277        let diff = engine.diff_releases(&old, &new);
1278
1279        assert_eq!(diff.changes.len(), 1);
1280        assert_eq!(diff.changes[0].change_type, ChangeType::Added);
1281        assert_eq!(diff.changes[0].name, "cm2");
1282    }
1283
1284    #[test]
1285    fn test_diff_releases_removal() {
1286        let engine = DiffEngine::new();
1287
1288        let old = test_release(
1289            "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: cm1\n---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: cm2",
1290        );
1291        let mut new = old.clone();
1292        new.version = 2;
1293        new.manifest = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: cm1".to_string();
1294
1295        let diff = engine.diff_releases(&old, &new);
1296
1297        assert_eq!(diff.changes.len(), 1);
1298        assert_eq!(diff.changes[0].change_type, ChangeType::Removed);
1299        assert_eq!(diff.changes[0].name, "cm2");
1300    }
1301
1302    #[test]
1303    fn test_diff_releases_modification() {
1304        let engine = DiffEngine::new();
1305
1306        let old = test_release(
1307            "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: cm1\ndata:\n  key: old-value",
1308        );
1309        let mut new = old.clone();
1310        new.version = 2;
1311        new.manifest =
1312            "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: cm1\ndata:\n  key: new-value"
1313                .to_string();
1314
1315        let diff = engine.diff_releases(&old, &new);
1316
1317        assert_eq!(diff.changes.len(), 1);
1318        assert_eq!(diff.changes[0].change_type, ChangeType::Modified);
1319        assert!(diff.changes[0].diff.is_some());
1320    }
1321
1322    #[test]
1323    fn test_normalize_strips_managed_fields() {
1324        let engine = DiffEngine::new();
1325
1326        let resource_with_managed = r#"
1327apiVersion: v1
1328kind: ConfigMap
1329metadata:
1330  name: test
1331  resourceVersion: "12345"
1332  uid: "abc-123"
1333  generation: 1
1334  creationTimestamp: "2024-01-01T00:00:00Z"
1335  managedFields:
1336    - manager: kubectl
1337      operation: Apply
1338data:
1339  key: value
1340"#;
1341
1342        let normalized = engine.normalize_resource(resource_with_managed);
1343
1344        // Check that managed fields are removed
1345        assert!(!normalized.contains("resourceVersion"));
1346        assert!(!normalized.contains("uid"));
1347        assert!(!normalized.contains("generation"));
1348        assert!(!normalized.contains("creationTimestamp"));
1349        assert!(!normalized.contains("managedFields"));
1350
1351        // Check that actual data is preserved
1352        assert!(normalized.contains("ConfigMap"));
1353        assert!(normalized.contains("key: value"));
1354    }
1355
1356    #[test]
1357    fn test_normalize_strips_ignored_annotations() {
1358        let engine = DiffEngine::new();
1359
1360        let resource_with_annotations = r#"
1361apiVersion: v1
1362kind: ConfigMap
1363metadata:
1364  name: test
1365  annotations:
1366    kubectl.kubernetes.io/last-applied-configuration: "{}"
1367    my-custom-annotation: "keep-this"
1368data:
1369  key: value
1370"#;
1371
1372        let normalized = engine.normalize_resource(resource_with_annotations);
1373
1374        assert!(!normalized.contains("last-applied-configuration"));
1375        assert!(normalized.contains("my-custom-annotation"));
1376    }
1377
1378    #[test]
1379    fn test_normalize_strips_status() {
1380        let engine = DiffEngine::new();
1381
1382        let resource_with_status = r#"
1383apiVersion: apps/v1
1384kind: Deployment
1385metadata:
1386  name: test
1387spec:
1388  replicas: 1
1389status:
1390  availableReplicas: 1
1391  readyReplicas: 1
1392"#;
1393
1394        let normalized = engine.normalize_resource(resource_with_status);
1395
1396        assert!(!normalized.contains("status:"));
1397        assert!(!normalized.contains("availableReplicas"));
1398        assert!(normalized.contains("replicas: 1"));
1399    }
1400
1401    #[test]
1402    fn test_diff_summary() {
1403        let engine = DiffEngine::new();
1404        let result = DiffResult {
1405            old_version: 1,
1406            new_version: 2,
1407            changes: vec![
1408                ResourceChange {
1409                    kind: "ConfigMap".to_string(),
1410                    api_version: "v1".to_string(),
1411                    name: "cm1".to_string(),
1412                    namespace: Some("default".to_string()),
1413                    change_type: ChangeType::Added,
1414                    diff: None,
1415                    is_drift: false,
1416                    source: DiffSource::ReleaseComparison,
1417                },
1418                ResourceChange {
1419                    kind: "Deployment".to_string(),
1420                    api_version: "apps/v1".to_string(),
1421                    name: "app".to_string(),
1422                    namespace: Some("default".to_string()),
1423                    change_type: ChangeType::Modified,
1424                    diff: None,
1425                    is_drift: true,
1426                    source: DiffSource::ClusterDrift,
1427                },
1428            ],
1429            has_drift: true,
1430        };
1431
1432        let summary = engine.summary(&result);
1433        assert!(summary.contains("1 added"));
1434        assert!(summary.contains("1 modified"));
1435        assert!(summary.contains("1 drifted"));
1436    }
1437
1438    #[test]
1439    fn test_parse_api_version() {
1440        assert_eq!(
1441            parse_api_version("apps/v1"),
1442            ("apps".to_string(), "v1".to_string())
1443        );
1444        assert_eq!(parse_api_version("v1"), (String::new(), "v1".to_string()));
1445        assert_eq!(
1446            parse_api_version("networking.k8s.io/v1"),
1447            ("networking.k8s.io".to_string(), "v1".to_string())
1448        );
1449    }
1450
1451    #[test]
1452    fn test_diff_content_counts() {
1453        let content = DiffContent {
1454            lines: vec![
1455                DiffLine {
1456                    line_type: LineType::Removed,
1457                    content: "old".to_string(),
1458                    old_line_no: Some(0),
1459                    new_line_no: None,
1460                },
1461                DiffLine {
1462                    line_type: LineType::Added,
1463                    content: "new1".to_string(),
1464                    old_line_no: None,
1465                    new_line_no: Some(0),
1466                },
1467                DiffLine {
1468                    line_type: LineType::Added,
1469                    content: "new2".to_string(),
1470                    old_line_no: None,
1471                    new_line_no: Some(1),
1472                },
1473            ],
1474        };
1475
1476        assert_eq!(content.added_count(), 2);
1477        assert_eq!(content.removed_count(), 1);
1478    }
1479
1480    #[test]
1481    fn test_unified_diff_output() {
1482        let content = DiffContent {
1483            lines: vec![
1484                DiffLine {
1485                    line_type: LineType::Context,
1486                    content: "unchanged".to_string(),
1487                    old_line_no: Some(0),
1488                    new_line_no: Some(0),
1489                },
1490                DiffLine {
1491                    line_type: LineType::Removed,
1492                    content: "old".to_string(),
1493                    old_line_no: Some(1),
1494                    new_line_no: None,
1495                },
1496                DiffLine {
1497                    line_type: LineType::Added,
1498                    content: "new".to_string(),
1499                    old_line_no: None,
1500                    new_line_no: Some(1),
1501                },
1502            ],
1503        };
1504
1505        let unified = content.to_unified_diff();
1506        assert!(unified.contains(" unchanged\n"));
1507        assert!(unified.contains("-old\n"));
1508        assert!(unified.contains("+new\n"));
1509    }
1510
1511    #[test]
1512    fn test_resource_display_name() {
1513        let namespaced = ResourceChange {
1514            kind: "Deployment".to_string(),
1515            api_version: "apps/v1".to_string(),
1516            name: "my-app".to_string(),
1517            namespace: Some("production".to_string()),
1518            change_type: ChangeType::Modified,
1519            diff: None,
1520            is_drift: false,
1521            source: DiffSource::ReleaseComparison,
1522        };
1523        assert_eq!(namespaced.display_name(), "production/Deployment/my-app");
1524
1525        let cluster_scoped = ResourceChange {
1526            kind: "ClusterRole".to_string(),
1527            api_version: "rbac.authorization.k8s.io/v1".to_string(),
1528            name: "admin".to_string(),
1529            namespace: None,
1530            change_type: ChangeType::Added,
1531            diff: None,
1532            is_drift: false,
1533            source: DiffSource::ReleaseComparison,
1534        };
1535        assert_eq!(cluster_scoped.display_name(), "ClusterRole/admin");
1536    }
1537
1538    #[test]
1539    fn test_changes_by_type() {
1540        let result = DiffResult {
1541            old_version: 1,
1542            new_version: 2,
1543            changes: vec![
1544                ResourceChange {
1545                    kind: "ConfigMap".to_string(),
1546                    api_version: "v1".to_string(),
1547                    name: "cm1".to_string(),
1548                    namespace: None,
1549                    change_type: ChangeType::Added,
1550                    diff: None,
1551                    is_drift: false,
1552                    source: DiffSource::ReleaseComparison,
1553                },
1554                ResourceChange {
1555                    kind: "ConfigMap".to_string(),
1556                    api_version: "v1".to_string(),
1557                    name: "cm2".to_string(),
1558                    namespace: None,
1559                    change_type: ChangeType::Added,
1560                    diff: None,
1561                    is_drift: false,
1562                    source: DiffSource::ReleaseComparison,
1563                },
1564                ResourceChange {
1565                    kind: "Secret".to_string(),
1566                    api_version: "v1".to_string(),
1567                    name: "sec1".to_string(),
1568                    namespace: None,
1569                    change_type: ChangeType::Removed,
1570                    diff: None,
1571                    is_drift: false,
1572                    source: DiffSource::ReleaseComparison,
1573                },
1574            ],
1575            has_drift: false,
1576        };
1577
1578        assert_eq!(result.changes_by_type(ChangeType::Added).len(), 2);
1579        assert_eq!(result.changes_by_type(ChangeType::Removed).len(), 1);
1580        assert_eq!(result.changes_by_type(ChangeType::Modified).len(), 0);
1581    }
1582
1583    #[test]
1584    fn test_ignore_custom_path() {
1585        let engine = DiffEngine::new().ignore_path("spec.replicas");
1586
1587        let resource = r#"
1588apiVersion: apps/v1
1589kind: Deployment
1590metadata:
1591  name: test
1592spec:
1593  replicas: 3
1594  template:
1595    spec:
1596      containers: []
1597"#;
1598
1599        let normalized = engine.normalize_resource(resource);
1600
1601        // replicas should be removed
1602        assert!(!normalized.contains("replicas"));
1603        // But template should remain
1604        assert!(normalized.contains("template"));
1605    }
1606}