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()); 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 { .. }))); }