1mod csv_ops;
13mod folder;
14mod operations;
15mod project;
16mod user;
17
18use std::path::PathBuf;
19
20use anyhow::Result;
21use clap::{Subcommand, ValueEnum};
22use indicatif::{ProgressBar, ProgressStyle};
23
24use raps_admin::{PermissionLevel, ProgressUpdate, ProjectFilter};
25use raps_kernel::auth::AuthClient;
26use raps_kernel::config::Config;
27
28use crate::output::OutputFormat;
29
30#[derive(Debug, Subcommand)]
32pub enum AdminCommands {
33 #[command(subcommand)]
35 User(UserCommands),
36
37 #[command(subcommand)]
39 Folder(FolderCommands),
40
41 #[command(subcommand)]
43 Project(AdminProjectCommands),
44
45 #[command(subcommand)]
47 Operation(OperationCommands),
48
49 #[command(name = "company-list")]
51 CompanyList {
52 #[arg(short, long)]
54 account: Option<String>,
55 },
56}
57
58#[derive(Debug, Subcommand)]
60pub enum UserCommands {
61 List {
63 #[arg(short, long)]
65 account: Option<String>,
66
67 #[arg(short, long)]
69 project: Option<String>,
70
71 #[arg(long)]
73 role: Option<String>,
74
75 #[arg(long)]
77 status: Option<String>,
78
79 #[arg(long)]
81 search: Option<String>,
82 },
83
84 Add {
86 email: String,
88
89 #[arg(short, long)]
91 account: Option<String>,
92
93 #[arg(short, long)]
95 role: Option<String>,
96
97 #[arg(short, long)]
99 filter: Option<String>,
100
101 #[arg(long, value_name = "FILE")]
103 project_ids: Option<PathBuf>,
104
105 #[arg(long, default_value = "10")]
107 concurrency: usize,
108
109 #[arg(long)]
111 dry_run: bool,
112
113 #[arg(short, long)]
115 yes: bool,
116 },
117
118 Remove {
120 email: String,
122
123 #[arg(short, long)]
125 account: Option<String>,
126
127 #[arg(short, long)]
129 filter: Option<String>,
130
131 #[arg(long, value_name = "FILE")]
133 project_ids: Option<PathBuf>,
134
135 #[arg(long, default_value = "10")]
137 concurrency: usize,
138
139 #[arg(long)]
141 dry_run: bool,
142
143 #[arg(short, long)]
145 yes: bool,
146 },
147
148 Update {
150 email: String,
152
153 #[arg(short, long)]
155 account: Option<String>,
156
157 #[arg(short, long)]
159 role: Option<String>,
160
161 #[arg(long)]
163 company: Option<String>,
164
165 #[arg(long)]
167 from_role: Option<String>,
168
169 #[arg(short, long)]
171 filter: Option<String>,
172
173 #[arg(long, value_name = "FILE")]
175 project_ids: Option<PathBuf>,
176
177 #[arg(long, value_name = "FILE")]
179 from_csv: Option<PathBuf>,
180
181 #[arg(long, default_value = "10")]
183 concurrency: usize,
184
185 #[arg(long)]
187 dry_run: bool,
188
189 #[arg(short, long)]
191 yes: bool,
192 },
193
194 #[command(name = "add-to-project")]
196 AddToProject {
197 #[arg(short, long)]
199 project: String,
200
201 #[arg(short, long)]
203 email: String,
204
205 #[arg(short, long)]
207 role_id: Option<String>,
208 },
209
210 #[command(name = "remove-from-project")]
212 RemoveFromProject {
213 #[arg(short, long)]
215 project: String,
216
217 #[arg(short, long)]
219 user_id: String,
220
221 #[arg(short, long)]
223 yes: bool,
224 },
225
226 #[command(name = "update-in-project")]
228 UpdateInProject {
229 #[arg(short, long)]
231 project: String,
232
233 #[arg(short, long)]
235 user_id: String,
236
237 #[arg(short, long)]
239 role_id: Option<String>,
240 },
241
242 #[command(name = "import")]
244 Import {
245 #[arg(short, long)]
247 project: String,
248
249 #[arg(long, value_name = "FILE")]
251 from_csv: PathBuf,
252 },
253}
254
255#[derive(Debug, Subcommand)]
257pub enum FolderCommands {
258 Rights {
260 email: String,
262
263 #[arg(short, long)]
265 account: Option<String>,
266
267 #[arg(short, long, value_enum)]
269 level: PermissionLevelArg,
270
271 #[arg(long, default_value = "project-files")]
273 folder: String,
274
275 #[arg(short, long)]
277 filter: Option<String>,
278
279 #[arg(long, value_name = "FILE")]
281 project_ids: Option<PathBuf>,
282
283 #[arg(long, default_value = "10")]
285 concurrency: usize,
286
287 #[arg(long)]
289 dry_run: bool,
290
291 #[arg(short, long)]
293 yes: bool,
294 },
295}
296
297#[derive(Debug, Clone, Copy, ValueEnum)]
299pub enum PermissionLevelArg {
300 ViewOnly,
302 ViewDownload,
304 UploadOnly,
306 ViewDownloadUpload,
308 ViewDownloadUploadEdit,
310 FolderControl,
312}
313
314impl From<PermissionLevelArg> for PermissionLevel {
315 fn from(arg: PermissionLevelArg) -> Self {
316 match arg {
317 PermissionLevelArg::ViewOnly => PermissionLevel::ViewOnly,
318 PermissionLevelArg::ViewDownload => PermissionLevel::ViewDownload,
319 PermissionLevelArg::UploadOnly => PermissionLevel::UploadOnly,
320 PermissionLevelArg::ViewDownloadUpload => PermissionLevel::ViewDownloadUpload,
321 PermissionLevelArg::ViewDownloadUploadEdit => PermissionLevel::ViewDownloadUploadEdit,
322 PermissionLevelArg::FolderControl => PermissionLevel::FolderControl,
323 }
324 }
325}
326
327#[derive(Debug, Subcommand)]
329pub enum AdminProjectCommands {
330 List {
332 #[arg(short, long)]
334 account: Option<String>,
335
336 #[arg(short, long)]
338 filter: Option<String>,
339
340 #[arg(long)]
342 status: Option<String>,
343
344 #[arg(long, default_value = "all")]
346 platform: String,
347
348 #[arg(long)]
350 limit: Option<usize>,
351 },
352
353 Create {
355 #[arg(short, long)]
357 account: Option<String>,
358
359 #[arg(short, long)]
361 name: String,
362
363 #[arg(short = 't', long)]
365 r#type: Option<String>,
366
367 #[arg(long)]
369 classification: Option<String>,
370
371 #[arg(long)]
373 start_date: Option<String>,
374
375 #[arg(long)]
377 end_date: Option<String>,
378
379 #[arg(long)]
381 timezone: Option<String>,
382 },
383
384 Update {
386 #[arg(short, long)]
388 account: Option<String>,
389
390 #[arg(short, long)]
392 project: String,
393
394 #[arg(short, long)]
396 name: Option<String>,
397
398 #[arg(long)]
400 status: Option<String>,
401
402 #[arg(long)]
404 start_date: Option<String>,
405
406 #[arg(long)]
408 end_date: Option<String>,
409 },
410
411 Archive {
413 #[arg(short, long)]
415 account: Option<String>,
416
417 #[arg(short, long)]
419 project: String,
420 },
421}
422
423#[derive(Debug, Subcommand)]
425pub enum OperationCommands {
426 Status {
428 operation_id: Option<uuid::Uuid>,
430 },
431
432 Resume {
434 operation_id: Option<uuid::Uuid>,
436
437 #[arg(long)]
439 concurrency: Option<usize>,
440 },
441
442 Cancel {
444 operation_id: Option<uuid::Uuid>,
446
447 #[arg(short, long)]
449 yes: bool,
450 },
451
452 List {
454 #[arg(long)]
456 status: Option<String>,
457
458 #[arg(long, default_value = "10")]
460 limit: usize,
461 },
462}
463
464pub(crate) fn get_account_id(account: Option<String>) -> Result<String> {
469 match account.or_else(|| std::env::var("APS_ACCOUNT_ID").ok()) {
470 Some(id) if !id.is_empty() => Ok(id),
471 _ => {
472 anyhow::bail!(
473 "Account ID is required. Use --account or set APS_ACCOUNT_ID environment variable."
474 );
475 }
476 }
477}
478
479pub(crate) fn parse_filter_with_ids(
480 filter: &Option<String>,
481 project_ids: &Option<PathBuf>,
482) -> Result<ProjectFilter> {
483 let mut project_filter = match filter {
484 Some(f) => ProjectFilter::from_expression(f)?,
485 None => ProjectFilter::new(),
486 };
487 if let Some(ids_file) = project_ids {
488 let content = std::fs::read_to_string(ids_file)?;
489 let ids: Vec<String> = content
490 .lines()
491 .map(|l| l.trim().to_string())
492 .filter(|l| !l.is_empty() && !l.starts_with('#'))
493 .collect();
494 project_filter.include_ids = Some(ids);
495 }
496 Ok(project_filter)
497}
498
499pub(crate) fn create_bulk_progress_bar(output_format: OutputFormat) -> Option<ProgressBar> {
500 if !output_format.supports_colors() {
501 return None;
502 }
503 let pb = ProgressBar::new(0);
504 pb.set_style(
505 ProgressStyle::default_bar()
506 .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}")
507 .expect("valid progress template")
508 .progress_chars("=>-"),
509 );
510 Some(pb)
511}
512
513pub(crate) fn make_progress_callback(pb: Option<ProgressBar>) -> impl Fn(ProgressUpdate) {
514 move |progress: ProgressUpdate| {
515 if let Some(ref pb) = pb {
516 pb.set_length(progress.total as u64);
517 pb.set_position((progress.completed + progress.failed + progress.skipped) as u64);
518 pb.set_message(format!(
519 "\u{2713}{} \u{25CB}{} \u{2717}{}",
520 progress.completed, progress.skipped, progress.failed
521 ));
522 }
523 }
524}
525
526impl AdminCommands {
527 pub async fn execute(
528 self,
529 config: &Config,
530 auth_client: &AuthClient,
531 output_format: OutputFormat,
532 ) -> Result<()> {
533 match self {
534 AdminCommands::User(cmd) => cmd.execute(config, auth_client, output_format).await,
535 AdminCommands::Folder(cmd) => cmd.execute(config, auth_client, output_format).await,
536 AdminCommands::Project(cmd) => cmd.execute(config, auth_client, output_format).await,
537 AdminCommands::Operation(cmd) => cmd.execute(output_format).await,
538 AdminCommands::CompanyList { account } => {
539 project::execute_company_list(config, auth_client, account, output_format).await
540 }
541 }
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::csv_ops::{CsvUpdateErrorOutput, CsvUpdateResultOutput};
548 use super::user::UserListOutput;
549
550 #[test]
551 fn test_csv_update_row_deserialization() {
552 let csv_data = "email,role,company\njohn@example.com,Project Admin,Acme Corp\n";
553 let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
554 let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
555 assert_eq!(row.email, "john@example.com");
556 assert_eq!(row.role.unwrap(), "Project Admin");
557 assert_eq!(row.company.unwrap(), "Acme Corp");
558 }
559
560 #[test]
561 fn test_csv_update_row_minimal() {
562 let csv_data = "email,role,company\njohn@example.com,,\n";
563 let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
564 let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
565 assert_eq!(row.email, "john@example.com");
566 assert!(
568 row.role.is_none() || row.role.as_deref() == Some(""),
569 "Expected None or empty string for role, got {:?}",
570 row.role
571 );
572 assert!(
573 row.company.is_none() || row.company.as_deref() == Some(""),
574 "Expected None or empty string for company, got {:?}",
575 row.company
576 );
577 }
578
579 #[test]
580 fn test_csv_update_row_email_only_header() {
581 let csv_data = "email\njohn@example.com\n";
583 let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
584 let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
585 assert_eq!(row.email, "john@example.com");
586 assert!(row.role.is_none());
587 assert!(row.company.is_none());
588 }
589
590 #[test]
591 fn test_csv_update_row_multiple_rows() {
592 let csv_data = "\
593email,role,company
594alice@example.com,Project Admin,Alpha Inc
595bob@example.com,Document Manager,Beta LLC
596carol@example.com,,
597";
598 let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
599 let rows: Vec<super::csv_ops::CsvUpdateRow> =
600 rdr.deserialize().collect::<Result<Vec<_>, _>>().unwrap();
601 assert_eq!(rows.len(), 3);
602 assert_eq!(rows[0].email, "alice@example.com");
603 assert_eq!(rows[1].email, "bob@example.com");
604 assert_eq!(rows[1].role.as_deref(), Some("Document Manager"));
605 assert_eq!(rows[2].email, "carol@example.com");
606 }
607
608 #[test]
609 fn test_user_list_output_serialization() {
610 let output = UserListOutput {
611 id: "abc-123".to_string(),
612 email: "test@example.com".to_string(),
613 name: "Test User".to_string(),
614 role: "Project Admin".to_string(),
615 company: Some("Acme Corp".to_string()),
616 status: Some("active".to_string()),
617 };
618 let json = serde_json::to_string(&output).unwrap();
619 assert!(json.contains("\"email\":\"test@example.com\""));
620 assert!(json.contains("\"name\":\"Test User\""));
621 assert!(json.contains("\"id\":\"abc-123\""));
622 assert!(json.contains("\"role\":\"Project Admin\""));
623 assert!(json.contains("\"company\":\"Acme Corp\""));
624 assert!(json.contains("\"status\":\"active\""));
625 }
626
627 #[test]
628 fn test_user_list_output_skips_none_fields() {
629 let output = UserListOutput {
630 id: "abc-123".to_string(),
631 email: "test@example.com".to_string(),
632 name: "Test User".to_string(),
633 role: "Admin".to_string(),
634 company: None,
635 status: None,
636 };
637 let json = serde_json::to_string(&output).unwrap();
638 assert!(!json.contains("company"));
640 assert!(!json.contains("status"));
641 }
642
643 #[test]
644 fn test_csv_update_result_output_serialization() {
645 let output = CsvUpdateResultOutput {
646 total: 10,
647 updated: 8,
648 skipped: 1,
649 failed: 1,
650 errors: vec![CsvUpdateErrorOutput {
651 email: "fail@test.com".to_string(),
652 error: "not found".to_string(),
653 }],
654 };
655 let json = serde_json::to_string(&output).unwrap();
656 assert!(json.contains("\"total\":10"));
657 assert!(json.contains("\"updated\":8"));
658 assert!(json.contains("\"skipped\":1"));
659 assert!(json.contains("\"failed\":1"));
660 assert!(json.contains("fail@test.com"));
661 assert!(json.contains("not found"));
662 }
663
664 #[test]
665 fn test_csv_update_result_output_empty_errors() {
666 let output = CsvUpdateResultOutput {
667 total: 5,
668 updated: 5,
669 skipped: 0,
670 failed: 0,
671 errors: vec![],
672 };
673 let json = serde_json::to_string(&output).unwrap();
674 assert!(json.contains("\"errors\":[]"));
675 }
676
677 #[test]
678 fn test_csv_update_error_output_serialization() {
679 let output = CsvUpdateErrorOutput {
680 email: "bad@test.com".to_string(),
681 error: "permission denied".to_string(),
682 };
683 let json = serde_json::to_string(&output).unwrap();
684 assert!(json.contains("\"email\":\"bad@test.com\""));
685 assert!(json.contains("\"error\":\"permission denied\""));
686 }
687
688 #[test]
689 fn test_format_project_status_active() {
690 let result = super::project::format_project_status("active");
691 assert!(result.contains("active"));
693 }
694
695 #[test]
696 fn test_format_project_status_unknown() {
697 let result = super::project::format_project_status("pending");
698 assert_eq!(result, "pending");
699 }
700
701 #[test]
702 fn test_format_user_status_active() {
703 let result = super::user::format_user_status("active");
704 assert!(result.contains("active"));
705 }
706
707 #[test]
708 fn test_format_user_status_unknown() {
709 let result = super::user::format_user_status("unknown");
710 assert_eq!(result, "unknown");
711 }
712}