1use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19use crate::capacity::scope::IssueSnapshot;
20use crate::config::{Config, PlanningCycle, Team};
21use crate::cycle_schedule::identity::ScheduleIndex;
22use crate::cycle_store::{
23 CycleStore, DEFAULT_CYCLE_NAME, IMPLICIT_DEFAULT_CYCLE, is_implicit_default,
24};
25use crate::error::{Result, TixError};
26use crate::model::Issue;
27use crate::records::{HasName, RecordStore};
28use crate::storage::Store;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct RolloverTarget {
32 pub from_cycle: String,
33 pub to_cycle: String,
34 pub issues: Vec<u32>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Progress {
39 pub done: usize,
40 pub total: usize,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RenameImpact {
45 pub changed_issue_count: usize,
46 pub issues: Vec<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Impact {
51 pub referencing_issues: Vec<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Milestone {
61 pub name: String,
62 pub target_date: String,
63 pub description: String,
64 pub created: String,
65 pub updated: String,
66 pub file_name: String,
67 pub color: Option<String>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
74#[serde(default, deny_unknown_fields)]
75struct MilestoneHead {
76 name: String,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 target_date: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
82 color: Option<String>,
83 created: String,
84 updated: String,
85}
86
87impl HasName for MilestoneHead {
88 fn name(&self) -> &str {
89 &self.name
90 }
91}
92
93#[derive(Debug, Clone)]
95pub struct Planner {
96 root: PathBuf,
97 config: Config,
98}
99
100impl Planner {
101 pub fn load() -> Result<Self> {
104 Self::load_from(Path::new("tix.toml"))
105 }
106
107 pub fn load_from(path: &Path) -> Result<Self> {
109 let config = Config::load_with_warning(path, |token| {
110 eprintln!(
111 "warning: {} embeds a cleartext {} token; prefer an environment variable",
112 path.display(),
113 token
114 );
115 })?;
116 let root = path
117 .parent()
118 .filter(|p| !p.as_os_str().is_empty())
119 .map(Path::to_path_buf)
120 .unwrap_or_else(|| PathBuf::from("."));
121 Ok(Self::from_config(config, root))
122 }
123
124 pub fn from_config(config: Config, root: PathBuf) -> Self {
127 Self { root, config }
128 }
129
130 pub fn config(&self) -> &Config {
132 &self.config
133 }
134
135 pub fn category_resolution(&self) -> crate::workflow::CategoryResolution {
143 let board = self.config.default_config();
144 crate::workflow::resolution(&self.config, board, "default")
145 }
146
147 fn is_done_issue(&self, issue: &Issue) -> bool {
157 let resolution = self.category_resolution();
158 crate::workflow::is_completed(issue, &resolution)
159 }
160
161 pub fn schedule_index(&self) -> Result<ScheduleIndex> {
170 crate::capacity::schedule_index(&self.config, &self.root)
171 }
172
173 pub fn capacity_snapshot_from(&self, issues: &[Issue]) -> Result<IssueSnapshot> {
182 let index = self.schedule_index()?;
183 Ok(IssueSnapshot::build(&self.config, &index, issues))
184 }
185
186 pub fn capacity_snapshot(&self, store: &Store) -> Result<IssueSnapshot> {
191 self.capacity_snapshot_from(&store.list()?)
192 }
193
194 pub fn milestones_dir(&self) -> PathBuf {
196 self.root.join(self.config.milestones_dir())
197 }
198
199 fn save(&self) -> Result<()> {
201 self.config.save(&self.root.join("tix.toml"))
202 }
203
204 pub fn team(&self, label: &str) -> Option<&Team> {
206 self.config.teams.get(label)
207 }
208
209 pub fn cycle(&self, name: &str) -> Option<&PlanningCycle> {
214 if let Some(cycle) = self.config.cycles.get(name) {
215 return Some(cycle);
216 }
217 if name == DEFAULT_CYCLE_NAME {
218 return Some(&IMPLICIT_DEFAULT_CYCLE);
219 }
220 None
221 }
222
223 pub fn cycle_names(&self) -> Vec<String> {
231 self.config.cycles.keys().cloned().collect()
232 }
233
234 fn cycle_store(&self) -> CycleStore {
236 CycleStore::new(&self.root)
237 }
238
239 fn persist_cycles(&self) -> Result<()> {
246 let store = self.cycle_store();
247 let file_mode = store.exists()
248 || self
249 .config
250 .cycles
251 .iter()
252 .any(|(name, cycle)| !is_implicit_default(name, cycle));
253 if !file_mode {
254 return Ok(());
255 }
256 for (name, cycle) in &self.config.cycles {
257 if is_implicit_default(name, cycle) {
258 continue;
259 }
260 let filename = format!("{}.md", crate::slug::slugify(name));
261 let body = RecordStore::new(store.dir()).body_of(&filename);
262 store.save(name, cycle, body.as_deref())?;
263 }
264 Ok(())
265 }
266
267 pub fn is_overdue(issue: &Issue, today: &str) -> bool {
268 issue.due_date.as_deref().is_some_and(|due| due < today)
269 }
270
271 pub fn cycle_expired(cycle: &PlanningCycle, today: &str) -> bool {
272 cycle.due_date.as_deref().is_some_and(|due| due < today)
273 }
274
275 pub fn is_running(cycle: &PlanningCycle, today: &str) -> bool {
281 cycle
282 .start_date
283 .as_deref()
284 .is_some_and(|start| start <= today)
285 && cycle.due_date.as_deref().is_none_or(|due| due >= today)
286 }
287
288 pub fn running_cycle(&self, today: &str) -> Option<String> {
296 let mut running = self
297 .config
298 .cycles
299 .iter()
300 .filter(|(_, cycle)| Self::is_running(cycle, today))
301 .map(|(name, _)| name.clone());
302 let first = running.next()?;
303 running.next().is_none().then_some(first)
304 }
305
306 pub fn overlapping_cycles(&self, name: &str) -> Vec<String> {
315 let Some(start) = self
316 .config
317 .cycles
318 .get(name)
319 .and_then(|cycle| cycle.start_date.as_deref())
320 else {
321 return Vec::new();
322 };
323 let due = self.config.cycles[name].due_date.as_deref();
324 self.config
325 .cycles
326 .iter()
327 .filter(|(other, _)| other.as_str() != name)
328 .filter(|(_, cycle)| {
329 let Some(other_start) = cycle.start_date.as_deref() else {
330 return false;
331 };
332 let other_ends_after_target_starts =
333 cycle.due_date.as_deref().is_none_or(|end| start <= end);
334 let target_ends_after_other_starts = due.is_none_or(|end| other_start <= end);
335 other_ends_after_target_starts && target_ends_after_other_starts
336 })
337 .map(|(other, _)| other.clone())
338 .collect()
339 }
340
341 pub fn successor<'a>(
342 &'a self,
343 cycle: &str,
344 today: &str,
345 ) -> Option<(&'a str, &'a PlanningCycle)> {
346 self.config
347 .cycles
348 .iter()
349 .filter(|(name, c)| {
350 *name != cycle && c.start_date.as_deref().is_some_and(|s| s >= today)
351 })
352 .min_by(|a, b| {
353 let by_start = a.1.start_date.cmp(&b.1.start_date);
354 if by_start == std::cmp::Ordering::Equal {
355 a.0.cmp(b.0)
356 } else {
357 by_start
358 }
359 })
360 .map(|(name, c)| (name.as_str(), c))
361 }
362
363 pub fn overdue_batch(&self, store: &Store, today: &str) -> Vec<Issue> {
364 store
365 .list()
366 .unwrap_or_default()
367 .into_iter()
368 .filter(|issue| !self.is_done_issue(issue) && Self::is_overdue(issue, today))
369 .collect()
370 }
371
372 pub fn rollover_plan(&self, store: &Store, today: &str) -> Vec<RolloverTarget> {
373 self.rollover_plan_from(&store.list().unwrap_or_default(), today)
374 }
375
376 pub fn rollover_plan_from(&self, all: &[Issue], today: &str) -> Vec<RolloverTarget> {
383 let mut out = Vec::new();
384 for (from_cycle, cycle) in &self.config.cycles {
385 if !Self::cycle_expired(cycle, today) {
386 continue;
387 }
388 let Some((to_cycle, _successor)) = self.successor(from_cycle, today) else {
389 continue;
390 };
391 let issues = all
392 .iter()
393 .filter(|issue| {
394 issue
395 .cycles
396 .as_ref()
397 .is_some_and(|cycles| cycles.iter().any(|c| c == from_cycle))
398 && !self.is_done_issue(issue)
399 && !issue
400 .cycles
401 .as_ref()
402 .is_some_and(|cycles| cycles.iter().any(|c| c == to_cycle))
403 })
404 .map(|issue| issue.number)
405 .collect::<Vec<_>>();
406 if !issues.is_empty() {
407 out.push(RolloverTarget {
408 from_cycle: from_cycle.clone(),
409 to_cycle: to_cycle.to_string(),
410 issues,
411 });
412 }
413 }
414 out
415 }
416
417 pub fn cycle_progress(&self, store: &Store, name: &str) -> Progress {
418 let mut done = 0usize;
419 let mut total = 0usize;
420 for issue in store.list().unwrap_or_default() {
421 if issue
422 .cycles
423 .as_ref()
424 .is_some_and(|cycles| cycles.iter().any(|cycle| cycle == name))
425 {
426 total += 1;
427 if self.is_done_issue(&issue) {
428 done += 1;
429 }
430 }
431 }
432 Progress { done, total }
433 }
434
435 pub fn milestone_progress(&self, store: &Store, name: &str) -> Progress {
436 let mut done = 0usize;
437 let mut total = 0usize;
438 for issue in store.list().unwrap_or_default() {
439 if issue
440 .milestones
441 .as_ref()
442 .is_some_and(|milestones| milestones.iter().any(|milestone| milestone == name))
443 {
444 total += 1;
445 if self.is_done_issue(&issue) {
446 done += 1;
447 }
448 }
449 }
450 Progress { done, total }
451 }
452
453 pub fn next_cycle_name(&mut self) -> String {
458 let mut n = self.config.planning.cycle_number;
459 loop {
460 let candidate = format!("Cycle {n}");
461 if !self.config.cycles.contains_key(&candidate) {
462 self.config.planning.cycle_number = n + 1;
463 return candidate;
464 }
465 n += 1;
466 }
467 }
468
469 pub fn add_cycle(&mut self, name: Option<String>) -> Result<String> {
473 let name = match name {
474 Some(name) => name,
475 None => self.next_cycle_name(),
476 };
477 let name = name.trim().to_string();
478 if name.is_empty() {
479 return Err(TixError::InvalidInput(
480 "cycle name must not be blank".into(),
481 ));
482 }
483 if name == DEFAULT_CYCLE_NAME && !self.config.cycles.contains_key(&name) {
484 return Err(TixError::InvalidInput(format!(
485 "cycle `{name}` already exists"
486 )));
487 }
488 if self.config.cycles.contains_key(&name) {
489 return Err(TixError::InvalidInput(format!(
490 "cycle `{name}` already exists"
491 )));
492 }
493 if let Some(auto) = self.current_auto_name()
494 && name == auto
495 {
496 return Err(TixError::Config(format!(
497 "cycle `{name}` collides with the auto-cycle counter; pick another name or let it auto-increment"
498 )));
499 }
500 self.config
501 .cycles
502 .insert(name.clone(), PlanningCycle::default());
503 self.persist_cycles()?;
504 self.save()?;
505 Ok(name)
506 }
507
508 pub fn rename_cycle(&mut self, old: &str, new: &str) -> Result<()> {
511 let new = new.trim().to_string();
512 if old == new {
513 return Ok(());
514 }
515 if !self.config.cycles.contains_key(old) {
516 return Err(TixError::InvalidInput(format!(
517 "cycle `{old}` does not exist"
518 )));
519 }
520 if new.is_empty() {
521 return Err(TixError::InvalidInput(
522 "cycle name must not be blank".into(),
523 ));
524 }
525 if new == DEFAULT_CYCLE_NAME && !self.config.cycles.contains_key(&new) {
526 return Err(TixError::InvalidInput(format!(
527 "cycle `{new}` already exists"
528 )));
529 }
530 if self.config.cycles.contains_key(&new) {
531 return Err(TixError::InvalidInput(format!(
532 "cycle `{new}` already exists"
533 )));
534 }
535 if let Some(auto) = self.current_auto_name()
536 && new == auto
537 {
538 return Err(TixError::Config(format!(
539 "cycle `{new}` collides with the auto-cycle counter"
540 )));
541 }
542 let had_file = self.cycle_store().get(old).is_ok();
543 let cycle = self
544 .config
545 .cycles
546 .shift_remove(old)
547 .expect("existence checked above");
548 self.config.cycles.insert(new.clone(), cycle);
549 if self.config.planning.default_cycle.as_deref() == Some(old) {
550 self.config.planning.default_cycle = Some(new.clone());
551 }
552 if had_file {
553 self.cycle_store().rename(old, &new)?;
554 }
555 self.persist_cycles()?;
556 self.save()?;
557 Ok(())
558 }
559
560 pub fn delete_impact(&self, store: &Store, name: &str) -> Result<Impact> {
561 let mut referencing_issues = Vec::new();
562 for issue in store.list()? {
563 if issue
564 .cycles
565 .as_ref()
566 .is_some_and(|cycles| cycles.iter().any(|cycle| cycle == name))
567 {
568 referencing_issues.push(issue.filename);
569 }
570 }
571 Ok(Impact { referencing_issues })
572 }
573
574 pub fn rename_cycle_cascade(
575 &mut self,
576 store: &Store,
577 old: &str,
578 new: &str,
579 ) -> Result<RenameImpact> {
580 let new = new.trim();
581 if new.is_empty() {
582 return Err(TixError::InvalidInput(
583 "cycle name must not be blank".into(),
584 ));
585 }
586 if !self.config.cycles.contains_key(old) {
587 return Err(TixError::InvalidInput(format!(
588 "cycle `{old}` does not exist"
589 )));
590 }
591 if old != new && self.config.cycles.contains_key(new) {
592 return Err(TixError::InvalidInput(format!(
593 "cycle `{new}` already exists"
594 )));
595 }
596 let mut issues = Vec::new();
597 for mut issue in store.list()? {
598 let Some(cycles) = issue.cycles.as_mut() else {
599 continue;
600 };
601 let mut changed = false;
602 for cycle in cycles.iter_mut() {
603 if cycle == old {
604 *cycle = new.to_string();
605 changed = true;
606 }
607 }
608 if changed {
609 issue.bump_updated();
610 issue.filename = store.update(&issue.filename, &issue)?;
611 issues.push(issue.filename);
612 }
613 }
614 self.rename_cycle(old, new)?;
615 Ok(RenameImpact {
616 changed_issue_count: issues.len(),
617 issues,
618 })
619 }
620
621 pub fn set_cycle_fields(
622 &mut self,
623 name: &str,
624 description: Option<&str>,
625 start_date: Option<&str>,
626 due_date: Option<&str>,
627 ) -> Result<()> {
628 if !self.config.cycles.contains_key(name) {
629 if name == DEFAULT_CYCLE_NAME {
630 self.config
631 .cycles
632 .insert(name.to_string(), PlanningCycle::default());
633 } else {
634 return Err(TixError::InvalidInput(format!(
635 "cycle `{name}` does not exist"
636 )));
637 }
638 }
639 let cycle = self.config.cycles.get_mut(name).expect("inserted above");
640
641 if let Some(description) = description {
642 let description = description.trim();
643 cycle.description = if description.is_empty() {
644 None
645 } else {
646 Some(description.to_string())
647 };
648 }
649 if let Some(start_date) = start_date {
650 let start_date = start_date.trim();
651 if start_date.is_empty() {
652 cycle.start_date = None;
653 } else {
654 if !crate::model::validate_date(start_date) {
655 return Err(TixError::InvalidInput(format!(
656 "start_date `{start_date}` must be a `YYYY-MM-DD` calendar date"
657 )));
658 }
659 cycle.start_date = Some(start_date.to_string());
660 }
661 }
662 if let Some(due_date) = due_date {
663 let due_date = due_date.trim();
664 if due_date.is_empty() {
665 cycle.due_date = None;
666 } else {
667 if !crate::model::validate_date(due_date) {
668 return Err(TixError::InvalidInput(format!(
669 "due_date `{due_date}` must be a `YYYY-MM-DD` calendar date"
670 )));
671 }
672 cycle.due_date = Some(due_date.to_string());
673 }
674 }
675 self.config.validate()?;
676 self.persist_cycles()?;
677 self.save()
678 }
679
680 pub fn delete_cycle(&mut self, name: &str) -> Result<()> {
681 if self.config.cycles.shift_remove(name).is_none() {
682 return Err(TixError::InvalidInput(format!(
683 "cycle `{name}` does not exist"
684 )));
685 }
686 if self.config.planning.default_cycle.as_deref() == Some(name) {
687 self.config.planning.default_cycle = None;
688 }
689 let store = self.cycle_store();
690 if store.exists() && store.get(name).is_ok() {
691 store.delete(name)?;
692 }
693 self.persist_cycles()?;
694 self.save()
695 }
696
697 pub fn add_team_member(&mut self, label: &str, member: &str) -> Result<()> {
700 let label = label.trim();
701 let member = member.trim();
702 if label.is_empty() {
703 return Err(TixError::InvalidInput(
704 "team label must not be blank".into(),
705 ));
706 }
707 if member.is_empty() {
708 return Err(TixError::InvalidInput("member must not be blank".into()));
709 }
710 let team = self.config.teams.entry(label.to_string()).or_default();
711 if team.members.iter().any(|m| m == member) {
712 return Err(TixError::InvalidInput(format!(
713 "`{member}` is already a member of team `{label}`"
714 )));
715 }
716 team.members.push(member.to_string());
717 self.config.validate()?;
718 self.save()?;
719 Ok(())
720 }
721
722 pub fn list_milestones(&self) -> Result<Vec<Milestone>> {
725 let store = RecordStore::new(self.milestones_dir());
726 let records = store.list::<MilestoneHead>()?;
727 Ok(records.into_iter().map(Self::milestone_from).collect())
728 }
729
730 pub fn milestone_names(&self) -> Result<Vec<String>> {
732 Ok(self
733 .list_milestones()?
734 .into_iter()
735 .map(|m| m.name)
736 .collect())
737 }
738
739 pub fn milestone(&self, name: &str) -> Result<Milestone> {
741 let store = RecordStore::new(self.milestones_dir());
742 match store.get::<MilestoneHead>(name) {
743 Ok(record) => Ok(Self::milestone_from(record)),
744 Err(TixError::NotFound(_)) => Err(TixError::NotFound(format!("milestone `{name}`"))),
745 Err(e) => Err(e),
746 }
747 }
748
749 pub fn create_milestone_file(
755 &self,
756 name: &str,
757 target_date: Option<&str>,
758 description: Option<&str>,
759 ) -> Result<PathBuf> {
760 let name = name.trim().to_string();
761 if name.is_empty() {
762 return Err(TixError::InvalidInput(
763 "milestone name must not be blank".into(),
764 ));
765 }
766 if let Some(date) = target_date
767 && !crate::model::validate_date(date)
768 {
769 return Err(TixError::InvalidInput(format!(
770 "target_date `{date}` must be a `YYYY-MM-DD` calendar date"
771 )));
772 }
773 let today = crate::model::today();
774 let file_name = format!("{today}-{}.md", crate::slug::slugify(&name));
775 let head = MilestoneHead {
776 name: name.clone(),
777 target_date: target_date.map(str::to_string),
778 color: None,
779 created: today.clone(),
780 updated: today,
781 };
782 RecordStore::new(self.milestones_dir()).write(&file_name, &head, description)
783 }
784
785 pub fn rename_milestone_file(&self, old: &Milestone, new_name: &str) -> Result<PathBuf> {
790 let new_name = new_name.trim();
791 if new_name.is_empty() {
792 return Err(TixError::InvalidInput(
793 "milestone name must not be blank".into(),
794 ));
795 }
796 let prefix = if old.created.is_empty() {
797 crate::model::today()
798 } else {
799 old.created.clone()
800 };
801 let new_file_name = format!("{prefix}-{}.md", crate::slug::slugify(new_name));
802 let head = MilestoneHead {
803 name: new_name.to_string(),
804 target_date: (!old.target_date.is_empty()).then(|| old.target_date.clone()),
805 color: old.color.clone(),
806 created: prefix,
807 updated: crate::model::today(),
808 };
809 let store = RecordStore::new(self.milestones_dir());
810 let new_path = store.write(&new_file_name, &head, Some(&old.description))?;
811 store.delete_file(&old.file_name)?;
812 Ok(new_path)
813 }
814
815 pub fn milestone_delete_impact(&self, store: &Store, name: &str) -> Result<Impact> {
816 let mut referencing_issues = Vec::new();
817 for issue in store.list()? {
818 if issue
819 .milestones
820 .as_ref()
821 .is_some_and(|milestones| milestones.iter().any(|milestone| milestone == name))
822 {
823 referencing_issues.push(issue.filename);
824 }
825 }
826 Ok(Impact { referencing_issues })
827 }
828
829 pub fn rename_milestone_cascade(
830 &self,
831 store: &Store,
832 old: &Milestone,
833 new_name: &str,
834 ) -> Result<RenameImpact> {
835 let _ = self.rename_milestone_file(old, new_name)?;
836 let mut issues = Vec::new();
837 for mut issue in store.list()? {
838 let Some(milestones) = issue.milestones.as_mut() else {
839 continue;
840 };
841 let mut changed = false;
842 for milestone in milestones.iter_mut() {
843 if milestone == &old.name {
844 *milestone = new_name.to_string();
845 changed = true;
846 }
847 }
848 if changed {
849 issue.bump_updated();
850 issue.filename = store.update(&issue.filename, &issue)?;
851 issues.push(issue.filename);
852 }
853 }
854 Ok(RenameImpact {
855 changed_issue_count: issues.len(),
856 issues,
857 })
858 }
859
860 pub fn update_milestone(
861 &self,
862 name: &str,
863 target_date: Option<&str>,
864 description: Option<&str>,
865 ) -> Result<()> {
866 let milestone = self.milestone(name)?;
867 let next_target = match target_date {
868 Some(value) => {
869 let value = value.trim();
870 if value.is_empty() {
871 None
872 } else {
873 if !crate::model::validate_date(value) {
874 return Err(TixError::InvalidInput(format!(
875 "target_date `{value}` must be a `YYYY-MM-DD` calendar date"
876 )));
877 }
878 Some(value.to_string())
879 }
880 }
881 None => (!milestone.target_date.is_empty()).then_some(milestone.target_date.clone()),
882 };
883 let body = description.unwrap_or(&milestone.description);
884 let head = MilestoneHead {
885 name: milestone.name.clone(),
886 target_date: next_target,
887 color: milestone.color.clone(),
888 created: milestone.created.clone(),
889 updated: crate::model::today(),
890 };
891 RecordStore::new(self.milestones_dir()).overwrite(
892 &milestone.file_name,
893 &head,
894 Some(body),
895 )?;
896 Ok(())
897 }
898
899 pub fn delete_milestone(&self, name: &str) -> Result<String> {
900 let milestone = self.milestone(name)?;
901 RecordStore::new(self.milestones_dir()).delete_file(&milestone.file_name)?;
902 Ok(milestone.file_name)
903 }
904
905 fn current_auto_name(&self) -> Option<String> {
907 Some(format!("Cycle {}", self.config.planning.cycle_number))
908 }
909
910 fn milestone_from(record: crate::records::RecordFile<MilestoneHead>) -> Milestone {
911 let head = record.value;
912 Milestone {
913 name: head.name,
914 target_date: head.target_date.unwrap_or_default(),
915 description: record.body.trim_end().to_string(),
916 created: head.created,
917 updated: head.updated,
918 file_name: record.filename,
919 color: head.color,
920 }
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 #[test]
942 fn completion_follows_the_category_not_the_done_column() {
943 use crate::config::{BoardConfiguration, OrderBy};
944 use indexmap::IndexMap;
945 let tmp = tempfile::TempDir::new().unwrap();
946 let issues_dir = tmp.path().join("docs").join("issues");
947 std::fs::create_dir_all(&issues_dir).unwrap();
948
949 let mut config = Config {
953 issues_dir: issues_dir.clone(),
954 ..Default::default()
955 };
956 let mut category_map = IndexMap::new();
957 category_map.insert("wip".to_string(), "active".to_string());
958 category_map.insert("shipped".to_string(), "active".to_string());
959 category_map.insert("archived".to_string(), "completed".to_string());
960 config.kanban.configurations.insert(
961 "default".to_string(),
962 BoardConfiguration {
963 columns: vec![
964 "wip".to_string(),
965 "shipped".to_string(),
966 "archived".to_string(),
967 ],
968 done_column: Some("shipped".to_string()),
969 category_map,
970 hidden_columns: vec![],
971 show_mismatch: false,
972 wrap_titles: true,
973 title_lines: 3,
974 card_order: vec![],
975 order_by: OrderBy::CreatedAtDesc,
976 wip: crate::wip::WipConfig::default(),
977 },
978 );
979 config
981 .validate()
982 .expect("the fixture must be a valid config");
983
984 let write = |name: &str, number: u32, status: &str, due: &str| {
985 std::fs::write(
986 issues_dir.join(name),
987 format!(
988 "---\nnumber: {number}\ntitle: \"{status}\"\nstatus: \"{status}\"\n\
989 labels: []\npriority: \"medium\"\ndue_date: \"{due}\"\n\
990 created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n"
991 ),
992 )
993 .unwrap();
994 };
995 write("2026-01-01-wip.md", 1, "wip", "2026-01-02");
996 write("2026-01-01-shipped.md", 2, "shipped", "2026-01-02");
997 write("2026-01-01-archived.md", 3, "archived", "2026-01-02");
998
999 let store = crate::storage::Store::open(&config).unwrap();
1000 let planner = Planner::from_config(config, tmp.path().to_path_buf());
1001 let today = "2026-06-01";
1002 let mut overdue: Vec<u32> = planner
1003 .overdue_batch(&store, today)
1004 .iter()
1005 .map(|i| i.number)
1006 .collect();
1007 overdue.sort_unstable();
1012
1013 assert_eq!(
1014 overdue,
1015 vec![1, 2],
1016 "`wip` and `shipped` are both `active`, so both are still overdue; \
1017 `archived` is the only completing column"
1018 );
1019 assert!(
1022 overdue.contains(&2),
1023 "an issue in the `done_column` is not complete when its category is \
1024 `active` — the category is the only completion definition (C-004)"
1025 );
1026 }
1027
1028 #[allow(clippy::wildcard_imports)]
1029 use super::*;
1030 use crate::model::today;
1031 use std::fs;
1032 use tempfile::TempDir;
1033
1034 fn planner_in(tmp: &TempDir) -> Planner {
1035 Planner::from_config(Config::default(), tmp.path().to_path_buf())
1036 }
1037
1038 #[test]
1039 fn milestones_dir_resolves_configured_and_default() {
1040 let tmp = TempDir::new().unwrap();
1041 let p = planner_in(&tmp);
1042 assert_eq!(p.milestones_dir(), tmp.path().join("docs/milestones"));
1043
1044 let mut cfg = Config {
1045 milestones_dir: Some(PathBuf::from("tasks/milestones")),
1046 ..Config::default()
1047 };
1048 let p = Planner::from_config(cfg.clone(), tmp.path().to_path_buf());
1049 assert_eq!(p.milestones_dir(), tmp.path().join("tasks/milestones"));
1050 cfg.milestones_dir = None;
1051 let p = Planner::from_config(cfg, tmp.path().to_path_buf());
1052 assert_eq!(p.milestones_dir(), tmp.path().join("docs/milestones"));
1053 }
1054
1055 #[test]
1056 fn auto_cycle_names_skip_existing_and_bump_counter() {
1057 let tmp = TempDir::new().unwrap();
1058 let mut p = planner_in(&tmp);
1059 assert_eq!(p.next_cycle_name(), "Cycle 1");
1060 assert_eq!(p.config.planning.cycle_number, 2);
1061 assert_eq!(p.add_cycle(None).unwrap(), "Cycle 2");
1062 assert_eq!(p.next_cycle_name(), "Cycle 3");
1063 }
1064
1065 #[test]
1066 fn add_cycle_rejects_duplicates_and_counter_collision() {
1067 let tmp = TempDir::new().unwrap();
1068 let mut p = planner_in(&tmp);
1069 p.config
1070 .cycles
1071 .insert("Cycle 1".into(), PlanningCycle::default());
1072 let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
1073 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1074 assert!(err.to_string().contains("already exists"));
1075 let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
1077 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1078 }
1079
1080 #[test]
1081 fn add_cycle_collision_with_current_counter_is_config_error() {
1082 let tmp = TempDir::new().unwrap();
1083 let mut p = planner_in(&tmp);
1084 let err = p.add_cycle(Some("Cycle 1".into())).unwrap_err();
1085 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1086 assert!(err.to_string().contains("collides"));
1087 }
1088
1089 #[test]
1090 fn rename_cycle_moves_slot_and_rebases_default() {
1091 let tmp = TempDir::new().unwrap();
1092 let mut p = planner_in(&tmp);
1093 p.config
1094 .cycles
1095 .insert("Old".into(), PlanningCycle::default());
1096 p.config.planning.default_cycle = Some("Old".into());
1097 p.rename_cycle("Old", "New").unwrap();
1098 assert!(p.cycle("Old").is_none());
1099 assert!(p.cycle("New").is_some());
1100 assert_eq!(p.config.planning.default_cycle.as_deref(), Some("New"));
1101 let err = p.rename_cycle("Old", "X").unwrap_err();
1102 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1103 }
1104
1105 #[test]
1106 fn add_team_member_creates_and_dedupes() {
1107 let tmp = TempDir::new().unwrap();
1108 let mut p = planner_in(&tmp);
1109 p.add_team_member("platform", "@alice").unwrap();
1110 assert_eq!(p.team("platform").unwrap().members, ["@alice"]);
1111 let err = p.add_team_member("platform", "@alice").unwrap_err();
1112 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1113 assert!(err.to_string().contains("already a member"));
1114 let err = p.add_team_member("", "@alice").unwrap_err();
1115 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1116 }
1117
1118 #[test]
1119 fn milestone_file_writes_contract_format_and_reads_back() {
1120 let tmp = TempDir::new().unwrap();
1121 let p = planner_in(&tmp);
1122 let today = today();
1123 let path = p
1124 .create_milestone_file("Ship V1", Some("2026-12-01"), Some("The launch"))
1125 .unwrap();
1126 assert_eq!(
1128 path.file_name().unwrap(),
1129 format!("{today}-ship-v1.md").as_str()
1130 );
1131 let raw = fs::read_to_string(&path).unwrap();
1132 assert!(raw.starts_with("---\n"));
1133 assert!(raw.contains("target_date: 2026-12-01"));
1134 assert!(raw.contains(&format!("created: {today}")));
1135 assert!(raw.contains(&format!("updated: {today}")));
1136 let ms = p.milestone("Ship V1").unwrap();
1137 assert_eq!(ms.target_date, "2026-12-01");
1138 assert_eq!(ms.description, "The launch");
1139 assert_eq!(ms.created, today);
1140 assert_eq!(ms.file_name, format!("{today}-ship-v1.md"));
1141 let names = p.milestone_names().unwrap();
1142 assert_eq!(names, ["Ship V1"]);
1143 }
1144
1145 #[test]
1146 fn milestone_without_target_date_omits_the_key() {
1147 let tmp = TempDir::new().unwrap();
1148 let p = planner_in(&tmp);
1149 let path = p.create_milestone_file("Backlog", None, None).unwrap();
1150 let raw = fs::read_to_string(&path).unwrap();
1151 assert!(
1152 !raw.contains("target_date"),
1153 "no target_date expected:\n{raw}"
1154 );
1155 assert!(raw.contains("name: Backlog"));
1156 let ms = p.milestone("Backlog").unwrap();
1157 assert_eq!(ms.target_date, "");
1158 assert_eq!(ms.description, "");
1159 }
1160
1161 #[test]
1162 fn milestone_rejects_invalid_dates_and_overwrites() {
1163 let tmp = TempDir::new().unwrap();
1164 let p = planner_in(&tmp);
1165 let err = p
1166 .create_milestone_file("X", Some("2026-13-99"), None)
1167 .unwrap_err();
1168 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1169 assert!(err.to_string().contains("target_date"));
1170 p.create_milestone_file("X", None, None).unwrap();
1171 let err = p.create_milestone_file("X", None, None).unwrap_err();
1172 assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1173 assert!(err.to_string().contains("already exists"));
1174 }
1175
1176 #[test]
1177 fn rename_milestone_file_preserves_body_and_bumps_updated() {
1178 let tmp = TempDir::new().unwrap();
1179 let p = planner_in(&tmp);
1180 let today = today();
1181 p.create_milestone_file("Alpha", None, Some("old body text"))
1182 .unwrap();
1183 let old = p.milestone("Alpha").unwrap();
1184 let new_path = p.rename_milestone_file(&old, "Beta").unwrap();
1185 assert_eq!(
1186 new_path.file_name().unwrap(),
1187 format!("{today}-beta.md").as_str()
1188 );
1189 assert!(p.milestone("Alpha").is_err());
1190 let beta = p.milestone("Beta").unwrap();
1191 assert_eq!(beta.created, today);
1192 assert_eq!(beta.updated, today);
1193 assert_eq!(beta.description, "old body text");
1194 let raw = fs::read_to_string(&new_path).unwrap();
1195 assert!(raw.contains("name: Beta"), "renamed frontmatter:\n{raw}");
1196 assert!(
1197 !raw.contains("name: Alpha"),
1198 "old name must be rebased:\n{raw}"
1199 );
1200 assert!(
1201 raw.contains("old body text"),
1202 "body preserved verbatim:\n{raw}"
1203 );
1204 }
1205
1206 #[test]
1207 fn list_milestones_is_empty_before_directory_exists() {
1208 let tmp = TempDir::new().unwrap();
1209 let p = planner_in(&tmp);
1210 assert_eq!(
1211 p.list_milestones().unwrap(),
1212 [] as [crate::planning::Milestone; 0]
1213 );
1214 assert!(p.milestone("nope").is_err());
1215 }
1216
1217 #[test]
1218 fn team_and_cycle_index_maps_preserve_insertion_order() {
1219 let tmp = TempDir::new().unwrap();
1220 let mut p = planner_in(&tmp);
1221 p.config.cycles.insert("C".into(), PlanningCycle::default());
1222 p.config.cycles.insert("A".into(), PlanningCycle::default());
1223 assert_eq!(p.cycle_names(), ["C", "A"]);
1224 let final_cfg = Config::load_with_warning(&p.root.join("tix.toml"), |_| {});
1225 assert!(final_cfg.is_err(), "nothing persisted, nothing to load");
1226 }
1227
1228 #[test]
1229 fn cycle_store_add_and_fields_are_lazy_and_file_backed() {
1230 let tmp = TempDir::new().unwrap();
1231 let mut p = planner_in(&tmp);
1232 let cycles_dir = tmp.path().join("docs/cycles");
1233
1234 assert!(
1236 !cycles_dir.exists(),
1237 "a defaults-only project needs no cycles directory"
1238 );
1239 assert_eq!(p.cycle_names(), [] as [std::string::String; 0]);
1240 assert!(p.cycle("Default").is_some());
1241
1242 p.add_cycle(Some("Sprint 7".into())).unwrap();
1243 assert!(
1244 cycles_dir.is_dir(),
1245 "adding a second cycle creates the directory"
1246 );
1247 assert!(
1248 !cycles_dir.join("default.md").exists(),
1249 "the implicit Default cycle is never a file"
1250 );
1251 assert!(
1252 p.cycle("Default").is_some(),
1253 "Default stays addressable alongside configured cycles"
1254 );
1255
1256 p.set_cycle_fields(
1257 "Sprint 7",
1258 Some("September sprint"),
1259 Some("2026-09-01"),
1260 Some("2026-09-14"),
1261 )
1262 .unwrap();
1263
1264 let reloaded = Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
1265 let cycle = reloaded
1266 .cycles
1267 .get("Sprint 7")
1268 .expect("the cycle is read back from its file");
1269 assert_eq!(cycle.description.as_deref(), Some("September sprint"));
1270 assert_eq!(cycle.start_date.as_deref(), Some("2026-09-01"));
1271 assert_eq!(cycle.due_date.as_deref(), Some("2026-09-14"));
1272 assert!(
1273 reloaded.cycles.contains_key("Default"),
1274 "the implicit Default cycle is available once the store exists"
1275 );
1276 let toml = fs::read_to_string(tmp.path().join("tix.toml")).unwrap();
1277 assert!(
1278 !toml.contains("[cycles"),
1279 "cycle records move out of tix.toml:\n{toml}"
1280 );
1281 assert_eq!(
1282 p.cycle("Sprint 7").unwrap().start_date.as_deref(),
1283 Some("2026-09-01")
1284 );
1285 }
1286
1287 #[test]
1288 fn cycle_rename_cascade_and_delete_impact_use_the_file_store() {
1289 let tmp = TempDir::new().unwrap();
1290 let issues_dir = tmp.path().join("docs").join("issues");
1291 fs::create_dir_all(&issues_dir).unwrap();
1292
1293 let config = Config {
1296 issues_dir: issues_dir.clone(),
1297 ..Config::default()
1298 };
1299 let mut p = Planner::from_config(config, tmp.path().to_path_buf());
1300 p.add_cycle(Some("Sprint 7".into())).unwrap();
1301 p.set_cycle_fields("Sprint 7", None, Some("2026-09-01"), None)
1302 .unwrap();
1303
1304 for (filename, number) in [("2026-01-01-a.md", 1), ("2026-01-01-b.md", 2)] {
1305 fs::write(
1306 issues_dir.join(filename),
1307 format!(
1308 "---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"todo\"\n\
1309 labels: []\npriority: \"medium\"\ncycles:\n- Sprint 7\n\
1310 created: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n"
1311 ),
1312 )
1313 .unwrap();
1314 }
1315
1316 let store = crate::storage::Store::open(p.config()).unwrap();
1317 assert_eq!(
1318 p.delete_impact(&store, "Sprint 7")
1319 .unwrap()
1320 .referencing_issues
1321 .len(),
1322 2
1323 );
1324
1325 let impact = p
1326 .rename_cycle_cascade(&store, "Sprint 7", "Sprint Seven")
1327 .unwrap();
1328 assert_eq!(impact.changed_issue_count, 2);
1329 for issue in store.list().unwrap() {
1330 assert_eq!(
1331 issue.cycles.as_deref(),
1332 Some(&["Sprint Seven".to_string()][..])
1333 );
1334 }
1335
1336 let cycles_dir = tmp.path().join("docs/cycles");
1337 assert!(!cycles_dir.join("sprint-7.md").exists(), "old file moved");
1338 assert!(cycles_dir.join("sprint-seven.md").exists());
1339
1340 assert_eq!(
1342 p.delete_impact(&store, "Sprint Seven")
1343 .unwrap()
1344 .referencing_issues
1345 .len(),
1346 2
1347 );
1348 p.delete_cycle("Sprint Seven").unwrap();
1349 assert!(!cycles_dir.join("sprint-seven.md").exists());
1350 assert_eq!(
1351 p.delete_impact(&store, "Sprint Seven")
1352 .unwrap()
1353 .referencing_issues
1354 .len(),
1355 2
1356 );
1357 }
1358
1359 fn cycle_with(start: Option<&str>, due: Option<&str>) -> PlanningCycle {
1360 PlanningCycle {
1361 description: None,
1362 start_date: start.map(str::to_string),
1363 due_date: due.map(str::to_string),
1364 }
1365 }
1366
1367 #[test]
1368 fn running_cycle_is_the_single_window_that_contains_today() {
1369 let tmp = TempDir::new().unwrap();
1370 let mut p = planner_in(&tmp);
1371
1372 p.config
1375 .cycles
1376 .insert(DEFAULT_CYCLE_NAME.into(), PlanningCycle::default());
1377 assert_eq!(
1378 p.running_cycle("2026-10-02"),
1379 None,
1380 "a date-less cycle (Default included) is never running"
1381 );
1382
1383 p.config
1385 .cycles
1386 .insert("October 2026".into(), cycle_with(Some("2026-10-01"), None));
1387 assert_eq!(
1388 p.running_cycle("2026-10-02").as_deref(),
1389 Some("October 2026")
1390 );
1391 assert_eq!(p.running_cycle("2026-09-30"), None, "before the start");
1392 assert_eq!(
1393 p.running_cycle("2026-10-01").as_deref(),
1394 Some("October 2026"),
1395 "the start bound is inclusive"
1396 );
1397
1398 p.config.cycles.insert(
1400 "September".into(),
1401 cycle_with(Some("2026-09-01"), Some("2026-09-30")),
1402 );
1403 assert_eq!(p.running_cycle("2026-09-30").as_deref(), Some("September"));
1404 assert_eq!(
1405 p.running_cycle("2026-10-02").as_deref(),
1406 Some("October 2026"),
1407 "September has ended, October still runs"
1408 );
1409
1410 p.config.cycles.insert(
1412 "Q4 2026".into(),
1413 cycle_with(Some("2026-10-01"), Some("2026-12-31")),
1414 );
1415 assert_eq!(
1416 p.running_cycle("2026-10-02"),
1417 None,
1418 "two running cycles are ambiguous"
1419 );
1420 }
1421
1422 #[test]
1423 fn overlapping_cycles_is_the_intersection_of_inclusive_windows() {
1424 let tmp = TempDir::new().unwrap();
1425 let mut p = planner_in(&tmp);
1426 p.config.cycles.insert(
1427 "B".into(),
1428 cycle_with(Some("2026-01-15"), Some("2026-02-15")),
1429 );
1430 p.config.cycles.insert(
1431 "C".into(),
1432 cycle_with(Some("2026-01-10"), Some("2026-01-12")),
1433 );
1434 p.config.cycles.insert(
1435 "D".into(),
1436 cycle_with(Some("2026-03-01"), Some("2026-03-31")),
1437 );
1438 p.config.cycles.insert(
1439 "E".into(),
1440 cycle_with(Some("2026-01-31"), Some("2026-02-28")),
1441 );
1442 p.config
1443 .cycles
1444 .insert("F".into(), cycle_with(None, Some("2026-06-30")));
1445 p.config
1446 .cycles
1447 .insert("Open".into(), cycle_with(Some("2026-01-20"), None));
1448 p.config.cycles.insert(
1449 "A".into(),
1450 cycle_with(Some("2026-01-01"), Some("2026-01-31")),
1451 );
1452
1453 assert_eq!(
1457 p.overlapping_cycles("A"),
1458 ["B", "C", "E", "Open"],
1459 "partial overlap, containment, touching endpoints and an open-ended window"
1460 );
1461
1462 assert_eq!(p.overlapping_cycles("F"), [] as [std::string::String; 0]);
1465 assert_eq!(
1466 p.overlapping_cycles("missing"),
1467 [] as [std::string::String; 0]
1468 );
1469
1470 p.config.cycles.clear();
1473 p.config
1474 .cycles
1475 .insert("Open".into(), cycle_with(Some("2026-02-01"), None));
1476 p.config.cycles.insert(
1477 "EndedBefore".into(),
1478 cycle_with(Some("2026-01-01"), Some("2026-01-15")),
1479 );
1480 p.config.cycles.insert(
1481 "StartsLater".into(),
1482 cycle_with(Some("2026-05-01"), Some("2026-05-31")),
1483 );
1484 assert_eq!(
1485 p.overlapping_cycles("Open"),
1486 ["StartsLater"],
1487 "an open-ended window meets a later one but not one that already ended"
1488 );
1489 }
1490}