Skip to main content

hs_relmon/
cbs.rs

1// SPDX-License-Identifier: MPL-2.0
2
3use quick_xml::events::Event;
4use quick_xml::Reader;
5use serde::Serialize;
6
7/// The highest promotion stage a build has reached in Hyperscale tags.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TagStage {
10    /// Only in -candidate tags.
11    Candidate,
12    /// In -testing tags (but not -release).
13    Testing,
14    /// In -release tags.
15    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/// A completed build from the CBS Koji instance.
29#[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    /// Whether this is a Hyperscale build (release contains ".hs.").
40    pub fn is_hyperscale(&self) -> bool {
41        self.release.contains(".hs.")
42    }
43
44    /// Return the EL version this build targets (e.g. 9, 10), if detectable.
45    ///
46    /// Matches `.elN` or `.elN_Z` at the end of the release string.
47    pub fn el_version(&self) -> Option<u32> {
48        let s = self.release.rsplit_once(".el")?;
49        // s.1 is e.g. "9", "10", "9_3"
50        let num = s.1.split('_').next()?;
51        num.parse().ok()
52    }
53}
54
55/// Client for the CentOS Build System (CBS) Koji XML-RPC API.
56pub 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    /// Look up the numeric package ID for a package name.
78    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        // Response is a single <int> or <nil/>
90        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    /// List completed builds for a package, newest first.
99    pub fn list_builds(&self, package_id: i64) -> Result<Vec<Build>, Box<dyn std::error::Error>> {
100        // listBuilds(packageID, userID, taskID, prefix, state, ..., queryOpts)
101        // state=1 means COMPLETE
102        // 14 positional params total, last one is queryOpts
103        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    /// List tag names for a given build ID.
132    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    /// Find the latest Hyperscale release and testing builds for an EL version.
147    ///
148    /// Walks builds newest-first, checking tags for each. Returns the latest
149    /// build in release, and the latest build in testing if it's newer than
150    /// the release build.
151    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/// Summary of the latest Hyperscale builds for an EL version.
172#[derive(Debug, Clone, Serialize)]
173pub struct HyperscaleSummary {
174    /// Latest build tagged for release.
175    pub release: Option<Build>,
176    /// Latest build tagged for testing, only if newer than the release build.
177    pub testing: Option<Build>,
178}
179
180/// Filter builds to Hyperscale builds for a given EL version.
181///
182/// Preserves ordering (assumed newest-first by descending build_id).
183pub 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
190/// Walk Hyperscale builds for an EL version and resolve the summary.
191///
192/// Uses the provided `lookup_tags` function to get tags for each build,
193/// allowing the caller to supply either a real API call or a test stub.
194pub 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
228/// Determine the highest promotion stage from a list of tag names.
229///
230/// Looks for Hyperscale tags ending in `-release`, `-testing`, or `-candidate`.
231pub 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// --- XML-RPC response parsing ---
258
259#[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
268/// Parse a methodResponse containing a single return value.
269fn 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
277/// Parse the top-level values from a methodResponse.
278fn parse_response_values(xml: &str) -> Result<Vec<XmlRpcValue>, Box<dyn std::error::Error>> {
279    // Find the <params> section and parse each <param><value>...</value></param>
280    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                // Handle <fault/> or similar
299                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
311/// Parse a single <value>...</value>. Assumes we just entered <value>.
312fn 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                        // Unknown type, read as string
352                        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                // Bare text inside <value> without type tag = string
368                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                // </value> with no content
376                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    // Read until we hit </value>
390    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
474/// Parse a listTags response into tag name strings.
475fn 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
497/// Parse a listBuilds response into Build objects.
498fn 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        // Newest build (id=4) is testing-only, older build (id=3) is release
691        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        // Build 5 is candidate-only, build 4 is testing, build 3 is release
720        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        // The latest build is already in release; no testing line needed
747        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            // build 2 would also be release but we stop at 3
754        ]);
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}