250 lines
10 KiB
Rust
250 lines
10 KiB
Rust
//! `repo-search [--query <kw>] [--topic] [--include-desc] [--limit N]` —
|
|
//! search for repositories on the forge instance.
|
|
//!
|
|
//! Wraps `GET /repos/search` (Forgejo explore endpoint). Useful for agents
|
|
//! that need to find a repo by keyword rather than knowing the exact
|
|
//! `owner/repo` slug upfront. Not repo-scoped — the `-r` flag is ignored.
|
|
//!
|
|
//! Human output: one `owner/repo description` line per hit. `--json`
|
|
//! emits the full `SearchResults.data` array as raw JSON.
|
|
//!
|
|
//! Like `list`, the forge clamps the page size to its own
|
|
//! `api.MAX_RESPONSE_ITEMS` (50 by default), so a large `--limit` silently
|
|
//! returns a smaller page. Unlike `list`'s generated binding, this crate's
|
|
//! `repo_search` method does not expose the response headers — but
|
|
//! Forgejo's own docs name `/repos/search` as the worked example for
|
|
//! `X-Total-Count`, so [`response_type`][forgejo_api::sync::Request::response_type]
|
|
//! reinterprets the response as `(TotalCount, SearchResults)` to read it
|
|
//! anyway, same number `list`'s trailer reports. When the header is
|
|
//! missing or unparseable (an older forge, a stripping proxy) we fall back
|
|
//! to the cruder `count == limit` heuristic, which under-reports exactly
|
|
//! when the server clamped — the same fallback-not-rule relationship
|
|
//! `list.rs`'s `trailer()` documents. Either way we warn on stderr rather
|
|
//! than let a capped list read as complete; stdout — and `--json` stdout
|
|
//! especially — stays exactly the raw result set, so a script piping it
|
|
//! doesn't have to parse prose out of its own data.
|
|
//!
|
|
//! The total (when present) still can't tell "doesn't exist" from "you
|
|
//! lack permission to see it" — a hidden repo is just absent from both the
|
|
//! count and the rows, with no signal either way.
|
|
|
|
use anyhow::Result;
|
|
use clap::Args as ClapArgs;
|
|
use forgejo_api::StructureError;
|
|
use forgejo_api::structs::{RepoSearchQuery, SearchResults};
|
|
|
|
use crate::client::Client;
|
|
use crate::verbs::print_json;
|
|
|
|
/// `X-Total-Count`, read generically via `response_type` since this
|
|
/// crate's `repo_search` binding (unlike `issue_list_issues`'s) has no
|
|
/// headers-carrying `Response` type of its own. Mirrors the header's own
|
|
/// error handling (`forgejo_api`'s `IssueListHeaders`) exactly, so a
|
|
/// non-ASCII or unparseable header fails the request the same way there.
|
|
struct TotalCount(Option<u64>);
|
|
|
|
impl TryFrom<&reqwest::header::HeaderMap> for TotalCount {
|
|
type Error = StructureError;
|
|
|
|
fn try_from(map: &reqwest::header::HeaderMap) -> Result<Self, Self::Error> {
|
|
let total = map
|
|
.get("x-total-count")
|
|
.map(|v| -> Result<_, StructureError> {
|
|
let s = v.to_str().map_err(|_| StructureError::HeaderNotAscii)?;
|
|
s.parse::<u64>()
|
|
.map_err(|_| StructureError::HeaderParseFailed)
|
|
})
|
|
.transpose()?;
|
|
Ok(Self(total))
|
|
}
|
|
}
|
|
|
|
#[derive(ClapArgs)]
|
|
pub struct Args {
|
|
/// Keyword to search for (matches repo name by default; combine with
|
|
/// `--include-desc` to also match description text).
|
|
#[arg(short = 'q', long)]
|
|
query: Option<String>,
|
|
/// Restrict matches to repositories that have the keyword as a **topic**
|
|
/// tag rather than in the name.
|
|
#[arg(long)]
|
|
topic: bool,
|
|
/// Extend the keyword search to repository descriptions (in addition to
|
|
/// names, or topics when you pass `--topic`).
|
|
#[arg(long = "include-desc")]
|
|
include_desc: bool,
|
|
/// Maximum number of results to return (default: 30).
|
|
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))]
|
|
limit: u64,
|
|
}
|
|
|
|
/// # Errors
|
|
///
|
|
/// Propagates transport errors from the Forgejo search call or JSON
|
|
/// serialisation errors when emitting `--json` output.
|
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
|
let limit = u32::try_from(args.limit.min(u64::from(u32::MAX))).unwrap_or(u32::MAX);
|
|
let (TotalCount(total), results) = client
|
|
.api()
|
|
.repo_search(RepoSearchQuery {
|
|
q: args.query,
|
|
topic: args.topic.then_some(true),
|
|
include_desc: args.include_desc.then_some(true),
|
|
// All other filters left at defaults — callers who need them
|
|
// can drop to `--json` and post-filter.
|
|
uid: None,
|
|
priority_owner_id: None,
|
|
team_id: None,
|
|
starred_by: None,
|
|
private: None,
|
|
is_private: None,
|
|
template: None,
|
|
archived: None,
|
|
mode: None,
|
|
exclusive: None,
|
|
sort: None,
|
|
order: None,
|
|
})
|
|
.page_size(limit)
|
|
.response_type::<(TotalCount, SearchResults)>()
|
|
.send()?;
|
|
|
|
let repos = results.data.unwrap_or_default();
|
|
if let Some(msg) = truncation_warning(total, repos.len() as u64, args.limit) {
|
|
eprintln!("{msg}");
|
|
}
|
|
if client.json_mode() {
|
|
return print_json(&serde_json::to_value(&repos)?);
|
|
}
|
|
if repos.is_empty() {
|
|
eprintln!("(no results)");
|
|
return Ok(());
|
|
}
|
|
for repo in &repos {
|
|
let full_name = repo.full_name.as_deref().unwrap_or("?/?");
|
|
let desc = repo.description.as_deref().unwrap_or("");
|
|
if desc.is_empty() {
|
|
println!("{full_name}");
|
|
} else {
|
|
println!("{full_name} {desc}");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Tell the caller, on stderr, whether this page is the whole answer.
|
|
///
|
|
/// `total` is `X-Total-Count` when the header parsed; `repo-search` always
|
|
/// starts from page 1 (it has no `--page`, unlike `list`), so `count` alone
|
|
/// is how much of `total` has been shown — no page-number multiply needed.
|
|
///
|
|
/// Without a usable total, falls back to the cruder `count == limit`
|
|
/// heuristic: a hit on the cap, not necessarily the whole answer, but a
|
|
/// short result *is* the complete answer. This is the case the server's
|
|
/// own clamp (`api.MAX_RESPONSE_ITEMS`, 50 by default) makes silently
|
|
/// wrong — `--limit 400` returning a clamped 50 reads as complete here,
|
|
/// which is exactly why the header-based path above is the rule and this
|
|
/// is only the fallback (same relationship as `list.rs`'s `trailer()`).
|
|
fn truncation_warning(total: Option<u64>, count: u64, limit: u64) -> Option<String> {
|
|
if count == 0 {
|
|
// "(no results)" is printed separately in `run` — a truncation
|
|
// warning here would wrongly imply more might still exist.
|
|
return None;
|
|
}
|
|
match total {
|
|
Some(total) => {
|
|
let remaining = total.saturating_sub(count);
|
|
(remaining > 0).then(|| {
|
|
// `count < limit` means the forge clamped the page below what
|
|
// was asked for — raising `--limit` again asks for the same
|
|
// clamp and gets the same `count` back. Unlike `list`, this
|
|
// verb has no `--page` to walk past the clamp with, so
|
|
// narrowing `--query` is the only remedy that can work once
|
|
// the server has capped the page. "raise --limit" only
|
|
// helps the other case: still under the cap, where asking
|
|
// for more actually serves more of it.
|
|
let remedy = if count < limit {
|
|
"the forge capped this page at its own limit — narrow --query to see them"
|
|
} else {
|
|
"raise --limit or narrow --query to see them"
|
|
};
|
|
format!(
|
|
"warning: {count} of {total} results shown — {remaining} more exist (the \
|
|
forge still silently drops any repo you lack permission to see, with no \
|
|
signal either way); {remedy}"
|
|
)
|
|
})
|
|
}
|
|
None => (count == limit).then(|| {
|
|
format!(
|
|
"warning: got exactly --limit ({limit}) results — the forge may be holding back \
|
|
more (and silently drops any repo you lack permission to see, with no signal \
|
|
either way); raise --limit or narrow --query rather than trusting this as a \
|
|
complete list"
|
|
)
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn warns_with_the_real_remaining_count_when_the_total_is_known() {
|
|
let msg = truncation_warning(Some(187), 30, 30).expect("137 more exist");
|
|
assert!(msg.contains("30 of 187"), "{msg}");
|
|
assert!(msg.contains("157 more"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn catches_the_clamp_the_old_count_eq_limit_heuristic_missed() {
|
|
// The regression this header read exists for: `--limit 400` against
|
|
// a forge that clamps to 50, with 187 matches. `count != limit`
|
|
// (50 != 400), so the old heuristic alone stayed silent here.
|
|
let msg = truncation_warning(Some(187), 50, 400).expect("137 more exist");
|
|
assert!(msg.contains("50 of 187"), "{msg}");
|
|
assert!(msg.contains("137 more"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn clamp_case_tells_the_caller_to_narrow_not_to_raise_limit() {
|
|
// `--limit` already exceeded the forge's own page cap here, so
|
|
// "raise --limit" is a dead end — only `--query` can still narrow
|
|
// the result set below the cap. (The bug argus caught: both
|
|
// branches used to say "raise --limit", which can't work once the
|
|
// server itself has clamped the page.)
|
|
let msg = truncation_warning(Some(187), 50, 400).expect("137 more exist");
|
|
assert!(msg.contains("narrow --query"), "{msg}");
|
|
assert!(!msg.contains("raise --limit"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn under_the_cap_case_still_offers_raise_limit() {
|
|
// `count == limit` and both are under the forge's cap: asking for
|
|
// more can actually serve more, so "raise --limit" is live advice
|
|
// here, not a dead end.
|
|
let msg = truncation_warning(Some(187), 30, 30).expect("157 more exist");
|
|
assert!(msg.contains("raise --limit"), "{msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn stays_quiet_once_the_total_is_accounted_for() {
|
|
assert_eq!(truncation_warning(Some(30), 30, 30), None);
|
|
}
|
|
|
|
#[test]
|
|
fn falls_back_to_the_cap_heuristic_without_a_usable_total() {
|
|
assert!(truncation_warning(None, 30, 30).is_some());
|
|
assert_eq!(truncation_warning(None, 9, 30), None);
|
|
}
|
|
|
|
#[test]
|
|
fn stays_quiet_on_zero_results_even_with_a_total() {
|
|
// Permission-filtering can make count 0 while total is nonzero
|
|
// (every match is hidden from this caller) — still not a
|
|
// truncation warning's job; "(no results)" covers it in `run`.
|
|
assert_eq!(truncation_warning(Some(5), 0, 30), None);
|
|
assert_eq!(truncation_warning(None, 0, 30), None);
|
|
}
|
|
}
|