Files
attune/crates/cli/src/commands/artifact.rs
David Culbreth 87d830f952
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 30s
CI / Web Blocking Checks (push) Successful in 48s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 1m55s
CI / Web Advisory Checks (push) Successful in 35s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Successful in 8m5s
[wip] cli capability parity
2026-03-06 16:58:50 -06:00

1300 lines
38 KiB
Rust

use anyhow::Result;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::path::Path;
use crate::client::ApiClient;
use crate::config::CliConfig;
use crate::output::{self, OutputFormat};
#[derive(Subcommand)]
pub enum ArtifactCommands {
/// List artifacts with optional filters
List {
/// Filter by owner scope type (system, identity, pack, action, sensor)
#[arg(long)]
scope: Option<String>,
/// Filter by owner identifier
#[arg(long)]
owner: Option<String>,
/// Filter by artifact type (file_binary, file_datatable, file_image, file_text, other, progress, url)
#[arg(long, name = "type")]
artifact_type: Option<String>,
/// Filter by visibility (public, private)
#[arg(long)]
visibility: Option<String>,
/// Filter by execution ID
#[arg(long)]
execution: Option<i64>,
/// Search by name (case-insensitive substring match)
#[arg(long)]
name: Option<String>,
/// Page number
#[arg(long, default_value = "1")]
page: u32,
/// Items per page
#[arg(long, default_value = "50")]
per_page: u32,
},
/// Show details of a specific artifact
Show {
/// Artifact ID or ref
artifact: String,
},
/// Create a new artifact
Create {
/// Artifact reference (unique identifier, e.g. "mypack.build_log")
#[arg(long)]
r#ref: String,
/// Owner scope type (system, identity, pack, action, sensor)
#[arg(long, default_value = "action")]
scope: String,
/// Owner identifier (ref string of the owning entity)
#[arg(long)]
owner: String,
/// Artifact type (file_binary, file_datatable, file_image, file_text, other, progress, url)
#[arg(long, name = "type", default_value = "file_text")]
artifact_type: String,
/// Visibility (public, private)
#[arg(long)]
visibility: Option<String>,
/// Retention policy (versions, days, hours, minutes)
#[arg(long, default_value = "versions")]
retention_policy: Option<String>,
/// Retention limit
#[arg(long, default_value = "5")]
retention_limit: Option<i32>,
/// Human-readable name
#[arg(long)]
name: Option<String>,
/// Description
#[arg(long)]
description: Option<String>,
/// MIME content type
#[arg(long)]
content_type: Option<String>,
/// Execution ID to link this artifact to
#[arg(long)]
execution: Option<i64>,
},
/// Delete an artifact
Delete {
/// Artifact ID
id: i64,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
},
/// Upload a file as a new version of an artifact
Upload {
/// Artifact ID
id: i64,
/// Path to the file to upload
file: String,
/// MIME content type override (auto-detected if omitted)
#[arg(long)]
content_type: Option<String>,
/// Creator identity string
#[arg(long)]
created_by: Option<String>,
/// JSON metadata to attach to the version
#[arg(long)]
meta: Option<String>,
},
/// Download the latest version of an artifact (or a specific version)
#[command(disable_version_flag = true)]
Download {
/// Artifact ID
id: i64,
/// Specific version number to download (latest if omitted)
#[arg(short = 'V', long = "version")]
version: Option<i32>,
/// Output file path (defaults to auto-derived filename or stdout)
#[arg(short, long)]
output: Option<String>,
},
/// Manage artifact versions
#[command(subcommand)]
Version(VersionCommands),
}
#[derive(Subcommand)]
pub enum VersionCommands {
/// List versions of an artifact
List {
/// Artifact ID
artifact_id: i64,
},
/// Show details of a specific version
Show {
/// Artifact ID
artifact_id: i64,
/// Version number
version: i32,
},
/// Upload a file as a new version
Upload {
/// Artifact ID
artifact_id: i64,
/// Path to the file to upload
file: String,
/// MIME content type override
#[arg(long)]
content_type: Option<String>,
/// Creator identity string
#[arg(long)]
created_by: Option<String>,
/// JSON metadata to attach to the version
#[arg(long)]
meta: Option<String>,
},
/// Create a JSON content version
CreateJson {
/// Artifact ID
artifact_id: i64,
/// JSON content (as a string)
content: String,
/// MIME content type (defaults to application/json)
#[arg(long)]
content_type: Option<String>,
/// Creator identity string
#[arg(long)]
created_by: Option<String>,
/// JSON metadata to attach to the version
#[arg(long)]
meta: Option<String>,
},
/// Download a specific version
#[command(disable_version_flag = true)]
Download {
/// Artifact ID
artifact_id: i64,
/// Version number
version: i32,
/// Output file path (defaults to auto-derived filename or stdout)
#[arg(short, long)]
output: Option<String>,
},
/// Delete a specific version
#[command(disable_version_flag = true)]
Delete {
/// Artifact ID
artifact_id: i64,
/// Version number
version: i32,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
},
}
// ── Response / request types used for (de)serialization against the API ────
#[derive(Debug, Serialize, Deserialize)]
struct ArtifactResponse {
id: i64,
#[serde(rename = "ref")]
artifact_ref: String,
scope: String,
owner: String,
r#type: String,
visibility: String,
retention_policy: String,
retention_limit: i32,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
size_bytes: Option<i64>,
#[serde(default)]
execution: Option<i64>,
#[serde(default)]
data: Option<JsonValue>,
created: String,
updated: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ArtifactSummary {
id: i64,
#[serde(rename = "ref")]
artifact_ref: String,
r#type: String,
visibility: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
size_bytes: Option<i64>,
#[serde(default)]
execution: Option<i64>,
scope: String,
owner: String,
created: String,
updated: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct VersionResponse {
id: i64,
artifact: i64,
version: i32,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
size_bytes: Option<i64>,
#[serde(default)]
content_json: Option<JsonValue>,
#[serde(default)]
file_path: Option<String>,
#[serde(default)]
meta: Option<JsonValue>,
#[serde(default)]
created_by: Option<String>,
created: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct VersionSummary {
id: i64,
version: i32,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
size_bytes: Option<i64>,
#[serde(default)]
file_path: Option<String>,
#[serde(default)]
created_by: Option<String>,
created: String,
}
#[derive(Debug, Serialize)]
struct CreateArtifactBody {
r#ref: String,
scope: String,
owner: String,
r#type: String,
#[serde(skip_serializing_if = "Option::is_none")]
visibility: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
retention_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
retention_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
execution: Option<i64>,
}
#[derive(Debug, Serialize)]
struct CreateVersionJsonBody {
content: JsonValue,
#[serde(skip_serializing_if = "Option::is_none")]
content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
meta: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
created_by: Option<String>,
}
// ── Command dispatch ───────────────────────────────────────────────────────
pub async fn handle_artifact_command(
profile: &Option<String>,
command: ArtifactCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
ArtifactCommands::List {
scope,
owner,
artifact_type,
visibility,
execution,
name,
page,
per_page,
} => {
handle_list(
profile,
scope,
owner,
artifact_type,
visibility,
execution,
name,
page,
per_page,
api_url,
output_format,
)
.await
}
ArtifactCommands::Show { artifact } => {
handle_show(profile, artifact, api_url, output_format).await
}
ArtifactCommands::Create {
r#ref,
scope,
owner,
artifact_type,
visibility,
retention_policy,
retention_limit,
name,
description,
content_type,
execution,
} => {
handle_create(
profile,
r#ref,
scope,
owner,
artifact_type,
visibility,
retention_policy,
retention_limit,
name,
description,
content_type,
execution,
api_url,
output_format,
)
.await
}
ArtifactCommands::Delete { id, yes } => {
handle_delete(profile, id, yes, api_url, output_format).await
}
ArtifactCommands::Upload {
id,
file,
content_type,
created_by,
meta,
} => {
handle_upload(
profile,
id,
file,
content_type,
created_by,
meta,
api_url,
output_format,
)
.await
}
ArtifactCommands::Download {
id,
version,
output,
} => handle_download(profile, id, version, output, api_url, output_format).await,
ArtifactCommands::Version(version_cmd) => {
handle_version_command(profile, version_cmd, api_url, output_format).await
}
}
}
async fn handle_version_command(
profile: &Option<String>,
command: VersionCommands,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
match command {
VersionCommands::List { artifact_id } => {
handle_version_list(profile, artifact_id, api_url, output_format).await
}
VersionCommands::Show {
artifact_id,
version,
} => handle_version_show(profile, artifact_id, version, api_url, output_format).await,
VersionCommands::Upload {
artifact_id,
file,
content_type,
created_by,
meta,
} => {
handle_upload(
profile,
artifact_id,
file,
content_type,
created_by,
meta,
api_url,
output_format,
)
.await
}
VersionCommands::CreateJson {
artifact_id,
content,
content_type,
created_by,
meta,
} => {
handle_version_create_json(
profile,
artifact_id,
content,
content_type,
created_by,
meta,
api_url,
output_format,
)
.await
}
VersionCommands::Download {
artifact_id,
version,
output,
} => {
handle_download(
profile,
artifact_id,
Some(version),
output,
api_url,
output_format,
)
.await
}
VersionCommands::Delete {
artifact_id,
version,
yes,
} => {
handle_version_delete(profile, artifact_id, version, yes, api_url, output_format).await
}
}
}
// ── Handlers ───────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
async fn handle_list(
profile: &Option<String>,
scope: Option<String>,
owner: Option<String>,
artifact_type: Option<String>,
visibility: Option<String>,
execution: Option<i64>,
name: Option<String>,
page: u32,
per_page: u32,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let mut query_params = vec![format!("page={}", page), format!("per_page={}", per_page)];
if let Some(s) = scope {
query_params.push(format!("scope={}", s));
}
if let Some(o) = owner {
query_params.push(format!("owner={}", urlencoding::encode(&o)));
}
if let Some(t) = artifact_type {
query_params.push(format!("type={}", t));
}
if let Some(v) = visibility {
query_params.push(format!("visibility={}", v));
}
if let Some(e) = execution {
query_params.push(format!("execution={}", e));
}
if let Some(n) = name {
query_params.push(format!("name={}", urlencoding::encode(&n)));
}
let path = format!("/artifacts?{}", query_params.join("&"));
let artifacts: Vec<ArtifactSummary> = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&artifacts, output_format)?;
}
OutputFormat::Table => {
if artifacts.is_empty() {
output::print_info("No artifacts found");
} else {
let mut table = output::create_table();
output::add_header(
&mut table,
vec![
"ID",
"Ref",
"Name",
"Type",
"Visibility",
"Size",
"Execution",
"Created",
],
);
for artifact in &artifacts {
table.add_row(vec![
artifact.id.to_string(),
artifact.artifact_ref.clone(),
artifact.name.clone().unwrap_or_else(|| "-".to_string()),
artifact.r#type.clone(),
artifact.visibility.clone(),
format_size(artifact.size_bytes),
artifact
.execution
.map(|e| e.to_string())
.unwrap_or_else(|| "-".to_string()),
output::format_timestamp(&artifact.created),
]);
}
println!("{}", table);
output::print_info(&format!("{} artifact(s)", artifacts.len()));
}
}
}
Ok(())
}
async fn handle_show(
profile: &Option<String>,
artifact: String,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
// Try to parse as i64 (ID), otherwise treat as ref
let path = if let Ok(id) = artifact.parse::<i64>() {
format!("/artifacts/{}", id)
} else {
format!("/artifacts/ref/{}", urlencoding::encode(&artifact))
};
let artifact_resp: ArtifactResponse = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&artifact_resp, output_format)?;
}
OutputFormat::Table => {
output::print_section(&format!("Artifact: {}", artifact_resp.artifact_ref));
let mut pairs = vec![
("ID", artifact_resp.id.to_string()),
("Reference", artifact_resp.artifact_ref.clone()),
(
"Name",
artifact_resp
.name
.clone()
.unwrap_or_else(|| "-".to_string()),
),
("Type", artifact_resp.r#type.clone()),
("Visibility", artifact_resp.visibility.clone()),
("Scope", artifact_resp.scope.clone()),
("Owner", artifact_resp.owner.clone()),
(
"Retention",
format!(
"{} (limit: {})",
artifact_resp.retention_policy, artifact_resp.retention_limit
),
),
(
"Content Type",
artifact_resp
.content_type
.clone()
.unwrap_or_else(|| "-".to_string()),
),
("Size", format_size(artifact_resp.size_bytes)),
(
"Execution",
artifact_resp
.execution
.map(|e| e.to_string())
.unwrap_or_else(|| "-".to_string()),
),
];
if let Some(ref desc) = artifact_resp.description {
pairs.push(("Description", desc.clone()));
}
if let Some(ref data) = artifact_resp.data {
let data_str =
serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string());
pairs.push(("Data", output::truncate(&data_str, 200)));
}
pairs.push(("Created", output::format_timestamp(&artifact_resp.created)));
pairs.push(("Updated", output::format_timestamp(&artifact_resp.updated)));
output::print_key_value_table(pairs);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_create(
profile: &Option<String>,
artifact_ref: String,
scope: String,
owner: String,
artifact_type: String,
visibility: Option<String>,
retention_policy: Option<String>,
retention_limit: Option<i32>,
name: Option<String>,
description: Option<String>,
content_type: Option<String>,
execution: Option<i64>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let request = CreateArtifactBody {
r#ref: artifact_ref,
scope,
owner,
r#type: artifact_type,
visibility,
retention_policy,
retention_limit,
name,
description,
content_type,
execution,
};
let artifact: ArtifactResponse = client.post("/artifacts", &request).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&artifact, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!(
"Artifact '{}' created successfully",
artifact.artifact_ref
));
output::print_key_value_table(vec![
("ID", artifact.id.to_string()),
("Reference", artifact.artifact_ref.clone()),
(
"Name",
artifact.name.clone().unwrap_or_else(|| "-".to_string()),
),
("Type", artifact.r#type.clone()),
("Visibility", artifact.visibility.clone()),
("Scope", artifact.scope.clone()),
("Owner", artifact.owner.clone()),
("Created", output::format_timestamp(&artifact.created)),
]);
}
}
Ok(())
}
async fn handle_delete(
profile: &Option<String>,
id: i64,
yes: bool,
api_url: &Option<String>,
_output_format: OutputFormat,
) -> Result<()> {
if !yes {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!(
"Delete artifact with ID {}? This cannot be undone",
id
))
.default(false)
.interact()?;
if !confirm {
output::print_info("Deletion cancelled");
return Ok(());
}
}
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/artifacts/{}", id);
client.delete_no_response(&path).await?;
output::print_success(&format!("Artifact {} deleted successfully", id));
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_upload(
profile: &Option<String>,
id: i64,
file: String,
content_type: Option<String>,
created_by: Option<String>,
meta: Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let file_path = Path::new(&file);
if !file_path.exists() {
anyhow::bail!("File not found: {}", file);
}
if !file_path.is_file() {
anyhow::bail!("Not a file: {}", file);
}
let file_bytes = tokio::fs::read(file_path).await?;
let file_name = file_path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "upload".to_string());
let mime = content_type
.clone()
.unwrap_or_else(|| guess_mime_type(&file_name));
let mut extra_fields: Vec<(&str, String)> = Vec::new();
if let Some(ref ct) = content_type {
extra_fields.push(("content_type", ct.clone()));
}
if let Some(ref cb) = created_by {
extra_fields.push(("created_by", cb.clone()));
}
if let Some(ref m) = meta {
// Validate it's valid JSON
serde_json::from_str::<JsonValue>(m)
.map_err(|e| anyhow::anyhow!("Invalid meta JSON: {}", e))?;
extra_fields.push(("meta", m.clone()));
}
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
if output_format == OutputFormat::Table {
output::print_info(&format!(
"Uploading '{}' ({}) to artifact {}...",
file_name,
format_bytes(file_bytes.len() as u64),
id,
));
}
let api_path = format!("/artifacts/{}/versions/upload", id);
let version: VersionResponse = client
.multipart_post(
&api_path,
"file",
file_bytes,
&file_name,
&mime,
extra_fields,
)
.await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&version, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!(
"Version {} uploaded successfully",
version.version
));
output::print_key_value_table(vec![
("Version ID", version.id.to_string()),
("Version Number", version.version.to_string()),
("Artifact ID", version.artifact.to_string()),
(
"Content Type",
version
.content_type
.clone()
.unwrap_or_else(|| "-".to_string()),
),
("Size", format_size(version.size_bytes)),
(
"Created By",
version
.created_by
.clone()
.unwrap_or_else(|| "-".to_string()),
),
("Created", output::format_timestamp(&version.created)),
]);
}
}
Ok(())
}
async fn handle_download(
profile: &Option<String>,
id: i64,
version: Option<i32>,
output_path: Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = match version {
Some(v) => format!("/artifacts/{}/versions/{}/download", id, v),
None => format!("/artifacts/{}/download", id),
};
let (bytes, content_type, server_filename) = client.download_bytes(&path).await?;
// Determine output destination
let dest = if let Some(ref out) = output_path {
out.clone()
} else if let Some(ref sf) = server_filename {
sf.clone()
} else {
// Build a default filename
let ext = extension_from_content_type(&content_type);
match version {
Some(v) => format!("artifact_{}_v{}{}", id, v, ext),
None => format!("artifact_{}_latest{}", id, ext),
}
};
// If output is "-", write to stdout
if dest == "-" {
use std::io::Write;
std::io::stdout().write_all(&bytes)?;
} else {
tokio::fs::write(&dest, &bytes).await?;
if output_format == OutputFormat::Table {
output::print_success(&format!(
"Downloaded {} to '{}' ({})",
match version {
Some(v) => format!("version {}", v),
None => "latest version".to_string(),
},
dest,
format_bytes(bytes.len() as u64),
));
}
}
Ok(())
}
// ── Version subcommand handlers ────────────────────────────────────────────
async fn handle_version_list(
profile: &Option<String>,
artifact_id: i64,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/artifacts/{}/versions", artifact_id);
let versions: Vec<VersionSummary> = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&versions, output_format)?;
}
OutputFormat::Table => {
if versions.is_empty() {
output::print_info(&format!("No versions found for artifact {}", artifact_id));
} else {
let mut table = output::create_table();
output::add_header(
&mut table,
vec![
"ID",
"Version",
"Content Type",
"Size",
"File Path",
"Created By",
"Created",
],
);
for ver in &versions {
table.add_row(vec![
ver.id.to_string(),
format!("v{}", ver.version),
ver.content_type.clone().unwrap_or_else(|| "-".to_string()),
format_size(ver.size_bytes),
ver.file_path.clone().unwrap_or_else(|| "(db)".to_string()),
ver.created_by.clone().unwrap_or_else(|| "-".to_string()),
output::format_timestamp(&ver.created),
]);
}
println!("{}", table);
output::print_info(&format!(
"{} version(s) for artifact {}",
versions.len(),
artifact_id,
));
}
}
}
Ok(())
}
async fn handle_version_show(
profile: &Option<String>,
artifact_id: i64,
version: i32,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/artifacts/{}/versions/{}", artifact_id, version);
let ver: VersionResponse = client.get(&path).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&ver, output_format)?;
}
OutputFormat::Table => {
output::print_section(&format!(
"Version {} of Artifact {}",
ver.version, artifact_id
));
let mut pairs = vec![
("Version ID", ver.id.to_string()),
("Version Number", format!("v{}", ver.version)),
("Artifact ID", ver.artifact.to_string()),
(
"Content Type",
ver.content_type.clone().unwrap_or_else(|| "-".to_string()),
),
("Size", format_size(ver.size_bytes)),
];
if let Some(ref fp) = ver.file_path {
pairs.push(("File Path", fp.clone()));
} else {
pairs.push(("Storage", "Database".to_string()));
}
if let Some(ref cj) = ver.content_json {
let json_str = serde_json::to_string_pretty(cj).unwrap_or_else(|_| cj.to_string());
pairs.push(("JSON Content", output::truncate(&json_str, 300)));
}
if let Some(ref meta) = ver.meta {
let meta_str =
serde_json::to_string_pretty(meta).unwrap_or_else(|_| meta.to_string());
pairs.push(("Metadata", output::truncate(&meta_str, 200)));
}
pairs.push((
"Created By",
ver.created_by.clone().unwrap_or_else(|| "-".to_string()),
));
pairs.push(("Created", output::format_timestamp(&ver.created)));
output::print_key_value_table(pairs);
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_version_create_json(
profile: &Option<String>,
artifact_id: i64,
content: String,
content_type: Option<String>,
created_by: Option<String>,
meta: Option<String>,
api_url: &Option<String>,
output_format: OutputFormat,
) -> Result<()> {
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let content_json: JsonValue = serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("Invalid JSON content: {}", e))?;
let meta_json: Option<JsonValue> = meta
.map(|m| serde_json::from_str(&m).map_err(|e| anyhow::anyhow!("Invalid meta JSON: {}", e)))
.transpose()?;
let body = CreateVersionJsonBody {
content: content_json,
content_type,
meta: meta_json,
created_by,
};
let path = format!("/artifacts/{}/versions", artifact_id);
let version: VersionResponse = client.post(&path, &body).await?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&version, output_format)?;
}
OutputFormat::Table => {
output::print_success(&format!(
"JSON version {} created successfully",
version.version
));
output::print_key_value_table(vec![
("Version ID", version.id.to_string()),
("Version Number", format!("v{}", version.version)),
("Artifact ID", version.artifact.to_string()),
(
"Content Type",
version
.content_type
.clone()
.unwrap_or_else(|| "application/json".to_string()),
),
("Size", format_size(version.size_bytes)),
("Created", output::format_timestamp(&version.created)),
]);
}
}
Ok(())
}
async fn handle_version_delete(
profile: &Option<String>,
artifact_id: i64,
version: i32,
yes: bool,
api_url: &Option<String>,
_output_format: OutputFormat,
) -> Result<()> {
if !yes {
let confirm = dialoguer::Confirm::new()
.with_prompt(format!(
"Delete version {} of artifact {}? This cannot be undone",
version, artifact_id
))
.default(false)
.interact()?;
if !confirm {
output::print_info("Deletion cancelled");
return Ok(());
}
}
let config = CliConfig::load_with_profile(profile.as_deref())?;
let mut client = ApiClient::from_config(&config, api_url);
let path = format!("/artifacts/{}/versions/{}", artifact_id, version);
client.delete_no_response(&path).await?;
output::print_success(&format!(
"Version {} of artifact {} deleted successfully",
version, artifact_id
));
Ok(())
}
// ── Utility functions ──────────────────────────────────────────────────────
/// Format an optional byte count for display
fn format_size(size_bytes: Option<i64>) -> String {
match size_bytes {
Some(b) => format_bytes(b as u64),
None => "-".to_string(),
}
}
/// Format a byte count as a human-readable string
fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
/// Guess MIME type from file extension
fn guess_mime_type(filename: &str) -> String {
let ext = Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"txt" | "log" => "text/plain",
"json" => "application/json",
"yaml" | "yml" => "application/x-yaml",
"xml" => "application/xml",
"html" | "htm" => "text/html",
"css" => "text/css",
"js" => "application/javascript",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"zip" => "application/zip",
"gz" | "gzip" => "application/gzip",
"tar" => "application/x-tar",
"csv" => "text/csv",
"py" => "text/x-python",
"rs" => "text/x-rust",
"sh" => "text/x-shellscript",
"md" => "text/markdown",
_ => "application/octet-stream",
}
.to_string()
}
/// Derive a file extension from a content type
fn extension_from_content_type(ct: &str) -> String {
// Strip parameters (e.g. "; charset=utf-8")
let base = ct.split(';').next().unwrap_or(ct).trim();
match base {
"text/plain" => ".txt",
"application/json" => ".json",
"application/x-yaml" | "text/yaml" => ".yaml",
"application/xml" | "text/xml" => ".xml",
"text/html" => ".html",
"text/css" => ".css",
"application/javascript" => ".js",
"image/png" => ".png",
"image/jpeg" => ".jpg",
"image/gif" => ".gif",
"image/svg+xml" => ".svg",
"application/pdf" => ".pdf",
"application/zip" => ".zip",
"application/gzip" => ".gz",
"text/csv" => ".csv",
"text/markdown" => ".md",
_ => ".bin",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(1048576), "1.0 MB");
assert_eq!(format_bytes(1073741824), "1.00 GB");
}
#[test]
fn test_format_size() {
assert_eq!(format_size(None), "-");
assert_eq!(format_size(Some(1024)), "1.0 KB");
}
#[test]
fn test_guess_mime_type() {
assert_eq!(guess_mime_type("test.txt"), "text/plain");
assert_eq!(guess_mime_type("data.json"), "application/json");
assert_eq!(guess_mime_type("image.png"), "image/png");
assert_eq!(guess_mime_type("archive.tar"), "application/x-tar");
assert_eq!(guess_mime_type("noext"), "application/octet-stream");
}
#[test]
fn test_extension_from_content_type() {
assert_eq!(extension_from_content_type("text/plain"), ".txt");
assert_eq!(
extension_from_content_type("text/plain; charset=utf-8"),
".txt"
);
assert_eq!(extension_from_content_type("application/json"), ".json");
assert_eq!(
extension_from_content_type("application/octet-stream"),
".bin"
);
}
}