1use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::config::{Config, PlanningCycle, Team};
18use crate::error::{Result, TixError};
19use crate::model::Issue;
20use crate::storage::Store;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RolloverTarget {
24 pub from_cycle: String,
25 pub to_cycle: String,
26 pub issues: Vec<u32>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Progress {
31 pub done: usize,
32 pub total: usize,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RenameImpact {
37 pub changed_issue_count: usize,
38 pub issues: Vec<String>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Impact {
43 pub referencing_issues: Vec<String>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Milestone {
53 pub name: String,
54 pub target_date: String,
55 pub description: String,
56 pub created: String,
57 pub updated: String,
58 pub file_name: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
63#[serde(default, deny_unknown_fields)]
64struct MilestoneHead {
65 name: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 target_date: Option<String>,
68 created: String,
69 updated: String,
70}
71
72#[derive(Debug, Clone)]
74pub struct Planner {
75 root: PathBuf,
76 config: Config,
77}
78
79impl Planner {
80 pub fn load() -> Result<Self> {
83 Self::load_from(Path::new("tix.toml"))
84 }
85
86 pub fn load_from(path: &Path) -> Result<Self> {
88 let config = Config::load_with_warning(path, |token| {
89 eprintln!(
90 "warning: {} embeds a cleartext {} token; prefer an environment variable",
91 path.display(),
92 token
93 );
94 })?;
95 let root = path
96 .parent()
97 .filter(|p| !p.as_os_str().is_empty())
98 .map(Path::to_path_buf)
99 .unwrap_or_else(|| PathBuf::from("."));
100 Ok(Self::from_config(config, root))
101 }
102
103 pub fn from_config(config: Config, root: PathBuf) -> Self {
106 Self { root, config }
107 }
108
109 pub fn config(&self) -> &Config {
111 &self.config
112 }
113
114 pub fn category_resolution(&self) -> crate::workflow::CategoryResolution {
122 let board = self.config.default_config();
123 crate::workflow::resolution(&self.config, board, "default")
124 }
125
126 fn is_done_issue(&self, issue: &Issue) -> bool {
136 let resolution = self.category_resolution();
137 crate::workflow::is_completed(issue, &resolution)
138 }
139
140 pub fn milestones_dir(&self) -> PathBuf {
142 self.root.join(self.config.milestones_dir())
143 }
144
145 fn save(&self) -> Result<()> {
147 self.config.save(&self.root.join("tix.toml"))
148 }
149
150 pub fn team(&self, label: &str) -> Option<&Team> {
152 self.config.teams.get(label)
153 }
154
155 pub fn cycle(&self, name: &str) -> Option<&PlanningCycle> {
157 self.config.cycles.get(name)
158 }
159
160 pub fn cycle_names(&self) -> Vec<String> {
162 self.config.cycles.keys().cloned().collect()
163 }
164
165 pub fn is_overdue(issue: &Issue, today: &str) -> bool {
166 issue.due_date.as_deref().is_some_and(|due| due < today)
167 }
168
169 pub fn cycle_expired(cycle: &PlanningCycle, today: &str) -> bool {
170 cycle.due_date.as_deref().is_some_and(|due| due < today)
171 }
172
173 pub fn successor<'a>(
174 &'a self,
175 cycle: &str,
176 today: &str,
177 ) -> Option<(&'a str, &'a PlanningCycle)> {
178 self.config
179 .cycles
180 .iter()
181 .filter(|(name, c)| {
182 *name != cycle && c.start_date.as_deref().is_some_and(|s| s >= today)
183 })
184 .min_by(|a, b| {
185 let by_start = a.1.start_date.cmp(&b.1.start_date);
186 if by_start == std::cmp::Ordering::Equal {
187 a.0.cmp(b.0)
188 } else {
189 by_start
190 }
191 })
192 .map(|(name, c)| (name.as_str(), c))
193 }
194
195 pub fn overdue_batch(&self, store: &Store, today: &str) -> Vec<Issue> {
196 store
197 .list()
198 .unwrap_or_default()
199 .into_iter()
200 .filter(|issue| !self.is_done_issue(issue) && Self::is_overdue(issue, today))
201 .collect()
202 }
203
204 pub fn rollover_plan(&self, store: &Store, today: &str) -> Vec<RolloverTarget> {
205 let mut out = Vec::new();
206 for (from_cycle, cycle) in &self.config.cycles {
207 if !Self::cycle_expired(cycle, today) {
208 continue;
209 }
210 let Some((to_cycle, _successor)) = self.successor(from_cycle, today) else {
211 continue;
212 };
213 let issues = store
214 .list()
215 .unwrap_or_default()
216 .into_iter()
217 .filter(|issue| {
218 issue
219 .cycles
220 .as_ref()
221 .is_some_and(|cycles| cycles.iter().any(|c| c == from_cycle))
222 && !self.is_done_issue(issue)
223 && !issue
224 .cycles
225 .as_ref()
226 .is_some_and(|cycles| cycles.iter().any(|c| c == to_cycle))
227 })
228 .map(|issue| issue.number)
229 .collect::<Vec<_>>();
230 if !issues.is_empty() {
231 out.push(RolloverTarget {
232 from_cycle: from_cycle.clone(),
233 to_cycle: to_cycle.to_string(),
234 issues,
235 });
236 }
237 }
238 out
239 }
240
241 pub fn cycle_progress(&self, store: &Store, name: &str) -> Progress {
242 let mut done = 0usize;
243 let mut total = 0usize;
244 for issue in store.list().unwrap_or_default() {
245 if issue
246 .cycles
247 .as_ref()
248 .is_some_and(|cycles| cycles.iter().any(|cycle| cycle == name))
249 {
250 total += 1;
251 if self.is_done_issue(&issue) {
252 done += 1;
253 }
254 }
255 }
256 Progress { done, total }
257 }
258
259 pub fn milestone_progress(&self, store: &Store, name: &str) -> Progress {
260 let mut done = 0usize;
261 let mut total = 0usize;
262 for issue in store.list().unwrap_or_default() {
263 if issue
264 .milestones
265 .as_ref()
266 .is_some_and(|milestones| milestones.iter().any(|milestone| milestone == name))
267 {
268 total += 1;
269 if self.is_done_issue(&issue) {
270 done += 1;
271 }
272 }
273 }
274 Progress { done, total }
275 }
276
277 pub fn next_cycle_name(&mut self) -> String {
282 let mut n = self.config.planning.cycle_number;
283 loop {
284 let candidate = format!("Cycle {n}");
285 if !self.config.cycles.contains_key(&candidate) {
286 self.config.planning.cycle_number = n + 1;
287 return candidate;
288 }
289 n += 1;
290 }
291 }
292
293 pub fn add_cycle(&mut self, name: Option<String>) -> Result<String> {
297 let name = match name {
298 Some(name) => name,
299 None => self.next_cycle_name(),
300 };
301 let name = name.trim().to_string();
302 if name.is_empty() {
303 return Err(TixError::InvalidInput(
304 "cycle name must not be blank".into(),
305 ));
306 }
307 if self.config.cycles.contains_key(&name) {
308 return Err(TixError::InvalidInput(format!(
309 "cycle `{name}` already exists"
310 )));
311 }
312 if let Some(auto) = self.current_auto_name()
313 && name == auto
314 {
315 return Err(TixError::Config(format!(
316 "cycle `{name}` collides with the auto-cycle counter; pick another name or let it auto-increment"
317 )));
318 }
319 self.config
320 .cycles
321 .insert(name.clone(), PlanningCycle::default());
322 self.save()?;
323 Ok(name)
324 }
325
326 pub fn rename_cycle(&mut self, old: &str, new: &str) -> Result<()> {
329 let new = new.trim().to_string();
330 if old == new {
331 return Ok(());
332 }
333 if !self.config.cycles.contains_key(old) {
334 return Err(TixError::InvalidInput(format!(
335 "cycle `{old}` does not exist"
336 )));
337 }
338 if new.is_empty() {
339 return Err(TixError::InvalidInput(
340 "cycle name must not be blank".into(),
341 ));
342 }
343 if self.config.cycles.contains_key(&new) {
344 return Err(TixError::InvalidInput(format!(
345 "cycle `{new}` already exists"
346 )));
347 }
348 if let Some(auto) = self.current_auto_name()
349 && new == auto
350 {
351 return Err(TixError::Config(format!(
352 "cycle `{new}` collides with the auto-cycle counter"
353 )));
354 }
355 let cycle = self
356 .config
357 .cycles
358 .shift_remove(old)
359 .expect("existence checked above");
360 self.config.cycles.insert(new.clone(), cycle);
361 if self.config.planning.default_cycle.as_deref() == Some(old) {
362 self.config.planning.default_cycle = Some(new);
363 }
364 self.save()?;
365 Ok(())
366 }
367
368 pub fn delete_impact(&self, store: &Store, name: &str) -> Result<Impact> {
369 let mut referencing_issues = Vec::new();
370 for issue in store.list()? {
371 if issue
372 .cycles
373 .as_ref()
374 .is_some_and(|cycles| cycles.iter().any(|cycle| cycle == name))
375 {
376 referencing_issues.push(issue.filename);
377 }
378 }
379 Ok(Impact { referencing_issues })
380 }
381
382 pub fn rename_cycle_cascade(
383 &mut self,
384 store: &Store,
385 old: &str,
386 new: &str,
387 ) -> Result<RenameImpact> {
388 let new = new.trim();
389 if new.is_empty() {
390 return Err(TixError::InvalidInput(
391 "cycle name must not be blank".into(),
392 ));
393 }
394 if !self.config.cycles.contains_key(old) {
395 return Err(TixError::InvalidInput(format!(
396 "cycle `{old}` does not exist"
397 )));
398 }
399 if old != new && self.config.cycles.contains_key(new) {
400 return Err(TixError::InvalidInput(format!(
401 "cycle `{new}` already exists"
402 )));
403 }
404 let mut issues = Vec::new();
405 for mut issue in store.list()? {
406 let Some(cycles) = issue.cycles.as_mut() else {
407 continue;
408 };
409 let mut changed = false;
410 for cycle in cycles.iter_mut() {
411 if cycle == old {
412 *cycle = new.to_string();
413 changed = true;
414 }
415 }
416 if changed {
417 issue.bump_updated();
418 store.update(&issue.filename, &issue)?;
419 issues.push(issue.filename);
420 }
421 }
422 self.rename_cycle(old, new)?;
423 Ok(RenameImpact {
424 changed_issue_count: issues.len(),
425 issues,
426 })
427 }
428
429 pub fn set_cycle_fields(
430 &mut self,
431 name: &str,
432 description: Option<&str>,
433 start_date: Option<&str>,
434 due_date: Option<&str>,
435 ) -> Result<()> {
436 let cycle = self
437 .config
438 .cycles
439 .get_mut(name)
440 .ok_or_else(|| TixError::InvalidInput(format!("cycle `{name}` does not exist")))?;
441
442 if let Some(description) = description {
443 let description = description.trim();
444 cycle.description = if description.is_empty() {
445 None
446 } else {
447 Some(description.to_string())
448 };
449 }
450 if let Some(start_date) = start_date {
451 let start_date = start_date.trim();
452 if start_date.is_empty() {
453 cycle.start_date = None;
454 } else {
455 if !crate::model::validate_date(start_date) {
456 return Err(TixError::InvalidInput(format!(
457 "start_date `{start_date}` must be a `YYYY-MM-DD` calendar date"
458 )));
459 }
460 cycle.start_date = Some(start_date.to_string());
461 }
462 }
463 if let Some(due_date) = due_date {
464 let due_date = due_date.trim();
465 if due_date.is_empty() {
466 cycle.due_date = None;
467 } else {
468 if !crate::model::validate_date(due_date) {
469 return Err(TixError::InvalidInput(format!(
470 "due_date `{due_date}` must be a `YYYY-MM-DD` calendar date"
471 )));
472 }
473 cycle.due_date = Some(due_date.to_string());
474 }
475 }
476 self.config.validate()?;
477 self.save()
478 }
479
480 pub fn delete_cycle(&mut self, name: &str) -> Result<()> {
481 if self.config.cycles.shift_remove(name).is_none() {
482 return Err(TixError::InvalidInput(format!(
483 "cycle `{name}` does not exist"
484 )));
485 }
486 if self.config.planning.default_cycle.as_deref() == Some(name) {
487 self.config.planning.default_cycle = None;
488 }
489 self.save()
490 }
491
492 pub fn add_team_member(&mut self, label: &str, member: &str) -> Result<()> {
495 let label = label.trim();
496 let member = member.trim();
497 if label.is_empty() {
498 return Err(TixError::InvalidInput(
499 "team label must not be blank".into(),
500 ));
501 }
502 if member.is_empty() {
503 return Err(TixError::InvalidInput("member must not be blank".into()));
504 }
505 let team = self.config.teams.entry(label.to_string()).or_default();
506 if team.members.iter().any(|m| m == member) {
507 return Err(TixError::InvalidInput(format!(
508 "`{member}` is already a member of team `{label}`"
509 )));
510 }
511 team.members.push(member.to_string());
512 self.config.validate()?;
513 self.save()?;
514 Ok(())
515 }
516
517 pub fn list_milestones(&self) -> Result<Vec<Milestone>> {
520 let dir = self.milestones_dir();
521 if !dir.exists() {
522 return Ok(Vec::new());
523 }
524 let mut milestones = Vec::new();
525 let mut read_error = None;
526 for entry in std::fs::read_dir(&dir)? {
527 let Ok(entry) = entry else { continue };
528 if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
529 continue;
530 }
531 let file_name = entry.file_name();
532 let Some(file_name) = file_name.to_str().map(str::to_string) else {
533 continue;
534 };
535 match std::fs::read_to_string(entry.path()) {
536 Ok(contents) => {
537 if let Some((head, body)) = Self::parse_milestone_file(&contents) {
538 milestones.push(Self::milestone_from(head, file_name, body));
539 }
540 }
541 Err(e) => read_error = Some(e),
542 }
543 }
544 if let Some(e) = read_error {
545 return Err(TixError::Io(e));
546 }
547 milestones.sort_by(|a, b| a.file_name.cmp(&b.file_name));
548 Ok(milestones)
549 }
550
551 pub fn milestone_names(&self) -> Result<Vec<String>> {
553 Ok(self
554 .list_milestones()?
555 .into_iter()
556 .map(|m| m.name)
557 .collect())
558 }
559
560 pub fn milestone(&self, name: &str) -> Result<Milestone> {
562 for entry in std::fs::read_dir(self.milestones_dir())? {
563 let Ok(entry) = entry else { continue };
564 let file_name = entry.file_name();
565 let Some(file_name) = file_name.to_str().map(str::to_string) else {
566 continue;
567 };
568 let Ok(contents) = std::fs::read_to_string(entry.path()) else {
569 continue;
570 };
571 if let Some((head, body)) = Self::parse_milestone_file(&contents)
572 && head.name == name
573 {
574 return Ok(Self::milestone_from(head, file_name, body));
575 }
576 }
577 Err(TixError::NotFound(format!("milestone `{name}`")))
578 }
579
580 pub fn create_milestone_file(
586 &self,
587 name: &str,
588 target_date: Option<&str>,
589 description: Option<&str>,
590 ) -> Result<PathBuf> {
591 let name = name.trim().to_string();
592 if name.is_empty() {
593 return Err(TixError::InvalidInput(
594 "milestone name must not be blank".into(),
595 ));
596 }
597 if let Some(date) = target_date
598 && !crate::model::validate_date(date)
599 {
600 return Err(TixError::InvalidInput(format!(
601 "target_date `{date}` must be a `YYYY-MM-DD` calendar date"
602 )));
603 }
604 let today = crate::model::today();
605 let dir = self.milestones_dir();
606 let file_name = format!("{today}-{}.md", crate::slug::slugify(&name));
607 let path = dir.join(file_name);
608 if path.exists() {
609 return Err(TixError::InvalidInput(format!(
610 "`{}` already exists",
611 path.display()
612 )));
613 }
614 std::fs::create_dir_all(&dir)?;
615 let head = MilestoneHead {
616 name: name.clone(),
617 target_date: target_date.map(str::to_string),
618 created: today.clone(),
619 updated: today,
620 };
621 let frontmatter = serde_yaml::to_string(&head)
622 .map_err(|e| TixError::Parse(format!("cannot serialize milestone frontmatter: {e}")))?;
623 let body = body_for(description);
624 std::fs::write(&path, format!("---\n{frontmatter}---\n\n{body}"))?;
625 Ok(path)
626 }
627
628 pub fn rename_milestone_file(&self, old: &Milestone, new_name: &str) -> Result<PathBuf> {
633 let new_name = new_name.trim();
634 if new_name.is_empty() {
635 return Err(TixError::InvalidInput(
636 "milestone name must not be blank".into(),
637 ));
638 }
639 let dir = self.milestones_dir();
640 let old_path = dir.join(&old.file_name);
641 let contents = std::fs::read_to_string(&old_path).map_err(|e| {
642 if e.kind() == std::io::ErrorKind::NotFound {
643 TixError::NotFound(format!("milestone file `{}`", old.file_name))
644 } else {
645 TixError::Io(e)
646 }
647 })?;
648 let Some((_fm, body)) = Self::split_frontmatter(&contents) else {
649 return Err(TixError::Parse(format!("cannot parse `{}`", old.file_name)));
650 };
651 let prefix = if old.created.is_empty() {
652 crate::model::today()
653 } else {
654 old.created.clone()
655 };
656 let new_file_name = format!("{prefix}-{}.md", crate::slug::slugify(new_name));
657 let new_path = dir.join(new_file_name);
658 if new_path.exists() {
659 return Err(TixError::InvalidInput(format!(
660 "`{}` already exists",
661 new_path.display()
662 )));
663 }
664 let head = MilestoneHead {
665 name: new_name.to_string(),
666 target_date: (!old.target_date.is_empty()).then(|| old.target_date.clone()),
667 created: prefix,
668 updated: crate::model::today(),
669 };
670 let frontmatter = serde_yaml::to_string(&head)
671 .map_err(|e| TixError::Parse(format!("cannot serialize milestone frontmatter: {e}")))?;
672 std::fs::write(&new_path, format!("---\n{frontmatter}---\n\n{body}"))?;
673 std::fs::remove_file(&old_path)?;
674 Ok(new_path)
675 }
676
677 pub fn milestone_delete_impact(&self, store: &Store, name: &str) -> Result<Impact> {
678 let mut referencing_issues = Vec::new();
679 for issue in store.list()? {
680 if issue
681 .milestones
682 .as_ref()
683 .is_some_and(|milestones| milestones.iter().any(|milestone| milestone == name))
684 {
685 referencing_issues.push(issue.filename);
686 }
687 }
688 Ok(Impact { referencing_issues })
689 }
690
691 pub fn rename_milestone_cascade(
692 &self,
693 store: &Store,
694 old: &Milestone,
695 new_name: &str,
696 ) -> Result<RenameImpact> {
697 let _ = self.rename_milestone_file(old, new_name)?;
698 let mut issues = Vec::new();
699 for mut issue in store.list()? {
700 let Some(milestones) = issue.milestones.as_mut() else {
701 continue;
702 };
703 let mut changed = false;
704 for milestone in milestones.iter_mut() {
705 if milestone == &old.name {
706 *milestone = new_name.to_string();
707 changed = true;
708 }
709 }
710 if changed {
711 issue.bump_updated();
712 store.update(&issue.filename, &issue)?;
713 issues.push(issue.filename);
714 }
715 }
716 Ok(RenameImpact {
717 changed_issue_count: issues.len(),
718 issues,
719 })
720 }
721
722 pub fn update_milestone(
723 &self,
724 name: &str,
725 target_date: Option<&str>,
726 description: Option<&str>,
727 ) -> Result<()> {
728 let milestone = self.milestone(name)?;
729 let next_target = match target_date {
730 Some(value) => {
731 let value = value.trim();
732 if value.is_empty() {
733 None
734 } else {
735 if !crate::model::validate_date(value) {
736 return Err(TixError::InvalidInput(format!(
737 "target_date `{value}` must be a `YYYY-MM-DD` calendar date"
738 )));
739 }
740 Some(value.to_string())
741 }
742 }
743 None => (!milestone.target_date.is_empty()).then_some(milestone.target_date.clone()),
744 };
745 let body = description.unwrap_or(&milestone.description);
746 let head = MilestoneHead {
747 name: milestone.name.clone(),
748 target_date: next_target,
749 created: milestone.created.clone(),
750 updated: crate::model::today(),
751 };
752 let frontmatter = serde_yaml::to_string(&head)
753 .map_err(|e| TixError::Parse(format!("cannot serialize milestone frontmatter: {e}")))?;
754 let path = self.milestones_dir().join(&milestone.file_name);
755 std::fs::write(
756 path,
757 format!("---\n{frontmatter}---\n\n{}", body_for(Some(body))),
758 )?;
759 Ok(())
760 }
761
762 pub fn delete_milestone(&self, name: &str) -> Result<String> {
763 let milestone = self.milestone(name)?;
764 let path = self.milestones_dir().join(&milestone.file_name);
765 std::fs::remove_file(path)?;
766 Ok(milestone.file_name)
767 }
768
769 fn current_auto_name(&self) -> Option<String> {
771 Some(format!("Cycle {}", self.config.planning.cycle_number))
772 }
773
774 fn milestone_from(head: MilestoneHead, file_name: String, body: &str) -> Milestone {
775 Milestone {
776 name: head.name,
777 target_date: head.target_date.unwrap_or_default(),
778 description: body.trim_end().to_string(),
779 created: head.created,
780 updated: head.updated,
781 file_name,
782 }
783 }
784
785 fn split_frontmatter(contents: &str) -> Option<(&str, &str)> {
790 let rest = contents.strip_prefix("---\n")?;
791 let mut current = rest;
792 let mut fm_len = 0usize;
793 loop {
794 let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
795 let content = current[..line_end]
796 .strip_suffix('\n')
797 .unwrap_or(¤t[..line_end]);
798 if content == "---" {
799 break;
800 }
801 fm_len += line_end;
802 current = ¤t[line_end..];
803 if current.is_empty() {
804 return None;
805 }
806 }
807 let mut body = &rest[fm_len..];
808 body = body.strip_prefix("---\n").unwrap_or(body);
809 if let Some(after) = body.strip_prefix('\n') {
810 body = after;
811 }
812 Some((&rest[..fm_len], body))
813 }
814
815 fn parse_milestone_file(contents: &str) -> Option<(MilestoneHead, &str)> {
818 let (frontmatter, body) = Self::split_frontmatter(contents)?;
819 let head: MilestoneHead = serde_yaml::from_str(frontmatter).ok()?;
820 Some((head, body))
821 }
822}
823
824fn body_for(description: Option<&str>) -> String {
827 match description {
828 Some(text) if !text.trim().is_empty() => format!("{}\n", text.trim_end()),
829 _ => String::new(),
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 #[test]
851 fn completion_follows_the_category_not_the_done_column() {
852 use crate::config::{BoardConfiguration, OrderBy};
853 use indexmap::IndexMap;
854 let tmp = tempfile::TempDir::new().unwrap();
855 let issues_dir = tmp.path().join("docs").join("issues");
856 std::fs::create_dir_all(&issues_dir).unwrap();
857
858 let mut config = Config {
862 issues_dir: issues_dir.clone(),
863 ..Default::default()
864 };
865 let mut category_map = IndexMap::new();
866 category_map.insert("wip".to_string(), "active".to_string());
867 category_map.insert("shipped".to_string(), "active".to_string());
868 category_map.insert("archived".to_string(), "completed".to_string());
869 config.kanban.configurations.insert(
870 "default".to_string(),
871 BoardConfiguration {
872 columns: vec![
873 "wip".to_string(),
874 "shipped".to_string(),
875 "archived".to_string(),
876 ],
877 done_column: Some("shipped".to_string()),
878 category_map,
879 hidden_columns: vec![],
880 show_mismatch: false,
881 wrap_titles: true,
882 title_lines: 3,
883 card_order: vec![],
884 order_by: OrderBy::CreatedAtDesc,
885 },
886 );
887 config
889 .validate()
890 .expect("the fixture must be a valid config");
891
892 let write = |name: &str, number: u32, status: &str, due: &str| {
893 std::fs::write(
894 issues_dir.join(name),
895 format!(
896 "---\nnumber: {number}\ntitle: \"{status}\"\nstatus: \"{status}\"\n\
897 labels: []\npriority: \"medium\"\ndue_date: \"{due}\"\n\
898 created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n"
899 ),
900 )
901 .unwrap();
902 };
903 write("2026-01-01-wip.md", 1, "wip", "2026-01-02");
904 write("2026-01-01-shipped.md", 2, "shipped", "2026-01-02");
905 write("2026-01-01-archived.md", 3, "archived", "2026-01-02");
906
907 let store = crate::storage::Store::open(&config).unwrap();
908 let planner = Planner::from_config(config, tmp.path().to_path_buf());
909 let today = "2026-06-01";
910 let mut overdue: Vec<u32> = planner
911 .overdue_batch(&store, today)
912 .iter()
913 .map(|i| i.number)
914 .collect();
915 overdue.sort_unstable();
920
921 assert_eq!(
922 overdue,
923 vec![1, 2],
924 "`wip` and `shipped` are both `active`, so both are still overdue; \
925 `archived` is the only completing column"
926 );
927 assert!(
930 overdue.contains(&2),
931 "an issue in the `done_column` is not complete when its category is \
932 `active` — the category is the only completion definition (C-004)"
933 );
934 }
935
936 use super::*;
937 use crate::model::today;
938 use std::fs;
939 use tempfile::TempDir;
940
941 fn planner_in(tmp: &TempDir) -> Planner {
942 Planner::from_config(Config::default(), tmp.path().to_path_buf())
943 }
944
945 #[test]
946 fn milestones_dir_resolves_configured_and_default() {
947 let tmp = TempDir::new().unwrap();
948 let p = planner_in(&tmp);
949 assert_eq!(p.milestones_dir(), tmp.path().join("docs/milestones"));
950
951 let mut cfg = Config {
952 milestones_dir: Some(PathBuf::from("tasks/milestones")),
953 ..Config::default()
954 };
955 let p = Planner::from_config(cfg.clone(), tmp.path().to_path_buf());
956 assert_eq!(p.milestones_dir(), tmp.path().join("tasks/milestones"));
957 cfg.milestones_dir = None;
958 let p = Planner::from_config(cfg, tmp.path().to_path_buf());
959 assert_eq!(p.milestones_dir(), tmp.path().join("docs/milestones"));
960 }
961
962 #[test]
963 fn auto_cycle_names_skip_existing_and_bump_counter() {
964 let tmp = TempDir::new().unwrap();
965 let mut p = planner_in(&tmp);
966 assert_eq!(p.next_cycle_name(), "Cycle 1");
967 assert_eq!(p.config.planning.cycle_number, 2);
968 assert_eq!(p.add_cycle(None).unwrap(), "Cycle 2");
969 assert_eq!(p.next_cycle_name(), "Cycle 3");
970 }
971
972 #[test]
973 fn add_cycle_rejects_duplicates_and_counter_collision() {
974 let tmp = TempDir::new().unwrap();
975 let mut p = planner_in(&tmp);
976 p.config
977 .cycles
978 .insert("Cycle 1".into(), PlanningCycle::default());
979 let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
980 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
981 assert!(err.to_string().contains("already exists"));
982 let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
984 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
985 }
986
987 #[test]
988 fn add_cycle_collision_with_current_counter_is_config_error() {
989 let tmp = TempDir::new().unwrap();
990 let mut p = planner_in(&tmp);
991 let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
992 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
993 assert!(err.to_string().contains("collides"));
994 }
995
996 #[test]
997 fn rename_cycle_moves_slot_and_rebases_default() {
998 let tmp = TempDir::new().unwrap();
999 let mut p = planner_in(&tmp);
1000 p.config
1001 .cycles
1002 .insert("Old".into(), PlanningCycle::default());
1003 p.config.planning.default_cycle = Some("Old".into());
1004 p.rename_cycle("Old", "New").unwrap();
1005 assert!(p.cycle("Old").is_none());
1006 assert!(p.cycle("New").is_some());
1007 assert_eq!(p.config.planning.default_cycle.as_deref(), Some("New"));
1008 let err = p.rename_cycle("Old", "X").unwrap_err();
1009 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1010 }
1011
1012 #[test]
1013 fn add_team_member_creates_and_dedupes() {
1014 let tmp = TempDir::new().unwrap();
1015 let mut p = planner_in(&tmp);
1016 p.add_team_member("platform", "@alice").unwrap();
1017 assert_eq!(p.team("platform").unwrap().members, ["@alice"]);
1018 let err = p.add_team_member("platform", "@alice").unwrap_err();
1019 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1020 assert!(err.to_string().contains("already a member"));
1021 let err = p.add_team_member("", "@alice").unwrap_err();
1022 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1023 }
1024
1025 #[test]
1026 fn milestone_file_writes_contract_format_and_reads_back() {
1027 let tmp = TempDir::new().unwrap();
1028 let p = planner_in(&tmp);
1029 let today = today();
1030 let path = p
1031 .create_milestone_file("Ship V1", Some("2026-12-01"), Some("The launch"))
1032 .unwrap();
1033 assert_eq!(
1035 path.file_name().unwrap(),
1036 format!("{today}-ship-v1.md").as_str()
1037 );
1038 let raw = fs::read_to_string(&path).unwrap();
1039 assert!(raw.starts_with("---\n"));
1040 assert!(raw.contains("target_date: 2026-12-01"));
1041 assert!(raw.contains(&format!("created: {today}")));
1042 assert!(raw.contains(&format!("updated: {today}")));
1043 let ms = p.milestone("Ship V1").unwrap();
1044 assert_eq!(ms.target_date, "2026-12-01");
1045 assert_eq!(ms.description, "The launch");
1046 assert_eq!(ms.created, today);
1047 assert_eq!(ms.file_name, format!("{today}-ship-v1.md"));
1048 let names = p.milestone_names().unwrap();
1049 assert_eq!(names, ["Ship V1"]);
1050 }
1051
1052 #[test]
1053 fn milestone_without_target_date_omits_the_key() {
1054 let tmp = TempDir::new().unwrap();
1055 let p = planner_in(&tmp);
1056 let path = p.create_milestone_file("Backlog", None, None).unwrap();
1057 let raw = fs::read_to_string(&path).unwrap();
1058 assert!(
1059 !raw.contains("target_date"),
1060 "no target_date expected:\n{raw}"
1061 );
1062 assert!(raw.contains("name: Backlog"));
1063 let ms = p.milestone("Backlog").unwrap();
1064 assert_eq!(ms.target_date, "");
1065 assert_eq!(ms.description, "");
1066 }
1067
1068 #[test]
1069 fn milestone_rejects_invalid_dates_and_overwrites() {
1070 let tmp = TempDir::new().unwrap();
1071 let p = planner_in(&tmp);
1072 let err = p
1073 .create_milestone_file("X", Some("2026-13-99"), None)
1074 .unwrap_err();
1075 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1076 assert!(err.to_string().contains("target_date"));
1077 p.create_milestone_file("X", None, None).unwrap();
1078 let err = p.create_milestone_file("X", None, None).unwrap_err();
1079 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1080 assert!(err.to_string().contains("already exists"));
1081 }
1082
1083 #[test]
1084 fn rename_milestone_file_preserves_body_and_bumps_updated() {
1085 let tmp = TempDir::new().unwrap();
1086 let p = planner_in(&tmp);
1087 let today = today();
1088 p.create_milestone_file("Alpha", None, Some("old body text"))
1089 .unwrap();
1090 let old = p.milestone("Alpha").unwrap();
1091 let new_path = p.rename_milestone_file(&old, "Beta").unwrap();
1092 assert_eq!(
1093 new_path.file_name().unwrap(),
1094 format!("{today}-beta.md").as_str()
1095 );
1096 assert!(p.milestone("Alpha").is_err());
1097 let beta = p.milestone("Beta").unwrap();
1098 assert_eq!(beta.created, today);
1099 assert_eq!(beta.updated, today);
1100 assert_eq!(beta.description, "old body text");
1101 let raw = fs::read_to_string(&new_path).unwrap();
1102 assert!(raw.contains("name: Beta"), "renamed frontmatter:\n{raw}");
1103 assert!(
1104 !raw.contains("name: Alpha"),
1105 "old name must be rebased:\n{raw}"
1106 );
1107 assert!(
1108 raw.contains("old body text"),
1109 "body preserved verbatim:\n{raw}"
1110 );
1111 }
1112
1113 #[test]
1114 fn list_milestones_is_empty_before_directory_exists() {
1115 let tmp = TempDir::new().unwrap();
1116 let p = planner_in(&tmp);
1117 assert!(p.list_milestones().unwrap().is_empty());
1118 assert!(p.milestone("nope").is_err());
1119 }
1120
1121 #[test]
1122 fn team_and_cycle_index_maps_preserve_insertion_order() {
1123 let tmp = TempDir::new().unwrap();
1124 let mut p = planner_in(&tmp);
1125 p.config.cycles.insert("C".into(), PlanningCycle::default());
1126 p.config.cycles.insert("A".into(), PlanningCycle::default());
1127 assert_eq!(p.cycle_names(), ["C", "A"]);
1128 let final_cfg = Config::load_with_warning(&p.root.join("tix.toml"), |_| {});
1129 assert!(final_cfg.is_err(), "nothing persisted, nothing to load");
1130 }
1131}