Skip to main content

codex_ops/cycles/
cli.rs

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, &times, 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}