Skip to main content

bodhi/data/
types.rs

1use std::collections::HashMap;
2use std::fmt::{Display, Formatter};
3
4use fedora::url::Url;
5use serde::{Deserialize, Serialize};
6
7use super::dates::*;
8use super::enums::*;
9use super::release::FedoraRelease;
10
11/// data type that represents a BugZilla bug that is associated with an update
12#[derive(Debug, Deserialize, Serialize)]
13#[non_exhaustive]
14pub struct Bug {
15    /// bug ID in the BugZilla system: <https://bugzilla.redhat.com/show_bug.cgi?id={bug_id}>
16    pub bug_id: u32,
17    /// flag to indicate whether this bug has been tagged as a parent / tracking bug
18    pub parent: bool,
19    /// flag to indicate whether this bug has been tagged as a `Security` issue
20    pub security: bool,
21    /// title of the bug in BugZilla
22    pub title: Option<String>,
23
24    /// catch-all for fields that are not explicitly deserialized
25    #[serde(flatten)]
26    pub extra: HashMap<String, serde_json::Value>,
27}
28
29impl Display for Bug {
30    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
31        let title = match &self.title {
32            Some(title) => title.as_str(),
33            None => "(None)",
34        };
35
36        writeln!(f, "Bug {}:", self.bug_id)?;
37        writeln!(f, "Title: {title}")?;
38        writeln!(f, "URL:   {}", self.url())?;
39
40        Ok(())
41    }
42}
43
44impl Bug {
45    /// construct the Red Hat BugZilla (RHBZ) URL from this [`Bug`] from its ID
46    pub fn url(&self) -> Url {
47        Url::parse(&format!("https://bugzilla.redhat.com/show_bug.cgi?id={}", self.bug_id))
48            .expect("Failed to parse the hard-coded URL, this should not happen.")
49    }
50}
51
52
53/// data type that represents a feedback item for a bug that is associated with an update
54#[derive(Debug, Deserialize, Serialize)]
55#[non_exhaustive]
56pub struct BugFeedback {
57    /// bug this feedback is associated with
58    pub bug: Option<Bug>,
59    /// ID of the bug that this feedback is associated with
60    pub bug_id: u32,
61    /// ID of the comment that this feedback is associated with
62    pub comment_id: Option<u32>,
63    /// feedback karma (positive, neutral, negative)
64    pub karma: Karma,
65
66    /// catch-all for fields that are not explicitly deserialized
67    #[serde(flatten)]
68    pub extra: HashMap<String, serde_json::Value>,
69}
70
71impl Display for BugFeedback {
72    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
73        write!(f, "{bug_id}: {karma}", bug_id = self.bug_id, karma = self.karma)
74    }
75}
76
77
78/// data type that represents a koji build that is associated with an update
79#[derive(Debug, Deserialize, Serialize)]
80#[non_exhaustive]
81pub struct Build {
82    /// Epoch value of this build (`None` if unspecified)
83    pub epoch: Option<u32>,
84    /// NVR (Name-Version-Release) string of this build
85    pub nvr: String,
86    /// release ID of the release that this build is associated with
87    pub release_id: Option<u32>,
88    /// flag to indicate whether this build has been signed yet
89    pub signed: bool,
90    /// build type (RPM, container, flatpak, module)
91    #[serde(rename = "type")]
92    pub build_type: ContentType,
93
94    /// catch-all for fields that are not explicitly deserialized
95    #[serde(flatten)]
96    pub extra: HashMap<String, serde_json::Value>,
97}
98
99impl Display for Build {
100    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
101        writeln!(f, "Build {}", &self.nvr)?;
102        writeln!(f, "Type:  {}", self.build_type)?;
103        writeln!(
104            f,
105            "Epoch: {}",
106            match self.epoch {
107                Some(epoch) => epoch.to_string(),
108                None => "(None)".to_string(),
109            }
110        )?;
111        Ok(())
112    }
113}
114
115
116/// data type that represents a comment on an update (including bug and test case feedback)
117#[derive(Debug, Deserialize, Serialize)]
118#[non_exhaustive]
119pub struct Comment {
120    // author of the comment (username), only provided for backwards compatibility
121    #[deprecated(since = "2.0.0")]
122    author: Option<String>,
123    /// list of bug feedback items
124    pub bug_feedback: Vec<BugFeedback>,
125    /// numerical ID of this comment
126    pub id: u32,
127    /// karma feedback associated with this comment
128    pub karma: Karma,
129    // feedback associated with "critpath" checks
130    #[deprecated(since = "2.0.0")]
131    karma_critpath: Karma,
132    /// list of test case feedback items
133    pub testcase_feedback: Vec<TestCaseFeedback>,
134    /// text of the comment
135    pub text: String,
136    /// date & time this comment was published
137    #[serde(with = "bodhi_date_format")]
138    pub timestamp: BodhiDate,
139    /// update this comment is associated with
140    pub update: Option<Update>,
141    /// ID of the update this comment is associated with
142    pub update_id: u32,
143    // alias of the update this comment is associated with
144    // (only provided for backwards compatibility)
145    #[deprecated(since = "2.0.0")]
146    update_alias: Option<String>,
147    /// user who submitted this comment
148    pub user: User,
149    /// user ID of the user who submitted this comment
150    pub user_id: u32,
151
152    /// catch-all for fields that are not explicitly deserialized
153    #[serde(flatten)]
154    pub extra: HashMap<String, serde_json::Value>,
155}
156
157impl Display for Comment {
158    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
159        writeln!(f, "Comment by {}", &self.user.name)?;
160        writeln!(f, "{}", &self.text)?;
161        writeln!(f, "Submitted: {}", &self.timestamp)?;
162        writeln!(f, "Karma:     {}", self.karma)?;
163
164        Ok(())
165    }
166}
167
168
169/// data type that represents a (running) compose for an "updates" or "updates-testing" repository
170#[derive(Debug, Deserialize, Serialize)]
171#[non_exhaustive]
172pub struct Compose {
173    /// string of JSON-formatted checkpoint data for the compose
174    pub checkpoints: String,
175    /// type of the contained contents (RPMs, containers, flatpaks, modules)
176    pub content_type: Option<ContentType>,
177    /// date & time when this compose was triggered
178    #[serde(with = "bodhi_date_format")]
179    pub date_created: BodhiDate,
180    /// error message in case of failures (empty string if no errors have occurred yet)
181    pub error_message: Option<String>,
182    /// release this compose is associated with
183    pub release: Option<Release>,
184    /// numerical ID of the release this compose is associated with
185    pub release_id: u32,
186    /// target of the compose:
187    ///
188    /// - stable: "updates" repository
189    /// - testing: "updates-testing" repository
190    pub request: ComposeRequest,
191    /// flag to indicate whether this compose contains security updates
192    pub security: bool,
193    /// current state of the compose
194    pub state: ComposeState,
195    /// date & time when the compose status was last updated
196    #[serde(with = "bodhi_date_format")]
197    pub state_date: BodhiDate,
198    /// list of summaries for the contained updates (contains update aliases and titles)
199    pub update_summary: Vec<UpdateSummary>,
200
201    /// catch-all for fields that are not explicitly deserialized
202    #[serde(flatten)]
203    pub extra: HashMap<String, serde_json::Value>,
204}
205
206impl Display for Compose {
207    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
208        writeln!(
209            f,
210            "Compose for {release} / {request} ({content_type})",
211            release = match &self.release {
212                Some(release) => release.name.to_string(),
213                None => "(None)".to_string(),
214            },
215            request = &self.request,
216            content_type = match &self.content_type {
217                Some(content_type) => content_type.to_string(),
218                None => "(None)".to_string(),
219            }
220        )?;
221
222        writeln!(f, "Created: {}", &self.date_created)?;
223        writeln!(f, "Status:  {}", self.state)?;
224
225        Ok(())
226    }
227}
228
229
230/// data type that represents a group of users in the fedora accounts system (FAS)
231#[derive(Debug, Deserialize, Serialize)]
232#[non_exhaustive]
233pub struct Group {
234    /// name of the group
235    pub name: String,
236
237    /// catch-all for fields that are not explicitly deserialized
238    #[serde(flatten)]
239    pub extra: HashMap<String, serde_json::Value>,
240}
241
242impl Display for Group {
243    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
244        write!(f, "{}", &self.name)
245    }
246}
247
248
249/// data type that represents a buildroot override and its associated koji build
250#[derive(Debug, Deserialize, Serialize)]
251#[non_exhaustive]
252pub struct Override {
253    /// koji build that is associated with this buildroot override
254    pub build: Build,
255    /// build ID of the koji build that is associated with this buildroot override
256    pub build_id: u32,
257    /// date & time when this buildroot override will expire
258    #[serde(with = "bodhi_date_format")]
259    pub expiration_date: BodhiDate,
260    /// date & time when this buildroot override has expired
261    #[serde(with = "option_bodhi_date_format")]
262    pub expired_date: Option<BodhiDate>,
263    /// notes associated with this buildroot override
264    pub notes: String,
265    /// NVR (Name-Version-Release) string of the build that is associated with this buildroot
266    /// override
267    pub nvr: String,
268    /// date & time when this buildroot override was submitted
269    #[serde(with = "bodhi_date_format")]
270    pub submission_date: BodhiDate,
271    /// user who submitted this buildroot override
272    pub submitter: User,
273    /// user ID of the user who submitted this buildroot override
274    pub submitter_id: u32,
275
276    /// catch-all for fields that are not explicitly deserialized
277    #[serde(flatten)]
278    pub extra: HashMap<String, serde_json::Value>,
279}
280
281impl Display for Override {
282    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
283        let expired_date = match &self.expired_date {
284            Some(date) => date.to_string(),
285            None => "no".to_string(),
286        };
287
288        writeln!(f, "Buildroot override for {}", &self.nvr)?;
289        writeln!(f, "{}", &self.notes)?;
290        writeln!(f, "Submitter:       {}", &self.submitter.name)?;
291        writeln!(f, "Submission date: {}", &self.submission_date)?;
292        writeln!(f, "Expiration date: {}", &self.expiration_date)?;
293        writeln!(f, "Expired:         {}", &expired_date)?;
294
295        Ok(())
296    }
297}
298
299
300/// data type that represents a package (or other distributable content) known to bodhi
301#[derive(Debug, Deserialize, Serialize)]
302#[non_exhaustive]
303pub struct Package {
304    /// unique identifier of the (source) package (or container, flatpak, or module, as appropriate)
305    pub name: String,
306    /// type of the associated contents (RPM package, container image, flatpak image, DNF module)
307    #[serde(rename = "type")]
308    pub package_type: ContentType,
309    /// test case requirements associated with this package
310    pub requirements: Option<String>,
311
312    /// catch-all for fields that are not explicitly deserialized
313    #[serde(flatten)]
314    pub extra: HashMap<String, serde_json::Value>,
315}
316
317impl Display for Package {
318    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
319        write!(
320            f,
321            "{name} ({package_type})",
322            name = &self.name,
323            package_type = self.package_type
324        )
325    }
326}
327
328
329/// data type that represents a release (or release variant, based on content type) known to bodhi
330#[derive(Debug, Deserialize, Serialize)]
331#[non_exhaustive]
332pub struct Release {
333    /// name of the dist-git branch that is associated with this release
334    pub branch: String,
335    /// name of the koji tag for update candidates
336    pub candidate_tag: String,
337    /// flag to indicate whether this release is composed by bodhi itself
338    pub composed_by_bodhi: bool,
339    /// optional list of running composes for this release
340    #[deprecated(
341        since = "2.0.1",
342        note = "The `composes` field was dropped from serialized `Release` objects with bodhi server versions 6.0 and later. It is only kept for backwards compatibility, but will in the future always have a value of `None` when deserializing JSON server responses."
343    )]
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub composes: Option<Vec<Compose>>,
346    /// flag to indicate whether updates should automatically be created for this release
347    pub create_automatic_updates: Option<bool>,
348    /// value of the RPM `%{?dist}` tag on this release
349    pub dist_tag: String,
350    /// update alias prefix for this release (`FEDORA{-EPEL,}{-CONTAINER,-FLATPAK,-MODULAR,}`)
351    pub id_prefix: String,
352    /// long name of this release
353    pub long_name: String,
354    /// name of the email template for errata
355    pub mail_template: String,
356    /// short identifier of this release
357    pub name: FedoraRelease,
358    /// package manager that is used on this release (parsed into [`PackageManager`] variants)
359    pub package_manager: PackageManager,
360    /// name of the tag for builds in buildroot overrides
361    pub override_tag: String,
362    /// name of the tag for builds that are pending to be signed
363    pub pending_signing_tag: String,
364    /// name of the tag for builds that are pending to be pushed to stable
365    pub pending_stable_tag: String,
366    /// name of the tag for builds that are pending to be pushed to testing
367    pub pending_testing_tag: String,
368    /// name of the tag for builds that have been pushed to stable
369    pub stable_tag: String,
370    /// current state of this release (parsed into [`ReleaseState`] variants)
371    pub state: ReleaseState,
372    /// name of the repository that is used for testing updates
373    pub testing_repository: Option<String>,
374    /// name of the tag for builds that have been pushed to testing
375    pub testing_tag: String,
376    /// Fedora version string corresponding to this release
377    pub version: String,
378    /// end-of-life date of this release in the format `YYYY-MM-DD`
379    pub eol: Option<String>,
380
381    /// catch-all for fields that are not explicitly deserialized
382    #[serde(flatten)]
383    pub extra: HashMap<String, serde_json::Value>,
384}
385
386impl Display for Release {
387    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
388        writeln!(f, "Release {}:", &self.name)?;
389        writeln!(f, "State:               {}", &self.state)?;
390        writeln!(f, "Branch:              {}", &self.branch)?;
391        writeln!(f, "Candidate tag:       {}", &self.candidate_tag)?;
392        writeln!(f, "Override tag:        {}", &self.override_tag)?;
393        writeln!(f, "Pending signing tag: {}", &self.pending_signing_tag)?;
394        writeln!(f, "Pending stable tag:  {}", &self.pending_stable_tag)?;
395        writeln!(f, "Pending testing tag: {}", &self.pending_testing_tag)?;
396        writeln!(f, "Stable tag:          {}", &self.stable_tag)?;
397        writeln!(f, "Testing tag:         {}", &self.testing_tag)?;
398
399        Ok(())
400    }
401}
402
403
404/// data type that represents a test case that is associated with a package
405#[derive(Debug, Deserialize, Serialize)]
406#[non_exhaustive]
407pub struct TestCase {
408    /// name of this test case
409    pub name: String,
410    /// package that this test case is associated with
411    pub package: Option<Package>,
412
413    /// catch-all for fields that are not explicitly deserialized
414    #[serde(flatten)]
415    pub extra: HashMap<String, serde_json::Value>,
416}
417
418impl Display for TestCase {
419    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
420        let package = match &self.package {
421            Some(package) => &package.name,
422            None => "(None)",
423        };
424
425        write!(
426            f,
427            "Test Case '{name}' for package {package}",
428            name = &self.name,
429            package = &package
430        )
431    }
432}
433
434impl TestCase {
435    /// construct the Fedora Project Wiki URL for this [`TestCase`] from its name
436    pub fn url(&self) -> Url {
437        Url::parse(&format!(
438            "https://fedoraproject.org/wiki/{}",
439            self.name.replace(' ', "_")
440        ))
441        .expect("Failed to parse the hard-coded URL, this should not happen.")
442    }
443}
444
445
446/// data type that represents a feedback item for a test case that is associated with an update
447#[derive(Debug, Deserialize, Serialize)]
448#[non_exhaustive]
449pub struct TestCaseFeedback {
450    /// ID of the comment that this feedback is associated with
451    pub comment_id: Option<u32>,
452    /// feedback karma (positive, neutral, negative)
453    pub karma: Karma,
454    /// test case that this feedback is associated with
455    pub testcase: TestCase,
456    /// ID of the test case that this feedback is associated with
457    pub testcase_id: u32,
458
459    /// catch-all for fields that are not explicitly deserialized
460    #[serde(flatten)]
461    pub extra: HashMap<String, serde_json::Value>,
462}
463
464impl Display for TestCaseFeedback {
465    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
466        write!(f, "{name}: {karma}", name = &self.testcase.name, karma = self.karma)
467    }
468}
469
470
471/// data type that represents an update
472#[derive(Debug, Deserialize, Serialize)]
473#[non_exhaustive]
474pub struct Update {
475    /// user-visible, human-readable update alias (`FEDORA-2019-1A2BB23E`)
476    pub alias: String,
477    /// flag to indicate whether this update will be pushed to stable automatically (based on karma)
478    pub autokarma: bool,
479    /// flag to indicate whether this update will be pushed to stable automatically (based on time)
480    pub autotime: bool,
481    /// list of bugs that are associated with this update
482    pub bugs: Vec<Bug>,
483    /// list of builds that are associated with this update
484    pub builds: Vec<Build>,
485    /// flag to indicate whether bugs will be closed when this update is pushed to stable
486    pub close_bugs: bool,
487    /// list of comments that are associated with this update
488    pub comments: Option<Vec<Comment>>,
489    /// currently running compose that this update is included in
490    pub compose: Option<Compose>,
491    /// type of the contained contents (RPMs, containers, flatpaks, modules)
492    pub content_type: Option<ContentType>,
493    /// flag to indicate whether this update contains packages from the "critical path"
494    pub critpath: bool,
495    /// optional string containing a space-separated list of critical path groups
496    /// (present since bodhi-server v7.0.0)
497    pub critpath_groups: Option<String>,
498    /// last date & time when this update has been approved
499    #[deprecated(
500        since = "2.0.0",
501        note = "`date_approved` is an unused field: <https://github.com/fedora-infra/bodhi/issues/4171>"
502    )]
503    #[serde(with = "option_bodhi_date_format")]
504    pub date_approved: Option<BodhiDate>,
505    /// date & time when this update was modified
506    #[serde(with = "option_bodhi_date_format")]
507    pub date_modified: Option<BodhiDate>,
508    /// date & time when this update was pushed
509    #[serde(with = "option_bodhi_date_format")]
510    pub date_pushed: Option<BodhiDate>,
511    /// date & time when this update was pushed to stable
512    #[serde(with = "option_bodhi_date_format")]
513    pub date_stable: Option<BodhiDate>,
514    /// date & time when this update was submitted
515    #[serde(with = "option_bodhi_date_format")]
516    pub date_submitted: Option<BodhiDate>,
517    /// date & time when this update was pushed to testing
518    #[serde(with = "option_bodhi_date_format")]
519    pub date_testing: Option<BodhiDate>,
520    /// displayed "pretty" name of this update
521    pub display_name: String,
522    /// koji side tag that this update was created from
523    pub from_tag: Option<String>,
524    /// current total of feedback karma values
525    pub karma: Option<i32>,
526    /// flag indicating whether this update can be edited
527    pub locked: bool,
528    /// flag indicating whether the update satisfies test requirements
529    pub meets_testing_requirements: bool,
530    /// notes / text that is associated with this update
531    pub notes: String,
532    /// flag indicating whether this update has already been pushed
533    pub pushed: bool,
534    /// release that this update was submitted for
535    pub release: Release,
536    /// currently requested new update status
537    pub request: Option<UpdateRequest>,
538    /// flag to specify whether feedback for bugs is required when adding karma to the total
539    pub require_bugs: bool,
540    /// flag to specify whether feedback for test cases is required when adding karma to the total
541    pub require_testcases: bool,
542    /// comma- or space-separated list of required gating test results
543    pub requirements: Option<String>,
544    /// severity of this update
545    pub severity: UpdateSeverity,
546    /// minimum number of days this update has to stay in the [`UpdateStatus::Testing`] state
547    pub stable_days: Option<u32>,
548    /// stable karma threshold for this update
549    pub stable_karma: Option<i32>,
550    /// current state of this update
551    pub status: UpdateStatus,
552    /// suggested action to take after installing this update
553    pub suggest: UpdateSuggestion,
554    /// list test cases that is  associated with this update
555    pub test_cases: Option<Vec<TestCase>>,
556    /// current greenwave gating status
557    ///
558    /// If this value is `None`, greenwave was not yet enabled when this update was created.
559    pub test_gating_status: Option<TestGatingStatus>,
560    /// title of this update (automatically generated from build NVRs if `display_name` is `None`)
561    pub title: String,
562    /// unstable karma threshold for this update
563    pub unstable_karma: Option<i32>,
564    // updateid is only provided for backwards compatibility with bodhi 1
565    #[deprecated(since = "2.0.0")]
566    #[serde(rename = "updateid")]
567    update_id: Option<UpdateID>,
568    /// type of this update
569    #[serde(rename = "type")]
570    pub update_type: UpdateType,
571    /// public URL of this update
572    pub url: String,
573    /// user who first created this update
574    pub user: User,
575    /// SHA-1 hash of the sorted, space-separated NVRs of the included builds
576    pub version_hash: String,
577
578    /// catch-all for fields that are not explicitly deserialized
579    #[serde(flatten)]
580    pub extra: HashMap<String, serde_json::Value>,
581}
582
583impl Display for Update {
584    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
585        let builds = if !self.builds.is_empty() {
586            self.builds
587                .iter()
588                .map(|b| b.nvr.as_str())
589                .collect::<Vec<&str>>()
590                .join("\n")
591        } else {
592            String::from("(None)")
593        };
594
595        let bugs = if !self.bugs.is_empty() {
596            self.bugs
597                .iter()
598                .map(|b| b.bug_id.to_string())
599                .collect::<Vec<String>>()
600                .join(" ")
601        } else {
602            String::from("(None)")
603        };
604
605        let test_cases = match &self.test_cases {
606            Some(test_cases) => {
607                if !test_cases.is_empty() {
608                    test_cases
609                        .iter()
610                        .map(|t| t.name.as_str())
611                        .collect::<Vec<&str>>()
612                        .join(" ")
613                } else {
614                    "(None)".to_string()
615                }
616            },
617            None => "(None)".to_string(),
618        };
619
620        writeln!(f, "Update {}:", &self.alias)?;
621        writeln!(f, "{}", &self.notes)?;
622        writeln!(f)?;
623        writeln!(f, "State:         {}", self.status)?;
624        writeln!(f, "Submitter:     {}", &self.user.name)?;
625        writeln!(f)?;
626        writeln!(f, "Builds:")?;
627        writeln!(f, "{}", &builds)?;
628        writeln!(f)?;
629        writeln!(f, "Bugs:       {}", &bugs)?;
630        writeln!(f, "Test Cases: {}", &test_cases)?;
631
632        Ok(())
633    }
634}
635
636
637/// data type that represents an update summary
638#[derive(Debug, Deserialize, Serialize)]
639#[non_exhaustive]
640pub struct UpdateSummary {
641    /// update alias that uniquely identifies the update
642    pub alias: String,
643    /// user-defined or automatically generated update title
644    pub title: String,
645}
646
647impl Display for UpdateSummary {
648    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
649        write!(f, "{}: {}", self.alias, self.title)
650    }
651}
652
653
654/// data type that represents a user in the Fedora Accounts System (FAS) who is known to bodhi
655#[derive(Debug, Deserialize, Serialize)]
656#[non_exhaustive]
657pub struct User {
658    /// URL of the [libravatar](https://www.libravatar.org/) avatar for this user
659    pub avatar: Option<String>,
660    /// E-Mail address associated with this user (if public according to their account settings)
661    pub email: Option<String>,
662    /// list of groups this user is a member of
663    pub groups: Vec<Group>,
664    /// user ID that is associated with this user
665    pub id: u32,
666    /// unique FAS username of this user
667    pub name: String,
668    /// OpenID identity that is associated with the user
669    pub openid: Option<String>,
670
671    /// catch-all for fields that are not explicitly deserialized
672    #[serde(flatten)]
673    pub extra: HashMap<String, serde_json::Value>,
674}
675
676impl Display for User {
677    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
678        let email = match &self.email {
679            Some(email) => email.as_str(),
680            None => "(None)",
681        };
682
683        let groups: String = self
684            .groups
685            .iter()
686            .map(|g| g.name.as_str())
687            .collect::<Vec<&str>>()
688            .join(", ");
689
690        writeln!(f, "User {}:", &self.name)?;
691        writeln!(f, "E-Mail: {email}")?;
692        writeln!(f, "Groups: {}", &groups)?;
693
694        Ok(())
695    }
696}