Skip to main content

codex_ops/cycles/
store.rs

1use super::accounts::{resolve_weekly_cycle_account, WeeklyCycleAccountSource};
2use super::cli::{resolve_cycle_file, CycleCommandOptions};
3use super::time::{
4    assert_iso_timestamp, iso_string, parse_weekly_cycle_anchor_time, weekly_cycle_anchor_id,
5};
6use super::{normalize_required_id, WEEKLY_CYCLE_PERIOD_HOURS, WEEKLY_CYCLE_STORE_VERSION};
7use crate::error::AppError;
8use crate::storage::{path_to_string, write_sensitive_file};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::fs;
13use std::io;
14use std::path::Path;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[serde(rename_all = "camelCase")]
18pub struct WeeklyCycleAnchor {
19    pub id: String,
20    pub at: String,
21    pub input: String,
22    pub time_zone: String,
23    pub source: String,
24    #[serde(default)]
25    pub note: String,
26    pub created_at: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "camelCase")]
31struct WeeklyCycleAccountEntry {
32    weekly: WeeklyCycleWeeklyEntry,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "camelCase")]
37struct WeeklyCycleWeeklyEntry {
38    period_hours: i64,
39    anchors: Vec<WeeklyCycleAnchor>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "camelCase")]
44struct WeeklyCycleStore {
45    version: u8,
46    accounts: BTreeMap<String, WeeklyCycleAccountEntry>,
47}
48
49pub(super) struct AnchorMutationReport {
50    pub(super) cycle_file: String,
51    pub(super) account_id: String,
52    pub(super) anchor: WeeklyCycleAnchor,
53    pub(super) anchors: Vec<WeeklyCycleAnchor>,
54}
55
56pub(super) struct AnchorListReport {
57    pub(super) cycle_file: String,
58    pub(super) account_id: String,
59    pub(super) account_source: WeeklyCycleAccountSource,
60    pub(super) anchors: Vec<WeeklyCycleAnchor>,
61}
62
63pub(super) fn add_weekly_cycle_anchors_to_file(
64    options: &CycleCommandOptions,
65    times: &[String],
66    now: DateTime<Utc>,
67) -> Result<AnchorMutationReport, AppError> {
68    if times.is_empty() {
69        return Err(AppError::new(
70            "At least one weekly cycle anchor time is required.",
71        ));
72    }
73
74    let cycle_file = resolve_cycle_file(options);
75    let account = resolve_weekly_cycle_account(options, now)?;
76    let mut store = read_weekly_cycle_store(&cycle_file)?;
77    let mut anchors = Vec::new();
78    for at in times {
79        let anchor = add_weekly_cycle_anchor(
80            &mut store,
81            &account.account_id,
82            at,
83            options.note.as_deref(),
84            now,
85        )?;
86        anchors.push(anchor);
87    }
88    write_weekly_cycle_store(&cycle_file, &store)?;
89
90    Ok(AnchorMutationReport {
91        cycle_file: path_to_string(&cycle_file),
92        account_id: account.account_id,
93        anchor: anchors
94            .first()
95            .cloned()
96            .ok_or_else(|| AppError::new("No weekly cycle anchor was added."))?,
97        anchors,
98    })
99}
100
101pub(super) fn list_weekly_cycle_anchors_from_file(
102    options: &CycleCommandOptions,
103    now: DateTime<Utc>,
104) -> Result<AnchorListReport, AppError> {
105    let cycle_file = resolve_cycle_file(options);
106    let account = resolve_weekly_cycle_account(options, now)?;
107    let store = read_weekly_cycle_store(&cycle_file)?;
108    Ok(AnchorListReport {
109        cycle_file: path_to_string(&cycle_file),
110        account_id: account.account_id.clone(),
111        account_source: account.source,
112        anchors: list_weekly_cycle_anchors(&store, &account.account_id),
113    })
114}
115
116pub(super) fn remove_weekly_cycle_anchor_from_file(
117    anchor_id: &str,
118    options: &CycleCommandOptions,
119    now: DateTime<Utc>,
120) -> Result<AnchorMutationReport, AppError> {
121    let cycle_file = resolve_cycle_file(options);
122    let account = resolve_weekly_cycle_account(options, now)?;
123    let mut store = read_weekly_cycle_store(&cycle_file)?;
124    let removed = remove_weekly_cycle_anchor(&mut store, &account.account_id, anchor_id)?;
125    write_weekly_cycle_store(&cycle_file, &store)?;
126
127    Ok(AnchorMutationReport {
128        cycle_file: path_to_string(&cycle_file),
129        account_id: account.account_id,
130        anchor: removed,
131        anchors: Vec::new(),
132    })
133}
134
135fn create_empty_weekly_cycle_store() -> WeeklyCycleStore {
136    WeeklyCycleStore {
137        version: WEEKLY_CYCLE_STORE_VERSION,
138        accounts: BTreeMap::new(),
139    }
140}
141
142fn add_weekly_cycle_anchor(
143    store: &mut WeeklyCycleStore,
144    account_id: &str,
145    at: &str,
146    note: Option<&str>,
147    now: DateTime<Utc>,
148) -> Result<WeeklyCycleAnchor, AppError> {
149    let account_id = normalize_required_id(account_id, "account id")?;
150    let parsed = parse_weekly_cycle_anchor_time(at)?;
151    normalize_weekly_cycle_store(store)?;
152    let entry = store
153        .accounts
154        .entry(account_id.clone())
155        .or_insert_with(create_weekly_cycle_account_entry);
156
157    if entry
158        .weekly
159        .anchors
160        .iter()
161        .any(|anchor| anchor.at == parsed.at_iso)
162    {
163        return Err(AppError::new(format!(
164            "Weekly cycle anchor already exists for account {account_id} at {}.",
165            parsed.at_iso
166        )));
167    }
168
169    let anchor = WeeklyCycleAnchor {
170        id: weekly_cycle_anchor_id(parsed.at),
171        at: parsed.at_iso,
172        input: parsed.input,
173        time_zone: parsed.time_zone,
174        source: "manual".to_string(),
175        note: note.unwrap_or("").to_string(),
176        created_at: iso_string(now),
177    };
178    entry.weekly.anchors.push(anchor.clone());
179    sort_weekly_cycle_anchors(&mut entry.weekly.anchors);
180    Ok(anchor)
181}
182
183fn list_weekly_cycle_anchors(store: &WeeklyCycleStore, account_id: &str) -> Vec<WeeklyCycleAnchor> {
184    let mut anchors = store
185        .accounts
186        .get(account_id)
187        .map(|entry| entry.weekly.anchors.clone())
188        .unwrap_or_default();
189    sort_weekly_cycle_anchors(&mut anchors);
190    anchors
191}
192
193fn remove_weekly_cycle_anchor(
194    store: &mut WeeklyCycleStore,
195    account_id: &str,
196    anchor_id: &str,
197) -> Result<WeeklyCycleAnchor, AppError> {
198    let account_id = normalize_required_id(account_id, "account id")?;
199    let anchor_id = normalize_required_id(anchor_id, "anchor id")?;
200    normalize_weekly_cycle_store(store)?;
201    let entry = store
202        .accounts
203        .entry(account_id.clone())
204        .or_insert_with(create_weekly_cycle_account_entry);
205    let index = entry
206        .weekly
207        .anchors
208        .iter()
209        .position(|anchor| anchor.id == anchor_id)
210        .ok_or_else(|| {
211            AppError::new(format!(
212                "No weekly cycle anchor found for account {account_id}: {anchor_id}."
213            ))
214        })?;
215
216    Ok(entry.weekly.anchors.remove(index))
217}
218
219fn read_weekly_cycle_store(cycle_file: &Path) -> Result<WeeklyCycleStore, AppError> {
220    let content = match fs::read_to_string(cycle_file) {
221        Ok(content) => content,
222        Err(error) if error.kind() == io::ErrorKind::NotFound => {
223            return Ok(create_empty_weekly_cycle_store());
224        }
225        Err(error) => return Err(AppError::new(error.to_string())),
226    };
227    let mut store: WeeklyCycleStore = serde_json::from_str(&content).map_err(|error| {
228        AppError::new(format!(
229            "Failed to parse {}: {}",
230            path_to_string(cycle_file),
231            error
232        ))
233    })?;
234    normalize_weekly_cycle_store(&mut store)?;
235    Ok(store)
236}
237
238fn write_weekly_cycle_store(cycle_file: &Path, store: &WeeklyCycleStore) -> Result<(), AppError> {
239    let content =
240        serde_json::to_string_pretty(store).map_err(|error| AppError::new(error.to_string()))?;
241    write_sensitive_file(cycle_file, &format!("{content}\n"))
242        .map_err(|error| AppError::new(error.to_string()))
243}
244
245fn normalize_weekly_cycle_store(store: &mut WeeklyCycleStore) -> Result<(), AppError> {
246    if store.version != WEEKLY_CYCLE_STORE_VERSION {
247        return Err(AppError::new(format!(
248            "Unsupported weekly cycle store version: {}.",
249            store.version
250        )));
251    }
252
253    for (account_id, entry) in &mut store.accounts {
254        if entry.weekly.period_hours != WEEKLY_CYCLE_PERIOD_HOURS {
255            return Err(AppError::new(format!(
256                "Expected weekly periodHours for account {account_id} to be {WEEKLY_CYCLE_PERIOD_HOURS}."
257            )));
258        }
259        for anchor in &entry.weekly.anchors {
260            if anchor.source != "manual" {
261                return Err(AppError::new(
262                    "Expected weekly cycle anchor source to be manual.",
263                ));
264            }
265            assert_iso_timestamp(&anchor.at, "anchor.at")?;
266            assert_iso_timestamp(&anchor.created_at, "anchor.createdAt")?;
267        }
268        sort_weekly_cycle_anchors(&mut entry.weekly.anchors);
269    }
270    Ok(())
271}
272
273fn create_weekly_cycle_account_entry() -> WeeklyCycleAccountEntry {
274    WeeklyCycleAccountEntry {
275        weekly: WeeklyCycleWeeklyEntry {
276            period_hours: WEEKLY_CYCLE_PERIOD_HOURS,
277            anchors: Vec::new(),
278        },
279    }
280}
281
282fn sort_weekly_cycle_anchors(anchors: &mut [WeeklyCycleAnchor]) {
283    anchors.sort_by(|left, right| left.at.cmp(&right.at).then_with(|| left.id.cmp(&right.id)));
284}