use std::path::Path;
use crate::config::Config;
use crate::cycle_schedule::config::{
Cadence, CycleDuration, CycleScheduleConfiguration, CycleScheduleExtensions, CycleScheduleId,
ScheduleState,
};
use crate::cycle_schedule::identity::ScheduleIndex;
use crate::cycle_schedule::snapshot::CycleSnapshotStore;
use crate::cycle_store::CycleStore;
use crate::error::{Result, TixError, render_diagnostics};
use crate::recurrence::rule::{IntervalUnit, RuleKind, Timezone};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduleDraft {
pub id: String,
pub name: String,
pub cadence: String,
pub interval: Option<u32>,
pub unit: Option<String>,
pub duration: u32,
pub duration_unit: String,
pub start_date: String,
pub timezone: String,
pub look_ahead: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScheduleEdit {
pub name: Option<String>,
pub cadence: Option<String>,
pub interval: Option<u32>,
pub unit: Option<String>,
pub duration: Option<u32>,
pub duration_unit: Option<String>,
pub start_date: Option<String>,
pub timezone: Option<String>,
pub look_ahead: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScheduleDeletion {
pub id: CycleScheduleId,
pub generated_cycles: usize,
}
pub fn load_index(config: &Config, root: &Path) -> Result<ScheduleIndex> {
let records = CycleStore::new(root).list()?;
Ok(ScheduleIndex::build(config, &records))
}
pub fn resolve(index: &ScheduleIndex, selector: &str) -> Result<CycleScheduleId> {
let trimmed = selector.trim();
if let Ok(id) = CycleScheduleId::new(trimmed)
&& index.schedule(&id).is_some()
{
return Ok(id);
}
let folded = trimmed.to_lowercase();
let matches: Vec<CycleScheduleId> = index
.schedules()
.filter(|(_, record)| record.name.to_lowercase() == folded)
.map(|(id, _)| id.clone())
.collect();
match matches.as_slice() {
[id] => Ok(id.clone()),
[] => Err(TixError::NotFound(format!("cycle schedule `{trimmed}`"))),
many => Err(TixError::InvalidInput(format!(
"cycle schedule name `{trimmed}` is ambiguous: {}",
many.iter()
.map(CycleScheduleId::as_str)
.collect::<Vec<_>>()
.join(", ")
))),
}
}
pub fn create(config: &mut Config, root: &Path, draft: &ScheduleDraft) -> Result<CycleScheduleId> {
let id = CycleScheduleId::new(&draft.id)?;
if config.cycle_schedules.contains_key(id.as_str()) {
return Err(refuse(
"exists",
format!(
"schedule `{id}` already exists; ids are never reused — choose another `--id` or \
edit the existing schedule"
),
));
}
let claimed_by_record = load_index(config, root)?.cycles_of(&id).next().is_some();
let claimed_by_snapshot = CycleSnapshotStore::new(root)
.list()?
.iter()
.any(|snapshot| snapshot.schedule_id == id);
if claimed_by_record || claimed_by_snapshot {
let holder = match (claimed_by_record, claimed_by_snapshot) {
(true, true) => "generated cycle record(s) and closed-cycle snapshot(s)",
(true, false) => "generated cycle record(s)",
(false, _) => "closed-cycle snapshot(s)",
};
return Err(refuse(
"id_spent",
format!(
"schedule id `{id}` is still claimed by {holder}; a deleted schedule's id is \
never reused, so nothing is silently rebound to a new definition — choose a new \
`--id`, or delete those {holder} deliberately"
),
));
}
let record = record_for(draft)?;
config
.cycle_schedules
.insert(id.as_str().to_string(), record);
save(config, root)?;
Ok(id)
}
pub fn edit(
config: &mut Config,
root: &Path,
selector: &str,
patch: &ScheduleEdit,
) -> Result<CycleScheduleId> {
let id = resolve(&load_index(config, root)?, selector)?;
let mut record = config
.cycle_schedules
.get(id.as_str())
.cloned()
.expect("a resolved schedule is in the registry");
if let Some(name) = patch.name.as_deref() {
record.name = name.to_string();
}
if patch.cadence.is_some() || patch.interval.is_some() || patch.unit.is_some() {
let mut cadence = record.cadence.clone();
if let Some(kind) = patch.cadence.as_deref() {
cadence.kind = Some(cadence_kind(kind)?);
}
if let Some(interval) = patch.interval {
cadence.interval = Some(interval);
}
if let Some(unit) = patch.unit.as_deref() {
cadence.unit = Some(interval_unit(unit)?);
}
record.cadence = cadence;
}
if patch.duration.is_some() || patch.duration_unit.is_some() {
let count = patch.duration.unwrap_or(record.duration.count);
let unit = match patch.duration_unit.as_deref() {
Some(unit) => Some(interval_unit(unit)?),
None => record.duration.unit,
};
record.duration = CycleDuration {
count,
unit,
extensions: record.duration.extensions.clone(),
};
}
if let Some(start_date) = patch.start_date.as_deref() {
record.start_date = start_date.to_string();
}
if let Some(timezone) = patch.timezone.as_deref() {
record.timezone = Some(Timezone::parse(timezone));
}
if let Some(look_ahead) = patch.look_ahead {
record.look_ahead = look_ahead;
}
config
.cycle_schedules
.insert(id.as_str().to_string(), record);
save(config, root)?;
Ok(id)
}
pub fn delete(config: &mut Config, root: &Path, selector: &str) -> Result<ScheduleDeletion> {
let index = load_index(config, root)?;
let id = resolve(&index, selector)?;
let generated_cycles = index.cycles_of(&id).count();
config.cycle_schedules.shift_remove(id.as_str());
save(config, root)?;
Ok(ScheduleDeletion {
id,
generated_cycles,
})
}
pub fn record_for(draft: &ScheduleDraft) -> Result<CycleScheduleConfiguration> {
Ok(CycleScheduleConfiguration {
name: draft.name.clone(),
cadence: build_cadence(
draft.cadence.as_str(),
draft.interval,
draft.unit.as_deref(),
)?,
duration: CycleDuration::new(draft.duration, interval_unit(&draft.duration_unit)?),
start_date: draft.start_date.clone(),
timezone: Some(Timezone::parse(&draft.timezone)),
look_ahead: draft.look_ahead.unwrap_or(0),
state: ScheduleState::Active,
cursor: None,
extensions: CycleScheduleExtensions::default(),
})
}
pub fn save(config: &mut Config, root: &Path) -> Result<()> {
config.validate()?;
let path = root.join("tix.toml");
config
.save(&path)
.map_err(|error| TixError::Config(format!("could not save `{}`: {error}", path.display())))
}
pub fn refuse(kind: &'static str, message: String) -> TixError {
TixError::Refused(render_diagnostics(
"E-tix-cycle-schedule",
std::iter::once((kind, message.as_str())),
))
}
pub fn cadence_kind(raw: &str) -> Result<RuleKind> {
[
RuleKind::Daily,
RuleKind::Weekly,
RuleKind::Monthly,
RuleKind::Interval,
RuleKind::CronLike,
]
.into_iter()
.find(|kind| kind.as_str() == raw.trim())
.ok_or_else(|| {
TixError::InvalidInput(format!(
"`{raw}` is not a cadence family; expected `daily`, `weekly`, `monthly` or `interval`"
))
})
}
pub fn interval_unit(raw: &str) -> Result<IntervalUnit> {
[
IntervalUnit::Days,
IntervalUnit::Weeks,
IntervalUnit::Months,
]
.into_iter()
.find(|unit| unit.as_str() == raw.trim())
.ok_or_else(|| {
TixError::InvalidInput(format!(
"`{raw}` is not a unit; expected `days`, `weeks` or `months`"
))
})
}
pub fn build_cadence(kind: &str, interval: Option<u32>, unit: Option<&str>) -> Result<Cadence> {
let mut cadence = Cadence::new(cadence_kind(kind)?);
if let Some(interval) = interval {
cadence = cadence.with_interval(interval);
}
if let Some(unit) = unit {
cadence = cadence.with_unit(interval_unit(unit)?);
}
Ok(cadence)
}
pub fn canonical_date(raw: &str) -> Result<String> {
let raw = raw.trim();
if !crate::model::validate_date(raw) {
return Err(TixError::InvalidInput(format!(
"`{raw}` is not a `YYYY-MM-DD` calendar date"
)));
}
Ok(raw.to_string())
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::cycle_schedule::identity::{GeneratedCycleId, ScheduleIndex, cadence_rule};
use crate::cycle_schedule::snapshot::CycleSnapshotStore;
use crate::cycle_store::CycleRecord;
use std::collections::BTreeMap;
use tempfile::TempDir;
fn project() -> (TempDir, Config, std::path::PathBuf) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
std::fs::create_dir_all(&dir).unwrap();
let mut config = Config {
issues_dir: dir,
..Config::default()
};
config.layout.move_on_change = false;
config.save(&tmp.path().join("tix.toml")).unwrap();
let root = tmp.path().to_path_buf();
(tmp, config, root)
}
fn weekly() -> ScheduleDraft {
ScheduleDraft {
id: "cs-weekly".to_string(),
name: "Weekly sprint".to_string(),
cadence: "weekly".to_string(),
duration: 1,
duration_unit: "weeks".to_string(),
start_date: "2026-01-05".to_string(),
timezone: "UTC".to_string(),
look_ahead: Some(1),
..Default::default()
}
}
#[test]
fn a_draft_defines_a_validated_record_and_a_snapshot_spends_the_id() {
let (_tmp, mut config, root) = project();
let id = create(&mut config, &root, &weekly()).unwrap();
assert_eq!(id.as_str(), "cs-weekly");
let record = config.cycle_schedules.get("cs-weekly").unwrap();
assert_eq!(record.look_ahead, 1);
assert!(cadence_rule(record).is_some(), "the cadence resolves");
let err = create(&mut config, &root, &weekly())
.unwrap_err()
.to_string();
assert!(err.contains("already exists"), "{err}");
delete(&mut config, &root, "cs-weekly").unwrap();
let cycle = GeneratedCycleId::new(&id, 1);
CycleSnapshotStore::new(&root)
.capture(&crate::cycle_schedule::snapshot::CycleSnapshot {
cycle_id: cycle.clone(),
schedule_id: id.clone(),
sequence: 1,
start_date: "2026-01-05".to_string(),
due_date: "2026-01-12".to_string(),
closed_at: "2026-01-13T09:00:00Z".to_string(),
closed_by: "tester".to_string(),
state_summary: "closed".to_string(),
measurements: BTreeMap::default(),
extensions: BTreeMap::default(),
})
.unwrap();
let err = create(&mut config, &root, &weekly())
.unwrap_err()
.to_string();
assert!(err.contains("closed-cycle snapshot"), "{err}");
assert!(!err.contains("generated cycle record"), "{err}");
}
#[test]
fn a_selector_resolves_by_id_then_by_a_unique_folded_name() {
let (_tmp, mut config, root) = project();
create(&mut config, &root, &weekly()).unwrap();
let index = load_index(&config, &root).unwrap();
assert_eq!(resolve(&index, "cs-weekly").unwrap().as_str(), "cs-weekly");
assert_eq!(
resolve(&index, " WEEKLY SPRINT ").unwrap().as_str(),
"cs-weekly"
);
assert!(matches!(
resolve(&index, "nope"),
Err(TixError::NotFound(_))
));
}
#[test]
fn an_edit_changes_only_the_fields_it_names_and_keeps_the_cursor() {
let (_tmp, mut config, root) = project();
create(&mut config, &root, &weekly()).unwrap();
let before = config.cycle_schedules.get("cs-weekly").unwrap().clone();
let id = edit(
&mut config,
&root,
"Weekly sprint",
&ScheduleEdit {
name: Some("Platform sprint".to_string()),
look_ahead: Some(3),
..Default::default()
},
)
.unwrap();
assert_eq!(id.as_str(), "cs-weekly", "the identity survives a rename");
let after = config.cycle_schedules.get("cs-weekly").unwrap();
assert_eq!(after.name, "Platform sprint");
assert_eq!(after.look_ahead, 3, "the named field changed");
assert_eq!(after.duration, before.duration);
assert_eq!(after.cadence, before.cadence);
assert_eq!(after.start_date, before.start_date);
assert_eq!(after.state, before.state);
let index = ScheduleIndex::build(&config, &[]);
assert_eq!(
resolve(&index, "platform sprint").unwrap().as_str(),
"cs-weekly"
);
}
#[test]
fn a_delete_reports_the_records_it_left_on_disk() {
let (_tmp, mut config, root) = project();
create(&mut config, &root, &weekly()).unwrap();
CycleStore::new(&root)
.save_record(
&CycleRecord {
name: "Sprint 1".to_string(),
start_date: Some("2026-01-05".to_string()),
due_date: Some("2026-01-12".to_string()),
schedule_id: Some("cs-weekly".to_string()),
sequence: Some(1),
state: Some("active".to_string()),
..Default::default()
},
None,
)
.unwrap();
let deletion = delete(&mut config, &root, "cs-weekly").unwrap();
assert_eq!(deletion.generated_cycles, 1);
assert!(!config.cycle_schedules.contains_key("cs-weekly"));
}
}