Skip to main content

ytcli/cli/
user.rs

1//! People.
2//!
3//! Every issue answer carries logins — assignee, author, whoever logged the
4//! time — and without this group they stay opaque strings. It is also the only
5//! way to get `--assignee` right on the first attempt rather than the second:
6//! Tracker validates a login by refusing the write.
7//!
8//! Read-only. Nothing in this group changes anything about anybody.
9
10use std::io::Write as _;
11
12use clap::{Args, Subcommand};
13
14use crate::api::models::{Page, Person};
15use crate::cli::{Session, emit as write_out, report};
16use crate::exit::ExitCode;
17use crate::render::{Format, machine, user as render};
18
19#[derive(Debug, Subcommand)]
20pub enum UserCommand {
21    /// List the people in the organisation.
22    #[command(long_about = crate::cli::help::md(crate::cli::help::USER_LIST))]
23    List(PageArgs),
24    /// Show one person, by login or uid.
25    #[command(long_about = crate::cli::help::md(crate::cli::help::USER_GET))]
26    Get {
27        /// Login, or the numeric uid. Not `me` — `auth status` answers that.
28        who: String,
29    },
30    /// Find people whose login, name or email contains some text.
31    #[command(long_about = crate::cli::help::md(crate::cli::help::USER_FIND))]
32    Find {
33        /// Matched case-insensitively against login, display name and email.
34        text: String,
35        /// How many people to read through before giving up.
36        #[arg(long, default_value_t = 1000)]
37        scan: usize,
38    },
39}
40
41#[derive(Debug, Args, Clone)]
42pub struct PageArgs {
43    /// Rows per page.
44    #[arg(long)]
45    pub limit: Option<usize>,
46    /// 1-based page number.
47    #[arg(long, default_value_t = 1)]
48    pub page: u32,
49}
50
51pub async fn run(command: &UserCommand, session: &Session) -> ExitCode {
52    let client = match session.client() {
53        Ok(client) => client,
54        Err(code) => return code,
55    };
56
57    match command {
58        UserCommand::List(args) => {
59            let per_page = args.limit.unwrap_or(session.display().limit);
60            let Ok(per_page) = u32::try_from(per_page.max(1)) else {
61                return report(&"--limit is too large", ExitCode::ConfirmationRequired);
62            };
63
64            match client.users(args.page.max(1), per_page).await {
65                Ok(page) => {
66                    let next = page.has_more().then(|| page.page + 1);
67                    emit(&page, next, session)
68                }
69                Err(error) => {
70                    let code = error.exit_code();
71                    report(&error, code)
72                }
73            }
74        }
75        UserCommand::Get { who } => match client.user(who).await {
76            Ok(person) => {
77                let rendered = match session.render.format {
78                    Format::Text => Ok(render::user(&person, &session.render)),
79                    Format::JsonRaw => machine(&person, Format::Json),
80                    other => machine(&person, other),
81                };
82                match rendered {
83                    Ok(text) => {
84                        write_out(&text);
85                        ExitCode::Success
86                    }
87                    Err(error) => report(&error, ExitCode::Failure),
88                }
89            }
90            Err(error) => {
91                let code = error.exit_code();
92                report(&error, code)
93            }
94        },
95        UserCommand::Find { text, scan } => find(&client, text, *scan, session).await,
96    }
97}
98
99/// Search, done here rather than by Tracker.
100///
101/// There is no user search endpoint — `/v3/users/_search` is a 404, and the
102/// name is read as a login — so matching means reading the directory and
103/// filtering it. That is honest but not free, which is why `--scan` is a
104/// visible ceiling rather than a hidden one, and why the tally says how many
105/// people were actually read.
106async fn find(client: &crate::api::Client, text: &str, scan: usize, session: &Session) -> ExitCode {
107    let needle = text.to_lowercase();
108    let mut matched: Vec<Person> = Vec::new();
109    let mut read = 0usize;
110    let mut page_number = 1;
111    let mut total = None;
112    let walk = crate::render::progress::Walk::start("reading the directory");
113
114    loop {
115        let page = match client.users(page_number, 100).await {
116            Ok(page) => page,
117            Err(error) => {
118                walk.finish();
119                let code = error.exit_code();
120                return report(&error, code);
121            }
122        };
123        total = page.total.or(total);
124        read += page.items.len();
125
126        let more = page.has_more();
127        matched.extend(
128            page.items
129                .into_iter()
130                .filter(|person| matches(person, &needle)),
131        );
132        walk.page(page_number, matched.len(), total);
133
134        if !more || read >= scan {
135            break;
136        }
137        page_number += 1;
138    }
139    walk.finish();
140
141    // The tally counts what was read, not what the organisation has: a match
142    // count against a total nobody searched would claim a completeness this
143    // command cannot offer.
144    let incomplete = total.is_some_and(|total| read < usize::try_from(total).unwrap_or(usize::MAX));
145    let Ok(count) = u32::try_from(matched.len()) else {
146        return report(&"too many results to render", ExitCode::Failure);
147    };
148    let page = Page {
149        items: matched,
150        page: 1,
151        per_page: count.max(1),
152        total: Some(read as u64),
153    };
154
155    // No `next: --page 2` here: a filtered answer has no second page to ask
156    // for, and offering one would send the caller back to the unfiltered
157    // listing.
158    let code = emit(&page, None, session);
159    if incomplete && session.render.format == Format::Text {
160        let mut err = anstream::stderr();
161        let _ = writeln!(
162            err,
163            "searched {read} of {} people; raise --scan to look further",
164            total.map_or_else(|| "unknown".to_owned(), |total| total.to_string())
165        );
166    }
167    code
168}
169
170fn matches(person: &Person, needle: &str) -> bool {
171    person.login.to_lowercase().contains(needle)
172        || person.display.to_lowercase().contains(needle)
173        || person
174            .email
175            .as_deref()
176            .is_some_and(|email| email.to_lowercase().contains(needle))
177}
178
179fn emit(page: &Page<Person>, next: Option<u32>, session: &Session) -> ExitCode {
180    let rendered = match session.render.format {
181        Format::Text => Ok(render::users(
182            &page.items,
183            page.total,
184            next,
185            &session.render,
186        )),
187        Format::JsonRaw => machine(&page.items, Format::Json),
188        other => machine(&page.items, other),
189    };
190
191    match rendered {
192        Ok(text) => {
193            write_out(&text);
194            ExitCode::Success
195        }
196        Err(error) => report(&error, ExitCode::Failure),
197    }
198}