use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::capacity::scope::IssueSnapshot;
use crate::config::{Config, PlanningCycle, Team};
use crate::cycle_schedule::identity::ScheduleIndex;
use crate::cycle_store::{
CycleStore, DEFAULT_CYCLE_NAME, IMPLICIT_DEFAULT_CYCLE, is_implicit_default,
};
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::records::{HasName, RecordStore};
use crate::storage::Store;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RolloverTarget {
pub from_cycle: String,
pub to_cycle: String,
pub issues: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Progress {
pub done: usize,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameImpact {
pub changed_issue_count: usize,
pub issues: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Impact {
pub referencing_issues: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Milestone {
pub name: String,
pub target_date: String,
pub description: String,
pub created: String,
pub updated: String,
pub file_name: String,
pub color: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct MilestoneHead {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
target_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
created: String,
updated: String,
}
impl HasName for MilestoneHead {
fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone)]
pub struct Planner {
root: PathBuf,
config: Config,
}
impl Planner {
pub fn load() -> Result<Self> {
Self::load_from(Path::new("tix.toml"))
}
pub fn load_from(path: &Path) -> Result<Self> {
let config = Config::load_with_warning(path, |token| {
eprintln!(
"warning: {} embeds a cleartext {} token; prefer an environment variable",
path.display(),
token
);
})?;
let root = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
Ok(Self::from_config(config, root))
}
pub fn from_config(config: Config, root: PathBuf) -> Self {
Self { root, config }
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn category_resolution(&self) -> crate::workflow::CategoryResolution {
let board = self.config.default_config();
crate::workflow::resolution(&self.config, board, "default")
}
fn is_done_issue(&self, issue: &Issue) -> bool {
let resolution = self.category_resolution();
crate::workflow::is_completed(issue, &resolution)
}
pub fn schedule_index(&self) -> Result<ScheduleIndex> {
crate::capacity::schedule_index(&self.config, &self.root)
}
pub fn capacity_snapshot_from(&self, issues: &[Issue]) -> Result<IssueSnapshot> {
let index = self.schedule_index()?;
Ok(IssueSnapshot::build(&self.config, &index, issues))
}
pub fn capacity_snapshot(&self, store: &Store) -> Result<IssueSnapshot> {
self.capacity_snapshot_from(&store.list()?)
}
pub fn milestones_dir(&self) -> PathBuf {
self.root.join(self.config.milestones_dir())
}
fn save(&self) -> Result<()> {
self.config.save(&self.root.join("tix.toml"))
}
pub fn team(&self, label: &str) -> Option<&Team> {
self.config.teams.get(label)
}
pub fn cycle(&self, name: &str) -> Option<&PlanningCycle> {
if let Some(cycle) = self.config.cycles.get(name) {
return Some(cycle);
}
if name == DEFAULT_CYCLE_NAME {
return Some(&IMPLICIT_DEFAULT_CYCLE);
}
None
}
pub fn cycle_names(&self) -> Vec<String> {
self.config.cycles.keys().cloned().collect()
}
fn cycle_store(&self) -> CycleStore {
CycleStore::new(&self.root)
}
fn persist_cycles(&self) -> Result<()> {
let store = self.cycle_store();
let file_mode = store.exists()
|| self
.config
.cycles
.iter()
.any(|(name, cycle)| !is_implicit_default(name, cycle));
if !file_mode {
return Ok(());
}
for (name, cycle) in &self.config.cycles {
if is_implicit_default(name, cycle) {
continue;
}
let filename = format!("{}.md", crate::slug::slugify(name));
let body = RecordStore::new(store.dir()).body_of(&filename);
store.save(name, cycle, body.as_deref())?;
}
Ok(())
}
pub fn is_overdue(issue: &Issue, today: &str) -> bool {
issue.due_date.as_deref().is_some_and(|due| due < today)
}
pub fn cycle_expired(cycle: &PlanningCycle, today: &str) -> bool {
cycle.due_date.as_deref().is_some_and(|due| due < today)
}
pub fn is_running(cycle: &PlanningCycle, today: &str) -> bool {
cycle
.start_date
.as_deref()
.is_some_and(|start| start <= today)
&& cycle.due_date.as_deref().is_none_or(|due| due >= today)
}
pub fn running_cycle(&self, today: &str) -> Option<String> {
let mut running = self
.config
.cycles
.iter()
.filter(|(_, cycle)| Self::is_running(cycle, today))
.map(|(name, _)| name.clone());
let first = running.next()?;
running.next().is_none().then_some(first)
}
pub fn overlapping_cycles(&self, name: &str) -> Vec<String> {
let Some(start) = self
.config
.cycles
.get(name)
.and_then(|cycle| cycle.start_date.as_deref())
else {
return Vec::new();
};
let due = self.config.cycles[name].due_date.as_deref();
self.config
.cycles
.iter()
.filter(|(other, _)| other.as_str() != name)
.filter(|(_, cycle)| {
let Some(other_start) = cycle.start_date.as_deref() else {
return false;
};
let other_ends_after_target_starts =
cycle.due_date.as_deref().is_none_or(|end| start <= end);
let target_ends_after_other_starts = due.is_none_or(|end| other_start <= end);
other_ends_after_target_starts && target_ends_after_other_starts
})
.map(|(other, _)| other.clone())
.collect()
}
pub fn successor<'a>(
&'a self,
cycle: &str,
today: &str,
) -> Option<(&'a str, &'a PlanningCycle)> {
self.config
.cycles
.iter()
.filter(|(name, c)| {
*name != cycle && c.start_date.as_deref().is_some_and(|s| s >= today)
})
.min_by(|a, b| {
let by_start = a.1.start_date.cmp(&b.1.start_date);
if by_start == std::cmp::Ordering::Equal {
a.0.cmp(b.0)
} else {
by_start
}
})
.map(|(name, c)| (name.as_str(), c))
}
pub fn overdue_batch(&self, store: &Store, today: &str) -> Vec<Issue> {
store
.list()
.unwrap_or_default()
.into_iter()
.filter(|issue| !self.is_done_issue(issue) && Self::is_overdue(issue, today))
.collect()
}
pub fn rollover_plan(&self, store: &Store, today: &str) -> Vec<RolloverTarget> {
self.rollover_plan_from(&store.list().unwrap_or_default(), today)
}
pub fn rollover_plan_from(&self, all: &[Issue], today: &str) -> Vec<RolloverTarget> {
let mut out = Vec::new();
for (from_cycle, cycle) in &self.config.cycles {
if !Self::cycle_expired(cycle, today) {
continue;
}
let Some((to_cycle, _successor)) = self.successor(from_cycle, today) else {
continue;
};
let issues = all
.iter()
.filter(|issue| {
issue
.cycles
.as_ref()
.is_some_and(|cycles| cycles.iter().any(|c| c == from_cycle))
&& !self.is_done_issue(issue)
&& !issue
.cycles
.as_ref()
.is_some_and(|cycles| cycles.iter().any(|c| c == to_cycle))
})
.map(|issue| issue.number)
.collect::<Vec<_>>();
if !issues.is_empty() {
out.push(RolloverTarget {
from_cycle: from_cycle.clone(),
to_cycle: to_cycle.to_string(),
issues,
});
}
}
out
}
pub fn cycle_progress(&self, store: &Store, name: &str) -> Progress {
let mut done = 0usize;
let mut total = 0usize;
for issue in store.list().unwrap_or_default() {
if issue
.cycles
.as_ref()
.is_some_and(|cycles| cycles.iter().any(|cycle| cycle == name))
{
total += 1;
if self.is_done_issue(&issue) {
done += 1;
}
}
}
Progress { done, total }
}
pub fn milestone_progress(&self, store: &Store, name: &str) -> Progress {
let mut done = 0usize;
let mut total = 0usize;
for issue in store.list().unwrap_or_default() {
if issue
.milestones
.as_ref()
.is_some_and(|milestones| milestones.iter().any(|milestone| milestone == name))
{
total += 1;
if self.is_done_issue(&issue) {
done += 1;
}
}
}
Progress { done, total }
}
pub fn next_cycle_name(&mut self) -> String {
let mut n = self.config.planning.cycle_number;
loop {
let candidate = format!("Cycle {n}");
if !self.config.cycles.contains_key(&candidate) {
self.config.planning.cycle_number = n + 1;
return candidate;
}
n += 1;
}
}
pub fn add_cycle(&mut self, name: Option<String>) -> Result<String> {
let name = match name {
Some(name) => name,
None => self.next_cycle_name(),
};
let name = name.trim().to_string();
if name.is_empty() {
return Err(TixError::InvalidInput(
"cycle name must not be blank".into(),
));
}
if name == DEFAULT_CYCLE_NAME && !self.config.cycles.contains_key(&name) {
return Err(TixError::InvalidInput(format!(
"cycle `{name}` already exists"
)));
}
if self.config.cycles.contains_key(&name) {
return Err(TixError::InvalidInput(format!(
"cycle `{name}` already exists"
)));
}
if let Some(auto) = self.current_auto_name()
&& name == auto
{
return Err(TixError::Config(format!(
"cycle `{name}` collides with the auto-cycle counter; pick another name or let it auto-increment"
)));
}
self.config
.cycles
.insert(name.clone(), PlanningCycle::default());
self.persist_cycles()?;
self.save()?;
Ok(name)
}
pub fn rename_cycle(&mut self, old: &str, new: &str) -> Result<()> {
let new = new.trim().to_string();
if old == new {
return Ok(());
}
if !self.config.cycles.contains_key(old) {
return Err(TixError::InvalidInput(format!(
"cycle `{old}` does not exist"
)));
}
if new.is_empty() {
return Err(TixError::InvalidInput(
"cycle name must not be blank".into(),
));
}
if new == DEFAULT_CYCLE_NAME && !self.config.cycles.contains_key(&new) {
return Err(TixError::InvalidInput(format!(
"cycle `{new}` already exists"
)));
}
if self.config.cycles.contains_key(&new) {
return Err(TixError::InvalidInput(format!(
"cycle `{new}` already exists"
)));
}
if let Some(auto) = self.current_auto_name()
&& new == auto
{
return Err(TixError::Config(format!(
"cycle `{new}` collides with the auto-cycle counter"
)));
}
let had_file = self.cycle_store().get(old).is_ok();
let cycle = self
.config
.cycles
.shift_remove(old)
.expect("existence checked above");
self.config.cycles.insert(new.clone(), cycle);
if self.config.planning.default_cycle.as_deref() == Some(old) {
self.config.planning.default_cycle = Some(new.clone());
}
if had_file {
self.cycle_store().rename(old, &new)?;
}
self.persist_cycles()?;
self.save()?;
Ok(())
}
pub fn delete_impact(&self, store: &Store, name: &str) -> Result<Impact> {
let mut referencing_issues = Vec::new();
for issue in store.list()? {
if issue
.cycles
.as_ref()
.is_some_and(|cycles| cycles.iter().any(|cycle| cycle == name))
{
referencing_issues.push(issue.filename);
}
}
Ok(Impact { referencing_issues })
}
pub fn rename_cycle_cascade(
&mut self,
store: &Store,
old: &str,
new: &str,
) -> Result<RenameImpact> {
let new = new.trim();
if new.is_empty() {
return Err(TixError::InvalidInput(
"cycle name must not be blank".into(),
));
}
if !self.config.cycles.contains_key(old) {
return Err(TixError::InvalidInput(format!(
"cycle `{old}` does not exist"
)));
}
if old != new && self.config.cycles.contains_key(new) {
return Err(TixError::InvalidInput(format!(
"cycle `{new}` already exists"
)));
}
let mut issues = Vec::new();
for mut issue in store.list()? {
let Some(cycles) = issue.cycles.as_mut() else {
continue;
};
let mut changed = false;
for cycle in cycles.iter_mut() {
if cycle == old {
*cycle = new.to_string();
changed = true;
}
}
if changed {
issue.bump_updated();
issue.filename = store.update(&issue.filename, &issue)?;
issues.push(issue.filename);
}
}
self.rename_cycle(old, new)?;
Ok(RenameImpact {
changed_issue_count: issues.len(),
issues,
})
}
pub fn set_cycle_fields(
&mut self,
name: &str,
description: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
) -> Result<()> {
if !self.config.cycles.contains_key(name) {
if name == DEFAULT_CYCLE_NAME {
self.config
.cycles
.insert(name.to_string(), PlanningCycle::default());
} else {
return Err(TixError::InvalidInput(format!(
"cycle `{name}` does not exist"
)));
}
}
let cycle = self.config.cycles.get_mut(name).expect("inserted above");
if let Some(description) = description {
let description = description.trim();
cycle.description = if description.is_empty() {
None
} else {
Some(description.to_string())
};
}
if let Some(start_date) = start_date {
let start_date = start_date.trim();
if start_date.is_empty() {
cycle.start_date = None;
} else {
if !crate::model::validate_date(start_date) {
return Err(TixError::InvalidInput(format!(
"start_date `{start_date}` must be a `YYYY-MM-DD` calendar date"
)));
}
cycle.start_date = Some(start_date.to_string());
}
}
if let Some(due_date) = due_date {
let due_date = due_date.trim();
if due_date.is_empty() {
cycle.due_date = None;
} else {
if !crate::model::validate_date(due_date) {
return Err(TixError::InvalidInput(format!(
"due_date `{due_date}` must be a `YYYY-MM-DD` calendar date"
)));
}
cycle.due_date = Some(due_date.to_string());
}
}
self.config.validate()?;
self.persist_cycles()?;
self.save()
}
pub fn delete_cycle(&mut self, name: &str) -> Result<()> {
if self.config.cycles.shift_remove(name).is_none() {
return Err(TixError::InvalidInput(format!(
"cycle `{name}` does not exist"
)));
}
if self.config.planning.default_cycle.as_deref() == Some(name) {
self.config.planning.default_cycle = None;
}
let store = self.cycle_store();
if store.exists() && store.get(name).is_ok() {
store.delete(name)?;
}
self.persist_cycles()?;
self.save()
}
pub fn add_team_member(&mut self, label: &str, member: &str) -> Result<()> {
let label = label.trim();
let member = member.trim();
if label.is_empty() {
return Err(TixError::InvalidInput(
"team label must not be blank".into(),
));
}
if member.is_empty() {
return Err(TixError::InvalidInput("member must not be blank".into()));
}
let team = self.config.teams.entry(label.to_string()).or_default();
if team.members.iter().any(|m| m == member) {
return Err(TixError::InvalidInput(format!(
"`{member}` is already a member of team `{label}`"
)));
}
team.members.push(member.to_string());
self.config.validate()?;
self.save()?;
Ok(())
}
pub fn list_milestones(&self) -> Result<Vec<Milestone>> {
let store = RecordStore::new(self.milestones_dir());
let records = store.list::<MilestoneHead>()?;
Ok(records.into_iter().map(Self::milestone_from).collect())
}
pub fn milestone_names(&self) -> Result<Vec<String>> {
Ok(self
.list_milestones()?
.into_iter()
.map(|m| m.name)
.collect())
}
pub fn milestone(&self, name: &str) -> Result<Milestone> {
let store = RecordStore::new(self.milestones_dir());
match store.get::<MilestoneHead>(name) {
Ok(record) => Ok(Self::milestone_from(record)),
Err(TixError::NotFound(_)) => Err(TixError::NotFound(format!("milestone `{name}`"))),
Err(e) => Err(e),
}
}
pub fn create_milestone_file(
&self,
name: &str,
target_date: Option<&str>,
description: Option<&str>,
) -> Result<PathBuf> {
let name = name.trim().to_string();
if name.is_empty() {
return Err(TixError::InvalidInput(
"milestone name must not be blank".into(),
));
}
if let Some(date) = target_date
&& !crate::model::validate_date(date)
{
return Err(TixError::InvalidInput(format!(
"target_date `{date}` must be a `YYYY-MM-DD` calendar date"
)));
}
let today = crate::model::today();
let file_name = format!("{today}-{}.md", crate::slug::slugify(&name));
let head = MilestoneHead {
name: name.clone(),
target_date: target_date.map(str::to_string),
color: None,
created: today.clone(),
updated: today,
};
RecordStore::new(self.milestones_dir()).write(&file_name, &head, description)
}
pub fn rename_milestone_file(&self, old: &Milestone, new_name: &str) -> Result<PathBuf> {
let new_name = new_name.trim();
if new_name.is_empty() {
return Err(TixError::InvalidInput(
"milestone name must not be blank".into(),
));
}
let prefix = if old.created.is_empty() {
crate::model::today()
} else {
old.created.clone()
};
let new_file_name = format!("{prefix}-{}.md", crate::slug::slugify(new_name));
let head = MilestoneHead {
name: new_name.to_string(),
target_date: (!old.target_date.is_empty()).then(|| old.target_date.clone()),
color: old.color.clone(),
created: prefix,
updated: crate::model::today(),
};
let store = RecordStore::new(self.milestones_dir());
let new_path = store.write(&new_file_name, &head, Some(&old.description))?;
store.delete_file(&old.file_name)?;
Ok(new_path)
}
pub fn milestone_delete_impact(&self, store: &Store, name: &str) -> Result<Impact> {
let mut referencing_issues = Vec::new();
for issue in store.list()? {
if issue
.milestones
.as_ref()
.is_some_and(|milestones| milestones.iter().any(|milestone| milestone == name))
{
referencing_issues.push(issue.filename);
}
}
Ok(Impact { referencing_issues })
}
pub fn rename_milestone_cascade(
&self,
store: &Store,
old: &Milestone,
new_name: &str,
) -> Result<RenameImpact> {
let _ = self.rename_milestone_file(old, new_name)?;
let mut issues = Vec::new();
for mut issue in store.list()? {
let Some(milestones) = issue.milestones.as_mut() else {
continue;
};
let mut changed = false;
for milestone in milestones.iter_mut() {
if milestone == &old.name {
*milestone = new_name.to_string();
changed = true;
}
}
if changed {
issue.bump_updated();
issue.filename = store.update(&issue.filename, &issue)?;
issues.push(issue.filename);
}
}
Ok(RenameImpact {
changed_issue_count: issues.len(),
issues,
})
}
pub fn update_milestone(
&self,
name: &str,
target_date: Option<&str>,
description: Option<&str>,
) -> Result<()> {
let milestone = self.milestone(name)?;
let next_target = match target_date {
Some(value) => {
let value = value.trim();
if value.is_empty() {
None
} else {
if !crate::model::validate_date(value) {
return Err(TixError::InvalidInput(format!(
"target_date `{value}` must be a `YYYY-MM-DD` calendar date"
)));
}
Some(value.to_string())
}
}
None => (!milestone.target_date.is_empty()).then_some(milestone.target_date.clone()),
};
let body = description.unwrap_or(&milestone.description);
let head = MilestoneHead {
name: milestone.name.clone(),
target_date: next_target,
color: milestone.color.clone(),
created: milestone.created.clone(),
updated: crate::model::today(),
};
RecordStore::new(self.milestones_dir()).overwrite(
&milestone.file_name,
&head,
Some(body),
)?;
Ok(())
}
pub fn delete_milestone(&self, name: &str) -> Result<String> {
let milestone = self.milestone(name)?;
RecordStore::new(self.milestones_dir()).delete_file(&milestone.file_name)?;
Ok(milestone.file_name)
}
fn current_auto_name(&self) -> Option<String> {
Some(format!("Cycle {}", self.config.planning.cycle_number))
}
fn milestone_from(record: crate::records::RecordFile<MilestoneHead>) -> Milestone {
let head = record.value;
Milestone {
name: head.name,
target_date: head.target_date.unwrap_or_default(),
description: record.body.trim_end().to_string(),
created: head.created,
updated: head.updated,
file_name: record.filename,
color: head.color,
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn completion_follows_the_category_not_the_done_column() {
use crate::config::{BoardConfiguration, OrderBy};
use indexmap::IndexMap;
let tmp = tempfile::TempDir::new().unwrap();
let issues_dir = tmp.path().join("docs").join("issues");
std::fs::create_dir_all(&issues_dir).unwrap();
let mut config = Config {
issues_dir: issues_dir.clone(),
..Default::default()
};
let mut category_map = IndexMap::new();
category_map.insert("wip".to_string(), "active".to_string());
category_map.insert("shipped".to_string(), "active".to_string());
category_map.insert("archived".to_string(), "completed".to_string());
config.kanban.configurations.insert(
"default".to_string(),
BoardConfiguration {
columns: vec![
"wip".to_string(),
"shipped".to_string(),
"archived".to_string(),
],
done_column: Some("shipped".to_string()),
category_map,
hidden_columns: vec![],
show_mismatch: false,
wrap_titles: true,
title_lines: 3,
card_order: vec![],
order_by: OrderBy::CreatedAtDesc,
wip: crate::wip::WipConfig::default(),
},
);
config
.validate()
.expect("the fixture must be a valid config");
let write = |name: &str, number: u32, status: &str, due: &str| {
std::fs::write(
issues_dir.join(name),
format!(
"---\nnumber: {number}\ntitle: \"{status}\"\nstatus: \"{status}\"\n\
labels: []\npriority: \"medium\"\ndue_date: \"{due}\"\n\
created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n"
),
)
.unwrap();
};
write("2026-01-01-wip.md", 1, "wip", "2026-01-02");
write("2026-01-01-shipped.md", 2, "shipped", "2026-01-02");
write("2026-01-01-archived.md", 3, "archived", "2026-01-02");
let store = crate::storage::Store::open(&config).unwrap();
let planner = Planner::from_config(config, tmp.path().to_path_buf());
let today = "2026-06-01";
let mut overdue: Vec<u32> = planner
.overdue_batch(&store, today)
.iter()
.map(|i| i.number)
.collect();
overdue.sort_unstable();
assert_eq!(
overdue,
vec![1, 2],
"`wip` and `shipped` are both `active`, so both are still overdue; \
`archived` is the only completing column"
);
assert!(
overdue.contains(&2),
"an issue in the `done_column` is not complete when its category is \
`active` — the category is the only completion definition (C-004)"
);
}
use super::*;
use crate::model::today;
use std::fs;
use tempfile::TempDir;
fn planner_in(tmp: &TempDir) -> Planner {
Planner::from_config(Config::default(), tmp.path().to_path_buf())
}
#[test]
fn milestones_dir_resolves_configured_and_default() {
let tmp = TempDir::new().unwrap();
let p = planner_in(&tmp);
assert_eq!(p.milestones_dir(), tmp.path().join("docs/milestones"));
let mut cfg = Config {
milestones_dir: Some(PathBuf::from("tasks/milestones")),
..Config::default()
};
let p = Planner::from_config(cfg.clone(), tmp.path().to_path_buf());
assert_eq!(p.milestones_dir(), tmp.path().join("tasks/milestones"));
cfg.milestones_dir = None;
let p = Planner::from_config(cfg, tmp.path().to_path_buf());
assert_eq!(p.milestones_dir(), tmp.path().join("docs/milestones"));
}
#[test]
fn auto_cycle_names_skip_existing_and_bump_counter() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
assert_eq!(p.next_cycle_name(), "Cycle 1");
assert_eq!(p.config.planning.cycle_number, 2);
assert_eq!(p.add_cycle(None).unwrap(), "Cycle 2");
assert_eq!(p.next_cycle_name(), "Cycle 3");
}
#[test]
fn add_cycle_rejects_duplicates_and_counter_collision() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
p.config
.cycles
.insert("Cycle 1".into(), PlanningCycle::default());
let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
assert!(err.to_string().contains("already exists"));
let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
}
#[test]
fn add_cycle_collision_with_current_counter_is_config_error() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
assert!(matches!(err, TixError::Config(_)), "got {err:?}");
assert!(err.to_string().contains("collides"));
}
#[test]
fn rename_cycle_moves_slot_and_rebases_default() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
p.config
.cycles
.insert("Old".into(), PlanningCycle::default());
p.config.planning.default_cycle = Some("Old".into());
p.rename_cycle("Old", "New").unwrap();
assert!(p.cycle("Old").is_none());
assert!(p.cycle("New").is_some());
assert_eq!(p.config.planning.default_cycle.as_deref(), Some("New"));
let err = p.rename_cycle("Old", "X").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
}
#[test]
fn add_team_member_creates_and_dedupes() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
p.add_team_member("platform", "@alice").unwrap();
assert_eq!(p.team("platform").unwrap().members, ["@alice"]);
let err = p.add_team_member("platform", "@alice").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
assert!(err.to_string().contains("already a member"));
let err = p.add_team_member("", "@alice").unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
}
#[test]
fn milestone_file_writes_contract_format_and_reads_back() {
let tmp = TempDir::new().unwrap();
let p = planner_in(&tmp);
let today = today();
let path = p
.create_milestone_file("Ship V1", Some("2026-12-01"), Some("The launch"))
.unwrap();
assert_eq!(
path.file_name().unwrap(),
format!("{today}-ship-v1.md").as_str()
);
let raw = fs::read_to_string(&path).unwrap();
assert!(raw.starts_with("---\n"));
assert!(raw.contains("target_date: 2026-12-01"));
assert!(raw.contains(&format!("created: {today}")));
assert!(raw.contains(&format!("updated: {today}")));
let ms = p.milestone("Ship V1").unwrap();
assert_eq!(ms.target_date, "2026-12-01");
assert_eq!(ms.description, "The launch");
assert_eq!(ms.created, today);
assert_eq!(ms.file_name, format!("{today}-ship-v1.md"));
let names = p.milestone_names().unwrap();
assert_eq!(names, ["Ship V1"]);
}
#[test]
fn milestone_without_target_date_omits_the_key() {
let tmp = TempDir::new().unwrap();
let p = planner_in(&tmp);
let path = p.create_milestone_file("Backlog", None, None).unwrap();
let raw = fs::read_to_string(&path).unwrap();
assert!(
!raw.contains("target_date"),
"no target_date expected:\n{raw}"
);
assert!(raw.contains("name: Backlog"));
let ms = p.milestone("Backlog").unwrap();
assert_eq!(ms.target_date, "");
assert_eq!(ms.description, "");
}
#[test]
fn milestone_rejects_invalid_dates_and_overwrites() {
let tmp = TempDir::new().unwrap();
let p = planner_in(&tmp);
let err = p
.create_milestone_file("X", Some("2026-13-99"), None)
.unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
assert!(err.to_string().contains("target_date"));
p.create_milestone_file("X", None, None).unwrap();
let err = p.create_milestone_file("X", None, None).unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
assert!(err.to_string().contains("already exists"));
}
#[test]
fn rename_milestone_file_preserves_body_and_bumps_updated() {
let tmp = TempDir::new().unwrap();
let p = planner_in(&tmp);
let today = today();
p.create_milestone_file("Alpha", None, Some("old body text"))
.unwrap();
let old = p.milestone("Alpha").unwrap();
let new_path = p.rename_milestone_file(&old, "Beta").unwrap();
assert_eq!(
new_path.file_name().unwrap(),
format!("{today}-beta.md").as_str()
);
assert!(p.milestone("Alpha").is_err());
let beta = p.milestone("Beta").unwrap();
assert_eq!(beta.created, today);
assert_eq!(beta.updated, today);
assert_eq!(beta.description, "old body text");
let raw = fs::read_to_string(&new_path).unwrap();
assert!(raw.contains("name: Beta"), "renamed frontmatter:\n{raw}");
assert!(
!raw.contains("name: Alpha"),
"old name must be rebased:\n{raw}"
);
assert!(
raw.contains("old body text"),
"body preserved verbatim:\n{raw}"
);
}
#[test]
fn list_milestones_is_empty_before_directory_exists() {
let tmp = TempDir::new().unwrap();
let p = planner_in(&tmp);
assert_eq!(
p.list_milestones().unwrap(),
[] as [crate::planning::Milestone; 0]
);
assert!(p.milestone("nope").is_err());
}
#[test]
fn team_and_cycle_index_maps_preserve_insertion_order() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
p.config.cycles.insert("C".into(), PlanningCycle::default());
p.config.cycles.insert("A".into(), PlanningCycle::default());
assert_eq!(p.cycle_names(), ["C", "A"]);
let final_cfg = Config::load_with_warning(&p.root.join("tix.toml"), |_| {});
assert!(final_cfg.is_err(), "nothing persisted, nothing to load");
}
#[test]
fn cycle_store_add_and_fields_are_lazy_and_file_backed() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
let cycles_dir = tmp.path().join("docs/cycles");
assert!(
!cycles_dir.exists(),
"a defaults-only project needs no cycles directory"
);
assert_eq!(p.cycle_names(), [] as [std::string::String; 0]);
assert!(p.cycle("Default").is_some());
p.add_cycle(Some("Sprint 7".into())).unwrap();
assert!(
cycles_dir.is_dir(),
"adding a second cycle creates the directory"
);
assert!(
!cycles_dir.join("default.md").exists(),
"the implicit Default cycle is never a file"
);
assert!(
p.cycle("Default").is_some(),
"Default stays addressable alongside configured cycles"
);
p.set_cycle_fields(
"Sprint 7",
Some("September sprint"),
Some("2026-09-01"),
Some("2026-09-14"),
)
.unwrap();
let reloaded = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
let cycle = reloaded
.cycles
.get("Sprint 7")
.expect("the cycle is read back from its file");
assert_eq!(cycle.description.as_deref(), Some("September sprint"));
assert_eq!(cycle.start_date.as_deref(), Some("2026-09-01"));
assert_eq!(cycle.due_date.as_deref(), Some("2026-09-14"));
assert!(
reloaded.cycles.contains_key("Default"),
"the implicit Default cycle is available once the store exists"
);
let toml = fs::read_to_string(tmp.path().join("tix.toml")).unwrap();
assert!(
!toml.contains("[cycles"),
"cycle records move out of tix.toml:\n{toml}"
);
assert_eq!(
p.cycle("Sprint 7").unwrap().start_date.as_deref(),
Some("2026-09-01")
);
}
#[test]
fn cycle_rename_cascade_and_delete_impact_use_the_file_store() {
let tmp = TempDir::new().unwrap();
let issues_dir = tmp.path().join("docs").join("issues");
fs::create_dir_all(&issues_dir).unwrap();
let config = Config {
issues_dir: issues_dir.clone(),
..Config::default()
};
let mut p = Planner::from_config(config, tmp.path().to_path_buf());
p.add_cycle(Some("Sprint 7".into())).unwrap();
p.set_cycle_fields("Sprint 7", None, Some("2026-09-01"), None)
.unwrap();
for (filename, number) in [("2026-01-01-a.md", 1), ("2026-01-01-b.md", 2)] {
fs::write(
issues_dir.join(filename),
format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\n\
labels: []\npriority: \"medium\"\ncycles:\n- Sprint 7\n\
created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n"
),
)
.unwrap();
}
let store = crate::storage::Store::open(p.config()).unwrap();
assert_eq!(
p.delete_impact(&store, "Sprint 7")
.unwrap()
.referencing_issues
.len(),
2
);
let impact = p
.rename_cycle_cascade(&store, "Sprint 7", "Sprint Seven")
.unwrap();
assert_eq!(impact.changed_issue_count, 2);
for issue in store.list().unwrap() {
assert_eq!(
issue.cycles.as_deref(),
Some(&["Sprint Seven".to_string()][..])
);
}
let cycles_dir = tmp.path().join("docs/cycles");
assert!(!cycles_dir.join("sprint-7.md").exists(), "old file moved");
assert!(cycles_dir.join("sprint-seven.md").exists());
assert_eq!(
p.delete_impact(&store, "Sprint Seven")
.unwrap()
.referencing_issues
.len(),
2
);
p.delete_cycle("Sprint Seven").unwrap();
assert!(!cycles_dir.join("sprint-seven.md").exists());
assert_eq!(
p.delete_impact(&store, "Sprint Seven")
.unwrap()
.referencing_issues
.len(),
2
);
}
fn cycle_with(start: Option<&str>, due: Option<&str>) -> PlanningCycle {
PlanningCycle {
description: None,
start_date: start.map(str::to_string),
due_date: due.map(str::to_string),
}
}
#[test]
fn running_cycle_is_the_single_window_that_contains_today() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
p.config
.cycles
.insert(DEFAULT_CYCLE_NAME.into(), PlanningCycle::default());
assert_eq!(
p.running_cycle("2026-10-02"),
None,
"a date-less cycle (Default included) is never running"
);
p.config
.cycles
.insert("October 2026".into(), cycle_with(Some("2026-10-01"), None));
assert_eq!(
p.running_cycle("2026-10-02").as_deref(),
Some("October 2026")
);
assert_eq!(p.running_cycle("2026-09-30"), None, "before the start");
assert_eq!(
p.running_cycle("2026-10-01").as_deref(),
Some("October 2026"),
"the start bound is inclusive"
);
p.config.cycles.insert(
"September".into(),
cycle_with(Some("2026-09-01"), Some("2026-09-30")),
);
assert_eq!(p.running_cycle("2026-09-30").as_deref(), Some("September"));
assert_eq!(
p.running_cycle("2026-10-02").as_deref(),
Some("October 2026"),
"September has ended, October still runs"
);
p.config.cycles.insert(
"Q4 2026".into(),
cycle_with(Some("2026-10-01"), Some("2026-12-31")),
);
assert_eq!(
p.running_cycle("2026-10-02"),
None,
"two running cycles are ambiguous"
);
}
#[test]
fn overlapping_cycles_is_the_intersection_of_inclusive_windows() {
let tmp = TempDir::new().unwrap();
let mut p = planner_in(&tmp);
p.config.cycles.insert(
"B".into(),
cycle_with(Some("2026-01-15"), Some("2026-02-15")),
);
p.config.cycles.insert(
"C".into(),
cycle_with(Some("2026-01-10"), Some("2026-01-12")),
);
p.config.cycles.insert(
"D".into(),
cycle_with(Some("2026-03-01"), Some("2026-03-31")),
);
p.config.cycles.insert(
"E".into(),
cycle_with(Some("2026-01-31"), Some("2026-02-28")),
);
p.config
.cycles
.insert("F".into(), cycle_with(None, Some("2026-06-30")));
p.config
.cycles
.insert("Open".into(), cycle_with(Some("2026-01-20"), None));
p.config.cycles.insert(
"A".into(),
cycle_with(Some("2026-01-01"), Some("2026-01-31")),
);
assert_eq!(
p.overlapping_cycles("A"),
["B", "C", "E", "Open"],
"partial overlap, containment, touching endpoints and an open-ended window"
);
assert_eq!(p.overlapping_cycles("F"), [] as [std::string::String; 0]);
assert_eq!(
p.overlapping_cycles("missing"),
[] as [std::string::String; 0]
);
p.config.cycles.clear();
p.config
.cycles
.insert("Open".into(), cycle_with(Some("2026-02-01"), None));
p.config.cycles.insert(
"EndedBefore".into(),
cycle_with(Some("2026-01-01"), Some("2026-01-15")),
);
p.config.cycles.insert(
"StartsLater".into(),
cycle_with(Some("2026-05-01"), Some("2026-05-31")),
);
assert_eq!(
p.overlapping_cycles("Open"),
["StartsLater"],
"an open-ended window meets a later one but not one that already ended"
);
}
}