debbugs/
lib.rs

1//! Rust client interface for the Debian Bug Tracking System (Debbugs)
2//!
3//! This crate provides both async and blocking interfaces to interact with Debbugs instances,
4//! allowing you to search for bugs, retrieve bug reports, and access detailed bug information.
5//!
6//! # Features
7//!
8//! - **blocking** (default): Enables the synchronous `debbugs::blocking::Debbugs` client
9//! - **tokio** (default): Enables the asynchronous `debbugs::Debbugs` client
10//! - **mailparse** (default): Enables parsing of email headers in bug logs
11//!
12//! # Examples
13//!
14//! ## Async Interface
15//! ```no_run
16//! use debbugs::Debbugs;
17//!
18//! #[tokio::main]
19//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
20//!     let client = Debbugs::default();
21//!     let bugs = client.newest_bugs(10).await?;
22//!     println!("Latest bugs: {:?}", bugs);
23//!     Ok(())
24//! }
25//! ```
26//!
27//! ## Blocking Interface
28//! ```no_run
29//! use debbugs::blocking::Debbugs;
30//!
31//! fn main() -> Result<(), Box<dyn std::error::Error>> {
32//!     let client = Debbugs::default();
33//!     let bugs = client.newest_bugs(10)?;
34//!     println!("Latest bugs: {:?}", bugs);
35//!     Ok(())
36//! }
37//! ```
38//!
39//! See the [Debian Debbugs SOAP Interface](https://wiki.debian.org/DebbugsSoapInterface)
40//! documentation for more information about the underlying API.
41mod soap;
42pub use soap::{BugLog, BugReport};
43
44const DEFAULT_URL: &str = "https://bugs.debian.org/cgi-bin/soap.cgi";
45
46/// Errors that can occur when interacting with the Debbugs API
47#[derive(Debug)]
48pub enum Error {
49    /// SOAP protocol related errors
50    ///
51    /// This occurs when there are issues with the SOAP request/response format
52    /// or when the server returns an unexpected SOAP structure.
53    SoapError(String),
54
55    /// XML parsing errors
56    ///
57    /// This occurs when the XML response from the server cannot be parsed,
58    /// typically due to malformed XML or unexpected structure.
59    XmlError(String),
60
61    /// HTTP request errors
62    ///
63    /// This occurs when there are network issues, connection failures,
64    /// timeouts, or other HTTP-level problems communicating with the server.
65    ReqwestError(reqwest::Error),
66
67    /// SOAP fault responses
68    ///
69    /// This occurs when the server returns a SOAP fault, indicating
70    /// an error in processing the request (e.g., invalid parameters,
71    /// server-side errors, or authentication issues).
72    Fault(soap::Fault),
73}
74
75impl From<reqwest::Error> for Error {
76    fn from(err: reqwest::Error) -> Self {
77        Error::ReqwestError(err)
78    }
79}
80
81/// The status of a bug report
82#[derive(Debug, PartialEq, Eq, Clone, Copy)]
83pub enum BugStatus {
84    /// Bug has been resolved/fixed
85    Done,
86    /// Bug has been forwarded to upstream maintainers
87    Forwarded,
88    /// Bug is still open and unresolved
89    Open,
90}
91
92impl std::str::FromStr for BugStatus {
93    type Err = Error;
94
95    fn from_str(s: &str) -> Result<Self, Self::Err> {
96        match s {
97            "done" => Ok(BugStatus::Done),
98            "forwarded" => Ok(BugStatus::Forwarded),
99            "open" => Ok(BugStatus::Open),
100            _ => Err(Error::SoapError(format!("Unknown status: {}", s))),
101        }
102    }
103}
104
105impl std::fmt::Display for BugStatus {
106    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
107        match self {
108            BugStatus::Done => f.write_str("done"),
109            BugStatus::Forwarded => f.write_str("forwarded"),
110            BugStatus::Open => f.write_str("open"),
111        }
112    }
113}
114
115/// The pending status of a bug report
116#[derive(Debug, PartialEq, Eq, Clone, Copy)]
117pub enum Pending {
118    /// Bug is pending action
119    Pending,
120    /// Bug is pending but has been fixed
121    PendingFixed,
122    /// Bug has been fixed
123    Fixed,
124    /// Bug is done/closed
125    Done,
126    /// Bug has been forwarded
127    Forwarded,
128}
129
130impl std::str::FromStr for Pending {
131    type Err = Error;
132
133    fn from_str(s: &str) -> Result<Self, Self::Err> {
134        match s {
135            "pending" => Ok(Pending::Pending),
136            "pending-fixed" => Ok(Pending::PendingFixed),
137            "fixed" => Ok(Pending::Fixed),
138            "done" => Ok(Pending::Done),
139            "forwarded" => Ok(Pending::Forwarded),
140            _ => Err(Error::SoapError(format!("Unknown pending: {}", s))),
141        }
142    }
143}
144
145impl std::fmt::Display for Pending {
146    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
147        match self {
148            Pending::Pending => f.write_str("pending"),
149            Pending::PendingFixed => f.write_str("pending-fixed"),
150            Pending::Done => f.write_str("done"),
151            Pending::Forwarded => f.write_str("forwarded"),
152            Pending::Fixed => f.write_str("fixed"),
153        }
154    }
155}
156
157/// Whether to search archived bugs
158#[derive(Debug, PartialEq, Eq, Default, Clone, Copy)]
159pub enum Archived {
160    /// Only archived bugs
161    Archived,
162    /// Only non-archived bugs (default)
163    #[default]
164    NotArchived,
165    /// Both archived and non-archived bugs
166    Both,
167}
168
169impl std::str::FromStr for Archived {
170    type Err = Error;
171
172    fn from_str(s: &str) -> Result<Self, Self::Err> {
173        match s {
174            "1" | "archived" => Ok(Archived::Archived),
175            "0" | "unarchived" => Ok(Archived::NotArchived),
176            "both" => Ok(Archived::Both),
177            _ => Err(Error::SoapError(format!("Unknown archived: {}", s))),
178        }
179    }
180}
181
182impl std::fmt::Display for Archived {
183    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
184        match self {
185            Archived::Archived => f.write_str("archived"),
186            Archived::NotArchived => f.write_str("unarchived"),
187            Archived::Both => f.write_str("both"),
188        }
189    }
190}
191
192impl std::fmt::Display for Error {
193    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
194        match &self {
195            Error::SoapError(err) => write!(f, "SOAP Error: {}", err),
196            Error::XmlError(err) => write!(f, "XML Error: {}", err),
197            Error::ReqwestError(err) => write!(f, "Reqwest Error: {}", err),
198            Error::Fault(err) => write!(f, "Fault: {}", err),
199        }
200    }
201}
202
203impl std::error::Error for Error {}
204
205pub type SoapResponse = Result<(reqwest::StatusCode, String), Error>;
206
207/// A bug ID used to uniquely identify bugs in the tracking system
208pub type BugId = i32;
209
210pub use soap::SearchQuery;
211
212#[cfg(feature = "blocking")]
213pub mod blocking;
214
215mod r#async;
216
217pub use r#async::Debbugs;
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::str::FromStr;
223
224    #[test]
225    fn test_bug_status_from_str() {
226        assert_eq!(BugStatus::from_str("done").unwrap(), BugStatus::Done);
227        assert_eq!(
228            BugStatus::from_str("forwarded").unwrap(),
229            BugStatus::Forwarded
230        );
231        assert_eq!(BugStatus::from_str("open").unwrap(), BugStatus::Open);
232    }
233
234    #[test]
235    fn test_bug_status_from_str_invalid() {
236        assert!(BugStatus::from_str("invalid").is_err());
237        assert!(BugStatus::from_str("").is_err());
238        assert!(BugStatus::from_str("DONE").is_err());
239        assert!(BugStatus::from_str("Done").is_err());
240    }
241
242    #[test]
243    fn test_bug_status_display() {
244        assert_eq!(BugStatus::Done.to_string(), "done");
245        assert_eq!(BugStatus::Forwarded.to_string(), "forwarded");
246        assert_eq!(BugStatus::Open.to_string(), "open");
247    }
248
249    #[test]
250    fn test_bug_status_roundtrip() {
251        let statuses = vec![BugStatus::Done, BugStatus::Forwarded, BugStatus::Open];
252        for status in statuses {
253            let s = status.to_string();
254            let parsed = BugStatus::from_str(&s).unwrap();
255            assert_eq!(status, parsed);
256        }
257    }
258
259    #[test]
260    fn test_pending_from_str() {
261        assert_eq!(Pending::from_str("pending").unwrap(), Pending::Pending);
262        assert_eq!(
263            Pending::from_str("pending-fixed").unwrap(),
264            Pending::PendingFixed
265        );
266        assert_eq!(Pending::from_str("fixed").unwrap(), Pending::Fixed);
267        assert_eq!(Pending::from_str("done").unwrap(), Pending::Done);
268        assert_eq!(Pending::from_str("forwarded").unwrap(), Pending::Forwarded);
269    }
270
271    #[test]
272    fn test_pending_from_str_invalid() {
273        assert!(Pending::from_str("invalid").is_err());
274        assert!(Pending::from_str("").is_err());
275        assert!(Pending::from_str("PENDING").is_err());
276        assert!(Pending::from_str("pending_fixed").is_err());
277    }
278
279    #[test]
280    fn test_pending_display() {
281        assert_eq!(Pending::Pending.to_string(), "pending");
282        assert_eq!(Pending::PendingFixed.to_string(), "pending-fixed");
283        assert_eq!(Pending::Fixed.to_string(), "fixed");
284        assert_eq!(Pending::Done.to_string(), "done");
285        assert_eq!(Pending::Forwarded.to_string(), "forwarded");
286    }
287
288    #[test]
289    fn test_pending_roundtrip() {
290        let pendings = vec![
291            Pending::Pending,
292            Pending::PendingFixed,
293            Pending::Fixed,
294            Pending::Done,
295            Pending::Forwarded,
296        ];
297        for pending in pendings {
298            let s = pending.to_string();
299            let parsed = Pending::from_str(&s).unwrap();
300            assert_eq!(pending, parsed);
301        }
302    }
303
304    #[test]
305    fn test_archived_from_str() {
306        assert_eq!(Archived::from_str("1").unwrap(), Archived::Archived);
307        assert_eq!(Archived::from_str("archived").unwrap(), Archived::Archived);
308        assert_eq!(Archived::from_str("0").unwrap(), Archived::NotArchived);
309        assert_eq!(
310            Archived::from_str("unarchived").unwrap(),
311            Archived::NotArchived
312        );
313        assert_eq!(Archived::from_str("both").unwrap(), Archived::Both);
314    }
315
316    #[test]
317    fn test_archived_from_str_invalid() {
318        assert!(Archived::from_str("invalid").is_err());
319        assert!(Archived::from_str("").is_err());
320        assert!(Archived::from_str("2").is_err());
321        assert!(Archived::from_str("ARCHIVED").is_err());
322        assert!(Archived::from_str("not-archived").is_err());
323    }
324
325    #[test]
326    fn test_archived_display() {
327        assert_eq!(Archived::Archived.to_string(), "archived");
328        assert_eq!(Archived::NotArchived.to_string(), "unarchived");
329        assert_eq!(Archived::Both.to_string(), "both");
330    }
331
332    #[test]
333    fn test_archived_roundtrip() {
334        let archiveds = vec![Archived::Archived, Archived::NotArchived, Archived::Both];
335        for archived in archiveds {
336            let s = archived.to_string();
337            // Note: from_str accepts both numeric and string forms, but display uses string form
338            let parsed = Archived::from_str(&s).unwrap();
339            assert_eq!(archived, parsed);
340        }
341    }
342
343    #[test]
344    fn test_archived_default() {
345        assert_eq!(Archived::default(), Archived::NotArchived);
346    }
347
348    #[test]
349    fn test_error_display() {
350        let soap_err = Error::SoapError("test error".to_string());
351        assert_eq!(soap_err.to_string(), "SOAP Error: test error");
352
353        let xml_err = Error::XmlError("xml parse failed".to_string());
354        assert_eq!(xml_err.to_string(), "XML Error: xml parse failed");
355
356        // We can't easily create a real reqwest::Error in tests, so we'll skip testing
357        // the exact error message format for ReqwestError
358
359        let fault = soap::Fault {
360            faultcode: "Client".to_string(),
361            faultstring: "Invalid request".to_string(),
362            faultactor: None,
363            detail: Some("Missing required parameter".to_string()),
364        };
365        let fault_err = Error::Fault(fault);
366        assert_eq!(fault_err.to_string(), "Fault: { faultcode: Client, faultstring: Invalid request, faultactor: None, detail: Some(\"Missing required parameter\") }");
367    }
368
369    #[test]
370    fn test_error_conversions() {
371        // We can't easily create a real reqwest::Error in tests without an actual HTTP failure
372        // The From<reqwest::Error> trait is trivial and doesn't need extensive testing
373    }
374}