Skip to main content

pacsea/logic/repos/
config.rs

1//! TOML parsing and validation for `repos.conf`.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8/// What: Root document for `repos.conf` (array of `[[repo]]` tables).
9///
10/// Inputs:
11/// - N/A (Serde shape).
12///
13/// Output:
14/// - Deserialized list under `repo`.
15///
16/// Details:
17/// - TOML maps `[[repo]]` to `repo = [ ... ]`.
18#[derive(Debug, Clone, Deserialize, Serialize)]
19pub struct ReposConfFile {
20    /// Repository entries from the file.
21    #[serde(default)]
22    pub repo: Vec<RepoRow>,
23}
24
25/// What: One `[[repo]]` row supplied by the user.
26///
27/// Inputs:
28/// - N/A (Serde shape).
29///
30/// Output:
31/// - Field bundle for validation and later apply phases.
32///
33/// Details:
34/// - Required for the results map: `name`, `results_filter`. Other fields are reserved for Phase 3 (apply).
35/// - The `preset` key is rejected: use full rows; see `config/repos.conf` in the Pacsea repo.
36#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37#[serde(default)]
38pub struct RepoRow {
39    /// Stable Pacsea id (optional; for future UI / apply).
40    pub id: Option<String>,
41    /// Unsupported; if set, parsing fails with a pointer to the example file.
42    pub preset: Option<String>,
43    /// Desired enabled state for future apply flows (`None` treated as true).
44    pub enabled: Option<bool>,
45    /// Pacman `[repo]` section name.
46    pub name: Option<String>,
47    /// Logical bucket for results-list toggles (`settings.conf` / dynamic map).
48    pub results_filter: Option<String>,
49    /// `Server =` URL (Phase 3).
50    pub server: Option<String>,
51    /// `SigLevel` (Phase 3).
52    pub sig_level: Option<String>,
53    /// Signing key fingerprint/id (Phase 3).
54    pub key_id: Option<String>,
55    /// Keyserver hostname (Phase 3).
56    pub key_server: Option<String>,
57    /// Local mirrorlist path (Phase 3).
58    pub mirrorlist: Option<String>,
59    /// Remote mirrorlist URL (Phase 3).
60    pub mirrorlist_url: Option<String>,
61}
62
63/// What: Normalize a `results_filter` label for map keys and `settings.conf` suffixes.
64///
65/// Inputs:
66/// - `raw`: Value from `repos.conf` (may contain `-`, spaces, etc.).
67///
68/// Output:
69/// - Lowercase string with non-alphanumeric runs folded to a single `_`.
70///
71/// Details:
72/// - Matches `results_filter_show_<token>` in `settings.conf` where `token` uses the same rule.
73#[must_use]
74pub fn canonical_results_filter_key(raw: &str) -> String {
75    let lower = raw.trim().to_lowercase();
76    let mut out = String::new();
77    let mut prev_sep = true;
78    for ch in lower.chars() {
79        if ch.is_ascii_alphanumeric() {
80            out.push(ch);
81            prev_sep = false;
82        } else if !prev_sep && !out.is_empty() {
83            out.push('_');
84            prev_sep = true;
85        }
86    }
87    while out.ends_with('_') {
88        out.pop();
89    }
90    out
91}
92
93/// What: Whether a `[[repo]]` row is treated as enabled for index and apply planning.
94///
95/// Inputs:
96/// - `row`: Parsed row from `repos.conf`.
97///
98/// Output:
99/// - `false` only when `enabled = false`; otherwise `true`.
100///
101/// Details:
102/// - Matches `row_enabled` in the apply-plan module (same semantics).
103#[must_use]
104pub fn row_is_enabled_for_repos_conf(row: &RepoRow) -> bool {
105    row.enabled != Some(false)
106}
107
108/// What: Non-empty string after trim for optional TOML fields.
109///
110/// Inputs:
111/// - `s`: Optional string slice.
112///
113/// Output:
114/// - `true` when `s` is `Some` and not empty or whitespace-only.
115fn non_empty_trim_opt(s: Option<&str>) -> bool {
116    s.map(str::trim).is_some_and(|t| !t.is_empty())
117}
118
119/// What: Whether a string looks like an HTTP(S) URL for mirrorlist fetch policy.
120///
121/// Inputs:
122/// - `u`: Trimmed URL candidate.
123///
124/// Output:
125/// - `true` for `http://` or `https://` prefixes (ASCII case-insensitive).
126fn looks_like_http_url_cfg(u: &str) -> bool {
127    let lower = u.to_ascii_lowercase();
128    lower.starts_with("https://") || lower.starts_with("http://")
129}
130
131/// What: Whether a `[[repo]]` row declares sources that participate in Apply (drop-in generation).
132///
133/// Inputs:
134/// - `r`: Parsed row from `repos.conf`.
135///
136/// Output:
137/// - `true` when `server`, local `mirrorlist`, or HTTP(S) `mirrorlist_url` is set per apply-plan rules.
138///
139/// Details:
140/// - Mirrors apply-plan eligibility for sources; non-HTTP `mirrorlist_url` alone does not qualify.
141#[must_use]
142pub fn repo_row_declares_apply_sources(r: &RepoRow) -> bool {
143    if non_empty_trim_opt(r.server.as_deref()) {
144        return true;
145    }
146    if non_empty_trim_opt(r.mirrorlist.as_deref()) {
147        return true;
148    }
149    r.mirrorlist_url
150        .as_deref()
151        .map(str::trim)
152        .filter(|s| !s.is_empty())
153        .is_some_and(looks_like_http_url_cfg)
154}
155
156/// What: Whether `repos.conf` has a matching `[[repo]]` that is disabled but still defines apply sources.
157///
158/// Inputs:
159/// - `content`: Full `repos.conf` text.
160/// - `section_name`: Pacman `[repo]` name (case-insensitive trim).
161///
162/// Output:
163/// - `Ok(true)` when a row matches, has `enabled = false`, and [`repo_row_declares_apply_sources`].
164/// - `Ok(false)` when no such row exists.
165///
166/// Details:
167/// - Used so the Repositories modal can re-enable a row after Apply removed it from pacman: pacman
168///   no longer shows the section as active from `pacsea-repos.conf`, but `repos.conf` still carries the recipe.
169///
170/// # Errors
171///
172/// - Propagates [`load_resolve_repos_from_str`] failures.
173pub fn repos_conf_section_is_disabled_with_apply_sources(
174    content: &str,
175    section_name: &str,
176) -> Result<bool, String> {
177    let (rows, _) = load_resolve_repos_from_str(content)?;
178    let want = section_name.trim().to_lowercase();
179    if want.is_empty() {
180        return Ok(false);
181    }
182    for row in rows {
183        let name = row.name.as_deref().map_or("", str::trim);
184        if name.to_lowercase() != want {
185            continue;
186        }
187        return Ok(row.enabled == Some(false) && repo_row_declares_apply_sources(&row));
188    }
189    Ok(false)
190}
191
192/// What: Ensure a row has `name` and `results_filter`.
193///
194/// Inputs:
195/// - `row`: Parsed `[[repo]]` entry.
196///
197/// Output:
198/// - `Ok(())` or an error message for empty required fields.
199///
200/// Details:
201/// - Used before the row enters the repo-name map.
202/// - Rejects `results_filter` values that normalize to an empty canonical key (no ASCII alphanumerics).
203fn validate_row(row: &RepoRow) -> Result<(), String> {
204    let name_ok = row
205        .name
206        .as_deref()
207        .map(str::trim)
208        .is_some_and(|s| !s.is_empty());
209    if !name_ok {
210        return Err("repo `name` is missing or empty".to_string());
211    }
212    let Some(rf_trimmed) = row
213        .results_filter
214        .as_deref()
215        .map(str::trim)
216        .filter(|s| !s.is_empty())
217    else {
218        return Err("repo `results_filter` is missing or empty".to_string());
219    };
220    if canonical_results_filter_key(rf_trimmed).is_empty() {
221        return Err(
222            "repo `results_filter` contains no ASCII letters or digits; add at least one so the filter can be toggled in settings (results_filter_show_<id>)"
223                .to_string(),
224        );
225    }
226    Ok(())
227}
228
229/// What: Validate one deserialized row (no preset merge).
230///
231/// Inputs:
232/// - `row`: Raw deserialized row.
233///
234/// Output:
235/// - `Ok(RepoRow)` clone for resolved list, or `Err(message)`.
236///
237/// Details:
238/// - Rejects non-empty `preset`; Pacsea does not ship an in-tree catalog.
239fn finalize_row(row: &RepoRow) -> Result<RepoRow, String> {
240    if row
241        .preset
242        .as_deref()
243        .map(str::trim)
244        .is_some_and(|s| !s.is_empty())
245    {
246        return Err(
247            "repos.conf: `preset` is not supported; define a full [[repo]] block. \
248             See config/repos.conf in the Pacsea repository."
249                .to_string(),
250        );
251    }
252    validate_row(row)?;
253    Ok(row.clone())
254}
255
256/// What: Build lowercase pacman repo name → canonical results-filter key.
257///
258/// Inputs:
259/// - `rows`: Valid rows.
260///
261/// Output:
262/// - Map for `repo_toggle_for` lookups.
263///
264/// # Errors
265///
266/// - Returns `Err` when two rows share the same case-insensitive `name`.
267/// - Returns `Err` when a row's `results_filter` normalizes to an empty canonical key.
268///
269/// Details:
270/// - Errors on duplicate `name` (case-insensitive).
271pub fn build_repo_name_to_filter_map(rows: &[RepoRow]) -> Result<HashMap<String, String>, String> {
272    let mut map = HashMap::new();
273    for row in rows {
274        let name = row.name.as_deref().map_or("", str::trim).to_lowercase();
275        if name.is_empty() {
276            continue;
277        }
278        let rf_raw = row.results_filter.as_deref().unwrap_or("");
279        let canon = canonical_results_filter_key(rf_raw);
280        if canon.is_empty() {
281            return Err(format!(
282                "repo `name` = {name:?}: `results_filter` normalizes to an empty settings key; include at least one ASCII letter or digit"
283            ));
284        }
285        if map.insert(name.clone(), canon).is_some() {
286            return Err(format!("duplicate repo `name` in repos.conf: {name}"));
287        }
288    }
289    Ok(map)
290}
291
292/// What: Parse TOML, validate rows, and build the repo-name map.
293///
294/// Inputs:
295/// - `content`: Full file contents.
296///
297/// Output:
298/// - Resolved rows and name→filter map, or concatenated error string.
299///
300/// # Errors
301///
302/// - Invalid TOML, unsupported `preset`, validation failures, or duplicate `name` values.
303///
304/// Details:
305/// - Empty or whitespace-only content yields empty results without error.
306pub fn load_resolve_repos_from_str(
307    content: &str,
308) -> Result<(Vec<RepoRow>, HashMap<String, String>), String> {
309    let trimmed = content.trim();
310    if trimmed.is_empty() {
311        return Ok((Vec::new(), HashMap::new()));
312    }
313    let file: ReposConfFile =
314        toml::from_str(trimmed).map_err(|e| format!("repos.conf TOML: {e}"))?;
315    let mut errors: Vec<String> = Vec::new();
316    let mut resolved: Vec<RepoRow> = Vec::new();
317    for row in file.repo {
318        match finalize_row(&row) {
319            Ok(r) => resolved.push(r),
320            Err(e) => errors.push(e),
321        }
322    }
323    if !errors.is_empty() {
324        return Err(errors.join("; "));
325    }
326    let name_map = build_repo_name_to_filter_map(&resolved)?;
327    Ok((resolved, name_map))
328}
329
330/// What: Load and resolve `repos.conf` from disk for app initialization.
331///
332/// Inputs:
333/// - `path`: File path (should exist when called).
334///
335/// Output:
336/// - Name→filter map, or empty map on read/parse failure (warnings logged).
337///
338/// Details:
339/// - IO errors and parse errors are non-fatal for startup.
340pub fn load_repo_name_map_from_path(path: &Path) -> HashMap<String, String> {
341    let Ok(content) = std::fs::read_to_string(path) else {
342        tracing::warn!(path = %path.display(), "repos.conf: read failed");
343        return HashMap::new();
344    };
345    match load_resolve_repos_from_str(&content) {
346        Ok((_, m)) => m,
347        Err(e) => {
348            tracing::warn!(path = %path.display(), err = %e, "repos.conf: parse failed");
349            HashMap::new()
350        }
351    }
352}
353
354/// What: Collect `[[repo]]` pacman section names from `repos.conf` for extra `pacman -Sl` index passes.
355///
356/// Inputs:
357/// - `content`: Full `repos.conf` file text.
358/// - `sl_names_lower_already_fetched`: Lowercase repo names already queried by the builtin index fetch.
359///
360/// Output:
361/// - Distinct `name` values not present in `sl_names_lower_already_fetched`, in file order.
362///
363/// Details:
364/// - Parses with [`load_resolve_repos_from_str`]; on failure returns an empty list (caller may log).
365/// - Skips duplicates case-insensitively so each extra `-Sl` runs at most once.
366#[allow(clippy::implicit_hasher)]
367fn repos_conf_repo_names_for_extra_sl_from_str(
368    content: &str,
369    sl_names_lower_already_fetched: &HashSet<String>,
370) -> Vec<String> {
371    let Ok((rows, _)) = load_resolve_repos_from_str(content) else {
372        tracing::debug!("repos.conf: skip index Sl extras (parse failed)");
373        return Vec::new();
374    };
375    let mut seen_out = HashSet::<String>::new();
376    let mut out = Vec::new();
377    for row in rows {
378        if !row_is_enabled_for_repos_conf(&row) {
379            continue;
380        }
381        let Some(name) = row.name.as_deref() else {
382            continue;
383        };
384        let nl = name.to_lowercase();
385        if sl_names_lower_already_fetched.contains(&nl) {
386            continue;
387        }
388        if seen_out.insert(nl) {
389            out.push(name.to_string());
390        }
391    }
392    out
393}
394
395/// What: Resolve `repos.conf` and list repo names that need an extra `pacman -Sl` for the package index.
396///
397/// Inputs:
398/// - `sl_names_lower_already_fetched`: Lowercase names already covered by Pacsea's builtin `-Sl` loop.
399///
400/// Output:
401/// - Repository `name` strings to pass to `pacman -Sl`, excluding builtins and duplicates.
402///
403/// Details:
404/// - When no file exists or read/parse fails, returns an empty vector (non-fatal).
405/// - Logs at info when extras are non-empty so diagnostics show third-party repos indexed (e.g. Chaotic-AUR).
406#[allow(clippy::implicit_hasher)]
407#[must_use]
408pub fn repos_conf_repo_names_for_index_sl(
409    sl_names_lower_already_fetched: &HashSet<String>,
410) -> Vec<String> {
411    let Some(path) = crate::theme::resolve_repos_config_path() else {
412        return Vec::new();
413    };
414    let Ok(content) = std::fs::read_to_string(&path) else {
415        tracing::debug!(path = %path.display(), "repos.conf: skip index Sl extras (read failed)");
416        return Vec::new();
417    };
418    let out = repos_conf_repo_names_for_extra_sl_from_str(&content, sl_names_lower_already_fetched);
419    if !out.is_empty() {
420        tracing::info!(repos = ?out, "index fetch: extra pacman -Sl repos from repos.conf");
421    }
422    out
423}
424
425/// What: Serialize a resolved `repos.conf` document to disk (overwrites the file).
426///
427/// Inputs:
428/// - `path`: Destination path (typically `resolve_repos_config_path()`).
429/// - `file`: Parsed and validated rows to write.
430///
431/// Output:
432/// - `Ok(())` or a user-visible error string.
433///
434/// Details:
435/// - Uses `toml::to_string`; formatting and comments from the prior file are not preserved.
436///
437/// # Errors
438///
439/// - Returns an error when TOML serialization fails or the file cannot be written.
440pub fn save_repos_conf_file(path: &Path, file: &ReposConfFile) -> Result<(), String> {
441    let out = toml::to_string(file).map_err(|e| format!("repos.conf: serialize failed: {e}"))?;
442    std::fs::write(path, &out)
443        .map_err(|e| format!("repos.conf: write failed ({}): {e}", path.display()))
444}
445
446/// What: Toggle `enabled` for the `[[repo]]` whose `name` matches `section_name` and save the file.
447///
448/// Inputs:
449/// - `path`: `repos.conf` path.
450/// - `section_name`: Pacman `[repo]` name (case-insensitive trim).
451///
452/// Output:
453/// - `Ok(())` or an error (read/parse/write).
454///
455/// Details:
456/// - When the row is currently enabled (`enabled` absent or `true`), sets `enabled = false`.
457/// - When currently disabled (`enabled = false`), clears `enabled` (treat as enabled again).
458/// - Requires a validated row with `name` and `results_filter` as in [`load_resolve_repos_from_str`].
459///
460/// # Errors
461///
462/// - Read/parse failures, missing matching `[[repo]]`, or write errors surface as `Err(String)`.
463pub fn toggle_repo_enabled_for_section_in_file(
464    path: &Path,
465    section_name: &str,
466) -> Result<(), String> {
467    let content = std::fs::read_to_string(path)
468        .map_err(|e| format!("repos.conf: read failed ({}): {e}", path.display()))?;
469    let (mut rows, _) = load_resolve_repos_from_str(&content)?;
470    let want = section_name.trim().to_lowercase();
471    if want.is_empty() {
472        return Err("repos.conf: empty repository name".to_string());
473    }
474    let mut found = false;
475    for row in &mut rows {
476        let name = row.name.as_deref().map_or("", str::trim);
477        if name.to_lowercase() == want {
478            found = true;
479            let on = row_is_enabled_for_repos_conf(row);
480            row.enabled = if on { Some(false) } else { None };
481            break;
482        }
483    }
484    if !found {
485        return Err(format!(
486            "repos.conf: no [[repo]] with name matching \"{section_name}\""
487        ));
488    }
489    save_repos_conf_file(path, &ReposConfFile { repo: rows })
490}
491
492/// What: Persist `enabled = false` for a `[[repo]]` row only while it is currently enabled.
493///
494/// Inputs:
495/// - `path`: `repos.conf` path.
496/// - `section_name`: Pacman `[repo]` name (case-insensitive trim).
497///
498/// Output:
499/// - `Ok(true)` when the file was updated; `Ok(false)` when the row is already disabled or equivalent.
500///
501/// Details:
502/// - Does **not** toggle back to enabled when the row is already `enabled = false` (unlike
503///   [`toggle_repo_enabled_for_section_in_file`]).
504/// - Returns an error when no matching `[[repo]]` exists or read/parse/write fails.
505///
506/// # Errors
507///
508/// - Same shape as [`toggle_repo_enabled_for_section_in_file`] for missing rows and I/O.
509pub fn disable_repo_section_in_repos_conf_if_enabled(
510    path: &Path,
511    section_name: &str,
512) -> Result<bool, String> {
513    let content = std::fs::read_to_string(path)
514        .map_err(|e| format!("repos.conf: read failed ({}): {e}", path.display()))?;
515    let (mut rows, _) = load_resolve_repos_from_str(&content)?;
516    let want = section_name.trim().to_lowercase();
517    if want.is_empty() {
518        return Err("repos.conf: empty repository name".to_string());
519    }
520    let mut found = false;
521    for row in &mut rows {
522        let name = row.name.as_deref().map_or("", str::trim);
523        if name.to_lowercase() == want {
524            found = true;
525            if !row_is_enabled_for_repos_conf(row) {
526                return Ok(false);
527            }
528            row.enabled = Some(false);
529            break;
530        }
531    }
532    if !found {
533        return Err(format!(
534            "repos.conf: no [[repo]] with name matching \"{section_name}\""
535        ));
536    }
537    save_repos_conf_file(path, &ReposConfFile { repo: rows })?;
538    Ok(true)
539}
540
541/// What: Merge per-filter toggles from `settings.conf` with defaults for all ids from repos.
542///
543/// Inputs:
544/// - `toggles`: Parsed `results_filter_show_*` entries (canonical key → bool).
545/// - `repo_name_to_filter`: Lowercase pacman repo name → canonical filter key.
546///
547/// Output:
548/// - Canonical filter key → visible in results.
549///
550/// Details:
551/// - Default is `true` when a key is absent from `toggles`.
552#[allow(clippy::implicit_hasher)]
553#[must_use]
554pub fn build_dynamic_visibility(
555    toggles: &HashMap<String, bool>,
556    repo_name_to_filter: &HashMap<String, String>,
557) -> HashMap<String, bool> {
558    let ids: HashSet<String> = repo_name_to_filter.values().cloned().collect();
559    let mut out = HashMap::new();
560    for id in ids {
561        let v = toggles.get(&id).copied().unwrap_or(true);
562        out.insert(id, v);
563    }
564    out
565}
566
567#[cfg(test)]
568mod tests {
569    use std::collections::HashSet;
570
571    use super::*;
572
573    #[test]
574    fn canonical_key_collapses_separators() {
575        assert_eq!(canonical_results_filter_key("vendor-aur"), "vendor_aur");
576        assert_eq!(canonical_results_filter_key("  Foo..Bar  "), "foo_bar");
577    }
578
579    #[test]
580    fn canonical_key_empty_when_no_alphanumerics() {
581        assert!(canonical_results_filter_key("---").is_empty());
582        assert!(canonical_results_filter_key("  ..  ").is_empty());
583    }
584
585    #[test]
586    fn results_filter_without_alphanumerics_rejected() {
587        let toml = r#"
588[[repo]]
589name = "myrepo"
590results_filter = "---"
591"#;
592        let err = load_resolve_repos_from_str(toml).expect_err("no alphanumeric");
593        assert!(
594            err.contains("ASCII letters or digits"),
595            "unexpected message: {err}"
596        );
597    }
598
599    #[test]
600    fn build_repo_name_to_filter_map_rejects_empty_canonical_key() {
601        let rows = vec![RepoRow {
602            name: Some("foo".to_string()),
603            results_filter: Some("---".to_string()),
604            ..Default::default()
605        }];
606        let err = build_repo_name_to_filter_map(&rows).expect_err("empty canon");
607        assert!(
608            err.contains("empty settings key"),
609            "unexpected message: {err}"
610        );
611    }
612
613    #[test]
614    fn full_repo_row_builds_map() {
615        let toml = r#"
616[[repo]]
617name = "myrepo"
618results_filter = "vendor_pkgs"
619"#;
620        let (_rows, map) = load_resolve_repos_from_str(toml).expect("parse");
621        assert_eq!(map.get("myrepo").map(String::as_str), Some("vendor_pkgs"));
622    }
623
624    #[test]
625    /// What: Ensure index Sl extras omit repos already fetched and builtins listed in `repos.conf`.
626    ///
627    /// Inputs:
628    /// - TOML with `core`, `chaotic-aur`, and `my-vendor`; `builtin` set marks `core` and
629    ///   `chaotic-aur` as already queried.
630    ///
631    /// Output:
632    /// - Only `my-vendor` is returned.
633    ///
634    /// Details:
635    /// - `repos.conf` may repeat names Pacsea already passes to `pacman -Sl`; those must not run twice.
636    fn repos_conf_index_sl_extras_skip_builtins() {
637        let mut builtin = HashSet::new();
638        builtin.insert("core".to_string());
639        builtin.insert("chaotic-aur".to_string());
640        let toml = r#"
641[[repo]]
642name = "core"
643results_filter = "c"
644[[repo]]
645name = "chaotic-aur"
646results_filter = "chaotic_aur"
647[[repo]]
648name = "my-vendor"
649results_filter = "vendor"
650"#;
651        let out = super::repos_conf_repo_names_for_extra_sl_from_str(toml, &builtin);
652        assert_eq!(out, vec!["my-vendor".to_string()]);
653    }
654
655    #[test]
656    /// What: Ensure `enabled = false` rows are omitted from extra `pacman -Sl` names.
657    ///
658    /// Inputs:
659    /// - TOML with one disabled repo and one enabled vendor repo; builtin set empty.
660    ///
661    /// Output:
662    /// - Only the enabled repo name is returned.
663    ///
664    /// Details:
665    /// - Disabled custom repos should not trigger index fetches.
666    fn repos_conf_index_sl_extras_skip_disabled() {
667        let builtin = HashSet::new();
668        let toml = r#"
669[[repo]]
670name = "off-vendor"
671results_filter = "off"
672enabled = false
673
674[[repo]]
675name = "on-vendor"
676results_filter = "on"
677"#;
678        let out = super::repos_conf_repo_names_for_extra_sl_from_str(toml, &builtin);
679        assert_eq!(out, vec!["on-vendor".to_string()]);
680    }
681
682    #[test]
683    fn repos_conf_disabled_with_server_detected_for_reenable() {
684        let toml = r#"
685[[repo]]
686name = "chaotic-aur"
687results_filter = "chaotic"
688enabled = false
689server = "https://example.com/$repo/os/$arch"
690"#;
691        assert!(
692            super::repos_conf_section_is_disabled_with_apply_sources(toml, "chaotic-aur")
693                .expect("parse")
694        );
695        assert!(
696            !super::repos_conf_section_is_disabled_with_apply_sources(
697                r#"
698[[repo]]
699name = "chaotic-aur"
700results_filter = "chaotic"
701server = "https://x.test"
702"#,
703                "chaotic-aur"
704            )
705            .expect("parse")
706        );
707    }
708
709    #[test]
710    fn duplicate_name_errors() {
711        let toml = r#"
712[[repo]]
713name = "myrepo"
714results_filter = "a"
715
716[[repo]]
717name = "myrepo"
718results_filter = "b"
719"#;
720        let err = load_resolve_repos_from_str(toml).expect_err("dup");
721        assert!(err.contains("duplicate"));
722    }
723
724    #[test]
725    fn preset_key_is_rejected() {
726        let toml = r#"
727[[repo]]
728preset = "anything"
729"#;
730        let err = load_resolve_repos_from_str(toml).expect_err("preset");
731        assert!(err.contains("preset"));
732    }
733
734    #[test]
735    fn dynamic_visibility_defaults_true() {
736        let mut repo = HashMap::new();
737        repo.insert("foo".to_string(), "bar".to_string());
738        let toggles = HashMap::new();
739        let v = build_dynamic_visibility(&toggles, &repo);
740        assert_eq!(v.get("bar").copied(), Some(true));
741    }
742
743    #[test]
744    fn dynamic_visibility_respects_settings() {
745        let mut repo = HashMap::new();
746        repo.insert("foo".to_string(), "bar".to_string());
747        let mut toggles = HashMap::new();
748        toggles.insert("bar".to_string(), false);
749        let v = build_dynamic_visibility(&toggles, &repo);
750        assert_eq!(v.get("bar").copied(), Some(false));
751    }
752
753    #[test]
754    fn disable_repo_section_in_repos_conf_if_enabled_disables_once() {
755        let dir = tempfile::tempdir().expect("tmpdir");
756        let path = dir.path().join("repos.conf");
757        std::fs::write(
758            &path,
759            r#"[[repo]]
760name = "alpha"
761results_filter = "a"
762"#,
763        )
764        .expect("write");
765        assert!(
766            super::disable_repo_section_in_repos_conf_if_enabled(&path, "alpha").expect("disable")
767        );
768        let body = std::fs::read_to_string(&path).expect("read");
769        let (rows, _) = load_resolve_repos_from_str(&body).expect("parse");
770        let alpha = rows
771            .iter()
772            .find(|r| r.name.as_deref() == Some("alpha"))
773            .expect("row");
774        assert_eq!(alpha.enabled, Some(false));
775        assert!(
776            !super::disable_repo_section_in_repos_conf_if_enabled(&path, "alpha")
777                .expect("idempotent")
778        );
779    }
780
781    #[test]
782    fn disable_repo_section_in_repos_conf_if_enabled_unknown_section_errors() {
783        let dir = tempfile::tempdir().expect("tmpdir");
784        let path = dir.path().join("repos.conf");
785        std::fs::write(
786            &path,
787            r#"[[repo]]
788name = "alpha"
789results_filter = "a"
790"#,
791        )
792        .expect("write");
793        let err = super::disable_repo_section_in_repos_conf_if_enabled(&path, "missing")
794            .expect_err("unknown");
795        assert!(err.contains("no [[repo]]"), "unexpected: {err}");
796    }
797}