Skip to main content

max / makenotwork

24.5 KB · 695 lines History Blame Raw
1 //! Non-interactive SSH command handlers.
2 //!
3 //! When a user runs `ssh cli.makenot.work <command>`, the exec_request handler
4 //! dispatches to this module. All commands write output to a byte buffer and
5 //! return it for the SSH channel.
6
7 use std::fmt::Write as _;
8
9 use crate::api::{MnwApiClient, UserInfo};
10 use crate::format;
11 use crate::staging;
12
13 /// Sanitize an API error for display to SSH clients.
14 ///
15 /// Strips raw response bodies (HTML error pages, stack traces) that may leak
16 /// from anyhow error chains. Keeps the high-level context + HTTP status code
17 /// but drops everything after ` — ` (the body separator our API client uses).
18 pub(crate) fn sanitize_api_error(e: &anyhow::Error) -> String {
19 let msg = e.to_string();
20 // Our API client formats errors as "context: HTTP 500 — <body>".
21 // Keep the part before the body separator.
22 if let Some(idx) = msg.find(" \u{2014} ") {
23 return msg[..idx].to_string();
24 }
25 // For connection/timeout errors, return a generic message.
26 if msg.contains("connect") || msg.contains("timed out") || msg.contains("dns") {
27 return "Service temporarily unavailable".to_string();
28 }
29 // Fallback: return the first line only (avoid multi-line leaks).
30 msg.lines().next().unwrap_or("Unknown error").to_string()
31 }
32
33 /// Execute a non-interactive command and return the output bytes.
34 pub(crate) async fn execute(command_line: &str, user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
35 let parts: Vec<&str> = command_line.split_whitespace().collect();
36 if parts.is_empty() {
37 return help_text();
38 }
39
40 let json = parts.contains(&"--json");
41 let parts: Vec<&str> = parts.into_iter().filter(|p| *p != "--json").collect();
42
43 match parts[0] {
44 "project" => match parts.get(1).copied() {
45 Some("create") => {
46 let title = extract_flag(&parts, &["--title", "-t"]).unwrap_or_default();
47 let ptype = extract_flag(&parts, &["--type"]).unwrap_or_else(|| "digital".to_string());
48 let desc = extract_flag(&parts, &["--description", "--desc", "-d"]);
49 cmd_project_create(user, api, &title, &ptype, desc.as_deref()).await
50 }
51 _ => b"Usage: project create --title \"Name\" [--type audio|digital|video|mixed|subscription] [--description \"...\"]\r\n".to_vec(),
52 },
53 "upload" => {
54 b"Pipe uploads use stdin. Example:\r\n cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-project\r\n".to_vec()
55 }
56 "projects" => cmd_projects(user, api, json).await,
57 "analytics" => {
58 let range = parts
59 .iter()
60 .find_map(|p| p.strip_prefix("--range="))
61 .unwrap_or("30d");
62 cmd_analytics(user, api, range, json).await
63 }
64 "transactions" => cmd_transactions(user, api, json).await,
65 "export" if parts.get(1) == Some(&"sales") => cmd_export_sales(user, api).await,
66 "promo" => match parts.get(1).copied() {
67 Some("list") => cmd_promo_list(user, api, json).await,
68 Some("create") => {
69 let code = parts.get(2).unwrap_or(&"");
70 let pct = parts.get(3).unwrap_or(&"0");
71 cmd_promo_create(user, api, code, pct).await
72 }
73 _ => b"Usage: promo list | promo create CODE DISCOUNT_PCT\r\n".to_vec(),
74 },
75 "blog" => match parts.get(1).copied() {
76 Some("list") => {
77 let slug = parts.get(2).unwrap_or(&"");
78 cmd_blog_list(user, api, slug, json).await
79 }
80 _ => b"Usage: blog list <project-slug>\r\n".to_vec(),
81 },
82 "broadcast" => {
83 let subject = extract_flag(&parts, &["--subject", "-s"]).unwrap_or_default();
84 let body = extract_flag(&parts, &["--body", "-b"]).unwrap_or_default();
85 cmd_broadcast(user, api, &subject, &body).await
86 }
87 "collections" => cmd_collections(user, api, json).await,
88 "domain" => match parts.get(1).copied() {
89 Some("add") => {
90 let domain = parts.get(2).unwrap_or(&"");
91 cmd_domain_add(user, api, domain).await
92 }
93 Some("verify") => cmd_domain_verify(user, api).await,
94 Some("remove") => cmd_domain_remove(user, api).await,
95 _ => cmd_domain_show(user, api).await,
96 },
97 "help" | "--help" | "-h" => help_text(),
98 other => format!("Unknown command: {other}\r\nRun without arguments for usage help.\r\n")
99 .into_bytes(),
100 }
101 }
102
103 async fn cmd_project_create(
104 user: &UserInfo,
105 api: &MnwApiClient,
106 title: &str,
107 project_type: &str,
108 description: Option<&str>,
109 ) -> Vec<u8> {
110 if title.is_empty() {
111 return b"Usage: project create --title \"Name\" [--type audio|digital|video|mixed|subscription] [--description \"...\"]\r\n".to_vec();
112 }
113 if !user.can_create_projects {
114 return b"Error: your account cannot create projects. Upgrade your tier at makenot.work/pricing\r\n".to_vec();
115 }
116 match api
117 .create_project(&user.user_id, title, project_type, description)
118 .await
119 {
120 Ok(p) => format!(
121 "Created project: {} (slug: {}, type: {})\r\n",
122 p.title, p.slug, p.project_type
123 )
124 .into_bytes(),
125 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
126 }
127 }
128
129 async fn cmd_projects(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
130 match api.get_projects(&user.user_id).await {
131 Ok(projects) => {
132 if json {
133 return serde_json::to_vec_pretty(&projects).unwrap_or_default();
134 }
135 if projects.is_empty() {
136 return b"No projects.\r\n".to_vec();
137 }
138 let mut out = format!(
139 "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n",
140 "Title", "Type", "Status", "Items", "Revenue"
141 );
142 out.push_str(&"-".repeat(70));
143 out.push_str("\r\n");
144 for p in &projects {
145 let status = if p.is_public { "public" } else { "draft" };
146 let revenue = format::format_cents(p.revenue_cents);
147 write!(
148 out,
149 "{:<30} {:<12} {:<8} {:<6} {:<10}\r\n",
150 truncate(&p.title, 29),
151 p.project_type,
152 status,
153 p.item_count,
154 revenue,
155 )
156 .unwrap();
157 }
158 out.into_bytes()
159 }
160 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
161 }
162 }
163
164 async fn cmd_analytics(user: &UserInfo, api: &MnwApiClient, range: &str, json: bool) -> Vec<u8> {
165 match api.get_analytics(&user.user_id, range).await {
166 Ok(data) => {
167 if json {
168 return serde_json::to_vec_pretty(&data).unwrap_or_default();
169 }
170
171 let mut out = String::new();
172 write!(out, "Analytics ({range})\r\n\r\n").unwrap();
173
174 let rev = format::format_cents(data.current_revenue_cents);
175 let prev_rev = format::format_cents(data.previous_revenue_cents);
176 write!(out, "Revenue: {rev} (prev: {prev_rev})\r\n").unwrap();
177 write!(
178 out,
179 "Sales: {} (prev: {})\r\n",
180 data.current_sales, data.previous_sales
181 )
182 .unwrap();
183 write!(
184 out,
185 "Followers: {} (prev: {})\r\n",
186 data.current_followers, data.previous_followers
187 )
188 .unwrap();
189
190 if !data.top_projects.is_empty() {
191 out.push_str("\r\nTop Projects:\r\n");
192 for p in &data.top_projects {
193 write!(
194 out,
195 " {:<30} {}\r\n",
196 truncate(&p.title, 29),
197 format::format_cents(p.revenue_cents)
198 )
199 .unwrap();
200 }
201 }
202
203 out.into_bytes()
204 }
205 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
206 }
207 }
208
209 async fn cmd_transactions(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
210 match api.get_transactions(&user.user_id).await {
211 Ok(txs) => {
212 if json {
213 return serde_json::to_vec_pretty(&txs).unwrap_or_default();
214 }
215 if txs.is_empty() {
216 return b"No transactions.\r\n".to_vec();
217 }
218 let mut out = format!(
219 "{:<30} {:<10} {:<12} {:<12}\r\n",
220 "Item", "Amount", "Status", "Date"
221 );
222 out.push_str(&"-".repeat(66));
223 out.push_str("\r\n");
224 for tx in &txs {
225 let title = tx.item_title.as_deref().unwrap_or("--");
226 let amount = format::format_cents(tx.amount_cents as i64);
227 let date = tx.created_at.get(..10).unwrap_or(&tx.created_at);
228 write!(
229 out,
230 "{:<30} {:<10} {:<12} {:<12}\r\n",
231 truncate(title, 29),
232 amount,
233 tx.status,
234 date,
235 )
236 .unwrap();
237 }
238 out.into_bytes()
239 }
240 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
241 }
242 }
243
244 async fn cmd_export_sales(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
245 match api.export_sales_csv(&user.user_id).await {
246 Ok(result) => result.csv.into_bytes(),
247 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
248 }
249 }
250
251 async fn cmd_promo_list(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
252 match api.list_promo_codes(&user.user_id).await {
253 Ok(codes) => {
254 if json {
255 return serde_json::to_vec_pretty(&codes).unwrap_or_default();
256 }
257 if codes.is_empty() {
258 return b"No promo codes.\r\n".to_vec();
259 }
260 let mut out = format!(
261 "{:<20} {:<12} {:<20} {:<10}\r\n",
262 "Code", "Discount", "Scope", "Uses"
263 );
264 out.push_str(&"-".repeat(64));
265 out.push_str("\r\n");
266 for c in &codes {
267 let discount = match (c.discount_type.as_deref(), c.discount_value) {
268 (Some("percentage"), Some(v)) => format!("{v}% off"),
269 (Some("fixed"), Some(v)) => format!("${}.{:02} off", v / 100, v % 100),
270 _ => "Free".to_string(),
271 };
272 let scope = c
273 .item_title
274 .as_deref()
275 .or(c.project_title.as_deref())
276 .unwrap_or("All items");
277 let uses = match c.max_uses {
278 Some(max) => format!("{}/{}", c.use_count, max),
279 None => c.use_count.to_string(),
280 };
281 write!(
282 out,
283 "{:<20} {:<12} {:<20} {:<10}\r\n",
284 truncate(&c.code, 19),
285 discount,
286 truncate(scope, 19),
287 uses,
288 )
289 .unwrap();
290 }
291 out.into_bytes()
292 }
293 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
294 }
295 }
296
297 async fn cmd_promo_create(user: &UserInfo, api: &MnwApiClient, code: &str, pct: &str) -> Vec<u8> {
298 if code.is_empty() {
299 return b"Usage: promo create CODE DISCOUNT_PCT\r\n".to_vec();
300 }
301 let discount: i32 = pct.parse().unwrap_or(0);
302 match api
303 .create_promo_code(&user.user_id, code, "percentage", discount, None, None)
304 .await
305 {
306 Ok(_) => format!("Created promo code: {code} ({discount}% off)\r\n").into_bytes(),
307 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
308 }
309 }
310
311 async fn cmd_blog_list(user: &UserInfo, api: &MnwApiClient, slug: &str, json: bool) -> Vec<u8> {
312 if slug.is_empty() {
313 return b"Usage: blog list <project-slug>\r\n".to_vec();
314 }
315
316 // Find the project by slug
317 let projects = match api.get_projects(&user.user_id).await {
318 Ok(p) => p,
319 Err(e) => return format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
320 };
321
322 let Some(project) = projects.iter().find(|p| p.slug == slug) else {
323 return format!("Project not found: {slug}\r\n").into_bytes();
324 };
325
326 match api.list_blog_posts(&user.user_id, &project.id).await {
327 Ok(posts) => {
328 if json {
329 return serde_json::to_vec_pretty(&posts).unwrap_or_default();
330 }
331 if posts.is_empty() {
332 return b"No blog posts.\r\n".to_vec();
333 }
334 let mut out = format!(
335 "{:<30} {:<20} {:<10} {:<12}\r\n",
336 "Title", "Slug", "Status", "Created"
337 );
338 out.push_str(&"-".repeat(74));
339 out.push_str("\r\n");
340 for p in &posts {
341 let status = if p.is_published { "published" } else { "draft" };
342 let date = p.created_at.get(..10).unwrap_or(&p.created_at);
343 write!(
344 out,
345 "{:<30} {:<20} {:<10} {:<12}\r\n",
346 truncate(&p.title, 29),
347 truncate(&p.slug, 19),
348 status,
349 date,
350 )
351 .unwrap();
352 }
353 out.into_bytes()
354 }
355 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
356 }
357 }
358
359 async fn cmd_broadcast(user: &UserInfo, api: &MnwApiClient, subject: &str, body: &str) -> Vec<u8> {
360 if subject.is_empty() || body.is_empty() {
361 return b"Usage: broadcast --subject \"Subject line\" --body \"Body text\"\r\n\
362 \x20 Aliases: -s, -b\r\n\
363 \x20 Sends an email to all your followers (1/24h limit).\r\n"
364 .to_vec();
365 }
366 match api.send_broadcast(&user.user_id, subject, body).await {
367 Ok(result) => format!(
368 "Broadcast sent to {} followers.\r\n",
369 result.recipient_count
370 )
371 .into_bytes(),
372 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
373 }
374 }
375
376 async fn cmd_collections(user: &UserInfo, api: &MnwApiClient, json: bool) -> Vec<u8> {
377 match api.list_collections(&user.user_id).await {
378 Ok(collections) => {
379 if json {
380 return serde_json::to_vec_pretty(&collections).unwrap_or_default();
381 }
382 if collections.is_empty() {
383 return b"No collections.\r\n".to_vec();
384 }
385 let mut out = format!(
386 "{:<25} {:<25} {:<8} {:<6}\r\n",
387 "Title", "Slug", "Status", "Items"
388 );
389 out.push_str(&"-".repeat(66));
390 out.push_str("\r\n");
391 for c in &collections {
392 let status = if c.is_public { "public" } else { "draft" };
393 write!(
394 out,
395 "{:<25} {:<25} {:<8} {:<6}\r\n",
396 truncate(&c.title, 24),
397 truncate(&c.slug, 24),
398 status,
399 c.item_count,
400 )
401 .unwrap();
402 }
403 out.into_bytes()
404 }
405 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
406 }
407 }
408
409 async fn cmd_domain_show(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
410 match api.get_domain(&user.user_id).await {
411 Ok(Some(d)) => {
412 let status = if d.verified { "verified" } else { "pending" };
413 let mut out = format!("Domain: {} ({})\r\n", d.domain, status);
414 if !d.verified
415 && let Some(ref instr) = d.instructions
416 {
417 write!(out, "{instr}\r\n").unwrap();
418 }
419 out.into_bytes()
420 }
421 Ok(None) => b"No custom domain configured.\r\nUsage: domain add <domain>\r\n".to_vec(),
422 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
423 }
424 }
425
426 async fn cmd_domain_add(user: &UserInfo, api: &MnwApiClient, domain: &str) -> Vec<u8> {
427 if domain.is_empty() {
428 return b"Usage: domain add <domain>\r\n".to_vec();
429 }
430 match api.add_domain(&user.user_id, domain).await {
431 Ok(d) => {
432 let mut out = format!("Domain added: {}\r\n", d.domain);
433 if let Some(ref instr) = d.instructions {
434 write!(out, "{instr}\r\n").unwrap();
435 }
436 out.push_str("Run `domain verify` after adding the DNS record.\r\n");
437 out.into_bytes()
438 }
439 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
440 }
441 }
442
443 async fn cmd_domain_verify(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
444 match api.verify_domain(&user.user_id).await {
445 Ok(result) => format!("{}\r\n", result.message).into_bytes(),
446 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
447 }
448 }
449
450 async fn cmd_domain_remove(user: &UserInfo, api: &MnwApiClient) -> Vec<u8> {
451 match api.remove_domain(&user.user_id).await {
452 Ok(()) => b"Domain removed.\r\n".to_vec(),
453 Err(e) => format!("Error: {}\r\n", sanitize_api_error(&e)).into_bytes(),
454 }
455 }
456
457 /// Execute a pipe-mode file upload (called from handler after stdin EOF).
458 pub(crate) async fn execute_pipe_upload(
459 api: &MnwApiClient,
460 upload: crate::ssh::handler::PipeUpload,
461 ) -> anyhow::Result<String> {
462 let user = &upload.user;
463 if upload.data.is_empty() {
464 anyhow::bail!("no data received on stdin");
465 }
466
467 let ext = upload
468 .filename
469 .rsplit('.')
470 .next()
471 .unwrap_or("")
472 .to_lowercase();
473 let classification = staging::classify_extension(&ext)
474 .ok_or_else(|| anyhow::anyhow!(
475 "unsupported file type: .{ext}\r\nSupported: mp3, wav, flac, ogg, m4a, aac, zip, dmg, exe, appimage, deb, clap, vst3"
476 ))?;
477
478 // Find project by slug
479 let projects = api.get_projects(&user.user_id).await?;
480 let project = projects
481 .iter()
482 .find(|p| p.slug == upload.project_slug)
483 .ok_or_else(|| anyhow::anyhow!("project not found: {}", upload.project_slug))?;
484
485 // Create item
486 let item = api
487 .create_item(
488 &user.user_id,
489 &project.id,
490 &upload.title,
491 classification.item_type,
492 upload.price_cents,
493 )
494 .await?;
495
496 // Presign upload
497 let presign = api
498 .presign_upload(
499 &user.user_id,
500 &item.item_id,
501 classification.file_type,
502 &upload.filename,
503 classification.content_type,
504 )
505 .await?;
506
507 // Upload data directly to S3
508 let resp = reqwest::Client::new()
509 .put(&presign.upload_url)
510 .header("content-type", classification.content_type)
511 .body(upload.data)
512 .send()
513 .await?;
514
515 if !resp.status().is_success() {
516 anyhow::bail!("S3 upload failed: HTTP {}", resp.status());
517 }
518
519 // Confirm
520 api.confirm_upload(
521 &user.user_id,
522 &item.item_id,
523 classification.file_type,
524 &presign.s3_key,
525 )
526 .await?;
527
528 // Publish
529 api.publish_item(&user.user_id, &item.item_id).await?;
530
531 Ok(format!(
532 "Uploaded and published: {} ({}, {})\r\n",
533 upload.title,
534 staging::format_bytes(resp.content_length().unwrap_or(0)),
535 classification.item_type,
536 ))
537 }
538
539 pub(crate) fn help_text() -> Vec<u8> {
540 b"Usage: ssh cli.makenot.work <command>\r\n\
541 \r\n\
542 Commands:\r\n\
543 \x20 projects List your projects\r\n\
544 \x20 project create [opts] Create a new project\r\n\
545 \x20 analytics [--range=N] Revenue stats (7d/30d/90d/all)\r\n\
546 \x20 transactions Recent transactions\r\n\
547 \x20 export sales Export sales as CSV\r\n\
548 \x20 promo list List promo codes\r\n\
549 \x20 promo create CODE PCT Create a promo code\r\n\
550 \x20 blog list SLUG List blog posts for project\r\n\
551 \x20 broadcast -s SUBJ -b BODY Email followers (1/24h limit)\r\n\
552 \x20 collections List your collections\r\n\
553 \x20 domain Show custom domain\r\n\
554 \x20 domain add DOMAIN Add a custom domain\r\n\
555 \x20 domain verify Verify DNS record\r\n\
556 \x20 domain remove Remove custom domain\r\n\
557 \x20 upload [args] Pipe upload (see below)\r\n\
558 \r\n\
559 Add --json to any command for machine-readable output.\r\n\
560 \r\n\
561 Pipe uploads:\r\n\
562 \x20 cat file.wav | ssh cli.makenot.work upload --filename track.wav --project my-slug\r\n\
563 \x20 Options: --filename/-f NAME --project/-p SLUG [--title/-t TITLE] [--price CENTS]\r\n\
564 \r\n\
565 File uploads (SFTP):\r\n\
566 \x20 scp file.wav cli.makenot.work:upload/\r\n\
567 \x20 Then publish via the interactive TUI (ssh cli.makenot.work)\r\n\
568 \r\n\
569 Git hosting:\r\n\
570 \x20 git remote add mnw cli.makenot.work:username/repo-name\r\n\
571 \x20 git push mnw main\r\n"
572 .to_vec()
573 }
574
575 /// Extract a flag value from command parts. Supports both `--flag value` and `-f value`.
576 /// Handles quoted values that were split by whitespace by rejoining until the closing quote.
577 fn extract_flag(parts: &[&str], flags: &[&str]) -> Option<String> {
578 for (i, part) in parts.iter().enumerate() {
579 if flags.contains(part)
580 && let Some(&next) = parts.get(i + 1)
581 {
582 // If the value starts with a quote, collect until closing quote
583 if next.starts_with('"') || next.starts_with('\'') {
584 let quote = next.as_bytes()[0] as char;
585 let stripped = &next[1..];
586 if stripped.ends_with(quote) {
587 return Some(stripped[..stripped.len() - 1].to_string());
588 }
589 let mut collected = stripped.to_string();
590 for &subsequent in &parts[i + 2..] {
591 collected.push(' ');
592 if subsequent.ends_with(quote) {
593 collected.push_str(&subsequent[..subsequent.len() - 1]);
594 return Some(collected);
595 }
596 collected.push_str(subsequent);
597 }
598 return Some(collected);
599 }
600 return Some(next.to_string());
601 }
602 }
603 None
604 }
605
606 fn truncate(s: &str, max_len: usize) -> &str {
607 if s.len() <= max_len {
608 s
609 } else {
610 &s[..s.floor_char_boundary(max_len)]
611 }
612 }
613
614 #[cfg(test)]
615 mod tests {
616 use super::*;
617
618 #[test]
619 fn truncate_short_string() {
620 assert_eq!(truncate("hello", 10), "hello");
621 }
622
623 #[test]
624 fn truncate_exact_length() {
625 assert_eq!(truncate("hello", 5), "hello");
626 }
627
628 #[test]
629 fn truncate_long_string() {
630 assert_eq!(truncate("hello world", 5), "hello");
631 }
632
633 #[test]
634 fn truncate_multibyte_utf8() {
635 // "café" is 5 bytes (é = 2 bytes), truncating at 4 should not panic
636 let result = truncate("café", 4);
637 assert_eq!(result, "caf");
638 }
639
640 #[test]
641 fn truncate_emoji() {
642 // Each emoji is 4 bytes
643 let result = truncate("🎵🎶🎸", 5);
644 assert_eq!(result, "🎵");
645 }
646
647 #[test]
648 fn extract_flag_simple() {
649 let parts = vec!["broadcast", "--subject", "Hello", "--body", "World"];
650 assert_eq!(
651 extract_flag(&parts, &["--subject", "-s"]),
652 Some("Hello".to_string())
653 );
654 assert_eq!(
655 extract_flag(&parts, &["--body", "-b"]),
656 Some("World".to_string())
657 );
658 }
659
660 #[test]
661 fn extract_flag_short() {
662 let parts = vec!["broadcast", "-s", "Hi", "-b", "There"];
663 assert_eq!(
664 extract_flag(&parts, &["--subject", "-s"]),
665 Some("Hi".to_string())
666 );
667 }
668
669 #[test]
670 fn extract_flag_quoted_multiword() {
671 let parts = vec![
672 "broadcast",
673 "--subject",
674 "\"Hello",
675 "everyone\"",
676 "--body",
677 "\"text\"",
678 ];
679 assert_eq!(
680 extract_flag(&parts, &["--subject", "-s"]),
681 Some("Hello everyone".to_string())
682 );
683 assert_eq!(
684 extract_flag(&parts, &["--body", "-b"]),
685 Some("text".to_string())
686 );
687 }
688
689 #[test]
690 fn extract_flag_missing() {
691 let parts = vec!["broadcast", "--subject", "Hi"];
692 assert_eq!(extract_flag(&parts, &["--body", "-b"]), None);
693 }
694 }
695