Skip to main content

raps_cli/commands/report/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Portfolio report commands
5//!
6//! Aggregated reports across multiple projects: RFI summaries, issue summaries,
7//! submittals, checklists, and assets.
8
9mod extended_reports;
10mod issues_report;
11mod rfi_report;
12#[cfg(test)]
13mod tests;
14
15use anyhow::Result;
16use clap::Subcommand;
17use colored::Colorize;
18use indicatif::{ProgressBar, ProgressStyle};
19use serde::Serialize;
20
21use raps_acc::admin::AccountAdminClient;
22use raps_admin::ProjectFilter;
23use raps_kernel::auth::AuthClient;
24use raps_kernel::config::Config;
25use raps_kernel::http::HttpClientConfig;
26
27use crate::output::OutputFormat;
28
29#[derive(Debug, Subcommand)]
30#[allow(clippy::enum_variant_names)]
31pub enum ReportCommands {
32    /// RFI summary across portfolio projects
33    RfiSummary {
34        /// Account ID (defaults to APS_ACCOUNT_ID env var)
35        #[arg(short, long)]
36        account: Option<String>,
37
38        /// Project filter expression (e.g., "status:active,name:*Hospital*")
39        #[arg(short, long)]
40        filter: Option<String>,
41
42        /// Filter RFIs by status (open, answered, closed, void)
43        #[arg(long)]
44        status: Option<String>,
45
46        /// Only include RFIs created after this date (YYYY-MM-DD)
47        #[arg(long)]
48        since: Option<String>,
49    },
50
51    /// Issue summary across portfolio projects
52    IssuesSummary {
53        /// Account ID (defaults to APS_ACCOUNT_ID env var)
54        #[arg(short, long)]
55        account: Option<String>,
56
57        /// Project filter expression
58        #[arg(short, long)]
59        filter: Option<String>,
60
61        /// Filter issues by status (open, closed, etc.)
62        #[arg(long)]
63        status: Option<String>,
64
65        /// Only include issues created after this date (YYYY-MM-DD)
66        #[arg(long)]
67        since: Option<String>,
68    },
69
70    /// Submittal summary across portfolio projects
71    SubmittalsSummary {
72        /// Account ID (defaults to APS_ACCOUNT_ID env var)
73        #[arg(short, long)]
74        account: Option<String>,
75
76        /// Project filter expression
77        #[arg(short, long)]
78        filter: Option<String>,
79
80        /// Filter submittals by status
81        #[arg(long)]
82        status: Option<String>,
83    },
84
85    /// Checklist summary across portfolio projects
86    ChecklistsSummary {
87        /// Account ID (defaults to APS_ACCOUNT_ID env var)
88        #[arg(short, long)]
89        account: Option<String>,
90
91        /// Project filter expression
92        #[arg(short, long)]
93        filter: Option<String>,
94
95        /// Filter checklists by status
96        #[arg(long)]
97        status: Option<String>,
98    },
99
100    /// Asset summary across portfolio projects
101    AssetsSummary {
102        /// Account ID (defaults to APS_ACCOUNT_ID env var)
103        #[arg(short, long)]
104        account: Option<String>,
105
106        /// Project filter expression
107        #[arg(short, long)]
108        filter: Option<String>,
109    },
110}
111
112// ---------------------------------------------------------------------------
113// Summary types
114// ---------------------------------------------------------------------------
115
116#[derive(Serialize)]
117pub(super) struct RfiProjectSummary {
118    pub(super) project_id: String,
119    pub(super) project_name: String,
120    pub(super) total: usize,
121    pub(super) open: usize,
122    pub(super) answered: usize,
123    pub(super) closed: usize,
124    pub(super) void: usize,
125}
126
127#[derive(Serialize)]
128pub(super) struct IssueProjectSummary {
129    pub(super) project_id: String,
130    pub(super) project_name: String,
131    pub(super) total: usize,
132    pub(super) open: usize,
133    pub(super) closed: usize,
134    pub(super) other: usize,
135}
136
137#[derive(Serialize)]
138pub(super) struct SubmittalProjectSummary {
139    pub(super) project_id: String,
140    pub(super) project_name: String,
141    pub(super) total: usize,
142}
143
144#[derive(Serialize)]
145pub(super) struct ChecklistProjectSummary {
146    pub(super) project_id: String,
147    pub(super) project_name: String,
148    pub(super) total: usize,
149}
150
151#[derive(Serialize)]
152pub(super) struct AssetProjectSummary {
153    pub(super) project_id: String,
154    pub(super) project_name: String,
155    pub(super) total: usize,
156}
157
158#[derive(Serialize)]
159pub(super) struct ReportSummaryOutput<T: Serialize> {
160    pub(super) total_projects: usize,
161    pub(super) projects: Vec<T>,
162}
163
164// ---------------------------------------------------------------------------
165// Shared traits
166// ---------------------------------------------------------------------------
167
168pub(super) trait HasProjectName {
169    fn project_name(&self) -> &str;
170}
171
172impl HasProjectName for SubmittalProjectSummary {
173    fn project_name(&self) -> &str {
174        &self.project_name
175    }
176}
177
178impl HasProjectName for ChecklistProjectSummary {
179    fn project_name(&self) -> &str {
180        &self.project_name
181    }
182}
183
184impl HasProjectName for AssetProjectSummary {
185    fn project_name(&self) -> &str {
186        &self.project_name
187    }
188}
189
190pub(super) trait HasStatus {
191    fn status(&self) -> &str;
192}
193
194impl HasStatus for raps_acc::Rfi {
195    fn status(&self) -> &str {
196        &self.status
197    }
198}
199
200impl HasStatus for raps_acc::Issue {
201    fn status(&self) -> &str {
202        &self.status
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Shared helpers
208// ---------------------------------------------------------------------------
209
210pub(super) fn get_account_id(account: Option<String>) -> Result<String> {
211    match account.or_else(|| std::env::var("APS_ACCOUNT_ID").ok()) {
212        Some(id) if !id.is_empty() => Ok(id),
213        _ => {
214            anyhow::bail!(
215                "Account ID is required. Use --account or set APS_ACCOUNT_ID environment variable."
216            );
217        }
218    }
219}
220
221pub(super) fn parse_project_filter(filter: &Option<String>) -> Result<ProjectFilter> {
222    match filter {
223        Some(f) => Ok(ProjectFilter::from_expression(f)?),
224        None => Ok(ProjectFilter::new()),
225    }
226}
227
228pub(super) fn create_progress_bar(
229    output_format: OutputFormat,
230    count: u64,
231    message: &str,
232) -> Option<ProgressBar> {
233    if !output_format.supports_colors() {
234        return None;
235    }
236    let template = format!(
237        "{{spinner:.green}} [{{bar:40.cyan/blue}}] {{pos}}/{{len}} {}",
238        message
239    );
240    let pb = ProgressBar::new(count);
241    pb.set_style(
242        ProgressStyle::default_bar()
243            .template(&template)
244            .expect("valid progress template")
245            .progress_chars("=>-"),
246    );
247    Some(pb)
248}
249
250pub(super) fn print_report_header(
251    output_format: OutputFormat,
252    label: &str,
253    account_id: &str,
254    filter: &Option<String>,
255) {
256    if !output_format.supports_colors() {
257        return;
258    }
259    println!(
260        "\n{} {} for account {}",
261        "→".cyan(),
262        label,
263        account_id.cyan()
264    );
265    if let Some(f) = filter {
266        println!("  Filter: {}", f);
267    }
268    println!();
269}
270
271pub(super) fn truncate_name(name: &str) -> String {
272    if name.len() > 28 {
273        format!("{}...", &name[..25])
274    } else {
275        name.to_string()
276    }
277}
278
279pub(super) fn print_simple_table<T, F>(
280    title: &str,
281    output: &ReportSummaryOutput<T>,
282    output_format: OutputFormat,
283    get_total: F,
284) -> Result<()>
285where
286    T: Serialize + HasProjectName,
287    F: Fn(&T) -> usize,
288{
289    match output_format {
290        OutputFormat::Table => {
291            let grand_total: usize = output.projects.iter().map(&get_total).sum();
292
293            println!("{}", format!("{} Portfolio Summary:", title).bold());
294            println!("{}", "─".repeat(45));
295            println!("{:<30} {:>8}", "Project".bold(), "Total".bold());
296            println!("{}", "─".repeat(45));
297
298            for s in &output.projects {
299                println!(
300                    "{:<30} {:>8}",
301                    truncate_name(s.project_name()),
302                    get_total(s).to_string().cyan(),
303                );
304            }
305
306            println!("{}", "─".repeat(45));
307            println!(
308                "{:<30} {:>8}",
309                "TOTAL".bold(),
310                grand_total.to_string().bold(),
311            );
312            println!(
313                "\n{} {} projects scanned",
314                "→".cyan(),
315                output.total_projects
316            );
317        }
318        _ => {
319            output_format.write(&output)?;
320        }
321    }
322    Ok(())
323}
324
325pub(super) fn count_status(items: &[impl HasStatus], status: &str) -> usize {
326    items
327        .iter()
328        .filter(|item| item.status().eq_ignore_ascii_case(status))
329        .count()
330}
331
332// ---------------------------------------------------------------------------
333// Shared project-listing boilerplate
334// ---------------------------------------------------------------------------
335
336pub(super) struct ReportContext {
337    pub(super) http_config: HttpClientConfig,
338    pub(super) filtered_projects: Vec<raps_acc::types::AccountProject>,
339}
340
341pub(super) async fn prepare_report(
342    config: &Config,
343    auth_client: &AuthClient,
344    account: Option<String>,
345    filter: &Option<String>,
346    label: &str,
347    output_format: OutputFormat,
348) -> Result<Option<ReportContext>> {
349    let account_id = get_account_id(account)?;
350    let project_filter = parse_project_filter(filter)?;
351    print_report_header(output_format, label, &account_id, filter);
352
353    let http_config = HttpClientConfig::default();
354    let admin_client = AccountAdminClient::new_with_http_config(
355        config.clone(),
356        auth_client.clone(),
357        http_config.clone(),
358    );
359
360    let all_projects = admin_client.list_all_projects(&account_id).await?;
361    let filtered_projects = project_filter.apply(all_projects);
362
363    if filtered_projects.is_empty() {
364        if output_format.supports_colors() {
365            println!("{}", "No projects found matching the filter.".yellow());
366        }
367        return Ok(None);
368    }
369
370    Ok(Some(ReportContext {
371        http_config,
372        filtered_projects,
373    }))
374}
375
376// ---------------------------------------------------------------------------
377// Command dispatch
378// ---------------------------------------------------------------------------
379
380impl ReportCommands {
381    pub async fn execute(
382        self,
383        config: &Config,
384        auth_client: &AuthClient,
385        output_format: OutputFormat,
386    ) -> Result<()> {
387        match self {
388            ReportCommands::RfiSummary {
389                account,
390                filter,
391                status,
392                since,
393            } => {
394                rfi_report::rfi_summary(
395                    config,
396                    auth_client,
397                    account,
398                    filter,
399                    status,
400                    since,
401                    output_format,
402                )
403                .await
404            }
405            ReportCommands::IssuesSummary {
406                account,
407                filter,
408                status,
409                since,
410            } => {
411                issues_report::issues_summary(
412                    config,
413                    auth_client,
414                    account,
415                    filter,
416                    status,
417                    since,
418                    output_format,
419                )
420                .await
421            }
422            ReportCommands::SubmittalsSummary {
423                account,
424                filter,
425                status,
426            } => {
427                extended_reports::submittals_summary(
428                    config,
429                    auth_client,
430                    account,
431                    filter,
432                    status,
433                    output_format,
434                )
435                .await
436            }
437            ReportCommands::ChecklistsSummary {
438                account,
439                filter,
440                status,
441            } => {
442                extended_reports::checklists_summary(
443                    config,
444                    auth_client,
445                    account,
446                    filter,
447                    status,
448                    output_format,
449                )
450                .await
451            }
452            ReportCommands::AssetsSummary { account, filter } => {
453                extended_reports::assets_summary(
454                    config,
455                    auth_client,
456                    account,
457                    filter,
458                    output_format,
459                )
460                .await
461            }
462        }
463    }
464}