1use crate::config::Config;
2use crate::os_detector::detect_os;
3use cmdhub_shared::AciCommandContract;
4use serde::Serialize;
5
6#[derive(Serialize)]
7pub struct UsageDto {
8 pub cmd_path: String,
9 pub example_template: Option<String>,
10 pub confidence: String,
11}
12
13#[derive(Serialize)]
14pub struct MinimalDto {
15 pub cmd_path: String,
16 pub confidence: String,
17}
18
19#[derive(Serialize)]
20pub struct FullDto {
21 pub app_id: String,
22 pub name: String,
23 pub cmd_path: String,
24 pub node_type: String,
25 pub description: String,
26 pub risk_level: String,
27 pub example_template: Option<String>,
28 pub status: String,
29 pub install_command: Option<String>,
30 pub verified: bool,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub docker_image: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub script_url: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub source_url: Option<String>,
39 pub confidence: String,
40}
41
42pub fn resolve_install_command(contract: &AciCommandContract, config: &Config) -> Option<String> {
43 let instructions = contract.install_instructions.as_ref()?;
44
45 let os = config.install.os.clone().or_else(detect_os);
46
47 let mut candidates: Vec<(&str, bool)> = Vec::new();
52
53 if let Some(ref os_str) = os {
54 let sys_pm = map_os_to_package_manager(os_str);
55 candidates.push((sys_pm, is_system_installer(sys_pm)));
56
57 if os_str == "arch" {
59 candidates.push(("yay", false));
60 candidates.push(("paru", false));
61 }
62 }
63
64 for pm in &config.install.package_managers {
65 candidates.push((pm.as_str(), is_system_installer(pm)));
66 }
67
68 for (pm, is_sys) in candidates {
69 if let Some(raw) = instructions.get_command(pm) {
70 let cmd = normalize_install_cmd(pm, raw);
73 return Some(if is_sys && !is_root_user() {
74 format!("sudo {}", cmd)
75 } else {
76 cmd
77 });
78 }
79 }
80
81 for pm in FALLBACK_PMS {
84 if let Some(raw) = instructions.get_command(pm) {
85 let cmd = normalize_install_cmd(pm, raw);
86 return Some(if is_system_installer(pm) && !is_root_user() {
87 format!("sudo {}", cmd)
88 } else {
89 cmd
90 });
91 }
92 }
93
94 None
95}
96
97const FALLBACK_PMS: &[&str] = &["cargo", "pip", "pipx", "uv", "npm", "go", "brew"];
102
103fn normalize_install_cmd(pm: &str, raw: &str) -> String {
104 if raw.contains(' ') || raw.starts_with(pm) {
106 return raw.to_string();
107 }
108 match pm {
110 "pacman" => format!("pacman -S {}", raw),
111 "apt" => format!("apt install {}", raw),
112 "dnf" => format!("dnf install {}", raw),
113 "apk" => format!("apk add {}", raw),
114 "emerge" => format!("emerge {}", raw),
115 "zypper" => format!("zypper install {}", raw),
116 "yum" => format!("yum install {}", raw),
117 "brew" => format!("brew install {}", raw),
118 "scoop" => format!("scoop install {}", raw),
119 "choco" => format!("choco install {}", raw),
120 "nix-env" | "nix_env" => format!("nix-env -iA nixpkgs.{}", raw),
121 "yay" => format!("yay -S {}", raw),
122 "paru" => format!("paru -S {}", raw),
123 "pip" => format!("pip install {}", raw),
124 "npm" => format!("npm install -g {}", raw),
125 "cargo" => format!("cargo install {}", raw),
126 _ => raw.to_string(),
127 }
128}
129
130#[cfg(unix)]
131fn is_root_user() -> bool {
132 unsafe { libc::getuid() == 0 }
133}
134
135#[cfg(not(unix))]
136fn is_root_user() -> bool {
137 false
138}
139
140fn is_system_installer(installer: &str) -> bool {
141 matches!(
142 installer,
143 "apt" | "pacman" | "dnf" | "apk" | "emerge" | "zypper" | "yum"
144 )
145}
146
147fn map_os_to_package_manager(os: &str) -> &str {
148 match os {
149 "macos" => "brew",
150 "windows" => "scoop",
151 "arch" => "pacman",
152 "ubuntu" | "debian" => "apt",
153 "fedora" => "dnf",
154 "centos" | "rhel" => "yum",
155 "gentoo" => "emerge",
156 "alpine" => "apk",
157 "opensuse" => "zypper",
158 "nixos" => "nix-env",
159 other => other,
160 }
161}
162
163pub fn resolve_binary_name(contract: &AciCommandContract, config: &Config) -> String {
164 let os = config.install.os.clone().or_else(detect_os);
165 let os_str = os.as_deref().unwrap_or("linux");
166
167 if let Some(ref aliases) = contract.os_aliases {
168 match os_str {
169 "windows" => aliases
170 .windows
171 .clone()
172 .unwrap_or_else(|| contract.name.clone()),
173 "macos" => aliases
174 .macos
175 .clone()
176 .unwrap_or_else(|| contract.name.clone()),
177 _ => {
178 if let Some(ref linux_aliases) = aliases.linux {
180 match linux_aliases {
181 cmdhub_shared::StringOrArray::Single(s) => s.clone(),
182 cmdhub_shared::StringOrArray::Multiple(arr) => {
183 if arr.is_empty() {
184 contract.name.clone()
185 } else {
186 for entry in arr {
187 if which::which(entry).is_ok() {
188 return entry.clone();
189 }
190 }
191 arr[0].clone()
192 }
193 }
194 }
195 } else {
196 contract.name.clone()
197 }
198 }
199 }
200 } else {
201 contract.name.clone()
202 }
203}
204
205pub fn check_is_installed(contract: &AciCommandContract, config: &Config) -> bool {
206 let binary_name = resolve_binary_name(contract, config);
207 which::which(&binary_name).is_ok()
208}
209
210pub fn format_results(
211 contracts: Vec<AciCommandContract>,
212 mode: &str,
213 config: &Config,
214) -> serde_json::Value {
215 match mode {
216 "usage" => {
217 let dtos: Vec<UsageDto> = contracts
218 .into_iter()
219 .map(|c| UsageDto {
220 cmd_path: c.cmd_path,
221 example_template: c.example_template,
222 confidence: c.confidence,
223 })
224 .collect();
225 serde_json::to_value(&dtos).unwrap()
226 }
227 "minimal" => {
228 let dtos: Vec<MinimalDto> = contracts
229 .into_iter()
230 .map(|c| MinimalDto {
231 cmd_path: c.cmd_path,
232 confidence: c.confidence,
233 })
234 .collect();
235 serde_json::to_value(&dtos).unwrap()
236 }
237 _ => {
238 let dtos: Vec<FullDto> = contracts
239 .into_iter()
240 .map(|c| {
241 let install_command = resolve_install_command(&c, config);
242 let is_installed = check_is_installed(&c, config);
243 let status = if is_installed {
244 "installed".to_string()
245 } else {
246 "not_installed".to_string()
247 };
248 FullDto {
249 app_id: c.app_id,
250 name: c.name,
251 cmd_path: c.cmd_path,
252 node_type: format!("{:?}", c.node_type).to_lowercase(),
253 description: c.description,
254 risk_level: format!("{:?}", c.risk_level).to_lowercase(),
255 example_template: c.example_template,
256 status,
257 install_command,
258 verified: c.verified,
259 docker_image: c.docker_image,
260 script_url: c.script_url,
261 source_url: c.source_url,
262 confidence: c.confidence,
263 }
264 })
265 .collect();
266 serde_json::to_value(&dtos).unwrap()
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use cmdhub_shared::InstallInstructions;
275
276 #[test]
277 fn test_resolve_install_command_sudo() {
278 let contract = AciCommandContract {
279 app_id: "test".to_string(),
280 name: "test".to_string(),
281 cmd_path: "test".to_string(),
282 node_type: cmdhub_shared::NodeType::Root,
283 description: "test".to_string(),
284 risk_level: cmdhub_shared::RiskLevel::Safe,
285 example_template: None,
286 os_aliases: None,
287 install_instructions: Some(InstallInstructions {
288 brew: Some("brew install test".to_string()),
289 apt: Some("apt-get install test".to_string()),
290 pacman: None,
291 cargo: None,
292 scoop: Some("scoop install test".to_string()),
293 ..Default::default()
294 }),
295 docker_image: None,
296 script_url: None,
297 source_url: None,
298 popularity: 0.0,
299 verified: false,
300 confidence: "high".to_string(),
301 };
302
303 let mut config = Config::default();
304 config.install.os = Some("debian".to_string());
305
306 let cmd = resolve_install_command(&contract, &config).unwrap();
307 if is_root_user() {
308 assert_eq!(cmd, "apt-get install test");
309 } else {
310 assert_eq!(cmd, "sudo apt-get install test");
311 }
312
313 config.install.os = Some("macos".to_string());
314 let cmd_brew = resolve_install_command(&contract, &config).unwrap();
315 assert_eq!(cmd_brew, "brew install test");
316 }
317
318 #[test]
319 fn test_resolve_install_command_arch_aur_fallback() {
320 use std::collections::HashMap;
321
322 let mut others = HashMap::new();
324 others.insert("yay".to_string(), "yay -S python-pytube".to_string());
325 others.insert("paru".to_string(), "paru -S python-pytube".to_string());
326
327 let contract = AciCommandContract {
328 app_id: "test".to_string(),
329 name: "python-pytube".to_string(),
330 cmd_path: "pytube".to_string(),
331 node_type: cmdhub_shared::NodeType::Root,
332 description: "test".to_string(),
333 risk_level: cmdhub_shared::RiskLevel::Safe,
334 example_template: None,
335 os_aliases: None,
336 install_instructions: Some(InstallInstructions {
337 others,
338 ..Default::default()
339 }),
340 docker_image: None,
341 script_url: None,
342 source_url: None,
343 popularity: 0.0,
344 verified: false,
345 confidence: "high".to_string(),
346 };
347
348 let mut config = Config::default();
349 config.install.os = Some("arch".to_string());
350
351 let cmd = resolve_install_command(&contract, &config);
353 assert_eq!(cmd, Some("yay -S python-pytube".to_string()));
354 }
355}