Skip to main content

tix/
planning.rs

1//! The planning registry: teams, cycles and milestones.
2//!
3//! Teams are names in `tix.toml` (`[teams.<label>]`, FR-001). Cycles are
4//! frontmatter files in `docs/cycles/` with a legacy `[cycles.<name>]`
5//! `tix.toml` fallback (the `Default` cycle is implicit). Milestones are
6//! markdown files in `docs/milestones/`
7//! (`contracts/milestone-file-format.md`, FR-005). The auto "Cycle N" counter
8//! lives in `[planning]` and only ever moves forward (FR-003). All mutating
9//! operations persist through [`Config::save`] and/or the record stores, so a
10//! planning round trip is a load/save pair (NFR-002/003).
11//!
12//! A `Planner` snapshot mirrors its backing config: re-load to observe
13//! hand-edits.
14
15use 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/// A milestone as materialized from its registry file. `description` is the
55/// markdown body (the work summary); `file_name` is the registry filename
56/// (`{created}-{slug}.md`, `contracts/milestone-file-format.md`). `created`
57/// is the creation date (the filename prefix); `target_date` is the optional
58/// `YYYY-MM-DD` due date (empty string when absent).
59#[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    /// An optional color name for the milestone (tix #1530), from the record's
68    /// `color:` field. A milestone with none renders uncolored.
69    pub color: Option<String>,
70}
71
72/// The YAML frontmatter of a milestone file (`contracts/milestone-file-format.md`).
73#[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    /// An optional color name (tix #1530); a milestone file may carry one and it
80    /// round-trips, while creation omits it.
81    #[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/// A working snapshot of the planning registry, bound to a repo root.
94#[derive(Debug, Clone)]
95pub struct Planner {
96    root: PathBuf,
97    config: Config,
98}
99
100impl Planner {
101    /// Load the planning registry from `tix.toml` in the current directory,
102    /// on the crate-wide assumption that commands run from the repo root.
103    pub fn load() -> Result<Self> {
104        Self::load_from(Path::new("tix.toml"))
105    }
106
107    /// Load the planning registry from an explicit config path.
108    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    /// Build a planner from an already-loaded config (used by `tix init` and
125    /// tests). The config is not persisted here; mutators save on demand.
126    pub fn from_config(config: Config, root: PathBuf) -> Self {
127        Self { root, config }
128    }
129
130    /// The backing config this planner currently mirrors.
131    pub fn config(&self) -> &Config {
132        &self.config
133    }
134
135    /// The resolution this planner's board maps through.
136    ///
137    /// Built here rather than stored, because a `Planner` is a short-lived view
138    /// over a config and caching it would be the per-object rebuild the service's
139    /// "built once per config load" rule exists to avoid. The expensive part of a
140    /// sweep is not building this — it is a handful of hash inserts per column —
141    /// it is *not* re-deriving it per issue, and that is what callers do wrong.
142    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    /// Whether `issue` is complete, by the service's definition.
148    ///
149    /// Delegates rather than comparing `issue.status` to `done_column()` directly.
150    /// The comparison it replaced was a **second** notion of completion: it read
151    /// the board's `done_column` and ignored categories entirely, so a board whose
152    /// `done_column` was `shipped` and whose `category_map` said something else
153    /// answered differently from the category rule (C-004). The two agree on a
154    /// board with no `category_map` — which is every existing configuration — and
155    /// disagree on exactly the boards this mission exists to support.
156    fn is_done_issue(&self, issue: &Issue) -> bool {
157        let resolution = self.category_resolution();
158        crate::workflow::is_completed(issue, &resolution)
159    }
160
161    /// The cycle-identity index this planner's records resolve through (issue #181).
162    ///
163    /// One read of `docs/cycles/` — or of the legacy `[cycles.<name>]` tables when
164    /// there is no store, which is the loader's own rule — through
165    /// [`crate::capacity::schedule_index`]. It is the same index
166    /// [`crate::cycle_schedule::registry::load_index`] builds, and the same one
167    /// [`crate::capacity::resolve_cycle`] resolves a capacity scope reference
168    /// against, so a caller never has to build a second one.
169    pub fn schedule_index(&self) -> Result<ScheduleIndex> {
170        crate::capacity::schedule_index(&self.config, &self.root)
171    }
172
173    /// One indexed capacity snapshot over the issues a caller already read (WP02).
174    ///
175    /// The cycle records are read once ([`Planner::schedule_index`]) and the issues are
176    /// indexed once ([`IssueSnapshot::build`]): cycle, team, and assignee membership,
177    /// #183's completion answer, and every estimate parse and explicit conversion are
178    /// resolved there, so no calculation needs to read the store or re-parse an
179    /// estimate again (NFR-001). This is the entry point WP03's calculation service
180    /// reads.
181    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    /// [`Planner::capacity_snapshot_from`] over a store, read **once** here.
187    ///
188    /// The store read and the index build are the only I/O a capacity calculation
189    /// performs; everything a surface then asks is a lookup into the returned snapshot.
190    pub fn capacity_snapshot(&self, store: &Store) -> Result<IssueSnapshot> {
191        self.capacity_snapshot_from(&store.list()?)
192    }
193
194    /// The resolved milestones directory, relative to the repo root.
195    pub fn milestones_dir(&self) -> PathBuf {
196        self.root.join(self.config.milestones_dir())
197    }
198
199    /// Persist the current config snapshot to `tix.toml`.
200    fn save(&self) -> Result<()> {
201        self.config.save(&self.root.join("tix.toml"))
202    }
203
204    /// Look up a team by label.
205    pub fn team(&self, label: &str) -> Option<&Team> {
206        self.config.teams.get(label)
207    }
208
209    /// Look up a cycle by name.
210    ///
211    /// The implicit `Default` cycle answers even when it has no file, so a
212    /// fresh project can name it.
213    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    /// Cycle names in declaration order.
224    ///
225    /// The implicit `Default` cycle is not invented here: a project with no
226    /// store shows only what it has actually configured, and the legacy
227    /// `tix.toml` listing is preserved. [`CycleStore::list`] is where the
228    /// implicit default is surfaced (the cycle mode lists through it), and
229    /// [`Planner::cycle`] answers for the name regardless.
230    pub fn cycle_names(&self) -> Vec<String> {
231        self.config.cycles.keys().cloned().collect()
232    }
233
234    /// The cycle file store rooted at this planner's repo root.
235    fn cycle_store(&self) -> CycleStore {
236        CycleStore::new(&self.root)
237    }
238
239    /// Write this planner's cycle records to the file store.
240    ///
241    /// The store is used once it exists or once a cycle beyond the implicit
242    /// `Default` is present; until then cycles stay in `tix.toml` and no
243    /// directory is created. This is the one-time migration path: the first
244    /// real cycle writes every configured cycle to `docs/cycles/`.
245    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    /// Whether `cycle` is *running* on `today`: its `start_date` is present and
276    /// `<= today`, and its `due_date` is either absent (open-ended) or
277    /// `>= today`. Both bounds are inclusive, so a cycle runs on its first and
278    /// its last day. The implicit, date-less `Default` cycle has no start date
279    /// and therefore never runs.
280    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    /// The single cycle running on `today`, if *exactly one* is.
289    ///
290    /// `None` when no cycle runs — there is nothing to assign — and `None` when
291    /// two or more do, because an ambiguous date range must fall back to the
292    /// configured `[planning].default_cycle` rather than guess which one the
293    /// caller meant. See [`Planner::is_running`] for the running rule; the
294    /// implicit `Default` cycle is never a candidate.
295    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    /// Names of the cycles whose date window intersects `name`'s.
307    ///
308    /// A cycle's window is `[start_date, due_date]`, both bounds inclusive; an
309    /// absent `due_date` leaves the window open-ended, so it meets every later
310    /// window. Touching endpoints intersect (the shared day is in both). A cycle
311    /// with no `start_date` has no window: it never overlaps anything, and a
312    /// `name` that has no `start_date` reports no overlaps. `name` is never
313    /// reported against itself, and the result follows config declaration order.
314    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    /// [`Planner::rollover_plan`] over an **already-read** issue set (tix #1518).
377    ///
378    /// The TUI reads the tree once at startup and hands the same slice to the
379    /// board, the corpus and this plan, instead of walking the store again for
380    /// every expired cycle. The result is identical to [`Planner::rollover_plan`]:
381    /// the enumeration is the only thing that moved out of the loop.
382    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    /// The next auto cycle name. Skips forward past any existing `Cycle N`
454    /// keys and permanently bumps `[planning].cycle_number` one past the
455    /// returned name, per the seed's `cycle_number = 1` successor semantics.
456    /// The bump is persisted by the mutator that calls this.
457    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    /// Create a named or auto cycle. An explicit name must not collide with
470    /// the current auto-counter successor (`Cycle {cycle_number}`), a config
471    /// error, so hand-authored keys and generated ones never overlap.
472    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    /// Rename a cycle, moving its declaration order slot and rebasing
509    /// `default_cycle` when it pointed at the old name.
510    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    /// Add a member to a team, creating the team when absent. Duplicate
698    /// members and blank inputs are rejected.
699    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    /// All milestones, sorted by filename (which sorts by `target_date` then
723    /// slug — the registry is a directory listing, never a stored list).
724    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    /// Milestone names in listing order.
731    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    /// Look up a milestone by its frontmatter `name`.
740    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    /// Create a milestone registry file. The filename is
750    /// `{created}-{slug}.md` where `created` is today's date; an optional
751    /// `target_date` is stored in the frontmatter and must be a valid
752    /// `YYYY-MM-DD` calendar date when given (FR-007). The markdown body is
753    /// the optional description. Refuses to overwrite an existing file.
754    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    /// Rename a milestone file, keeping the `created` date prefix, bumping
786    /// `updated` and rebasing the frontmatter `name`; the body is preserved
787    /// verbatim (the cascade back into referencing issues is FR-009/IC-08
788    /// work that lives in the issue layer, not the registry).
789    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    /// The auto successor name the counter currently implies.
906    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    /// The delegation to `workflow::is_completed` is **load-bearing only on a
927    /// board that declares a `category_map`**, and that is asserted here rather
928    /// than assumed.
929    ///
930    /// Reverting `is_done_issue` to its previous body — comparing the status to
931    /// `done_column()` — left the whole suite **green**. The two rules agree on
932    /// every board without a `category_map`, which is every existing configuration,
933    /// so the delegation was unverified: a refactor that looked correct and proved
934    /// nothing. This fixture is the configuration where they disagree, reached
935    /// through the public `overdue_batch`, which filters on completion.
936    ///
937    /// The board: `shipped` is the `done_column` but maps to `active`, and
938    /// `archived` is what actually completes. Under the old rule an issue in
939    /// `shipped` was done and therefore not overdue; under the category rule it is
940    /// active work and therefore still overdue. `archived` is the mirror image.
941    #[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        // Absolute, not the config default: `Store` resolves `issues_dir` against
950        // the process working directory, so a relative path reads some other
951        // repository's issues and the batch comes back empty.
952        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        // `archived` is the only completing column, so load accepts the mapping.
980        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        // Sorted before comparing: `overdue_batch` does not promise an order, and
1008        // the claim here is about *membership*. The first version of this test
1009        // asserted a fixed order and failed on `[2, 1]` — the right two issues in
1010        // an order nothing specifies.
1011        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        // And the specific claim: `shipped` is the done_column, so the old
1020        // comparison would have dropped it from the batch.
1021        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        // Counter is 1 and "Cycle 1" exists as a hand-authored key → config error.
1076        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        // Filename prefix is the creation date, not target_date.
1127        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        // Defaults only: no directory, and the implicit `Default` cycle answers.
1235        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        // Absolute, not the config default: `Store` resolves `issues_dir`
1294        // against the process working directory.
1295        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        // Delete reports the references and leaves them in place.
1341        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        // The date-less implicit `Default` cycle has no start date, so it never
1373        // runs — here written the way `tix init` seeds it.
1374        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        // Start-only window: open-ended and therefore running.
1384        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        // A bounded window runs inside its box and not outside it.
1399        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        // Two overlapping windows → ambiguous → no automatic assignment.
1411        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        // B partially overlaps, C is contained in A, E touches A's last day, and
1454        // the open-ended Open has already started. D is disjoint and F has no
1455        // start date at all. Reported in declaration order.
1456        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        // A cycle with no start date has no window: it overlaps nothing, and a
1463        // start-less target reports nothing.
1464        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        // An open-ended target meets every later window, but not one that ended
1471        // before it began.
1472        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}