1use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::PlanningCycle;
21use crate::error::{Result, TixError};
22use crate::records::{HasName, RecordStore};
23
24pub const DEFAULT_CYCLE_NAME: &str = "Default";
26
27pub static IMPLICIT_DEFAULT_CYCLE: PlanningCycle = PlanningCycle {
29 description: None,
30 start_date: None,
31 due_date: None,
32};
33
34#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
51#[serde(default, deny_unknown_fields)]
52pub struct CycleRecord {
53 pub name: String,
56 pub description: Option<String>,
58 pub start_date: Option<String>,
60 pub due_date: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
65 pub schedule_id: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
69 pub sequence: Option<u32>,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub state: Option<String>,
74}
75
76impl HasName for CycleRecord {
77 fn name(&self) -> &str {
78 &self.name
79 }
80}
81
82impl From<CycleRecord> for PlanningCycle {
83 fn from(record: CycleRecord) -> Self {
84 Self {
85 description: record.description,
86 start_date: record.start_date,
87 due_date: record.due_date,
88 }
89 }
90}
91
92pub fn cycle_record(name: &str, cycle: &PlanningCycle) -> CycleRecord {
99 CycleRecord {
100 name: name.to_string(),
101 description: cycle.description.clone(),
102 start_date: cycle.start_date.clone(),
103 due_date: cycle.due_date.clone(),
104 schedule_id: None,
105 sequence: None,
106 state: None,
107 }
108}
109
110pub fn is_implicit_default(name: &str, cycle: &PlanningCycle) -> bool {
114 name == DEFAULT_CYCLE_NAME
115 && cycle.description.is_none()
116 && cycle.start_date.is_none()
117 && cycle.due_date.is_none()
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct CycleStore {
123 dir: PathBuf,
124}
125
126impl CycleStore {
127 pub fn new(root: &Path) -> Self {
129 Self {
130 dir: root.join("docs/cycles"),
131 }
132 }
133
134 pub fn dir(&self) -> &Path {
136 &self.dir
137 }
138
139 pub fn exists(&self) -> bool {
142 self.dir.is_dir()
143 }
144
145 pub fn list(&self) -> Result<Vec<CycleRecord>> {
148 let mut records: Vec<CycleRecord> = RecordStore::new(&self.dir)
149 .list::<CycleRecord>()?
150 .into_iter()
151 .map(|record| record.value)
152 .collect();
153 if !records
154 .iter()
155 .any(|record| record.name == DEFAULT_CYCLE_NAME)
156 {
157 records.insert(
158 0,
159 CycleRecord {
160 name: DEFAULT_CYCLE_NAME.to_string(),
161 ..CycleRecord::default()
162 },
163 );
164 }
165 Ok(records)
166 }
167
168 pub fn get(&self, name: &str) -> Result<CycleRecord> {
172 RecordStore::new(&self.dir)
173 .get::<CycleRecord>(name)
174 .map(|record| record.value)
175 }
176
177 pub fn save(&self, name: &str, cycle: &PlanningCycle, body: Option<&str>) -> Result<()> {
190 let existing = RecordStore::new(&self.dir).get::<CycleRecord>(name).ok();
191 let record = CycleRecord {
192 name: name.to_string(),
193 description: cycle.description.clone(),
194 start_date: cycle.start_date.clone(),
195 due_date: cycle.due_date.clone(),
196 schedule_id: existing.as_ref().and_then(|r| r.value.schedule_id.clone()),
197 sequence: existing.as_ref().and_then(|r| r.value.sequence),
198 state: existing.as_ref().and_then(|r| r.value.state.clone()),
199 };
200 self.save_record(&record, body)
201 }
202
203 pub fn save_record(&self, record: &CycleRecord, body: Option<&str>) -> Result<()> {
206 let filename = format!("{}.md", crate::slug::slugify(&record.name));
207 RecordStore::new(&self.dir).overwrite(&filename, record, body)?;
208 Ok(())
209 }
210
211 pub fn rename(&self, old: &str, new: &str) -> Result<()> {
215 if old == new {
216 return Ok(());
217 }
218 let store = RecordStore::new(&self.dir);
219 let record = store.get::<CycleRecord>(old)?;
220 let new_filename = format!("{}.md", crate::slug::slugify(new));
221 if self.dir.join(&new_filename).exists() {
222 return Err(TixError::InvalidInput(format!(
223 "cycle `{new}` already exists"
224 )));
225 }
226 let value = CycleRecord {
227 name: new.to_string(),
228 ..record.value
229 };
230 store.overwrite(&new_filename, &value, Some(&record.body))?;
231 store.delete_file(&record.filename)?;
232 Ok(())
233 }
234
235 pub fn delete(&self, name: &str) -> Result<()> {
237 let store = RecordStore::new(&self.dir);
238 let record = store.get::<CycleRecord>(name)?;
239 store.delete_file(&record.filename)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use std::fs;
246
247 use tempfile::TempDir;
248
249 #[allow(clippy::wildcard_imports)]
250 use super::*;
251
252 #[test]
253 fn default_is_implicit_and_a_second_cycle_creates_the_directory() {
254 let tmp = TempDir::new().unwrap();
255 let store = CycleStore::new(tmp.path());
256 assert!(!store.exists());
257 assert_eq!(store.list().unwrap().len(), 1);
258 assert_eq!(store.list().unwrap()[0].name, DEFAULT_CYCLE_NAME);
259 assert!(
260 !tmp.path().join("docs/cycles").exists(),
261 "reading must not create the directory"
262 );
263
264 store
265 .save("Sprint 7", &IMPLICIT_DEFAULT_CYCLE, None)
266 .unwrap();
267 assert!(tmp.path().join("docs/cycles").is_dir());
268 let names: Vec<String> = store.list().unwrap().into_iter().map(|r| r.name).collect();
269 assert_eq!(names, [DEFAULT_CYCLE_NAME.to_string(), "Sprint 7".into()]);
270 assert!(
271 !tmp.path().join("docs/cycles/default.md").exists(),
272 "Default stays implicit"
273 );
274 }
275
276 #[test]
277 fn cycle_file_round_trips_with_rename_and_delete() {
278 let tmp = TempDir::new().unwrap();
279 let dir = tmp.path().join("docs/cycles");
280 fs::create_dir_all(&dir).unwrap();
281 fs::write(
282 dir.join("release.md"),
283 "---\nname: Release\ndescription: September\nstart_date: 2026-09-01\n\
284 due_date: 2026-09-14\n---\n\nNotes.\n",
285 )
286 .unwrap();
287 let store = CycleStore::new(tmp.path());
288 let record = store.get("Release").unwrap();
289 assert_eq!(record.start_date.as_deref(), Some("2026-09-01"));
290
291 store.rename("Release", "Release 2").unwrap();
292 assert!(!dir.join("release.md").exists());
293 assert!(dir.join("release-2.md").exists());
294 let renamed = store.get("Release 2").unwrap();
295 assert_eq!(renamed.description.as_deref(), Some("September"));
296 assert_eq!(renamed.due_date.as_deref(), Some("2026-09-14"));
297
298 store.delete("Release 2").unwrap();
299 assert!(store.get("Release 2").is_err());
300 }
301}