Skip to main content

carch_core/ui/
app.rs

1use std::collections::{HashMap, VecDeque};
2use std::path::PathBuf;
3use std::process::Command;
4
5use log::info;
6use ratatui::layout::Rect;
7
8use super::state::{
9    App, AppMode, DescriptionState, FocusedPanel, HelpState, MultiSelectState, PreviewState,
10    SearchState, StatefulList, ThemeSelectorState,
11};
12use crate::ui::state::{ScriptItem, UiOptions};
13use crate::ui::theme::Theme;
14
15fn detect_distro() -> String {
16    if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
17        for line in content.lines() {
18            if let Some(value) = line.strip_prefix("ID=") {
19                let id = value.trim_matches('"');
20                let name = match id {
21                    "arch" | "manjaro" | "endeavouros" => "Arch",
22                    "fedora" => "Fedora",
23                    "opensuse-tumbleweed" | "opensuse-leap" | "opensuse" => "openSUSE",
24                    "debian" | "ubuntu" | "linuxmint" | "pop" | "zorin" => "Debian",
25                    "alpine" => "Alpine",
26                    "void" => "Void",
27                    "gentoo" => "Gentoo",
28                    "nixos" => "NixOS",
29                    "termux" => "Termux",
30                    _ => "",
31                };
32                if !name.is_empty() {
33                    return name.to_string();
34                }
35            }
36        }
37        for line in content.lines() {
38            if let Some(value) = line.strip_prefix("PRETTY_NAME=") {
39                return value.trim_matches('"').to_string();
40            }
41        }
42    }
43
44    if std::env::var("TERMUX_VERSION").is_ok() {
45        return "Termux".to_string();
46    }
47
48    let checks: &[(&str, &str)] = &[
49        ("dnf", "Fedora"),
50        ("zypper", "openSUSE"),
51        ("apt", "Debian"),
52        ("pacman", "Arch"),
53        ("apk", "Alpine"),
54        ("xbps-install", "Void"),
55        ("emerge", "Gentoo"),
56        ("nix", "NixOS"),
57        ("pkg", "Termux"),
58    ];
59
60    for (cmd, name) in checks {
61        if Command::new("command").args(["-v", cmd]).output().is_ok() {
62            return name.to_string();
63        }
64    }
65
66    "Linux".to_string()
67}
68
69impl App {
70    pub fn new(options: &UiOptions) -> App {
71        let theme = match options.theme.as_str() {
72            "dracula" => Theme::dracula(),
73            "gruvbox" => Theme::gruvbox(),
74            "nord" => Theme::nord(),
75            "rose-pine" => Theme::rose_pine(),
76            _ => Theme::catppuccin_mocha(),
77        };
78
79        let distro = detect_distro();
80
81        let mode = if options.is_root {
82            AppMode::RootWarning
83        } else if distro == "Termux" {
84            AppMode::TermuxWarning
85        } else {
86            AppMode::Normal
87        };
88
89        App {
90            mode,
91            quit: false,
92            focused_panel: FocusedPanel::Categories,
93            log_mode: options.log_mode,
94            modules_dir: PathBuf::new(),
95            theme,
96            theme_locked: options.theme_locked,
97            distro,
98
99            scripts: StatefulList::new(),
100            categories: StatefulList::new(),
101            all_scripts: HashMap::new(),
102
103            script_panel_area: Rect::default(),
104            preview: PreviewState::default(),
105            search: SearchState::default(),
106            multi_select: MultiSelectState::default(),
107            help: HelpState::default(),
108            description: DescriptionState::default(),
109            run_script_popup: None,
110            script_execution_queue: VecDeque::new(),
111            theme_selector: ThemeSelectorState::default(),
112
113            needs_redraw: true,
114            last_size: Rect::default(),
115            log_path: options.log_path.clone(),
116        }
117    }
118
119    pub fn toggle_description_popup(&mut self) {
120        if self.mode == AppMode::Description {
121            self.mode = AppMode::Normal;
122            self.description.content = None;
123            if self.log_mode {
124                info!("Closed description popup");
125            }
126        } else if let Some(selected_script) = self.get_selected_script() {
127            let desc_path = self.modules_dir.join(&selected_script.category).join("desc.toml");
128
129            if self.log_mode {
130                info!(
131                    "Attempting to show description for script: {}/{}",
132                    selected_script.category, selected_script.name
133                );
134                info!("Description file path: {}", desc_path.display());
135            }
136
137            let desc = read_description(&desc_path, &selected_script.name);
138
139            if self.log_mode {
140                match &desc {
141                    Some(_) => {
142                        info!("Successfully loaded description and entered description mode.");
143                    }
144                    None => info!(
145                        "No description available for script '{}/{}'",
146                        selected_script.category, selected_script.name
147                    ),
148                }
149            }
150
151            self.description.content = Some(desc.unwrap_or_else(|| {
152                format!(
153                    "No description available for '{}/{}'.",
154                    selected_script.category, selected_script.name
155                )
156            }));
157            self.description.scroll = 0;
158            self.mode = AppMode::Description;
159        }
160    }
161
162    pub fn get_selected_script(&self) -> Option<&ScriptItem> {
163        self.scripts.state.selected().map(|i| &self.scripts.items[i])
164    }
165}
166
167fn read_description(desc_path: &std::path::Path, script_name: &str) -> Option<String> {
168    let content = std::fs::read_to_string(desc_path).ok()?;
169    let table: toml::Table = content.parse().ok()?;
170    let stem = std::path::Path::new(script_name).file_stem().and_then(|s| s.to_str())?;
171    table
172        .get(stem)
173        .and_then(|v| v.as_table())
174        .and_then(|t| t.get("description"))
175        .and_then(|v| v.as_str())
176        .map(str::to_string)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn write_desc(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
184        let path = dir.join("desc.toml");
185        std::fs::write(&path, content).unwrap();
186        path
187    }
188
189    #[test]
190    fn read_description_found() {
191        let dir = std::env::temp_dir().join(format!("carch_test_{}", std::process::id()));
192        std::fs::create_dir_all(&dir).unwrap();
193        let path = write_desc(
194            &dir,
195            r#"
196[install]
197description = "Installs packages"
198"#,
199        );
200        let desc = read_description(&path, "install.sh");
201        assert_eq!(desc.as_deref(), Some("Installs packages"));
202        let _ = std::fs::remove_dir_all(&dir);
203    }
204
205    #[test]
206    fn read_description_missing_file() {
207        let path = std::path::Path::new("/tmp/this_file_should_not_exist_carch.toml");
208        let _ = std::fs::remove_file(path);
209        assert_eq!(read_description(path, "x"), None);
210    }
211
212    #[test]
213    fn read_description_malformed_toml() {
214        let dir = std::env::temp_dir().join(format!("carch_test_bad_{}", std::process::id()));
215        std::fs::create_dir_all(&dir).unwrap();
216        let path = dir.join("desc.toml");
217        std::fs::write(&path, "this is = not valid toml [[[").unwrap();
218        assert_eq!(read_description(&path, "install"), None);
219        let _ = std::fs::remove_dir_all(&dir);
220    }
221
222    #[test]
223    fn read_description_missing_key() {
224        let dir = std::env::temp_dir().join(format!("carch_test_k_{}", std::process::id()));
225        std::fs::create_dir_all(&dir).unwrap();
226        let path = dir.join("desc.toml");
227        std::fs::write(
228            &path,
229            r#"[other]
230description = "x"
231"#,
232        )
233        .unwrap();
234        assert_eq!(read_description(&path, "install"), None);
235        let _ = std::fs::remove_dir_all(&dir);
236    }
237}