Skip to main content

ncu/
global.rs

1use anyhow::Result;
2use check_updates_core::{UpdateSeverity, Version};
3use std::process::Command;
4use std::str::FromStr;
5
6/// Source of a globally installed package
7#[derive(Debug, Clone, PartialEq, Eq, Hash)]
8pub enum GlobalSource {
9    Npm,
10}
11
12impl std::fmt::Display for GlobalSource {
13    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
14        match self {
15            GlobalSource::Npm => write!(f, "npm"),
16        }
17    }
18}
19
20/// A globally installed package
21#[derive(Debug, Clone)]
22pub struct GlobalPackage {
23    pub name: String,
24    pub installed_version: Version,
25    pub source: GlobalSource,
26}
27
28/// Result of checking a global package
29#[derive(Debug, Clone)]
30pub struct GlobalCheck {
31    pub package: GlobalPackage,
32    pub latest: Version,
33    pub has_update: bool,
34}
35
36impl GlobalCheck {
37    /// Get update severity for coloring
38    pub fn update_severity(&self) -> Option<UpdateSeverity> {
39        if !self.has_update {
40            return None;
41        }
42        let current = &self.package.installed_version;
43        let target = &self.latest;
44
45        if target.major > current.major {
46            Some(UpdateSeverity::Major)
47        } else if target.minor > current.minor {
48            Some(UpdateSeverity::Minor)
49        } else if target.patch > current.patch {
50            Some(UpdateSeverity::Patch)
51        } else {
52            None
53        }
54    }
55}
56
57/// Discovers globally installed packages
58#[derive(Default)]
59pub struct GlobalPackageDiscovery;
60
61impl GlobalPackageDiscovery {
62    pub fn new() -> Self {
63        Self
64    }
65
66    /// Discover all globally installed packages
67    pub fn discover(&self) -> Vec<GlobalPackage> {
68        self.discover_npm_packages().unwrap_or_default()
69    }
70
71    /// Discover npm global packages using `npm list -g --json --depth=0`
72    fn discover_npm_packages(&self) -> Result<Vec<GlobalPackage>> {
73        let output = Command::new("npm")
74            .args(["list", "-g", "--json", "--depth=0"])
75            .output();
76
77        match output {
78            Ok(output) if output.status.success() => {
79                self.parse_npm_global_json(&String::from_utf8_lossy(&output.stdout))
80            }
81            _ => Ok(Vec::new()),
82        }
83    }
84
85    /// Parse `npm list -g --json --depth=0` output
86    /// Format: {"dependencies": {"pkg": {"version": "X.Y.Z"}, ...}}
87    fn parse_npm_global_json(&self, json_str: &str) -> Result<Vec<GlobalPackage>> {
88        let data: serde_json::Value = serde_json::from_str(json_str)?;
89        let mut packages = Vec::new();
90
91        if let Some(deps) = data.get("dependencies").and_then(|v| v.as_object()) {
92            for (name, dep_data) in deps {
93                if let Some(version_str) = dep_data.get("version").and_then(|v| v.as_str())
94                    && let Ok(version) = Version::from_str(version_str)
95                {
96                    packages.push(GlobalPackage {
97                        name: name.clone(),
98                        installed_version: version,
99                        source: GlobalSource::Npm,
100                    });
101                }
102            }
103        }
104
105        Ok(packages)
106    }
107}
108
109/// Generate upgrade commands for outdated global packages
110pub fn generate_upgrade_commands(checks: &[GlobalCheck]) -> Vec<String> {
111    let outdated: Vec<&GlobalCheck> = checks.iter().filter(|c| c.has_update).collect();
112
113    if outdated.is_empty() {
114        return Vec::new();
115    }
116
117    let package_names: Vec<&str> = outdated.iter().map(|c| c.package.name.as_str()).collect();
118    vec![format!("npm install -g {}", package_names.join(" "))]
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_parse_npm_global_json() {
127        let discovery = GlobalPackageDiscovery::new();
128        let json = r#"{
129            "version": "10.2.0",
130            "name": "lib",
131            "dependencies": {
132                "typescript": {
133                    "version": "5.4.5"
134                },
135                "prettier": {
136                    "version": "3.2.5"
137                },
138                "@angular/cli": {
139                    "version": "17.3.8"
140                }
141            }
142        }"#;
143        let packages = discovery.parse_npm_global_json(json).expect("should parse");
144        assert_eq!(packages.len(), 3);
145
146        let ts = packages.iter().find(|p| p.name == "typescript").expect("should find typescript");
147        assert_eq!(ts.installed_version.to_string(), "5.4.5");
148        assert_eq!(ts.source, GlobalSource::Npm);
149
150        let angular = packages.iter().find(|p| p.name == "@angular/cli").expect("should find @angular/cli");
151        assert_eq!(angular.installed_version.to_string(), "17.3.8");
152    }
153
154    #[test]
155    fn test_parse_npm_global_json_empty() {
156        let discovery = GlobalPackageDiscovery::new();
157        let json = r#"{"version": "10.2.0", "name": "lib"}"#;
158        let packages = discovery.parse_npm_global_json(json).expect("should parse");
159        assert!(packages.is_empty());
160    }
161
162    #[test]
163    fn test_global_source_display() {
164        assert_eq!(GlobalSource::Npm.to_string(), "npm");
165    }
166
167    #[test]
168    fn test_update_severity() {
169        let pkg = GlobalPackage {
170            name: "test".to_string(),
171            installed_version: Version::from_str("1.0.0").expect("valid version"),
172            source: GlobalSource::Npm,
173        };
174
175        let check = GlobalCheck {
176            package: pkg.clone(),
177            latest: Version::from_str("2.0.0").expect("valid version"),
178            has_update: true,
179        };
180        assert_eq!(check.update_severity(), Some(UpdateSeverity::Major));
181
182        let check = GlobalCheck {
183            package: pkg.clone(),
184            latest: Version::from_str("1.1.0").expect("valid version"),
185            has_update: true,
186        };
187        assert_eq!(check.update_severity(), Some(UpdateSeverity::Minor));
188
189        let check = GlobalCheck {
190            package: pkg.clone(),
191            latest: Version::from_str("1.0.1").expect("valid version"),
192            has_update: true,
193        };
194        assert_eq!(check.update_severity(), Some(UpdateSeverity::Patch));
195
196        let check = GlobalCheck {
197            package: pkg,
198            latest: Version::from_str("1.0.0").expect("valid version"),
199            has_update: false,
200        };
201        assert_eq!(check.update_severity(), None);
202    }
203
204    #[test]
205    fn test_generate_upgrade_commands() {
206        let checks = vec![
207            GlobalCheck {
208                package: GlobalPackage {
209                    name: "typescript".to_string(),
210                    installed_version: Version::from_str("5.4.5").expect("valid version"),
211                    source: GlobalSource::Npm,
212                },
213                latest: Version::from_str("5.6.3").expect("valid version"),
214                has_update: true,
215            },
216            GlobalCheck {
217                package: GlobalPackage {
218                    name: "prettier".to_string(),
219                    installed_version: Version::from_str("3.2.5").expect("valid version"),
220                    source: GlobalSource::Npm,
221                },
222                latest: Version::from_str("3.2.5").expect("valid version"),
223                has_update: false,
224            },
225        ];
226
227        let commands = generate_upgrade_commands(&checks);
228        assert_eq!(commands.len(), 1);
229        assert_eq!(commands[0], "npm install -g typescript");
230    }
231
232    #[test]
233    fn test_generate_upgrade_commands_none_outdated() {
234        let checks = vec![GlobalCheck {
235            package: GlobalPackage {
236                name: "typescript".to_string(),
237                installed_version: Version::from_str("5.6.3").expect("valid version"),
238                source: GlobalSource::Npm,
239            },
240            latest: Version::from_str("5.6.3").expect("valid version"),
241            has_update: false,
242        }];
243
244        let commands = generate_upgrade_commands(&checks);
245        assert!(commands.is_empty());
246    }
247}