use anyhow::Result;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum SourceFormat {
OneLineStyle,
Deb822,
}
#[derive(Debug, Clone)]
pub struct AptSource {
pub enabled: bool,
pub source_type: String,
pub uri: String,
pub suite: String,
pub components: Vec<String>,
pub options: HashMap<String, String>,
pub file: PathBuf,
pub line: usize,
pub format: SourceFormat,
pub raw_line: String,
}
impl AptSource {
pub fn to_line(&self, enabled: bool) -> String {
let components = self.components.join(" ");
let prefix = if enabled { "" } else { "# " };
if self.options.is_empty() {
format!("{}{} {} {} {}", prefix, self.source_type, self.uri, self.suite, components)
} else {
let opts: String = self
.options
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(" ");
format!(
"{}{} [{}] {} {} {}",
prefix, self.source_type, opts, self.uri, self.suite, components
)
}
}
}
fn parse_one_line_file(path: &Path) -> Vec<AptSource> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return vec![],
};
let mut sources = Vec::new();
for (line_idx, raw_line) in content.lines().enumerate() {
let trimmed = raw_line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (enabled, line) = if trimmed.starts_with("# deb") || trimmed.starts_with("#deb") {
(false, trimmed.trim_start_matches('#').trim())
} else {
(true, trimmed)
};
let mut parts = line.split_whitespace();
let source_type = match parts.next() {
Some(t) if t == "deb" || t == "deb-src" => t.to_string(),
_ => continue,
};
let mut options: HashMap<String, String> = HashMap::new();
let mut remaining: Vec<&str> = parts.collect();
if remaining.first().map_or(false, |s| s.starts_with('[')) {
let joined = remaining.join(" ");
if let Some(end) = joined.find(']') {
let opts_str = &joined[1..end];
for opt in opts_str.split_whitespace() {
let mut kv = opt.splitn(2, '=');
if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
options.insert(k.to_string(), v.to_string());
}
}
let rest = joined[end + 1..].trim().to_string();
remaining = rest.split_whitespace().map(|s| s).collect::<Vec<_>>()
.into_iter()
.map(|s| s)
.collect();
let rest_parts: Vec<String> = rest.split_whitespace().map(|s| s.to_string()).collect();
let uri = rest_parts.get(0).cloned().unwrap_or_default();
let suite = rest_parts.get(1).cloned().unwrap_or_default();
let components: Vec<String> = rest_parts[2..].to_vec();
sources.push(AptSource {
enabled,
source_type,
uri,
suite,
components,
options,
file: path.to_path_buf(),
line: line_idx,
format: SourceFormat::OneLineStyle,
raw_line: raw_line.to_string(),
});
continue;
}
}
let remaining_owned: Vec<String> = remaining.iter().map(|s| s.to_string()).collect();
let uri = remaining_owned.get(0).cloned().unwrap_or_default();
let suite = remaining_owned.get(1).cloned().unwrap_or_default();
let components: Vec<String> = remaining_owned[2..].to_vec();
if uri.is_empty() {
continue;
}
sources.push(AptSource {
enabled,
source_type,
uri,
suite,
components,
options,
file: path.to_path_buf(),
line: line_idx,
format: SourceFormat::OneLineStyle,
raw_line: raw_line.to_string(),
});
}
sources
}
pub fn parse_all_sources() -> Result<Vec<AptSource>> {
let mut sources = Vec::new();
let main_list = Path::new("/etc/apt/sources.list");
if main_list.exists() {
sources.extend(parse_one_line_file(main_list));
}
let sources_dir = Path::new("/etc/apt/sources.list.d");
if sources_dir.is_dir() {
let mut entries: Vec<_> = fs::read_dir(sources_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map_or(false, |ext| ext == "list" || ext == "sources")
})
.collect();
entries.sort_by_key(|e| e.path());
for entry in entries {
sources.extend(parse_one_line_file(&entry.path()));
}
}
Ok(sources)
}