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,
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
112 needs_redraw: true,
113 last_size: Rect::default(),
114 }
115 }
116
117 pub fn cycle_theme(&mut self) {
118 self.theme = match self.theme.name.as_str() {
119 "Catppuccin Mocha" => Theme::dracula(),
120 "Dracula" => Theme::gruvbox(),
121 "Gruvbox" => Theme::nord(),
122 "Nord" => Theme::rose_pine(),
123 _ => Theme::catppuccin_mocha(),
124 }
125 }
126
127 pub fn toggle_description_popup(&mut self) {
128 if self.mode == AppMode::Description {
129 self.mode = AppMode::Normal;
130 self.description.content = None;
131 if self.log_mode {
132 info!("Closed description popup");
133 }
134 } else if let Some(selected_script) = self.get_selected_script() {
135 let desc_path = self.modules_dir.join(&selected_script.category).join("desc.toml");
136
137 if self.log_mode {
138 info!(
139 "Attempting to show description for script: {}/{}",
140 selected_script.category, selected_script.name
141 );
142 info!("Description file path: {}", desc_path.display());
143 }
144
145 let desc = read_description(&desc_path, &selected_script.name);
146
147 if self.log_mode {
148 match &desc {
149 Some(_) => {
150 info!("Successfully loaded description and entered description mode.");
151 }
152 None => info!(
153 "No description available for script '{}/{}'",
154 selected_script.category, selected_script.name
155 ),
156 }
157 }
158
159 self.description.content = Some(desc.unwrap_or_else(|| {
160 format!(
161 "No description available for '{}/{}'.",
162 selected_script.category, selected_script.name
163 )
164 }));
165 self.description.scroll = 0;
166 self.mode = AppMode::Description;
167 }
168 }
169
170 pub fn get_selected_script(&self) -> Option<&ScriptItem> {
171 self.scripts.state.selected().map(|i| &self.scripts.items[i])
172 }
173}
174
175fn read_description(desc_path: &std::path::Path, script_name: &str) -> Option<String> {
176 let content = std::fs::read_to_string(desc_path).ok()?;
177 let table: toml::Table = content.parse().ok()?;
178 let stem = std::path::Path::new(script_name).file_stem().and_then(|s| s.to_str())?;
179 table
180 .get(stem)
181 .and_then(|v| v.as_table())
182 .and_then(|t| t.get("description"))
183 .and_then(|v| v.as_str())
184 .map(str::to_string)
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn write_desc(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
192 let path = dir.join("desc.toml");
193 std::fs::write(&path, content).unwrap();
194 path
195 }
196
197 #[test]
198 fn read_description_found() {
199 let dir = std::env::temp_dir().join(format!("carch_test_{}", std::process::id()));
200 std::fs::create_dir_all(&dir).unwrap();
201 let path = write_desc(
202 &dir,
203 r#"
204[install]
205description = "Installs packages"
206"#,
207 );
208 let desc = read_description(&path, "install.sh");
209 assert_eq!(desc.as_deref(), Some("Installs packages"));
210 let _ = std::fs::remove_dir_all(&dir);
211 }
212
213 #[test]
214 fn read_description_missing_file() {
215 let path = std::path::Path::new("/tmp/this_file_should_not_exist_carch.toml");
216 let _ = std::fs::remove_file(path);
217 assert_eq!(read_description(path, "x"), None);
218 }
219
220 #[test]
221 fn read_description_malformed_toml() {
222 let dir = std::env::temp_dir().join(format!("carch_test_bad_{}", std::process::id()));
223 std::fs::create_dir_all(&dir).unwrap();
224 let path = dir.join("desc.toml");
225 std::fs::write(&path, "this is = not valid toml [[[").unwrap();
226 assert_eq!(read_description(&path, "install"), None);
227 let _ = std::fs::remove_dir_all(&dir);
228 }
229
230 #[test]
231 fn read_description_missing_key() {
232 let dir = std::env::temp_dir().join(format!("carch_test_k_{}", std::process::id()));
233 std::fs::create_dir_all(&dir).unwrap();
234 let path = dir.join("desc.toml");
235 std::fs::write(
236 &path,
237 r#"[other]
238description = "x"
239"#,
240 )
241 .unwrap();
242 assert_eq!(read_description(&path, "install"), None);
243 let _ = std::fs::remove_dir_all(&dir);
244 }
245}