Skip to main content

dnf_repofile/
diff.rs

1//! Diff engine for comparing DNF configuration files and values.
2//!
3//! Provides three diff functions and three result types:
4//!
5//! - [`diff_files`] — compares two complete [`RepoFile`] values,
6//!   reporting added, removed, and modified repos plus any [`MainConfig`] changes.
7//! - [`diff_repos`] — compares two individual [`Repo`] values field by field.
8//! - [`diff_main`] — compares two [`MainConfig`] values field by field.
9//!
10//! Each diff reports three categories of changes: **added** (field present in B
11//! but not in A), **removed** (field present in A but not in B), and **changed**
12//! (field differs between A and B, showing both old and new values).
13
14use crate::mainconfig::MainConfig;
15use crate::repo::Repo;
16use crate::repofile::RepoFile;
17use crate::types::RepoId;
18use indexmap::IndexMap;
19
20/// Result of comparing two entire `.repo` files.
21///
22/// Reports added, removed, modified, and unchanged repos, plus any changes
23/// to the `[main]` section.
24///
25/// # Examples
26///
27/// ```
28/// use dnf_repofile::{RepoFile, diff_files};
29///
30/// let a = RepoFile::parse("[repo]\nname=Old\nbaseurl=https://a.com/\n").unwrap();
31/// let b = RepoFile::parse("[repo]\nname=New\nbaseurl=https://b.com/\n").unwrap();
32/// let diff = diff_files(&a, &b);
33/// assert!(diff.has_changes);
34/// ```
35#[derive(Debug, Clone)]
36pub struct FileDiff {
37    /// Changes to the `[main]` section, if present.
38    pub main_changes: Option<ConfigDiff>,
39    /// Repo IDs present in B but not in A.
40    pub repos_added: Vec<RepoId>,
41    /// Repo IDs present in A but not in B.
42    pub repos_removed: Vec<RepoId>,
43    /// Repo IDs present in both files with field-level changes.
44    pub repos_modified: IndexMap<RepoId, RepoDiff>,
45    /// Repo IDs present in both files with identical values.
46    pub repos_unchanged: Vec<RepoId>,
47    /// Whether any changes were detected across all categories.
48    pub has_changes: bool,
49}
50
51/// Per-repository field-level diff between two [`Repo`] values.
52///
53/// Each tuple in `changed` is `(field_name, old_value, new_value)`.
54/// Each tuple in `added` and `removed` is `(field_name, value)`.
55#[derive(Debug, Clone)]
56pub struct RepoDiff {
57    /// Fields whose values differ between A and B.
58    pub changed: Vec<(String, String, String)>,
59    /// Fields present in B but absent in A.
60    pub added: Vec<(String, String)>,
61    /// Fields present in A but absent in B.
62    pub removed: Vec<(String, String)>,
63    /// Whether any field-level changes were detected.
64    pub has_changes: bool,
65}
66
67/// Field-level diff between two [`MainConfig`] values.
68///
69/// Each tuple in `changed` is `(field_name, old_value, new_value)`.
70/// Each tuple in `added` and `removed` is `(field_name, value)`.
71#[derive(Debug, Clone)]
72pub struct ConfigDiff {
73    /// Fields whose values differ between A and B.
74    pub changed: Vec<(String, String, String)>,
75    /// Fields present in B but absent in A.
76    pub added: Vec<(String, String)>,
77    /// Fields present in A but absent in B.
78    pub removed: Vec<(String, String)>,
79    /// Whether any field-level changes were detected.
80    pub has_changes: bool,
81}
82
83/// Compare two [`RepoFile`] values and produce a [`FileDiff`].
84///
85/// Diffs the `[main]` section (if present in both files), then enumerates
86/// repos by ID to find added, removed, modified, and unchanged repos.
87///
88/// # Examples
89///
90/// ```
91/// use dnf_repofile::{RepoFile, diff_files};
92///
93/// let a = RepoFile::parse("[repo]\nname=Old\nbaseurl=https://a.com/\n").unwrap();
94/// let b = RepoFile::parse("[repo]\nname=New\nbaseurl=https://b.com/\n").unwrap();
95/// let diff = diff_files(&a, &b);
96/// assert!(diff.repos_modified.contains_key(
97///     &dnf_repofile::RepoId::try_new("repo").unwrap()
98/// ));
99/// ```
100pub fn diff_files(a: &RepoFile, b: &RepoFile) -> FileDiff {
101    let mut diff = FileDiff {
102        main_changes: None,
103        repos_added: vec![],
104        repos_removed: vec![],
105        repos_modified: IndexMap::new(),
106        repos_unchanged: vec![],
107        has_changes: false,
108    };
109
110    match (&a.main, &b.main) {
111        (Some(am), Some(bm)) => {
112            let cd = diff_main(&am.data, &bm.data);
113            if cd.has_changes {
114                diff.has_changes = true;
115                diff.main_changes = Some(cd);
116            }
117        }
118        (None, Some(_)) | (Some(_), None) => {
119            diff.has_changes = true;
120        }
121        (None, None) => {}
122    }
123
124    for (id, bb) in &b.repos {
125        match a.repos.get(id) {
126            None => {
127                diff.repos_added.push(id.clone());
128                diff.has_changes = true;
129            }
130            Some(ba) => {
131                let rd = diff_repos(&ba.data, &bb.data);
132                if rd.has_changes {
133                    diff.repos_modified.insert(id.clone(), rd);
134                    diff.has_changes = true;
135                } else {
136                    diff.repos_unchanged.push(id.clone());
137                }
138            }
139        }
140    }
141
142    for (id, _) in &a.repos {
143        if !b.repos.contains_key(id) {
144            diff.repos_removed.push(id.clone());
145            diff.has_changes = true;
146        }
147    }
148
149    diff
150}
151
152/// Compare two [`Repo`] values and produce a [`RepoDiff`].
153///
154/// Compares the following fields: `name`, `baseurl`, `enabled`, `gpgcheck`,
155/// `priority`, and `gpgkey`. Fields that are `None` in both directions are
156/// considered absent; fields that change from `Some` to `None` or vice versa
157/// are reported as removed or added respectively.
158///
159/// # Examples
160///
161/// ```
162/// use dnf_repofile::{Repo, RepoId, diff_repos};
163///
164/// let mut a = Repo::new(RepoId::try_new("repo").unwrap());
165/// a.name = Some(dnf_repofile::RepoName::try_new("Old Name").unwrap());
166///
167/// let mut b = Repo::new(RepoId::try_new("repo").unwrap());
168/// b.name = Some(dnf_repofile::RepoName::try_new("New Name").unwrap());
169///
170/// let diff = diff_repos(&a, &b);
171/// assert!(diff.has_changes);
172/// ```
173pub fn diff_repos(a: &Repo, b: &Repo) -> RepoDiff {
174    let mut diff = RepoDiff {
175        changed: vec![],
176        added: vec![],
177        removed: vec![],
178        has_changes: false,
179    };
180
181    diff_opt(
182        &mut diff,
183        "name",
184        a.name.as_ref().map(|n| n.as_ref().to_owned()),
185        b.name.as_ref().map(|n| n.as_ref().to_owned()),
186    );
187    diff_opt(
188        &mut diff,
189        "baseurl",
190        if a.baseurl.is_empty() {
191            None
192        } else {
193            Some(
194                a.baseurl
195                    .iter()
196                    .map(|u| u.to_string())
197                    .collect::<Vec<_>>()
198                    .join(", "),
199            )
200        },
201        if b.baseurl.is_empty() {
202            None
203        } else {
204            Some(
205                b.baseurl
206                    .iter()
207                    .map(|u| u.to_string())
208                    .collect::<Vec<_>>()
209                    .join(", "),
210            )
211        },
212    );
213    diff_opt(
214        &mut diff,
215        "enabled",
216        a.enabled.map(|d| d.to_string()),
217        b.enabled.map(|d| d.to_string()),
218    );
219    diff_opt(
220        &mut diff,
221        "gpgcheck",
222        a.gpgcheck.map(|d| d.to_string()),
223        b.gpgcheck.map(|d| d.to_string()),
224    );
225    diff_opt(
226        &mut diff,
227        "priority",
228        a.priority.map(|p| p.to_string()),
229        b.priority.map(|p| p.to_string()),
230    );
231    diff_opt(
232        &mut diff,
233        "gpgkey",
234        if a.gpgkey.is_empty() {
235            None
236        } else {
237            Some(a.gpgkey.join(", "))
238        },
239        if b.gpgkey.is_empty() {
240            None
241        } else {
242            Some(b.gpgkey.join(", "))
243        },
244    );
245
246    diff.has_changes =
247        !diff.changed.is_empty() || !diff.added.is_empty() || !diff.removed.is_empty();
248    diff
249}
250
251fn diff_opt(diff: &mut RepoDiff, key: &str, a: Option<String>, b: Option<String>) {
252    match (a, b) {
253        (None, Some(nv)) => diff.added.push((key.to_string(), nv)),
254        (Some(ov), None) => diff.removed.push((key.to_string(), ov)),
255        (Some(ov), Some(nv)) if ov != nv => diff.changed.push((key.to_string(), ov, nv)),
256        _ => {}
257    }
258}
259
260/// Compare two [`MainConfig`] values and produce a [`ConfigDiff`].
261///
262/// Currently compares `debuglevel` and `best`. This field list will expand
263/// in future releases.
264///
265/// # Examples
266///
267/// ```
268/// use dnf_repofile::{MainConfig, diff_main};
269///
270/// let a = MainConfig::default();
271/// let b = MainConfig::default();
272/// let diff = diff_main(&a, &b);
273/// assert!(!diff.has_changes);
274/// ```
275pub fn diff_main(a: &MainConfig, b: &MainConfig) -> ConfigDiff {
276    let mut diff = ConfigDiff {
277        changed: vec![],
278        added: vec![],
279        removed: vec![],
280        has_changes: false,
281    };
282
283    diff_opt_cfg(
284        &mut diff,
285        "debuglevel",
286        a.debuglevel.map(|d| d.to_string()),
287        b.debuglevel.map(|d| d.to_string()),
288    );
289    diff_opt_cfg(
290        &mut diff,
291        "best",
292        a.best.map(|d| d.to_string()),
293        b.best.map(|d| d.to_string()),
294    );
295
296    diff.has_changes =
297        !diff.changed.is_empty() || !diff.added.is_empty() || !diff.removed.is_empty();
298    diff
299}
300
301fn diff_opt_cfg(diff: &mut ConfigDiff, key: &str, a: Option<String>, b: Option<String>) {
302    match (a, b) {
303        (None, Some(nv)) => diff.added.push((key.to_string(), nv)),
304        (Some(ov), None) => diff.removed.push((key.to_string(), ov)),
305        (Some(ov), Some(nv)) if ov != nv => diff.changed.push((key.to_string(), ov, nv)),
306        _ => {}
307    }
308}