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}