use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::{Config, PlanningCycle, Team};
use crate::error::{Result, TixError};
use crate::model::Issue;
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,
}
#[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>,
created: String,
updated: String,
}
#[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 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> {
self.config.cycles.get(name)
}
pub fn cycle_names(&self) -> Vec<String> {
self.config.cycles.keys().cloned().collect()
}
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 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> {
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 = store
.list()
.unwrap_or_default()
.into_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 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.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 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 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);
}
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();
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<()> {
let cycle = self
.config
.cycles
.get_mut(name)
.ok_or_else(|| TixError::InvalidInput(format!("cycle `{name}` does not exist")))?;
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.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;
}
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 dir = self.milestones_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let mut milestones = Vec::new();
let mut read_error = None;
for entry in std::fs::read_dir(&dir)? {
let Ok(entry) = entry else { continue };
if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str().map(str::to_string) else {
continue;
};
match std::fs::read_to_string(entry.path()) {
Ok(contents) => {
if let Some((head, body)) = Self::parse_milestone_file(&contents) {
milestones.push(Self::milestone_from(head, file_name, body));
}
}
Err(e) => read_error = Some(e),
}
}
if let Some(e) = read_error {
return Err(TixError::Io(e));
}
milestones.sort_by(|a, b| a.file_name.cmp(&b.file_name));
Ok(milestones)
}
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> {
for entry in std::fs::read_dir(self.milestones_dir())? {
let Ok(entry) = entry else { continue };
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str().map(str::to_string) else {
continue;
};
let Ok(contents) = std::fs::read_to_string(entry.path()) else {
continue;
};
if let Some((head, body)) = Self::parse_milestone_file(&contents)
&& head.name == name
{
return Ok(Self::milestone_from(head, file_name, body));
}
}
Err(TixError::NotFound(format!("milestone `{name}`")))
}
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 dir = self.milestones_dir();
let file_name = format!("{today}-{}.md", crate::slug::slugify(&name));
let path = dir.join(file_name);
if path.exists() {
return Err(TixError::InvalidInput(format!(
"`{}` already exists",
path.display()
)));
}
std::fs::create_dir_all(&dir)?;
let head = MilestoneHead {
name: name.clone(),
target_date: target_date.map(str::to_string),
created: today.clone(),
updated: today,
};
let frontmatter = serde_yaml::to_string(&head)
.map_err(|e| TixError::Parse(format!("cannot serialize milestone frontmatter: {e}")))?;
let body = body_for(description);
std::fs::write(&path, format!("---\n{frontmatter}---\n\n{body}"))?;
Ok(path)
}
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 dir = self.milestones_dir();
let old_path = dir.join(&old.file_name);
let contents = std::fs::read_to_string(&old_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
TixError::NotFound(format!("milestone file `{}`", old.file_name))
} else {
TixError::Io(e)
}
})?;
let Some((_fm, body)) = Self::split_frontmatter(&contents) else {
return Err(TixError::Parse(format!("cannot parse `{}`", old.file_name)));
};
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 new_path = dir.join(new_file_name);
if new_path.exists() {
return Err(TixError::InvalidInput(format!(
"`{}` already exists",
new_path.display()
)));
}
let head = MilestoneHead {
name: new_name.to_string(),
target_date: (!old.target_date.is_empty()).then(|| old.target_date.clone()),
created: prefix,
updated: crate::model::today(),
};
let frontmatter = serde_yaml::to_string(&head)
.map_err(|e| TixError::Parse(format!("cannot serialize milestone frontmatter: {e}")))?;
std::fs::write(&new_path, format!("---\n{frontmatter}---\n\n{body}"))?;
std::fs::remove_file(&old_path)?;
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();
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,
created: milestone.created.clone(),
updated: crate::model::today(),
};
let frontmatter = serde_yaml::to_string(&head)
.map_err(|e| TixError::Parse(format!("cannot serialize milestone frontmatter: {e}")))?;
let path = self.milestones_dir().join(&milestone.file_name);
std::fs::write(
path,
format!("---\n{frontmatter}---\n\n{}", body_for(Some(body))),
)?;
Ok(())
}
pub fn delete_milestone(&self, name: &str) -> Result<String> {
let milestone = self.milestone(name)?;
let path = self.milestones_dir().join(&milestone.file_name);
std::fs::remove_file(path)?;
Ok(milestone.file_name)
}
fn current_auto_name(&self) -> Option<String> {
Some(format!("Cycle {}", self.config.planning.cycle_number))
}
fn milestone_from(head: MilestoneHead, file_name: String, body: &str) -> Milestone {
Milestone {
name: head.name,
target_date: head.target_date.unwrap_or_default(),
description: body.trim_end().to_string(),
created: head.created,
updated: head.updated,
file_name,
}
}
fn split_frontmatter(contents: &str) -> Option<(&str, &str)> {
let rest = contents.strip_prefix("---\n")?;
let mut current = rest;
let mut fm_len = 0usize;
loop {
let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
let content = current[..line_end]
.strip_suffix('\n')
.unwrap_or(¤t[..line_end]);
if content == "---" {
break;
}
fm_len += line_end;
current = ¤t[line_end..];
if current.is_empty() {
return None;
}
}
let mut body = &rest[fm_len..];
body = body.strip_prefix("---\n").unwrap_or(body);
if let Some(after) = body.strip_prefix('\n') {
body = after;
}
Some((&rest[..fm_len], body))
}
fn parse_milestone_file(contents: &str) -> Option<(MilestoneHead, &str)> {
let (frontmatter, body) = Self::split_frontmatter(contents)?;
let head: MilestoneHead = serde_yaml::from_str(frontmatter).ok()?;
Some((head, body))
}
}
fn body_for(description: Option<&str>) -> String {
match description {
Some(text) if !text.trim().is_empty() => format!("{}\n", text.trim_end()),
_ => String::new(),
}
}
#[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,
},
);
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!(p.list_milestones().unwrap().is_empty());
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");
}
}