The trailer computed shown as page * rows-on-this-page, so a short last page (page 27 of 1331 rows at 50/page, 31 rows) was counted as 837 of 1331 and printed a hint to fetch page 28. A page whose unclamped row offset lands exactly on the total is now recognised as the last one, so the trailer stays silent there. Shown is also clamped to the total.
565 lines
23 KiB
Rust
565 lines
23 KiB
Rust
//! `list [--kind issue|pr|both] [--state open|closed|all] [--assignee
|
|
//! <user>] [--author <user>] [--label <name>] [--milestone <name>]
|
|
//! [--search <text>] [--limit N] [--page N]` —
|
|
//! list issues / PRs with filters. Pretty `#NNN [author] title` output by
|
|
//! default; `--json` for piping. `--limit` is the page size and `--page`
|
|
//! the 1-based page number — walk pages incrementally for a large result
|
|
//! set rather than pulling the whole population into one response.
|
|
//!
|
|
//! Mirrors Forgejo's `GET /repos/{owner}/{repo}/issues` query-string
|
|
//! filters one-for-one so the mental model carries over. Closes the
|
|
//! read-side curl-fallback gap (no boundary concerns —
|
|
//! every agent + the operator queries the issue tracker constantly).
|
|
//!
|
|
//! `--search` is why there is no separate `search` verb: the forge
|
|
//! matches it against title **and body** on this same endpoint, and a
|
|
//! text match is only useful *composed* with the other filters — a
|
|
//! separate verb would have to grow every one of them back.
|
|
//!
|
|
//! Each row in the pretty (non-`--json`) output also carries a
|
|
//! `(N/M deps done)` suffix when the item has dependencies and at least
|
|
//! one is still open — omitted entirely once every dependency is closed
|
|
//! or there are none, so a ready item's row looks exactly like it did
|
|
//! before this existed. Forgejo has no server-side dependency filter on
|
|
//! this endpoint, so this costs one extra request per row shown (not
|
|
//! per match — only the current page).
|
|
|
|
use anyhow::Result;
|
|
use clap::{Args as ClapArgs, ValueEnum};
|
|
use forgejo_api::structs::{
|
|
IssueListIssuesQuery, IssueListIssuesQueryState, IssueListIssuesQueryType,
|
|
};
|
|
use serde_json::Value;
|
|
|
|
use crate::client::Client;
|
|
use crate::verbs::{blocking_summaries, dependency_summaries, labels, milestone, print_json};
|
|
|
|
/// What kind of items to return. Maps onto Forgejo's `type` query
|
|
/// parameter: `issues` / `pulls`, or no filter at all for `both`
|
|
/// (Forgejo returns issues + PRs when `type` is absent — same slice
|
|
/// the forge UI's "Issues" tab shows without a type filter).
|
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
|
#[clap(rename_all = "kebab-case")]
|
|
pub enum Kind {
|
|
/// Issues only (excludes PRs).
|
|
Issue,
|
|
/// Pull requests only.
|
|
Pr,
|
|
/// Issues + pull requests (default).
|
|
Both,
|
|
}
|
|
|
|
impl Kind {
|
|
fn query_type(self) -> Option<IssueListIssuesQueryType> {
|
|
match self {
|
|
Self::Issue => Some(IssueListIssuesQueryType::Issues),
|
|
Self::Pr => Some(IssueListIssuesQueryType::Pulls),
|
|
Self::Both => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// State filter. Matches Forgejo's `state` query parameter
|
|
/// (`open` / `closed` / `all`).
|
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
|
#[clap(rename_all = "kebab-case")]
|
|
pub enum State {
|
|
Open,
|
|
Closed,
|
|
All,
|
|
}
|
|
|
|
impl State {
|
|
fn query_state(self) -> IssueListIssuesQueryState {
|
|
match self {
|
|
Self::Open => IssueListIssuesQueryState::Open,
|
|
Self::Closed => IssueListIssuesQueryState::Closed,
|
|
Self::All => IssueListIssuesQueryState::All,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(ClapArgs)]
|
|
pub struct Args {
|
|
/// What to return: issues, PRs, or both (default: both).
|
|
#[arg(long, value_enum, default_value_t = Kind::Both)]
|
|
kind: Kind,
|
|
/// Issue/PR state (default: open).
|
|
#[arg(long, value_enum, default_value_t = State::Open)]
|
|
state: State,
|
|
/// Filter to items assigned to this user (single login).
|
|
#[arg(long)]
|
|
assignee: Option<String>,
|
|
/// Filter to items authored by this user (single login).
|
|
#[arg(long)]
|
|
author: Option<String>,
|
|
/// Filter to items mentioning this user.
|
|
#[arg(long)]
|
|
mention: Option<String>,
|
|
/// Filter to items carrying any of these label names. Repeatable.
|
|
/// Validated client-side: the CLI drops a name the forge can't resolve
|
|
/// from the filter rather than rejecting it, which returns MORE results
|
|
/// than asked for, not fewer.
|
|
#[arg(long = "label")]
|
|
labels: Vec<String>,
|
|
/// Filter to items in any of these milestones, by title or id.
|
|
/// Repeatable. Validated client-side against the repo's milestones
|
|
/// (closed ones included), since the forge would silently discard a
|
|
/// name it can't resolve and return the UNFILTERED list.
|
|
#[arg(long = "milestone")]
|
|
milestones: Vec<String>,
|
|
/// Full-text search over title AND body, server-side. Composes with
|
|
/// every filter above — this is the duplicate-hunting path that
|
|
/// grepping `list` output can't cover, since grep only ever sees
|
|
/// the titles.
|
|
#[arg(long)]
|
|
search: Option<String>,
|
|
/// Page size — items per page (default: 30). The forge clamps this
|
|
/// to its own `api.MAX_RESPONSE_ITEMS` (50 by default), so a large
|
|
/// `--limit` silently returns a smaller page; the trailer reports
|
|
/// the real total rather than trusting this number. Must be >= 1.
|
|
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))]
|
|
limit: u64,
|
|
/// Page number to fetch (1-based, default 1). Combine with `--limit`
|
|
/// to page through large result sets incrementally.
|
|
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u64).range(1..))]
|
|
page: u64,
|
|
}
|
|
|
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
|
let (owner, name) = client.owner_repo()?;
|
|
// Validate the name-based filters BEFORE querying. The forge
|
|
// *discards* a label or milestone it can't resolve instead of
|
|
// rejecting it, so a typo doesn't narrow the result set — it returns
|
|
// the UNFILTERED one. That doesn't waste a query, it inverts the
|
|
// answer: "is anything open in this milestone" comes back as every
|
|
// open issue and reads as yes, and a duplicate check gets a list that
|
|
// never narrowed and concludes there isn't one.
|
|
//
|
|
// The ids are discarded on purpose — this endpoint filters by name,
|
|
// so resolution here is a spell-check, not a lookup. `resolve_ids` is
|
|
// reused rather than reimplemented so the message stays identical to
|
|
// the one the write side has always produced.
|
|
//
|
|
// Costs one extra round-trip per filtered invocation, and only when
|
|
// the filter is actually used.
|
|
if !args.labels.is_empty() {
|
|
let all = labels::repo_labels(client)?;
|
|
labels::resolve_ids(&all, &args.labels)?;
|
|
}
|
|
if !args.milestones.is_empty() {
|
|
let all = milestone::repo_milestones(client, "all")?;
|
|
milestone::ensure_filters_resolve(&all, &args.milestones)?;
|
|
}
|
|
let query = IssueListIssuesQuery {
|
|
state: Some(args.state.query_state()),
|
|
// The forge parses `labels` as a comma-separated list of names.
|
|
labels: (!args.labels.is_empty()).then(|| args.labels.join(",")),
|
|
q: args.search.clone().filter(|s| !s.is_empty()),
|
|
r#type: args.kind.query_type(),
|
|
// Same comma-separated shape as `labels` above.
|
|
milestones: (!args.milestones.is_empty()).then(|| args.milestones.join(",")),
|
|
since: None,
|
|
before: None,
|
|
created_by: args.author.clone().filter(|s| !s.is_empty()),
|
|
assigned_by: args.assignee.clone().filter(|s| !s.is_empty()),
|
|
mentioned_by: args.mention.clone().filter(|s| !s.is_empty()),
|
|
sort: None,
|
|
};
|
|
let (headers, issues) = client
|
|
.api()
|
|
.issue_list_issues(owner, name, query)
|
|
.page(u32::try_from(args.page).unwrap_or(u32::MAX))
|
|
.page_size(u32::try_from(args.limit).unwrap_or(u32::MAX))
|
|
.send()?;
|
|
let count = issues.len() as u64;
|
|
let note = trailer(
|
|
headers.x_total_count.and_then(|t| u64::try_from(t).ok()),
|
|
count,
|
|
args.page,
|
|
args.limit,
|
|
);
|
|
let mut items = serde_json::to_value(issues)?;
|
|
if client.json_mode() {
|
|
hoist_merge_state(&mut items);
|
|
print_json(&items)?;
|
|
if let Some(msg) = note {
|
|
eprintln!("{msg}");
|
|
}
|
|
return Ok(());
|
|
}
|
|
for item in items.as_array().into_iter().flatten() {
|
|
let number = item.get("number").and_then(Value::as_u64);
|
|
let progress = number
|
|
.map(|n| dependency_summaries(client, owner, name, n))
|
|
.transpose()?;
|
|
let blocks = number
|
|
.map(|n| blocking_summaries(client, owner, name, n))
|
|
.transpose()?;
|
|
print_row(
|
|
item,
|
|
progress.as_deref().and_then(dep_progress),
|
|
blocks.as_deref().map_or(0, blocking_open_count),
|
|
);
|
|
}
|
|
if let Some(msg) = note {
|
|
eprintln!("{msg}");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Tell the caller, on stderr, whether this page is the whole answer.
|
|
///
|
|
/// The forge clamps `page_size` to its own `api.MAX_RESPONSE_ITEMS`, so
|
|
/// the requested `limit` is not the page size that was served: with
|
|
/// `--limit 400` a full clamped page of 50 comes back, and the old
|
|
/// `count == limit` test stayed silent — suppressing the warning in the
|
|
/// one case where the truncation is invisible. Hence the header.
|
|
///
|
|
/// `total` is `X-Total-Count`, which Forgejo sends on this endpoint. The
|
|
/// *effective* page size isn't reported, but it doesn't need to be: a
|
|
/// short page is by definition the last one, so on a page that is not the
|
|
/// last, `count` **is** the page size and `page * count` is how many rows
|
|
/// have been shown up to here. On the last page `count` is only the
|
|
/// remainder, so `page * count` undercounts; there the unclamped
|
|
/// `(page - 1) * limit + count` lands exactly on `total`, which identifies
|
|
/// the page as the last one.
|
|
///
|
|
/// Deliberately not clamping `--limit` client-side to 50: that number is
|
|
/// the *remote's* configuration, and baking a peer's setting into the
|
|
/// client is how you get a value that's wrong on the one deployment that
|
|
/// changed it.
|
|
fn trailer(total: Option<u64>, count: u64, page: u64, limit: u64) -> Option<String> {
|
|
// An empty page is the end, whatever else is true.
|
|
if count == 0 {
|
|
return None;
|
|
}
|
|
match total {
|
|
Some(total) => {
|
|
let by_limit = page
|
|
.saturating_sub(1)
|
|
.saturating_mul(limit)
|
|
.saturating_add(count);
|
|
let shown = if by_limit == total {
|
|
total
|
|
} else {
|
|
page.saturating_mul(count).min(total)
|
|
};
|
|
let remaining = total.saturating_sub(shown);
|
|
(remaining > 0).then(|| {
|
|
format!(
|
|
"… {shown} of {total} shown (page {page}, {count}/page) — {remaining} more; re-run with --page {}.",
|
|
page + 1
|
|
)
|
|
})
|
|
}
|
|
// No header (older forge, or a proxy that dropped it): fall back
|
|
// to the full-page heuristic. It under-reports when the server
|
|
// clamped, which is why it is the fallback and not the rule.
|
|
None => (count == limit).then(|| {
|
|
format!(
|
|
"… {count} shown (page {page}); more results may exist — re-run with --page {}.",
|
|
page + 1
|
|
)
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Render one issue/PR as a single `#NNN [author] title` line, plus a
|
|
/// `(N/M deps done)` suffix when `progress` is `Some` and a `(blocks N)`
|
|
/// suffix when `blocking_open` is nonzero — the actionability signal the
|
|
/// operator asked for: an issue blocking open work is worth picking over
|
|
/// one with no unresolved followers, at a glance in `list`'s own output
|
|
/// rather than a per-issue `show`. Defensive: missing fields drop to
|
|
/// placeholders so a partial response from a future API change still
|
|
/// produces readable output instead of panicking on `unwrap`.
|
|
/// Copy a PR row's `merged` / `merged_at` from the nested `pull_request`
|
|
/// object up to the top level of the row.
|
|
///
|
|
/// Forgejo's issue-list endpoint answers "was this merged?" only inside
|
|
/// `pull_request`, while `state` says `closed` for a merged PR *and* one
|
|
/// closed without merging. So the obvious top-level query — `.merged_at`,
|
|
/// or `.state` — is null/ambiguous for **every** row, and with a `//`
|
|
/// default it renders as a confident "nothing merged" that cannot ever be
|
|
/// right. Hoisting makes the obvious query the correct one instead of
|
|
/// leaving a trap only a nested path avoids.
|
|
///
|
|
/// Additive: the original `pull_request` object is left untouched, so a
|
|
/// consumer already reading the nested path keeps working. Issue rows have
|
|
/// no `pull_request` and pass through unchanged.
|
|
///
|
|
/// ⚠️ The head branch is deliberately NOT hoisted — this endpoint does not
|
|
/// carry it at all. The row's `ref` is an **empty string**, not the branch,
|
|
/// so there is nothing to lift; `pr show <n>` has `head_branch`.
|
|
fn hoist_merge_state(items: &mut Value) {
|
|
let Some(rows) = items.as_array_mut() else {
|
|
return;
|
|
};
|
|
for row in rows {
|
|
let Some(pr) = row.get("pull_request") else {
|
|
continue;
|
|
};
|
|
let merged = pr.get("merged").cloned();
|
|
let merged_at = pr.get("merged_at").cloned();
|
|
let Some(obj) = row.as_object_mut() else {
|
|
continue;
|
|
};
|
|
if let Some(v) = merged {
|
|
obj.insert("merged".to_owned(), v);
|
|
}
|
|
if let Some(v) = merged_at {
|
|
obj.insert("merged_at".to_owned(), v);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn print_row(item: &Value, progress: Option<(usize, usize)>, blocking_open: usize) {
|
|
let number = item.get("number").and_then(Value::as_u64).unwrap_or(0);
|
|
let title = item.get("title").and_then(Value::as_str).unwrap_or("");
|
|
let author = item
|
|
.get("user")
|
|
.and_then(|u| u.get("login"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("?");
|
|
// Tag PRs visually so a mixed-kind result stays scannable. The
|
|
// forge always populates `pull_request` but sets it to `null` for
|
|
// issues; we check non-null specifically rather than just-present
|
|
// (which would render every issue as a PR).
|
|
let is_pr = item.get("pull_request").is_some_and(|v| !v.is_null());
|
|
let kind = if is_pr { "PR" } else { " " };
|
|
let deps_suffix = progress.map(|(done, total)| format!(" ({done}/{total} deps done)"));
|
|
let blocks_suffix = (blocking_open > 0).then(|| format!(" (blocks {blocking_open})"));
|
|
println!(
|
|
"#{number:>4} {kind} [{author}] {title}{}{}",
|
|
deps_suffix.unwrap_or_default(),
|
|
blocks_suffix.unwrap_or_default()
|
|
);
|
|
}
|
|
|
|
/// Count of *open* issues `blocking` this row's issue blocks — closed
|
|
/// followers don't count toward "actionable" (see [`print_row`]'s doc
|
|
/// comment); they're already done regardless of this one's own state.
|
|
fn blocking_open_count(blocking: &[Value]) -> usize {
|
|
blocking
|
|
.iter()
|
|
.filter(|b| b.get("state").and_then(Value::as_str) == Some("open"))
|
|
.count()
|
|
}
|
|
|
|
/// Dependency completion progress from a `dependency_summaries` list —
|
|
/// `Some((done, total))` only when there's at least one dependency AND
|
|
/// at least one of them is still open. `None` for no dependencies, or
|
|
/// all of them already closed: the operator asked for the counter to
|
|
/// appear only "when there are deps that are not done", so a ready
|
|
/// item's row stays exactly as clean as before this existed.
|
|
fn dep_progress(deps: &[Value]) -> Option<(usize, usize)> {
|
|
let total = deps.len();
|
|
if total == 0 {
|
|
return None;
|
|
}
|
|
let done = deps
|
|
.iter()
|
|
.filter(|d| d.get("state").and_then(Value::as_str) == Some("closed"))
|
|
.count();
|
|
(done < total).then_some((done, total))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn dep(state: &str) -> Value {
|
|
serde_json::json!({ "number": 1, "title": "x", "state": state })
|
|
}
|
|
|
|
/// A merged PR, a closed-but-unmerged PR and a plain issue in one
|
|
/// array — because telling the first two apart is the entire point,
|
|
/// and `state` cannot: it reads `closed` for both.
|
|
#[test]
|
|
fn hoisting_separates_merged_from_closed_unmerged_and_leaves_issues_alone() {
|
|
let mut items = serde_json::json!([
|
|
{ "number": 1, "state": "closed",
|
|
"pull_request": { "merged": true, "merged_at": "2026-08-26T22:47:32+02:00" } },
|
|
{ "number": 2, "state": "closed",
|
|
"pull_request": { "merged": false, "merged_at": null } },
|
|
{ "number": 3, "state": "closed" },
|
|
]);
|
|
|
|
// Control: before hoisting, a top-level query cannot tell 1 from 2.
|
|
assert!(items[0].get("merged").is_none(), "control: not hoisted yet");
|
|
assert_eq!(
|
|
items[0]["state"], items[1]["state"],
|
|
"control: `state` is identical"
|
|
);
|
|
|
|
hoist_merge_state(&mut items);
|
|
|
|
assert_eq!(items[0]["merged"], serde_json::json!(true), "merged PR");
|
|
assert_eq!(
|
|
items[1]["merged"],
|
|
serde_json::json!(false),
|
|
"closed unmerged"
|
|
);
|
|
assert_eq!(
|
|
items[0]["merged_at"],
|
|
serde_json::json!("2026-08-26T22:47:32+02:00")
|
|
);
|
|
assert!(items[1]["merged_at"].is_null());
|
|
assert!(
|
|
items[2].get("merged").is_none(),
|
|
"an issue must not gain a `merged` field"
|
|
);
|
|
// Additive: the nested path an existing consumer reads still works.
|
|
assert_eq!(items[0]["pull_request"]["merged"], serde_json::json!(true));
|
|
}
|
|
|
|
/// Non-array input (an error object, say) must not panic or mangle.
|
|
#[test]
|
|
fn hoisting_a_non_array_is_a_no_op() {
|
|
let mut v = serde_json::json!({ "message": "not found" });
|
|
let before = v.clone();
|
|
hoist_merge_state(&mut v);
|
|
assert_eq!(v, before);
|
|
}
|
|
|
|
#[test]
|
|
fn dep_progress_none_when_no_dependencies() {
|
|
assert_eq!(dep_progress(&[]), None);
|
|
}
|
|
|
|
#[test]
|
|
fn dep_progress_none_when_all_dependencies_closed() {
|
|
let deps = [dep("closed"), dep("closed")];
|
|
assert_eq!(dep_progress(&deps), None);
|
|
}
|
|
|
|
#[test]
|
|
fn dep_progress_some_when_at_least_one_dependency_still_open() {
|
|
let deps = [dep("closed"), dep("open"), dep("closed")];
|
|
assert_eq!(dep_progress(&deps), Some((2, 3)));
|
|
}
|
|
|
|
#[test]
|
|
fn dep_progress_counts_all_open_as_zero_done() {
|
|
let deps = [dep("open"), dep("open")];
|
|
assert_eq!(dep_progress(&deps), Some((0, 2)));
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_open_count_zero_when_none_blocking() {
|
|
assert_eq!(blocking_open_count(&[]), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_open_count_ignores_closed_ones() {
|
|
let blocking = [dep("closed"), dep("closed")];
|
|
assert_eq!(blocking_open_count(&blocking), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_open_count_counts_only_open_ones() {
|
|
let blocking = [dep("closed"), dep("open"), dep("open")];
|
|
assert_eq!(blocking_open_count(&blocking), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn kind_query_types_match_forgejo_enum() {
|
|
// The forge accepts only `issues` / `pulls` for the `type`
|
|
// parameter (absent = both) — pin the mapping so a clap rename
|
|
// doesn't silently start returning the wrong slice.
|
|
assert_eq!(
|
|
Kind::Issue.query_type(),
|
|
Some(IssueListIssuesQueryType::Issues)
|
|
);
|
|
assert_eq!(Kind::Pr.query_type(), Some(IssueListIssuesQueryType::Pulls));
|
|
assert_eq!(Kind::Both.query_type(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn state_query_states_match_forgejo_enum() {
|
|
assert_eq!(State::Open.query_state(), IssueListIssuesQueryState::Open);
|
|
assert_eq!(
|
|
State::Closed.query_state(),
|
|
IssueListIssuesQueryState::Closed
|
|
);
|
|
assert_eq!(State::All.query_state(), IssueListIssuesQueryState::All);
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_fires_on_a_clamped_page_the_old_heuristic_missed() {
|
|
// The regression this function exists for: `--limit 400` against
|
|
// a forge that clamps to 50, with 187 matches. `count != limit`,
|
|
// so the pre-header code printed nothing and the caller concluded
|
|
// the list was complete.
|
|
let msg = trailer(Some(187), 50, 1, 400).expect("must warn about the other 137");
|
|
assert!(msg.contains("50 of 187"), "{msg}");
|
|
assert!(msg.contains("137 more"), "{msg}");
|
|
assert!(msg.contains("--page 2"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_counts_from_the_served_page_size_not_the_requested_one() {
|
|
// Page 3 of a clamped 50/page walk has shown 150, not 3 * 400.
|
|
let msg = trailer(Some(187), 50, 3, 400).expect("187 > 150");
|
|
assert!(msg.contains("150 of 187"), "{msg}");
|
|
assert!(msg.contains("37 more"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_silent_once_the_total_is_accounted_for() {
|
|
// Last page, short: 2 * 50 + ... -> 60 shown of 60.
|
|
assert_eq!(trailer(Some(60), 10, 6, 10), None);
|
|
// Exactly-full last page: no phantom "page 4".
|
|
assert_eq!(trailer(Some(150), 50, 3, 50), None);
|
|
// Empty page is the end even if the total disagrees (a filter
|
|
// the forge discarded, a concurrent close) — never advertise a
|
|
// next page we just proved is empty.
|
|
assert_eq!(trailer(Some(999), 0, 9, 30), None);
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_falls_back_to_the_full_page_heuristic_without_the_header() {
|
|
let msg = trailer(None, 30, 1, 30).expect("full page, unknown total");
|
|
assert!(msg.contains("30 shown (page 1)"), "{msg}");
|
|
assert!(msg.contains("--page 2"), "{msg}");
|
|
// Short page with no header: the end, as far as we can tell.
|
|
assert_eq!(trailer(None, 12, 1, 30), None);
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_counts_rows_shown_on_first_and_middle_pages() {
|
|
let msg = trailer(Some(1331), 50, 1, 50).expect("more pages");
|
|
assert!(msg.contains("50 of 1331 shown"), "{msg}");
|
|
assert!(msg.contains("1281 more"), "{msg}");
|
|
let msg = trailer(Some(1331), 50, 26, 50).expect("one page left");
|
|
assert!(msg.contains("1300 of 1331 shown"), "{msg}");
|
|
assert!(msg.contains("31 more"), "{msg}");
|
|
assert!(msg.contains("--page 27"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_short_last_page_prints_no_hint() {
|
|
// Page 27 of 1331 rows at 50/page holds 31: `page * count` gave
|
|
// 837 of 1331 and a hint to fetch page 28.
|
|
assert_eq!(trailer(Some(1331), 31, 27, 50), None);
|
|
// Page 2 of 2: `2 * 5 = 10 < 55` also produced a bogus hint.
|
|
assert_eq!(trailer(Some(55), 5, 2, 50), None);
|
|
// Control: the page before the last still hints.
|
|
assert!(trailer(Some(1331), 50, 26, 50).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_exact_multiple_last_page_is_silent() {
|
|
assert_eq!(trailer(Some(100), 50, 2, 50), None);
|
|
// Control: page 1 of the same list still hints.
|
|
let msg = trailer(Some(100), 50, 1, 50).expect("page 2 remains");
|
|
assert!(msg.contains("50 of 100 shown"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_without_total_is_silent_on_a_short_page() {
|
|
assert_eq!(trailer(None, 31, 27, 50), None);
|
|
// Control: a full page still hints.
|
|
assert!(trailer(None, 50, 27, 50).is_some());
|
|
}
|
|
}
|