Skip to main content

codex_ops/cycles/
mod.rs

1mod accounts;
2mod cli;
3mod formatters;
4mod reports;
5mod store;
6mod time;
7mod usage;
8mod windows;
9
10pub use cli::{
11    run_cycle_add, run_cycle_current, run_cycle_history, run_cycle_list, run_cycle_remove,
12    CycleCommandHelps, CycleCommandOptions,
13};
14pub use store::WeeklyCycleAnchor;
15
16use crate::error::AppError;
17
18pub(super) const WEEKLY_CYCLE_STORE_VERSION: u8 = 1;
19pub(super) const WEEKLY_CYCLE_PERIOD_HOURS: i64 = 168;
20pub(super) const WEEKLY_CYCLE_PERIOD_MS: i64 = WEEKLY_CYCLE_PERIOD_HOURS * 60 * 60 * 1000;
21pub(super) const DEFAULT_WEEKLY_CYCLE_ACCOUNT_ID: &str = "default";
22
23fn normalize_required_id(value: &str, label: &str) -> Result<String, AppError> {
24    let normalized = value.trim();
25    if normalized.is_empty() {
26        Err(AppError::new(format!(
27            "Weekly cycle {label} cannot be empty."
28        )))
29    } else {
30        Ok(normalized.to_string())
31    }
32}
33
34fn normalize_optional_id(value: Option<&str>) -> Option<String> {
35    let normalized = value?.trim();
36    if normalized.is_empty() {
37        None
38    } else {
39        Some(normalized.to_string())
40    }
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46    use crate::error::AppError;
47    use crate::stats::{StatFormat, TokenUsage, UsageRecord};
48
49    #[test]
50    fn parses_multiple_cycle_add_times() {
51        assert_eq!(
52            time::parse_cycle_add_times(&[
53                "2026-05-17".to_string(),
54                "09:00".to_string(),
55                "2026-05-18T00:00:00Z,2026-05-19".to_string()
56            ])
57            .expect("times"),
58            vec!["2026-05-17 09:00", "2026-05-18T00:00:00Z", "2026-05-19"]
59        );
60    }
61
62    #[test]
63    fn derives_delayed_weekly_cycles() {
64        let anchors = vec![anchor("anchor-may-01", "2026-05-01T00:00:00.000Z")];
65        let records = vec![
66            record("2026-05-01T01:00:00.000Z", "session-a", 100),
67            record("2026-05-07T23:59:59.000Z", "session-a", 20),
68            record("2026-05-09T08:00:00.000Z", "session-b", 50),
69        ];
70        let report = reports::build_weekly_cycle_history_report(
71            &anchors,
72            records,
73            None,
74            time::parse_iso_timestamp("2026-05-10T00:00:00.000Z").expect("now"),
75            false,
76            None,
77        );
78
79        assert_eq!(report.status, "ok");
80        assert_eq!(
81            report.rows.iter().map(|row| row.source).collect::<Vec<_>>(),
82            vec!["manual", "derived"]
83        );
84        assert_eq!(
85            report
86                .rows
87                .iter()
88                .map(|row| row.id.as_str())
89                .collect::<Vec<_>>(),
90            vec!["anchor-may-01", "cyc_20260509T080000000Z"]
91        );
92        assert_eq!(report.rows[0].calls, 2);
93        assert_eq!(report.rows[1].usage.total_tokens, 50);
94        assert_eq!(report.totals.calls, 3);
95    }
96
97    #[test]
98    fn estimates_windows_before_anchor_when_requested() {
99        let anchors = vec![anchor("anchor-may-08", "2026-05-08T00:00:00.000Z")];
100        let records = vec![
101            record("2026-05-01T01:00:00.000Z", "session-a", 100),
102            record("2026-05-08T01:00:00.000Z", "session-b", 50),
103        ];
104        let report = reports::build_weekly_cycle_history_report(
105            &anchors,
106            records,
107            Some(time::parse_iso_timestamp("2026-05-01T00:00:00.000Z").expect("start")),
108            time::parse_iso_timestamp("2026-05-10T00:00:00.000Z").expect("end"),
109            true,
110            None,
111        );
112
113        assert_eq!(
114            report.rows.iter().map(|row| row.source).collect::<Vec<_>>(),
115            vec!["estimated", "manual"]
116        );
117        assert_eq!(report.diagnostics.estimated_windows, 1);
118        assert_eq!(report.diagnostics.ignored_before_anchor_events, 0);
119    }
120
121    #[test]
122    fn unpriced_model_notes_are_carried_in_cycle_totals() {
123        let anchors = vec![anchor("anchor-may-01", "2026-05-01T00:00:00.000Z")];
124        let mut unpriced = record("2026-05-01T01:00:00.000Z", "session-a", 100);
125        unpriced.model = "new-unknown-model".to_string();
126        let report = reports::build_weekly_cycle_history_report(
127            &anchors,
128            vec![unpriced],
129            None,
130            time::parse_iso_timestamp("2026-05-02T00:00:00.000Z").expect("end"),
131            false,
132            None,
133        );
134
135        assert_eq!(report.totals.unpriced_calls, 1);
136        assert_eq!(report.totals.unpriced_models[0].model, "new-unknown-model");
137        assert!(report.totals.unpriced_models[0]
138            .pricing_stub
139            .contains("\"new-unknown-model\""));
140    }
141
142    #[test]
143    fn interactive_history_select_formats_selected_detail() {
144        let anchors = vec![anchor("anchor-may-01", "2026-05-01T00:00:00.000Z")];
145        let records = vec![
146            record("2026-05-01T01:00:00.000Z", "session-a", 100),
147            record("2026-05-09T08:00:00.000Z", "session-b", 50),
148        ];
149        let history = reports::build_weekly_cycle_history_report(
150            &anchors,
151            records.clone(),
152            None,
153            time::parse_iso_timestamp("2026-05-10T00:00:00.000Z").expect("now"),
154            false,
155            None,
156        );
157        let context = reports::WeeklyCycleReportContext {
158            account_id: Some("account-fixture".to_string()),
159            account_label: None,
160            account_source: Some(accounts::WeeklyCycleAccountSource::Explicit.as_str()),
161            cycle_file: Some("/tmp/stat-cycles.json".to_string()),
162        };
163        let mut prompt = FakePrompt {
164            select: Some(Some(1)),
165            ..FakePrompt::default()
166        };
167
168        let output = cli::select_weekly_cycle_history_detail(
169            &history,
170            records,
171            None,
172            StatFormat::Table,
173            &context,
174            &mut prompt,
175        )
176        .expect("selected detail");
177
178        assert!(output.contains("Codex weekly cycle detail"));
179        assert!(output.contains("Cycle ID: cyc_20260509T080000000Z"));
180        assert!(output.contains("50"));
181        assert_eq!(prompt.select_items[0].len(), 2);
182        assert!(prompt.select_items[0][1].contains("cyc_20260509T080000000Z"));
183    }
184
185    #[derive(Default)]
186    struct FakePrompt {
187        select: Option<Option<usize>>,
188        select_items: Vec<Vec<String>>,
189    }
190
191    impl crate::prompt::Prompt for FakePrompt {
192        fn select(&mut self, _prompt: &str, items: &[String]) -> Result<Option<usize>, AppError> {
193            self.select_items.push(items.to_vec());
194            self.select
195                .take()
196                .ok_or_else(|| AppError::new("missing fake select response"))
197        }
198
199        fn multi_select(
200            &mut self,
201            _prompt: &str,
202            _items: &[String],
203        ) -> Result<Option<Vec<usize>>, AppError> {
204            Err(AppError::new("unexpected fake multi-select call"))
205        }
206
207        fn confirm(&mut self, _prompt: &str, _default: bool) -> Result<Option<bool>, AppError> {
208            Err(AppError::new("unexpected fake confirm call"))
209        }
210    }
211
212    fn anchor(id: &str, at: &str) -> WeeklyCycleAnchor {
213        WeeklyCycleAnchor {
214            id: id.to_string(),
215            at: at.to_string(),
216            input: at.to_string(),
217            time_zone: "UTC".to_string(),
218            source: "manual".to_string(),
219            note: String::new(),
220            created_at: "2026-05-01T00:00:00.000Z".to_string(),
221        }
222    }
223
224    fn record(timestamp: &str, session_id: &str, total_tokens: i64) -> UsageRecord {
225        UsageRecord {
226            timestamp: time::parse_iso_timestamp(timestamp).expect("timestamp"),
227            session_id: session_id.to_string(),
228            model: "gpt-5.5".to_string(),
229            reasoning_effort: None,
230            cwd: "/repo".to_string(),
231            account_id: Some("account-fixture".to_string()),
232            file_path: "/tmp/session.jsonl".to_string(),
233            usage: TokenUsage {
234                input_tokens: total_tokens,
235                cached_input_tokens: 0,
236                output_tokens: 0,
237                reasoning_output_tokens: 0,
238                total_tokens,
239            },
240        }
241    }
242}