1use quick_xml::events::Event;
4use quick_xml::Reader;
5use serde::Serialize;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TagStage {
10 Candidate,
12 Testing,
14 Release,
16}
17
18impl std::fmt::Display for TagStage {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 TagStage::Candidate => write!(f, "candidate"),
22 TagStage::Testing => write!(f, "testing"),
23 TagStage::Release => write!(f, "release"),
24 }
25 }
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct Build {
31 pub build_id: i64,
32 pub name: String,
33 pub version: String,
34 pub release: String,
35 pub nvr: String,
36}
37
38impl Build {
39 pub fn is_hyperscale(&self) -> bool {
41 self.release.contains(".hs.")
42 }
43
44 pub fn el_version(&self) -> Option<u32> {
48 let s = self.release.rsplit_once(".el")?;
49 let num = s.1.split('_').next()?;
51 num.parse().ok()
52 }
53}
54
55pub struct Client {
57 http: reqwest::blocking::Client,
58 hub_url: String,
59}
60
61impl Client {
62 pub fn new() -> Self {
63 Self::with_hub_url("https://cbs.centos.org/kojihub")
64 }
65
66 pub fn with_hub_url(hub_url: &str) -> Self {
67 let http = reqwest::blocking::Client::builder()
68 .user_agent("hs-relmon/0.1.0")
69 .build()
70 .expect("failed to build HTTP client");
71 Self {
72 http,
73 hub_url: hub_url.trim_end_matches('/').to_string(),
74 }
75 }
76
77 pub fn get_package_id(&self, name: &str) -> Result<Option<i64>, Box<dyn std::error::Error>> {
79 let body = format!(
80 r#"<?xml version="1.0"?>
81<methodCall>
82 <methodName>getPackageID</methodName>
83 <params>
84 <param><value><string>{name}</string></value></param>
85 </params>
86</methodCall>"#
87 );
88 let resp = self.call(&body)?;
89 let value = parse_single_value(&resp)?;
91 match value {
92 XmlRpcValue::Int(id) => Ok(Some(id)),
93 XmlRpcValue::Nil => Ok(None),
94 other => Err(format!("unexpected response type: {other:?}").into()),
95 }
96 }
97
98 pub fn list_builds(&self, package_id: i64) -> Result<Vec<Build>, Box<dyn std::error::Error>> {
100 let body = format!(
104 r#"<?xml version="1.0"?>
105<methodCall>
106 <methodName>listBuilds</methodName>
107 <params>
108 <param><value><int>{package_id}</int></value></param>
109 <param><value><nil/></value></param>
110 <param><value><nil/></value></param>
111 <param><value><nil/></value></param>
112 <param><value><int>1</int></value></param>
113 <param><value><nil/></value></param>
114 <param><value><nil/></value></param>
115 <param><value><nil/></value></param>
116 <param><value><nil/></value></param>
117 <param><value><nil/></value></param>
118 <param><value><nil/></value></param>
119 <param><value><nil/></value></param>
120 <param><value><nil/></value></param>
121 <param><value><struct>
122 <member><name>order</name><value><string>-build_id</string></value></member>
123 </struct></value></param>
124 </params>
125</methodCall>"#
126 );
127 let resp = self.call(&body)?;
128 parse_builds(&resp)
129 }
130
131 pub fn list_tags(&self, build_id: i64) -> Result<Vec<String>, Box<dyn std::error::Error>> {
133 let body = format!(
134 r#"<?xml version="1.0"?>
135<methodCall>
136 <methodName>listTags</methodName>
137 <params>
138 <param><value><int>{build_id}</int></value></param>
139 </params>
140</methodCall>"#
141 );
142 let resp = self.call(&body)?;
143 parse_tag_names(&resp)
144 }
145
146 pub fn hyperscale_summary(
152 &self,
153 builds: &[Build],
154 el_version: u32,
155 ) -> Result<HyperscaleSummary, Box<dyn std::error::Error>> {
156 resolve_summary(builds, el_version, |build_id| self.list_tags(build_id))
157 }
158
159 fn call(&self, body: &str) -> Result<String, Box<dyn std::error::Error>> {
160 let resp = self
161 .http
162 .post(&self.hub_url)
163 .header("Content-Type", "text/xml")
164 .body(body.to_string())
165 .send()?
166 .text()?;
167 Ok(resp)
168 }
169}
170
171#[derive(Debug, Clone, Serialize)]
173pub struct HyperscaleSummary {
174 pub release: Option<Build>,
176 pub testing: Option<Build>,
178}
179
180pub fn hyperscale_builds(builds: &[Build], el_version: u32) -> Vec<&Build> {
184 builds
185 .iter()
186 .filter(|b| b.is_hyperscale() && b.el_version() == Some(el_version))
187 .collect()
188}
189
190pub fn resolve_summary<F>(
195 builds: &[Build],
196 el_version: u32,
197 lookup_tags: F,
198) -> Result<HyperscaleSummary, Box<dyn std::error::Error>>
199where
200 F: Fn(i64) -> Result<Vec<String>, Box<dyn std::error::Error>>,
201{
202 let mut summary = HyperscaleSummary {
203 release: None,
204 testing: None,
205 };
206
207 for build in hyperscale_builds(builds, el_version) {
208 let tags = lookup_tags(build.build_id)?;
209 let stage = tag_stage(&tags);
210
211 match stage {
212 Some(TagStage::Release) => {
213 summary.release = Some(build.clone());
214 break;
215 }
216 Some(TagStage::Testing) => {
217 if summary.testing.is_none() {
218 summary.testing = Some(build.clone());
219 }
220 }
221 _ => {}
222 }
223 }
224
225 Ok(summary)
226}
227
228pub fn tag_stage(tags: &[String]) -> Option<TagStage> {
232 let mut stage: Option<TagStage> = None;
233 for tag in tags {
234 if !tag.starts_with("hyperscale") {
235 continue;
236 }
237 let new = if tag.ends_with("-release") {
238 TagStage::Release
239 } else if tag.ends_with("-testing") {
240 TagStage::Testing
241 } else if tag.ends_with("-candidate") {
242 TagStage::Candidate
243 } else {
244 continue;
245 };
246 stage = Some(match stage {
247 None => new,
248 Some(TagStage::Release) => TagStage::Release,
249 Some(TagStage::Testing) if new == TagStage::Release => TagStage::Release,
250 Some(TagStage::Testing) => TagStage::Testing,
251 Some(TagStage::Candidate) => new,
252 });
253 }
254 stage
255}
256
257#[derive(Debug, Clone, PartialEq)]
260enum XmlRpcValue {
261 Int(i64),
262 Str(String),
263 Nil,
264 Array(Vec<XmlRpcValue>),
265 Struct(Vec<(String, XmlRpcValue)>),
266}
267
268fn parse_single_value(xml: &str) -> Result<XmlRpcValue, Box<dyn std::error::Error>> {
270 let values = parse_response_values(xml)?;
271 values
272 .into_iter()
273 .next()
274 .ok_or_else(|| "empty response".into())
275}
276
277fn parse_response_values(xml: &str) -> Result<Vec<XmlRpcValue>, Box<dyn std::error::Error>> {
279 let mut reader = Reader::from_str(xml);
281 let mut values = Vec::new();
282 let mut depth = Vec::<String>::new();
283
284 loop {
285 match reader.read_event()? {
286 Event::Start(e) => {
287 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
288 depth.push(tag);
289 if depth == ["methodResponse", "params", "param", "value"] {
290 let val = parse_value(&mut reader, &mut depth)?;
291 values.push(val);
292 }
293 }
294 Event::End(_) => {
295 depth.pop();
296 }
297 Event::Empty(e) => {
298 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
300 if tag == "fault" {
301 return Err("XML-RPC fault".into());
302 }
303 }
304 Event::Eof => break,
305 _ => {}
306 }
307 }
308 Ok(values)
309}
310
311fn parse_value(
313 reader: &mut Reader<&[u8]>,
314 depth: &mut Vec<String>,
315) -> Result<XmlRpcValue, Box<dyn std::error::Error>> {
316 loop {
317 match reader.read_event()? {
318 Event::Start(e) => {
319 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
320 depth.push(tag.clone());
321 match tag.as_str() {
322 "int" | "i4" | "i8" => {
323 let text = reader.read_text(e.name())?;
324 depth.pop();
325 consume_end_value(reader, depth)?;
326 return Ok(XmlRpcValue::Int(text.trim().parse()?));
327 }
328 "string" => {
329 let text = reader.read_text(e.name())?;
330 depth.pop();
331 consume_end_value(reader, depth)?;
332 return Ok(XmlRpcValue::Str(text.to_string()));
333 }
334 "array" => {
335 let arr = parse_array(reader, depth)?;
336 consume_end_value(reader, depth)?;
337 return Ok(XmlRpcValue::Array(arr));
338 }
339 "struct" => {
340 let members = parse_struct(reader, depth)?;
341 consume_end_value(reader, depth)?;
342 return Ok(XmlRpcValue::Struct(members));
343 }
344 "nil" => {
345 let _ = reader.read_text(e.name())?;
346 depth.pop();
347 consume_end_value(reader, depth)?;
348 return Ok(XmlRpcValue::Nil);
349 }
350 _ => {
351 let text = reader.read_text(e.name())?;
353 depth.pop();
354 consume_end_value(reader, depth)?;
355 return Ok(XmlRpcValue::Str(text.to_string()));
356 }
357 }
358 }
359 Event::Empty(e) => {
360 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
361 if tag == "nil" {
362 consume_end_value(reader, depth)?;
363 return Ok(XmlRpcValue::Nil);
364 }
365 }
366 Event::Text(e) => {
367 let text = e.unescape()?.to_string();
369 if !text.trim().is_empty() {
370 consume_end_value(reader, depth)?;
371 return Ok(XmlRpcValue::Str(text));
372 }
373 }
374 Event::End(_) => {
375 depth.pop();
377 return Ok(XmlRpcValue::Nil);
378 }
379 Event::Eof => return Err("unexpected EOF in value".into()),
380 _ => {}
381 }
382 }
383}
384
385fn consume_end_value(
386 reader: &mut Reader<&[u8]>,
387 depth: &mut Vec<String>,
388) -> Result<(), Box<dyn std::error::Error>> {
389 loop {
391 match reader.read_event()? {
392 Event::End(e) => {
393 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
394 depth.pop();
395 if tag == "value" {
396 return Ok(());
397 }
398 }
399 Event::Eof => return Err("unexpected EOF waiting for </value>".into()),
400 _ => {}
401 }
402 }
403}
404
405fn parse_array(
406 reader: &mut Reader<&[u8]>,
407 depth: &mut Vec<String>,
408) -> Result<Vec<XmlRpcValue>, Box<dyn std::error::Error>> {
409 let mut items = Vec::new();
410 loop {
411 match reader.read_event()? {
412 Event::Start(e) => {
413 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
414 depth.push(tag.clone());
415 if tag == "value" {
416 items.push(parse_value(reader, depth)?);
417 }
418 }
419 Event::End(e) => {
420 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
421 if tag == "array" {
422 depth.pop();
423 return Ok(items);
424 }
425 depth.pop();
426 }
427 Event::Eof => return Err("unexpected EOF in array".into()),
428 _ => {}
429 }
430 }
431}
432
433fn parse_struct(
434 reader: &mut Reader<&[u8]>,
435 depth: &mut Vec<String>,
436) -> Result<Vec<(String, XmlRpcValue)>, Box<dyn std::error::Error>> {
437 let mut members = Vec::new();
438 let mut current_name: Option<String> = None;
439
440 loop {
441 match reader.read_event()? {
442 Event::Start(e) => {
443 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
444 depth.push(tag.clone());
445 match tag.as_str() {
446 "name" => {
447 let text = reader.read_text(e.name())?;
448 depth.pop();
449 current_name = Some(text.to_string());
450 }
451 "value" => {
452 let val = parse_value(reader, depth)?;
453 if let Some(name) = current_name.take() {
454 members.push((name, val));
455 }
456 }
457 _ => {}
458 }
459 }
460 Event::End(e) => {
461 let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
462 if tag == "struct" {
463 depth.pop();
464 return Ok(members);
465 }
466 depth.pop();
467 }
468 Event::Eof => return Err("unexpected EOF in struct".into()),
469 _ => {}
470 }
471 }
472}
473
474fn parse_tag_names(xml: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
476 let value = parse_single_value(xml)?;
477 let XmlRpcValue::Array(items) = value else {
478 return Err("expected array response".into());
479 };
480
481 let mut names = Vec::new();
482 for item in items {
483 let XmlRpcValue::Struct(members) = item else {
484 continue;
485 };
486 for (key, val) in &members {
487 if key == "name" {
488 if let XmlRpcValue::Str(v) = val {
489 names.push(v.clone());
490 }
491 }
492 }
493 }
494 Ok(names)
495}
496
497fn parse_builds(xml: &str) -> Result<Vec<Build>, Box<dyn std::error::Error>> {
499 let value = parse_single_value(xml)?;
500 let XmlRpcValue::Array(items) = value else {
501 return Err("expected array response".into());
502 };
503
504 let mut builds = Vec::new();
505 for item in items {
506 let XmlRpcValue::Struct(members) = item else {
507 continue;
508 };
509 let mut build_id = 0i64;
510 let mut name = String::new();
511 let mut version = String::new();
512 let mut release = String::new();
513 let mut nvr = String::new();
514
515 for (key, val) in &members {
516 match key.as_str() {
517 "build_id" => {
518 if let XmlRpcValue::Int(v) = val {
519 build_id = *v;
520 }
521 }
522 "name" | "package_name" => {
523 if let XmlRpcValue::Str(v) = val {
524 if name.is_empty() {
525 name = v.clone();
526 }
527 }
528 }
529 "version" => {
530 if let XmlRpcValue::Str(v) = val {
531 version = v.clone();
532 }
533 }
534 "release" => {
535 if let XmlRpcValue::Str(v) = val {
536 release = v.clone();
537 }
538 }
539 "nvr" => {
540 if let XmlRpcValue::Str(v) = val {
541 nvr = v.clone();
542 }
543 }
544 _ => {}
545 }
546 }
547
548 if !nvr.is_empty() {
549 builds.push(Build {
550 build_id,
551 name,
552 version,
553 release,
554 nvr,
555 });
556 }
557 }
558
559 Ok(builds)
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 #[test]
567 fn test_build_is_hyperscale() {
568 let hs = Build {
569 build_id: 1,
570 name: "ethtool".into(),
571 version: "6.15".into(),
572 release: "3.hs.el10".into(),
573 nvr: "ethtool-6.15-3.hs.el10".into(),
574 };
575 assert!(hs.is_hyperscale());
576
577 let non_hs = Build {
578 build_id: 2,
579 name: "ethtool".into(),
580 version: "6.2".into(),
581 release: "1.el9sbase_901".into(),
582 nvr: "ethtool-6.2-1.el9sbase_901".into(),
583 };
584 assert!(!non_hs.is_hyperscale());
585 }
586
587 #[test]
588 fn test_el_version() {
589 let cases = [
590 ("3.hs.el9", Some(9)),
591 ("3.hs.el10", Some(10)),
592 ("1.hs.el9_3", Some(9)),
593 ("1.hs.el10_2", Some(10)),
594 ("1.el9sbase_901", None),
595 ("2.el10s~1", None),
596 ];
597 for (release, expected) in cases {
598 let b = Build {
599 build_id: 1,
600 name: "test".into(),
601 version: "1".into(),
602 release: release.into(),
603 nvr: format!("test-1-{release}"),
604 };
605 assert_eq!(b.el_version(), expected, "release={release}");
606 }
607 }
608
609 #[test]
610 fn test_hyperscale_builds_filters_by_el_version() {
611 let builds = vec![
612 Build {
613 build_id: 3,
614 name: "ethtool".into(),
615 version: "6.15".into(),
616 release: "2.el10s~1".into(),
617 nvr: "ethtool-6.15-2.el10s~1".into(),
618 },
619 Build {
620 build_id: 2,
621 name: "ethtool".into(),
622 version: "6.15".into(),
623 release: "3.hs.el9".into(),
624 nvr: "ethtool-6.15-3.hs.el9".into(),
625 },
626 Build {
627 build_id: 1,
628 name: "ethtool".into(),
629 version: "6.14".into(),
630 release: "1.hs.el10".into(),
631 nvr: "ethtool-6.14-1.hs.el10".into(),
632 },
633 ];
634 let el9 = hyperscale_builds(&builds, 9);
635 assert_eq!(el9.len(), 1);
636 assert_eq!(el9[0].nvr, "ethtool-6.15-3.hs.el9");
637
638 let el10 = hyperscale_builds(&builds, 10);
639 assert_eq!(el10.len(), 1);
640 assert_eq!(el10[0].nvr, "ethtool-6.14-1.hs.el10");
641
642 assert!(hyperscale_builds(&builds, 8).is_empty());
643 }
644
645 #[test]
646 fn test_hyperscale_builds_empty() {
647 let builds = vec![Build {
648 build_id: 1,
649 name: "ethtool".into(),
650 version: "6.2".into(),
651 release: "1.el9sbase_901".into(),
652 nvr: "ethtool-6.2-1.el9sbase_901".into(),
653 }];
654 assert!(hyperscale_builds(&builds, 9).is_empty());
655 }
656
657 fn make_build(build_id: i64, version: &str, release: &str) -> Build {
658 Build {
659 build_id,
660 name: "pkg".into(),
661 version: version.into(),
662 release: release.into(),
663 nvr: format!("pkg-{version}-{release}"),
664 }
665 }
666
667 fn mock_tags(mapping: &[(i64, &[&str])]) -> impl Fn(i64) -> Result<Vec<String>, Box<dyn std::error::Error>> {
668 let map: std::collections::HashMap<i64, Vec<String>> = mapping
669 .iter()
670 .map(|(id, tags)| (*id, tags.iter().map(|s| s.to_string()).collect()))
671 .collect();
672 move |build_id| Ok(map.get(&build_id).cloned().unwrap_or_default())
673 }
674
675 #[test]
676 fn test_resolve_summary_release_only() {
677 let builds = vec![
678 make_build(3, "6.15", "3.hs.el9"),
679 ];
680 let tags = mock_tags(&[
681 (3, &["hyperscale9s-packages-main-release"]),
682 ]);
683 let summary = resolve_summary(&builds, 9, tags).unwrap();
684 assert_eq!(summary.release.as_ref().unwrap().build_id, 3);
685 assert!(summary.testing.is_none());
686 }
687
688 #[test]
689 fn test_resolve_summary_testing_then_release() {
690 let builds = vec![
692 make_build(4, "260~rc2", "20260309.hs.el9"),
693 make_build(3, "258.5", "1.1.hs.el9"),
694 ];
695 let tags = mock_tags(&[
696 (4, &["hyperscale9s-packages-main-testing"]),
697 (3, &["hyperscale9s-packages-main-release"]),
698 ]);
699 let summary = resolve_summary(&builds, 9, tags).unwrap();
700 assert_eq!(summary.release.as_ref().unwrap().version, "258.5");
701 assert_eq!(summary.testing.as_ref().unwrap().version, "260~rc2");
702 }
703
704 #[test]
705 fn test_resolve_summary_testing_only() {
706 let builds = vec![
707 make_build(4, "260~rc2", "20260309.hs.el10"),
708 ];
709 let tags = mock_tags(&[
710 (4, &["hyperscale10s-packages-main-testing"]),
711 ]);
712 let summary = resolve_summary(&builds, 10, tags).unwrap();
713 assert!(summary.release.is_none());
714 assert_eq!(summary.testing.as_ref().unwrap().version, "260~rc2");
715 }
716
717 #[test]
718 fn test_resolve_summary_skips_candidate() {
719 let builds = vec![
721 make_build(5, "261", "1.hs.el9"),
722 make_build(4, "260", "1.hs.el9"),
723 make_build(3, "258", "1.hs.el9"),
724 ];
725 let tags = mock_tags(&[
726 (5, &["hyperscale9s-packages-main-candidate"]),
727 (4, &["hyperscale9s-packages-main-testing"]),
728 (3, &["hyperscale9s-packages-main-release"]),
729 ]);
730 let summary = resolve_summary(&builds, 9, tags).unwrap();
731 assert_eq!(summary.release.as_ref().unwrap().version, "258");
732 assert_eq!(summary.testing.as_ref().unwrap().version, "260");
733 }
734
735 #[test]
736 fn test_resolve_summary_empty() {
737 let builds: Vec<Build> = vec![];
738 let tags = mock_tags(&[]);
739 let summary = resolve_summary(&builds, 9, tags).unwrap();
740 assert!(summary.release.is_none());
741 assert!(summary.testing.is_none());
742 }
743
744 #[test]
745 fn test_resolve_summary_no_testing_when_release_is_latest() {
746 let builds = vec![
748 make_build(3, "6.15", "3.hs.el10"),
749 make_build(2, "6.14", "1.hs.el10"),
750 ];
751 let tags = mock_tags(&[
752 (3, &["hyperscale10s-packages-main-release"]),
753 ]);
755 let summary = resolve_summary(&builds, 10, tags).unwrap();
756 assert_eq!(summary.release.as_ref().unwrap().version, "6.15");
757 assert!(summary.testing.is_none());
758 }
759
760 #[test]
761 fn test_parse_get_package_id_response() {
762 let xml = r#"<?xml version='1.0'?>
763<methodResponse>
764<params>
765<param>
766<value><int>8491</int></value>
767</param>
768</params>
769</methodResponse>"#;
770 let val = parse_single_value(xml).unwrap();
771 assert_eq!(val, XmlRpcValue::Int(8491));
772 }
773
774 #[test]
775 fn test_parse_nil_response() {
776 let xml = r#"<?xml version='1.0'?>
777<methodResponse>
778<params>
779<param>
780<value><nil/></value>
781</param>
782</params>
783</methodResponse>"#;
784 let val = parse_single_value(xml).unwrap();
785 assert_eq!(val, XmlRpcValue::Nil);
786 }
787
788 #[test]
789 fn test_parse_builds_response() {
790 let xml = include_str!("../tests/fixtures/koji_builds.xml");
791 let builds = parse_builds(xml).unwrap();
792 assert_eq!(builds.len(), 3);
793
794 assert_eq!(builds[0].nvr, "ethtool-6.15-3.hs.el9");
795 assert_eq!(builds[0].build_id, 61758);
796 assert_eq!(builds[0].version, "6.15");
797 assert_eq!(builds[0].release, "3.hs.el9");
798 assert!(builds[0].is_hyperscale());
799
800 assert_eq!(builds[1].nvr, "ethtool-6.15-3.hs.el10");
801 assert!(builds[1].is_hyperscale());
802
803 assert_eq!(builds[2].nvr, "ethtool-6.14-1.hs.el10");
804 }
805
806 #[test]
807 fn test_parse_empty_array() {
808 let xml = r#"<?xml version='1.0'?>
809<methodResponse>
810<params>
811<param>
812<value><array><data></data></array></value>
813</param>
814</params>
815</methodResponse>"#;
816 let builds = parse_builds(xml).unwrap();
817 assert!(builds.is_empty());
818 }
819
820 #[test]
821 fn test_parse_tag_names() {
822 let xml = include_str!("../tests/fixtures/koji_tags.xml");
823 let names = parse_tag_names(xml).unwrap();
824 assert_eq!(names.len(), 3);
825 assert_eq!(names[0], "hyperscale9s-packages-main-candidate");
826 assert_eq!(names[1], "hyperscale9s-packages-main-testing");
827 assert_eq!(names[2], "hyperscale9s-packages-main-release");
828 }
829
830 #[test]
831 fn test_tag_stage_release() {
832 let tags = vec![
833 "hyperscale9s-packages-main-candidate".into(),
834 "hyperscale9s-packages-main-testing".into(),
835 "hyperscale9s-packages-main-release".into(),
836 ];
837 assert_eq!(tag_stage(&tags), Some(TagStage::Release));
838 }
839
840 #[test]
841 fn test_tag_stage_testing_only() {
842 let tags = vec!["hyperscale10s-packages-main-testing".into()];
843 assert_eq!(tag_stage(&tags), Some(TagStage::Testing));
844 }
845
846 #[test]
847 fn test_tag_stage_candidate_only() {
848 let tags = vec!["hyperscale9s-packages-main-candidate".into()];
849 assert_eq!(tag_stage(&tags), Some(TagStage::Candidate));
850 }
851
852 #[test]
853 fn test_tag_stage_no_hyperscale_tags() {
854 let tags = vec!["some-other-tag".into()];
855 assert_eq!(tag_stage(&tags), None);
856 }
857
858 #[test]
859 fn test_tag_stage_display() {
860 assert_eq!(TagStage::Release.to_string(), "release");
861 assert_eq!(TagStage::Testing.to_string(), "testing");
862 assert_eq!(TagStage::Candidate.to_string(), "candidate");
863 }
864
865 #[test]
866 fn test_client_new() {
867 let client = Client::new();
868 assert_eq!(client.hub_url, "https://cbs.centos.org/kojihub");
869 }
870
871 #[test]
872 fn test_client_with_hub_url_trims_slash() {
873 let client = Client::with_hub_url("https://example.com/kojihub/");
874 assert_eq!(client.hub_url, "https://example.com/kojihub");
875 }
876}