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}