Skip to main content

husako_core/
version_check.rs

1use crate::HusakoError;
2
3// TODO: use urlencoding instead
4fn percent_encode(s: &str) -> String {
5    let mut out = String::with_capacity(s.len() * 3);
6    for byte in s.bytes() {
7        match byte {
8            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
9                out.push(byte as char);
10            }
11            _ => {
12                out.push_str(&format!("%{byte:02X}"));
13            }
14        }
15    }
16    out
17}
18
19// --- ArtifactHub search types ---
20
21#[derive(Debug, serde::Deserialize)]
22pub struct ArtifactHubPackage {
23    pub name: String,
24    pub version: String,
25    pub description: Option<String>,
26    pub repository: ArtifactHubRepo,
27}
28
29#[derive(Debug, serde::Deserialize)]
30pub struct ArtifactHubRepo {
31    pub name: String,
32}
33
34pub struct ArtifactHubSearchResult {
35    pub packages: Vec<ArtifactHubPackage>,
36    pub has_more: bool,
37}
38
39pub const ARTIFACTHUB_PAGE_SIZE: usize = 20;
40
41/// Search ArtifactHub for Helm charts matching the query.
42pub fn search_artifacthub(
43    query: &str,
44    offset: usize,
45) -> Result<ArtifactHubSearchResult, HusakoError> {
46    let client = reqwest::blocking::Client::builder()
47        .user_agent("husako")
48        .timeout(std::time::Duration::from_secs(10))
49        .build()
50        .map_err(|e| HusakoError::GenerateIo(format!("HTTP client: {e}")))?;
51
52    let encoded_query = percent_encode(query);
53    let limit = ARTIFACTHUB_PAGE_SIZE + 1;
54    let url = format!(
55        "https://artifacthub.io/api/v1/packages/search?ts_query_web={encoded_query}&kind=0&limit={limit}&offset={offset}"
56    );
57
58    let resp = client
59        .get(&url)
60        .send()
61        .map_err(|e| HusakoError::GenerateIo(format!("ArtifactHub search: {e}")))?;
62
63    let mut packages: Vec<ArtifactHubPackage> = resp
64        .json::<serde_json::Value>()
65        .map_err(|e| HusakoError::GenerateIo(format!("parse ArtifactHub search: {e}")))?
66        .get("packages")
67        .cloned()
68        .unwrap_or(serde_json::Value::Array(vec![]))
69        .as_array()
70        .cloned()
71        .unwrap_or_default()
72        .into_iter()
73        .filter_map(|v| serde_json::from_value(v).ok())
74        .collect();
75
76    let has_more = packages.len() > ARTIFACTHUB_PAGE_SIZE;
77    packages.truncate(ARTIFACTHUB_PAGE_SIZE);
78
79    Ok(ArtifactHubSearchResult { packages, has_more })
80}
81
82/// Discover the N most recent stable Kubernetes release versions (major.minor).
83pub fn discover_recent_releases(limit: usize, offset: usize) -> Result<Vec<String>, HusakoError> {
84    let client = reqwest::blocking::Client::builder()
85        .user_agent("husako")
86        .build()
87        .map_err(|e| HusakoError::GenerateIo(format!("HTTP client: {e}")))?;
88
89    let resp = client
90        .get("https://api.github.com/repos/kubernetes/kubernetes/tags?per_page=100")
91        .send()
92        .map_err(|e| HusakoError::GenerateIo(format!("GitHub API: {e}")))?;
93
94    let tags: Vec<serde_json::Value> = resp
95        .json()
96        .map_err(|e| HusakoError::GenerateIo(format!("parse tags: {e}")))?;
97
98    let mut versions: Vec<semver::Version> = Vec::new();
99    let mut seen = std::collections::HashSet::new();
100
101    for tag in &tags {
102        let Some(name) = tag["name"].as_str() else {
103            continue;
104        };
105        let stripped = name.strip_prefix('v').unwrap_or(name);
106        if stripped.contains('-') {
107            continue;
108        }
109        if let Ok(v) = semver::Version::parse(stripped) {
110            let key = format!("{}.{}", v.major, v.minor);
111            if seen.insert(key) {
112                versions.push(v);
113            }
114        }
115    }
116
117    versions.sort_by(|a, b| b.cmp(a));
118
119    Ok(versions
120        .iter()
121        .skip(offset)
122        .take(limit)
123        .map(|v| format!("{}.{}", v.major, v.minor))
124        .collect())
125}
126
127/// Discover available versions for a chart from a Helm registry.
128pub fn discover_registry_versions(
129    repo: &str,
130    chart: &str,
131    limit: usize,
132    offset: usize,
133) -> Result<Vec<String>, HusakoError> {
134    let url = format!("{}/index.yaml", repo.trim_end_matches('/'));
135    let client = reqwest::blocking::Client::builder()
136        .user_agent("husako")
137        .build()
138        .map_err(|e| HusakoError::GenerateIo(format!("HTTP client: {e}")))?;
139
140    let resp = client
141        .get(&url)
142        .send()
143        .map_err(|e| HusakoError::GenerateIo(format!("fetch registry index: {e}")))?;
144
145    let text = resp
146        .text()
147        .map_err(|e| HusakoError::GenerateIo(format!("read registry index: {e}")))?;
148
149    let index: serde_yaml_ng::Value = serde_yaml_ng::from_str(&text)
150        .map_err(|e| HusakoError::GenerateIo(format!("parse registry index: {e}")))?;
151
152    let entries = index
153        .get("entries")
154        .and_then(|e| e.get(chart))
155        .and_then(|e| e.as_sequence())
156        .ok_or_else(|| {
157            HusakoError::GenerateIo(format!("chart '{chart}' not found in registry index"))
158        })?;
159
160    let mut versions: Vec<semver::Version> = Vec::new();
161    for entry in entries {
162        let Some(version_str) = entry.get("version").and_then(|v| v.as_str()) else {
163            continue;
164        };
165        if let Ok(v) = semver::Version::parse(version_str)
166            && v.pre.is_empty()
167        {
168            versions.push(v);
169        }
170    }
171
172    versions.sort_by(|a, b| b.cmp(a));
173
174    Ok(versions
175        .iter()
176        .skip(offset)
177        .take(limit)
178        .map(|v| v.to_string())
179        .collect())
180}
181
182/// Discover the latest stable Kubernetes release version from GitHub API.
183pub fn discover_latest_release() -> Result<String, HusakoError> {
184    let client = reqwest::blocking::Client::builder()
185        .user_agent("husako")
186        .build()
187        .map_err(|e| HusakoError::GenerateIo(format!("HTTP client: {e}")))?;
188
189    let resp = client
190        .get("https://api.github.com/repos/kubernetes/kubernetes/tags?per_page=100")
191        .send()
192        .map_err(|e| HusakoError::GenerateIo(format!("GitHub API: {e}")))?;
193
194    let tags: Vec<serde_json::Value> = resp
195        .json()
196        .map_err(|e| HusakoError::GenerateIo(format!("parse tags: {e}")))?;
197
198    let mut best: Option<semver::Version> = None;
199
200    for tag in &tags {
201        let Some(name) = tag["name"].as_str() else {
202            continue;
203        };
204        let stripped = name.strip_prefix('v').unwrap_or(name);
205
206        // Skip pre-release tags (alpha, beta, rc)
207        if stripped.contains('-') {
208            continue;
209        }
210
211        if let Ok(v) = semver::Version::parse(stripped)
212            && best.as_ref().is_none_or(|b| v > *b)
213        {
214            best = Some(v);
215        }
216    }
217
218    best.map(|v| format!("{}.{}", v.major, v.minor))
219        .ok_or_else(|| HusakoError::GenerateIo("no stable release tags found".to_string()))
220}
221
222/// Discover the latest version from a Helm chart registry's index.yaml.
223pub fn discover_latest_registry(repo: &str, chart: &str) -> Result<String, HusakoError> {
224    let url = format!("{}/index.yaml", repo.trim_end_matches('/'));
225    let client = reqwest::blocking::Client::builder()
226        .user_agent("husako")
227        .build()
228        .map_err(|e| HusakoError::GenerateIo(format!("HTTP client: {e}")))?;
229
230    let resp = client
231        .get(&url)
232        .send()
233        .map_err(|e| HusakoError::GenerateIo(format!("fetch registry index: {e}")))?;
234
235    let text = resp
236        .text()
237        .map_err(|e| HusakoError::GenerateIo(format!("read registry index: {e}")))?;
238
239    let index: serde_yaml_ng::Value = serde_yaml_ng::from_str(&text)
240        .map_err(|e| HusakoError::GenerateIo(format!("parse registry index: {e}")))?;
241
242    let entries = index
243        .get("entries")
244        .and_then(|e| e.get(chart))
245        .and_then(|e| e.as_sequence())
246        .ok_or_else(|| {
247            HusakoError::GenerateIo(format!("chart '{chart}' not found in registry index"))
248        })?;
249
250    let mut best: Option<semver::Version> = None;
251
252    for entry in entries {
253        let Some(version_str) = entry.get("version").and_then(|v| v.as_str()) else {
254            continue;
255        };
256        if let Ok(v) = semver::Version::parse(version_str)
257            && v.pre.is_empty()
258            && best.as_ref().is_none_or(|b| v > *b)
259        {
260            best = Some(v);
261        }
262    }
263
264    best.map(|v| v.to_string())
265        .ok_or_else(|| HusakoError::GenerateIo(format!("no versions found for chart '{chart}'")))
266}
267
268/// Discover the latest version from ArtifactHub API.
269pub fn discover_latest_artifacthub(package: &str) -> Result<String, HusakoError> {
270    let url = format!(
271        "https://artifacthub.io/api/v1/packages/helm/{}",
272        package.trim_start_matches('/')
273    );
274    let client = reqwest::blocking::Client::builder()
275        .user_agent("husako")
276        .build()
277        .map_err(|e| HusakoError::GenerateIo(format!("HTTP client: {e}")))?;
278
279    let resp = client
280        .get(&url)
281        .send()
282        .map_err(|e| HusakoError::GenerateIo(format!("ArtifactHub API: {e}")))?;
283
284    let data: serde_json::Value = resp
285        .json()
286        .map_err(|e| HusakoError::GenerateIo(format!("parse ArtifactHub response: {e}")))?;
287
288    data["version"]
289        .as_str()
290        .map(|s| s.to_string())
291        .ok_or_else(|| {
292            HusakoError::GenerateIo(format!(
293                "no version field in ArtifactHub response for '{package}'"
294            ))
295        })
296}
297
298/// Discover available versions for a package from ArtifactHub API.
299/// Returns up to `limit` stable versions, sorted newest first.
300pub fn discover_artifacthub_versions(
301    package: &str,
302    limit: usize,
303    offset: usize,
304) -> Result<Vec<String>, HusakoError> {
305    let url = format!(
306        "https://artifacthub.io/api/v1/packages/helm/{}",
307        package.trim_start_matches('/')
308    );
309    let client = reqwest::blocking::Client::builder()
310        .user_agent("husako")
311        .timeout(std::time::Duration::from_secs(10))
312        .build()
313        .map_err(|e| HusakoError::GenerateIo(format!("HTTP client: {e}")))?;
314
315    let resp = client
316        .get(&url)
317        .send()
318        .map_err(|e| HusakoError::GenerateIo(format!("ArtifactHub API: {e}")))?;
319
320    let data: serde_json::Value = resp
321        .json()
322        .map_err(|e| HusakoError::GenerateIo(format!("parse ArtifactHub response: {e}")))?;
323
324    let versions = parse_artifacthub_versions(&data, limit, offset);
325    Ok(versions)
326}
327
328/// Parse and sort ArtifactHub `available_versions` by semver descending.
329/// Filters out pre-release entries and non-semver strings.
330fn parse_artifacthub_versions(
331    data: &serde_json::Value,
332    limit: usize,
333    offset: usize,
334) -> Vec<String> {
335    let mut parsed: Vec<semver::Version> = data["available_versions"]
336        .as_array()
337        .unwrap_or(&vec![])
338        .iter()
339        .filter(|entry| !entry["prerelease"].as_bool().unwrap_or(false))
340        .filter_map(|entry| entry["version"].as_str())
341        .filter_map(|v| semver::Version::parse(v).ok())
342        .filter(|v| v.pre.is_empty())
343        .collect();
344
345    parsed.sort_by(|a, b| b.cmp(a));
346
347    parsed
348        .iter()
349        .skip(offset)
350        .take(limit)
351        .map(|v| v.to_string())
352        .collect()
353}
354
355/// Discover the latest tag from a git repository using `git ls-remote --tags`.
356pub fn discover_latest_git_tag(repo: &str) -> Result<Option<String>, HusakoError> {
357    let output = std::process::Command::new("git")
358        .args(["ls-remote", "--tags", "--sort=-v:refname", repo])
359        .output()
360        .map_err(|e| HusakoError::GenerateIo(format!("git ls-remote: {e}")))?;
361
362    if !output.status.success() {
363        return Err(HusakoError::GenerateIo(format!(
364            "git ls-remote failed for '{repo}'"
365        )));
366    }
367
368    let stdout = String::from_utf8_lossy(&output.stdout);
369    let mut best: Option<(semver::Version, String)> = None;
370
371    for line in stdout.lines() {
372        let parts: Vec<&str> = line.split('\t').collect();
373        if parts.len() < 2 {
374            continue;
375        }
376        let refname = parts[1];
377        let tag = refname
378            .strip_prefix("refs/tags/")
379            .unwrap_or(refname)
380            .trim_end_matches("^{}");
381
382        let stripped = tag.strip_prefix('v').unwrap_or(tag);
383        if let Ok(v) = semver::Version::parse(stripped)
384            && v.pre.is_empty()
385            && best.as_ref().is_none_or(|(b, _)| v > *b)
386        {
387            best = Some((v, tag.to_string()));
388        }
389    }
390
391    Ok(best.map(|(_, tag)| tag))
392}
393
394/// Discover recent stable tags from a git repository.
395/// Returns up to `limit` stable semver tags, sorted newest first.
396pub fn discover_git_tags(
397    repo: &str,
398    limit: usize,
399    offset: usize,
400) -> Result<Vec<String>, HusakoError> {
401    let output = std::process::Command::new("git")
402        .args(["ls-remote", "--tags", "--sort=-v:refname", repo])
403        .output()
404        .map_err(|e| HusakoError::GenerateIo(format!("git ls-remote: {e}")))?;
405
406    if !output.status.success() {
407        return Err(HusakoError::GenerateIo(format!(
408            "git ls-remote failed for '{repo}'"
409        )));
410    }
411
412    let stdout = String::from_utf8_lossy(&output.stdout);
413    let mut seen = std::collections::HashSet::new();
414    let mut entries: Vec<(semver::Version, String)> = Vec::new();
415
416    for line in stdout.lines() {
417        let parts: Vec<&str> = line.split('\t').collect();
418        if parts.len() < 2 {
419            continue;
420        }
421        let refname = parts[1];
422        let tag = refname
423            .strip_prefix("refs/tags/")
424            .unwrap_or(refname)
425            .trim_end_matches("^{}");
426
427        let stripped = tag.strip_prefix('v').unwrap_or(tag);
428        if let Ok(v) = semver::Version::parse(stripped)
429            && v.pre.is_empty()
430            && seen.insert(tag.to_string())
431        {
432            entries.push((v, tag.to_string()));
433        }
434    }
435
436    entries.sort_by(|a, b| b.0.cmp(&a.0));
437
438    Ok(entries
439        .into_iter()
440        .skip(offset)
441        .take(limit)
442        .map(|(_, tag)| tag)
443        .collect())
444}
445
446/// Compare two version strings for equivalence.
447///
448/// Handles cases like "1.35" matching "1.35" from latest discovery,
449/// and "v1.17.2" matching "v1.17.2".
450pub fn versions_match(current: &str, latest: &str) -> bool {
451    if current == latest {
452        return true;
453    }
454
455    // Try semver comparison: parse both (stripping 'v' prefix)
456    let c = current.strip_prefix('v').unwrap_or(current);
457    let l = latest.strip_prefix('v').unwrap_or(latest);
458
459    // If current is just major.minor, check if latest starts with it
460    if !c.contains('.') || c.matches('.').count() == 1 {
461        return l.starts_with(c) || l == c;
462    }
463
464    c == l
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn artifacthub_package_deserialize() {
473        let json = serde_json::json!({
474            "name": "postgresql",
475            "version": "16.4.0",
476            "description": "PostgreSQL object-relational database",
477            "repository": { "name": "bitnami" }
478        });
479        let pkg: ArtifactHubPackage = serde_json::from_value(json).unwrap();
480        assert_eq!(pkg.name, "postgresql");
481        assert_eq!(pkg.version, "16.4.0");
482        assert_eq!(
483            pkg.description.as_deref(),
484            Some("PostgreSQL object-relational database")
485        );
486        assert_eq!(pkg.repository.name, "bitnami");
487    }
488
489    #[test]
490    fn artifacthub_package_missing_description() {
491        let json = serde_json::json!({
492            "name": "test",
493            "version": "1.0.0",
494            "repository": { "name": "org" }
495        });
496        let pkg: ArtifactHubPackage = serde_json::from_value(json).unwrap();
497        assert!(pkg.description.is_none());
498    }
499
500    #[test]
501    fn artifacthub_has_more_detection() {
502        // Simulate 21 results → has_more = true
503        let packages: Vec<ArtifactHubPackage> = (0..21)
504            .map(|i| ArtifactHubPackage {
505                name: format!("pkg-{i}"),
506                version: "1.0.0".to_string(),
507                description: None,
508                repository: ArtifactHubRepo {
509                    name: "org".to_string(),
510                },
511            })
512            .collect();
513        let has_more = packages.len() > ARTIFACTHUB_PAGE_SIZE;
514        assert!(has_more);
515
516        // Simulate 15 results → has_more = false
517        let packages: Vec<ArtifactHubPackage> = (0..15)
518            .map(|i| ArtifactHubPackage {
519                name: format!("pkg-{i}"),
520                version: "1.0.0".to_string(),
521                description: None,
522                repository: ArtifactHubRepo {
523                    name: "org".to_string(),
524                },
525            })
526            .collect();
527        let has_more = packages.len() > ARTIFACTHUB_PAGE_SIZE;
528        assert!(!has_more);
529    }
530
531    #[test]
532    fn artifacthub_display_formatting() {
533        let pkg = ArtifactHubPackage {
534            name: "postgresql".to_string(),
535            version: "16.4.0".to_string(),
536            description: Some("A very long description that should be truncated when displayed in the selection prompt for the user".to_string()),
537            repository: ArtifactHubRepo {
538                name: "bitnami".to_string(),
539            },
540        };
541        let package_id = format!("{}/{}", pkg.repository.name, pkg.name);
542        assert_eq!(package_id, "bitnami/postgresql");
543
544        // Truncate description at 50 chars
545        let desc = pkg.description.as_deref().unwrap_or("");
546        let truncated = if desc.len() > 50 {
547            format!("{}...", &desc[..50])
548        } else {
549            desc.to_string()
550        };
551        assert!(truncated.ends_with("..."));
552        assert!(truncated.len() <= 53);
553    }
554
555    #[test]
556    fn git_tags_multiple() {
557        // Simulate the parsing logic from discover_git_tags
558        let stdout = "\
559abc123\trefs/tags/v2.0.0\n\
560def456\trefs/tags/v2.0.0^{}\n\
561ghi789\trefs/tags/v1.9.0\n\
562jkl012\trefs/tags/v1.9.0^{}\n\
563mno345\trefs/tags/v1.8.0-rc.1\n\
564pqr678\trefs/tags/v1.8.0-rc.1^{}\n\
565stu901\trefs/tags/v1.7.0\n\
566vwx234\trefs/tags/v1.7.0^{}\n";
567
568        let mut seen = std::collections::HashSet::new();
569        let mut entries: Vec<(semver::Version, String)> = Vec::new();
570
571        for line in stdout.lines() {
572            let parts: Vec<&str> = line.split('\t').collect();
573            if parts.len() < 2 {
574                continue;
575            }
576            let refname = parts[1];
577            let tag = refname
578                .strip_prefix("refs/tags/")
579                .unwrap_or(refname)
580                .trim_end_matches("^{}");
581
582            let stripped = tag.strip_prefix('v').unwrap_or(tag);
583            if let Ok(v) = semver::Version::parse(stripped)
584                && v.pre.is_empty()
585                && seen.insert(tag.to_string())
586            {
587                entries.push((v, tag.to_string()));
588            }
589        }
590
591        entries.sort_by(|a, b| b.0.cmp(&a.0));
592        entries.truncate(2);
593
594        let tags: Vec<String> = entries.into_iter().map(|(_, tag)| tag).collect();
595        assert_eq!(tags, vec!["v2.0.0", "v1.9.0"]);
596    }
597
598    #[test]
599    fn artifacthub_versions_filters_prerelease() {
600        let data = serde_json::json!({
601            "available_versions": [
602                {"version": "3.0.0", "prerelease": false},
603                {"version": "3.0.0-rc.1", "prerelease": true},
604                {"version": "2.5.0", "prerelease": false},
605                {"version": "2.5.0-beta.1", "prerelease": true},
606                {"version": "2.4.0", "prerelease": false},
607            ]
608        });
609        let versions = parse_artifacthub_versions(&data, 10, 0);
610        assert_eq!(versions, vec!["3.0.0", "2.5.0", "2.4.0"]);
611    }
612
613    #[test]
614    fn artifacthub_versions_sorted_descending() {
615        // API may return versions in arbitrary order
616        let data = serde_json::json!({
617            "available_versions": [
618                {"version": "1.0.0", "prerelease": false},
619                {"version": "3.0.0", "prerelease": false},
620                {"version": "0.0.0", "prerelease": false},
621                {"version": "2.1.0", "prerelease": false},
622                {"version": "2.0.0", "prerelease": false},
623            ]
624        });
625        let versions = parse_artifacthub_versions(&data, 10, 0);
626        assert_eq!(versions, vec!["3.0.0", "2.1.0", "2.0.0", "1.0.0", "0.0.0"]);
627    }
628
629    #[test]
630    fn artifacthub_versions_offset_and_limit() {
631        let data = serde_json::json!({
632            "available_versions": [
633                {"version": "5.0.0", "prerelease": false},
634                {"version": "4.0.0", "prerelease": false},
635                {"version": "3.0.0", "prerelease": false},
636                {"version": "2.0.0", "prerelease": false},
637                {"version": "1.0.0", "prerelease": false},
638            ]
639        });
640        // Skip first 2, take 2
641        let versions = parse_artifacthub_versions(&data, 2, 2);
642        assert_eq!(versions, vec!["3.0.0", "2.0.0"]);
643    }
644
645    #[test]
646    fn artifacthub_versions_skips_invalid_semver() {
647        let data = serde_json::json!({
648            "available_versions": [
649                {"version": "2.0.0", "prerelease": false},
650                {"version": "not-a-version", "prerelease": false},
651                {"version": "1.0.0", "prerelease": false},
652            ]
653        });
654        let versions = parse_artifacthub_versions(&data, 10, 0);
655        assert_eq!(versions, vec!["2.0.0", "1.0.0"]);
656    }
657
658    #[test]
659    fn versions_match_exact() {
660        assert!(versions_match("1.35", "1.35"));
661        assert!(versions_match("v1.17.2", "v1.17.2"));
662    }
663
664    #[test]
665    fn versions_match_prefix() {
666        assert!(versions_match("1.35", "1.35.0"));
667        assert!(versions_match("1.35", "1.35.1"));
668    }
669
670    #[test]
671    fn versions_no_match() {
672        assert!(!versions_match("1.35", "1.36"));
673        assert!(!versions_match("v1.17.2", "v1.18.0"));
674    }
675
676    #[test]
677    fn versions_match_v_prefix() {
678        assert!(versions_match("1.35", "1.35"));
679    }
680}