diff --git a/AGENTS.md b/AGENTS.md index 7240b52..dcaf875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Most command modules follow this shape: 6. Call `api::::...`. 7. Print through `output::print_success`, `print_success_list`, or a table helper. -For paginated list commands, pass `&PaginationOpts` from `src/cli/mod.rs` and use `client.get_paged` in the API layer. +For paginated list commands, pass `&PaginationOpts` from `src/cli/mod.rs` and use `client.get_paged` in the API layer. It returns a `Page`; print it with `output::print_success_page` (and `output::print_more_hint` after a table) so `metadata.has_more`/`next_link` reach the caller. For new command domains, update: diff --git a/CHANGELOG.md b/CHANGELOG.md index bc1b07a..30e984c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- List commands without `--all-pages` report whether the result is complete: JSON `metadata.has_more`, plus `metadata.next_link` (Graph's `@odata.nextLink`) when more pages exist. Previously a truncated page looked identical to a complete collection. With `--all-pages`, `has_more` is false. `data` is unchanged. Human and plain output print "More results available; use --all-pages" on stderr. + ### Changed - `Retry-After` is read in its HTTP-date form as well as seconds, honored on retried 5xx responses as well as 429, and capped at 300 seconds. diff --git a/CLAUDE.md b/CLAUDE.md index e294edd..2b06f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ Single-crate Rust binary. Key modules: ### Output Contract All commands emit a JSON envelope: `{ "success": bool, "data": ..., "metadata": { "request_id", "timestamp", "duration_ms" } }`. +Paged list results add `metadata.has_more` and, when true, `metadata.next_link`; human/plain output prints a stderr hint instead. 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. diff --git a/README.md b/README.md index c07ae5f..33a1730 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Every command, when piped or called programmatically, returns a **JSON envelope* } ``` +Paged list results add `metadata.has_more` (more pages exist) and, when true, `metadata.next_link` (Graph's `@odata.nextLink`). With `--all-pages`, `has_more` is false. Human and plain output note truncation on stderr. + On failure: ```json diff --git a/docs/README.md b/docs/README.md index 95bdc5d..8a15161 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,8 @@ When stdout is piped or called by an agent, the CLI defaults to JSON: } ``` +Paged list results add `metadata.has_more` and, when true, `metadata.next_link` (Graph's `@odata.nextLink`); `--all-pages` gives `has_more: false`. + Errors use the same envelope shape with `success: false` and a stable error code. See [command reference](command-reference.md#exit-codes) and `teams-agent-contract(7)`. ## Official Microsoft references diff --git a/docs/man/teams-agent-contract.7 b/docs/man/teams-agent-contract.7 index d28b5e7..ba5f763 100644 --- a/docs/man/teams-agent-contract.7 +++ b/docs/man/teams-agent-contract.7 @@ -102,6 +102,20 @@ code 7 and a hint that it was performed; do not re-send it. List commands use .B --page-size and can follow all pages with .B --all-pages. +.PP +Paged list results add +.B metadata.has_more +(true when Graph has further pages) and, only when true, +.B metadata.next_link +(Graph's +.B @odata.nextLink\c +). With +.B --all-pages +.B has_more +is false. +.B data +is unchanged. Human and plain output print +"More results available; use --all-pages" on stderr instead. .SH AUTHENTICATION FOR AGENTS For automation that posts normal Teams chat or channel messages, use a delegated profile or a future Teams bot mode. Microsoft Graph app-only tokens diff --git a/src/api/apps.rs b/src/api/apps.rs index d78a8d0..cfa6e4b 100644 --- a/src/api/apps.rs +++ b/src/api/apps.rs @@ -2,6 +2,7 @@ use crate::error::Result; use crate::models::app::{ CreateTabRequest, InstallAppRequest, TeamsAppInstallation, TeamsTab, UpdateTabRequest, }; +use crate::models::common::Page; use super::client::{GraphClient, PaginationOpts}; use super::endpoints; @@ -10,7 +11,7 @@ pub async fn list_team_apps( client: &GraphClient, team_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged( &endpoints::team_installed_apps(team_id), @@ -45,7 +46,7 @@ pub async fn list_tabs( team_id: &str, channel_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged( &endpoints::channel_tabs(team_id, channel_id), diff --git a/src/api/channels.rs b/src/api/channels.rs index 97b1608..bb1c759 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -1,5 +1,6 @@ use crate::error::Result; use crate::models::channel::{Channel, ChannelCreateRequest, ChannelUpdateRequest}; +use crate::models::common::Page; use crate::models::member::{AddMemberRequest, ConversationMember}; use super::client::{GraphClient, PaginationOpts}; @@ -9,7 +10,7 @@ pub async fn list_channels( client: &GraphClient, team_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { list_channels_at(client, &endpoints::channels(team_id), pagination).await } @@ -17,7 +18,7 @@ async fn list_channels_at( client: &GraphClient, url: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client.get_paged_without_top(url, &[], pagination).await } @@ -57,7 +58,7 @@ pub async fn list_members( team_id: &str, channel_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged( &endpoints::channel_members(team_id, channel_id), @@ -145,7 +146,8 @@ mod tests { }, ) .await - .unwrap(); + .unwrap() + .items; assert_eq!(channels.len(), 1); assert_eq!(channels[0].display_name.as_deref(), Some("General")); diff --git a/src/api/chats.rs b/src/api/chats.rs index 2db8957..7118823 100644 --- a/src/api/chats.rs +++ b/src/api/chats.rs @@ -1,5 +1,6 @@ use crate::error::Result; use crate::models::chat::{Chat, ChatCreateRequest, ChatUpdateRequest}; +use crate::models::common::Page; use crate::models::member::{AddMemberRequest, ConversationMember}; use super::client::{GraphClient, PaginationOpts}; @@ -31,7 +32,7 @@ pub async fn list_chats( client: &GraphClient, order: ChatOrder, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { list_chats_at(client, &endpoints::my_chats(), order, pagination).await } @@ -40,7 +41,7 @@ async fn list_chats_at( url: &str, order: ChatOrder, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client.get_paged(url, order.query(), pagination).await } @@ -66,7 +67,7 @@ pub async fn list_members( client: &GraphClient, chat_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { list_members_at(client, &endpoints::chat_members(chat_id), pagination).await } @@ -76,7 +77,7 @@ pub(crate) async fn list_members_at( client: &GraphClient, url: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client.get_paged_without_top(url, &[], pagination).await } @@ -173,7 +174,8 @@ mod tests { }, ) .await - .unwrap(); + .unwrap() + .items; assert_eq!(chats.len(), 1); } @@ -201,7 +203,8 @@ mod tests { }, ) .await - .unwrap(); + .unwrap() + .items; assert_eq!(chats.len(), 1); } @@ -233,7 +236,8 @@ mod tests { }, ) .await - .unwrap(); + .unwrap() + .items; assert_eq!(members.len(), 1); assert_eq!(members[0].display_name.as_deref(), Some("Alice")); diff --git a/src/api/client.rs b/src/api/client.rs index 5ead728..3c16a3c 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -7,7 +7,7 @@ use serde::de::DeserializeOwned; use crate::auth::token::TokenInfo; use crate::config::NetworkConfig; use crate::error::{Result, TeamsError, UNCONFIRMED_HINT}; -use crate::models::common::PageResponse; +use crate::models::common::{Page, PageResponse}; /// Options for paginated API requests. #[derive(Debug, Clone)] @@ -153,22 +153,19 @@ impl GraphClient { } /// GET with pagination support. Appends `$top` and optionally follows all pages. + /// A single page keeps Graph's `@odata.nextLink`. pub async fn get_paged( &self, url: &str, query: &[(&str, &str)], pagination: &PaginationOpts, - ) -> Result> { + ) -> Result> { let top_str = pagination.page_size.to_string(); let mut full_query: Vec<(&str, &str)> = query.to_vec(); full_query.push(("$top", &top_str)); - if pagination.all_pages { - self.get_all_pages(url, &full_query).await - } else { - let resp: PageResponse = self.get(url, &full_query).await?; - Ok(resp.value) - } + self.get_paged_without_top(url, &full_query, pagination) + .await } /// GET with pagination support for endpoints that reject `$top`. @@ -177,12 +174,12 @@ impl GraphClient { url: &str, query: &[(&str, &str)], pagination: &PaginationOpts, - ) -> Result> { + ) -> Result> { if pagination.all_pages { - self.get_all_pages(url, query).await + Ok(Page::complete(self.get_all_pages(url, query).await?)) } else { let resp: PageResponse = self.get(url, query).await?; - Ok(resp.value) + Ok(resp.into()) } } @@ -947,7 +944,8 @@ mod tests { }, ) .await - .unwrap(); + .unwrap() + .items; assert_eq!(channels.len(), 1); assert_eq!(channels[0]["displayName"], "General"); @@ -988,7 +986,8 @@ mod tests { }, ) .await - .unwrap(); + .unwrap() + .items; assert_eq!(channels.len(), 2); assert_eq!(channels[0]["id"], "channel-1"); @@ -1601,4 +1600,138 @@ mod tests { assert_eq!(updated, serde_json::json!({ "id": "t1" })); } } + /// Mounts a two-page `/me/chats` collection; returns the second page's URL. + async fn mount_two_pages(server: &MockServer) -> String { + let next = format!("{}/me/chats?$skiptoken=abc", server.uri()); + Mock::given(method("GET")) + .and(path("/me/chats")) + .and(query_param_is_missing("$skiptoken")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [{ "id": "chat-1" }], + "@odata.nextLink": next + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/me/chats")) + .and(query_param("$skiptoken", "abc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [{ "id": "chat-2" }] + }))) + .mount(server) + .await; + next + } + + fn one_page() -> PaginationOpts { + PaginationOpts { + page_size: 1, + all_pages: false, + } + } + + #[tokio::test] + async fn get_paged_keeps_next_link_of_a_truncated_page() { + let server = MockServer::start().await; + let next = mount_two_pages(&server).await; + + let page = test_client() + .get_paged::(&format!("{}/me/chats", server.uri()), &[], &one_page()) + .await + .unwrap(); + + assert_eq!(page.items, vec![serde_json::json!({ "id": "chat-1" })]); + assert_eq!(page.next_link.as_deref(), Some(next.as_str())); + } + + #[tokio::test] + async fn get_paged_reports_no_next_link_on_the_last_page() { + let server = MockServer::start().await; + let next = mount_two_pages(&server).await; + + let page = test_client() + .get_paged::(&next, &[], &one_page()) + .await + .unwrap(); + + assert_eq!(page.items, vec![serde_json::json!({ "id": "chat-2" })]); + assert!(page.next_link.is_none()); + } + + #[tokio::test] + async fn get_paged_all_pages_follows_links_and_reports_no_next_link() { + let server = MockServer::start().await; + mount_two_pages(&server).await; + + let page = test_client() + .get_paged::( + &format!("{}/me/chats", server.uri()), + &[], + &PaginationOpts { + page_size: 1, + all_pages: true, + }, + ) + .await + .unwrap(); + + assert_eq!( + page.items, + vec![ + serde_json::json!({ "id": "chat-1" }), + serde_json::json!({ "id": "chat-2" }) + ] + ); + assert!(page.next_link.is_none()); + } + #[tokio::test] + async fn review_empty_page_with_opaque_next_link_is_not_terminal() { + let server = MockServer::start().await; + let next = format!( + "{}/me/chats?$skiptoken=opaque%2Btoken%2F%3D&$top=7", + server.uri() + ); + Mock::given(method("GET")) + .and(path("/me/chats")) + .and(query_param_is_missing("$skiptoken")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [], "@odata.nextLink": next + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/me/chats")) + .and(query_param("$skiptoken", "opaque+token/=")) + .and(query_param("$top", "7")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [{"id": "last"}] + }))) + .mount(&server) + .await; + let url = format!("{}/me/chats", server.uri()); + let single = test_client() + .get_paged::(&url, &[], &one_page()) + .await + .unwrap(); + assert!(single.items.is_empty()); + assert_eq!(single.next_link.as_deref(), Some(next.as_str())); + let all = test_client() + .get_paged::( + &url, + &[], + &PaginationOpts { + page_size: 1, + all_pages: true, + }, + ) + .await + .unwrap(); + assert_eq!(all.items, vec![serde_json::json!({"id":"last"})]); + assert!(all.next_link.is_none()); + let requests = server.received_requests().await.unwrap(); + assert_eq!( + requests.last().unwrap().url.query(), + Some("$skiptoken=opaque%2Btoken%2F%3D&$top=7") + ); + } } diff --git a/src/api/files.rs b/src/api/files.rs index dbecb5d..2c6ec84 100644 --- a/src/api/files.rs +++ b/src/api/files.rs @@ -1,5 +1,5 @@ use crate::error::{Result, TeamsError}; -use crate::models::common::PageResponse; +use crate::models::common::{Page, PageResponse}; use crate::models::file::{ DriveInviteRequest, DriveItem, DrivePermission, DriveRecipient, FilesFolder, ShareLinkRequest, ShareLinkResponse, @@ -25,7 +25,7 @@ pub async fn list_files( team_id: &str, channel_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { let folder = get_files_folder(client, team_id, channel_id).await?; let drive_id = folder .parent_reference diff --git a/src/api/meetings.rs b/src/api/meetings.rs index 0b43b61..a183972 100644 --- a/src/api/meetings.rs +++ b/src/api/meetings.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use crate::models::common::Page; use crate::models::meeting::{ AttendanceRecord, CreateMeetingRequest, MeetingAttendanceReport, OnlineMeeting, UpdateMeetingRequest, @@ -10,7 +11,7 @@ use super::endpoints; pub async fn list_meetings( client: &GraphClient, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged(&endpoints::my_online_meetings(), &[], pagination) .await @@ -47,7 +48,7 @@ pub async fn list_attendance_reports( client: &GraphClient, meeting_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged( &endpoints::meeting_attendance_reports(meeting_id), @@ -76,7 +77,7 @@ pub async fn list_attendance_records( meeting_id: &str, report_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged( &endpoints::attendance_records(meeting_id, report_id), diff --git a/src/api/messages.rs b/src/api/messages.rs index d60c67d..99d60e4 100644 --- a/src/api/messages.rs +++ b/src/api/messages.rs @@ -1,4 +1,5 @@ use crate::error::{Result, TeamsError}; +use crate::models::common::Page; use crate::models::message::{ ChatMessage, ChatMessageHostedContent, PinMessageRequest, PinnedMessage, ReactionRequest, ReplyWithQuoteRequest, SendMessageRequest, @@ -164,7 +165,7 @@ pub async fn list_channel_messages( team_id: &str, channel_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged( &endpoints::channel_messages(team_id, channel_id), @@ -273,7 +274,7 @@ pub async fn list_channel_message_replies( channel_id: &str, message_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { list_channel_message_replies_at( client, &endpoints::channel_message_replies(team_id, channel_id, message_id), @@ -286,7 +287,7 @@ async fn list_channel_message_replies_at( client: &GraphClient, url: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client.get_paged(url, &[], pagination).await } @@ -294,7 +295,7 @@ pub async fn list_chat_messages( client: &GraphClient, chat_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged(&endpoints::chat_messages(chat_id), &[], pagination) .await @@ -895,7 +896,8 @@ mod tests { }, ) .await - .unwrap(); + .unwrap() + .items; assert_eq!(replies.len(), 2); assert_eq!(replies[0].id.as_deref(), Some("1700000000002")); diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index 21de584..893856c 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use crate::models::common::Page; use crate::models::subscription::{ CreateSubscriptionRequest, RenewSubscriptionRequest, Subscription, }; @@ -16,7 +17,7 @@ pub async fn create_subscription( pub async fn list_subscriptions( client: &GraphClient, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged(&endpoints::subscriptions(), &[], pagination) .await diff --git a/src/api/tags.rs b/src/api/tags.rs index 17c65be..7f5073b 100644 --- a/src/api/tags.rs +++ b/src/api/tags.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use crate::models::common::Page; use crate::models::tag::{ AddTagMemberRequest, CreateTagRequest, TeamworkTag, TeamworkTagMember, UpdateTagRequest, }; @@ -10,7 +11,7 @@ pub async fn list_tags( client: &GraphClient, team_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged(&endpoints::team_tags(team_id), &[], pagination) .await @@ -49,7 +50,7 @@ pub async fn list_tag_members( team_id: &str, tag_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged(&endpoints::tag_members(team_id, tag_id), &[], pagination) .await diff --git a/src/api/teams.rs b/src/api/teams.rs index 91fccd4..3c337f5 100644 --- a/src/api/teams.rs +++ b/src/api/teams.rs @@ -1,5 +1,5 @@ use crate::error::Result; -use crate::models::common::PageResponse; +use crate::models::common::Page; use crate::models::member::{AddMemberRequest, ConversationMember}; use crate::models::team::{Team, TeamCloneRequest, TeamCreateRequest, TeamUpdateRequest}; @@ -9,15 +9,10 @@ use super::endpoints; pub async fn list_joined_teams( client: &GraphClient, pagination: &PaginationOpts, -) -> Result> { - if pagination.all_pages { - client - .get_all_pages(&endpoints::my_joined_teams(), &[]) - .await - } else { - let page: PageResponse = client.get(&endpoints::my_joined_teams(), &[]).await?; - Ok(page.value) - } +) -> Result> { + client + .get_paged_without_top(&endpoints::my_joined_teams(), &[], pagination) + .await } pub async fn get_team(client: &GraphClient, id: &str) -> Result { @@ -62,7 +57,7 @@ pub async fn list_members( client: &GraphClient, team_id: &str, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { client .get_paged(&endpoints::team_members(team_id), &[], pagination) .await diff --git a/src/api/users.rs b/src/api/users.rs index 941a0e4..20df547 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use crate::models::common::Page; use crate::models::user::User; use super::client::{GraphClient, PaginationOpts}; @@ -20,7 +21,7 @@ pub async fn list_users( client: &GraphClient, filter: Option<&str>, pagination: &PaginationOpts, -) -> Result> { +) -> Result> { let mut query: Vec<(&str, &str)> = vec![]; if let Some(f) = filter { query.push(("$filter", f)); diff --git a/src/cli/app.rs b/src/cli/app.rs index 7916822..1d339ec 100644 --- a/src/cli/app.rs +++ b/src/cli/app.rs @@ -50,6 +50,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["Installation ID", "App Name", "Version", "Distribution"]; let rows: Vec> = apps + .items .iter() .map(|a| { let app = a.teams_app.as_ref(); @@ -64,8 +65,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&apps); } else { - output::print_success_list(format, &apps, start); + output::print_success_page(format, &apps, start); } Ok(()) } diff --git a/src/cli/channel.rs b/src/cli/channel.rs index 74a9d23..9fdb4de 100644 --- a/src/cli/channel.rs +++ b/src/cli/channel.rs @@ -114,6 +114,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["ID", "Display Name", "Type", "Email"]; let rows: Vec> = channels + .items .iter() .map(|c| { vec![ @@ -125,8 +126,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&channels); } else { - output::print_success_list(format, &channels, start); + output::print_success_page(format, &channels, start); } Ok(()) } @@ -209,6 +211,7 @@ async fn run_members( if format == OutputFormat::Human { let headers = vec!["ID", "Display Name", "Roles", "Email"]; let rows: Vec> = members + .items .iter() .map(|m| { vec![ @@ -220,8 +223,9 @@ async fn run_members( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&members); } else { - output::print_success_list(format, &members, start); + output::print_success_page(format, &members, start); } Ok(()) } diff --git a/src/cli/chat.rs b/src/cli/chat.rs index e8866e7..b791fb8 100644 --- a/src/cli/chat.rs +++ b/src/cli/chat.rs @@ -115,10 +115,15 @@ pub async fn run( ChatOrder::Default => vec!["ID", "Topic", "Type", "Last Updated"], ChatOrder::Activity => vec!["ID", "Topic", "Type", "Last Message", "From"], }; - let rows = chats.iter().map(|c| chat_list_row(c, order)).collect(); + let rows = chats + .items + .iter() + .map(|c| chat_list_row(c, order)) + .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&chats); } else { - output::print_success_list(format, &chats, start); + output::print_success_page(format, &chats, start); } Ok(()) } @@ -255,6 +260,7 @@ async fn run_members( if format == OutputFormat::Human { let headers = vec!["ID", "Display Name", "Roles", "Email"]; let rows: Vec> = members + .items .iter() .map(|m| { vec![ @@ -266,8 +272,9 @@ async fn run_members( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&members); } else { - output::print_success_list(format, &members, start); + output::print_success_page(format, &members, start); } Ok(()) } diff --git a/src/cli/file.rs b/src/cli/file.rs index 938d5f3..47661a7 100644 --- a/src/cli/file.rs +++ b/src/cli/file.rs @@ -113,6 +113,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["ID", "Name", "Size", "Type", "Modified"]; let rows: Vec> = files + .items .iter() .map(|f| { let file_type = if f.folder.is_some() { "folder" } else { "file" }; @@ -126,8 +127,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&files); } else { - output::print_success_list(format, &files, start); + output::print_success_page(format, &files, start); } Ok(()) } diff --git a/src/cli/meeting.rs b/src/cli/meeting.rs index 4aed776..afbeb09 100644 --- a/src/cli/meeting.rs +++ b/src/cli/meeting.rs @@ -86,6 +86,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["ID", "Subject", "Start", "End", "Join URL"]; let rows: Vec> = meetings + .items .iter() .map(|m| { vec![ @@ -98,8 +99,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&meetings); } else { - output::print_success_list(format, &meetings, start); + output::print_success_page(format, &meetings, start); } Ok(()) } @@ -183,6 +185,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["ID", "Email", "Role", "Attendance (s)"]; let rows: Vec> = records + .items .iter() .map(|r| { vec![ @@ -196,8 +199,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&records); } else { - output::print_success_list(format, &records, start); + output::print_success_page(format, &records, start); } } else { let reports = @@ -206,6 +210,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["Report ID", "Participants", "Start", "End"]; let rows: Vec> = reports + .items .iter() .map(|r| { vec![ @@ -219,8 +224,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&reports); } else { - output::print_success_list(format, &reports, start); + output::print_success_page(format, &reports, start); } } Ok(()) diff --git a/src/cli/message.rs b/src/cli/message.rs index 421723b..383cd07 100644 --- a/src/cli/message.rs +++ b/src/cli/message.rs @@ -476,10 +476,11 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["ID", "From", "Subject", "Body Preview", "Date"]; - let rows: Vec> = messages.iter().map(message_list_row).collect(); + let rows: Vec> = messages.items.iter().map(message_list_row).collect(); output::table::print_table(headers, rows); + output::print_more_hint(&messages); } else { - output::print_success_list(format, &messages, start); + output::print_success_page(format, &messages, start); } Ok(()) } diff --git a/src/cli/message_media.rs b/src/cli/message_media.rs index 10b28b6..5193cb5 100644 --- a/src/cli/message_media.rs +++ b/src/cli/message_media.rs @@ -134,7 +134,7 @@ async fn chat_recipients_at( }, ) .await?; - Ok(invite_recipients(&members, me.id.as_deref())) + Ok(invite_recipients(&members.items, me.id.as_deref())) } .await; match lookup { diff --git a/src/cli/subscribe.rs b/src/cli/subscribe.rs index fad8740..d70bf94 100644 --- a/src/cli/subscribe.rs +++ b/src/cli/subscribe.rs @@ -92,6 +92,7 @@ pub async fn run( "Notification URL", ]; let rows: Vec> = subs + .items .iter() .map(|s| { vec![ @@ -104,8 +105,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&subs); } else { - output::print_success_list(format, &subs, start); + output::print_success_page(format, &subs, start); } Ok(()) } diff --git a/src/cli/tab.rs b/src/cli/tab.rs index 858cdb9..04875cc 100644 --- a/src/cli/tab.rs +++ b/src/cli/tab.rs @@ -65,6 +65,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["Tab ID", "Display Name", "App Name", "Web URL"]; let rows: Vec> = tabs + .items .iter() .map(|t| { vec![ @@ -79,8 +80,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&tabs); } else { - output::print_success_list(format, &tabs, start); + output::print_success_page(format, &tabs, start); } Ok(()) } diff --git a/src/cli/tag.rs b/src/cli/tag.rs index 27fd816..6a4b366 100644 --- a/src/cli/tag.rs +++ b/src/cli/tag.rs @@ -91,6 +91,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["ID", "Display Name", "Description", "Member Count"]; let rows: Vec> = tags + .items .iter() .map(|t| { vec![ @@ -102,8 +103,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&tags); } else { - output::print_success_list(format, &tags, start); + output::print_success_page(format, &tags, start); } Ok(()) } diff --git a/src/cli/team.rs b/src/cli/team.rs index 5b7f542..fec7821 100644 --- a/src/cli/team.rs +++ b/src/cli/team.rs @@ -121,6 +121,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["ID", "Display Name", "Description", "Archived"]; let rows: Vec> = teams + .items .iter() .map(|t| { vec![ @@ -132,8 +133,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&teams); } else { - output::print_success_list(format, &teams, start); + output::print_success_page(format, &teams, start); } Ok(()) } @@ -242,6 +244,7 @@ async fn run_members( if format == OutputFormat::Human { let headers = vec!["ID", "Display Name", "Roles", "Email"]; let rows: Vec> = members + .items .iter() .map(|m| { vec![ @@ -253,8 +256,9 @@ async fn run_members( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&members); } else { - output::print_success_list(format, &members, start); + output::print_success_page(format, &members, start); } Ok(()) } diff --git a/src/cli/user.rs b/src/cli/user.rs index c0a5bf2..f21e416 100644 --- a/src/cli/user.rs +++ b/src/cli/user.rs @@ -66,6 +66,7 @@ pub async fn run( if format == OutputFormat::Human { let headers = vec!["ID", "Display Name", "Email", "Job Title"]; let rows: Vec> = users + .items .iter() .map(|u| { vec![ @@ -80,8 +81,9 @@ pub async fn run( }) .collect(); output::table::print_table(headers, rows); + output::print_more_hint(&users); } else { - output::print_success_list(format, &users, start); + output::print_success_page(format, &users, start); } Ok(()) } diff --git a/src/models/common.rs b/src/models/common.rs index 84d89d4..8fa0c9f 100644 --- a/src/models/common.rs +++ b/src/models/common.rs @@ -8,6 +8,9 @@ pub struct Metadata { pub api_version: Option, #[serde(skip_serializing_if = "Option::is_none")] pub duration_ms: Option, + /// Set on paged list results: true when Graph has further pages. + #[serde(skip_serializing_if = "Option::is_none")] + pub has_more: Option, #[serde(skip_serializing_if = "Option::is_none")] pub next_link: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -21,6 +24,7 @@ impl Metadata { timestamp: chrono::Utc::now().to_rfc3339(), api_version: Some("v1.0".to_string()), duration_ms: None, + has_more: None, next_link: None, total_count: None, } @@ -30,6 +34,12 @@ impl Metadata { self.duration_ms = Some(ms); self } + + pub fn with_page(mut self, next_link: Option<&str>) -> Self { + self.has_more = Some(next_link.is_some()); + self.next_link = next_link.map(str::to_string); + self + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -109,6 +119,31 @@ pub struct PageResponse { pub next_link: Option, } +/// A list result: one page, or every page with `next_link` None. +#[derive(Debug, Clone)] +pub struct Page { + pub items: Vec, + pub next_link: Option, +} + +impl Page { + pub fn complete(items: Vec) -> Self { + Self { + items, + next_link: None, + } + } +} + +impl From> for Page { + fn from(resp: PageResponse) -> Self { + Self { + items: resp.value, + next_link: resp.next_link, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -138,6 +173,20 @@ mod tests { assert_eq!(json["error"]["message"], "bad credentials"); } + #[test] + fn page_metadata_reports_continuation() { + let more = serde_json::to_value(Metadata::new().with_page(Some("https://next"))).unwrap(); + assert_eq!(more["has_more"], true); + assert_eq!(more["next_link"], "https://next"); + + let last = serde_json::to_value(Metadata::new().with_page(None)).unwrap(); + assert_eq!(last["has_more"], false); + assert!(last.get("next_link").is_none()); + + let plain = serde_json::to_value(Metadata::new()).unwrap(); + assert!(plain.get("has_more").is_none()); + } + #[test] fn metadata_has_uuid_and_timestamp() { let meta = Metadata::new(); diff --git a/src/output/mod.rs b/src/output/mod.rs index 5dfacf7..e9bbd8e 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -4,7 +4,7 @@ pub mod progress; pub mod table; use crate::error::TeamsError; -use crate::models::common::{Envelope, Metadata}; +use crate::models::common::{Envelope, Metadata, Page}; use serde::Serialize; use std::io::{self, IsTerminal, Write}; use std::time::Instant; @@ -99,6 +99,36 @@ pub fn print_success_list(format: OutputFormat, data: &[T], start: } } +/// Print a paged list result. JSON metadata carries `has_more` and +/// `next_link`; other formats note truncation on stderr. +pub fn print_success_page(format: OutputFormat, page: &Page, start: Instant) { + match format { + OutputFormat::Json => { + json::print(&page_envelope(page, start.elapsed().as_millis() as u64)); + } + OutputFormat::Human | OutputFormat::Plain => { + print_success_list(format, &page.items, start); + print_more_hint(page); + } + } +} + +fn page_envelope(page: &Page, duration_ms: u64) -> Envelope<&[T]> { + let metadata = Metadata::new() + .with_duration(duration_ms) + .with_page(page.next_link.as_deref()); + Envelope::success(page.items.as_slice(), metadata) +} + +/// Stderr footer for a truncated list; for table output. +pub fn print_more_hint(page: &Page) { + if page.next_link.is_some() { + eprintln!("{MORE_RESULTS_HINT}"); + } +} + +const MORE_RESULTS_HINT: &str = "More results available; use --all-pages"; + pub fn print_error(format: OutputFormat, err: &TeamsError) { let metadata = Metadata::new(); match format { @@ -116,3 +146,49 @@ pub fn print_error(format: OutputFormat, err: &TeamsError) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn items() -> Vec { + vec![ + serde_json::json!({ "id": "a" }), + serde_json::json!({ "id": "b" }), + ] + } + + fn render(next_link: Option<&str>) -> serde_json::Value { + let page = Page { + items: items(), + next_link: next_link.map(str::to_string), + }; + serde_json::to_value(page_envelope(&page, 1)).unwrap() + } + + #[test] + fn truncated_page_reports_has_more_and_next_link() { + let json = render(Some( + "https://graph.microsoft.com/v1.0/me/chats?$skiptoken=x", + )); + assert_eq!(json["metadata"]["has_more"], true); + assert_eq!( + json["metadata"]["next_link"], + "https://graph.microsoft.com/v1.0/me/chats?$skiptoken=x" + ); + } + + #[test] + fn complete_page_reports_no_more_and_omits_next_link() { + let json = render(None); + assert_eq!(json["metadata"]["has_more"], false); + assert!(json["metadata"].get("next_link").is_none()); + } + + #[test] + fn page_data_matches_plain_list_envelope() { + let list = serde_json::to_value(Envelope::success(&items(), Metadata::new())).unwrap(); + assert_eq!(render(Some("https://next"))["data"], list["data"]); + assert_eq!(render(None)["data"], list["data"]); + } +}