Skip to main content

dnf_repofile/
repofile.rs

1//! Parsing, rendering, and manipulation of `.repo` files.
2//!
3//! The central type is [`RepoFile`], which represents a complete INI-style
4//! `.repo` file as a structured document: a preamble (comments/blank lines
5//! before any section), an optional `[main]` section ([`SectionBlock<MainConfig>`]),
6//! and a collection of `[repo-id]` sections ([`SectionBlock<Repo>`]).
7//!
8//! # Round-trip fidelity
9//!
10//! [`RepoFile`] preserves comments, blank lines, whitespace, and entry ordering
11//! via [`RawEntry`] records. Parsing and re-rendering produces text that is
12//! semantically (and typically textually) identical to the original.
13//!
14//! # Key types
15//!
16//! - [`SectionBlock<T>`] — wraps typed data with formatting metadata
17//! - [`RawEntry`] — a single key-value pair with associated comments
18//! - [`RepoFile`] — the top-level document type
19
20use crate::error::{Error, ParseError, Result};
21use crate::mainconfig::MainConfig;
22use crate::repo::Repo;
23use crate::types::*;
24use camino::Utf8PathBuf;
25use indexmap::IndexMap;
26use std::str::FromStr;
27use url::Url;
28
29// ============================================================================
30// Helper macro for nutype numeric types that lack FromStr
31// ============================================================================
32
33macro_rules! try_parse_nutype {
34    ($val:expr, $typ:ty, $inner:ty) => {
35        $val.trim()
36            .parse::<$inner>()
37            .ok()
38            .and_then(|n| <$typ>::try_new(n).ok())
39    };
40}
41
42// ============================================================================
43// Core public types
44// ============================================================================
45
46/// A section block containing typed data plus formatting metadata.
47///
48/// Wraps a typed value (either [`Repo`] or [`MainConfig`]) together with
49/// comments, entry ordering, and raw entry records to support round-trip
50/// rendering.
51///
52/// # Examples
53///
54/// ```
55/// use dnf_repofile::{SectionBlock, Repo, RepoId};
56///
57/// let block = SectionBlock {
58///     header_comments: vec![],
59///     data: Repo::new(RepoId::try_new("test").unwrap()),
60///     item_comments: indexmap::IndexMap::new(),
61///     item_order: vec![],
62///     raw_entries: vec![],
63/// };
64/// ```
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct SectionBlock<T> {
67    /// Comment lines and blank lines preceding the section header.
68    pub header_comments: Vec<String>,
69    /// The typed section data ([`Repo`] or [`MainConfig`]).
70    pub data: T,
71    /// Inline comments for specific keys: `key -> comment text`.
72    pub item_comments: IndexMap<String, String>,
73    /// Ordered list of key names as they appeared in the original file.
74    pub item_order: Vec<String>,
75    /// Raw key-value entries preserving comments and ordering.
76    pub raw_entries: Vec<RawEntry>,
77}
78
79/// An unrecognized key-value entry preserved for round-trip fidelity.
80///
81/// Stores the key, value, optional inline comment, and any leading comments
82/// that appeared before the entry in the original `.repo` file.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct RawEntry {
85    /// The option key name.
86    pub key: String,
87    /// The option value.
88    pub value: String,
89    /// Optional inline comment after the value (text after `#`).
90    pub inline_comment: Option<String>,
91    /// Comment lines immediately preceding this entry.
92    pub leading_comments: Vec<String>,
93}
94
95/// A complete parsed `.repo` file.
96///
97/// Represents the entire INI document structure: a preamble (comments/lines
98/// before the first section), an optional `[main]` section, and zero or more
99/// `[repo-id]` repository sections.
100///
101/// # Examples
102///
103/// ```
104/// use dnf_repofile::RepoFile;
105///
106/// let input = "[main]\ncachedir=/var/cache/dnf\n\n[epel]\nname=EPEL\nbaseurl=https://example.com/\nenabled=1\n";
107/// let rf = RepoFile::parse(input).unwrap();
108/// assert_eq!(rf.len(), 1);
109/// assert!(rf.main().is_some());
110///
111/// // Render back to string
112/// let output = rf.render();
113/// assert!(output.contains("[epel]"));
114/// assert!(output.contains("[main]"));
115/// ```
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct RepoFile {
118    /// Lines appearing before the first section header (preamble comments/blanks).
119    pub preamble: Vec<String>,
120    /// The optional `[main]` configuration section.
121    pub main: Option<SectionBlock<MainConfig>>,
122    /// Repository sections keyed by [`RepoId`].
123    pub repos: IndexMap<RepoId, SectionBlock<Repo>>,
124}
125
126impl std::fmt::Display for RepoFile {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        write!(f, "{}", self.render())
129    }
130}
131
132impl std::str::FromStr for RepoFile {
133    type Err = ParseError;
134    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
135        Self::parse(s)
136    }
137}
138
139impl From<RepoFile> for String {
140    fn from(rf: RepoFile) -> Self {
141        rf.render()
142    }
143}
144
145impl<'a> IntoIterator for &'a RepoFile {
146    type Item = (&'a RepoId, &'a SectionBlock<Repo>);
147    type IntoIter = indexmap::map::Iter<'a, RepoId, SectionBlock<Repo>>;
148    fn into_iter(self) -> Self::IntoIter {
149        self.repos.iter()
150    }
151}
152
153// ============================================================================
154// Internal parse types
155// ============================================================================
156
157#[derive(Debug)]
158struct ParseState {
159    preamble: Vec<String>,
160    pending_comments: Vec<String>,
161    current_section: Option<String>,
162    current_entries: Vec<RawLine>,
163    sections: IndexMap<String, Vec<RawLine>>,
164    section_header_comments: IndexMap<String, Vec<String>>,
165}
166
167/// A raw INI entry being built up during parsing (private)
168#[derive(Debug, Clone)]
169struct RawLine {
170    key: String,
171    value: String,
172    inline_comment: Option<String>,
173    leading_comments: Vec<String>,
174}
175
176// ============================================================================
177// Helper: split value and inline comment
178// ============================================================================
179
180fn split_value_and_comment(value_part: &str) -> (String, Option<String>) {
181    let mut in_quotes = false;
182    for (i, ch) in value_part.char_indices() {
183        if ch == '"' {
184            in_quotes = !in_quotes;
185        }
186        if ch == '#' && !in_quotes {
187            return (
188                value_part[..i].to_string(),
189                Some(value_part[i + 1..].trim().to_string()),
190            );
191        }
192    }
193    (value_part.to_string(), None)
194}
195
196// ============================================================================
197// Enum value parsers
198// ============================================================================
199
200fn parse_ip_resolve(val: &str) -> Option<IpResolve> {
201    match val.trim().to_lowercase().as_str() {
202        "4" | "ipv4" => Some(IpResolve::V4),
203        "6" | "ipv6" => Some(IpResolve::V6),
204        _ => None,
205    }
206}
207
208fn parse_proxy_auth_method(val: &str) -> Option<ProxyAuthMethod> {
209    match val.trim().to_lowercase().as_str() {
210        "any" => Some(ProxyAuthMethod::Any),
211        "none" => Some(ProxyAuthMethod::None_),
212        "basic" => Some(ProxyAuthMethod::Basic),
213        "digest" => Some(ProxyAuthMethod::Digest),
214        "negotiate" => Some(ProxyAuthMethod::Negotiate),
215        "ntlm" => Some(ProxyAuthMethod::Ntlm),
216        "digest_ie" | "digestie" => Some(ProxyAuthMethod::DigestIe),
217        "ntlm_wb" | "ntlmwb" => Some(ProxyAuthMethod::NtlmWb),
218        _ => None,
219    }
220}
221
222fn parse_proxy(val: &str) -> ProxySetting {
223    let trimmed = val.trim();
224    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("_none_") {
225        return ProxySetting::Disabled;
226    }
227    match Url::from_str(trimmed) {
228        Ok(url) => ProxySetting::Url(url),
229        Err(_) => ProxySetting::Raw(trimmed.to_owned()),
230    }
231}
232
233fn parse_multilib_policy(val: &str) -> Option<MultilibPolicy> {
234    match val.trim().to_lowercase().as_str() {
235        "best" => Some(MultilibPolicy::Best),
236        "all" => Some(MultilibPolicy::All),
237        _ => None,
238    }
239}
240
241fn parse_persistence(val: &str) -> Option<Persistence> {
242    match val.trim().to_lowercase().as_str() {
243        "auto" => Some(Persistence::Auto),
244        "transient" => Some(Persistence::Transient),
245        "persist" => Some(Persistence::Persist),
246        _ => None,
247    }
248}
249
250fn parse_rpmverbosity(val: &str) -> Option<RpmVerbosity> {
251    match val.trim().to_lowercase().as_str() {
252        "critical" => Some(RpmVerbosity::Critical),
253        "emergency" => Some(RpmVerbosity::Emergency),
254        "error" => Some(RpmVerbosity::Error),
255        "warn" => Some(RpmVerbosity::Warn),
256        "info" => Some(RpmVerbosity::Info),
257        "debug" => Some(RpmVerbosity::Debug),
258        _ => None,
259    }
260}
261
262fn parse_tsflags(val: &str) -> Vec<TsFlag> {
263    val.split(|c: char| c == ',' || c.is_whitespace())
264        .filter_map(|s| match s.trim().to_lowercase().as_str() {
265            "notriggers" | "notrigger" => Some(TsFlag::NoTriggers),
266            "noscripts" | "noscript" => Some(TsFlag::NoScripts),
267            "test" => Some(TsFlag::Test),
268            "nodocs" | "nodoc" => Some(TsFlag::NoDocs),
269            "justdb" => Some(TsFlag::JustDb),
270            "nocontexts" => Some(TsFlag::NoContexts),
271            "nocaps" => Some(TsFlag::NoCaps),
272            "nocrypto" => Some(TsFlag::NoCrypto),
273            "deploops" => Some(TsFlag::Deploops),
274            "noplugins" => Some(TsFlag::NoPlugins),
275            _ => None,
276        })
277        .collect()
278}
279
280fn parse_storage_size(val: &str) -> Option<StorageSize> {
281    let trimmed = val.trim();
282    if trimmed.is_empty() {
283        return None;
284    }
285    let (num_str, multiplier) = match trimmed.chars().last() {
286        Some('k') | Some('K') => (&trimmed[..trimmed.len() - 1], 1024),
287        Some('M') => (&trimmed[..trimmed.len() - 1], 1024 * 1024),
288        Some('G') => (&trimmed[..trimmed.len() - 1], 1024 * 1024 * 1024),
289        _ => (trimmed, 1),
290    };
291    let num: u64 = num_str.trim().parse().ok()?;
292    let bytes = num.checked_mul(multiplier)?;
293    Some(StorageSize(bytes))
294}
295
296fn parse_metadata_expire(val: &str) -> Option<MetadataExpire> {
297    let trimmed = val.trim();
298    if trimmed.eq_ignore_ascii_case("never") {
299        return Some(MetadataExpire::Never);
300    }
301    if let Ok(secs) = trimmed.parse::<u64>() {
302        return Some(MetadataExpire::Duration(secs));
303    }
304    None
305}
306
307fn parse_throttle(val: &str) -> Option<Throttle> {
308    let trimmed = val.trim();
309    if trimmed.is_empty() {
310        return None;
311    }
312    // Try percentage format (nn%)
313    if let Some(pct_str) = trimmed.strip_suffix('%') {
314        if let Ok(pct) = pct_str.trim().parse::<u8>() {
315            if pct <= 100 {
316                return Some(Throttle::Percent(pct));
317            }
318        }
319    }
320    // Try absolute storage size
321    parse_storage_size(trimmed).map(Throttle::Absolute)
322}
323
324fn parse_repo_type(val: &str) -> Option<RepoMetadataType> {
325    match val.trim().to_lowercase().as_str() {
326        "rpm-md" | "rpm" => Some(RepoMetadataType::RpmMd),
327        _ => None,
328    }
329}
330
331// ============================================================================
332// Section entry parsers
333// ============================================================================
334
335/// Parse raw entries into a `Repo` struct, extracting typed values for known
336/// keys and stashing unknown keys into `repo.extras`.
337fn parse_entries_into_repo(
338    repo: &mut Repo,
339    entries: &[RawLine],
340) -> (Vec<String>, IndexMap<String, String>, Vec<RawEntry>) {
341    let mut item_order = Vec::new();
342    let mut item_comments = IndexMap::new();
343    let mut raw_entries = Vec::new();
344
345    for entry in entries {
346        let key = entry.key.clone();
347        let value = entry.value.clone();
348
349        // Always track raw entry for round-trip fidelity
350        raw_entries.push(RawEntry {
351            key: key.clone(),
352            value: value.clone(),
353            inline_comment: entry.inline_comment.clone(),
354            leading_comments: entry.leading_comments.clone(),
355        });
356
357        item_order.push(key.clone());
358        if let Some(ref ic) = entry.inline_comment {
359            item_comments.insert(key.clone(), ic.clone());
360        }
361
362        match key.as_str() {
363            // ---- Identifiers ----
364            "name" => {
365                if let Ok(v) = RepoName::from_str(&value) {
366                    repo.name = Some(v);
367                }
368            }
369            "mediaid" => {
370                repo.mediaid = Some(value.clone());
371            }
372
373            // ---- URL sources ----
374            "baseurl" => {
375                if let Ok(v) = Url::from_str(&value) {
376                    repo.baseurl.push(v);
377                }
378            }
379            "mirrorlist" => {
380                if let Ok(v) = Url::from_str(&value) {
381                    repo.mirrorlist = Some(v);
382                }
383            }
384            "metalink" => {
385                if let Ok(v) = Url::from_str(&value) {
386                    repo.metalink = Some(v);
387                }
388            }
389
390            // ---- String lists ----
391            "gpgkey" => {
392                repo.gpgkey.push(value.clone());
393            }
394            "enabled_metadata" => {
395                repo.enabled_metadata.push(value.clone());
396            }
397            "excludepkgs" => {
398                repo.excludepkgs.push(value.clone());
399            }
400            "includepkgs" => {
401                repo.includepkgs.push(value.clone());
402            }
403
404            // ---- DNF booleans ----
405            "enabled" => {
406                if let Ok(v) = DnfBool::parse(&value) {
407                    repo.enabled = Some(v);
408                }
409            }
410            "module_hotfixes" => {
411                if let Ok(v) = DnfBool::parse(&value) {
412                    repo.module_hotfixes = Some(v);
413                }
414            }
415            "gpgcheck" => {
416                if let Ok(v) = DnfBool::parse(&value) {
417                    repo.gpgcheck = Some(v);
418                }
419            }
420            "repo_gpgcheck" => {
421                if let Ok(v) = DnfBool::parse(&value) {
422                    repo.repo_gpgcheck = Some(v);
423                }
424            }
425            "localpkg_gpgcheck" => {
426                if let Ok(v) = DnfBool::parse(&value) {
427                    repo.localpkg_gpgcheck = Some(v);
428                }
429            }
430            "skip_if_unavailable" => {
431                if let Ok(v) = DnfBool::parse(&value) {
432                    repo.skip_if_unavailable = Some(v);
433                }
434            }
435            "deltarpm" => {
436                if let Ok(v) = DnfBool::parse(&value) {
437                    repo.deltarpm = Some(v);
438                }
439            }
440            "enablegroups" => {
441                if let Ok(v) = DnfBool::parse(&value) {
442                    repo.enablegroups = Some(v);
443                }
444            }
445            "fastestmirror" => {
446                if let Ok(v) = DnfBool::parse(&value) {
447                    repo.fastestmirror = Some(v);
448                }
449            }
450            "countme" => {
451                if let Ok(v) = DnfBool::parse(&value) {
452                    repo.countme = Some(v);
453                }
454            }
455            "sslverify" => {
456                if let Ok(v) = DnfBool::parse(&value) {
457                    repo.sslverify = Some(v);
458                }
459            }
460            "sslverifystatus" => {
461                if let Ok(v) = DnfBool::parse(&value) {
462                    repo.sslverifystatus = Some(v);
463                }
464            }
465            "proxy_sslverify" => {
466                if let Ok(v) = DnfBool::parse(&value) {
467                    repo.proxy_sslverify = Some(v);
468                }
469            }
470
471            // ---- Numerics ----
472            "priority" => {
473                if let Some(v) = try_parse_nutype!(&value, Priority, i32) {
474                    repo.priority = Some(v);
475                }
476            }
477            "cost" => {
478                if let Some(v) = try_parse_nutype!(&value, Cost, i32) {
479                    repo.cost = Some(v);
480                }
481            }
482            "deltarpm_percentage" => {
483                if let Some(v) = try_parse_nutype!(&value, DeltaRpmPercentage, u32) {
484                    repo.deltarpm_percentage = Some(v);
485                }
486            }
487            "retries" => {
488                if let Some(v) = try_parse_nutype!(&value, Retries, u32) {
489                    repo.retries = Some(v);
490                }
491            }
492            "timeout" => {
493                if let Some(v) = try_parse_nutype!(&value, TimeoutSeconds, u32) {
494                    repo.timeout = Some(v);
495                }
496            }
497            "max_parallel_downloads" => {
498                if let Some(v) = try_parse_nutype!(&value, MaxParallelDownloads, u32) {
499                    repo.max_parallel_downloads = Some(v);
500                }
501            }
502
503            // ---- Storage sizes ----
504            "bandwidth" => {
505                if let Some(v) = parse_storage_size(&value) {
506                    repo.bandwidth = Some(v);
507                }
508            }
509            "minrate" => {
510                if let Some(v) = parse_storage_size(&value) {
511                    repo.minrate = Some(v);
512                }
513            }
514
515            // ---- Throttle ----
516            "throttle" => {
517                if let Some(v) = parse_throttle(&value) {
518                    repo.throttle = Some(v);
519                }
520            }
521
522            // ---- Metadata expire ----
523            "metadata_expire" => {
524                if let Some(v) = parse_metadata_expire(&value) {
525                    repo.metadata_expire = Some(v);
526                }
527            }
528
529            // ---- IP resolve ----
530            "ip_resolve" => {
531                if let Some(v) = parse_ip_resolve(&value) {
532                    repo.ip_resolve = Some(v);
533                }
534            }
535
536            // ---- SSL path fields ----
537            "sslcacert" => {
538                repo.sslcacert = Some(Utf8PathBuf::from(&value));
539            }
540            "sslclientcert" => {
541                repo.sslclientcert = Some(Utf8PathBuf::from(&value));
542            }
543            "sslclientkey" => {
544                repo.sslclientkey = Some(Utf8PathBuf::from(&value));
545            }
546
547            // ---- Proxy ----
548            "proxy" => {
549                repo.proxy = parse_proxy(&value);
550            }
551            "proxy_username" => {
552                repo.proxy_username = ProxyUsername::from_str(&value).ok();
553            }
554            "proxy_password" => {
555                repo.proxy_password = ProxyPassword::from_str(&value).ok();
556            }
557            "proxy_auth_method" => {
558                if let Some(v) = parse_proxy_auth_method(&value) {
559                    repo.proxy_auth_method = Some(v);
560                }
561            }
562            "proxy_sslcacert" => {
563                repo.proxy_sslcacert = Some(Utf8PathBuf::from(&value));
564            }
565            "proxy_sslclientcert" => {
566                repo.proxy_sslclientcert = Some(Utf8PathBuf::from(&value));
567            }
568            "proxy_sslclientkey" => {
569                repo.proxy_sslclientkey = Some(Utf8PathBuf::from(&value));
570            }
571
572            // ---- Authentication ----
573            "username" => {
574                repo.username = Username::from_str(&value).ok();
575            }
576            "password" => {
577                repo.password = Password::from_str(&value).ok();
578            }
579            "user_agent" => {
580                repo.user_agent = UserAgent::from_str(&value).ok();
581            }
582
583            // ---- Type ----
584            "type" => {
585                if let Some(v) = parse_repo_type(&value) {
586                    repo.metadata_type = Some(v);
587                }
588            }
589
590            // ---- Unknown -> extras ----
591            _ => {
592                repo.extras
593                    .entry(key.clone())
594                    .or_default()
595                    .push(value.clone());
596            }
597        }
598    }
599
600    (item_order, item_comments, raw_entries)
601}
602
603/// Parse raw entries into a `MainConfig` struct.
604fn parse_entries_into_mainconfig(
605    config: &mut MainConfig,
606    entries: &[RawLine],
607) -> (Vec<String>, IndexMap<String, String>, Vec<RawEntry>) {
608    let mut item_order = Vec::new();
609    let mut item_comments = IndexMap::new();
610    let mut raw_entries = Vec::new();
611
612    for entry in entries {
613        let key = entry.key.clone();
614        let value = entry.value.clone();
615
616        raw_entries.push(RawEntry {
617            key: key.clone(),
618            value: value.clone(),
619            inline_comment: entry.inline_comment.clone(),
620            leading_comments: entry.leading_comments.clone(),
621        });
622
623        item_order.push(key.clone());
624        if let Some(ref ic) = entry.inline_comment {
625            item_comments.insert(key.clone(), ic.clone());
626        }
627
628        match key.as_str() {
629            // ---- String fields ----
630            "arch" => config.arch = Some(value.clone()),
631            "basearch" => config.basearch = Some(value.clone()),
632            "releasever" => config.releasever = Some(value.clone()),
633
634            // ---- Path fields ----
635            "cachedir" => config.cachedir = Some(Utf8PathBuf::from(&value)),
636            "persistdir" => config.persistdir = Some(Utf8PathBuf::from(&value)),
637            "logdir" => config.logdir = Some(Utf8PathBuf::from(&value)),
638            "config_file_path" => config.config_file_path = Some(Utf8PathBuf::from(&value)),
639            "installroot" => config.installroot = Some(Utf8PathBuf::from(&value)),
640
641            // ---- Path lists ----
642            "reposdir" => config.reposdir.push(Utf8PathBuf::from(&value)),
643            "varsdir" => config.varsdir.push(Utf8PathBuf::from(&value)),
644            "pluginconfpath" => config.pluginconfpath.push(Utf8PathBuf::from(&value)),
645            "pluginpath" => config.pluginpath.push(Utf8PathBuf::from(&value)),
646
647            // ---- String lists ----
648            "installonlypkgs" => config.installonlypkgs.push(value.clone()),
649            "protected_packages" => config.protected_packages.push(value.clone()),
650            "exclude_from_weak" => config.exclude_from_weak.push(value.clone()),
651            "group_package_types" => config.group_package_types.push(value.clone()),
652            "optional_metadata_types" => config.optional_metadata_types.push(value.clone()),
653            "usr_drift_protected_paths" => config.usr_drift_protected_paths.push(value.clone()),
654
655            // ---- DNF booleans ----
656            "allow_vendor_change" => {
657                if let Ok(v) = DnfBool::parse(&value) {
658                    config.allow_vendor_change = Some(v);
659                }
660            }
661            "assumeno" => {
662                if let Ok(v) = DnfBool::parse(&value) {
663                    config.assumeno = Some(v);
664                }
665            }
666            "assumeyes" => {
667                if let Ok(v) = DnfBool::parse(&value) {
668                    config.assumeyes = Some(v);
669                }
670            }
671            "autocheck_running_kernel" => {
672                if let Ok(v) = DnfBool::parse(&value) {
673                    config.autocheck_running_kernel = Some(v);
674                }
675            }
676            "best" => {
677                if let Ok(v) = DnfBool::parse(&value) {
678                    config.best = Some(v);
679                }
680            }
681            "cacheonly" => {
682                if let Ok(v) = DnfBool::parse(&value) {
683                    config.cacheonly = Some(v);
684                }
685            }
686            "check_config_file_age" => {
687                if let Ok(v) = DnfBool::parse(&value) {
688                    config.check_config_file_age = Some(v);
689                }
690            }
691            "clean_requirements_on_remove" => {
692                if let Ok(v) = DnfBool::parse(&value) {
693                    config.clean_requirements_on_remove = Some(v);
694                }
695            }
696            "debug_solver" => {
697                if let Ok(v) = DnfBool::parse(&value) {
698                    config.debug_solver = Some(v);
699                }
700            }
701            "defaultyes" => {
702                if let Ok(v) = DnfBool::parse(&value) {
703                    config.defaultyes = Some(v);
704                }
705            }
706            "diskspacecheck" => {
707                if let Ok(v) = DnfBool::parse(&value) {
708                    config.diskspacecheck = Some(v);
709                }
710            }
711            "exclude_from_weak_autodetect" => {
712                if let Ok(v) = DnfBool::parse(&value) {
713                    config.exclude_from_weak_autodetect = Some(v);
714                }
715            }
716            "exit_on_lock" => {
717                if let Ok(v) = DnfBool::parse(&value) {
718                    config.exit_on_lock = Some(v);
719                }
720            }
721            "gpgkey_dns_verification" => {
722                if let Ok(v) = DnfBool::parse(&value) {
723                    config.gpgkey_dns_verification = Some(v);
724                }
725            }
726            "ignorearch" => {
727                if let Ok(v) = DnfBool::parse(&value) {
728                    config.ignorearch = Some(v);
729                }
730            }
731            "install_weak_deps" => {
732                if let Ok(v) = DnfBool::parse(&value) {
733                    config.install_weak_deps = Some(v);
734                }
735            }
736            "keepcache" => {
737                if let Ok(v) = DnfBool::parse(&value) {
738                    config.keepcache = Some(v);
739                }
740            }
741            "log_compress" => {
742                if let Ok(v) = DnfBool::parse(&value) {
743                    config.log_compress = Some(v);
744                }
745            }
746            "module_obsoletes" => {
747                if let Ok(v) = DnfBool::parse(&value) {
748                    config.module_obsoletes = Some(v);
749                }
750            }
751            "module_stream_switch" => {
752                if let Ok(v) = DnfBool::parse(&value) {
753                    config.module_stream_switch = Some(v);
754                }
755            }
756            "obsoletes" => {
757                if let Ok(v) = DnfBool::parse(&value) {
758                    config.obsoletes = Some(v);
759                }
760            }
761            "plugins" => {
762                if let Ok(v) = DnfBool::parse(&value) {
763                    config.plugins = Some(v);
764                }
765            }
766            "protect_running_kernel" => {
767                if let Ok(v) = DnfBool::parse(&value) {
768                    config.protect_running_kernel = Some(v);
769                }
770            }
771            "strict" => {
772                if let Ok(v) = DnfBool::parse(&value) {
773                    config.strict = Some(v);
774                }
775            }
776            "upgrade_group_objects_upgrade" => {
777                if let Ok(v) = DnfBool::parse(&value) {
778                    config.upgrade_group_objects_upgrade = Some(v);
779                }
780            }
781            "zchunk" => {
782                if let Ok(v) = DnfBool::parse(&value) {
783                    config.zchunk = Some(v);
784                }
785            }
786
787            // ---- Numerics ----
788            "debuglevel" => {
789                if let Some(v) = try_parse_nutype!(&value, DebugLevel, u8) {
790                    config.debuglevel = Some(v);
791                }
792            }
793            "logfilelevel" => {
794                if let Some(v) = try_parse_nutype!(&value, LogLevel, u8) {
795                    config.logfilelevel = Some(v);
796                }
797            }
798            "log_rotate" => {
799                if let Some(v) = try_parse_nutype!(&value, LogRotate, u32) {
800                    config.log_rotate = Some(v);
801                }
802            }
803            "installonly_limit" => {
804                if let Some(v) = try_parse_nutype!(&value, InstallOnlyLimit, u32) {
805                    config.installonly_limit = Some(v);
806                }
807            }
808            "errorlevel" => {
809                if let Some(v) = try_parse_nutype!(&value, ErrorLevel, u8) {
810                    config.errorlevel = Some(v);
811                }
812            }
813            "metadata_timer_sync" => {
814                if let Some(v) = try_parse_nutype!(&value, MetadataTimerSync, u32) {
815                    config.metadata_timer_sync = Some(v);
816                }
817            }
818
819            // ---- Storage size ----
820            "log_size" => {
821                if let Some(v) = parse_storage_size(&value) {
822                    config.log_size = Some(v);
823                }
824            }
825
826            // ---- Enums ----
827            "multilib_policy" => {
828                if let Some(v) = parse_multilib_policy(&value) {
829                    config.multilib_policy = Some(v);
830                }
831            }
832            "persistence" => {
833                if let Some(v) = parse_persistence(&value) {
834                    config.persistence = Some(v);
835                }
836            }
837            "rpmverbosity" => {
838                if let Some(v) = parse_rpmverbosity(&value) {
839                    config.rpmverbosity = Some(v);
840                }
841            }
842
843            // ---- TsFlags ----
844            "tsflags" => {
845                let flags = parse_tsflags(&value);
846                config.tsflags.extend(flags);
847            }
848
849            // ---- Module platform id ----
850            "module_platform_id" => {
851                config.module_platform_id = ModulePlatformId::from_str(&value).ok();
852            }
853
854            // ---- Unknown -> extras ----
855            _ => {
856                config
857                    .extras
858                    .entry(key.clone())
859                    .or_default()
860                    .push(value.clone());
861            }
862        }
863    }
864
865    (item_order, item_comments, raw_entries)
866}
867
868// ============================================================================
869// Build RepoFile from ParseState
870// ============================================================================
871
872fn build_repofile(state: ParseState) -> std::result::Result<RepoFile, ParseError> {
873    let mut rf = RepoFile::new();
874    rf.preamble = state.preamble;
875
876    for (sec_name, entries) in &state.sections {
877        let header_comments = state
878            .section_header_comments
879            .get(sec_name)
880            .cloned()
881            .unwrap_or_default();
882        if sec_name == "main" {
883            let mut mc = MainConfig::default();
884            let (io, ic, re) = parse_entries_into_mainconfig(&mut mc, entries);
885            rf.main = Some(SectionBlock {
886                header_comments,
887                data: mc,
888                item_comments: ic,
889                item_order: io,
890                raw_entries: re,
891            });
892        } else {
893            let repo_id =
894                RepoId::try_new(sec_name.as_str()).map_err(|_| ParseError::InvalidRepoId {
895                    id: sec_name.clone(),
896                    reason: "invalid characters in repo ID".into(),
897                })?;
898            let mut repo = Repo::new(repo_id);
899            let (io, ic, re) = parse_entries_into_repo(&mut repo, entries);
900            rf.repos.insert(
901                repo.id.clone(),
902                SectionBlock {
903                    header_comments,
904                    data: repo,
905                    item_comments: ic,
906                    item_order: io,
907                    raw_entries: re,
908                },
909            );
910        }
911    }
912    Ok(rf)
913}
914
915// ============================================================================
916// RepoFile implementation
917// ============================================================================
918
919impl RepoFile {
920    /// Create an empty [`RepoFile`] with no sections.
921    ///
922    /// # Examples
923    ///
924    /// ```
925    /// use dnf_repofile::RepoFile;
926    ///
927    /// let rf = RepoFile::new();
928    /// assert!(rf.is_empty());
929    /// assert!(rf.main().is_none());
930    /// ```
931    pub fn new() -> Self {
932        RepoFile {
933            preamble: Vec::new(),
934            main: None,
935            repos: IndexMap::new(),
936        }
937    }
938
939    /// Parse a `.repo` file string into a [`RepoFile`].
940    ///
941    /// Handles INI syntax with `[section]` headers, `key=value` pairs,
942    /// `#` and `;` comment lines, inline comments, and blank lines.
943    /// The `[main]` section is parsed into a [`MainConfig`], while
944    /// other sections become [`Repo`] values.
945    ///
946    /// # Errors
947    ///
948    /// Returns [`ParseError`] for malformed input: invalid section headers,
949    /// missing `=` separators, empty section names, or invalid repo IDs.
950    ///
951    /// # Examples
952    ///
953    /// ```
954    /// use dnf_repofile::RepoFile;
955    ///
956    /// let input = "[test]\nname=Test\nbaseurl=https://example.com/\n";
957    /// let rf = RepoFile::parse(input).unwrap();
958    /// assert_eq!(rf.len(), 1);
959    /// ```
960    pub fn parse(input: &str) -> std::result::Result<Self, ParseError> {
961        let mut state = ParseState {
962            preamble: Vec::new(),
963            pending_comments: Vec::new(),
964            current_section: None,
965            current_entries: Vec::new(),
966            sections: IndexMap::new(),
967            section_header_comments: IndexMap::new(),
968        };
969
970        for (line_idx, raw_line) in input.lines().enumerate() {
971            let trimmed = raw_line.trim();
972
973            if trimmed.is_empty() {
974                if state.current_section.is_some() {
975                    state.pending_comments.push(String::new());
976                } else {
977                    state.preamble.push(String::new());
978                }
979                continue;
980            }
981
982            if trimmed.starts_with('#') || trimmed.starts_with(';') {
983                if state.current_section.is_some() {
984                    state.pending_comments.push(raw_line.to_owned());
985                } else {
986                    state.preamble.push(raw_line.to_owned());
987                }
988                continue;
989            }
990
991            if trimmed.starts_with('[') && trimmed.ends_with(']') {
992                // Flush current section (keep pending_comments for the new section)
993                if let Some(ref sec_name) = state.current_section.take() {
994                    state
995                        .sections
996                        .insert(sec_name.clone(), std::mem::take(&mut state.current_entries));
997                }
998
999                let section_name = trimmed[1..trimmed.len() - 1].trim().to_string();
1000                if section_name.is_empty() {
1001                    return Err(ParseError::EmptySectionName);
1002                }
1003                if section_name != "main" && RepoId::try_new(section_name.as_str()).is_err() {
1004                    return Err(ParseError::InvalidRepoId {
1005                        id: section_name.clone(),
1006                        reason: "invalid characters in repo ID".into(),
1007                    });
1008                }
1009
1010                // Assign pending comments as header_comments of the NEW section
1011                if !state.pending_comments.is_empty() {
1012                    state.section_header_comments.insert(
1013                        section_name.clone(),
1014                        std::mem::take(&mut state.pending_comments),
1015                    );
1016                }
1017
1018                state.current_section = Some(section_name);
1019                continue;
1020            }
1021
1022            if let Some(eq_pos) = trimmed.find('=') {
1023                let key = trimmed[..eq_pos].trim().to_string();
1024                let value_part = &trimmed[eq_pos + 1..];
1025                let (value, inline_comment) = split_value_and_comment(value_part);
1026
1027                if key.is_empty() {
1028                    return Err(ParseError::MissingEquals {
1029                        line: line_idx + 1,
1030                        line_text: raw_line.to_owned(),
1031                    });
1032                }
1033
1034                let entry = RawLine {
1035                    key,
1036                    value: value.trim().to_string(),
1037                    inline_comment,
1038                    leading_comments: std::mem::take(&mut state.pending_comments),
1039                };
1040
1041                if state.current_section.is_some() {
1042                    state.current_entries.push(entry);
1043                } else {
1044                    state.preamble.push(raw_line.to_owned());
1045                }
1046            } else {
1047                return Err(ParseError::MissingEquals {
1048                    line: line_idx + 1,
1049                    line_text: raw_line.to_owned(),
1050                });
1051            }
1052        }
1053
1054        // Flush final section and any remaining pending comments
1055        if let Some(ref sec_name) = state.current_section.take() {
1056            if !state.pending_comments.is_empty()
1057                || !state.section_header_comments.contains_key(sec_name)
1058            {
1059                state.section_header_comments.insert(
1060                    sec_name.clone(),
1061                    std::mem::take(&mut state.pending_comments),
1062                );
1063            }
1064            state
1065                .sections
1066                .insert(sec_name.clone(), std::mem::take(&mut state.current_entries));
1067        } else if !state.pending_comments.is_empty() {
1068            state
1069                .preamble
1070                .extend(std::mem::take(&mut state.pending_comments));
1071        }
1072
1073        build_repofile(state)
1074    }
1075
1076    /// Render the [`RepoFile`] back to INI text.
1077    ///
1078    /// Preserves comments, blank lines, and entry ordering from the original
1079    /// parse. Entries are rendered in their original order via the `raw_entries`
1080    /// recorded during parsing.
1081    ///
1082    /// # Examples
1083    ///
1084    /// ```
1085    /// use dnf_repofile::RepoFile;
1086    ///
1087    /// let input = "[test]\nname=Test\nbaseurl=https://example.com/\n";
1088    /// let rf = RepoFile::parse(input).unwrap();
1089    /// let output = rf.render();
1090    /// assert!(output.contains("[test]"));
1091    /// assert!(output.contains("name=Test"));
1092    /// ```
1093    #[must_use]
1094    pub fn render(&self) -> String {
1095        let mut out = String::new();
1096        for line in &self.preamble {
1097            render_line(&mut out, line);
1098        }
1099        if let Some(ref block) = self.main {
1100            for c in &block.header_comments {
1101                render_line(&mut out, c);
1102            }
1103            out.push_str("[main]\n");
1104            render_section_entries(&mut out, block);
1105        }
1106        for (repo_id, block) in &self.repos {
1107            for c in &block.header_comments {
1108                render_line(&mut out, c);
1109            }
1110            out.push_str(&format!("[{}]\n", repo_id.as_ref()));
1111            render_section_entries(&mut out, block);
1112        }
1113        out
1114    }
1115
1116    // ---- Accessors ----
1117
1118    /// Get a reference to a repository section by [`RepoId`].
1119    ///
1120    /// Returns `None` if no repo with this ID exists.
1121    ///
1122    /// # Examples
1123    ///
1124    /// ```
1125    /// use dnf_repofile::{RepoFile, RepoId};
1126    ///
1127    /// let rf = RepoFile::parse("[epel]\nname=EPEL\nbaseurl=https://example.com/\n").unwrap();
1128    /// let block = rf.get(&RepoId::try_new("epel").unwrap()).unwrap();
1129    /// assert_eq!(block.data.name.as_ref().unwrap().as_ref(), "EPEL");
1130    /// ```
1131    pub fn get(&self, id: &RepoId) -> Option<&SectionBlock<Repo>> {
1132        self.repos.get(id)
1133    }
1134
1135    /// Get a mutable reference to a repository section by [`RepoId`].
1136    ///
1137    /// Returns `None` if no repo with this ID exists.
1138    ///
1139    /// # Examples
1140    ///
1141    /// ```
1142    /// use dnf_repofile::{RepoFile, RepoId, DnfBool};
1143    ///
1144    /// let mut rf = RepoFile::parse("[epel]\nname=EPEL\nbaseurl=https://example.com/\n").unwrap();
1145    /// let block = rf.get_mut(&RepoId::try_new("epel").unwrap()).unwrap();
1146    /// block.data.enabled = Some(DnfBool::False);
1147    /// ```
1148    pub fn get_mut(&mut self, id: &RepoId) -> Option<&mut SectionBlock<Repo>> {
1149        self.repos.get_mut(id)
1150    }
1151
1152    /// Add a repository to the file.
1153    ///
1154    /// The repo's ID is used as the section name.
1155    ///
1156    /// # Errors
1157    ///
1158    /// Returns [`Error::DuplicateRepo`](crate::Error::DuplicateRepo) if a repo with the same
1159    /// ID already exists.
1160    ///
1161    /// # Examples
1162    ///
1163    /// ```
1164    /// use dnf_repofile::{RepoFile, Repo, RepoId};
1165    ///
1166    /// let mut rf = RepoFile::new();
1167    /// let repo = Repo::new(RepoId::try_new("custom").unwrap());
1168    /// rf.add(repo).unwrap();
1169    /// assert_eq!(rf.len(), 1);
1170    /// ```
1171    pub fn add(&mut self, repo: Repo) -> Result<()> {
1172        let id = repo.id.clone();
1173        if self.repos.contains_key(&id) {
1174            return Err(Error::DuplicateRepo(id.to_string()));
1175        }
1176        self.repos.insert(
1177            id,
1178            SectionBlock {
1179                header_comments: Vec::new(),
1180                data: repo,
1181                item_comments: IndexMap::new(),
1182                item_order: Vec::new(),
1183                raw_entries: Vec::new(),
1184            },
1185        );
1186        Ok(())
1187    }
1188
1189    /// Insert or replace a repository by ID.
1190    ///
1191    /// Unlike [`add`](RepoFile::add), this will overwrite any existing repo
1192    /// with the same ID.
1193    ///
1194    /// # Examples
1195    ///
1196    /// ```
1197    /// use dnf_repofile::{RepoFile, Repo, RepoId};
1198    ///
1199    /// let mut rf = RepoFile::new();
1200    /// rf.set(Repo::new(RepoId::try_new("test").unwrap()));
1201    /// assert_eq!(rf.len(), 1);
1202    /// ```
1203    pub fn set(&mut self, repo: Repo) {
1204        let id = repo.id.clone();
1205        self.repos.insert(
1206            id,
1207            SectionBlock {
1208                header_comments: Vec::new(),
1209                data: repo,
1210                item_comments: IndexMap::new(),
1211                item_order: Vec::new(),
1212                raw_entries: Vec::new(),
1213            },
1214        );
1215    }
1216
1217    /// Remove a repository by [`RepoId`] and return its section, if present.
1218    ///
1219    /// # Examples
1220    ///
1221    /// ```
1222    /// use dnf_repofile::{RepoFile, Repo, RepoId};
1223    ///
1224    /// let mut rf = RepoFile::new();
1225    /// rf.set(Repo::new(RepoId::try_new("test").unwrap()));
1226    /// let removed = rf.remove(&RepoId::try_new("test").unwrap());
1227    /// assert!(removed.is_some());
1228    /// assert!(rf.is_empty());
1229    /// ```
1230    pub fn remove(&mut self, id: &RepoId) -> Option<SectionBlock<Repo>> {
1231        self.repos.shift_remove(id)
1232    }
1233
1234    /// Check if a repository with the given [`RepoId`] exists.
1235    ///
1236    /// # Examples
1237    ///
1238    /// ```
1239    /// use dnf_repofile::{RepoFile, Repo, RepoId};
1240    ///
1241    /// let mut rf = RepoFile::new();
1242    /// rf.set(Repo::new(RepoId::try_new("test").unwrap()));
1243    /// assert!(rf.contains(&RepoId::try_new("test").unwrap()));
1244    /// ```
1245    pub fn contains(&self, id: &RepoId) -> bool {
1246        self.repos.contains_key(id)
1247    }
1248
1249    /// Return the number of repository sections.
1250    ///
1251    /// # Examples
1252    ///
1253    /// ```
1254    /// use dnf_repofile::{RepoFile, Repo, RepoId};
1255    ///
1256    /// let mut rf = RepoFile::new();
1257    /// rf.set(Repo::new(RepoId::try_new("a").unwrap()));
1258    /// rf.set(Repo::new(RepoId::try_new("b").unwrap()));
1259    /// assert_eq!(rf.len(), 2);
1260    /// ```
1261    pub fn len(&self) -> usize {
1262        self.repos.len()
1263    }
1264
1265    /// Returns `true` if there are no repository sections.
1266    ///
1267    /// # Examples
1268    ///
1269    /// ```
1270    /// use dnf_repofile::RepoFile;
1271    ///
1272    /// let rf = RepoFile::new();
1273    /// assert!(rf.is_empty());
1274    /// ```
1275    pub fn is_empty(&self) -> bool {
1276        self.repos.is_empty()
1277    }
1278
1279    /// Iterate over all `(RepoId, SectionBlock<Repo>)` pairs.
1280    ///
1281    /// # Examples
1282    ///
1283    /// ```
1284    /// use dnf_repofile::{RepoFile, Repo, RepoId};
1285    ///
1286    /// let mut rf = RepoFile::new();
1287    /// rf.set(Repo::new(RepoId::try_new("test").unwrap()));
1288    /// for (id, _block) in rf.iter() {
1289    ///     println!("Found repo: {}", id);
1290    /// }
1291    /// ```
1292    pub fn iter(&self) -> impl Iterator<Item = (&RepoId, &SectionBlock<Repo>)> {
1293        self.repos.iter()
1294    }
1295
1296    /// Iterate over all [`Repo`] data values (wrapping `SectionBlock`).
1297    ///
1298    /// # Examples
1299    ///
1300    /// ```
1301    /// use dnf_repofile::{RepoFile, Repo, RepoId};
1302    ///
1303    /// let mut rf = RepoFile::new();
1304    /// rf.set(Repo::new(RepoId::try_new("test").unwrap()));
1305    /// for repo in rf.repos() {
1306    ///     println!("Repo ID: {}", repo.id);
1307    /// }
1308    /// ```
1309    pub fn repos(&self) -> impl Iterator<Item = &Repo> {
1310        self.repos.values().map(|block| &block.data)
1311    }
1312
1313    /// Iterate over all repository IDs.
1314    ///
1315    /// # Examples
1316    ///
1317    /// ```
1318    /// use dnf_repofile::{RepoFile, Repo, RepoId};
1319    ///
1320    /// let mut rf = RepoFile::new();
1321    /// rf.set(Repo::new(RepoId::try_new("test").unwrap()));
1322    /// let ids: Vec<&RepoId> = rf.repo_ids().collect();
1323    /// assert_eq!(ids.len(), 1);
1324    /// ```
1325    pub fn repo_ids(&self) -> impl Iterator<Item = &RepoId> {
1326        self.repos.keys()
1327    }
1328
1329    /// Get a reference to the `[main]` section, if present.
1330    ///
1331    /// # Examples
1332    ///
1333    /// ```
1334    /// use dnf_repofile::RepoFile;
1335    ///
1336    /// let rf = RepoFile::parse("[main]\ncachedir=/var/cache/dnf\n").unwrap();
1337    /// assert!(rf.main().is_some());
1338    /// ```
1339    pub fn main(&self) -> Option<&SectionBlock<MainConfig>> {
1340        self.main.as_ref()
1341    }
1342
1343    /// Get a mutable reference to the `[main]` section, if present.
1344    ///
1345    /// # Examples
1346    ///
1347    /// ```
1348    /// use dnf_repofile::RepoFile;
1349    ///
1350    /// let mut rf = RepoFile::parse("[main]\ncachedir=/var/cache/dnf\n").unwrap();
1351    /// if let Some(main) = rf.main_mut() {
1352    ///     main.data.keepcache = Some(dnf_repofile::DnfBool::True);
1353    /// }
1354    /// ```
1355    pub fn main_mut(&mut self) -> Option<&mut SectionBlock<MainConfig>> {
1356        self.main.as_mut()
1357    }
1358
1359    /// Set the `[main]` configuration, replacing any existing one.
1360    ///
1361    /// # Examples
1362    ///
1363    /// ```
1364    /// use dnf_repofile::{RepoFile, MainConfig};
1365    ///
1366    /// let mut rf = RepoFile::new();
1367    /// rf.set_main(MainConfig::default());
1368    /// assert!(rf.main().is_some());
1369    /// ```
1370    pub fn set_main(&mut self, config: MainConfig) {
1371        self.main = Some(SectionBlock {
1372            header_comments: Vec::new(),
1373            data: config,
1374            item_comments: IndexMap::new(),
1375            item_order: Vec::new(),
1376            raw_entries: Vec::new(),
1377        });
1378    }
1379
1380    /// Remove the `[main]` section, if present.
1381    ///
1382    /// # Examples
1383    ///
1384    /// ```
1385    /// use dnf_repofile::{RepoFile, MainConfig};
1386    ///
1387    /// let mut rf = RepoFile::new();
1388    /// rf.set_main(MainConfig::default());
1389    /// rf.remove_main();
1390    /// assert!(rf.main().is_none());
1391    /// ```
1392    pub fn remove_main(&mut self) {
1393        self.main = None;
1394    }
1395
1396    /// Merge another [`RepoFile`] into this one.
1397    ///
1398    /// # `[main]` merge strategy
1399    ///
1400    /// For each field in the other `[main]` section, if the other's field is
1401    /// `Some` and self's field is `None`, the value is copied over (i.e., other's
1402    /// values fill self's gaps). Other's inline comments are also added if not
1403    /// already present.
1404    ///
1405    /// # Repo merge strategy
1406    ///
1407    /// For repo sections, the other's repos overwrite self's repos by ID.
1408    /// If a repo with the same ID already exists, the other's version replaces
1409    /// it entirely. New repo IDs from the other file are appended.
1410    ///
1411    /// # Examples
1412    ///
1413    /// ```
1414    /// use dnf_repofile::RepoFile;
1415    ///
1416    /// let mut rf = RepoFile::parse("[a]\nname=A\nbaseurl=https://a.com/\n").unwrap();
1417    /// let other = RepoFile::parse("[b]\nname=B\nbaseurl=https://b.com/\n").unwrap();
1418    /// rf.merge(other);
1419    /// assert_eq!(rf.len(), 2);
1420    /// ```
1421    pub fn merge(&mut self, other: RepoFile) {
1422        if let Some(other_main) = other.main {
1423            if let Some(ref mut self_main) = self.main {
1424                merge_mainconfig(&mut self_main.data, &other_main.data);
1425                for (k, v) in other_main.item_comments {
1426                    self_main.item_comments.entry(k).or_insert(v);
1427                }
1428            } else {
1429                self.main = Some(other_main);
1430            }
1431        }
1432        for (id, block) in other.repos {
1433            self.repos.insert(id, block);
1434        }
1435    }
1436}
1437
1438fn merge_mainconfig(dest: &mut MainConfig, src: &MainConfig) {
1439    macro_rules! merge_opt {
1440        ($field:ident) => {
1441            if src.$field.is_some() && dest.$field.is_none() {
1442                dest.$field = src.$field.clone();
1443            }
1444        };
1445    }
1446    macro_rules! merge_vec {
1447        ($field:ident) => {
1448            dest.$field.extend(src.$field.iter().cloned());
1449        };
1450    }
1451    merge_opt!(arch);
1452    merge_opt!(basearch);
1453    merge_opt!(releasever);
1454    merge_opt!(cachedir);
1455    merge_opt!(persistdir);
1456    merge_opt!(logdir);
1457    merge_opt!(config_file_path);
1458    merge_opt!(installroot);
1459    merge_opt!(debuglevel);
1460    merge_opt!(logfilelevel);
1461    merge_opt!(log_rotate);
1462    merge_opt!(log_size);
1463    merge_opt!(installonly_limit);
1464    merge_opt!(errorlevel);
1465    merge_opt!(metadata_timer_sync);
1466    merge_opt!(allow_vendor_change);
1467    merge_opt!(assumeyes);
1468    merge_opt!(assumeno);
1469    merge_opt!(autocheck_running_kernel);
1470    merge_opt!(best);
1471    merge_opt!(cacheonly);
1472    merge_opt!(check_config_file_age);
1473    merge_opt!(clean_requirements_on_remove);
1474    merge_opt!(debug_solver);
1475    merge_opt!(defaultyes);
1476    merge_opt!(diskspacecheck);
1477    merge_opt!(exclude_from_weak_autodetect);
1478    merge_opt!(exit_on_lock);
1479    merge_opt!(gpgkey_dns_verification);
1480    merge_opt!(ignorearch);
1481    merge_opt!(install_weak_deps);
1482    merge_opt!(keepcache);
1483    merge_opt!(log_compress);
1484    merge_opt!(module_obsoletes);
1485    merge_opt!(module_stream_switch);
1486    merge_opt!(obsoletes);
1487    merge_opt!(plugins);
1488    merge_opt!(protect_running_kernel);
1489    merge_opt!(strict);
1490    merge_opt!(upgrade_group_objects_upgrade);
1491    merge_opt!(zchunk);
1492    merge_opt!(multilib_policy);
1493    merge_opt!(persistence);
1494    merge_opt!(rpmverbosity);
1495    merge_opt!(module_platform_id);
1496    merge_vec!(reposdir);
1497    merge_vec!(varsdir);
1498    merge_vec!(pluginconfpath);
1499    merge_vec!(pluginpath);
1500    merge_vec!(installonlypkgs);
1501    merge_vec!(protected_packages);
1502    merge_vec!(exclude_from_weak);
1503    merge_vec!(group_package_types);
1504    merge_vec!(optional_metadata_types);
1505    merge_vec!(tsflags);
1506    merge_vec!(usr_drift_protected_paths);
1507    for (k, v) in &src.extras {
1508        if !dest.extras.contains_key(k) {
1509            dest.extras.insert(k.clone(), v.clone());
1510        }
1511    }
1512}
1513
1514impl Default for RepoFile {
1515    fn default() -> Self {
1516        Self::new()
1517    }
1518}
1519
1520// ============================================================================
1521// Render helpers
1522// ============================================================================
1523
1524fn render_line(out: &mut String, line: &str) {
1525    out.push_str(line);
1526    if !line.ends_with('\n') {
1527        out.push('\n');
1528    }
1529}
1530
1531fn render_section_entries<T>(out: &mut String, block: &SectionBlock<T>) {
1532    for entry in &block.raw_entries {
1533        for c in &entry.leading_comments {
1534            render_line(out, c);
1535        }
1536        let mut line = format!("{}={}", entry.key, entry.value);
1537        if let Some(ref ic) = entry.inline_comment {
1538            line.push_str(&format!(" #{}", ic));
1539        }
1540        out.push_str(&line);
1541        out.push('\n');
1542    }
1543}