use clap::Subcommand;
use crate::api::Sprint;
use crate::cli::{Session, emit, report};
use crate::exit::ExitCode;
use crate::render::{Format, board as render, machine};
#[derive(Debug, Subcommand)]
pub enum SprintCommand {
#[command(long_about = crate::cli::help::md(crate::cli::help::SPRINT_LIST))]
List {
#[arg(long)]
planning: bool,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::SPRINT_GET))]
Get {
id: String,
#[arg(long)]
no_issues: bool,
},
}
pub async fn run(command: &SprintCommand, session: &Session) -> ExitCode {
let client = match session.client() {
Ok(client) => client,
Err(code) => return code,
};
match command {
SprintCommand::List { planning } => list(&client, *planning, session).await,
SprintCommand::Get { id, no_issues } => get(&client, id, *no_issues, session).await,
}
}
async fn list(client: &crate::api::Client, planning: bool, session: &Session) -> ExitCode {
let sprints = match client.all_sprints().await {
Ok(sprints) => sprints,
Err(error) => {
let code = error.exit_code();
return report(&error, code);
}
};
let shown: Vec<Sprint> = if planning {
planning_sprint(&sprints).cloned().into_iter().collect()
} else {
sprints.clone()
};
let rendered = match session.render.format {
Format::Text => Ok(render::all_sprints(&shown, &session.render)),
Format::JsonRaw => machine(&shown, Format::Json),
other => machine(&shown, other),
};
match rendered {
Ok(text) => {
emit(&text);
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Failure),
}
}
fn planning_sprint(sprints: &[Sprint]) -> Option<&Sprint> {
let live = |sprint: &&Sprint| {
!matches!(
sprint.status.as_deref(),
Some("archived" | "closed" | "completed")
)
};
let by_start = |sprint: &&Sprint| sprint.start.clone().unwrap_or_else(|| "9999".to_owned());
sprints
.iter()
.filter(live)
.filter(|sprint| matches!(sprint.status.as_deref(), Some("draft" | "planned")))
.min_by_key(by_start)
.or_else(|| {
sprints
.iter()
.filter(live)
.filter(|sprint| sprint.status.as_deref() == Some("in_progress"))
.min_by_key(by_start)
})
}
async fn get(
client: &crate::api::Client,
id: &str,
no_issues: bool,
session: &Session,
) -> ExitCode {
let sprint = match client.sprint(id).await {
Ok(sprint) => sprint,
Err(error) => {
let code = error.exit_code();
return report(&error, code);
}
};
if session.render.format != Format::Text {
let format = match session.render.format {
Format::JsonRaw => Format::Json,
other => other,
};
return match machine(&sprint, format) {
Ok(text) => {
emit(&text);
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Failure),
};
}
let counts = if no_issues {
None
} else {
issue_counts(client, id).await
};
let today = jiff::Zoned::now().strftime("%Y-%m-%d").to_string();
emit(&render::sprint(&sprint, counts, &today, &session.render));
ExitCode::Success
}
async fn issue_counts(client: &crate::api::Client, id: &str) -> Option<(u64, u64)> {
let total = client.count(&format!("Sprint: {id}")).await.ok()?;
let open = client
.count(&format!("Sprint: {id} AND Resolution: empty()"))
.await
.ok()?;
Some((total.saturating_sub(open), total))
}
#[cfg(test)]
mod tests {
use super::*;
fn sprint(id: &str, status: &str, start: &str) -> Sprint {
Sprint {
id: id.to_owned(),
name: format!("Sprint {id}"),
status: Some(status.to_owned()),
start: Some(start.to_owned()),
end: None,
board: None,
}
}
#[test]
fn a_draft_beats_the_running_sprint() {
let sprints = [
sprint("1", "in_progress", "2026-08-01"),
sprint("2", "planned", "2026-08-15"),
sprint("3", "draft", "2026-09-01"),
];
assert_eq!(
planning_sprint(&sprints).map(|sprint| sprint.id.as_str()),
Some("2"),
"the nearest draft, not the furthest"
);
}
#[test]
fn without_a_draft_the_running_sprint_is_the_answer() {
let sprints = [
sprint("1", "archived", "2026-07-01"),
sprint("2", "in_progress", "2026-08-01"),
];
assert_eq!(
planning_sprint(&sprints).map(|sprint| sprint.id.as_str()),
Some("2")
);
}
#[test]
fn a_board_with_nothing_live_has_no_answer() {
let sprints = [sprint("1", "archived", "2026-07-01")];
assert!(planning_sprint(&sprints).is_none());
}
}