use std::process::{Command, Stdio};
use crate::error::{ArchToolkitError, Result};
use crate::types::index::{OfficialIndex, OfficialPackage};
#[cfg(feature = "aur")]
use crate::client::{ArchClient, rate_limit_archlinux};
const DEFAULT_REPOS: [&str; 3] = ["core", "extra", "multilib"];
pub fn fetch_official_index() -> Result<OfficialIndex> {
fetch_via_pacman(&DEFAULT_REPOS)
}
pub fn fetch_official_index_for_repos(repos: &[&str]) -> Result<OfficialIndex> {
fetch_via_pacman(repos)
}
#[must_use]
pub fn detect_enabled_repos() -> Vec<String> {
detect_enabled_repos_from(std::path::Path::new("/etc/pacman.conf"))
}
#[must_use]
pub fn detect_enabled_repos_from(path: &std::path::Path) -> Vec<String> {
let Ok(content) = std::fs::read_to_string(path) else {
tracing::debug!(path = %path.display(), "pacman.conf unreadable; using default repos");
return DEFAULT_REPOS.iter().map(ToString::to_string).collect();
};
let mut repos: Vec<String> = Vec::new();
collect_repo_sections(&content, &mut repos, true);
if repos.is_empty() {
return DEFAULT_REPOS.iter().map(ToString::to_string).collect();
}
repos
}
fn collect_repo_sections(content: &str, repos: &mut Vec<String>, follow_includes: bool) {
for line in content.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let section = section.trim();
if !section.is_empty()
&& !section.eq_ignore_ascii_case("options")
&& !repos.iter().any(|r| r == section)
{
repos.push(section.to_string());
}
} else if follow_includes && let Some(value) = line.strip_prefix("Include") {
let Some(include_path) = value.split('=').nth(1).map(str::trim) else {
continue;
};
for file in expand_include_glob(include_path) {
if let Ok(included) = std::fs::read_to_string(&file) {
collect_repo_sections(&included, repos, false);
}
}
}
}
}
fn expand_include_glob(pattern: &str) -> Vec<std::path::PathBuf> {
let path = std::path::Path::new(pattern);
let Some(file_pattern) = path.file_name().and_then(|f| f.to_str()) else {
return Vec::new();
};
if !file_pattern.contains('*') {
return vec![path.to_path_buf()];
}
let Some(parent) = path.parent() else {
return Vec::new();
};
let (prefix, suffix) = file_pattern.split_once('*').unwrap_or((file_pattern, ""));
let Ok(entries) = std::fs::read_dir(parent) else {
return Vec::new();
};
let mut matches: Vec<std::path::PathBuf> = entries
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|f| f.to_str())
.is_some_and(|name| name.starts_with(prefix) && name.ends_with(suffix))
})
.collect();
matches.sort();
matches
}
#[cfg(feature = "index")]
pub async fn fetch_official_index_async() -> Result<OfficialIndex> {
match tokio::task::spawn_blocking(|| fetch_via_pacman(&DEFAULT_REPOS))
.await
.map_err(|e| ArchToolkitError::Parse(format!("Blocking task failed: {e}")))?
{
Ok(index) => {
tracing::debug!("Successfully fetched official index via pacman");
return Ok(index);
}
Err(e) => {
tracing::debug!("Failed to fetch via pacman: {}, falling back to API", e);
}
}
#[cfg(feature = "aur")]
{
let client = crate::client::ArchClient::new()
.map_err(|e| ArchToolkitError::Parse(format!("Failed to create HTTP client: {e}")))?;
fetch_via_api(&client).await
}
#[cfg(not(feature = "aur"))]
{
Err(ArchToolkitError::Parse(
"pacman unavailable and API fetch requires 'aur' feature".to_string(),
))
}
}
#[cfg(feature = "index")]
pub async fn fetch_official_index_for_repos_async(repos: Vec<String>) -> Result<OfficialIndex> {
tokio::task::spawn_blocking(move || {
let repo_refs: Vec<&str> = repos.iter().map(String::as_str).collect();
fetch_via_pacman(&repo_refs)
})
.await
.map_err(|e| ArchToolkitError::Parse(format!("Blocking task failed: {e}")))?
}
fn fetch_via_pacman(repos: &[&str]) -> Result<OfficialIndex> {
let mut pkgs = Vec::new();
for repo in repos {
tracing::debug!("Running: pacman -Sl {}", repo);
let output = Command::new("pacman")
.args(["-Sl", repo])
.env("LC_ALL", "C")
.env("LANG", "C")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| {
ArchToolkitError::Parse(format!("Failed to execute pacman -Sl {repo}: {e}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ArchToolkitError::Parse(format!(
"pacman -Sl {repo} failed: {stderr}"
)));
}
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let mut parts = line.split_whitespace();
let Some(repo_part) = parts.next() else {
continue;
};
let Some(name) = parts.next() else {
continue;
};
let version = parts.next().unwrap_or("");
if repo_part != *repo {
continue;
}
pkgs.push(OfficialPackage {
name: name.to_string(),
repo: repo_part.to_string(),
arch: String::new(), version: version.to_string(),
description: String::new(), });
}
}
pkgs.sort_by(|a, b| a.repo.cmp(&b.repo).then(a.name.cmp(&b.name)));
pkgs.dedup_by(|a, b| a.repo == b.repo && a.name == b.name);
let mut index = OfficialIndex {
pkgs,
name_to_idx: std::collections::HashMap::new(),
};
index.rebuild_name_index();
tracing::debug!("Fetched {} packages via pacman", index.pkgs.len());
Ok(index)
}
#[cfg(feature = "aur")]
async fn fetch_via_api(client: &ArchClient) -> Result<OfficialIndex> {
let repos = ["core", "extra", "multilib"];
let archs = ["x86_64", "any"];
let limit = 250; let mut pkgs = Vec::new();
for repo in &repos {
for arch in &archs {
let mut page = 1;
let mut has_more = true;
while has_more {
let url = format!(
"https://archlinux.org/packages/search/json/?repo={repo}&arch={arch}&limit={limit}&page={page}"
);
tracing::debug!(
repo = repo,
arch = arch,
page = page,
"Fetching package page from API"
);
let _permit = rate_limit_archlinux().await;
let response = client.http_client().get(&url).send().await.map_err(|e| {
ArchToolkitError::Parse(format!(
"Failed to fetch packages from API (repo={repo}, arch={arch}, page={page}): {e}"
))
})?;
let status = response.status();
if !status.is_success() {
return Err(ArchToolkitError::Parse(format!(
"API returned error status {status} for repo={repo}, arch={arch}, page={page}"
)));
}
let json: serde_json::Value = response.json().await.map_err(|e| {
ArchToolkitError::Parse(format!("Failed to parse JSON response: {e}"))
})?;
let results = json
.get("results")
.and_then(|v| v.as_array())
.ok_or_else(|| {
ArchToolkitError::Parse(format!(
"Invalid API response: missing 'results' array for repo={repo}, arch={arch}, page={page}"
))
})?;
for result in results {
let pkgname =
result
.get("pkgname")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ArchToolkitError::Parse(
"Invalid API response: missing 'pkgname' field".to_string(),
)
})?;
let repo_name = result.get("repo").and_then(|v| v.as_str()).unwrap_or(repo);
let arch_name = result.get("arch").and_then(|v| v.as_str()).unwrap_or(arch);
let version = result
.get("pkgver")
.and_then(|v| v.as_str())
.map(|v| {
let rel = result.get("pkgrel").and_then(|r| r.as_str()).unwrap_or("");
if rel.is_empty() {
v.to_string()
} else {
format!("{v}-{rel}")
}
})
.unwrap_or_default();
let description = result
.get("pkgdesc")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
pkgs.push(OfficialPackage {
name: pkgname.to_string(),
repo: repo_name.to_string(),
arch: arch_name.to_string(),
version,
description,
});
}
let num_pages = json
.get("num_pages")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1);
has_more = page < num_pages;
page += 1;
}
}
}
pkgs.sort_by(|a, b| a.repo.cmp(&b.repo).then(a.name.cmp(&b.name)));
pkgs.dedup_by(|a, b| a.repo == b.repo && a.name == b.name);
let mut index = OfficialIndex {
pkgs,
name_to_idx: std::collections::HashMap::new(),
};
index.rebuild_name_index();
tracing::debug!("Fetched {} packages via API", index.pkgs.len());
Ok(index)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_via_pacman_parses_output() {
let result = fetch_via_pacman(&DEFAULT_REPOS);
if let Ok(index) = result {
assert!(!index.pkgs.is_empty() || index.pkgs.is_empty()); } else {
}
}
#[test]
fn detect_enabled_repos_parses_sections() {
let dir = std::env::temp_dir().join("arch-toolkit-test-pacmanconf");
std::fs::create_dir_all(&dir).expect("create temp dir");
let conf = dir.join("pacman.conf");
std::fs::write(
&conf,
"# comment\n[options]\nHoldPkg = pacman\n\n[core]\nInclude = /nonexistent/mirrorlist\n[extra]\n[multilib]\n[chaotic-aur]\n[core]\n",
)
.expect("write conf");
let repos = detect_enabled_repos_from(&conf);
assert_eq!(repos, ["core", "extra", "multilib", "chaotic-aur"]);
let missing = detect_enabled_repos_from(std::path::Path::new("/nonexistent/pacman.conf"));
assert_eq!(missing, DEFAULT_REPOS.map(String::from));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn fetch_official_index_fallback() {
let result = fetch_official_index();
match result {
Ok(index) => {
assert!(index.pkgs.is_empty() || !index.pkgs.is_empty());
}
Err(e) => {
let error_msg = format!("{e}");
assert!(!error_msg.is_empty());
}
}
}
#[cfg(feature = "index")]
#[tokio::test]
async fn fetch_official_index_async_works() {
let result = fetch_official_index_async().await;
if let Ok(index) = result {
assert!(index.pkgs.is_empty() || !index.pkgs.is_empty());
} else {
}
}
}