Skip to main content

max / makenotwork

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