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}