Skip to main content

tix/
planning.rs

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