use anyhow::{Context, Result};
use clap::Subcommand;
use colored::Colorize;
use dialoguer::{Input, Select};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize;
use std::time::Duration;
use std::{path::PathBuf, str::FromStr};
use crate::api::{DerivativeClient, derivative::OutputFormat as DerivativeOutputFormat};
use crate::interactive;
use crate::output::OutputFormat;
#[derive(Debug, Subcommand)]
pub enum TranslateCommands {
Start {
urn: Option<String>,
#[arg(short, long)]
format: Option<String>,
#[arg(short, long)]
root_filename: Option<String>,
},
Status {
urn: String,
#[arg(short, long)]
wait: bool,
},
Manifest {
urn: String,
},
Derivatives {
urn: String,
#[arg(short, long)]
format: Option<String>,
},
Download {
urn: String,
#[arg(short, long)]
format: Option<String>,
#[arg(short, long)]
guid: Option<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
all: bool,
},
#[command(subcommand)]
Preset(PresetCommands),
}
#[derive(Debug, Subcommand)]
pub enum PresetCommands {
List,
Show {
name: String,
},
Create {
name: String,
#[arg(short, long)]
format: String,
#[arg(short, long)]
description: Option<String>,
},
Delete {
name: String,
},
Use {
urn: String,
preset: String,
},
}
impl TranslateCommands {
pub async fn execute(
self,
client: &DerivativeClient,
output_format: OutputFormat,
) -> Result<()> {
match self {
TranslateCommands::Start {
urn,
format,
root_filename,
} => start_translation(client, urn, format, root_filename, output_format).await,
TranslateCommands::Status { urn, wait } => {
check_status(client, &urn, wait, output_format).await
}
TranslateCommands::Manifest { urn } => show_manifest(client, &urn, output_format).await,
TranslateCommands::Derivatives { urn, format } => {
list_derivatives(client, &urn, format, output_format).await
}
TranslateCommands::Download {
urn,
format,
guid,
output,
all,
} => download_derivatives(client, &urn, format, guid, output, all, output_format).await,
TranslateCommands::Preset(cmd) => cmd.execute(client, output_format).await,
}
}
}
impl PresetCommands {
pub async fn execute(
self,
client: &DerivativeClient,
output_format: OutputFormat,
) -> Result<()> {
match self {
PresetCommands::List => list_presets(output_format),
PresetCommands::Show { name } => show_preset(&name, output_format),
PresetCommands::Create {
name,
format,
description,
} => create_preset(&name, &format, description, output_format),
PresetCommands::Delete { name } => delete_preset(&name, output_format),
PresetCommands::Use { urn, preset } => {
use_preset(client, &urn, &preset, output_format).await
}
}
}
}
#[derive(Serialize)]
struct TranslationStartOutput {
success: bool,
result: String,
urn: String,
accepted_formats: Vec<String>,
}
async fn start_translation(
client: &DerivativeClient,
urn: Option<String>,
format: Option<String>,
root_filename: Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let source_urn = match urn {
Some(u) => u,
None => {
if interactive::is_non_interactive() {
anyhow::bail!(
"URN is required in non-interactive mode. Use --urn flag or provide as argument."
);
}
Input::new()
.with_prompt("Enter the base64-encoded URN")
.validate_with(|input: &String| -> Result<(), &str> {
if input.is_empty() {
Err("URN cannot be empty")
} else {
Ok(())
}
})
.interact_text()?
}
};
let derivative_format = match format {
Some(f) => match f.to_lowercase().as_str() {
"svf2" => DerivativeOutputFormat::Svf2,
"svf" => DerivativeOutputFormat::Svf,
"thumbnail" => DerivativeOutputFormat::Thumbnail,
"obj" => DerivativeOutputFormat::Obj,
"stl" => DerivativeOutputFormat::Stl,
"step" => DerivativeOutputFormat::Step,
"iges" => DerivativeOutputFormat::Iges,
"ifc" => DerivativeOutputFormat::Ifc,
_ => anyhow::bail!(
"Invalid format. Use: svf2, svf, thumbnail, obj, stl, step, iges, ifc"
),
},
None => {
if interactive::is_non_interactive() {
anyhow::bail!(
"--format is required in non-interactive mode. Use: svf2, svf, thumbnail, obj, stl, step, iges, ifc"
);
}
let formats = DerivativeOutputFormat::all();
let format_labels: Vec<String> = formats.iter().map(|f| f.to_string()).collect();
let selection = Select::new()
.with_prompt("Select output format")
.items(&format_labels)
.default(0)
.interact()?;
formats[selection]
}
};
if output_format.supports_colors() {
println!(
"{} {} {} {}",
"Starting translation".dimmed(),
"→".dimmed(),
derivative_format.to_string().cyan(),
"format".dimmed()
);
}
let response = client
.translate(&source_urn, derivative_format, root_filename.as_deref())
.await?;
let accepted_formats: Vec<String> = response
.accepted_jobs
.as_ref()
.map(|jobs| {
jobs.output
.formats
.iter()
.map(|f| f.format_type.clone())
.collect()
})
.unwrap_or_default();
let output = TranslationStartOutput {
success: true,
result: response.result.clone(),
urn: response.urn.clone(),
accepted_formats,
};
match output_format {
OutputFormat::Table => {
println!("{} Translation job started!", "✓".green().bold());
println!(" {} {}", "Result:".bold(), output.result);
println!(" {} {}", "URN:".bold(), output.urn);
if !output.accepted_formats.is_empty() {
println!(" {} ", "Accepted formats:".bold());
for format in &output.accepted_formats {
println!(" {} {}", "•".dimmed(), format.cyan());
}
}
println!(
"\n{}",
"Tip: Use 'raps translate status <urn> --wait' to monitor progress".dimmed()
);
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
#[derive(Serialize)]
struct StatusOutput {
status: String,
progress: String,
}
async fn check_status(
client: &DerivativeClient,
urn: &str,
wait: bool,
output_format: OutputFormat,
) -> Result<()> {
if wait {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.cyan} {msg}")
.unwrap(),
);
spinner.enable_steady_tick(Duration::from_millis(100));
loop {
let (status, progress) = client.get_status(urn).await?;
spinner.set_message(format!("Translation: {} ({})", status, progress));
match status.as_str() {
"success" => {
spinner.finish_with_message(format!(
"{} Translation complete! (100%)",
"✓".green().bold()
));
break;
}
"failed" => {
spinner
.finish_with_message(format!("{} Translation failed!", "✗".red().bold()));
anyhow::bail!("Translation failed");
}
"timeout" => {
spinner.finish_with_message(format!(
"{} Translation timed out!",
"✗".red().bold()
));
anyhow::bail!("Translation timed out");
}
_ => {
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
} else {
let (status, progress) = client.get_status(urn).await?;
let output = StatusOutput {
status: status.clone(),
progress: progress.clone(),
};
match output_format {
OutputFormat::Table => {
let status_icon = match status.as_str() {
"success" => "✓".green().bold(),
"failed" | "timeout" => "✗".red().bold(),
"inprogress" | "pending" => "⋯".yellow().bold(),
_ => "?".dimmed(),
};
println!("{} {} ({})", status_icon, status, progress);
}
_ => {
output_format.write(&output)?;
}
}
}
Ok(())
}
async fn show_manifest(
client: &DerivativeClient,
urn: &str,
output_format: OutputFormat,
) -> Result<()> {
println!("{}", "Fetching manifest...".dimmed());
let manifest = client.get_manifest(urn).await?;
match output_format {
OutputFormat::Table => {
let status_icon = match manifest.status.as_str() {
"success" => "✓".green().bold(),
"failed" | "timeout" => "✗".red().bold(),
"inprogress" | "pending" => "⋯".yellow().bold(),
_ => "?".dimmed(),
};
println!("\n{}", "Manifest".bold());
println!("{}", "─".repeat(60));
println!(" {} {} {}", "Status:".bold(), status_icon, manifest.status);
println!(" {} {}", "Progress:".bold(), manifest.progress);
println!(" {} {}", "Region:".bold(), manifest.region);
println!(" {} {}", "Has Thumbnail:".bold(), manifest.has_thumbnail);
if let Some(version) = &manifest.version {
println!(" {} {}", "Version:".bold(), version);
}
if !manifest.derivatives.is_empty() {
println!("\n{}", "Derivatives:".bold());
println!("{}", "─".repeat(60));
for derivative in &manifest.derivatives {
let status_icon = match derivative.status.as_str() {
"success" => "✓".green(),
"failed" | "timeout" => "✗".red(),
"inprogress" | "pending" => "⋯".yellow(),
_ => "?".dimmed(),
};
println!(
" {} {} {}",
status_icon,
derivative.output_type.cyan().bold(),
derivative.progress.as_deref().unwrap_or("").dimmed()
);
if let Some(name) = &derivative.name {
println!(" {} {}", "Name:".dimmed(), name);
}
for child in &derivative.children {
println!(
" {} {} ({})",
"â””".dimmed(),
child.name.as_deref().unwrap_or(&child.guid),
child.role.dimmed()
);
}
}
}
println!("{}", "─".repeat(60));
}
_ => {
output_format.write(&manifest)?;
}
}
Ok(())
}
#[derive(Serialize)]
struct DerivativeListOutput {
guid: String,
name: String,
output_type: String,
role: String,
size: Option<u64>,
size_human: Option<String>,
}
async fn list_derivatives(
client: &DerivativeClient,
urn: &str,
format_filter: Option<String>,
output_format: OutputFormat,
) -> Result<()> {
if output_format.supports_colors() {
println!("{}", "Fetching downloadable derivatives...".dimmed());
}
let mut derivatives = client.list_downloadable_derivatives(urn).await?;
if let Some(ref format) = format_filter {
derivatives = DerivativeClient::filter_by_format(&derivatives, format);
}
let outputs: Vec<DerivativeListOutput> = derivatives
.iter()
.map(|d| DerivativeListOutput {
guid: d.guid.clone(),
name: d.name.clone(),
output_type: d.output_type.clone(),
role: d.role.clone(),
size: d.size,
size_human: d.size.map(format_size),
})
.collect();
if outputs.is_empty() {
match output_format {
OutputFormat::Table => {
if format_filter.is_some() {
println!(
"{}",
"No derivatives found matching the specified format.".yellow()
);
} else {
println!("{}", "No downloadable derivatives found.".yellow());
}
}
_ => {
output_format.write(&Vec::<DerivativeListOutput>::new())?;
}
}
return Ok(());
}
match output_format {
OutputFormat::Table => {
println!("\n{}", "Downloadable Derivatives:".bold());
println!("{}", "─".repeat(90));
println!(
"{:<40} {:<12} {:<15} {:>10}",
"Name".bold(),
"Format".bold(),
"Role".bold(),
"Size".bold()
);
println!("{}", "─".repeat(90));
for d in &outputs {
let size_str = d.size_human.as_deref().unwrap_or("-");
println!(
"{:<40} {:<12} {:<15} {:>10}",
truncate_str(&d.name, 40).cyan(),
d.output_type,
d.role.dimmed(),
size_str
);
}
println!("{}", "─".repeat(90));
println!(
"\n{}",
"Tip: Use 'raps translate download <urn> --format <format>' to download".dimmed()
);
}
_ => {
output_format.write(&outputs)?;
}
}
Ok(())
}
#[derive(Serialize)]
struct DownloadResultOutput {
success: bool,
downloaded: Vec<DownloadedFile>,
total_size: u64,
total_size_human: String,
}
#[derive(Serialize)]
struct DownloadedFile {
name: String,
size: u64,
size_human: String,
path: String,
}
async fn download_derivatives(
client: &DerivativeClient,
urn: &str,
format_filter: Option<String>,
guid_filter: Option<String>,
output_dir: Option<PathBuf>,
all: bool,
output_format: OutputFormat,
) -> Result<()> {
if format_filter.is_none() && guid_filter.is_none() && !all {
anyhow::bail!(
"Please specify --format, --guid, or --all to select derivatives to download"
);
}
if output_format.supports_colors() {
println!("{}", "Fetching downloadable derivatives...".dimmed());
}
let derivatives = client.list_downloadable_derivatives(urn).await?;
if derivatives.is_empty() {
anyhow::bail!("No downloadable derivatives found in manifest");
}
let to_download: Vec<_> = if let Some(ref guid) = guid_filter {
match DerivativeClient::filter_by_guid(&derivatives, guid) {
Some(d) => vec![d],
None => anyhow::bail!("No derivative found with GUID '{}'", guid),
}
} else if let Some(ref format) = format_filter {
let filtered = DerivativeClient::filter_by_format(&derivatives, format);
if filtered.is_empty() {
anyhow::bail!("No derivatives found with format '{}'", format);
}
filtered
} else {
derivatives
};
let output_path = output_dir.unwrap_or_else(|| PathBuf::from("."));
if !output_path.exists() {
tokio::fs::create_dir_all(&output_path).await?;
}
if output_format.supports_colors() {
println!(
"{} {} derivatives to {}",
"Downloading".dimmed(),
to_download.len().to_string().cyan(),
output_path.display().to_string().cyan()
);
}
let mut downloaded_files = Vec::new();
let mut total_size: u64 = 0;
for derivative in to_download {
let file_path = output_path.join(&derivative.name);
match client
.download_derivative(urn, &derivative.urn, &file_path)
.await
{
Ok(size) => {
total_size += size;
downloaded_files.push(DownloadedFile {
name: derivative.name.clone(),
size,
size_human: format_size(size),
path: file_path.display().to_string(),
});
}
Err(e) => {
eprintln!(
"{} Failed to download {}: {}",
"✗".red().bold(),
derivative.name,
e
);
}
}
}
let output = DownloadResultOutput {
success: !downloaded_files.is_empty(),
downloaded: downloaded_files,
total_size,
total_size_human: format_size(total_size),
};
match output_format {
OutputFormat::Table => {
if output.downloaded.is_empty() {
println!("{} No files were downloaded.", "✗".red().bold());
} else {
println!("\n{} Download complete!", "✓".green().bold());
println!(
" {} {} files",
"Downloaded:".bold(),
output.downloaded.len()
);
println!(" {} {}", "Total size:".bold(), output.total_size_human);
if output.downloaded.len() <= 10 {
println!("\n {}:", "Files".bold());
for file in &output.downloaded {
println!(
" {} {} ({})",
"•".cyan(),
file.name,
file.size_human.dimmed()
);
}
}
}
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
struct TranslationPreset {
name: String,
format: String,
description: Option<String>,
}
#[derive(Debug, Serialize, serde::Deserialize, Default)]
struct PresetStore {
presets: Vec<TranslationPreset>,
}
impl PresetStore {
fn file_path() -> Result<std::path::PathBuf> {
let proj_dirs = directories::ProjectDirs::from("com", "autodesk", "raps")
.context("Failed to get project directories")?;
let config_dir = proj_dirs.config_dir();
std::fs::create_dir_all(config_dir)?;
Ok(config_dir.join("presets.json"))
}
fn load() -> Result<Self> {
let path = Self::file_path()?;
if !path.exists() {
return Ok(Self::default_presets());
}
let content = std::fs::read_to_string(&path)?;
let store: Self = serde_json::from_str(&content)?;
Ok(store)
}
fn save(&self) -> Result<()> {
let path = Self::file_path()?;
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
fn default_presets() -> Self {
Self {
presets: vec![
TranslationPreset {
name: "viewer".to_string(),
format: "svf2".to_string(),
description: Some("Optimized for web viewer (SVF2)".to_string()),
},
TranslationPreset {
name: "export-3d".to_string(),
format: "obj".to_string(),
description: Some("Export as OBJ mesh for external tools".to_string()),
},
TranslationPreset {
name: "3d-print".to_string(),
format: "stl".to_string(),
description: Some("Export for 3D printing (STL)".to_string()),
},
TranslationPreset {
name: "cad-exchange".to_string(),
format: "step".to_string(),
description: Some("CAD interchange format (STEP)".to_string()),
},
TranslationPreset {
name: "bim".to_string(),
format: "ifc".to_string(),
description: Some("BIM format (IFC)".to_string()),
},
],
}
}
}
fn list_presets(output_format: OutputFormat) -> Result<()> {
let store = PresetStore::load()?;
match output_format {
OutputFormat::Table => {
println!("\n{}", "Translation Presets:".bold());
println!("{}", "─".repeat(70));
println!(
"{:<20} {:<12} {}",
"Name".bold(),
"Format".bold(),
"Description".bold()
);
println!("{}", "─".repeat(70));
for preset in &store.presets {
let desc = preset.description.as_deref().unwrap_or("-");
println!(
"{:<20} {:<12} {}",
preset.name.cyan(),
preset.format,
desc.dimmed()
);
}
println!("{}", "─".repeat(70));
println!(
"\n{}",
"Use 'raps translate preset use <urn> <preset>' to translate".dimmed()
);
}
_ => {
output_format.write(&store.presets)?;
}
}
Ok(())
}
fn show_preset(name: &str, output_format: OutputFormat) -> Result<()> {
let store = PresetStore::load()?;
let preset = store
.presets
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
.ok_or_else(|| anyhow::anyhow!("Preset '{}' not found", name))?;
match output_format {
OutputFormat::Table => {
println!("\n{}", "Preset Details:".bold());
println!("{}", "─".repeat(50));
println!(" {} {}", "Name:".bold(), preset.name.cyan());
println!(" {} {}", "Format:".bold(), preset.format);
if let Some(ref desc) = preset.description {
println!(" {} {}", "Description:".bold(), desc);
}
println!("{}", "─".repeat(50));
}
_ => {
output_format.write(preset)?;
}
}
Ok(())
}
fn create_preset(
name: &str,
format: &str,
description: Option<String>,
output_format: OutputFormat,
) -> Result<()> {
if DerivativeOutputFormat::from_str(format).is_err() {
anyhow::bail!(
"Invalid format '{}'. Valid formats: svf2, svf, obj, stl, step, iges, ifc, thumbnail",
format
);
}
let mut store = PresetStore::load()?;
if store
.presets
.iter()
.any(|p| p.name.eq_ignore_ascii_case(name))
{
anyhow::bail!("Preset '{}' already exists", name);
}
let preset = TranslationPreset {
name: name.to_string(),
format: format.to_string(),
description,
};
store.presets.push(preset.clone());
store.save()?;
match output_format {
OutputFormat::Table => {
println!("{} Preset '{}' created!", "✓".green().bold(), name.cyan());
}
_ => {
output_format.write(&preset)?;
}
}
Ok(())
}
fn delete_preset(name: &str, output_format: OutputFormat) -> Result<()> {
let mut store = PresetStore::load()?;
let initial_len = store.presets.len();
store.presets.retain(|p| !p.name.eq_ignore_ascii_case(name));
if store.presets.len() == initial_len {
anyhow::bail!("Preset '{}' not found", name);
}
store.save()?;
#[derive(Serialize)]
struct DeleteOutput {
success: bool,
name: String,
}
let output = DeleteOutput {
success: true,
name: name.to_string(),
};
match output_format {
OutputFormat::Table => {
println!("{} Preset '{}' deleted!", "✓".green().bold(), name);
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
async fn use_preset(
client: &DerivativeClient,
urn: &str,
preset_name: &str,
output_format: OutputFormat,
) -> Result<()> {
let store = PresetStore::load()?;
let preset = store
.presets
.iter()
.find(|p| p.name.eq_ignore_ascii_case(preset_name))
.ok_or_else(|| anyhow::anyhow!("Preset '{}' not found", preset_name))?;
let format = DerivativeOutputFormat::from_str(&preset.format)
.map_err(|_| anyhow::anyhow!("Invalid format in preset: {}", preset.format))?;
if output_format.supports_colors() {
println!(
"{} Using preset: {} ({})",
"→".cyan(),
preset.name.bold(),
preset.format
);
}
let response = client.translate(urn, format, None).await?;
#[derive(Serialize)]
struct UsePresetOutput {
success: bool,
preset: String,
format: String,
urn: String,
result: String,
}
let output = UsePresetOutput {
success: response.result == "created" || response.result == "success",
preset: preset.name.clone(),
format: preset.format.clone(),
urn: response.urn.clone(),
result: response.result.clone(),
};
match output_format {
OutputFormat::Table => {
println!(
"{} Translation started with preset '{}'!",
"✓".green().bold(),
preset.name
);
println!(" {} {}", "Format:".bold(), output.format.cyan());
println!(" {} {}", "URN:".bold(), output.urn.dimmed());
println!(
"\n{}",
"Use 'raps translate status <urn> --wait' to monitor progress".dimmed()
);
}
_ => {
output_format.write(&output)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_translation_preset_deserialization() {
let json = r#"{
"name": "test-preset",
"format": "svf2",
"description": "Test preset"
}"#;
let preset: TranslationPreset = serde_json::from_str(json).unwrap();
assert_eq!(preset.name, "test-preset");
assert_eq!(preset.format, "svf2");
assert_eq!(preset.description, Some("Test preset".to_string()));
}
#[test]
fn test_preset_store_default_presets() {
let store = PresetStore::default_presets();
assert!(!store.presets.is_empty());
let preset_names: Vec<&str> = store.presets.iter().map(|p| p.name.as_str()).collect();
assert!(preset_names.contains(&"viewer"));
assert!(preset_names.contains(&"3d-print"));
assert!(preset_names.contains(&"cad-exchange"));
}
#[test]
fn test_preset_store_serialization() {
let store = PresetStore {
presets: vec![TranslationPreset {
name: "custom".to_string(),
format: "obj".to_string(),
description: Some("Custom preset".to_string()),
}],
};
let json = serde_json::to_string(&store).unwrap();
let parsed: PresetStore = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.presets.len(), 1);
assert_eq!(parsed.presets[0].name, "custom");
}
#[test]
fn test_format_size() {
assert_eq!(format_size(0), "0 B");
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1024), "1.00 KB");
assert_eq!(format_size(1536), "1.50 KB");
assert_eq!(format_size(1024 * 1024), "1.00 MB");
assert_eq!(format_size(1024 * 1024 * 1024), "1.00 GB");
}
#[test]
fn test_truncate_str() {
assert_eq!(truncate_str("short", 10), "short");
assert_eq!(truncate_str("exactly10!", 10), "exactly10!");
assert_eq!(truncate_str("this is too long", 10), "this is...");
}
}