Skip to main content

cmdhub_cli/
dto.rs

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    /// True when the contract was parsed from the tool's real --help (provenance =
31    /// probe). Agents should prefer verified contracts; inferred examples may be wrong.
32    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    // Build ordered candidate list:
48    //   1. System package manager for the detected OS
49    //   2. OS-specific AUR helpers (arch: yay, paru)
50    //   3. User-configured package managers from config
51    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        // Arch Linux: fall back to common AUR helpers when pacman doesn't have the package
58        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            // Normalize: if value is just a package name (no spaces, doesn't include PM keyword)
71            // expand to a full install command (e.g. "restic" → "pacman -S restic")
72            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    // Last resort: the tool may only ship via a manager the user didn't configure
82    // (e.g. oci-cli is pip-only). Returning *some* working install command beats null.
83    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
97/// Fallback when none of the user's configured/system managers have an entry.
98/// ONLY cross-platform/language managers — never another OS's system manager, so a
99/// Debian user is never told to `yay -S` (Arch-only) an AUR package. If a tool has
100/// no language-PM install for the user's platform, returning None is correct.
101const FALLBACK_PMS: &[&str] = &["cargo", "pip", "pipx", "uv", "npm", "go", "brew"];
102
103fn normalize_install_cmd(pm: &str, raw: &str) -> String {
104    // If the stored value already looks like a full command (contains spaces or PM keyword), use as-is
105    if raw.contains(' ') || raw.starts_with(pm) {
106        return raw.to_string();
107    }
108    // Package name only — expand to canonical install command
109    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                // Linux or other unix-like
179                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        // Package only has yay/paru, not pacman
323        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        // On arch, should fall back to yay when pacman not present
352        let cmd = resolve_install_command(&contract, &config);
353        assert_eq!(cmd, Some("yay -S python-pytube".to_string()));
354    }
355}