rustpm 0.2.2

A fast, friendly APT frontend with kernel, desktop, and sources management
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,
        };

        // Check for options block [key=val ...]
        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();
                // Rebuild remaining from the rest string — we need 'static refs, use owned
                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)
}