carch-core 6.1.2

Core library for carch, providing script management and UI components.
Documentation
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::process::Command;

use log::info;
use ratatui::layout::Rect;

use super::state::{
    App, AppMode, DescriptionState, FocusedPanel, HelpState, MultiSelectState, PreviewState,
    SearchState, StatefulList, ThemeSelectorState,
};
use crate::ui::state::{ScriptItem, UiOptions};
use crate::ui::theme::Theme;

fn detect_distro() -> String {
    if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
        for line in content.lines() {
            if let Some(value) = line.strip_prefix("ID=") {
                let id = value.trim_matches('"');
                let name = match id {
                    "arch" | "manjaro" | "endeavouros" => "Arch",
                    "fedora" => "Fedora",
                    "opensuse-tumbleweed" | "opensuse-leap" | "opensuse" => "openSUSE",
                    "debian" | "ubuntu" | "linuxmint" | "pop" | "zorin" => "Debian",
                    "alpine" => "Alpine",
                    "void" => "Void",
                    "gentoo" => "Gentoo",
                    "nixos" => "NixOS",
                    "termux" => "Termux",
                    _ => "",
                };
                if !name.is_empty() {
                    return name.to_string();
                }
            }
        }
        for line in content.lines() {
            if let Some(value) = line.strip_prefix("PRETTY_NAME=") {
                return value.trim_matches('"').to_string();
            }
        }
    }

    if std::env::var("TERMUX_VERSION").is_ok() {
        return "Termux".to_string();
    }

    let checks: &[(&str, &str)] = &[
        ("dnf", "Fedora"),
        ("zypper", "openSUSE"),
        ("apt", "Debian"),
        ("pacman", "Arch"),
        ("apk", "Alpine"),
        ("xbps-install", "Void"),
        ("emerge", "Gentoo"),
        ("nix", "NixOS"),
        ("pkg", "Termux"),
    ];

    for (cmd, name) in checks {
        if Command::new("command").args(["-v", cmd]).output().is_ok() {
            return name.to_string();
        }
    }

    "Linux".to_string()
}

impl App {
    pub fn new(options: &UiOptions) -> App {
        let theme = match options.theme.as_str() {
            "dracula" => Theme::dracula(),
            "gruvbox" => Theme::gruvbox(),
            "nord" => Theme::nord(),
            "rose-pine" => Theme::rose_pine(),
            _ => Theme::catppuccin_mocha(),
        };

        let distro = detect_distro();

        let mode = if options.is_root {
            AppMode::RootWarning
        } else if distro == "Termux" {
            AppMode::TermuxWarning
        } else {
            AppMode::Normal
        };

        App {
            mode,
            quit: false,
            focused_panel: FocusedPanel::Categories,
            log_mode: options.log_mode,
            modules_dir: PathBuf::new(),
            theme,
            theme_locked: options.theme_locked,
            distro,

            scripts: StatefulList::new(),
            categories: StatefulList::new(),
            all_scripts: HashMap::new(),

            script_panel_area: Rect::default(),
            preview: PreviewState::default(),
            search: SearchState::default(),
            multi_select: MultiSelectState::default(),
            help: HelpState::default(),
            description: DescriptionState::default(),
            run_script_popup: None,
            script_execution_queue: VecDeque::new(),
            theme_selector: ThemeSelectorState::default(),

            needs_redraw: true,
            last_size: Rect::default(),
            log_path: options.log_path.clone(),
        }
    }

    pub fn toggle_description_popup(&mut self) {
        if self.mode == AppMode::Description {
            self.mode = AppMode::Normal;
            self.description.content = None;
            if self.log_mode {
                info!("Closed description popup");
            }
        } else if let Some(selected_script) = self.get_selected_script() {
            let desc_path = self.modules_dir.join(&selected_script.category).join("desc.toml");

            if self.log_mode {
                info!(
                    "Attempting to show description for script: {}/{}",
                    selected_script.category, selected_script.name
                );
                info!("Description file path: {}", desc_path.display());
            }

            let desc = read_description(&desc_path, &selected_script.name);

            if self.log_mode {
                match &desc {
                    Some(_) => {
                        info!("Successfully loaded description and entered description mode.");
                    }
                    None => info!(
                        "No description available for script '{}/{}'",
                        selected_script.category, selected_script.name
                    ),
                }
            }

            self.description.content = Some(desc.unwrap_or_else(|| {
                format!(
                    "No description available for '{}/{}'.",
                    selected_script.category, selected_script.name
                )
            }));
            self.description.scroll = 0;
            self.mode = AppMode::Description;
        }
    }

    pub fn get_selected_script(&self) -> Option<&ScriptItem> {
        self.scripts.state.selected().map(|i| &self.scripts.items[i])
    }
}

fn read_description(desc_path: &std::path::Path, script_name: &str) -> Option<String> {
    let content = std::fs::read_to_string(desc_path).ok()?;
    let table: toml::Table = content.parse().ok()?;
    let stem = std::path::Path::new(script_name).file_stem().and_then(|s| s.to_str())?;
    table
        .get(stem)
        .and_then(|v| v.as_table())
        .and_then(|t| t.get("description"))
        .and_then(|v| v.as_str())
        .map(str::to_string)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write_desc(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
        let path = dir.join("desc.toml");
        std::fs::write(&path, content).unwrap();
        path
    }

    #[test]
    fn read_description_found() {
        let dir = std::env::temp_dir().join(format!("carch_test_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = write_desc(
            &dir,
            r#"
[install]
description = "Installs packages"
"#,
        );
        let desc = read_description(&path, "install.sh");
        assert_eq!(desc.as_deref(), Some("Installs packages"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn read_description_missing_file() {
        let path = std::path::Path::new("/tmp/this_file_should_not_exist_carch.toml");
        let _ = std::fs::remove_file(path);
        assert_eq!(read_description(path, "x"), None);
    }

    #[test]
    fn read_description_malformed_toml() {
        let dir = std::env::temp_dir().join(format!("carch_test_bad_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("desc.toml");
        std::fs::write(&path, "this is = not valid toml [[[").unwrap();
        assert_eq!(read_description(&path, "install"), None);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn read_description_missing_key() {
        let dir = std::env::temp_dir().join(format!("carch_test_k_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("desc.toml");
        std::fs::write(
            &path,
            r#"[other]
description = "x"
"#,
        )
        .unwrap();
        assert_eq!(read_description(&path, "install"), None);
        let _ = std::fs::remove_dir_all(&dir);
    }
}