mod csv_ops;
mod folder;
mod operations;
mod project;
mod user;
use std::path::PathBuf;
use anyhow::Result;
use clap::{Subcommand, ValueEnum};
use indicatif::{ProgressBar, ProgressStyle};
use raps_admin::{PermissionLevel, ProgressUpdate, ProjectFilter};
use raps_kernel::auth::AuthClient;
use raps_kernel::config::Config;
use crate::output::OutputFormat;
#[derive(Debug, Subcommand)]
pub enum AdminCommands {
#[command(subcommand)]
User(UserCommands),
#[command(subcommand)]
Folder(FolderCommands),
#[command(subcommand)]
Project(AdminProjectCommands),
#[command(subcommand)]
Operation(OperationCommands),
#[command(name = "company-list")]
CompanyList {
#[arg(short, long)]
account: Option<String>,
},
}
#[derive(Debug, Subcommand)]
pub enum UserCommands {
List {
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
project: Option<String>,
#[arg(long)]
role: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
search: Option<String>,
},
Add {
email: String,
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
role: Option<String>,
#[arg(short, long)]
filter: Option<String>,
#[arg(long, value_name = "FILE")]
project_ids: Option<PathBuf>,
#[arg(long, default_value = "10")]
concurrency: usize,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
yes: bool,
},
Remove {
email: String,
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
filter: Option<String>,
#[arg(long, value_name = "FILE")]
project_ids: Option<PathBuf>,
#[arg(long, default_value = "10")]
concurrency: usize,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
yes: bool,
},
Update {
email: String,
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
role: Option<String>,
#[arg(long)]
company: Option<String>,
#[arg(long)]
from_role: Option<String>,
#[arg(short, long)]
filter: Option<String>,
#[arg(long, value_name = "FILE")]
project_ids: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
from_csv: Option<PathBuf>,
#[arg(long, default_value = "10")]
concurrency: usize,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
yes: bool,
},
#[command(name = "add-to-project")]
AddToProject {
#[arg(short, long)]
project: String,
#[arg(short, long)]
email: String,
#[arg(short, long)]
role_id: Option<String>,
},
#[command(name = "remove-from-project")]
RemoveFromProject {
#[arg(short, long)]
project: String,
#[arg(short, long)]
user_id: String,
#[arg(short, long)]
yes: bool,
},
#[command(name = "update-in-project")]
UpdateInProject {
#[arg(short, long)]
project: String,
#[arg(short, long)]
user_id: String,
#[arg(short, long)]
role_id: Option<String>,
},
#[command(name = "import")]
Import {
#[arg(short, long)]
project: String,
#[arg(long, value_name = "FILE")]
from_csv: PathBuf,
},
}
#[derive(Debug, Subcommand)]
pub enum FolderCommands {
Rights {
email: String,
#[arg(short, long)]
account: Option<String>,
#[arg(short, long, value_enum)]
level: PermissionLevelArg,
#[arg(long, default_value = "project-files")]
folder: String,
#[arg(short, long)]
filter: Option<String>,
#[arg(long, value_name = "FILE")]
project_ids: Option<PathBuf>,
#[arg(long, default_value = "10")]
concurrency: usize,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
yes: bool,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum PermissionLevelArg {
ViewOnly,
ViewDownload,
UploadOnly,
ViewDownloadUpload,
ViewDownloadUploadEdit,
FolderControl,
}
impl From<PermissionLevelArg> for PermissionLevel {
fn from(arg: PermissionLevelArg) -> Self {
match arg {
PermissionLevelArg::ViewOnly => PermissionLevel::ViewOnly,
PermissionLevelArg::ViewDownload => PermissionLevel::ViewDownload,
PermissionLevelArg::UploadOnly => PermissionLevel::UploadOnly,
PermissionLevelArg::ViewDownloadUpload => PermissionLevel::ViewDownloadUpload,
PermissionLevelArg::ViewDownloadUploadEdit => PermissionLevel::ViewDownloadUploadEdit,
PermissionLevelArg::FolderControl => PermissionLevel::FolderControl,
}
}
}
#[derive(Debug, Subcommand)]
pub enum AdminProjectCommands {
List {
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
filter: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long, default_value = "all")]
platform: String,
#[arg(long)]
limit: Option<usize>,
},
Create {
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
name: String,
#[arg(short = 't', long)]
r#type: Option<String>,
#[arg(long)]
classification: Option<String>,
#[arg(long)]
start_date: Option<String>,
#[arg(long)]
end_date: Option<String>,
#[arg(long)]
timezone: Option<String>,
},
Update {
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
project: String,
#[arg(short, long)]
name: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
start_date: Option<String>,
#[arg(long)]
end_date: Option<String>,
},
Archive {
#[arg(short, long)]
account: Option<String>,
#[arg(short, long)]
project: String,
},
}
#[derive(Debug, Subcommand)]
pub enum OperationCommands {
Status {
operation_id: Option<uuid::Uuid>,
},
Resume {
operation_id: Option<uuid::Uuid>,
#[arg(long)]
concurrency: Option<usize>,
},
Cancel {
operation_id: Option<uuid::Uuid>,
#[arg(short, long)]
yes: bool,
},
List {
#[arg(long)]
status: Option<String>,
#[arg(long, default_value = "10")]
limit: usize,
},
}
pub(crate) fn get_account_id(account: Option<String>) -> Result<String> {
match account.or_else(|| std::env::var("APS_ACCOUNT_ID").ok()) {
Some(id) if !id.is_empty() => Ok(id),
_ => {
anyhow::bail!(
"Account ID is required. Use --account or set APS_ACCOUNT_ID environment variable."
);
}
}
}
pub(crate) fn parse_filter_with_ids(
filter: &Option<String>,
project_ids: &Option<PathBuf>,
) -> Result<ProjectFilter> {
let mut project_filter = match filter {
Some(f) => ProjectFilter::from_expression(f)?,
None => ProjectFilter::new(),
};
if let Some(ids_file) = project_ids {
let content = std::fs::read_to_string(ids_file)?;
let ids: Vec<String> = content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
project_filter.include_ids = Some(ids);
}
Ok(project_filter)
}
pub(crate) fn create_bulk_progress_bar(output_format: OutputFormat) -> Option<ProgressBar> {
if !output_format.supports_colors() {
return None;
}
let pb = ProgressBar::new(0);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}")
.expect("valid progress template")
.progress_chars("=>-"),
);
Some(pb)
}
pub(crate) fn make_progress_callback(pb: Option<ProgressBar>) -> impl Fn(ProgressUpdate) {
move |progress: ProgressUpdate| {
if let Some(ref pb) = pb {
pb.set_length(progress.total as u64);
pb.set_position((progress.completed + progress.failed + progress.skipped) as u64);
pb.set_message(format!(
"\u{2713}{} \u{25CB}{} \u{2717}{}",
progress.completed, progress.skipped, progress.failed
));
}
}
}
impl AdminCommands {
pub async fn execute(
self,
config: &Config,
auth_client: &AuthClient,
output_format: OutputFormat,
) -> Result<()> {
match self {
AdminCommands::User(cmd) => cmd.execute(config, auth_client, output_format).await,
AdminCommands::Folder(cmd) => cmd.execute(config, auth_client, output_format).await,
AdminCommands::Project(cmd) => cmd.execute(config, auth_client, output_format).await,
AdminCommands::Operation(cmd) => cmd.execute(output_format).await,
AdminCommands::CompanyList { account } => {
project::execute_company_list(config, auth_client, account, output_format).await
}
}
}
}
#[cfg(test)]
mod tests {
use super::csv_ops::{CsvUpdateErrorOutput, CsvUpdateResultOutput};
use super::user::UserListOutput;
#[test]
fn test_csv_update_row_deserialization() {
let csv_data = "email,role,company\njohn@example.com,Project Admin,Acme Corp\n";
let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
assert_eq!(row.email, "john@example.com");
assert_eq!(row.role.unwrap(), "Project Admin");
assert_eq!(row.company.unwrap(), "Acme Corp");
}
#[test]
fn test_csv_update_row_minimal() {
let csv_data = "email,role,company\njohn@example.com,,\n";
let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
assert_eq!(row.email, "john@example.com");
assert!(
row.role.is_none() || row.role.as_deref() == Some(""),
"Expected None or empty string for role, got {:?}",
row.role
);
assert!(
row.company.is_none() || row.company.as_deref() == Some(""),
"Expected None or empty string for company, got {:?}",
row.company
);
}
#[test]
fn test_csv_update_row_email_only_header() {
let csv_data = "email\njohn@example.com\n";
let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
assert_eq!(row.email, "john@example.com");
assert!(row.role.is_none());
assert!(row.company.is_none());
}
#[test]
fn test_csv_update_row_multiple_rows() {
let csv_data = "\
email,role,company
alice@example.com,Project Admin,Alpha Inc
bob@example.com,Document Manager,Beta LLC
carol@example.com,,
";
let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
let rows: Vec<super::csv_ops::CsvUpdateRow> =
rdr.deserialize().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].email, "alice@example.com");
assert_eq!(rows[1].email, "bob@example.com");
assert_eq!(rows[1].role.as_deref(), Some("Document Manager"));
assert_eq!(rows[2].email, "carol@example.com");
}
#[test]
fn test_user_list_output_serialization() {
let output = UserListOutput {
id: "abc-123".to_string(),
email: "test@example.com".to_string(),
name: "Test User".to_string(),
role: "Project Admin".to_string(),
company: Some("Acme Corp".to_string()),
status: Some("active".to_string()),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"email\":\"test@example.com\""));
assert!(json.contains("\"name\":\"Test User\""));
assert!(json.contains("\"id\":\"abc-123\""));
assert!(json.contains("\"role\":\"Project Admin\""));
assert!(json.contains("\"company\":\"Acme Corp\""));
assert!(json.contains("\"status\":\"active\""));
}
#[test]
fn test_user_list_output_skips_none_fields() {
let output = UserListOutput {
id: "abc-123".to_string(),
email: "test@example.com".to_string(),
name: "Test User".to_string(),
role: "Admin".to_string(),
company: None,
status: None,
};
let json = serde_json::to_string(&output).unwrap();
assert!(!json.contains("company"));
assert!(!json.contains("status"));
}
#[test]
fn test_csv_update_result_output_serialization() {
let output = CsvUpdateResultOutput {
total: 10,
updated: 8,
skipped: 1,
failed: 1,
errors: vec![CsvUpdateErrorOutput {
email: "fail@test.com".to_string(),
error: "not found".to_string(),
}],
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"total\":10"));
assert!(json.contains("\"updated\":8"));
assert!(json.contains("\"skipped\":1"));
assert!(json.contains("\"failed\":1"));
assert!(json.contains("fail@test.com"));
assert!(json.contains("not found"));
}
#[test]
fn test_csv_update_result_output_empty_errors() {
let output = CsvUpdateResultOutput {
total: 5,
updated: 5,
skipped: 0,
failed: 0,
errors: vec![],
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"errors\":[]"));
}
#[test]
fn test_csv_update_error_output_serialization() {
let output = CsvUpdateErrorOutput {
email: "bad@test.com".to_string(),
error: "permission denied".to_string(),
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"email\":\"bad@test.com\""));
assert!(json.contains("\"error\":\"permission denied\""));
}
#[test]
fn test_format_project_status_active() {
let result = super::project::format_project_status("active");
assert!(result.contains("active"));
}
#[test]
fn test_format_project_status_unknown() {
let result = super::project::format_project_status("pending");
assert_eq!(result, "pending");
}
#[test]
fn test_format_user_status_active() {
let result = super::user::format_user_status("active");
assert!(result.contains("active"));
}
#[test]
fn test_format_user_status_unknown() {
let result = super::user::format_user_status("unknown");
assert_eq!(result, "unknown");
}
}