1use std::io::Write as _;
12
13use clap::Subcommand;
14
15use crate::cli::{Session, emit, report};
16use crate::exit::ExitCode;
17use crate::render::{Format, machine, text};
18
19#[derive(Debug, Subcommand)]
20pub enum WorklogCommand {
21 #[command(long_about = crate::cli::help::md(crate::cli::help::WORKLOG_FIND))]
23 Find {
24 #[arg(long, short = 'b')]
26 by: Option<String>,
27 #[arg(long)]
29 since: Option<String>,
30 #[arg(long)]
32 until: Option<String>,
33 #[arg(long, default_value_t = 100)]
35 limit: u32,
36 },
37}
38
39pub async fn run(command: &WorklogCommand, session: &Session) -> ExitCode {
40 let client = match session.client() {
41 Ok(client) => client,
42 Err(code) => return code,
43 };
44
45 let WorklogCommand::Find {
46 by,
47 since,
48 until,
49 limit,
50 } = command;
51
52 let who = match by.as_deref() {
56 Some("me") => match client.myself().await {
57 Ok(user) => match user.login.or(Some(user.id)).filter(|id| !id.is_empty()) {
58 Some(login) => Some(login),
59 None => return report(&"this token has no login to search by", ExitCode::Auth),
60 },
61 Err(error) => {
62 let code = error.exit_code();
63 return report(&error, code);
64 }
65 },
66 other => other.map(ToOwned::to_owned),
67 };
68
69 let since = match since.as_deref().map(as_date) {
70 Some(Ok(date)) => Some(date),
71 Some(Err(error)) => return report(&error, ExitCode::ConfirmationRequired),
72 None => None,
73 };
74 let until = match until.as_deref().map(as_date) {
75 Some(Ok(date)) => Some(date),
76 Some(Err(error)) => return report(&error, ExitCode::ConfirmationRequired),
77 None => None,
78 };
79
80 match client
81 .worklog_search(who.as_deref(), since.as_deref(), until.as_deref(), *limit)
82 .await
83 {
84 Ok(entries) => {
85 let rendered = match session.render.format {
86 Format::Text => Ok(text::worklog_search(&entries, &session.render)),
87 Format::JsonRaw => machine(&entries, Format::Json),
88 other => machine(&entries, other),
89 };
90 match rendered {
91 Ok(text) => {
92 emit(&text);
93 if u32::try_from(entries.len()).is_ok_and(|count| count >= *limit) {
97 let mut err = anstream::stderr();
98 let _ =
99 writeln!(err, "stopped at --limit {limit}; there may be more entries");
100 }
101 ExitCode::Success
102 }
103 Err(error) => report(&error, ExitCode::Failure),
104 }
105 }
106 Err(error) => {
107 let code = error.exit_code();
108 report(&error, code)
109 }
110 }
111}
112
113fn as_date(value: &str) -> Result<String, String> {
118 let Some((count, unit)) = value.split_at_checked(value.len().saturating_sub(1)) else {
119 return Err(format!("cannot read `{value}` as a date or a span"));
120 };
121
122 let span = match (count.parse::<i64>(), unit) {
123 (Ok(count), "d") => jiff::Span::new().try_days(count),
124 (Ok(count), "w") => jiff::Span::new().try_weeks(count),
125 (Ok(count), "m") => jiff::Span::new().try_months(count),
126 _ => return Ok(value.to_owned()),
129 }
130 .map_err(|_| format!("`{value}` is too large a span"))?;
131
132 jiff::Zoned::now()
133 .checked_sub(span)
134 .map(|then| then.strftime("%Y-%m-%d").to_string())
135 .map_err(|_| format!("`{value}` lands outside the range of dates"))
136}
137
138#[cfg(test)]
139#[allow(clippy::expect_used)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn a_span_becomes_a_date_in_the_past() {
145 let today = jiff::Zoned::now().strftime("%Y-%m-%d").to_string();
146 let week = as_date("7d").expect("a week");
147
148 assert_eq!(week.len(), today.len());
149 assert!(week < today, "{week} is not before {today}");
150 }
151
152 #[test]
155 fn a_date_passes_through_untouched() {
156 assert_eq!(as_date("2026-08-01").as_deref(), Ok("2026-08-01"));
157 }
158}