Files
cli/sunbeam-sdk/src/lasuite/cli.rs
Sienna Meridian Satterwhite cd80a57a40 fix: DynamicBearer auth, retry on 500/429, upload resilience
- DynamicBearer AuthMethod: La Suite clients resolve tokens fresh
  per-request from cache file, surviving token expiry mid-session
- Retry with exponential backoff on all Drive API calls (create_child,
  upload_ended) — up to 5 retries on 429/500/502/503
- Token refresh triggered on 500 before retry (handles expired SSO)
- S3 upload retry with backoff (up to 3 retries on 502/503)
- Connection pooling: reuse DriveClient HTTP client for S3 PUTs
- Folder/file dedup: skip existing items on re-upload
2026-03-24 15:25:01 +00:00

1513 lines
48 KiB
Rust

//! CLI command definitions and dispatch for all 7 La Suite services.
use clap::Subcommand;
use crate::client::SunbeamClient;
use crate::error::Result;
use crate::output::{self, OutputFormat};
// ═══════════════════════════════════════════════════════════════════════════
// People
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum PeopleCommand {
/// Contact management.
Contact {
#[command(subcommand)]
action: ContactAction,
},
/// Team management.
Team {
#[command(subcommand)]
action: TeamAction,
},
/// Service provider listing.
ServiceProvider {
#[command(subcommand)]
action: ServiceProviderAction,
},
/// Mail domain listing.
MailDomain {
#[command(subcommand)]
action: MailDomainAction,
},
}
#[derive(Subcommand, Debug)]
pub enum ContactAction {
/// List contacts.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a contact by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a contact from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a contact from JSON.
Update {
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a contact.
Delete {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum TeamAction {
/// List teams.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a team by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a team from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum ServiceProviderAction {
/// List service providers.
List,
}
#[derive(Subcommand, Debug)]
pub enum MailDomainAction {
/// List mail domains.
List,
}
pub async fn dispatch_people(
cmd: PeopleCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let people = client.people().await?;
match cmd {
PeopleCommand::Contact { action } => match action {
ContactAction::List { page } => {
let page_data = people.list_contacts(page).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "EMAIL", "ORGANIZATION"],
|c| {
vec![
c.id.clone(),
format!(
"{} {}",
c.first_name.as_deref().unwrap_or(""),
c.last_name.as_deref().unwrap_or("")
)
.trim()
.to_string(),
c.email.clone().unwrap_or_default(),
c.organization.clone().unwrap_or_default(),
]
},
fmt,
)
}
ContactAction::Get { id } => {
let item = people.get_contact(&id).await?;
output::render(&item, fmt)
}
ContactAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = people.create_contact(&json).await?;
output::render(&item, fmt)
}
ContactAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = people.update_contact(&id, &json).await?;
output::render(&item, fmt)
}
ContactAction::Delete { id } => {
people.delete_contact(&id).await?;
output::ok(&format!("Deleted contact {id}"));
Ok(())
}
},
PeopleCommand::Team { action } => match action {
TeamAction::List { page } => {
let page_data = people.list_teams(page).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "DESCRIPTION"],
|t| {
vec![
t.id.clone(),
t.name.clone().unwrap_or_default(),
t.description.clone().unwrap_or_default(),
]
},
fmt,
)
}
TeamAction::Get { id } => {
let item = people.get_team(&id).await?;
output::render(&item, fmt)
}
TeamAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = people.create_team(&json).await?;
output::render(&item, fmt)
}
},
PeopleCommand::ServiceProvider { action } => match action {
ServiceProviderAction::List => {
let page_data = people.list_service_providers().await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "BASE_URL"],
|sp| {
vec![
sp.id.clone(),
sp.name.clone().unwrap_or_default(),
sp.base_url.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
PeopleCommand::MailDomain { action } => match action {
MailDomainAction::List => {
let page_data = people.list_mail_domains().await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "STATUS"],
|md| {
vec![
md.id.clone(),
md.name.clone().unwrap_or_default(),
md.status.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Docs
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum DocsCommand {
/// Document management.
Document {
#[command(subcommand)]
action: DocumentAction,
},
/// Template management.
Template {
#[command(subcommand)]
action: TemplateAction,
},
/// Version history.
Version {
#[command(subcommand)]
action: VersionAction,
},
/// Invite a user to a document.
Invite {
/// Document ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum DocumentAction {
/// List documents.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a document by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a document from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a document from JSON.
Update {
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a document.
Delete {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum TemplateAction {
/// List templates.
List {
#[arg(long)]
page: Option<u32>,
},
/// Create a template from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum VersionAction {
/// List versions of a document.
List {
/// Document ID.
#[arg(short, long)]
id: String,
},
}
pub async fn dispatch_docs(
cmd: DocsCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let docs = client.docs().await?;
match cmd {
DocsCommand::Document { action } => match action {
DocumentAction::List { page } => {
let page_data = docs.list_documents(page).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "PUBLIC", "UPDATED"],
|d| {
vec![
d.id.clone(),
d.title.clone().unwrap_or_default(),
d.is_public.map_or("-".into(), |p| p.to_string()),
d.updated_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
DocumentAction::Get { id } => {
let item = docs.get_document(&id).await?;
output::render(&item, fmt)
}
DocumentAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = docs.create_document(&json).await?;
output::render(&item, fmt)
}
DocumentAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = docs.update_document(&id, &json).await?;
output::render(&item, fmt)
}
DocumentAction::Delete { id } => {
docs.delete_document(&id).await?;
output::ok(&format!("Deleted document {id}"));
Ok(())
}
},
DocsCommand::Template { action } => match action {
TemplateAction::List { page } => {
let page_data = docs.list_templates(page).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "PUBLIC"],
|t| {
vec![
t.id.clone(),
t.title.clone().unwrap_or_default(),
t.is_public.map_or("-".into(), |p| p.to_string()),
]
},
fmt,
)
}
TemplateAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = docs.create_template(&json).await?;
output::render(&item, fmt)
}
},
DocsCommand::Version { action } => match action {
VersionAction::List { id } => {
let page_data = docs.list_versions(&id).await?;
output::render_list(
&page_data.results,
&["ID", "VERSION", "CREATED"],
|v| {
vec![
v.id.clone(),
v.version_number.map_or("-".into(), |n| n.to_string()),
v.created_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
DocsCommand::Invite { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = docs.invite_user(&id, &json).await?;
output::render(&item, fmt)
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Meet
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum MeetCommand {
/// Room management.
Room {
#[command(subcommand)]
action: RoomAction,
},
/// Recording management.
Recording {
#[command(subcommand)]
action: RecordingAction,
},
}
#[derive(Subcommand, Debug)]
pub enum RoomAction {
/// List rooms.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a room by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a room from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a room from JSON.
Update {
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a room.
Delete {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum RecordingAction {
/// List recordings for a room.
List {
/// Room ID.
#[arg(short, long)]
id: String,
},
}
pub async fn dispatch_meet(
cmd: MeetCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let meet = client.meet().await?;
match cmd {
MeetCommand::Room { action } => match action {
RoomAction::List { page } => {
let page_data = meet.list_rooms(page).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "SLUG", "PUBLIC"],
|r| {
vec![
r.id.clone(),
r.name.clone().unwrap_or_default(),
r.slug.clone().unwrap_or_default(),
r.is_public.map_or("-".into(), |p| p.to_string()),
]
},
fmt,
)
}
RoomAction::Get { id } => {
let item = meet.get_room(&id).await?;
output::render(&item, fmt)
}
RoomAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = meet.create_room(&json).await?;
output::render(&item, fmt)
}
RoomAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = meet.update_room(&id, &json).await?;
output::render(&item, fmt)
}
RoomAction::Delete { id } => {
meet.delete_room(&id).await?;
output::ok(&format!("Deleted room {id}"));
Ok(())
}
},
MeetCommand::Recording { action } => match action {
RecordingAction::List { id } => {
let page_data = meet.list_recordings(&id).await?;
output::render_list(
&page_data.results,
&["ID", "FILENAME", "DURATION", "CREATED"],
|r| {
vec![
r.id.clone(),
r.filename.clone().unwrap_or_default(),
r.duration.map_or("-".into(), |d| format!("{d:.1}s")),
r.created_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Drive
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum DriveCommand {
/// File management.
File {
#[command(subcommand)]
action: FileAction,
},
/// Folder management.
Folder {
#[command(subcommand)]
action: FolderAction,
},
/// Share a file with a user.
Share {
/// File ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Permission management.
Permission {
#[command(subcommand)]
action: PermissionAction,
},
/// Upload a local file or directory to a Drive folder.
Upload {
/// Local path to upload (file or directory).
#[arg(short, long)]
path: String,
/// Target Drive folder ID.
#[arg(short = 't', long)]
folder_id: String,
/// Number of concurrent uploads.
#[arg(long, default_value = "3")]
parallel: usize,
},
}
#[derive(Subcommand, Debug)]
pub enum FileAction {
/// List files.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a file by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Upload a file (JSON metadata).
Upload {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a file.
Delete {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum FolderAction {
/// List folders.
List {
#[arg(long)]
page: Option<u32>,
},
/// Create a folder from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum PermissionAction {
/// List permissions for a file.
List {
/// File ID.
#[arg(short, long)]
id: String,
},
}
pub async fn dispatch_drive(
cmd: DriveCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let drive = client.drive().await?;
match cmd {
DriveCommand::File { action } => match action {
FileAction::List { page } => {
let page_data = drive.list_files(page).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "TYPE", "SIZE", "MIMETYPE"],
|f| {
vec![
f.id.clone(),
f.title.clone().unwrap_or_default(),
f.item_type.clone().unwrap_or_default(),
f.size.map_or("-".into(), |s| s.to_string()),
f.mimetype.clone().unwrap_or_default(),
]
},
fmt,
)
}
FileAction::Get { id } => {
let item = drive.get_file(&id).await?;
output::render(&item, fmt)
}
FileAction::Upload { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = drive.upload_file(&json).await?;
output::render(&item, fmt)
}
FileAction::Delete { id } => {
drive.delete_file(&id).await?;
output::ok(&format!("Deleted file {id}"));
Ok(())
}
},
DriveCommand::Folder { action } => match action {
FolderAction::List { page } => {
let page_data = drive.list_folders(page).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "CHILDREN", "CREATED"],
|f| {
vec![
f.id.clone(),
f.title.clone().unwrap_or_default(),
f.numchild.map_or("-".into(), |n| n.to_string()),
f.created_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
FolderAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = drive.create_folder(&json).await?;
output::render(&item, fmt)
}
},
DriveCommand::Share { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = drive.share_file(&id, &json).await?;
output::render(&item, fmt)
}
DriveCommand::Permission { action } => match action {
PermissionAction::List { id } => {
let page_data = drive.get_permissions(&id).await?;
output::render_list(
&page_data.results,
&["ID", "USER_ID", "ROLE", "READ", "WRITE"],
|p| {
vec![
p.id.clone(),
p.user_id.clone().unwrap_or_default(),
p.role.clone().unwrap_or_default(),
p.can_read.map_or("-".into(), |v| v.to_string()),
p.can_write.map_or("-".into(), |v| v.to_string()),
]
},
fmt,
)
}
},
DriveCommand::Upload { path, folder_id, parallel } => {
upload_recursive(drive, &path, &folder_id, parallel).await
}
}
}
/// A file that needs uploading, collected during the directory-walk phase.
struct UploadJob {
local_path: std::path::PathBuf,
parent_id: String,
file_size: u64,
relative_path: String,
}
/// Recursively upload a local file or directory to a Drive folder.
async fn upload_recursive(
drive: &super::DriveClient,
local_path: &str,
parent_id: &str,
parallel: usize,
) -> Result<()> {
use indicatif::{HumanBytes, MultiProgress, ProgressBar, ProgressStyle};
use std::sync::Arc;
use tokio::sync::Semaphore;
let path = std::path::Path::new(local_path);
if !path.exists() {
return Err(crate::error::SunbeamError::Other(format!(
"Path does not exist: {local_path}"
)));
}
// Phase 1 — Walk and collect: create folders sequentially, gather file jobs.
let mut jobs = Vec::new();
if path.is_file() {
let file_size = std::fs::metadata(path)
.map_err(|e| crate::error::SunbeamError::Other(format!("stat: {e}")))?
.len();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed");
if !filename.starts_with('.') {
jobs.push(UploadJob {
local_path: path.to_path_buf(),
parent_id: parent_id.to_string(),
file_size,
relative_path: filename.to_string(),
});
}
} else if path.is_dir() {
collect_upload_jobs(drive, path, parent_id, "", &mut jobs).await?;
} else {
return Err(crate::error::SunbeamError::Other(format!(
"Not a file or directory: {local_path}"
)));
}
if jobs.is_empty() {
output::ok("Nothing to upload.");
return Ok(());
}
let total_files = jobs.len() as u64;
let total_bytes: u64 = jobs.iter().map(|j| j.file_size).sum();
// Clear the folder creation line
eprint!("\r\x1b[K");
// Phase 2 — Parallel upload with progress bars.
let multi = MultiProgress::new();
// Overall bar tracks file count. Bandwidth is computed manually in the message.
let overall_style = ProgressStyle::with_template(
" {spinner:.green} [{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} files {msg}",
)
.unwrap()
.progress_chars("█▓░");
let overall = multi.add(ProgressBar::new(total_files));
overall.set_style(overall_style);
overall.enable_steady_tick(std::time::Duration::from_millis(100));
let completed_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let file_style = ProgressStyle::with_template(
" {spinner:.cyan} {wide_msg}",
)
.unwrap();
let sem = Arc::new(Semaphore::new(parallel));
let drive = Arc::new(drive.clone());
let mut handles = Vec::new();
let start = std::time::Instant::now();
for job in jobs {
let permit = sem.clone().acquire_owned().await.unwrap();
let drive = Arc::clone(&drive);
let multi = multi.clone();
let overall = overall.clone();
let file_style = file_style.clone();
let job_size = job.file_size;
let completed_bytes = Arc::clone(&completed_bytes);
let total_bytes = total_bytes;
let start = start.clone();
let handle = tokio::spawn(async move {
let pb = multi.add(ProgressBar::new_spinner());
pb.set_style(file_style);
pb.set_message(job.relative_path.clone());
pb.enable_steady_tick(std::time::Duration::from_millis(80));
let result = upload_single_file_with_progress(&drive, &job, &pb).await;
pb.finish_and_clear();
multi.remove(&pb);
// Update overall — increment file count, compute bandwidth from bytes
overall.inc(1);
let done_bytes = completed_bytes.fetch_add(job_size, std::sync::atomic::Ordering::Relaxed) + job_size;
let elapsed = start.elapsed().as_secs_f64();
let speed = if elapsed > 1.0 { done_bytes as f64 / elapsed } else { 0.0 };
let remaining = total_bytes.saturating_sub(done_bytes);
let eta = if speed > 0.0 { remaining as f64 / speed } else { 0.0 };
let eta_m = eta as u64 / 60;
let eta_s = eta as u64 % 60;
overall.set_message(format!(
"{}/{} {}/s ETA: {}m {:02}s",
indicatif::HumanBytes(done_bytes),
indicatif::HumanBytes(total_bytes),
indicatif::HumanBytes(speed as u64),
eta_m, eta_s,
));
drop(permit);
result
});
handles.push(handle);
}
let mut errors = 0u64;
for handle in handles {
match handle.await {
Ok(Ok(())) => {}
Ok(Err(e)) => {
errors += 1;
multi.suspend(|| eprintln!(" ERROR: {e}"));
}
Err(e) => {
errors += 1;
multi.suspend(|| eprintln!(" ERROR: task panic: {e}"));
}
}
}
overall.finish_and_clear();
multi.clear().ok();
let elapsed = start.elapsed();
let secs = elapsed.as_secs_f64();
let speed = if secs > 0.0 {
total_bytes as f64 / secs
} else {
0.0
};
let mins = elapsed.as_secs() / 60;
let secs_rem = elapsed.as_secs() % 60;
let uploaded = total_files - errors;
if errors > 0 {
println!(
"✓ Uploaded {uploaded}/{total_files} files ({}) in {mins}m {secs_rem}s ({}/s) — {errors} failed",
HumanBytes(total_bytes),
HumanBytes(speed as u64),
);
} else {
println!(
"✓ Uploaded {total_files} files ({}) in {mins}m {secs_rem}s ({}/s)",
HumanBytes(total_bytes),
HumanBytes(speed as u64),
);
}
Ok(())
}
/// Phase 1: Walk a directory recursively, create folders in Drive sequentially,
/// and collect [`UploadJob`]s for every regular file.
async fn collect_upload_jobs(
drive: &super::DriveClient,
dir: &std::path::Path,
parent_id: &str,
prefix: &str,
jobs: &mut Vec<UploadJob>,
) -> Result<()> {
let dir_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed");
// Skip hidden directories
if dir_name.starts_with('.') {
return Ok(());
}
// Build the display prefix for children
let display_prefix = if prefix.is_empty() {
dir_name.to_string()
} else {
format!("{prefix}/{dir_name}")
};
eprint!("\r\x1b[K Scanning: {display_prefix} ");
// Check if folder already exists under the parent.
let existing = drive.list_children(parent_id, None).await.ok();
let existing_folder_id = existing.and_then(|page| {
page.results.iter().find_map(|item| {
let is_folder = item.get("type").and_then(|v| v.as_str()) == Some("folder");
let title_matches = item.get("title").and_then(|v| v.as_str()) == Some(dir_name);
if is_folder && title_matches {
item.get("id").and_then(|v| v.as_str()).map(String::from)
} else {
None
}
})
});
let folder_id = if let Some(id) = existing_folder_id {
id
} else {
let folder = drive
.create_child(
parent_id,
&serde_json::json!({
"title": dir_name,
"type": "folder",
}),
)
.await?;
folder["id"]
.as_str()
.ok_or_else(|| crate::error::SunbeamError::Other("No folder ID in response".into()))?
.to_string()
};
// Build a set of existing file titles in this folder to skip duplicates.
let existing_file_titles: std::collections::HashSet<String> = {
let mut titles = std::collections::HashSet::new();
if let Ok(page) = drive.list_children(&folder_id, None).await {
for item in &page.results {
if item.get("type").and_then(|v| v.as_str()) == Some("file") {
if let Some(title) = item.get("title").and_then(|v| v.as_str()) {
titles.insert(title.to_string());
}
}
}
}
titles
};
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| crate::error::SunbeamError::Other(format!("reading dir: {e}")))?
.filter_map(|e| e.ok())
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let entry_path = entry.path();
let name = entry
.file_name()
.to_str()
.unwrap_or_default()
.to_string();
// Skip hidden entries
if name.starts_with('.') {
continue;
}
if entry_path.is_dir() {
Box::pin(collect_upload_jobs(
drive,
&entry_path,
&folder_id,
&display_prefix,
jobs,
))
.await?;
} else if entry_path.is_file() {
// Skip if a file with this title already exists in the folder.
if existing_file_titles.contains(&name) {
continue;
}
let file_size = std::fs::metadata(&entry_path)
.map_err(|e| crate::error::SunbeamError::Other(format!("stat: {e}")))?
.len();
jobs.push(UploadJob {
local_path: entry_path,
parent_id: folder_id.clone(),
file_size,
relative_path: format!("{display_prefix}/{name}"),
});
}
}
Ok(())
}
/// Upload a single file to Drive, updating the progress bar.
/// Retries on 429/500/502/503 up to 5 times with exponential backoff.
async fn upload_single_file_with_progress(
drive: &super::DriveClient,
job: &UploadJob,
pb: &indicatif::ProgressBar,
) -> Result<()> {
let filename = job
.local_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed");
// Create the file item in Drive (with retry)
let body = serde_json::json!({
"title": filename,
"filename": filename,
"type": "file",
});
let item = retry_drive_call(|| drive.create_child(&job.parent_id, &body), 5).await?;
let item_id = item["id"]
.as_str()
.ok_or_else(|| crate::error::SunbeamError::Other("No item ID in response".into()))?;
let upload_url = item["policy"]
.as_str()
.ok_or_else(|| {
crate::error::SunbeamError::Other(
"No upload policy URL in response \u{2014} is the item a file?".into(),
)
})?;
tracing::debug!("S3 presigned URL: {upload_url}");
// Read the file and upload to S3
let data = std::fs::read(&job.local_path)
.map_err(|e| crate::error::SunbeamError::Other(format!("reading file: {e}")))?;
let len = data.len() as u64;
drive
.upload_to_s3(upload_url, bytes::Bytes::from(data))
.await?;
pb.set_position(len);
// Notify Drive the upload is complete (with retry)
retry_drive_call(|| drive.upload_ended(item_id), 5).await?;
Ok(())
}
/// Retry a Drive API call on 429/500/502/503 with exponential backoff.
async fn retry_drive_call<F, Fut, T>(f: F, max_retries: u32) -> Result<T>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let mut last_err = None;
for attempt in 0..=max_retries {
match f().await {
Ok(v) => return Ok(v),
Err(e) => {
let msg = e.to_string();
let retryable = msg.contains("429")
|| msg.contains("500")
|| msg.contains("502")
|| msg.contains("503")
|| msg.contains("request failed");
if retryable && attempt < max_retries {
// On 500, try refreshing the SSO token (may have expired)
if msg.contains("500") {
let _ = crate::auth::get_token().await;
}
let delay = std::time::Duration::from_millis(
500 * 2u64.pow(attempt.min(4)),
);
tokio::time::sleep(delay).await;
last_err = Some(e);
continue;
}
return Err(e);
}
}
}
Err(last_err.unwrap())
}
// ═══════════════════════════════════════════════════════════════════════════
// Mail (Messages)
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum MailCommand {
/// Mailbox management.
Mailbox {
#[command(subcommand)]
action: MailboxAction,
},
/// Message management.
Message {
#[command(subcommand)]
action: MessageAction,
},
/// Folder listing.
Folder {
#[command(subcommand)]
action: MailFolderAction,
},
/// Contact listing.
Contact {
#[command(subcommand)]
action: MailContactAction,
},
}
#[derive(Subcommand, Debug)]
pub enum MailboxAction {
/// List mailboxes.
List,
/// Get a mailbox by ID.
Get {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum MessageAction {
/// List messages in a mailbox folder.
List {
/// Mailbox ID.
#[arg(short, long)]
id: String,
/// Folder name (e.g. "inbox").
#[arg(short, long, default_value = "inbox")]
folder: String,
},
/// Get a message.
Get {
/// Mailbox ID.
#[arg(short, long)]
id: String,
/// Message ID.
#[arg(short, long)]
message_id: String,
},
/// Send a message from a mailbox.
Send {
/// Mailbox ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum MailFolderAction {
/// List folders in a mailbox.
List {
/// Mailbox ID.
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum MailContactAction {
/// List contacts in a mailbox.
List {
/// Mailbox ID.
#[arg(short, long)]
id: String,
},
}
pub async fn dispatch_mail(
cmd: MailCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let mail = client.messages().await?;
match cmd {
MailCommand::Mailbox { action } => match action {
MailboxAction::List => {
let page_data = mail.list_mailboxes().await?;
output::render_list(
&page_data.results,
&["ID", "EMAIL", "DISPLAY_NAME"],
|m| {
vec![
m.id.clone(),
m.email.clone().unwrap_or_default(),
m.display_name.clone().unwrap_or_default(),
]
},
fmt,
)
}
MailboxAction::Get { id } => {
let item = mail.get_mailbox(&id).await?;
output::render(&item, fmt)
}
},
MailCommand::Message { action } => match action {
MessageAction::List { id, folder } => {
let page_data = mail.list_messages(&id, &folder).await?;
output::render_list(
&page_data.results,
&["ID", "SUBJECT", "FROM", "READ", "CREATED"],
|m| {
vec![
m.id.clone(),
m.subject.clone().unwrap_or_default(),
m.from_address.clone().unwrap_or_default(),
m.is_read.map_or("-".into(), |r| r.to_string()),
m.created_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
MessageAction::Get { id, message_id } => {
let item = mail.get_message(&id, &message_id).await?;
output::render(&item, fmt)
}
MessageAction::Send { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = mail.send_message(&id, &json).await?;
output::render(&item, fmt)
}
},
MailCommand::Folder { action } => match action {
MailFolderAction::List { id } => {
let page_data = mail.list_folders(&id).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "MESSAGES", "UNREAD"],
|f| {
vec![
f.id.clone(),
f.name.clone().unwrap_or_default(),
f.message_count.map_or("-".into(), |c| c.to_string()),
f.unread_count.map_or("-".into(), |c| c.to_string()),
]
},
fmt,
)
}
},
MailCommand::Contact { action } => match action {
MailContactAction::List { id } => {
let page_data = mail.list_contacts(&id).await?;
output::render_list(
&page_data.results,
&["ID", "EMAIL", "DISPLAY_NAME"],
|c| {
vec![
c.id.clone(),
c.email.clone().unwrap_or_default(),
c.display_name.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Calendars
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum CalCommand {
/// Calendar management.
Calendar {
#[command(subcommand)]
action: CalendarAction,
},
/// Event management.
Event {
#[command(subcommand)]
action: EventAction,
},
/// RSVP to an event.
Rsvp {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// Event ID.
#[arg(short = 'e', long)]
event_id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum CalendarAction {
/// List calendars.
List,
/// Get a calendar by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a calendar from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum EventAction {
/// List events in a calendar.
List {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
},
/// Get an event.
Get {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// Event ID.
#[arg(short = 'e', long)]
event_id: String,
},
/// Create an event from JSON.
Create {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update an event from JSON.
Update {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// Event ID.
#[arg(short = 'e', long)]
event_id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete an event.
Delete {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// Event ID.
#[arg(short = 'e', long)]
event_id: String,
},
}
pub async fn dispatch_cal(
cmd: CalCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let cal = client.calendars().await?;
match cmd {
CalCommand::Calendar { action } => match action {
CalendarAction::List => {
let page_data = cal.list_calendars().await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "COLOR", "DEFAULT"],
|c| {
vec![
c.id.clone(),
c.name.clone().unwrap_or_default(),
c.color.clone().unwrap_or_default(),
c.is_default.map_or("-".into(), |d| d.to_string()),
]
},
fmt,
)
}
CalendarAction::Get { id } => {
let item = cal.get_calendar(&id).await?;
output::render(&item, fmt)
}
CalendarAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = cal.create_calendar(&json).await?;
output::render(&item, fmt)
}
},
CalCommand::Event { action } => match action {
EventAction::List { calendar_id } => {
let page_data = cal.list_events(&calendar_id).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "START", "END", "ALL_DAY"],
|e| {
vec![
e.id.clone(),
e.title.clone().unwrap_or_default(),
e.start.clone().unwrap_or_default(),
e.end.clone().unwrap_or_default(),
e.all_day.map_or("-".into(), |a| a.to_string()),
]
},
fmt,
)
}
EventAction::Get {
calendar_id,
event_id,
} => {
let item = cal.get_event(&calendar_id, &event_id).await?;
output::render(&item, fmt)
}
EventAction::Create { calendar_id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = cal.create_event(&calendar_id, &json).await?;
output::render(&item, fmt)
}
EventAction::Update {
calendar_id,
event_id,
data,
} => {
let json = output::read_json_input(data.as_deref())?;
let item = cal.update_event(&calendar_id, &event_id, &json).await?;
output::render(&item, fmt)
}
EventAction::Delete {
calendar_id,
event_id,
} => {
cal.delete_event(&calendar_id, &event_id).await?;
output::ok(&format!("Deleted event {event_id}"));
Ok(())
}
},
CalCommand::Rsvp {
calendar_id,
event_id,
data,
} => {
let json = output::read_json_input(data.as_deref())?;
cal.rsvp(&calendar_id, &event_id, &json).await?;
output::ok(&format!("RSVP sent for event {event_id}"));
Ok(())
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Find
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum FindCommand {
/// Search across La Suite services.
Search {
/// Search query.
#[arg(short, long)]
query: String,
#[arg(long)]
page: Option<u32>,
},
}
pub async fn dispatch_find(
cmd: FindCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let find = client.find().await?;
match cmd {
FindCommand::Search { query, page } => {
let page_data = find.search(&query, page).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "SOURCE", "SCORE", "URL"],
|r| {
vec![
r.id.clone(),
r.title.clone().unwrap_or_default(),
r.source.clone().unwrap_or_default(),
r.score.map_or("-".into(), |s| format!("{s:.2}")),
r.url.clone().unwrap_or_default(),
]
},
fmt,
)
}
}
}