azure_pim_cli/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(
3    clippy::indexing_slicing,
4    clippy::manual_assert,
5    clippy::panic,
6    clippy::expect_used,
7    clippy::unwrap_used
8)]
9#![allow(clippy::module_name_repetitions)]
10
11mod activate;
12mod az_cli;
13mod backend;
14mod expiring;
15pub mod graph;
16pub mod interactive;
17mod latest;
18pub mod models;
19
20pub use crate::latest::check_latest_version;
21use crate::{
22    activate::check_error_response,
23    backend::Backend,
24    expiring::ExpiringMap,
25    graph::{get_objects_by_ids, group_members, Object, PrincipalType},
26    models::{
27        assignments::{Assignment, Assignments},
28        definitions::{Definition, Definitions},
29        resources::ChildResource,
30        roles::{RoleAssignment, RolesExt},
31        scope::Scope,
32    },
33};
34use anyhow::{bail, ensure, Context, Result};
35use backend::Operation;
36use clap::ValueEnum;
37use reqwest::Method;
38use std::{
39    collections::BTreeSet,
40    fmt::{Display, Formatter, Result as FmtResult},
41    io::stdin,
42    thread::sleep,
43    time::{Duration, Instant},
44};
45use tokio::sync::Mutex;
46use tracing::{debug, error, info, warn};
47use uuid::Uuid;
48
49const WAIT_DELAY: Duration = Duration::from_secs(5);
50const RBAC_ADMIN_ROLES: &[&str] = &["Owner", "Role Based Access Control Administrator"];
51
52#[allow(clippy::large_enum_variant)]
53pub enum ActivationResult {
54    Success,
55    Failed(RoleAssignment),
56}
57
58#[allow(clippy::manual_assert, clippy::panic)]
59#[derive(Clone, ValueEnum, PartialEq, Eq, PartialOrd, Ord)]
60pub enum ListFilter {
61    AtScope,
62    AsTarget,
63}
64
65impl Display for ListFilter {
66    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
67        match self {
68            Self::AtScope => write!(f, "at-scope"),
69            Self::AsTarget => write!(f, "as-target"),
70        }
71    }
72}
73
74impl ListFilter {
75    fn as_str(&self) -> &'static str {
76        match self {
77            Self::AtScope => "atScope()",
78            Self::AsTarget => "asTarget()",
79        }
80    }
81}
82
83pub struct PimClient {
84    backend: Backend,
85    object_cache: Mutex<ExpiringMap<String, Option<Object>>>,
86    group_cache: Mutex<ExpiringMap<String, BTreeSet<Object>>>,
87    role_definitions_cache: Mutex<ExpiringMap<Scope, Vec<Definition>>>,
88}
89
90impl PimClient {
91    pub fn new() -> Result<Self> {
92        let backend = Backend::new();
93        let object_cache = Mutex::new(ExpiringMap::new(Duration::from_secs(60 * 10)));
94        let group_cache = Mutex::new(ExpiringMap::new(Duration::from_secs(60 * 10)));
95        let role_definitions_cache = Mutex::new(ExpiringMap::new(Duration::from_secs(60 * 10)));
96        Ok(Self {
97            backend,
98            object_cache,
99            group_cache,
100            role_definitions_cache,
101        })
102    }
103
104    pub async fn clear_cache(&self) {
105        self.object_cache.lock().await.clear();
106        self.role_definitions_cache.lock().await.clear();
107    }
108
109    pub async fn current_user(&self) -> Result<String> {
110        self.backend.principal_id().await
111    }
112
113    /// List the roles available to the current user
114    ///
115    /// # Errors
116    /// Will return `Err` if the request fails or the response is not valid JSON
117    pub async fn list_eligible_role_assignments(
118        &self,
119        scope: Option<Scope>,
120        filter: Option<ListFilter>,
121    ) -> Result<BTreeSet<RoleAssignment>> {
122        let with_principal = filter.as_ref() != Some(&ListFilter::AsTarget);
123        if let Some(scope) = &scope {
124            info!("listing eligible assignments for {scope}");
125        } else {
126            info!("listing eligible assignments");
127        }
128        let mut builder = self
129            .backend
130            .request(Method::GET, Operation::RoleEligibilityScheduleInstances);
131
132        if let Some(scope) = scope {
133            builder = builder.scope(scope);
134        }
135
136        if let Some(filter) = filter {
137            builder = builder.query("$filter", filter.as_str());
138        }
139
140        let response = builder
141            .send()
142            .await
143            .context("unable to list eligible assignments")?;
144        let mut results = RoleAssignment::parse(&response, with_principal)
145            .context("unable to parse eligible assignments")?;
146
147        if with_principal {
148            let ids = results
149                .iter()
150                .filter_map(|x| x.principal_id.as_deref())
151                .collect::<BTreeSet<_>>();
152
153            let objects = get_objects_by_ids(self, ids)
154                .await
155                .context("getting objects by id")?;
156            results = results
157                .into_iter()
158                .map(|mut x| {
159                    if let Some(principal_id) = x.principal_id.as_ref() {
160                        x.object = objects.get(principal_id).cloned();
161                    }
162                    x
163                })
164                .collect();
165        }
166
167        Ok(results)
168    }
169
170    /// List the roles active role assignments for the current user
171    ///
172    /// # Errors
173    /// Will return `Err` if the request fails or the response is not valid JSON
174    pub async fn list_active_role_assignments(
175        &self,
176        scope: Option<Scope>,
177        filter: Option<ListFilter>,
178    ) -> Result<BTreeSet<RoleAssignment>> {
179        let with_principal = filter.as_ref() != Some(&ListFilter::AsTarget);
180
181        if let Some(scope) = &scope {
182            info!("listing active role assignments in {scope}");
183        } else {
184            info!("listing active role assignments");
185        }
186
187        let mut builder = self
188            .backend
189            .request(Method::GET, Operation::RoleAssignmentScheduleInstances);
190
191        if let Some(scope) = scope {
192            builder = builder.scope(scope);
193        }
194
195        if let Some(filter) = filter {
196            builder = builder.query("$filter", filter.as_str());
197        }
198
199        let response = builder
200            .send()
201            .await
202            .context("unable to list active role assignments")?;
203        let mut results = RoleAssignment::parse(&response, with_principal)
204            .context("unable to parse active role assignments")?;
205
206        if with_principal {
207            let ids = results
208                .iter()
209                .filter_map(|x| x.principal_id.as_deref())
210                .collect::<BTreeSet<_>>();
211
212            let objects = get_objects_by_ids(self, ids)
213                .await
214                .context("getting objects by id")?;
215            results = results
216                .into_iter()
217                .map(|mut x| {
218                    if let Some(principal_id) = x.principal_id.as_ref() {
219                        x.object = objects.get(principal_id).cloned();
220                    }
221                    x
222                })
223                .collect();
224        }
225        Ok(results)
226    }
227
228    /// Request extending the specified role eligibility
229    ///
230    /// # Errors
231    /// Will return `Err` if the request fails or the response is not valid JSON
232    pub async fn extend_role_assignment(
233        &self,
234        assignment: &RoleAssignment,
235        justification: &str,
236        duration: Duration,
237    ) -> Result<()> {
238        let RoleAssignment {
239            scope,
240            role_definition_id,
241            role,
242            scope_name,
243            principal_id: _,
244            principal_type: _,
245            object: _,
246        } = assignment;
247        if let Some(scope_name) = scope_name {
248            info!("extending {role} in {scope_name} ({scope})");
249        } else {
250            info!("extending {role} in {scope}");
251        }
252        let request_id = Uuid::now_v7();
253        let body = serde_json::json!({
254            "properties": {
255                "principalId": self.backend.principal_id().await?,
256                "roleDefinitionId": role_definition_id,
257                "requestType": "SelfExtend",
258                "justification": justification,
259                "scheduleInfo": {
260                    "expiration": {
261                        "duration": format_duration(duration)?,
262                        "type": "AfterDuration",
263                    }
264                }
265            }
266        });
267
268        self.backend
269            .request(Method::PUT, Operation::RoleAssignmentScheduleRequests)
270            .extra(format!("/{request_id}"))
271            .scope(scope.clone())
272            .json(body)
273            .validate(check_error_response)
274            .send()
275            .await?;
276        Ok(())
277    }
278
279    /// Activates the specified role
280    ///
281    /// # Errors
282    /// Will return `Err` if the request fails or the response is not valid JSON
283    pub async fn activate_role_assignment(
284        &self,
285        assignment: &RoleAssignment,
286        justification: &str,
287        duration: Duration,
288    ) -> Result<()> {
289        let RoleAssignment {
290            scope,
291            role_definition_id,
292            role,
293            scope_name,
294            principal_id: _,
295            principal_type: _,
296            object: _,
297        } = assignment;
298        if let Some(scope_name) = scope_name {
299            info!("activating {role} in {scope_name} ({scope})");
300        } else {
301            info!("activating {role} in {scope}");
302        }
303        let request_id = Uuid::now_v7();
304        let body = serde_json::json!({
305            "properties": {
306                "principalId": self.backend.principal_id().await?,
307                "roleDefinitionId": role_definition_id,
308                "requestType": "SelfActivate",
309                "justification": justification,
310                "scheduleInfo": {
311                    "expiration": {
312                        "duration": format_duration(duration)?,
313                        "type": "AfterDuration",
314                    }
315                }
316            }
317        });
318
319        self.backend
320            .request(Method::PUT, Operation::RoleAssignmentScheduleRequests)
321            .extra(format!("/{request_id}"))
322            .scope(scope.clone())
323            .json(body)
324            .validate(check_error_response)
325            .send()
326            .await?;
327
328        Ok(())
329    }
330
331    pub async fn activate_role_assignment_set(
332        &self,
333        assignments: &BTreeSet<RoleAssignment>,
334        justification: &str,
335        duration: Duration,
336    ) -> Result<()> {
337        ensure!(!assignments.is_empty(), "no roles specified");
338
339        let results = assignments.iter().map(|x| async {
340            let result = self
341                .activate_role_assignment(x, justification, duration)
342                .await;
343            match result {
344                Ok(()) => ActivationResult::Success,
345                Err(error) => {
346                    error!(
347                        "scope: {} definition: {} error: {error:?}",
348                        x.scope, x.role_definition_id
349                    );
350                    ActivationResult::Failed(x.clone())
351                }
352            }
353        });
354
355        let results = futures::future::join_all(results).await;
356
357        let mut failed = BTreeSet::new();
358
359        for result in results {
360            match result {
361                ActivationResult::Failed(entry) => {
362                    failed.insert(entry);
363                }
364                ActivationResult::Success => {}
365            }
366        }
367
368        if !failed.is_empty() {
369            bail!(
370                "failed to activate the following roles:\n{}",
371                failed.friendly()
372            );
373        }
374
375        Ok(())
376    }
377
378    /// Deactivate the specified role
379    ///
380    /// # Errors
381    /// Will return `Err` if the request fails or the response is not valid JSON
382    pub async fn deactivate_role_assignment(&self, assignment: &RoleAssignment) -> Result<()> {
383        let RoleAssignment {
384            scope,
385            role_definition_id,
386            role,
387            scope_name,
388            principal_id: _,
389            principal_type: _,
390            object: _,
391        } = assignment;
392        if let Some(scope_name) = scope_name {
393            info!("deactivating {role} in {scope_name} ({scope})");
394        } else {
395            info!("deactivating {role} in {scope}");
396        }
397        let request_id = Uuid::now_v7();
398        let body = serde_json::json!({
399            "properties": {
400                "principalId": self.backend.principal_id().await?,
401                "roleDefinitionId": role_definition_id,
402                "requestType": "SelfDeactivate",
403                "justification": "Deactivation request",
404            }
405        });
406
407        self.backend
408            .request(Method::PUT, Operation::RoleAssignmentScheduleRequests)
409            .extra(format!("/{request_id}"))
410            .scope(scope.clone())
411            .json(body)
412            .validate(check_error_response)
413            .send()
414            .await?;
415        Ok(())
416    }
417
418    pub async fn deactivate_role_assignment_set(
419        &self,
420        assignments: &BTreeSet<RoleAssignment>,
421    ) -> Result<()> {
422        ensure!(!assignments.is_empty(), "no roles specified");
423
424        let results = assignments.iter().map(|entry| async {
425            match self.deactivate_role_assignment(entry).await {
426                Ok(()) => ActivationResult::Success,
427                Err(error) => {
428                    error!(
429                        "scope: {} definition: {} error: {error:?}",
430                        entry.scope, entry.role_definition_id
431                    );
432                    ActivationResult::Failed(entry.clone())
433                }
434            }
435        });
436        let results = futures::future::join_all(results).await;
437
438        let mut failed = BTreeSet::new();
439
440        for result in results {
441            match result {
442                ActivationResult::Failed(entry) => {
443                    failed.insert(entry);
444                }
445                ActivationResult::Success => {}
446            }
447        }
448
449        if !failed.is_empty() {
450            bail!(
451                "failed to deactivate the following roles:\n{}",
452                failed.friendly()
453            );
454        }
455
456        Ok(())
457    }
458
459    pub async fn wait_for_role_activation(
460        &self,
461        assignments: &BTreeSet<RoleAssignment>,
462        wait_timeout: Duration,
463    ) -> Result<()> {
464        if assignments.is_empty() {
465            return Ok(());
466        }
467
468        let start = Instant::now();
469        let mut last = None::<Instant>;
470
471        let mut waiting = assignments.clone();
472        while !waiting.is_empty() {
473            if start.elapsed() > wait_timeout {
474                break;
475            }
476
477            // only check active assignments every `wait_timeout` seconds.
478            //
479            // While the list active assignments endpoint takes ~10-30 seconds
480            // today, it could go faster in the future and this should avoid
481            // spamming said API
482            let current = Instant::now();
483            if let Some(last) = last {
484                let to_wait = last.duration_since(current).saturating_sub(WAIT_DELAY);
485                if !to_wait.is_zero() {
486                    debug!("sleeping {to_wait:?} before checking active assignments");
487                    sleep(to_wait);
488                }
489            }
490            last = Some(current);
491
492            let active = self
493                .list_active_role_assignments(None, Some(ListFilter::AsTarget))
494                .await?;
495            debug!("active assignments: {active:#?}");
496            waiting.retain(|entry| !active.contains(entry));
497            debug!("still waiting: {waiting:#?}");
498        }
499
500        if !waiting.is_empty() {
501            bail!(
502                "timed out waiting for the following roles to activate:\n{}",
503                waiting.friendly()
504            );
505        }
506
507        Ok(())
508    }
509
510    /// List role assignments
511    ///
512    /// # Errors
513    /// Will return `Err` if the request fails or the response is not valid JSON
514    pub async fn role_assignments(&self, scope: &Scope) -> Result<Vec<Assignment>> {
515        info!("listing assignments for {scope}");
516        let value = self
517            .backend
518            .request(Method::GET, Operation::RoleAssignments)
519            .scope(scope.clone())
520            .send()
521            .await
522            .with_context(|| format!("unable to list role assignments at {scope}"))?;
523        let assignments: Assignments = serde_json::from_value(value)
524            .with_context(|| format!("unable to parse role assignment response at {scope}"))?;
525        let mut assignments = assignments.value;
526        let ids = assignments
527            .iter()
528            .map(|x| x.properties.principal_id.as_str())
529            .collect();
530
531        let objects = get_objects_by_ids(self, ids)
532            .await
533            .context("getting objects by id")?;
534        for x in &mut assignments {
535            x.object = objects.get(&x.properties.principal_id).cloned();
536        }
537        Ok(assignments)
538    }
539
540    /// List eligible child resources for the specified scope
541    ///
542    /// # Errors
543    /// Will return `Err` if the request fails or the response is not valid JSON
544    pub async fn eligible_child_resources(
545        &self,
546        scope: &Scope,
547        nested: bool,
548    ) -> Result<BTreeSet<ChildResource>> {
549        let mut todo = [scope.clone()].into_iter().collect::<BTreeSet<_>>();
550        let mut seen = BTreeSet::new();
551        let mut result = BTreeSet::new();
552
553        while !todo.is_empty() {
554            seen.extend(todo.clone());
555            // let iteration: Vec<Result<Result<BTreeSet<ChildResource>>>> = todo
556            let iteration = todo.iter().map(|scope| async {
557                let scope = scope.clone();
558                info!("listing eligible child resources for {scope}");
559                self.backend
560                    .request(Method::GET, Operation::EligibleChildResources)
561                    .scope(scope.clone())
562                    .send()
563                    .await
564                    .with_context(|| format!("unable to list eligible child resources for {scope}"))
565                    .map(|x| {
566                        ChildResource::parse(&x).with_context(|| {
567                            format!("unable to parse eligible child resources for {scope}")
568                        })
569                    })
570            });
571            let iteration = futures::future::join_all(iteration).await;
572
573            todo = BTreeSet::new();
574            for entry in iteration {
575                for child in entry?? {
576                    if nested && !seen.contains(&child.id) {
577                        todo.insert(child.id.clone());
578                    }
579                    result.insert(child);
580                }
581            }
582        }
583
584        Ok(result)
585    }
586
587    /// List role definitions available at the target scope
588    ///
589    /// Note, this will cache the results for 10 minutes.
590    ///
591    /// # Errors
592    /// Will return `Err` if the request fails or the response is not valid JSON
593    pub async fn role_definitions(&self, scope: &Scope) -> Result<Vec<Definition>> {
594        let mut cache = self.role_definitions_cache.lock().await;
595
596        if let Some(cached) = cache.get(scope) {
597            return Ok(cached.clone());
598        }
599
600        info!("listing role definitions for {scope}");
601        let definitions = self
602            .backend
603            .request(Method::GET, Operation::RoleDefinitions)
604            .scope(scope.clone())
605            .send()
606            .await
607            .with_context(|| format!("unable to list role definitions at {scope}"))?;
608        let definitions: Definitions = serde_json::from_value(definitions)
609            .with_context(|| format!("unable to parse role definitions at {scope}"))?;
610        cache.insert(scope.clone(), definitions.value.clone());
611
612        Ok(definitions.value)
613    }
614
615    /// Delete a role assignment
616    ///
617    /// # Errors
618    /// Will return `Err` if the request fails or the response is not valid JSON
619    pub async fn delete_role_assignment(&self, scope: &Scope, assignment_name: &str) -> Result<()> {
620        info!("deleting assignment {assignment_name} from {scope}");
621        self.backend
622            .request(Method::DELETE, Operation::RoleAssignments)
623            .extra(format!("/{assignment_name}"))
624            .scope(scope.clone())
625            .send()
626            .await
627            .with_context(|| format!("unable to delete assignment {assignment_name} at {scope}"))?;
628        Ok(())
629    }
630
631    /// Delete eligibile role assignment
632    ///
633    /// This removes role assignments that are available via PIM.
634    ///
635    /// # Errors
636    /// Will return `Err` if the request fails or the response is not valid JSON
637    pub async fn delete_eligible_role_assignment(&self, assignment: &RoleAssignment) -> Result<()> {
638        let RoleAssignment {
639            scope,
640            role_definition_id,
641            role,
642            scope_name,
643            principal_id,
644            principal_type: _,
645            object: _,
646        } = assignment;
647
648        let principal_id = principal_id.as_deref().context("missing principal id")?;
649        info!("deleting {role} in {scope_name:?} ({scope})");
650        let request_id = Uuid::now_v7();
651        let body = serde_json::json!({
652            "properties": {
653                "principalId": principal_id,
654                "roleDefinitionId": role_definition_id,
655                "requestType": "AdminRemove",
656                "ScheduleInfo": {
657                    "Expiration": {
658                        "Type": "NoExpiration",
659                    }
660                }
661            }
662        });
663
664        self.backend
665            .request(Method::PUT, Operation::RoleEligibilityScheduleRequests)
666            .extra(format!("/{request_id}"))
667            .scope(scope.clone())
668            .json(body)
669            .validate(check_error_response)
670            .send()
671            .await
672            .with_context(|| {
673                format!("unable to delete role definition {role_definition_id} for {principal_id}")
674            })?;
675        Ok(())
676    }
677
678    pub async fn delete_orphaned_role_assignments(
679        &self,
680        scope: &Scope,
681        answer_yes: bool,
682        nested: bool,
683    ) -> Result<()> {
684        let scopes = if nested {
685            self.eligible_child_resources(scope, nested)
686                .await?
687                .into_iter()
688                .map(|x| x.id)
689                .collect::<BTreeSet<_>>()
690        } else {
691            [scope.clone()].into_iter().collect()
692        };
693
694        for scope in scopes {
695            let definitions = self.role_definitions(&scope).await?;
696
697            let mut objects = self
698                .role_assignments(&scope)
699                .await
700                .with_context(|| format!("unable to list role assignments at {scope}"))?;
701            debug!("{} total entries", objects.len());
702            objects.retain(|x| x.object.is_none());
703            debug!("{} orphaned entries", objects.len());
704            for entry in objects {
705                let definition = definitions
706                    .iter()
707                    .find(|x| x.id == entry.properties.role_definition_id);
708                let value = format!(
709                    "role:\"{}\" principal:{} (type: {}) scope:{}",
710                    definition.map_or(entry.name.as_str(), |x| x.properties.role_name.as_str()),
711                    entry.properties.principal_id,
712                    entry.properties.principal_type,
713                    entry.properties.scope
714                );
715                if !answer_yes && !confirm(&format!("delete {value}")) {
716                    info!("skipping {value}");
717                    continue;
718                }
719
720                self.delete_role_assignment(&entry.properties.scope, &entry.name)
721                    .await
722                    .context("unable to delete assignment")?;
723            }
724        }
725        Ok(())
726    }
727
728    pub async fn delete_orphaned_eligible_role_assignments(
729        &self,
730        scope: &Scope,
731        answer_yes: bool,
732        nested: bool,
733    ) -> Result<()> {
734        let scopes = if nested {
735            self.eligible_child_resources(scope, nested)
736                .await?
737                .into_iter()
738                .map(|x| x.id)
739                .collect::<BTreeSet<_>>()
740        } else {
741            [scope.clone()].into_iter().collect()
742        };
743        for scope in scopes {
744            let definitions = self.role_definitions(&scope).await?;
745            for entry in self
746                .list_eligible_role_assignments(Some(scope), None)
747                .await?
748            {
749                if entry.object.is_some() {
750                    continue;
751                }
752
753                let definition = definitions
754                    .iter()
755                    .find(|x| x.id == entry.role_definition_id);
756
757                let value = format!(
758                    "role:\"{}\" principal:{} (type: {}) scope:{}",
759                    definition.map_or(entry.role_definition_id.as_str(), |x| x
760                        .properties
761                        .role_name
762                        .as_str()),
763                    entry.principal_id.clone().unwrap_or_default(),
764                    entry.principal_type.clone().unwrap_or_default(),
765                    entry
766                        .scope_name
767                        .clone()
768                        .unwrap_or_else(|| entry.scope.to_string())
769                );
770                if !answer_yes && !confirm(&format!("delete {value}")) {
771                    info!("skipping {value}");
772                    continue;
773                }
774                info!("deleting {value}");
775
776                self.delete_eligible_role_assignment(&entry).await?;
777            }
778        }
779
780        Ok(())
781    }
782
783    pub async fn activate_role_admin(
784        &self,
785        scope: &Scope,
786        justification: &str,
787        duration: Duration,
788    ) -> Result<()> {
789        let active = self
790            .list_active_role_assignments(None, Some(ListFilter::AsTarget))
791            .await?;
792
793        for entry in active {
794            if entry.scope.contains(scope) && RBAC_ADMIN_ROLES.contains(&entry.role.0.as_str()) {
795                info!("role already active: {entry:?}");
796                return Ok(());
797            }
798        }
799
800        let eligible = self
801            .list_eligible_role_assignments(None, Some(ListFilter::AsTarget))
802            .await?;
803        for entry in eligible {
804            if entry.scope.contains(scope) && RBAC_ADMIN_ROLES.contains(&entry.role.0.as_str()) {
805                return self
806                    .activate_role_assignment(&entry, justification, duration)
807                    .await;
808            }
809        }
810
811        bail!("unable to find role to administrate RBAC for {scope}");
812    }
813
814    pub async fn group_members(&self, id: &str, nested: bool) -> Result<BTreeSet<Object>> {
815        if !nested {
816            return group_members(self, id).await;
817        }
818
819        let mut results = BTreeSet::new();
820        let mut todo = [id.to_string()].into_iter().collect::<BTreeSet<_>>();
821        let mut done = BTreeSet::new();
822
823        while let Some(id) = todo.pop_first() {
824            if done.contains(&id) {
825                continue;
826            }
827            done.insert(id.clone());
828
829            let group_results = group_members(self, &id).await?;
830            todo.extend(
831                group_results
832                    .iter()
833                    .filter(|x| matches!(x.object_type, PrincipalType::Group))
834                    .map(|x| x.id.clone()),
835            );
836            results.extend(group_results);
837        }
838        Ok(results)
839    }
840}
841
842fn format_duration(duration: Duration) -> Result<String> {
843    let mut as_secs = duration.as_secs();
844
845    let hours = as_secs / 3600;
846    as_secs %= 3600;
847
848    let minutes = as_secs / 60;
849    let seconds = as_secs % 60;
850
851    let mut data = vec![];
852    if hours > 0 {
853        data.push(format!("{hours}H"));
854    }
855    if minutes > 0 {
856        data.push(format!("{minutes}M"));
857    }
858    if seconds > 0 {
859        data.push(format!("{seconds}S"));
860    }
861
862    ensure!(!data.is_empty(), "duration must be at least 1 second");
863    Ok(format!("PT{}", data.join("")))
864}
865
866pub fn confirm(msg: &str) -> bool {
867    info!("Are you sure you want to {msg}? (y/n): ");
868    loop {
869        let mut input = String::new();
870        let Ok(_) = stdin().read_line(&mut input) else {
871            continue;
872        };
873        match input.trim().to_lowercase().as_str() {
874            "y" => break true,
875            "n" => break false,
876            _ => {
877                warn!("Please enter 'y' or 'n': ");
878            }
879        }
880    }
881}
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    #[test]
888    fn test_format_duration() -> Result<()> {
889        assert!(format_duration(Duration::from_secs(0)).is_err());
890
891        for (secs, parsed) in [
892            (1, "PT1S"),
893            (60, "PT1M"),
894            (61, "PT1M1S"),
895            (3600, "PT1H"),
896            (86400, "PT24H"),
897            (86401, "PT24H1S"),
898            (86460, "PT24H1M"),
899            (86520, "PT24H2M"),
900            (90061, "PT25H1M1S"),
901        ] {
902            assert_eq!(format_duration(Duration::from_secs(secs))?, parsed);
903        }
904
905        Ok(())
906    }
907}