From 4ccc6e816be9dd465129babcdcc976a273eac18f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:58:49 +0000 Subject: [PATCH 1/2] feat(client): send titles and references, read links and removed pastes Creating a paste takes a CreateOptions struct instead of a growing list of positional arguments, and passes title and reference_url along with the existing syntax, expiry and visibility parameters. Pastes now carry their id, title, reference link, share and raw URLs and timestamps, all optional so older servers still decode. New calls list removed pastes, restore one and delete one for good. Pastes serialize the way the server represents them, with binary content base64 encoded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- crates/client/src/lib.rs | 344 ++++++++++++++++++++++++++++++++++----- 1 file changed, 299 insertions(+), 45 deletions(-) diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 505c339..21894a7 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -2,9 +2,9 @@ use base64::Engine as _; use reqwest::StatusCode; use reqwest::blocking::Body; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; -use serde::Deserialize; -use serde::Deserializer; use serde::de::Error as _; +use serde::ser::SerializeMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::collections::BTreeMap; use std::error; use std::fmt; @@ -13,6 +13,17 @@ use std::net::IpAddr; const DEFAULT_TEXTBIN_URL: &str = "http://localhost:4400"; +/// Optional attributes for a new paste. Empty strings are treated as unset so +/// callers can pass flags straight through. +#[derive(Debug, Clone, Copy, Default)] +pub struct CreateOptions<'a> { + pub title: Option<&'a str>, + pub reference_url: Option<&'a str>, + pub syntax_highlight: Option<&'a str>, + pub expires_in: Option<&'a str>, + pub visibility: Option<&'a str>, +} + #[derive(Debug, Clone)] pub struct Client { base_url: String, @@ -141,6 +152,47 @@ impl Client { ensure_success(&url, response) } + /// Lists the caller's removed pastes that can still be restored. + pub fn list_removed_pastes(&self) -> Result, Error> { + let url = format!("{}/api/v1/pastes/deleted", self.base_url); + let response = self + .authorize(self.http.get(&url)) + .send() + .map_err(|source| Error::Request { + url: url.clone(), + source, + })?; + + Ok(decode_response::(&url, response)?.data) + } + + pub fn restore_paste(&self, id: &str) -> Result { + let url = format!("{}/api/v1/pastes/deleted/{id}/restore", self.base_url); + let response = self + .authorize(self.http.post(&url)) + .send() + .map_err(|source| Error::Request { + url: url.clone(), + source, + })?; + + Ok(decode_response::(&url, response)?.data) + } + + /// Deletes a removed paste for good, before its recovery window ends. + pub fn purge_paste(&self, id: &str) -> Result<(), Error> { + let url = format!("{}/api/v1/pastes/deleted/{id}", self.base_url); + let response = self + .authorize(self.http.delete(&url)) + .send() + .map_err(|source| Error::Request { + url: url.clone(), + source, + })?; + + ensure_success(&url, response) + } + pub fn paste_url(&self, id: &str) -> String { format!("{}/pastes/{id}", self.base_url) } @@ -152,34 +204,28 @@ impl Client { pub fn create_paste( &self, data: String, - syntax_highlight: Option<&str>, - expires_in: Option<&str>, - visibility: Option<&str>, + options: &CreateOptions<'_>, ) -> Result { - self.create_paste_body(Body::from(data), syntax_highlight, expires_in, visibility) + self.create_paste_body(Body::from(data), options) } pub fn create_paste_stream( &self, reader: R, - syntax_highlight: Option<&str>, - expires_in: Option<&str>, - visibility: Option<&str>, + options: &CreateOptions<'_>, ) -> Result where R: Read + Send + 'static, { - self.create_paste_body(Body::new(reader), syntax_highlight, expires_in, visibility) + self.create_paste_body(Body::new(reader), options) } fn create_paste_body( &self, body: Body, - syntax_highlight: Option<&str>, - expires_in: Option<&str>, - visibility: Option<&str>, + options: &CreateOptions<'_>, ) -> Result { - let url = self.create_paste_url(syntax_highlight, expires_in, visibility)?; + let url = self.create_paste_url(options)?; let request = self .http .post(&url) @@ -198,29 +244,19 @@ impl Client { Ok(response.data) } - fn create_paste_url( - &self, - syntax_highlight: Option<&str>, - expires_in: Option<&str>, - visibility: Option<&str>, - ) -> Result { + // The body is the paste itself, so metadata travels as query parameters. + fn create_paste_url(&self, options: &CreateOptions<'_>) -> Result { let url = format!("{}/api/v1/pastes", self.base_url); - let syntax_highlight = syntax_highlight.filter(|syntax| !syntax.is_empty()); - let expires_in = expires_in.filter(|ttl| !ttl.is_empty()); - let visibility = visibility.filter(|visibility| !visibility.is_empty()); - let mut query = Vec::new(); - - if let Some(syntax_highlight) = syntax_highlight { - query.push(("syntax_highlight", syntax_highlight)); - } - - if let Some(expires_in) = expires_in { - query.push(("expires_in", expires_in)); - } - - if let Some(visibility) = visibility { - query.push(("visibility", visibility)); - } + let query: Vec<(&str, &str)> = [ + ("title", options.title), + ("reference_url", options.reference_url), + ("syntax_highlight", options.syntax_highlight), + ("expires_in", options.expires_in), + ("visibility", options.visibility), + ] + .into_iter() + .filter_map(|(key, value)| value.filter(|value| !value.is_empty()).map(|v| (key, v))) + .collect(); let mut request = self.http.post(&url); @@ -267,6 +303,11 @@ struct CreateResponse { data: CreatedPaste, } +#[derive(Debug, Deserialize)] +struct RemovedPastesResponse { + data: Vec, +} + #[derive(Debug, Deserialize)] struct CreateApiTokenResponse { data: CreatedApiToken, @@ -312,8 +353,17 @@ pub struct ApiTokenMetadata { pub inserted_at: String, } -#[derive(Debug, Clone)] +/// A paste with its content. Link and title fields are optional so the client +/// still reads responses from servers that predate them. +#[derive(Debug, Clone, Default)] pub struct Paste { + pub id: Option, + pub title: Option, + pub reference_url: Option, + pub url: Option, + pub raw_url: Option, + pub expires_at: Option, + pub inserted_at: Option, pub data: Vec, pub content_type: String, pub syntax_highlight: String, @@ -321,6 +371,41 @@ pub struct Paste { pub is_text: bool, } +// Mirrors the server's representation: text in `data`, anything else base64 +// encoded, so `--json` output round-trips through the same rules. +impl Serialize for Paste { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("id", &self.id)?; + map.serialize_entry("title", &self.title)?; + map.serialize_entry("reference_url", &self.reference_url)?; + map.serialize_entry("url", &self.url)?; + map.serialize_entry("raw_url", &self.raw_url)?; + map.serialize_entry("content_type", &self.content_type)?; + map.serialize_entry("syntax_highlight", &self.syntax_highlight)?; + map.serialize_entry("visibility", &self.visibility)?; + map.serialize_entry("expires_at", &self.expires_at)?; + map.serialize_entry("inserted_at", &self.inserted_at)?; + + match self.text() { + Some(text) => map.serialize_entry("data", text)?, + None => { + map.serialize_entry("data", &Option::<&str>::None)?; + map.serialize_entry( + "data_base64", + &base64::engine::general_purpose::STANDARD.encode(&self.data), + )?; + map.serialize_entry("data_encoding", "base64")?; + } + } + + map.end() + } +} + impl Paste { pub fn text(&self) -> Option<&str> { if self.is_text && !self.data.contains(&0) { @@ -351,6 +436,13 @@ impl<'de> Deserialize<'de> for Paste { }; Ok(Self { + id: paste.id, + title: paste.title, + reference_url: paste.reference_url, + url: paste.url, + raw_url: paste.raw_url, + expires_at: paste.expires_at, + inserted_at: paste.inserted_at, data, content_type: paste.content_type, syntax_highlight: paste.syntax_highlight, @@ -362,6 +454,20 @@ impl<'de> Deserialize<'de> for Paste { #[derive(Deserialize)] struct PasteResponse { + #[serde(default)] + id: Option, + #[serde(default)] + title: Option, + #[serde(default)] + reference_url: Option, + #[serde(default)] + url: Option, + #[serde(default)] + raw_url: Option, + #[serde(default)] + expires_at: Option, + #[serde(default)] + inserted_at: Option, data: Option, data_base64: Option, data_encoding: Option, @@ -375,9 +481,18 @@ fn default_content_type() -> String { "text/plain".to_string() } -#[derive(Debug, Clone, Deserialize)] +/// Paste metadata without content, as returned by create and restore. +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct CreatedPaste { pub id: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub reference_url: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub raw_url: Option, #[serde(default = "default_content_type")] pub content_type: String, pub syntax_highlight: String, @@ -385,6 +500,19 @@ pub struct CreatedPaste { pub expires_at: Option, } +/// A deleted or expired paste that can still be restored. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RemovedPaste { + pub id: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub url: Option, + pub removal_reason: String, + pub removed_at: Option, + pub purge_after: Option, +} + #[derive(Debug, Deserialize)] struct ApiErrorResponse { errors: ApiErrors, @@ -799,12 +927,18 @@ mod tests { let client = Client::new("http://localhost:4000/"); assert_eq!( - client.create_paste_url(None, None, None).unwrap(), + client.create_paste_url(&CreateOptions::default()).unwrap(), "http://localhost:4000/api/v1/pastes" ); assert_eq!( client - .create_paste_url(Some(""), Some(""), Some("")) + .create_paste_url(&CreateOptions { + title: Some(""), + reference_url: Some(""), + syntax_highlight: Some(""), + expires_in: Some(""), + visibility: Some(""), + }) .unwrap(), "http://localhost:4000/api/v1/pastes" ); @@ -815,7 +949,12 @@ mod tests { let client = Client::new("http://localhost:4000/"); assert_eq!( - client.create_paste_url(Some("rust"), None, None).unwrap(), + client + .create_paste_url(&CreateOptions { + syntax_highlight: Some("rust"), + ..CreateOptions::default() + }) + .unwrap(), "http://localhost:4000/api/v1/pastes?syntax_highlight=rust" ); } @@ -826,7 +965,11 @@ mod tests { assert_eq!( client - .create_paste_url(Some("rust"), Some("1h"), None) + .create_paste_url(&CreateOptions { + syntax_highlight: Some("rust"), + expires_in: Some("1h"), + ..CreateOptions::default() + }) .unwrap(), "http://localhost:4000/api/v1/pastes?syntax_highlight=rust&expires_in=1h" ); @@ -837,15 +980,126 @@ mod tests { let client = Client::new("http://localhost:4000/"); assert_eq!( - client.create_paste_url(None, None, Some("public")).unwrap(), + client + .create_paste_url(&CreateOptions { + visibility: Some("public"), + ..CreateOptions::default() + }) + .unwrap(), "http://localhost:4000/api/v1/pastes?visibility=public" ); } + #[test] + fn create_paste_url_adds_title_and_reference_query_params() { + let client = Client::new("http://localhost:4000/"); + + assert_eq!( + client + .create_paste_url(&CreateOptions { + title: Some("nginx 502s"), + reference_url: Some("https://tracker.example.com/INC-2041"), + ..CreateOptions::default() + }) + .unwrap(), + "http://localhost:4000/api/v1/pastes?title=nginx+502s&reference_url=https%3A%2F%2Ftracker.example.com%2FINC-2041" + ); + } + + #[test] + fn decodes_paste_links_and_title() { + let paste = decode_json::( + r#"{"data":{"id":"p1","title":"Deploy log","reference_url":"https://t.example/1","url":"https://demo.textbin.com/pastes/p1","raw_url":"https://demo.textbin.com/pastes/p1/raw","data":"hello","content_type":"text/plain","syntax_highlight":"plain","visibility":"private"}}"#, + ) + .unwrap() + .data; + + assert_eq!(paste.title.as_deref(), Some("Deploy log")); + assert_eq!( + paste.url.as_deref(), + Some("https://demo.textbin.com/pastes/p1") + ); + assert_eq!( + paste.raw_url.as_deref(), + Some("https://demo.textbin.com/pastes/p1/raw") + ); + } + + #[test] + fn serializes_text_and_binary_pastes_like_the_server() { + let text = Paste { + id: Some("p1".to_string()), + data: b"hello".to_vec(), + content_type: "text/plain".to_string(), + is_text: true, + ..Paste::default() + }; + let binary = Paste { + data: vec![255, 0, 1], + content_type: "application/octet-stream".to_string(), + ..Paste::default() + }; + + let text = serde_json::to_value(&text).unwrap(); + let binary = serde_json::to_value(&binary).unwrap(); + + assert_eq!(text["id"], "p1"); + assert_eq!(text["data"], "hello"); + assert!(text.get("data_base64").is_none()); + assert_eq!(binary["data"], serde_json::Value::Null); + assert_eq!(binary["data_base64"], "/wAB"); + assert_eq!(binary["data_encoding"], "base64"); + } + + #[test] + fn removed_paste_endpoints_use_the_deleted_collection() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let mut requests = Vec::new(); + let responses = [ + r#"{"data":[{"id":"p1","title":null,"url":"u","removal_reason":"deleted","removed_at":"2026-10-10T00:00:00.000Z","purge_after":"2026-11-09T00:00:00.000Z"}]}"#, + r#"{"data":{"id":"p1","syntax_highlight":"plain","visibility":"private","expires_at":null}}"#, + "", + ]; + + for body in responses { + let (mut stream, _) = listener.accept().unwrap(); + requests.push(read_request_headers(&mut stream)); + let status = if body.is_empty() { + "204 No Content" + } else { + "200 OK" + }; + let response = format!( + "HTTP/1.1 {status}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + } + + requests + }); + let client = Client::new(format!("http://{address}")) + .with_api_token(Some("txb_test_token".to_string())); + + let removed = client.list_removed_pastes().unwrap(); + let restored = client.restore_paste("p1").unwrap(); + client.purge_paste("p1").unwrap(); + + assert_eq!(removed[0].removal_reason, "deleted"); + assert_eq!(restored.id, "p1"); + + let requests = server.join().unwrap(); + assert!(requests[0].starts_with("GET /api/v1/pastes/deleted HTTP/1.1")); + assert!(requests[1].starts_with("POST /api/v1/pastes/deleted/p1/restore HTTP/1.1")); + assert!(requests[2].starts_with("DELETE /api/v1/pastes/deleted/p1 HTTP/1.1")); + } + #[test] fn create_paste_with_invalid_base_url_returns_request_error() { - let result = - Client::new("not a valid URL").create_paste("paste body".to_string(), None, None, None); + let result = Client::new("not a valid URL") + .create_paste("paste body".to_string(), &CreateOptions::default()); assert!(matches!(result, Err(Error::Request { .. }))); } From e58c75b5f018a5fde2ca3aaec2e0804a5f1d10b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:58:49 +0000 Subject: [PATCH 2/2] feat(cli): titles, references, JSON output and restoring deleted pastes - create takes --title and --reference (alias --ref) and prints the server's share link. - create, show, delete, deleted and restore accept --json and print a single line of JSON, so scripts can read ids, links and metadata. - delete explains how to restore the paste; delete --permanent removes it for good, including one that was already deleted. - deleted lists restorable pastes with their reason, purge date and title. - restore brings a deleted or expired paste back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- Cargo.lock | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/base/create.rs | 41 ++++++++++++++--- crates/cli/src/base/delete.rs | 39 +++++++++++++++- crates/cli/src/base/deleted.rs | 81 ++++++++++++++++++++++++++++++++++ crates/cli/src/base/mod.rs | 16 ++++++- crates/cli/src/base/output.rs | 9 ++++ crates/cli/src/base/restore.rs | 29 ++++++++++++ crates/cli/src/base/show.rs | 10 +++++ crates/cli/src/main.rs | 31 +++++++++++++ 10 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 crates/cli/src/base/deleted.rs create mode 100644 crates/cli/src/base/output.rs create mode 100644 crates/cli/src/base/restore.rs diff --git a/Cargo.lock b/Cargo.lock index 2ca97a8..23fff6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2261,6 +2261,7 @@ dependencies = [ "lumis", "rpassword", "serde", + "serde_json", "textbin-client", "toml 0.9.12+spec-1.1.0", "uuid", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 46cf79c..76c03d9 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -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 diff --git a/crates/cli/src/base/create.rs b/crates/cli/src/base/create.rs index fce311a..6c20466 100644 --- a/crates/cli/src/base/create.rs +++ b/crates/cli/src/base/create.rs @@ -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 { @@ -11,6 +13,14 @@ pub struct CreateArgs { /// to read the data from the file, essentially the same as `cat | textbin create` data: Option, + /// Paste title. Without one, the server takes the first non-blank line. + #[arg(long)] + title: Option, + + /// Link to a related ticket or incident, e.g. https://tracker.example.com/INC-2041 + #[arg(long, visible_alias = "ref")] + reference: Option, + /// 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, @@ -22,6 +32,10 @@ pub struct CreateArgs { /// Paste visibility. #[arg(long, value_enum)] visibility: Option, + + /// Print the created paste's metadata as JSON instead of its link + #[arg(long, default_value_t = false)] + json: bool, } #[derive(Clone, ValueEnum)] @@ -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)? { @@ -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 @@ -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(()) } diff --git a/crates/cli/src/base/delete.rs b/crates/cli/src/base/delete.rs index 2789ac3..2aad5dd 100644 --- a/crates/cli/src/base/delete.rs +++ b/crates/cli/src/base/delete.rs @@ -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(()) } diff --git a/crates/cli/src/base/deleted.rs b/crates/cli/src/base/deleted.rs new file mode 100644 index 0000000..68bcb29 --- /dev/null +++ b/crates/cli/src/base/deleted.rs @@ -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")); + } +} diff --git a/crates/cli/src/base/mod.rs b/crates/cli/src/base/mod.rs index f86e91a..62a8364 100644 --- a/crates/cli/src/base/mod.rs +++ b/crates/cli/src/base/mod.rs @@ -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)] @@ -13,8 +19,12 @@ 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<()> { @@ -22,5 +32,7 @@ pub fn handle(command: &Commands, settings: &Settings) -> anyhow::Result<()> { 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), } } diff --git a/crates/cli/src/base/output.rs b/crates/cli/src/base/output.rs new file mode 100644 index 0000000..e20cd0b --- /dev/null +++ b/crates/cli/src/base/output.rs @@ -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(()) +} diff --git a/crates/cli/src/base/restore.rs b/crates/cli/src/base/restore.rs new file mode 100644 index 0000000..abdf8ff --- /dev/null +++ b/crates/cli/src/base/restore.rs @@ -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(()) +} diff --git a/crates/cli/src/base/show.rs b/crates/cli/src/base/show.rs index 7aea37c..7fd6596 100644 --- a/crates/cli/src/base/show.rs +++ b/crates/cli/src/base/show.rs @@ -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)] @@ -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<()> { @@ -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(()); @@ -107,6 +116,7 @@ mod tests { syntax_highlight: syntax_highlight.to_string(), visibility: "private".to_string(), is_text: true, + ..Paste::default() } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3d02d95..8ddf313 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -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());