1use crate::HusakoError;
2
3fn 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#[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
41pub 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
82pub 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
127pub 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
182pub 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 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
222pub 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
268pub 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
298pub 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
328fn 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
355pub 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
394pub 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
446pub fn versions_match(current: &str, latest: &str) -> bool {
451 if current == latest {
452 return true;
453 }
454
455 let c = current.strip_prefix('v').unwrap_or(current);
457 let l = latest.strip_prefix('v').unwrap_or(latest);
458
459 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 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 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 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 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 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 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}