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}