Skip to content

Commit 5f10fdc

Browse files
authored
feat: cli create (#10)
Enable creating pastes from CLI in the following cases - simple string `create "hello"` - file path string `create @src/main.rs` - stream/pipe stdin `cat hello.go | create` Also allow specifyin syntax/extension on create. --------- Co-authored-by: Darwin D Wu <darwin67@users.noreply.github.com>
1 parent 211bb50 commit 5f10fdc

3 files changed

Lines changed: 251 additions & 24 deletions

File tree

‎crates/cli/src/base/create.rs‎

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,111 @@
1-
pub fn handle(data: &str) -> anyhow::Result<()> {
2-
println!("Creating a paste with data: {data}");
1+
use clap::Args;
2+
use std::fs;
3+
use std::io::{self, BufReader, IsTerminal};
4+
use std::path::PathBuf;
5+
use textbin_client::Client;
6+
7+
#[derive(Args)]
8+
pub struct CreateArgs {
9+
/// Accepts static strings and stdin inputs including pipes.
10+
/// When given a string with a '@' prefix, it'll treat it as a file and will attempt
11+
/// to read the data from the file, essentially the same as `cat <file> | textbin create`
12+
data: Option<String>,
13+
14+
/// If provided, `syntax` will be used as reference to syntax highlight the data. e.g. go, rust, json
15+
#[arg(long, visible_alias = "ext")]
16+
syntax: Option<String>,
17+
}
18+
19+
pub fn handle(args: &CreateArgs) -> anyhow::Result<()> {
20+
let client = Client::from_env();
21+
let syntax = args.syntax.as_deref();
22+
23+
let paste = match &args.data {
24+
Some(data) => match create_data_from_arg(data)? {
25+
Data::File(path) => {
26+
// stream from the file to API
27+
let file = fs::File::open(path)?;
28+
let reader = BufReader::new(file);
29+
30+
client.create_paste_stream(reader, syntax)?
31+
}
32+
Data::Literal(data) => client.create_paste(data, syntax)?,
33+
},
34+
None => {
35+
// If stdin is still the interactive terminal, reading from it would
36+
// look like the command hung while waiting for EOF. When stdin is
37+
// not a terminal, it may be a pipe, redirected file, heredoc, or
38+
// another non-interactive source; all of those can be streamed.
39+
if io::stdin().is_terminal() {
40+
anyhow::bail!("provide paste data as an argument or pipe it on stdin");
41+
}
42+
43+
client.create_paste_stream(io::stdin(), syntax)?
44+
}
45+
};
46+
47+
println!("{}", paste.id);
348
Ok(())
449
}
50+
51+
#[derive(Debug, PartialEq, Eq)]
52+
enum Data {
53+
File(PathBuf),
54+
Literal(String),
55+
}
56+
57+
fn create_data_from_arg(data: &str) -> anyhow::Result<Data> {
58+
match data.strip_prefix('@') {
59+
Some(path) => match fs::exists(path) {
60+
Ok(true) => Ok(Data::File(PathBuf::from(path))),
61+
Ok(false) => anyhow::bail!("file does not exist at path {}", path),
62+
Err(err) => anyhow::bail!("error checking path: {}", err),
63+
},
64+
None => Ok(Data::Literal(data.to_string())),
65+
}
66+
}
67+
68+
#[cfg(test)]
69+
mod tests {
70+
use super::*;
71+
use std::time::{SystemTime, UNIX_EPOCH};
72+
73+
#[test]
74+
fn create_data_from_arg_treats_plain_data_as_literal() {
75+
assert_eq!(
76+
create_data_from_arg("hello world").unwrap(),
77+
Data::Literal("hello world".to_string())
78+
);
79+
}
80+
81+
#[test]
82+
fn create_data_from_arg_treats_existing_at_path_as_file() {
83+
let path = temp_file_path("create-data-file");
84+
fs::write(&path, "hello from file").unwrap();
85+
86+
let result = create_data_from_arg(&format!("@{}", path.display()));
87+
88+
fs::remove_file(&path).unwrap();
89+
assert_eq!(result.unwrap(), Data::File(path));
90+
}
91+
92+
#[test]
93+
fn create_data_from_arg_errors_when_at_path_does_not_exist() {
94+
let path = temp_file_path("missing-create-data-file");
95+
let result = create_data_from_arg(&format!("@{}", path.display()));
96+
97+
assert_eq!(
98+
result.unwrap_err().to_string(),
99+
format!("file does not exist at path {}", path.display())
100+
);
101+
}
102+
103+
fn temp_file_path(label: &str) -> PathBuf {
104+
let nonce = SystemTime::now()
105+
.duration_since(UNIX_EPOCH)
106+
.unwrap()
107+
.as_nanos();
108+
109+
std::env::temp_dir().join(format!("textbin-{label}-{}-{nonce}", std::process::id()))
110+
}
111+
}

‎crates/cli/src/base/mod.rs‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,19 @@ mod show;
33

44
use clap::Subcommand;
55

6-
use crate::base::show::ShowArgs;
6+
use crate::base::{create::CreateArgs, show::ShowArgs};
77

88
#[derive(Subcommand)]
99
pub enum Commands {
1010
/// Retrieve and print a paste
1111
Show(ShowArgs),
1212
/// Create a new paste
13-
Create {
14-
/// data to paste.
15-
data: String,
16-
},
13+
Create(CreateArgs),
1714
}
1815

1916
pub fn handle(command: &Commands) -> anyhow::Result<()> {
2017
match command {
2118
Commands::Show(args) => show::handle(args),
22-
Commands::Create { data } => create::handle(data),
19+
Commands::Create(args) => create::handle(args),
2320
}
2421
}

‎crates/client/src/lib.rs‎

Lines changed: 139 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
use reqwest::StatusCode;
2+
use reqwest::blocking::Body;
3+
use reqwest::header::CONTENT_TYPE;
24
use serde::Deserialize;
35
use std::collections::BTreeMap;
46
use std::error;
57
use std::fmt;
8+
use std::io::Read;
69

710
const DEFAULT_TEXTBIN_URL: &str = "http://localhost:4000";
811

912
#[derive(Debug, Clone)]
1013
pub struct Client {
1114
base_url: String,
15+
http: reqwest::blocking::Client,
1216
}
1317

1418
impl Client {
@@ -22,44 +26,104 @@ impl Client {
2226
pub fn new(base_url: impl Into<String>) -> Self {
2327
Self {
2428
base_url: base_url.into().trim_end_matches('/').to_string(),
29+
http: reqwest::blocking::Client::new(),
2530
}
2631
}
2732

2833
pub fn get_paste(&self, id: &str) -> Result<Paste, Error> {
2934
let url = format!("{}/api/v1/pastes/{id}", self.base_url);
30-
let response = reqwest::blocking::get(&url).map_err(|source| Error::Request {
31-
url: url.clone(),
32-
source,
33-
})?;
34-
35-
let status = response.status();
36-
let body = response.text().map_err(|source| Error::ReadResponse {
37-
url: url.clone(),
38-
source,
39-
})?;
40-
41-
if !status.is_success() {
42-
return Err(Error::Api(format_api_error(status, &body)));
43-
}
35+
let response = self
36+
.http
37+
.get(&url)
38+
.send()
39+
.map_err(|source| Error::Request {
40+
url: url.clone(),
41+
source,
42+
})?;
4443

45-
let response = serde_json::from_str::<ShowResponse>(&body)
46-
.map_err(|source| Error::Decode { source })?;
44+
let response = decode_response::<ShowResponse>(&url, response)?;
4745

4846
Ok(response.data)
4947
}
48+
49+
pub fn create_paste(
50+
&self,
51+
data: String,
52+
syntax_highlight: Option<&str>,
53+
) -> Result<CreatedPaste, Error> {
54+
self.create_paste_body(Body::from(data), syntax_highlight)
55+
}
56+
57+
pub fn create_paste_stream<R>(
58+
&self,
59+
reader: R,
60+
syntax_highlight: Option<&str>,
61+
) -> Result<CreatedPaste, Error>
62+
where
63+
R: Read + Send + 'static,
64+
{
65+
self.create_paste_body(Body::new(reader), syntax_highlight)
66+
}
67+
68+
fn create_paste_body(
69+
&self,
70+
body: Body,
71+
syntax_highlight: Option<&str>,
72+
) -> Result<CreatedPaste, Error> {
73+
let url = self.create_paste_url(syntax_highlight);
74+
let response = self
75+
.http
76+
.post(&url)
77+
.header(CONTENT_TYPE, "text/plain")
78+
.body(body)
79+
.send()
80+
.map_err(|source| Error::Request {
81+
url: url.clone(),
82+
source,
83+
})?;
84+
85+
let response = decode_response::<CreateResponse>(&url, response)?;
86+
87+
Ok(response.data)
88+
}
89+
90+
fn create_paste_url(&self, syntax_highlight: Option<&str>) -> String {
91+
let url = format!("{}/api/v1/pastes", self.base_url);
92+
93+
match syntax_highlight.filter(|syntax| !syntax.is_empty()) {
94+
Some(syntax_highlight) => {
95+
let mut url = reqwest::Url::parse(&url).expect("client base_url must be valid URL");
96+
url.query_pairs_mut()
97+
.append_pair("syntax_highlight", syntax_highlight);
98+
url.into()
99+
}
100+
None => url,
101+
}
102+
}
50103
}
51104

52105
#[derive(Debug, Deserialize)]
53106
struct ShowResponse {
54107
data: Paste,
55108
}
56109

110+
#[derive(Debug, Deserialize)]
111+
struct CreateResponse {
112+
data: CreatedPaste,
113+
}
114+
57115
#[derive(Debug, Clone, Deserialize)]
58116
pub struct Paste {
59117
pub data: String,
60118
pub syntax_highlight: String,
61119
}
62120

121+
#[derive(Debug, Clone, Deserialize)]
122+
pub struct CreatedPaste {
123+
pub id: String,
124+
pub syntax_highlight: String,
125+
}
126+
63127
#[derive(Debug, Deserialize)]
64128
struct ApiErrorResponse {
65129
errors: ApiErrors,
@@ -103,6 +167,30 @@ impl error::Error for Error {
103167
}
104168
}
105169

170+
fn decode_response<T>(url: &str, response: reqwest::blocking::Response) -> Result<T, Error>
171+
where
172+
T: for<'de> Deserialize<'de>,
173+
{
174+
let status = response.status();
175+
let body = response.text().map_err(|source| Error::ReadResponse {
176+
url: url.to_string(),
177+
source,
178+
})?;
179+
180+
if !status.is_success() {
181+
return Err(Error::Api(format_api_error(status, &body)));
182+
}
183+
184+
decode_json(&body)
185+
}
186+
187+
fn decode_json<T>(body: &str) -> Result<T, Error>
188+
where
189+
T: for<'de> Deserialize<'de>,
190+
{
191+
serde_json::from_str(body).map_err(|source| Error::Decode { source })
192+
}
193+
106194
fn format_api_error(status: StatusCode, body: &str) -> String {
107195
let detail = serde_json::from_str::<ApiErrorResponse>(body)
108196
.ok()
@@ -141,6 +229,30 @@ mod tests {
141229
assert_eq!(client.base_url, "http://localhost:4000");
142230
}
143231

232+
#[test]
233+
fn create_paste_url_omits_empty_syntax_highlight() {
234+
let client = Client::new("http://localhost:4000/");
235+
236+
assert_eq!(
237+
client.create_paste_url(None),
238+
"http://localhost:4000/api/v1/pastes"
239+
);
240+
assert_eq!(
241+
client.create_paste_url(Some("")),
242+
"http://localhost:4000/api/v1/pastes"
243+
);
244+
}
245+
246+
#[test]
247+
fn create_paste_url_adds_syntax_highlight_query_param() {
248+
let client = Client::new("http://localhost:4000/");
249+
250+
assert_eq!(
251+
client.create_paste_url(Some("rust")),
252+
"http://localhost:4000/api/v1/pastes?syntax_highlight=rust"
253+
);
254+
}
255+
144256
#[test]
145257
fn format_api_error_uses_detail_from_json_response() {
146258
let message = format_api_error(
@@ -170,4 +282,15 @@ mod tests {
170282

171283
assert_eq!(message, "paste request failed: 500 Internal Server Error");
172284
}
285+
286+
#[test]
287+
fn decodes_create_response_metadata() {
288+
let response = decode_json::<CreateResponse>(
289+
r#"{"data":{"id":"00000000-0000-0000-0000-000000000000","syntax_highlight":"plain"}}"#,
290+
)
291+
.unwrap();
292+
293+
assert_eq!(response.data.id, "00000000-0000-0000-0000-000000000000");
294+
assert_eq!(response.data.syntax_highlight, "plain");
295+
}
173296
}

0 commit comments

Comments
 (0)