use super::build_api_client;
use crate::{
api,
config::Config,
models::{Tag, TagColor, TagCreateBuilder, TagListParams, TagUpdateBuilder},
output::OutputFormat,
};
use anyhow::{Context, Result, anyhow};
use clap::{Args, Subcommand, ValueEnum};
use colored::Colorize;
use std::io::{IsTerminal, stdout};
use tokio::runtime::Builder as RuntimeBuilder;
#[derive(Subcommand, Debug)]
pub enum TagCommand {
List(TagListArgs),
Show(TagShowArgs),
Create(TagCreateArgs),
Update(TagUpdateArgs),
Delete(TagDeleteArgs),
}
#[derive(Args, Debug)]
pub struct TagListArgs {
#[arg(long)]
pub workspace: Option<String>,
#[arg(long)]
pub limit: Option<usize>,
#[arg(long, value_enum)]
pub output: Option<OutputFormat>,
}
#[derive(Args, Debug)]
pub struct TagShowArgs {
#[arg(value_name = "TAG")]
pub tag: String,
#[arg(long, value_enum)]
pub output: Option<OutputFormat>,
}
#[derive(Args, Debug)]
pub struct TagCreateArgs {
#[arg(long)]
pub name: String,
#[arg(long)]
pub workspace: Option<String>,
#[arg(long, value_enum)]
pub color: Option<ColorArg>,
#[arg(long)]
pub notes: Option<String>,
#[arg(long, value_enum)]
pub output: Option<OutputFormat>,
}
#[derive(Args, Debug)]
pub struct TagUpdateArgs {
#[arg(value_name = "TAG")]
pub tag: String,
#[arg(long)]
pub name: Option<String>,
#[arg(long, value_enum)]
pub color: Option<ColorArg>,
#[arg(long)]
pub notes: Option<String>,
#[arg(long = "clear-notes")]
pub clear_notes: bool,
#[arg(long, value_enum)]
pub output: Option<OutputFormat>,
}
#[derive(Args, Debug)]
pub struct TagDeleteArgs {
#[arg(value_name = "TAG")]
pub tag: String,
#[arg(long)]
pub yes: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum ColorArg {
DarkBlue,
DarkBrown,
DarkGreen,
DarkOrange,
DarkPink,
DarkPurple,
DarkRed,
DarkTeal,
DarkWarmGray,
LightBlue,
LightBrown,
LightGreen,
LightOrange,
LightPink,
LightPurple,
LightRed,
LightTeal,
LightWarmGray,
}
impl ColorArg {
#[must_use]
pub fn as_slug(self) -> &'static str {
match self {
Self::DarkBlue => "dark-blue",
Self::DarkBrown => "dark-brown",
Self::DarkGreen => "dark-green",
Self::DarkOrange => "dark-orange",
Self::DarkPink => "dark-pink",
Self::DarkPurple => "dark-purple",
Self::DarkRed => "dark-red",
Self::DarkTeal => "dark-teal",
Self::DarkWarmGray => "dark-warm-gray",
Self::LightBlue => "light-blue",
Self::LightBrown => "light-brown",
Self::LightGreen => "light-green",
Self::LightOrange => "light-orange",
Self::LightPink => "light-pink",
Self::LightPurple => "light-purple",
Self::LightRed => "light-red",
Self::LightTeal => "light-teal",
Self::LightWarmGray => "light-warm-gray",
}
}
}
impl From<ColorArg> for TagColor {
fn from(arg: ColorArg) -> Self {
match arg {
ColorArg::DarkBlue => Self::DarkBlue,
ColorArg::DarkBrown => Self::DarkBrown,
ColorArg::DarkGreen => Self::DarkGreen,
ColorArg::DarkOrange => Self::DarkOrange,
ColorArg::DarkPink => Self::DarkPink,
ColorArg::DarkPurple => Self::DarkPurple,
ColorArg::DarkRed => Self::DarkRed,
ColorArg::DarkTeal => Self::DarkTeal,
ColorArg::DarkWarmGray => Self::DarkWarmGray,
ColorArg::LightBlue => Self::LightBlue,
ColorArg::LightBrown => Self::LightBrown,
ColorArg::LightGreen => Self::LightGreen,
ColorArg::LightOrange => Self::LightOrange,
ColorArg::LightPink => Self::LightPink,
ColorArg::LightPurple => Self::LightPurple,
ColorArg::LightRed => Self::LightRed,
ColorArg::LightTeal => Self::LightTeal,
ColorArg::LightWarmGray => Self::LightWarmGray,
}
}
}
fn determine_output(value: Option<OutputFormat>) -> OutputFormat {
value.unwrap_or_else(|| {
if stdout().is_terminal() {
OutputFormat::Table
} else {
OutputFormat::Json
}
})
}
pub fn handle_tag_command(command: TagCommand, config: &Config) -> Result<()> {
let runtime = RuntimeBuilder::new_current_thread()
.enable_all()
.build()
.context("failed to initialise async runtime")?;
match command {
TagCommand::List(args) => runtime.block_on(handle_tag_list(args, config)),
TagCommand::Show(args) => runtime.block_on(handle_tag_show(args, config)),
TagCommand::Create(args) => runtime.block_on(handle_tag_create(args, config)),
TagCommand::Update(args) => runtime.block_on(handle_tag_update(args, config)),
TagCommand::Delete(args) => runtime.block_on(handle_tag_delete(args, config)),
}
}
async fn handle_tag_list(args: TagListArgs, config: &Config) -> Result<()> {
let client = build_api_client(config)?;
let workspace = args
.workspace
.or_else(|| config.default_workspace().map(String::from))
.ok_or_else(|| anyhow!("workspace is required; provide --workspace or set a default"))?;
let params = TagListParams {
workspace,
limit: args.limit,
};
let tags = api::list_tags(&client, params)
.await
.context("failed to list tags")?;
let format = determine_output(args.output);
match format {
OutputFormat::Table => render_tag_table(&tags),
OutputFormat::Markdown => {
println!("| GID | Name | Color | Workspace |");
println!("|---|---|---|---|");
for tag in &tags {
let color_str = tag.color.map_or_else(|| String::from("none"), format_color);
let workspace_name = tag
.workspace
.as_ref()
.and_then(|ws| ws.name.as_deref())
.unwrap_or("unknown");
println!(
"| {} | {} | {} | {} |",
tag.gid, tag.name, color_str, workspace_name
);
}
}
OutputFormat::Csv => {
println!("gid,name,color,workspace");
for tag in &tags {
let color_str = tag.color.map_or_else(|| String::from("none"), format_color);
let workspace_name = tag
.workspace
.as_ref()
.and_then(|ws| ws.name.as_deref())
.unwrap_or("");
println!("{},{},{},{}", tag.gid, tag.name, color_str, workspace_name);
}
}
OutputFormat::Json => {
let json =
serde_json::to_string_pretty(&tags).context("failed to serialize tags to JSON")?;
println!("{json}");
}
}
Ok(())
}
async fn handle_tag_show(args: TagShowArgs, config: &Config) -> Result<()> {
let client = build_api_client(config)?;
let tag = api::get_tag(&client, &args.tag)
.await
.context("failed to retrieve tag")?;
let format = determine_output(args.output);
match format {
OutputFormat::Json => {
let json =
serde_json::to_string_pretty(&tag).context("failed to serialize tag to JSON")?;
println!("{json}");
}
_ => render_tag_detail(&tag),
}
Ok(())
}
async fn handle_tag_create(args: TagCreateArgs, config: &Config) -> Result<()> {
let client = build_api_client(config)?;
let workspace = args
.workspace
.or_else(|| config.default_workspace().map(String::from))
.ok_or_else(|| anyhow!("workspace is required; provide --workspace or set a default"))?;
let mut builder = TagCreateBuilder::new(&args.name, workspace);
if let Some(color) = args.color {
builder = builder.color(color.into());
}
if let Some(notes) = args.notes {
builder = builder.notes(notes);
}
let request = builder
.build()
.context("failed to build tag create request")?;
let tag = api::create_tag(&client, request)
.await
.context("failed to create tag")?;
let format = determine_output(args.output);
if matches!(format, OutputFormat::Json) {
let json = serde_json::to_string_pretty(&tag).context("failed to serialize tag to JSON")?;
println!("{json}");
} else {
println!("{}", "Tag created successfully:".green().bold());
render_tag_detail(&tag);
}
Ok(())
}
async fn handle_tag_update(args: TagUpdateArgs, config: &Config) -> Result<()> {
let client = build_api_client(config)?;
let mut builder = TagUpdateBuilder::new();
if let Some(name) = args.name {
builder = builder.name(name);
}
if let Some(color) = args.color {
builder = builder.color(color.into());
}
if args.clear_notes {
builder = builder.clear_notes();
} else if let Some(notes) = args.notes {
builder = builder.notes(notes);
}
let request = builder
.build()
.context("failed to build tag update request")?;
let tag = api::update_tag(&client, &args.tag, request)
.await
.context("failed to update tag")?;
let format = determine_output(args.output);
if matches!(format, OutputFormat::Json) {
let json = serde_json::to_string_pretty(&tag).context("failed to serialize tag to JSON")?;
println!("{json}");
} else {
println!("{}", "Tag updated successfully:".green().bold());
render_tag_detail(&tag);
}
Ok(())
}
async fn handle_tag_delete(args: TagDeleteArgs, config: &Config) -> Result<()> {
let client = build_api_client(config)?;
if !args.yes {
let tag = api::get_tag(&client, &args.tag)
.await
.context("failed to retrieve tag")?;
println!("{}", "Tag to be deleted:".yellow().bold());
println!(" GID: {}", tag.gid);
println!(" Name: {}", tag.name);
if !confirm_deletion()? {
println!("Deletion cancelled.");
return Ok(());
}
}
api::delete_tag(&client, &args.tag)
.await
.context("failed to delete tag")?;
println!("{}", "Tag deleted successfully.".green().bold());
Ok(())
}
fn render_tag_table(tags: &[Tag]) {
if tags.is_empty() {
println!("No tags found.");
return;
}
let is_tty = stdout().is_terminal();
if is_tty {
println!(
"{:<20} {:<30} {:<15} {}",
"GID".bold(),
"Name".bold(),
"Color".bold(),
"Workspace".bold()
);
println!("{}", "─".repeat(80));
}
for tag in tags {
let color_str = tag.color.map_or_else(|| String::from("none"), format_color);
let workspace_name = tag
.workspace
.as_ref()
.and_then(|ws| ws.name.as_deref())
.unwrap_or("unknown");
if is_tty {
println!(
"{:<20} {:<30} {:<15} {}",
tag.gid, tag.name, color_str, workspace_name
);
} else {
println!(
"{}\t{}\t{}\t{}",
tag.gid, tag.name, color_str, workspace_name
);
}
}
if is_tty {
println!("\n{} tags listed.", tags.len());
}
}
fn render_tag_detail(tag: &Tag) {
let is_tty = stdout().is_terminal();
if is_tty {
println!("{}", "Tag Details".bold().underline());
println!(" {}: {}", "GID".bold(), tag.gid);
println!(" {}: {}", "Name".bold(), tag.name);
if let Some(color) = &tag.color {
println!(" {}: {}", "Color".bold(), format_color(*color));
}
if let Some(notes) = &tag.notes
&& !notes.is_empty()
{
println!(" {}: {}", "Notes".bold(), notes);
}
if let Some(workspace) = &tag.workspace {
println!(
" {}: {} ({})",
"Workspace".bold(),
workspace.label(),
workspace.gid
);
}
if !tag.followers.is_empty() {
println!(" {}:", "Followers".bold());
for follower in &tag.followers {
println!(" - {}", follower.label());
}
}
if let Some(created_at) = &tag.created_at {
println!(" {}: {}", "Created".bold(), created_at);
}
if let Some(url) = &tag.permalink_url {
println!(" {}: {}", "URL".bold(), url);
}
} else {
println!("GID: {}", tag.gid);
println!("Name: {}", tag.name);
if let Some(color) = &tag.color {
println!("Color: {}", format_color(*color));
}
if let Some(notes) = &tag.notes
&& !notes.is_empty()
{
println!("Notes: {notes}");
}
if let Some(workspace) = &tag.workspace {
println!("Workspace: {} ({})", workspace.label(), workspace.gid);
}
if !tag.followers.is_empty() {
println!("Followers:");
for follower in &tag.followers {
println!(" {}", follower.label());
}
}
if let Some(created_at) = &tag.created_at {
println!("Created: {created_at}");
}
if let Some(url) = &tag.permalink_url {
println!("URL: {url}");
}
}
}
fn format_color(color: TagColor) -> String {
match color {
TagColor::DarkBlue => "dark-blue".to_string(),
TagColor::DarkBrown => "dark-brown".to_string(),
TagColor::DarkGreen => "dark-green".to_string(),
TagColor::DarkOrange => "dark-orange".to_string(),
TagColor::DarkPink => "dark-pink".to_string(),
TagColor::DarkPurple => "dark-purple".to_string(),
TagColor::DarkRed => "dark-red".to_string(),
TagColor::DarkTeal => "dark-teal".to_string(),
TagColor::DarkWarmGray => "dark-warm-gray".to_string(),
TagColor::LightBlue => "light-blue".to_string(),
TagColor::LightBrown => "light-brown".to_string(),
TagColor::LightGreen => "light-green".to_string(),
TagColor::LightOrange => "light-orange".to_string(),
TagColor::LightPink => "light-pink".to_string(),
TagColor::LightPurple => "light-purple".to_string(),
TagColor::LightRed => "light-red".to_string(),
TagColor::LightTeal => "light-teal".to_string(),
TagColor::LightWarmGray => "light-warm-gray".to_string(),
TagColor::Unknown => "unknown".to_string(),
}
}
fn confirm_deletion() -> Result<bool> {
use std::io::{self, Write};
print!("Are you sure you want to delete this tag? [y/N] ");
io::stdout().flush().context("failed to flush stdout")?;
let mut response = String::new();
io::stdin()
.read_line(&mut response)
.context("failed to read user input")?;
let response = response.trim().to_lowercase();
Ok(response == "y" || response == "yes")
}