1use super::accounts::{
2 cycle_report_context, format_cycle_account_line, resolve_cycle_account_label,
3};
4use super::formatters::{
5 format_cycle_history_prompt_item, format_weekly_cycle_anchor_list, format_weekly_cycle_current,
6 format_weekly_cycle_detail, format_weekly_cycle_history,
7};
8use super::reports::{
9 build_weekly_cycle_current_report, build_weekly_cycle_detail_report,
10 build_weekly_cycle_history_report, WeeklyCycleHistoryReport, WeeklyCycleReportContext,
11};
12use super::store::{
13 add_weekly_cycle_anchors_to_file, list_weekly_cycle_anchors_from_file,
14 remove_weekly_cycle_anchor_from_file,
15};
16use super::time::parse_cycle_add_times;
17use super::usage::{read_weekly_cycle_usage_for_current, read_weekly_cycle_usage_for_history};
18use crate::auth::AuthCommandOptions;
19use crate::error::AppError;
20use crate::prompt::{self, DialoguerPrompt, Prompt};
21use crate::stats::{
22 resolve_stat_range_options_from_raw, StatCommandOptions, StatFormat, UsageDiagnostics,
23 UsageRecord,
24};
25use crate::storage::{resolve_storage_paths, StorageOptions};
26use chrono::{DateTime, Utc};
27use std::path::PathBuf;
28
29#[derive(Debug, Clone)]
30pub struct CycleCommandHelps<'a> {
31 pub add: &'a str,
32 pub list: &'a str,
33 pub remove: &'a str,
34 pub current: &'a str,
35 pub history: &'a str,
36}
37
38#[derive(Debug, Clone, Default, Eq, PartialEq)]
39pub struct CycleCommandOptions {
40 pub auth_file: Option<PathBuf>,
41 pub codex_home: Option<PathBuf>,
42 pub cycle_file: Option<PathBuf>,
43 pub account_history_file: Option<PathBuf>,
44 pub sessions_dir: Option<PathBuf>,
45 pub account_id: Option<String>,
46 pub note: Option<String>,
47 pub format: Option<String>,
48 pub json: bool,
49 pub select: bool,
50 pub estimate_before_anchor: bool,
51 pub stat: StatCommandOptions,
52}
53
54pub fn run_cycle_add(
55 time_parts: &[String],
56 options: CycleCommandOptions,
57 now: DateTime<Utc>,
58) -> Result<String, AppError> {
59 let times = parse_cycle_add_times(time_parts)?;
60 let report = add_weekly_cycle_anchors_to_file(&options, ×, now)?;
61 let account_label = resolve_cycle_account_label(&report.account_id, &options, now);
62 let mut lines = Vec::new();
63
64 if report.anchors.len() == 1 {
65 let anchor = report
66 .anchors
67 .first()
68 .ok_or_else(|| AppError::new("No weekly cycle anchor was added."))?;
69 lines.push(format!("Added weekly cycle anchor: {}", anchor.id));
70 lines.push(format!("At: {}", anchor.at));
71 } else {
72 lines.push(format!(
73 "Added {} weekly cycle anchors:",
74 report.anchors.len()
75 ));
76 for anchor in &report.anchors {
77 lines.push(format!("- {} at {}", anchor.id, anchor.at));
78 }
79 }
80
81 lines.push(format_cycle_account_line(
82 &report.account_id,
83 account_label.as_deref(),
84 ));
85 lines.push(format!("Cycle file: {}", report.cycle_file));
86 Ok(lines.join("\n"))
87}
88
89pub fn run_cycle_list(
90 options: CycleCommandOptions,
91 now: DateTime<Utc>,
92) -> Result<String, AppError> {
93 let report = list_weekly_cycle_anchors_from_file(&options, now)?;
94 let context = cycle_report_context(
95 &report.account_id,
96 report.account_source,
97 &report.cycle_file,
98 &options,
99 now,
100 );
101 format_weekly_cycle_anchor_list(&report, resolve_cycle_format(&options)?, &context)
102}
103
104pub fn run_cycle_remove(
105 anchor_id: &str,
106 options: CycleCommandOptions,
107 now: DateTime<Utc>,
108) -> Result<String, AppError> {
109 let report = remove_weekly_cycle_anchor_from_file(anchor_id, &options, now)?;
110 let account_label = resolve_cycle_account_label(&report.account_id, &options, now);
111
112 Ok(format!(
113 "Removed weekly cycle anchor: {}\n{}\nCycle file: {}",
114 report.anchor.id,
115 format_cycle_account_line(&report.account_id, account_label.as_deref()),
116 report.cycle_file
117 ))
118}
119
120pub fn run_cycle_current(
121 options: CycleCommandOptions,
122 now: DateTime<Utc>,
123) -> Result<String, AppError> {
124 let format = resolve_cycle_format(&options)?;
125 let anchor_report = list_weekly_cycle_anchors_from_file(&options, now)?;
126 let context = cycle_report_context(
127 &anchor_report.account_id,
128 anchor_report.account_source,
129 &anchor_report.cycle_file,
130 &options,
131 now,
132 );
133 let usage = read_weekly_cycle_usage_for_current(
134 &anchor_report.anchors,
135 &anchor_report.account_id,
136 &options,
137 now,
138 )?;
139 let report = build_weekly_cycle_current_report(
140 &anchor_report.anchors,
141 usage.records,
142 now,
143 usage.diagnostics,
144 );
145
146 format_weekly_cycle_current(&report, format, &context)
147}
148
149pub fn run_cycle_history(
150 cycle_id: Option<String>,
151 mut options: CycleCommandOptions,
152 now: DateTime<Utc>,
153) -> Result<String, AppError> {
154 if cycle_id.is_some() && options.select {
155 return Err(AppError::new(
156 "cycle history accepts either a cycle id or --select, not both.",
157 ));
158 }
159
160 if !has_explicit_cycle_history_range(&options) {
161 options.stat.all = true;
162 }
163
164 let format = resolve_cycle_format(&options)?;
165 let range = resolve_stat_range_options_from_raw(&options.stat, now)?;
166 let anchor_report = list_weekly_cycle_anchors_from_file(&options, now)?;
167 let context = cycle_report_context(
168 &anchor_report.account_id,
169 anchor_report.account_source,
170 &anchor_report.cycle_file,
171 &options,
172 now,
173 );
174 let usage = read_weekly_cycle_usage_for_history(
175 &anchor_report.anchors,
176 &anchor_report.account_id,
177 &options,
178 &range,
179 )?;
180 let history = build_weekly_cycle_history_report(
181 &anchor_report.anchors,
182 usage.records.clone(),
183 Some(range.start),
184 range.end,
185 options.estimate_before_anchor,
186 usage.diagnostics.clone(),
187 );
188
189 if let Some(cycle_id) = cycle_id {
190 let detail = build_weekly_cycle_detail_report(
191 &history,
192 &cycle_id,
193 usage.records,
194 usage.diagnostics,
195 )?;
196 return format_weekly_cycle_detail(&detail, format, &context);
197 }
198
199 if options.select {
200 if history.rows.is_empty() {
201 return Ok("No weekly cycles to select.\n".to_string());
202 }
203 if !prompt::stdin_and_stderr_are_terminals() {
204 return Err(AppError::new(
205 "cycle history --select requires an interactive terminal unless a cycle id is supplied.",
206 ));
207 }
208
209 let mut prompt = DialoguerPrompt::default();
210 return select_weekly_cycle_history_detail(
211 &history,
212 usage.records,
213 usage.diagnostics,
214 format,
215 &context,
216 &mut prompt,
217 );
218 }
219
220 format_weekly_cycle_history(&history, format, &context)
221}
222
223pub(super) fn select_weekly_cycle_history_detail(
224 history: &WeeklyCycleHistoryReport,
225 records: Vec<UsageRecord>,
226 usage_diagnostics: Option<UsageDiagnostics>,
227 format: StatFormat,
228 context: &WeeklyCycleReportContext,
229 prompt: &mut impl Prompt,
230) -> Result<String, AppError> {
231 let items = history
232 .rows
233 .iter()
234 .map(format_cycle_history_prompt_item)
235 .collect::<Vec<_>>();
236 let selected_index = prompt
237 .select("Select weekly cycle", &items)?
238 .ok_or_else(|| AppError::new("cycle history select cancelled."))?;
239 let selected = history
240 .rows
241 .get(selected_index)
242 .ok_or_else(|| AppError::new("Prompt returned an invalid weekly cycle selection."))?;
243 let detail =
244 build_weekly_cycle_detail_report(history, &selected.id, records, usage_diagnostics)?;
245 format_weekly_cycle_detail(&detail, format, context)
246}
247
248pub(super) fn resolve_cycle_format(options: &CycleCommandOptions) -> Result<StatFormat, AppError> {
249 if options.json {
250 return Ok(StatFormat::Json);
251 }
252 match options.format.as_deref().unwrap_or("table") {
253 "table" => Ok(StatFormat::Table),
254 "json" => Ok(StatFormat::Json),
255 "csv" => Ok(StatFormat::Csv),
256 "markdown" => Ok(StatFormat::Markdown),
257 _ => Err(AppError::invalid_input(
258 "Invalid format value. Expected one of: table, json, csv, markdown.",
259 )),
260 }
261}
262
263pub(super) fn has_explicit_cycle_history_range(options: &CycleCommandOptions) -> bool {
264 options.stat.all
265 || options.stat.today
266 || options.stat.yesterday
267 || options.stat.month
268 || options.stat.last.is_some()
269 || options.stat.start.is_some()
270 || options.stat.end.is_some()
271}
272
273pub(super) fn resolve_cycle_file(options: &CycleCommandOptions) -> PathBuf {
274 resolve_storage_paths(&StorageOptions {
275 codex_home: options.codex_home.clone(),
276 cycle_file: options.cycle_file.clone(),
277 ..StorageOptions::default()
278 })
279 .cycle_file
280}
281
282pub(super) fn resolve_account_history_file(options: &CycleCommandOptions) -> PathBuf {
283 resolve_storage_paths(&StorageOptions {
284 codex_home: options.codex_home.clone(),
285 account_history_file: options.account_history_file.clone(),
286 ..StorageOptions::default()
287 })
288 .account_history_file
289}
290
291pub(super) fn auth_options(options: &CycleCommandOptions) -> AuthCommandOptions {
292 AuthCommandOptions {
293 auth_file: options.auth_file.clone(),
294 codex_home: options.codex_home.clone(),
295 store_dir: None,
296 account_history_file: options.account_history_file.clone(),
297 }
298}