raps_cli/commands/report/
mod.rs1mod 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 RfiSummary {
34 #[arg(short, long)]
36 account: Option<String>,
37
38 #[arg(short, long)]
40 filter: Option<String>,
41
42 #[arg(long)]
44 status: Option<String>,
45
46 #[arg(long)]
48 since: Option<String>,
49 },
50
51 IssuesSummary {
53 #[arg(short, long)]
55 account: Option<String>,
56
57 #[arg(short, long)]
59 filter: Option<String>,
60
61 #[arg(long)]
63 status: Option<String>,
64
65 #[arg(long)]
67 since: Option<String>,
68 },
69
70 SubmittalsSummary {
72 #[arg(short, long)]
74 account: Option<String>,
75
76 #[arg(short, long)]
78 filter: Option<String>,
79
80 #[arg(long)]
82 status: Option<String>,
83 },
84
85 ChecklistsSummary {
87 #[arg(short, long)]
89 account: Option<String>,
90
91 #[arg(short, long)]
93 filter: Option<String>,
94
95 #[arg(long)]
97 status: Option<String>,
98 },
99
100 AssetsSummary {
102 #[arg(short, long)]
104 account: Option<String>,
105
106 #[arg(short, long)]
108 filter: Option<String>,
109 },
110}
111
112#[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
164pub(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
206pub(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
332pub(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
376impl 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}