1use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Deserialize, Serialize)]
19pub struct ReposConfFile {
20 #[serde(default)]
22 pub repo: Vec<RepoRow>,
23}
24
25#[derive(Debug, Clone, Default, Deserialize, Serialize)]
37#[serde(default)]
38pub struct RepoRow {
39 pub id: Option<String>,
41 pub preset: Option<String>,
43 pub enabled: Option<bool>,
45 pub name: Option<String>,
47 pub results_filter: Option<String>,
49 pub server: Option<String>,
51 pub sig_level: Option<String>,
53 pub key_id: Option<String>,
55 pub key_server: Option<String>,
57 pub mirrorlist: Option<String>,
59 pub mirrorlist_url: Option<String>,
61}
62
63#[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#[must_use]
104pub fn row_is_enabled_for_repos_conf(row: &RepoRow) -> bool {
105 row.enabled != Some(false)
106}
107
108fn non_empty_trim_opt(s: Option<&str>) -> bool {
116 s.map(str::trim).is_some_and(|t| !t.is_empty())
117}
118
119fn 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#[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
156pub 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
192fn 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
229fn 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
256pub 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
292pub 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
330pub 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#[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#[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
425pub 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
446pub 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
492pub 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#[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 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 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}