From 42dee8eb1b20955b62333c3b30370d3890e609a3 Mon Sep 17 00:00:00 2001 From: amanjoshicodes Date: Tue, 1 Sep 2026 12:03:11 +0530 Subject: [PATCH 1/2] fix: preserve chatMessage subject on reads and add message send --subject --- CHANGELOG.md | 6 ++++ README.md | 7 ++++ docs/command-reference.md | 10 +++++- docs/man/teams.1 | 13 ++++++- src/api/messages.rs | 2 ++ src/cli/message.rs | 10 ++++++ src/cli/message_media.rs | 1 + src/models/attachment_inventory.rs | 1 + src/models/message.rs | 55 ++++++++++++++++++++++++++++++ tests/cli.rs | 43 +++++++++++++++++++++++ 10 files changed, 146 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd641e..7593265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,15 @@ ## Unreleased +### Added + +- `teams message send --subject TEXT` sets the subject line on a channel root message — the bold title Teams renders above the body, the same field the client offers behind "Add a subject". Channel sends only: chat messages have no subject, so `--subject` with `--chat` (or without `--channel`) is rejected as invalid input before anything is sent. + ### Fixed - Windows builds reserve an 8 MiB main-thread stack, matching Linux and macOS. Windows gives the main thread 1 MiB by default, and building clap's command tree for this many subcommands needs almost all of it in an unoptimized build, so any addition to the `message` command made every debug and test invocation of `teams` on Windows — `--help` included — fail with `thread 'main' has overflowed its stack`, and `cargo test` failed on `windows-latest` while passing on Linux and macOS. A build script now passes `/STACK:8388608` to the MSVC linker (`--stack` on the GNU toolchain). The reservation is address space rather than committed memory, so an idle process costs nothing extra. +- `message list` and `message get` no longer drop the `subject` of a message. The `ChatMessage` model had no `subject` field, so a channel root message's subject — returned by Graph on both reads — silently vanished from every output: a message posted with a subject read back without one. Messages without a subject are unchanged and gain no `"subject": null` noise. + ## v0.6.0 - 2026-08-30 diff --git a/README.md b/README.md index f19beec..c37dd41 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,7 @@ teams message send --chat --body "Hello" teams message send --team --channel --body "

Rich

" --content-type html teams message send --chat --mention --body "Please review and send the drafts." teams message send --team --channel --mention --mention --body "Deploy is going out now." +teams message send --team --channel --subject "Release plan" --body "Details inside." echo "Build passed" | teams message send --team --channel --stdin teams message list --team --channel teams message list --chat @@ -403,6 +404,12 @@ preserved), the `` elements are prepended in flag order, and a mention by it as a body (`--body` optional). Raw `` markup typed into an HTML body is rejected with exit code 2 before anything is sent. +`message send --subject TEXT` sets the subject line on a channel root message — the bold +title Teams shows above the body, the same field the Teams client offers behind "Add a +subject". Channel messages only: chat messages have no subject, so `--subject` with +`--chat` is rejected as invalid input. `message list` and `message get` return the +subject Graph stores, so a posted subject survives a read-back. + ```bash teams message send --chat \ --mention sophie@example.com \ diff --git a/docs/command-reference.md b/docs/command-reference.md index 322bb06..5157b34 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -106,7 +106,7 @@ teams channel members remove TEAM_ID CHANNEL_ID MEMBER_ID ## Messages ```bash -teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]... +teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]... [--subject TEXT] teams message list (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) teams message get --team TEAM_ID --channel CHANNEL_ID (MESSAGE_ID | --message MESSAGE_ID) [--with-attachments] teams message attachments list (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) @@ -146,6 +146,14 @@ teams message send --team TEAM_ID --channel CHANNEL_ID \ --mention --mention --body "Deploy is going out now." ``` +`--subject TEXT` sets the subject line on a channel root message — the bold title Teams renders above the body, the same field the client offers behind "Add a subject". Channel sends only: chat messages have no subject, so `--subject` with `--chat` (or without `--channel`) is rejected with exit code 2 before anything is sent. The stored subject comes back on `message list` and `message get`. + +```bash +# Post a channel message with a subject line +teams message send --team TEAM_ID --channel CHANNEL_ID \ + --subject "Release plan" --body "Details inside." +``` + `message attachments` unifies the two ways Teams stores message media: inline images pasted into the compose box (Graph "hosted contents") and files attached via SharePoint/OneDrive (`reference` attachments). `list` returns an indexed inventory; `download` fetches everything downloadable by default, or one item with `--index` (add `--path FILE` for an exact destination, or `--path -` to stream to stdout). Inline images and code snippets need no scopes beyond message reads; file attachments additionally require the `Files.Read.All` delegated scope. `message get --with-attachments` embeds the same inventory under `attachment_items` in the message output. ## Chats diff --git a/docs/man/teams.1 b/docs/man/teams.1 index 9d6dcb6..bc7e745 100644 --- a/docs/man/teams.1 +++ b/docs/man/teams.1 @@ -149,7 +149,7 @@ teams channel members remove TEAM_ID CHANNEL_ID MEMBER_ID .fi .SH MESSAGE COMMANDS .nf -teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]... +teams message send (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) [--body TEXT | --stdin] [--content-type text|html] [--adaptive-card PATH] [--image PATH]... [--attach PATH]... [--mention USER]... [--subject TEXT] teams message list (--team TEAM_ID --channel CHANNEL_ID | --chat CHAT_ID) teams message get --team TEAM_ID --channel CHANNEL_ID (MESSAGE_ID | --message MESSAGE_ID) [--with-attachments] teams message attachments list (--team TEAM_ID --channel CHANNEL_ID [--reply REPLY_ID] | --chat CHAT_ID) (MESSAGE_ID | --message MESSAGE_ID) @@ -200,6 +200,17 @@ array, so the CLI builds both: a plain-text body is converted to HTML (escaped, line breaks preserved), the elements are prepended to the body in flag order, and a mention by itself counts as a body. Raw markup typed into an HTML body is rejected before anything is sent. +.PP +.B --subject TEXT +sets the subject line on a channel root message, the bold title Teams shows +above the body. Channel sends only: chat messages have no subject, so +.B --subject +with +.B --chat +is rejected as invalid input. The subject Graph stores is returned by +.B message list +and +.B message get. .SH CHAT COMMANDS .nf teams chat list diff --git a/src/api/messages.rs b/src/api/messages.rs index 713cf75..fcd6c84 100644 --- a/src/api/messages.rs +++ b/src/api/messages.rs @@ -521,6 +521,7 @@ mod tests { fn edit_request() -> SendMessageRequest { SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("text".to_string()), content: Some("corrected text".to_string()), @@ -578,6 +579,7 @@ mod tests { .await; let req = SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("html".into()), content: Some("Sophie Daniels Please review".into()), diff --git a/src/cli/message.rs b/src/cli/message.rs index efa67d6..c4f0e21 100644 --- a/src/cli/message.rs +++ b/src/cli/message.rs @@ -47,6 +47,9 @@ pub enum MessageCommand { /// User to @mention (repeatable): an Entra object ID or UPN #[arg(long, value_name = "USER")] mention: Vec, + /// Subject line for a channel root message + #[arg(long, requires = "channel", conflicts_with = "chat")] + subject: Option, }, /// List messages in a channel or chat List { @@ -295,6 +298,7 @@ pub async fn run( image, attach, mention, + subject, } => { let start = Instant::now(); auth::require_delegated_token(&client.token, "Sending Teams messages")?; @@ -308,6 +312,7 @@ pub async fn run( let identities = resolve_mentions(&client, &mention).await?; ensure_no_raw_at_markup(&content_type, &content)?; let mut req = build_send_request(content, &content_type, adaptive_card.as_deref())?; + req.subject = subject; apply_mentions(&mut req, &identities)?; let msg = if let Some(chat_id) = chat { @@ -720,6 +725,7 @@ fn build_send_request( adaptive_card_path: Option<&str>, ) -> Result { let mut req = SendMessageRequest { + subject: None, body: ItemBody { content_type: Some(content_type.to_string()), content: Some(content), @@ -1046,6 +1052,7 @@ mod tests { fn html_request(body: &str) -> SendMessageRequest { SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("html".into()), content: Some(body.into()), @@ -1145,6 +1152,7 @@ mod tests { #[test] fn text_body_is_promoted_to_html_with_line_breaks_intact() { let mut req = SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("text".into()), content: Some("line one\nline & three".into()), @@ -1179,6 +1187,7 @@ mod tests { #[test] fn mention_alone_becomes_the_whole_body() { let mut req = SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("text".into()), content: Some(String::new()), @@ -1232,6 +1241,7 @@ mod tests { #[test] fn no_mentions_leaves_the_request_untouched() { let mut text_req = SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("text".into()), content: Some("plain & simple\nbody".into()), diff --git a/src/cli/message_media.rs b/src/cli/message_media.rs index a32b09b..e09ec76 100644 --- a/src/cli/message_media.rs +++ b/src/cli/message_media.rs @@ -230,6 +230,7 @@ mod tests { fn text_request(body: &str) -> SendMessageRequest { SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("text".into()), content: Some(body.into()), diff --git a/src/models/attachment_inventory.rs b/src/models/attachment_inventory.rs index b804582..9fa3b59 100644 --- a/src/models/attachment_inventory.rs +++ b/src/models/attachment_inventory.rs @@ -258,6 +258,7 @@ mod tests { ChatMessage { id: Some("1783503421261".into()), created_date_time: None, + subject: None, from: None, body: Some(ItemBody { content_type: Some("html".into()), diff --git a/src/models/message.rs b/src/models/message.rs index 0acdabb..068f2da 100644 --- a/src/models/message.rs +++ b/src/models/message.rs @@ -9,6 +9,8 @@ pub struct ChatMessage { #[serde(skip_serializing_if = "Option::is_none")] pub created_date_time: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub from: Option, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, @@ -93,6 +95,8 @@ pub struct ChatMessageMentioned { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct SendMessageRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, pub body: ItemBody, #[serde(skip_serializing_if = "Option::is_none")] pub attachments: Option>, @@ -184,6 +188,7 @@ mod tests { let msg = ChatMessage { id: Some("msg1".into()), created_date_time: Some("2024-01-01T00:00:00Z".into()), + subject: None, from: Some(ChatMessageFrom { user: Some(ChatMessageUser { id: Some("u1".into()), @@ -208,6 +213,7 @@ mod tests { #[test] fn send_request_serializes_hosted_contents_with_temporary_id() { let req = SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("html".into()), content: Some(r#"

"#.into()), @@ -233,6 +239,7 @@ mod tests { #[test] fn send_request_serializes_the_exact_mention_shape() { let req = SendMessageRequest { + subject: None, body: ItemBody { content_type: Some("html".into()), content: Some(r#"Sophie Daniels Please review"#.into()), @@ -322,6 +329,54 @@ mod tests { ); } + /// Graph returns `subject` on channel root messages; it must survive a + /// parse/print round trip instead of being dropped from JSON output. + #[test] + fn chat_message_keeps_returned_subject() { + let json = serde_json::json!({ + "id": "1700000000000", + "subject": "Release plan", + "body": { "contentType": "html", "content": "Team, details inside." } + }); + let msg: ChatMessage = serde_json::from_value(json).unwrap(); + assert_eq!(msg.subject.as_deref(), Some("Release plan")); + + let re = serde_json::to_value(&msg).unwrap(); + assert_eq!(re["subject"], "Release plan"); + } + + /// Messages without a subject (every chat message, most replies) must not + /// gain a `"subject": null` field on output. + #[test] + fn chat_message_without_subject_omits_the_field() { + let json = serde_json::json!({ + "id": "1700000000001", + "body": { "contentType": "text", "content": "hi" } + }); + let msg: ChatMessage = serde_json::from_value(json).unwrap(); + assert!(msg.subject.is_none()); + + let re = serde_json::to_value(&msg).unwrap(); + assert!(re.get("subject").is_none()); + } + + #[test] + fn send_request_serializes_subject_at_top_level() { + let req = SendMessageRequest { + subject: Some("Release plan".into()), + body: ItemBody { + content_type: Some("text".into()), + content: Some("Team, details inside.".into()), + }, + attachments: None, + hosted_contents: None, + mentions: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["subject"], "Release plan"); + assert_eq!(json["body"]["content"], "Team, details inside."); + } + #[test] fn reference_attachment_keeps_content_url() { let json = r#"{ diff --git a/tests/cli.rs b/tests/cli.rs index 5896939..2c14b94 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -588,6 +588,49 @@ fn message_send_help_advertises_repeatable_mention_flag() { .stdout(predicate::str::contains("--mention ")); } +#[test] +fn message_send_help_advertises_subject_flag() { + teams() + .args(["message", "send", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--subject ")); +} + +#[test] +fn message_send_rejects_subject_on_chat_messages() { + teams() + .args([ + "message", + "send", + "--chat", + "19:chat@thread.v2", + "--subject", + "Release plan", + "--body", + "hi", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("--subject")); +} + +#[test] +fn message_send_rejects_subject_without_a_channel() { + teams() + .args([ + "message", + "send", + "--subject", + "Release plan", + "--body", + "hi", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("--subject")); +} + #[test] fn message_documented_flags_are_available() { teams() From cb53414efef4492db740140480146b9fcaf9af84 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:48:25 +0100 Subject: [PATCH 2/2] Preserve message subjects in plain and human lists --- CHANGELOG.md | 1 + CLAUDE.md | 1 + README.md | 4 ++- docs/command-reference.md | 2 +- docs/man/teams.1 | 3 ++ src/cli/message.rs | 75 ++++++++++++++++++++++++--------------- src/output/plain.rs | 74 +++++++++++++++++++++++++++++++++----- tests/cli.rs | 23 ++++++++++++ 8 files changed, 144 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7593265..2dd35de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed - Windows builds reserve an 8 MiB main-thread stack, matching Linux and macOS. Windows gives the main thread 1 MiB by default, and building clap's command tree for this many subcommands needs almost all of it in an unoptimized build, so any addition to the `message` command made every debug and test invocation of `teams` on Windows — `--help` included — fail with `thread 'main' has overflowed its stack`, and `cargo test` failed on `windows-latest` while passing on Linux and macOS. A build script now passes `/STACK:8388608` to the MSVC linker (`--stack` on the GNU toolchain). The reservation is address space rather than committed memory, so an idle process costs nothing extra. +- Plain lists retain optional fields that first appear after the first row, including message subjects. Human message lists include a Subject column; JSON still omits absent subjects. - `message list` and `message get` no longer drop the `subject` of a message. The `ChatMessage` model had no `subject` field, so a channel root message's subject — returned by Graph on both reads — silently vanished from every output: a message posted with a subject read back without one. Messages without a subject are unchanged and gain no `"subject": null` noise. diff --git a/CLAUDE.md b/CLAUDE.md index 98b515a..8e7bca1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ Single-crate Rust binary. Key modules: ### Output Contract All commands emit a JSON envelope: `{ "success": bool, "data": ..., "metadata": { "request_id", "timestamp", "duration_ms" } }`. When stdout is a TTY, defaults to human-readable table format. When piped, defaults to JSON. +Plain list columns include keys from every row, with blank cells for absent values. Human message lists include a Subject column; absent message subjects remain omitted in JSON. ### Exit Codes 0=success, 1=general, 2=invalid input, 3=auth, 4=permission denied, 5=not found, 6=rate limited, 7=network, 8=server error, 10=config error diff --git a/README.md b/README.md index c37dd41..ffc3510 100644 --- a/README.md +++ b/README.md @@ -408,7 +408,9 @@ exit code 2 before anything is sent. title Teams shows above the body, the same field the Teams client offers behind "Add a subject". Channel messages only: chat messages have no subject, so `--subject` with `--chat` is rejected as invalid input. `message list` and `message get` return the -subject Graph stores, so a posted subject survives a read-back. +subject Graph stores, so a posted subject survives a read-back. Human message lists +include a Subject column; plain lists include subjects even when the first message +is untitled. JSON continues to omit the subject field when it is absent. ```bash teams message send --chat \ diff --git a/docs/command-reference.md b/docs/command-reference.md index 5157b34..cb3afbd 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -146,7 +146,7 @@ teams message send --team TEAM_ID --channel CHANNEL_ID \ --mention --mention --body "Deploy is going out now." ``` -`--subject TEXT` sets the subject line on a channel root message — the bold title Teams renders above the body, the same field the client offers behind "Add a subject". Channel sends only: chat messages have no subject, so `--subject` with `--chat` (or without `--channel`) is rejected with exit code 2 before anything is sent. The stored subject comes back on `message list` and `message get`. +`--subject TEXT` sets the subject line on a channel root message — the bold title Teams renders above the body, the same field the client offers behind "Add a subject". Channel sends only: chat messages have no subject, so `--subject` with `--chat` (or without `--channel`) is rejected with exit code 2 before anything is sent. The stored subject comes back on `message list` and `message get`. Human lists include a Subject column. Plain lists collect columns across all messages, so a titled message keeps its subject even when the first message is untitled; missing values are blank. JSON omits absent subjects. ```bash # Post a channel message with a subject line diff --git a/docs/man/teams.1 b/docs/man/teams.1 index bc7e745..cf08430 100644 --- a/docs/man/teams.1 +++ b/docs/man/teams.1 @@ -211,6 +211,9 @@ is rejected as invalid input. The subject Graph stores is returned by .B message list and .B message get. +Human message lists include a Subject column. Plain lists include columns +present in any message, with blank cells for missing values, so subjects do +not depend on the first message having a title. JSON omits absent subjects. .SH CHAT COMMANDS .nf teams chat list diff --git a/src/cli/message.rs b/src/cli/message.rs index c4f0e21..ba019ad 100644 --- a/src/cli/message.rs +++ b/src/cli/message.rs @@ -7,8 +7,8 @@ use crate::auth; use crate::config::ConfigFile; use crate::error::{Result, TeamsError}; use crate::models::message::{ - ChatMessageAttachment, ChatMessageMention, ChatMessageMentioned, ChatMessageUser, ItemBody, - SendMessageRequest, + ChatMessage, ChatMessageAttachment, ChatMessageMention, ChatMessageMentioned, ChatMessageUser, + ItemBody, SendMessageRequest, }; use crate::models::user::User; use crate::output::{self, OutputFormat}; @@ -372,33 +372,8 @@ pub async fn run( }; if format == OutputFormat::Human { - let headers = vec!["ID", "From", "Body Preview", "Date"]; - let rows: Vec> = messages - .iter() - .map(|m| { - let from = m - .from - .as_ref() - .and_then(|f| f.user.as_ref()) - .and_then(|u| u.display_name.clone()) - .unwrap_or_default(); - let body_preview = m - .body - .as_ref() - .and_then(|b| b.content.as_ref()) - .map(|c| { - let clean: String = c.chars().take(60).collect(); - clean - }) - .unwrap_or_default(); - vec![ - m.id.clone().unwrap_or_default(), - from, - body_preview, - m.created_date_time.clone().unwrap_or_default(), - ] - }) - .collect(); + let headers = vec!["ID", "From", "Subject", "Body Preview", "Date"]; + let rows: Vec> = messages.iter().map(message_list_row).collect(); output::table::print_table(headers, rows); } else { output::print_success_list(format, &messages, start); @@ -698,6 +673,26 @@ fn require_channel(team: Option, channel: Option) -> Result<(Str Ok((team_id, channel_id)) } +fn message_list_row(message: &ChatMessage) -> Vec { + vec![ + message.id.clone().unwrap_or_default(), + message + .from + .as_ref() + .and_then(|from| from.user.as_ref()) + .and_then(|user| user.display_name.clone()) + .unwrap_or_default(), + message.subject.clone().unwrap_or_default(), + message + .body + .as_ref() + .and_then(|body| body.content.as_ref()) + .map(|content| content.chars().take(60).collect()) + .unwrap_or_default(), + message.created_date_time.clone().unwrap_or_default(), + ] +} + fn resolve_body(body: Option, stdin: bool) -> Result { if stdin { let mut buf = String::new(); @@ -937,6 +932,28 @@ fn escape_body_text(text: &str) -> String { mod tests { use super::*; + #[test] + fn human_message_row_preserves_subject_and_body() { + let mut message: ChatMessage = serde_json::from_value(serde_json::json!({ + "id": "message-id", "subject": "Release α & ", + "from": {"user": {"displayName": "Example User"}}, + "body": {"content": "Details"}, "createdDateTime": "2026-01-01T00:00:00Z" + })) + .unwrap(); + assert_eq!( + message_list_row(&message), + [ + "message-id", + "Example User", + "Release α & ", + "Details", + "2026-01-01T00:00:00Z" + ] + ); + message.subject = None; + assert_eq!(message_list_row(&message)[2], ""); + } + fn write_card(dir: &std::path::Path) -> String { let path = dir.join("card.json"); std::fs::write( diff --git a/src/output/plain.rs b/src/output/plain.rs index 2be2f91..db95668 100644 --- a/src/output/plain.rs +++ b/src/output/plain.rs @@ -23,6 +23,10 @@ pub fn print_object(data: &T) { /// Print a list of objects as TSV with a header row. pub fn print_list(items: &[T]) { + write_list(items, super::write_stdout_line); +} + +fn write_list(items: &[T], mut write_line: impl FnMut(&str)) { if items.is_empty() { return; } @@ -33,27 +37,81 @@ pub fn print_list(items: &[T]) { .filter_map(|item| serde_json::to_value(item).ok()) .collect(); - // Extract headers from the first object - let headers: Vec = match values.first() { - Some(serde_json::Value::Object(map)) => map.keys().cloned().collect(), - _ => return, - }; + if !matches!(values.first(), Some(serde_json::Value::Object(_))) { + return; + } + // Optional fields can first appear in any row. Keep the same sorted key + // order as serde's object maps, but include columns from every object. + let headers: std::collections::BTreeSet<&String> = values + .iter() + .filter_map(serde_json::Value::as_object) + .flat_map(|map| map.keys()) + .collect(); + let headers: Vec<&str> = headers.into_iter().map(String::as_str).collect(); // Print header row - super::write_stdout_line(&headers.join("\t")); + write_line(&headers.join("\t")); // Print data rows for val in &values { if let serde_json::Value::Object(map) = val { let row: Vec = headers .iter() - .map(|h| match map.get(h) { + .map(|h| match map.get(*h) { Some(serde_json::Value::Null) | None => String::new(), Some(serde_json::Value::String(s)) => s.clone(), Some(other) => other.to_string(), }) .collect(); - super::write_stdout_line(&row.join("\t")); + write_line(&row.join("\t")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::message::ChatMessage; + + #[test] + fn message_subject_column_does_not_depend_on_first_message() { + let untitled: ChatMessage = serde_json::from_value(serde_json::json!({ + "id": "untitled", "subject": null + })) + .unwrap(); + let titled: ChatMessage = serde_json::from_value(serde_json::json!({ + "id": "titled", "subject": "Release α & " + })) + .unwrap(); + for messages in [ + vec![untitled.clone(), titled.clone()], + vec![titled, untitled], + ] { + let mut lines = Vec::new(); + write_list(&messages, |line| lines.push(line.to_owned())); + assert_eq!(lines[0], "id\tsubject"); + assert!(lines.iter().any(|line| line == "untitled\t")); + assert!(lines + .iter() + .any(|line| line == "titled\tRelease α & ")); + } + } + + #[test] + fn list_keeps_nested_values_and_blanks_for_missing_columns() { + let values = [ + serde_json::json!({"a": 1}), + serde_json::json!({"b": {"nested": true}}), + ]; + let mut lines = Vec::new(); + write_list(&values, |line| lines.push(line.to_owned())); + assert_eq!(lines, ["a\tb", "1\t", "\t{\"nested\":true}"]); + } + + #[test] + fn empty_and_non_object_lists_produce_no_output() { + for values in [vec![], vec![serde_json::json!(1)]] { + write_list(&values, |_| panic!("unexpected output")); } } } diff --git a/tests/cli.rs b/tests/cli.rs index 2c14b94..5a4259c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -597,6 +597,29 @@ fn message_send_help_advertises_subject_flag() { .stdout(predicate::str::contains("--subject ")); } +#[test] +fn help_json_includes_message_subject_flag() { + let result = teams().arg("--help-json").assert().success(); + let help: serde_json::Value = serde_json::from_slice(&result.get_output().stdout).unwrap(); + let message = help["commands"] + .as_array() + .unwrap() + .iter() + .find(|command| command["name"] == "message") + .unwrap(); + let send = message["subcommands"] + .as_array() + .unwrap() + .iter() + .find(|command| command["name"] == "send") + .unwrap(); + assert!(send["flags"] + .as_array() + .unwrap() + .iter() + .any(|flag| flag["name"] == "--subject")); +} + #[test] fn message_send_rejects_subject_on_chat_messages() { teams()