Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ keyring.workspace = true
lumis.workspace = true
rpassword.workspace = true
serde.workspace = true
serde_json.workspace = true
textbin-client.workspace = true
toml.workspace = true
uuid.workspace = true
Expand Down
41 changes: 34 additions & 7 deletions crates/cli/src/base/create.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use crate::base::output::print_json;
use crate::settings::Settings;
use clap::{Args, ValueEnum};
use std::fs;
use std::io::{self, BufReader, IsTerminal};
use std::path::PathBuf;
use textbin_client::CreateOptions;

#[derive(Args)]
pub struct CreateArgs {
Expand All @@ -11,6 +13,14 @@ pub struct CreateArgs {
/// to read the data from the file, essentially the same as `cat <file> | textbin create`
data: Option<String>,

/// Paste title. Without one, the server takes the first non-blank line.
#[arg(long)]
title: Option<String>,

/// Link to a related ticket or incident, e.g. https://tracker.example.com/INC-2041
#[arg(long, visible_alias = "ref")]
reference: Option<String>,

/// If provided, `syntax` will be used as reference to syntax highlight the data. e.g. go, rust, json
#[arg(long, visible_alias = "ext")]
syntax: Option<String>,
Expand All @@ -22,6 +32,10 @@ pub struct CreateArgs {
/// Paste visibility.
#[arg(long, value_enum)]
visibility: Option<Visibility>,

/// Print the created paste's metadata as JSON instead of its link
#[arg(long, default_value_t = false)]
json: bool,
}

#[derive(Clone, ValueEnum)]
Expand All @@ -43,9 +57,13 @@ impl Visibility {

pub fn handle(args: &CreateArgs, settings: &Settings) -> anyhow::Result<()> {
let client = settings.client()?;
let syntax = args.syntax.as_deref();
let expires = args.expires.as_deref();
let visibility = args.visibility.as_ref().map(Visibility::as_str);
let options = CreateOptions {
title: args.title.as_deref(),
reference_url: args.reference.as_deref(),
syntax_highlight: args.syntax.as_deref(),
expires_in: args.expires.as_deref(),
visibility: args.visibility.as_ref().map(Visibility::as_str),
};

let paste = match &args.data {
Some(data) => match create_data_from_arg(data)? {
Expand All @@ -54,9 +72,9 @@ pub fn handle(args: &CreateArgs, settings: &Settings) -> anyhow::Result<()> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);

client.create_paste_stream(reader, syntax, expires, visibility)?
client.create_paste_stream(reader, &options)?
}
Data::Literal(data) => client.create_paste(data, syntax, expires, visibility)?,
Data::Literal(data) => client.create_paste(data, &options)?,
},
None => {
// If stdin is still the interactive terminal, reading from it would
Expand All @@ -67,11 +85,20 @@ pub fn handle(args: &CreateArgs, settings: &Settings) -> anyhow::Result<()> {
anyhow::bail!("provide paste data as an argument or pipe it on stdin");
}

client.create_paste_stream(io::stdin(), syntax, expires, visibility)?
client.create_paste_stream(io::stdin(), &options)?
}
};

println!("{}", client.paste_url(&paste.id));
if args.json {
return print_json(&paste);
}

// Prefer the server's own link; older servers do not send one.
let url = paste
.url
.clone()
.unwrap_or_else(|| client.paste_url(&paste.id));
println!("{url}");
Ok(())
}

Expand Down
39 changes: 38 additions & 1 deletion crates/cli/src/base/delete.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,53 @@
use crate::base::output::print_json;
use crate::settings::Settings;
use clap::Args;
use serde::Serialize;

#[derive(Args)]
pub struct DeleteArgs {
/// The identifier/UUID of the paste
id: String,

/// Delete the paste for good instead of leaving it restorable
#[arg(long, default_value_t = false)]
permanent: bool,

/// Print the outcome as JSON
#[arg(long, default_value_t = false)]
json: bool,
}

#[derive(Serialize)]
struct Deleted<'a> {
id: &'a str,
permanent: bool,
}

pub fn handle(args: &DeleteArgs, settings: &Settings) -> anyhow::Result<()> {
let client = settings.client()?;
client.delete_paste(&args.id)?;

println!("Deleted {}", client.paste_url(&args.id));
// Deleting is idempotent, so this also purges a paste that was already
// removed earlier.
if args.permanent {
client.purge_paste(&args.id)?;
}

if args.json {
return print_json(&Deleted {
id: &args.id,
permanent: args.permanent,
});
}

if args.permanent {
println!("Permanently deleted {}", args.id);
} else {
println!(
"Deleted {}. Restore it with `textbin restore {}` before the recovery window ends.",
args.id, args.id
);
}

Ok(())
}
81 changes: 81 additions & 0 deletions crates/cli/src/base/deleted.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use crate::base::output::print_json;
use crate::settings::Settings;
use clap::Args;
use textbin_client::RemovedPaste;

#[derive(Args)]
pub struct DeletedArgs {
/// Print the list as a JSON array
#[arg(long, default_value_t = false)]
json: bool,
}

pub fn handle(args: &DeletedArgs, settings: &Settings) -> anyhow::Result<()> {
let client = settings.client()?;
let pastes = client.list_removed_pastes()?;

if args.json {
return print_json(&pastes);
}

if pastes.is_empty() {
println!("Nothing deleted.");
return Ok(());
}

for paste in &pastes {
println!("{}", format_row(paste));
}

Ok(())
}

fn format_row(paste: &RemovedPaste) -> String {
let title = paste.title.as_deref().unwrap_or("Untitled paste");
let purge_after = paste
.purge_after
.as_deref()
.and_then(|timestamp| timestamp.get(..10))
.unwrap_or("unknown");

format!(
"{} {:<8} purged after {} {}",
paste.id, paste.removal_reason, purge_after, title
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn format_row_shows_id_reason_purge_date_and_title() {
let paste = RemovedPaste {
id: "p1".to_string(),
title: Some("Deploy log".to_string()),
url: None,
removal_reason: "deleted".to_string(),
removed_at: Some("2026-10-10T08:00:00.000Z".to_string()),
purge_after: Some("2026-11-09T08:00:00.000Z".to_string()),
};

assert_eq!(
format_row(&paste),
"p1 deleted purged after 2026-11-09 Deploy log"
);
}

#[test]
fn format_row_names_untitled_pastes() {
let paste = RemovedPaste {
id: "p2".to_string(),
title: None,
url: None,
removal_reason: "expired".to_string(),
removed_at: None,
purge_after: None,
};

assert!(format_row(&paste).ends_with("purged after unknown Untitled paste"));
}
}
16 changes: 14 additions & 2 deletions crates/cli/src/base/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
mod create;
mod delete;
mod deleted;
mod output;
mod restore;
mod show;

use clap::Subcommand;

use crate::base::{create::CreateArgs, delete::DeleteArgs, show::ShowArgs};
use crate::base::{
create::CreateArgs, delete::DeleteArgs, deleted::DeletedArgs, restore::RestoreArgs,
show::ShowArgs,
};
use crate::settings::Settings;

#[derive(Subcommand)]
Expand All @@ -13,14 +19,20 @@ pub enum Commands {
Show(ShowArgs),
/// Create a new paste
Create(CreateArgs),
/// Delete a paste
/// Delete a paste; it stays restorable for the workspace's recovery window
Delete(DeleteArgs),
/// List deleted and expired pastes that can still be restored
Deleted(DeletedArgs),
/// Restore a deleted or expired paste
Restore(RestoreArgs),
}

pub fn handle(command: &Commands, settings: &Settings) -> anyhow::Result<()> {
match command {
Commands::Show(args) => show::handle(args, settings),
Commands::Create(args) => create::handle(args, settings),
Commands::Delete(args) => delete::handle(args, settings),
Commands::Deleted(args) => deleted::handle(args, settings),
Commands::Restore(args) => restore::handle(args, settings),
}
}
9 changes: 9 additions & 0 deletions crates/cli/src/base/output.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
use anyhow::Result;
use serde::Serialize;

/// Prints a value as one line of JSON. Commands with `--json` write only this
/// to stdout so scripts can parse it; human messages go elsewhere or not at all.
pub fn print_json(value: &impl Serialize) -> Result<()> {
println!("{}", serde_json::to_string(value)?);
Ok(())
}
29 changes: 29 additions & 0 deletions crates/cli/src/base/restore.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use crate::base::output::print_json;
use crate::settings::Settings;
use clap::Args;

#[derive(Args)]
pub struct RestoreArgs {
/// The identifier/UUID of the deleted or expired paste
id: String,

/// Print the restored paste's metadata as JSON instead of its link
#[arg(long, default_value_t = false)]
json: bool,
}

pub fn handle(args: &RestoreArgs, settings: &Settings) -> anyhow::Result<()> {
let client = settings.client()?;
let paste = client.restore_paste(&args.id)?;

if args.json {
return print_json(&paste);
}

let url = paste
.url
.clone()
.unwrap_or_else(|| client.paste_url(&paste.id));
println!("Restored {url}");
Ok(())
}
10 changes: 10 additions & 0 deletions crates/cli/src/base/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use lumis::themes;
use std::io::{self, IsTerminal, Write};
use textbin_client::Paste;

use crate::base::output::print_json;
use crate::settings::Settings;

#[derive(Args)]
Expand All @@ -24,6 +25,10 @@ pub struct ShowArgs {
/// Open the paste in the default browser instead of printing it
#[arg(long, default_value_t = false, conflicts_with = "no_color")]
open: bool,

/// Print the paste and its metadata as JSON. Binary content is base64 encoded.
#[arg(long, default_value_t = false, conflicts_with_all = ["no_color", "raw", "open"])]
json: bool,
}

pub fn handle(args: &ShowArgs, settings: &Settings) -> Result<()> {
Expand All @@ -43,6 +48,10 @@ pub fn handle(args: &ShowArgs, settings: &Settings) -> Result<()> {
let client = settings.client()?;
let paste = client.get_paste(&args.id)?;

if args.json {
return print_json(&paste);
}

if args.raw || paste.text().is_none() {
write_raw(io::stdout().lock(), &paste.data)?;
return Ok(());
Expand Down Expand Up @@ -107,6 +116,7 @@ mod tests {
syntax_highlight: syntax_highlight.to_string(),
visibility: "private".to_string(),
is_text: true,
..Paste::default()
}
}

Expand Down
31 changes: 31 additions & 0 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,37 @@ mod tests {
assert!(result.is_err());
}

#[test]
fn parses_paste_metadata_and_lifecycle_commands() {
for args in [
&[
"textbin",
"create",
"hello",
"--title",
"Notes",
"--ref",
"https://t.example/1",
][..],
&["textbin", "create", "hello", "--json"],
&["textbin", "show", "p1", "--json"],
&["textbin", "delete", "p1", "--permanent", "--json"],
&["textbin", "deleted", "--json"],
&["textbin", "restore", "p1", "--json"],
] {
assert!(TextbinCli::try_parse_from(args).is_ok(), "{args:?}");
}
}

#[test]
fn show_json_excludes_other_output_modes() {
for flag in ["--raw", "--open", "--no-color"] {
let result = TextbinCli::try_parse_from(["textbin", "show", "p1", "--json", flag]);

assert!(result.is_err(), "{flag}");
}
}

#[test]
fn parses_auth_workflows() {
assert!(TextbinCli::try_parse_from(["textbin", "auth", "whoami"]).is_ok());
Expand Down
Loading
Loading