1use std::collections::HashSet;
12use std::hash::{BuildHasher, Hash, Hasher};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use indexmap::IndexMap;
18use serde::ser::SerializeStruct;
19use serde::{Deserialize, Serialize, Serializer};
20
21use crate::board_store::BoardStore;
22use crate::capacity::config::CapacityConfig;
23use crate::cycle_store::{CycleStore, is_implicit_default};
24use crate::error::{Result, TixError};
25use crate::keybindings::{EffectiveBindings, KeyBindings};
26use crate::records::RecordStore;
27use crate::templates::registry::TemplateRegistry;
28
29#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(default, deny_unknown_fields)]
36pub struct RemoteConfig {
37 pub token: Option<String>,
40 pub base_url: Option<String>,
42 pub project: Option<String>,
44 pub team: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
50#[serde(default, deny_unknown_fields)]
51pub struct ExportConfig {
52 pub gitlab: Option<RemoteConfig>,
54 pub linear: Option<RemoteConfig>,
56}
57
58impl ExportConfig {
59 pub fn is_empty(&self) -> bool {
64 self.gitlab.is_none() && self.linear.is_none()
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
75#[serde(default, deny_unknown_fields)]
76pub struct Team {
77 pub members: Vec<String>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
85#[serde(default, deny_unknown_fields)]
86pub struct PlanningCycle {
87 pub description: Option<String>,
89 pub start_date: Option<String>,
91 pub due_date: Option<String>,
93}
94
95pub trait StableId: Sized {
108 const PREFIX: &'static str;
111 const LABEL: &'static str;
113 const ARTICLE: &'static str;
115 const MAX_LEN: usize = 128;
118
119 fn from_validated(raw: String) -> Self;
121
122 fn as_str(&self) -> &str;
124
125 fn new(raw: &str) -> Result<Self> {
133 if raw.trim().is_empty() {
134 return Err(TixError::Config(format!(
135 "{} {} must not be blank",
136 Self::ARTICLE,
137 Self::LABEL
138 )));
139 }
140 if raw.trim() != raw {
141 return Err(TixError::Config(format!(
142 "{} `{raw}` has leading or trailing whitespace",
143 Self::LABEL
144 )));
145 }
146 if raw.chars().count() > Self::MAX_LEN {
147 return Err(TixError::Config(format!(
148 "{} is {} characters; the limit is {}",
149 Self::LABEL,
150 raw.chars().count(),
151 Self::MAX_LEN
152 )));
153 }
154 if let Some(bad) = raw.chars().find(|c| is_structural_key_char(*c)) {
155 return Err(TixError::Config(format!(
156 "{} `{raw}` contains `{bad}`; use letters, digits, `-`, `_`, or `:`",
157 Self::LABEL
158 )));
159 }
160 Ok(Self::from_validated(raw.to_string()))
161 }
162
163 #[must_use]
177 fn generate() -> Self {
178 static COUNTER: AtomicU64 = AtomicU64::new(0);
179
180 let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
181 std::process::id().hash(&mut hasher);
182 COUNTER.fetch_add(1, Ordering::Relaxed).hash(&mut hasher);
183 SystemTime::now()
184 .duration_since(UNIX_EPOCH)
185 .map_or(0, |since| since.as_nanos())
186 .hash(&mut hasher);
187 let bits = hasher.finish();
188 Self::from_validated(format!("{}{bits:016x}", Self::PREFIX))
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
204#[serde(try_from = "String", into = "String")]
205pub struct ProjectId(String);
206
207impl StableId for ProjectId {
208 const PREFIX: &'static str = "p-";
209 const LABEL: &'static str = "project id";
210 const ARTICLE: &'static str = "a";
211
212 fn from_validated(raw: String) -> Self {
213 Self(raw)
214 }
215
216 fn as_str(&self) -> &str {
217 &self.0
218 }
219}
220
221impl ProjectId {
222 pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
224
225 pub fn new(raw: &str) -> Result<Self> {
227 <Self as StableId>::new(raw)
228 }
229
230 #[must_use]
233 pub fn generate() -> Self {
234 <Self as StableId>::generate()
235 }
236
237 #[must_use]
239 pub fn as_str(&self) -> &str {
240 <Self as StableId>::as_str(self)
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
253#[serde(try_from = "String", into = "String")]
254pub struct EpicId(String);
255
256impl StableId for EpicId {
257 const PREFIX: &'static str = "e-";
258 const LABEL: &'static str = "epic id";
259 const ARTICLE: &'static str = "an";
260
261 fn from_validated(raw: String) -> Self {
262 Self(raw)
263 }
264
265 fn as_str(&self) -> &str {
266 &self.0
267 }
268}
269
270impl EpicId {
271 pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
273
274 pub fn new(raw: &str) -> Result<Self> {
276 <Self as StableId>::new(raw)
277 }
278
279 #[must_use]
282 pub fn generate() -> Self {
283 <Self as StableId>::generate()
284 }
285
286 #[must_use]
288 pub fn as_str(&self) -> &str {
289 <Self as StableId>::as_str(self)
290 }
291}
292
293impl std::fmt::Display for ProjectId {
294 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295 f.write_str(&self.0)
296 }
297}
298
299impl TryFrom<String> for ProjectId {
300 type Error = TixError;
301
302 fn try_from(raw: String) -> Result<Self> {
303 Self::new(&raw)
304 }
305}
306
307impl From<ProjectId> for String {
308 fn from(id: ProjectId) -> Self {
309 id.0
310 }
311}
312
313impl std::fmt::Display for EpicId {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 f.write_str(&self.0)
316 }
317}
318
319impl TryFrom<String> for EpicId {
320 type Error = TixError;
321
322 fn try_from(raw: String) -> Result<Self> {
323 Self::new(&raw)
324 }
325}
326
327impl From<EpicId> for String {
328 fn from(id: EpicId) -> Self {
329 id.0
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
342#[serde(try_from = "String", into = "String")]
343pub struct InitiativeId(String);
344
345impl StableId for InitiativeId {
346 const PREFIX: &'static str = "i-";
347 const LABEL: &'static str = "initiative id";
348 const ARTICLE: &'static str = "an";
349
350 fn from_validated(raw: String) -> Self {
351 Self(raw)
352 }
353
354 fn as_str(&self) -> &str {
355 &self.0
356 }
357}
358
359impl InitiativeId {
360 pub const MAX_LEN: usize = <Self as StableId>::MAX_LEN;
362
363 pub fn new(raw: &str) -> Result<Self> {
365 <Self as StableId>::new(raw)
366 }
367
368 #[must_use]
371 pub fn generate() -> Self {
372 <Self as StableId>::generate()
373 }
374
375 #[must_use]
377 pub fn as_str(&self) -> &str {
378 <Self as StableId>::as_str(self)
379 }
380}
381
382impl std::fmt::Display for InitiativeId {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 f.write_str(&self.0)
385 }
386}
387
388impl TryFrom<String> for InitiativeId {
389 type Error = TixError;
390
391 fn try_from(raw: String) -> Result<Self> {
392 Self::new(&raw)
393 }
394}
395
396impl From<InitiativeId> for String {
397 fn from(id: InitiativeId) -> Self {
398 id.0
399 }
400}
401
402fn is_structural_key_char(c: char) -> bool {
405 c.is_whitespace()
406 || matches!(c, '.' | '"' | '\'' | '[' | ']' | '#' | '=' | '\\')
407 || c.is_control()
408}
409
410#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
423#[serde(default, deny_unknown_fields)]
424pub struct ProjectConfiguration {
425 pub name: String,
428 pub description: Option<String>,
430 pub status: Option<String>,
432 pub lead: Option<String>,
434 pub start_date: Option<String>,
436 pub due_date: Option<String>,
438}
439
440#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
471#[serde(default, deny_unknown_fields)]
472pub struct EpicConfiguration {
473 pub name: String,
476 pub description: Option<String>,
478 pub status: Option<String>,
480 pub owner: Option<String>,
482 pub start_date: Option<String>,
484 pub due_date: Option<String>,
486 pub weight: Option<i64>,
488 pub parent: Option<String>,
490 #[serde(default, skip_serializing_if = "Vec::is_empty")]
492 pub projects: Vec<String>,
493 pub milestone: Option<String>,
495 pub cycle: Option<String>,
497}
498
499#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
535#[serde(default)]
536pub struct InitiativeConfiguration {
537 pub name: String,
540 #[serde(skip_serializing_if = "Option::is_none")]
542 pub outcome: Option<String>,
543 #[serde(skip_serializing_if = "Option::is_none")]
545 pub owner: Option<String>,
546 #[serde(skip_serializing_if = "Option::is_none")]
548 pub status: Option<String>,
549 #[serde(skip_serializing_if = "Option::is_none")]
551 pub priority: Option<crate::model::Priority>,
552 #[serde(default, skip_serializing_if = "Vec::is_empty")]
554 pub labels: Vec<String>,
555 #[serde(skip_serializing_if = "Option::is_none")]
557 pub target_date: Option<String>,
558 #[serde(default, skip_serializing_if = "Vec::is_empty")]
560 pub projects: Vec<String>,
561 #[serde(default, skip_serializing_if = "Vec::is_empty")]
563 pub epics: Vec<String>,
564 #[serde(default, skip_serializing_if = "Vec::is_empty")]
566 pub milestones: Vec<String>,
567 #[serde(flatten)]
569 pub extensions: crate::roadmap::registry::InitiativeExtensions,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580#[serde(default, deny_unknown_fields)]
581pub struct PlanningConfig {
582 pub cycle_number: u32,
584 pub default_cycle: Option<String>,
586 pub default_milestone: Option<String>,
588 pub default_team: Option<String>,
590}
591
592impl PlanningConfig {
593 pub fn is_default(&self) -> bool {
597 self == &Self::default()
598 }
599}
600
601impl Default for PlanningConfig {
602 fn default() -> Self {
603 Self {
604 cycle_number: 1,
605 default_cycle: None,
606 default_milestone: None,
607 default_team: None,
608 }
609 }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
632#[serde(default, deny_unknown_fields)]
633pub struct IssueTemplate {
634 pub title: Option<String>,
637 pub body: Option<String>,
639 pub labels: Vec<String>,
641 pub status: Option<String>,
643 pub priority: Option<String>,
645 pub assignee: Option<String>,
647 pub estimate: Option<String>,
649 pub reviewer: Option<String>,
651 pub cycles: Vec<String>,
653 pub milestones: Vec<String>,
655 pub start_date: Option<String>,
657 pub due_date: Option<String>,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
668#[serde(default, deny_unknown_fields)]
669pub struct StateConfig {
670 #[serde(skip_serializing)]
674 pub next_number: u32,
675}
676
677#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
682pub enum OrderBy {
683 #[default]
685 #[serde(rename = "created_at_desc")]
686 CreatedAtDesc,
687 #[serde(rename = "created_at_asc")]
689 CreatedAtAsc,
690 #[serde(rename = "none")]
692 None,
693}
694
695impl OrderBy {
696 pub fn as_str(self) -> &'static str {
698 match self {
699 Self::CreatedAtDesc => "created_at_desc",
700 Self::CreatedAtAsc => "created_at_asc",
701 Self::None => "none",
702 }
703 }
704
705 pub fn next(self) -> Self {
707 match self {
708 Self::CreatedAtDesc => Self::CreatedAtAsc,
709 Self::CreatedAtAsc => Self::None,
710 Self::None => Self::CreatedAtDesc,
711 }
712 }
713}
714
715pub const CATEGORY_KEYS: &[&str] = &[
726 "backlog",
727 "unstarted",
728 "active",
729 "blocked",
730 "completed",
731 "canceled",
732];
733
734pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
736
737pub const COMPLETE_CATEGORY: &str = "completed";
740
741pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
748
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
754#[serde(default, deny_unknown_fields)]
755pub struct CategoryConfig {
756 pub order: Vec<String>,
763 pub default: String,
766}
767
768impl Default for CategoryConfig {
769 fn default() -> Self {
770 Self {
771 order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
772 default: SAFE_DEFAULT_CATEGORY.to_string(),
773 }
774 }
775}
776
777impl CategoryConfig {
778 pub fn is_default(&self) -> bool {
780 self == &Self::default()
781 }
782
783 pub fn contains(&self, key: &str) -> bool {
785 self.order.iter().any(|k| k == key)
786 }
787}
788
789#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(default, deny_unknown_fields)]
792pub struct TransitionConfig {
793 pub terminal_locked: bool,
799}
800
801impl Default for TransitionConfig {
802 fn default() -> Self {
803 Self {
804 terminal_locked: true,
805 }
806 }
807}
808
809impl TransitionConfig {
810 pub fn is_default(&self) -> bool {
811 self == &Self::default()
812 }
813}
814
815const DEFAULT_TITLE_LINES: usize = 3;
819
820const DEFAULT_DONE_COLUMN: &str = "done";
827
828#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
841#[serde(default, deny_unknown_fields)]
842pub struct HealthConfig {
843 #[serde(default, skip_serializing_if = "Option::is_none")]
845 pub policy: Option<String>,
846}
847
848impl HealthConfig {
849 pub fn is_empty(&self) -> bool {
851 self.policy.is_none()
852 }
853
854 pub fn policy(&self) -> std::result::Result<crate::health::HealthPolicy, TixError> {
856 match self.policy.as_deref() {
857 Some(version) => crate::health::HealthPolicy::for_version(version)
858 .map_err(|error| TixError::Config(error.render())),
859 None => Ok(crate::health::HealthPolicy::v1()),
860 }
861 }
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
874#[serde(default, deny_unknown_fields)]
875pub struct BoardConfiguration {
876 pub columns: Vec<String>,
878 pub done_column: Option<String>,
887 pub category_map: IndexMap<String, String>,
896 pub hidden_columns: Vec<String>,
899 pub show_mismatch: bool,
902 pub wrap_titles: bool,
904 pub title_lines: usize,
908 pub card_order: Vec<String>,
912 pub order_by: OrderBy,
915 pub wip: crate::wip::WipConfig,
921}
922
923impl Default for BoardConfiguration {
924 fn default() -> Self {
925 Self {
926 columns: ["todo", "in progress", "in review", "done"]
927 .into_iter()
928 .map(String::from)
929 .collect(),
930 done_column: Some(DEFAULT_DONE_COLUMN.to_string()),
931 category_map: IndexMap::new(),
932 hidden_columns: Vec::new(),
933 show_mismatch: false,
934 wrap_titles: true,
935 title_lines: DEFAULT_TITLE_LINES,
936 card_order: Vec::new(),
937 order_by: OrderBy::default(),
938 wip: crate::wip::WipConfig::default(),
939 }
940 }
941}
942
943impl Serialize for BoardConfiguration {
944 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
957 where
958 S: Serializer,
959 {
960 let done = self
961 .done_column
962 .as_deref()
963 .filter(|done| *done != DEFAULT_DONE_COLUMN);
964 let order_by = (self.order_by != OrderBy::default()).then_some(self.order_by);
965
966 let mut len = 1; len += usize::from(done.is_some());
968 len += usize::from(!self.category_map.is_empty());
969 len += usize::from(!self.hidden_columns.is_empty());
970 len += usize::from(self.show_mismatch);
971 len += usize::from(!self.wrap_titles);
972 len += usize::from(self.title_lines != DEFAULT_TITLE_LINES);
973 len += usize::from(!self.card_order.is_empty());
974 len += usize::from(order_by.is_some());
975 len += usize::from(!self.wip.is_empty());
976
977 let mut board = serializer.serialize_struct("BoardConfiguration", len)?;
978 board.serialize_field("columns", &self.columns)?;
979 if let Some(done) = done {
980 board.serialize_field("done_column", done)?;
981 }
982 if !self.category_map.is_empty() {
983 board.serialize_field("category_map", &self.category_map)?;
984 }
985 if !self.hidden_columns.is_empty() {
986 board.serialize_field("hidden_columns", &self.hidden_columns)?;
987 }
988 if self.show_mismatch {
989 board.serialize_field("show_mismatch", &self.show_mismatch)?;
990 }
991 if !self.wrap_titles {
992 board.serialize_field("wrap_titles", &self.wrap_titles)?;
993 }
994 if self.title_lines != DEFAULT_TITLE_LINES {
995 board.serialize_field("title_lines", &self.title_lines)?;
996 }
997 if !self.card_order.is_empty() {
998 board.serialize_field("card_order", &self.card_order)?;
999 }
1000 if let Some(order_by) = order_by {
1001 board.serialize_field("order_by", &order_by)?;
1002 }
1003 if !self.wip.is_empty() {
1004 board.serialize_field("wip", &self.wip)?;
1005 }
1006 board.end()
1007 }
1008}
1009
1010pub fn starter_board() -> BoardConfiguration {
1027 BoardConfiguration {
1028 columns: [
1029 "triage",
1030 "todo",
1031 "in progress",
1032 "in review",
1033 "done",
1034 "wontfix",
1035 ]
1036 .into_iter()
1037 .map(String::from)
1038 .collect(),
1039 category_map: IndexMap::from([
1040 ("done".to_string(), "completed".to_string()),
1041 ("wontfix".to_string(), "canceled".to_string()),
1042 ]),
1043 ..BoardConfiguration::default()
1044 }
1045}
1046
1047impl BoardConfiguration {
1048 pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
1068 if let Some(mapped) = self.category_map.get(column) {
1069 return mapped;
1070 }
1071 if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
1072 return COMPLETE_CATEGORY;
1073 }
1074 &categories.default
1075 }
1076
1077 pub fn effective_done_column(&self) -> Option<&str> {
1080 self.done_column
1081 .as_deref()
1082 .or_else(|| self.inferred_done_column())
1083 }
1084
1085 fn inferred_done_column(&self) -> Option<&str> {
1093 if self.columns.iter().any(|c| c == DEFAULT_DONE_COLUMN) {
1094 Some(DEFAULT_DONE_COLUMN)
1095 } else {
1096 self.columns.last().map(String::as_str)
1097 }
1098 }
1099}
1100
1101#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1112#[serde(default, transparent)]
1113pub struct KanbanSection {
1114 pub configurations: IndexMap<String, BoardConfiguration>,
1116}
1117
1118impl KanbanSection {
1119 pub fn is_empty(&self) -> bool {
1123 self.configurations.is_empty()
1124 }
1125}
1126
1127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1135#[serde(default, deny_unknown_fields)]
1136pub struct Config {
1137 #[serde(default, skip_serializing_if = "KanbanSection::is_empty")]
1139 pub kanban: KanbanSection,
1140 pub issues_dir: PathBuf,
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1145 pub milestones_dir: Option<PathBuf>,
1146 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1148 pub teams: IndexMap<String, Team>,
1149 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1151 pub cycles: IndexMap<String, PlanningCycle>,
1152 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1157 pub templates: IndexMap<String, IssueTemplate>,
1158 #[serde(
1167 default,
1168 skip_serializing_if = "crate::views::registry::ViewRegistry::is_empty"
1169 )]
1170 pub views: crate::views::registry::ViewRegistry,
1171 #[serde(default, skip_serializing_if = "HealthConfig::is_empty")]
1173 pub health: HealthConfig,
1174 #[serde(default, skip_serializing_if = "TemplateRegistry::is_empty")]
1186 pub issue_templates: TemplateRegistry,
1187 #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
1189 pub planning: PlanningConfig,
1190 #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
1193 pub categories: CategoryConfig,
1194 #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
1196 pub transitions: TransitionConfig,
1197 #[serde(default, skip_serializing_if = "Option::is_none")]
1205 pub triage: Option<crate::triage::TriageConfig>,
1206 pub editor: Option<String>,
1208 #[serde(skip_serializing_if = "Option::is_none")]
1215 pub viewer: Option<String>,
1216 pub agent: Option<String>,
1220 #[serde(skip_serializing)]
1223 pub state: StateConfig,
1224 #[serde(default, skip_serializing_if = "ExportConfig::is_empty")]
1227 pub export: ExportConfig,
1228 #[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
1230 pub keybindings: KeyBindings,
1231 #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
1242 pub custom_attributes: CustomAttributes,
1243 #[serde(default = "default_true", skip_serializing_if = "is_true")]
1249 pub lint_foreign_repositories: bool,
1250 #[serde(default = "default_true", skip_serializing_if = "is_true")]
1259 pub tips_on_startup: bool,
1260 #[serde(default, skip_serializing_if = "MentionsConfig::is_default")]
1265 pub mentions: MentionsConfig,
1266 #[serde(default, skip_serializing_if = "LayoutConfig::is_default")]
1268 pub layout: LayoutConfig,
1269 #[serde(
1277 default,
1278 skip_serializing_if = "Option::is_none",
1279 alias = "custom_types"
1280 )]
1281 pub types: Option<Vec<String>>,
1282 #[serde(default, skip_serializing_if = "Option::is_none")]
1295 pub labels: Option<Vec<String>>,
1296 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1302 pub type_colors: IndexMap<String, String>,
1303 #[serde(default, skip_serializing_if = "Option::is_none")]
1309 pub tags_dir: Option<PathBuf>,
1310 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1318 pub repos: IndexMap<String, PathBuf>,
1319 #[serde(default, skip_serializing_if = "Option::is_none")]
1326 pub parent_repo: Option<PathBuf>,
1327 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1337 pub projects: IndexMap<String, ProjectConfiguration>,
1338 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1350 pub epics: IndexMap<String, EpicConfiguration>,
1351 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1366 pub initiatives: IndexMap<String, InitiativeConfiguration>,
1367 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
1385 pub cycle_schedules:
1386 IndexMap<String, crate::cycle_schedule::config::CycleScheduleConfiguration>,
1387 #[serde(default, skip_serializing_if = "CapacityConfig::is_empty")]
1404 pub capacity: CapacityConfig,
1405}
1406
1407#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1419#[serde(default, deny_unknown_fields)]
1420pub struct CustomAttributes {
1421 pub keys: Vec<String>,
1425}
1426
1427impl CustomAttributes {
1428 pub fn is_empty(&self) -> bool {
1430 self.keys.is_empty()
1431 }
1432}
1433
1434pub(crate) fn default_true() -> bool {
1439 true
1440}
1441#[allow(clippy::trivially_copy_pass_by_ref)] pub(crate) fn is_true(value: &bool) -> bool {
1445 *value
1446}
1447
1448pub const DEFAULT_TYPES: &[&str] = &[
1455 "default",
1456 "bug",
1457 "feature",
1458 "task",
1459 "improvement",
1460 "documentation",
1461 "question",
1462 "duplicate",
1463 "chore",
1464 "open",
1465];
1466
1467pub const BUILTIN_LABELS: &[&str] = &["decision-pending"];
1475
1476pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
1483 ("default", "gray"),
1484 ("bug", "red"),
1485 ("feature", "green"),
1486 ("task", "blue"),
1487 ("improvement", "cyan"),
1488 ("documentation", "yellow"),
1489 ("question", "magenta"),
1490 ("duplicate", "dark_gray"),
1491 ("chore", "dark_gray"),
1492 ("open", "white"),
1493];
1494
1495#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1506#[serde(default, deny_unknown_fields)]
1507pub struct MentionsConfig {
1508 pub enabled: bool,
1510 pub dir: PathBuf,
1513}
1514
1515pub const DEFAULT_MENTIONS_DIR: &str = "docs/mentions";
1517
1518impl Default for MentionsConfig {
1519 fn default() -> Self {
1520 MentionsConfig {
1521 enabled: true,
1522 dir: PathBuf::from(DEFAULT_MENTIONS_DIR),
1523 }
1524 }
1525}
1526
1527impl MentionsConfig {
1528 #[must_use]
1530 pub fn is_default(&self) -> bool {
1531 *self == Self::default()
1532 }
1533
1534 #[must_use]
1540 pub fn resolve_dir(&self, issues_dir: &Path) -> PathBuf {
1541 if self.dir.is_absolute() {
1542 return self.dir.clone();
1543 }
1544 match Config::project_root(issues_dir) {
1545 Some(root) => root.join(&self.dir),
1546 None => self.dir.clone(),
1547 }
1548 }
1549}
1550
1551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1558#[serde(default, deny_unknown_fields)]
1559pub struct LayoutConfig {
1560 pub directory: String,
1562 pub filename: String,
1564 pub move_on_change: bool,
1566}
1567
1568impl Default for LayoutConfig {
1569 fn default() -> Self {
1570 LayoutConfig {
1571 directory: "{assignee}/{type}/{status}".to_string(),
1572 filename: "{date}-{id}-{slug}.md".to_string(),
1578 move_on_change: true,
1579 }
1580 }
1581}
1582
1583impl LayoutConfig {
1584 #[must_use]
1586 pub fn is_default(&self) -> bool {
1587 *self == Self::default()
1588 }
1589}
1590
1591impl Default for Config {
1592 fn default() -> Self {
1593 Config {
1594 kanban: KanbanSection {
1595 configurations: IndexMap::from([(
1596 "default".to_string(),
1597 BoardConfiguration::default(),
1598 )]),
1599 },
1600 issues_dir: PathBuf::from("docs/issues"),
1601 milestones_dir: None,
1602 teams: IndexMap::new(),
1603 cycles: IndexMap::new(),
1604 templates: IndexMap::new(),
1605 views: crate::views::registry::ViewRegistry::new(),
1606 health: HealthConfig::default(),
1607 issue_templates: TemplateRegistry::default(),
1608 planning: PlanningConfig::default(),
1609 categories: CategoryConfig::default(),
1610 transitions: TransitionConfig::default(),
1611 triage: None,
1618 editor: None,
1619 viewer: None,
1620 state: StateConfig::default(),
1621 export: ExportConfig::default(),
1622 agent: None,
1623 keybindings: KeyBindings::default(),
1624 custom_attributes: CustomAttributes::default(),
1625 lint_foreign_repositories: true,
1626 tips_on_startup: true,
1627 mentions: MentionsConfig::default(),
1628 layout: LayoutConfig::default(),
1629 types: None,
1630 labels: None,
1631 type_colors: IndexMap::new(),
1632 tags_dir: None,
1633 repos: IndexMap::new(),
1634 parent_repo: None,
1635 projects: IndexMap::new(),
1636 epics: IndexMap::new(),
1637 initiatives: IndexMap::new(),
1638 cycle_schedules: IndexMap::new(),
1639 capacity: CapacityConfig::new(),
1640 }
1641 }
1642}
1643
1644impl Config {
1645 #[must_use]
1659 pub fn starter() -> Config {
1660 let mut config = Config::default();
1661 config
1662 .kanban
1663 .configurations
1664 .insert("default".to_string(), starter_board());
1665 config.triage = Some(crate::triage::TriageConfig {
1666 entry_status: Some("triage".to_string()),
1667 accepted_status: Some("todo".to_string()),
1668 declined_status: Some("wontfix".to_string()),
1669 deferred_status: None,
1670 });
1671 config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
1675 config.tags_dir = Some(PathBuf::from("docs/tags"));
1682 config
1683 }
1684
1685 pub(crate) fn validate(&self) -> Result<()> {
1689 if self.issues_dir.as_os_str().is_empty() {
1690 return Err(TixError::Config("`issues_dir` must not be blank".into()));
1691 }
1692 if self.milestones_dir().as_os_str().is_empty() {
1693 return Err(TixError::Config(
1694 "`milestones_dir` must not be blank".into(),
1695 ));
1696 }
1697 if self.tags_dir().as_os_str().is_empty() {
1698 return Err(TixError::Config("`tags_dir` must not be blank".into()));
1699 }
1700 validate_repos(&self.repos)?;
1701 self.health.policy()?;
1706 if self
1707 .parent_repo
1708 .as_deref()
1709 .is_some_and(|p| p.as_os_str().is_empty())
1710 {
1711 return Err(TixError::Config("`parent_repo` must not be blank".into()));
1712 }
1713 if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
1718 return Err(TixError::Config(
1719 "`viewer` must not be blank; use `auto` for the default chain".into(),
1720 ));
1721 }
1722 validate_planning(&self.planning)?;
1723 crate::layout::validate(&self.layout)?;
1724 validate_categories(&self.categories)?;
1725 self.effective_keybindings()?;
1726 if self.kanban.configurations.is_empty() {
1727 return Err(TixError::Config(
1728 "`[kanban]` must define at least one configuration".into(),
1729 ));
1730 }
1731 for (name, board) in &self.kanban.configurations {
1732 validate_board(name, board)?;
1733 validate_category_map(name, board, &self.categories)?;
1734 }
1735 for (label, team) in &self.teams {
1736 validate_team(label, team)?;
1737 }
1738 for (name, cycle) in &self.cycles {
1739 validate_cycle(name, cycle)?;
1740 }
1741 for (name, template) in &self.templates {
1742 validate_template(name, template)?;
1743 }
1744 validate_projects(&self.projects)?;
1750 validate_epics(&self.epics)?;
1759 validate_initiatives(&self.initiatives)?;
1768 crate::cycle_schedule::config::validate_schedules(&self.cycle_schedules)?;
1769 crate::capacity::config::validate(&self.capacity, self)?;
1779 self.views
1787 .check_structure()
1788 .map_err(|refusal| TixError::Config(refusal.render()))?;
1789 self.issue_templates.validate(self)?;
1792 crate::triage::config::validate(self)?;
1798 Ok(())
1799 }
1800
1801 pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
1803 KeyBindings::resolve(&self.keybindings)
1804 }
1805
1806 #[must_use]
1815 pub fn allocate_project_id(&self) -> ProjectId {
1816 self.allocate_project_id_with(ProjectId::generate)
1817 }
1818
1819 fn allocate_project_id_with(&self, draw: impl FnMut() -> ProjectId) -> ProjectId {
1823 allocate_stable_id(&self.projects, draw)
1824 }
1825
1826 #[must_use]
1834 pub fn allocate_epic_id(&self) -> EpicId {
1835 self.allocate_epic_id_with(EpicId::generate)
1836 }
1837
1838 fn allocate_epic_id_with(&self, draw: impl FnMut() -> EpicId) -> EpicId {
1841 allocate_stable_id(&self.epics, draw)
1842 }
1843
1844 #[must_use]
1852 pub fn allocate_initiative_id(&self) -> InitiativeId {
1853 self.allocate_initiative_id_with(InitiativeId::generate)
1854 }
1855
1856 fn allocate_initiative_id_with(&self, draw: impl FnMut() -> InitiativeId) -> InitiativeId {
1859 allocate_stable_id(&self.initiatives, draw)
1860 }
1861
1862 #[must_use]
1870 pub fn allocate_cycle_schedule_id(&self) -> crate::cycle_schedule::config::CycleScheduleId {
1871 self.allocate_cycle_schedule_id_with(
1872 crate::cycle_schedule::config::CycleScheduleId::generate,
1873 )
1874 }
1875
1876 fn allocate_cycle_schedule_id_with(
1880 &self,
1881 draw: impl FnMut() -> crate::cycle_schedule::config::CycleScheduleId,
1882 ) -> crate::cycle_schedule::config::CycleScheduleId {
1883 allocate_stable_id(&self.cycle_schedules, draw)
1884 }
1885
1886 fn restore_declaration_order(&mut self, contents: &str) {
1890 crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
1891 crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
1892 crate::config::restore_section_order(&mut self.teams, contents, "teams");
1893 crate::config::restore_section_order(&mut self.templates, contents, "templates");
1894 self.views.restore_declaration_order(contents);
1895 crate::config::restore_section_order(
1896 &mut self.issue_templates.templates,
1897 contents,
1898 "issue_templates",
1899 );
1900 crate::config::restore_section_order(&mut self.projects, contents, "projects");
1901 crate::config::restore_section_order(&mut self.epics, contents, "epics");
1902 crate::config::restore_section_order(&mut self.initiatives, contents, "initiatives");
1903 crate::config::restore_section_order(
1904 &mut self.cycle_schedules,
1905 contents,
1906 "cycle_schedules",
1907 );
1908 self.capacity.restore_declaration_order(contents);
1909 }
1910
1911 pub fn custom_attributes(&self) -> Vec<String> {
1917 self.custom_attributes
1918 .keys
1919 .iter()
1920 .map(|k| k.trim().to_ascii_lowercase())
1921 .collect()
1922 }
1923
1924 #[must_use]
1927 pub fn types(&self) -> Vec<String> {
1928 self.types
1929 .clone()
1930 .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
1931 }
1932
1933 #[must_use]
1936 pub fn custom_types(&self) -> Vec<String> {
1937 self.types()
1938 }
1939
1940 #[must_use]
1947 pub fn labels(&self) -> Vec<String> {
1948 let mut out: Vec<String> = BUILTIN_LABELS.iter().map(|s| (*s).to_string()).collect();
1949 if let Some(extra) = &self.labels {
1950 for label in extra {
1951 if !out.contains(label) {
1952 out.push(label.clone());
1953 }
1954 }
1955 }
1956 out
1957 }
1958
1959 #[must_use]
1963 pub fn type_color(&self, type_name: &str) -> Option<String> {
1964 let needle = type_name.trim().to_ascii_lowercase();
1965 self.type_colors
1966 .iter()
1967 .find(|(name, _)| name.to_ascii_lowercase() == needle)
1968 .map(|(_, color)| color.clone())
1969 .or_else(|| {
1970 DEFAULT_TYPE_COLORS
1971 .iter()
1972 .find(|(name, _)| *name == needle)
1973 .map(|(_, color)| (*color).to_string())
1974 })
1975 }
1976
1977 #[must_use]
1981 pub fn attributes(&self) -> Vec<String> {
1982 if self.custom_attributes.keys.is_empty() {
1983 crate::model::KNOWN_FRONTMATTER_KEYS
1984 .iter()
1985 .filter(|key| **key != "links")
1986 .map(|key| (*key).to_string())
1987 .collect()
1988 } else {
1989 self.custom_attributes.keys.clone()
1990 }
1991 }
1992
1993 pub fn milestones_dir(&self) -> &Path {
1996 self.milestones_dir
1997 .as_deref()
1998 .unwrap_or(Path::new("docs/milestones"))
1999 }
2000
2001 pub fn tags_dir(&self) -> &Path {
2004 self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
2005 }
2006
2007 pub fn repos(&self) -> &IndexMap<String, PathBuf> {
2009 &self.repos
2010 }
2011
2012 pub fn parent_repo(&self) -> Option<&Path> {
2014 self.parent_repo.as_deref()
2015 }
2016
2017 pub fn resolve_repo_paths(&mut self, base: &Path) {
2027 if let Some(path) = self.tags_dir.as_ref() {
2028 if path.is_relative() {
2029 self.tags_dir = Some(base.join(path));
2030 }
2031 }
2032 for path in self.repos.values_mut() {
2033 if path.is_relative() {
2034 *path = base.join(&*path);
2035 }
2036 }
2037 if let Some(path) = self.parent_repo.clone() {
2038 if path.is_relative() {
2039 self.parent_repo = Some(base.join(path));
2040 }
2041 }
2042 }
2043
2044 pub fn default_config(&self) -> &BoardConfiguration {
2047 self.kanban
2048 .configurations
2049 .get("default")
2050 .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
2051 .expect("validate() guarantees at least one configuration")
2052 }
2053
2054 pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
2057 self.kanban
2058 .configurations
2059 .iter()
2060 .map(|(name, board)| (name.as_str(), board))
2061 }
2062
2063 pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
2069 let contents = std::fs::read_to_string(path)
2070 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2071 let mut config: Config = toml::from_str(&contents)
2072 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2073 config.restore_declaration_order(&contents);
2074 let root = path
2078 .parent()
2079 .filter(|p| !p.as_os_str().is_empty())
2080 .unwrap_or_else(|| Path::new("."));
2081 let board_store = BoardStore::new(root);
2082 if board_store.exists() {
2083 let boards = board_store
2084 .list()
2085 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2086 if !boards.is_empty() {
2087 let declared = std::mem::take(&mut config.kanban.configurations);
2097 config.kanban = KanbanSection {
2098 configurations: boards
2099 .into_iter()
2100 .map(|(name, mut board)| {
2101 if let Some(from_toml) = declared.get(&name) {
2102 board.wip = from_toml.wip.clone();
2103 }
2104 (name, board)
2105 })
2106 .collect(),
2107 };
2108 }
2109 }
2110 let cycle_store = CycleStore::new(root);
2111 if cycle_store.exists() {
2112 let cycles = cycle_store
2113 .list()
2114 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2115 config.cycles = cycles
2116 .into_iter()
2117 .map(|record| (record.name.clone(), record.into()))
2118 .collect();
2119 }
2120 if config.kanban.configurations.is_empty() {
2121 config.kanban = KanbanSection {
2122 configurations: IndexMap::from([(
2123 "default".to_string(),
2124 BoardConfiguration::default(),
2125 )]),
2126 };
2127 }
2128 config.validate().map_err(|e| at_file(path, e))?;
2129 crate::capacity::config::validate_cycle_references(&config.capacity, &config, root)
2135 .map_err(|e| at_file(path, e))?;
2136 for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
2137 .into_iter()
2138 .flatten()
2139 {
2140 if remote.token.is_some() {
2141 sink(
2142 "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
2143 );
2144 }
2145 }
2146 Ok(config)
2147 }
2148
2149 pub fn load(project_root: &Path) -> Result<Config> {
2153 let path = project_root.join("tix.toml");
2154 if !path.is_file() {
2155 return Ok(Config::default());
2156 }
2157 Self::load_with_warning(&path, |w| eprintln!("{w}"))
2158 }
2159
2160 pub fn project_root(cwd: &Path) -> Option<PathBuf> {
2163 let mut dir = Some(cwd.to_path_buf());
2164 while let Some(current) = dir {
2165 if current.join("tix.toml").is_file() {
2166 return Some(current);
2167 }
2168 dir = current.parent().map(Path::to_path_buf);
2169 }
2170 None
2171 }
2172
2173 fn sanitized_for_disk(&self) -> Config {
2175 let mut copy = self.clone();
2176 if let Some(gitlab) = copy.export.gitlab.as_mut() {
2177 gitlab.token = None;
2178 }
2179 if let Some(linear) = copy.export.linear.as_mut() {
2180 linear.token = None;
2181 }
2182 copy
2183 }
2184
2185 pub fn save(&self, path: &Path) -> Result<()> {
2191 let dir = match path.parent() {
2192 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
2193 _ => PathBuf::from("."),
2194 };
2195 std::fs::create_dir_all(&dir)?;
2196
2197 let board_store = BoardStore::new(&dir);
2201 let board_store_active = board_store.exists();
2202 if board_store_active {
2203 for (name, board) in &self.kanban.configurations {
2204 let filename = format!("{}.md", crate::slug::slugify(name));
2205 let body = RecordStore::new(board_store.dir()).body_of(&filename);
2206 board_store.save(name, board, body.as_deref())?;
2207 }
2208 }
2209 let cycle_store = CycleStore::new(&dir);
2210 let cycle_store_active = cycle_store.exists();
2211 if cycle_store_active {
2212 for (name, cycle) in &self.cycles {
2213 if is_implicit_default(name, cycle) {
2214 continue;
2215 }
2216 let filename = format!("{}.md", crate::slug::slugify(name));
2217 let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
2218 cycle_store.save(name, cycle, body.as_deref())?;
2219 }
2220 }
2221
2222 let mut disk = self.sanitized_for_disk();
2223 if board_store_active {
2224 disk.kanban = KanbanSection::default();
2225 }
2226 if cycle_store_active {
2227 disk.cycles.clear();
2228 }
2229 let contents = toml::to_string_pretty(&disk)
2230 .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
2231
2232 let file_name = path
2233 .file_name()
2234 .and_then(|n| n.to_str())
2235 .unwrap_or("tix.toml");
2236 let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
2237
2238 let write_result = (|| -> std::io::Result<()> {
2239 let mut f = std::fs::File::create(&tmp)?;
2240 use std::io::Write;
2241 f.write_all(contents.as_bytes())?;
2242 f.sync_all()?;
2243 Ok(())
2244 })();
2245 if let Err(e) = write_result {
2246 let _ = std::fs::remove_file(&tmp);
2247 return Err(TixError::Io(e));
2248 }
2249
2250 if let Err(e) = std::fs::rename(&tmp, path) {
2251 let _ = std::fs::remove_file(&tmp);
2252 return Err(TixError::Io(e));
2253 }
2254 Ok(())
2255 }
2256}
2257
2258fn at_file(path: &Path, error: TixError) -> TixError {
2265 match error {
2266 TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
2267 other => other,
2268 }
2269}
2270
2271pub(crate) fn allocate_stable_id<I: StableId, V>(
2279 registry: &IndexMap<String, V>,
2280 mut draw: impl FnMut() -> I,
2281) -> I {
2282 loop {
2283 let candidate = draw();
2284 if !registry.contains_key(candidate.as_str()) {
2285 return candidate;
2286 }
2287 }
2288}
2289
2290pub(crate) fn restore_section_order<V: Clone>(
2300 map: &mut IndexMap<String, V>,
2301 contents: &str,
2302 section: &str,
2303) {
2304 let mut order: Vec<String> = Vec::new();
2305 for line in contents.lines() {
2306 let line = line.trim();
2307 if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
2308 && let Some((head, key)) = rest.split_once('.')
2309 && head.trim() == section
2310 && !key.trim().contains('.')
2311 && let Some(name) = unquote_key(key.trim())
2312 && !order.iter().any(|n| n == name)
2313 {
2314 order.push(name.to_string());
2315 }
2316 }
2317 if order.is_empty() {
2318 return;
2319 }
2320 let mut ordered = IndexMap::new();
2321 for name in &order {
2322 if let Some(value) = map.shift_remove(name) {
2323 ordered.insert(name.clone(), value);
2324 }
2325 }
2326 ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
2327 *map = ordered;
2328}
2329
2330fn unquote_key(key: &str) -> Option<&str> {
2333 if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
2334 Some(inner)
2335 } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
2336 Some(inner)
2337 } else {
2338 Some(key)
2339 }
2340}
2341
2342fn validate_planning(planning: &PlanningConfig) -> Result<()> {
2345 if planning.cycle_number == 0 {
2346 return Err(TixError::Config(
2347 "`planning.cycle_number` must be at least 1".into(),
2348 ));
2349 }
2350 Ok(())
2351}
2352
2353fn validate_repos(repos: &IndexMap<String, PathBuf>) -> Result<()> {
2359 for (name, path) in repos {
2360 if name.trim().is_empty() {
2361 return Err(TixError::Config(
2362 "`[repos]` entry names must not be blank".into(),
2363 ));
2364 }
2365 if path.as_os_str().is_empty() {
2366 return Err(TixError::Config(format!(
2367 "`repos.{name}` must name the repository path"
2368 )));
2369 }
2370 }
2371 Ok(())
2372}
2373
2374fn validate_team(label: &str, team: &Team) -> Result<()> {
2377 if label.trim().is_empty() {
2378 return Err(TixError::Config("`teams.<label>` must not be blank".into()));
2379 }
2380 if team.members.is_empty() {
2381 return Err(TixError::Config(format!(
2382 "`teams.{label}.members` must not be empty"
2383 )));
2384 }
2385 let mut seen: HashSet<&str> = HashSet::new();
2386 for member in &team.members {
2387 if !seen.insert(member.as_str()) {
2388 return Err(TixError::Config(format!(
2389 "`teams.{label}.members` must be unique; found duplicate `{member}`"
2390 )));
2391 }
2392 }
2393 Ok(())
2394}
2395
2396pub(crate) fn validate_registry_key<I: StableId>(section: &str, id: &str) -> Result<()> {
2404 I::new(id).map_err(|err| TixError::Config(format!("`{section}`: {err}")))?;
2405 Ok(())
2406}
2407
2408pub(crate) fn validate_display_name(
2417 section: &str,
2418 id: &str,
2419 name: &str,
2420 noun: &str,
2421 seen: &mut HashSet<String>,
2422) -> Result<()> {
2423 if name.trim().is_empty() {
2424 return Err(TixError::Config(format!(
2425 "`{section}.{id}.name` must not be blank"
2426 )));
2427 }
2428 if name.trim() != name {
2429 return Err(TixError::Config(format!(
2430 "`{section}.{id}.name` has leading or trailing whitespace"
2431 )));
2432 }
2433 if !seen.insert(name.to_lowercase()) {
2434 return Err(TixError::Config(format!(
2435 "`{section}.{id}.name` (`{name}`) must be unique; another {noun} already uses it"
2436 )));
2437 }
2438 Ok(())
2439}
2440
2441fn validate_optional_text(section: &str, id: &str, fields: &[(&str, Option<&str>)]) -> Result<()> {
2445 for (field, value) in fields {
2446 if value.is_some_and(|v| v.trim().is_empty()) {
2447 return Err(TixError::Config(format!(
2448 "`{section}.{id}.{field}` must not be blank when present"
2449 )));
2450 }
2451 }
2452 Ok(())
2453}
2454
2455fn validate_record_dates(
2459 section: &str,
2460 id: &str,
2461 start_date: Option<&str>,
2462 due_date: Option<&str>,
2463) -> Result<()> {
2464 crate::model::validate_issue_dates(start_date, due_date)
2465 .map_err(|err| TixError::Config(format!("`{section}.{id}`: {err}")))
2466}
2467
2468fn validate_projects(projects: &IndexMap<String, ProjectConfiguration>) -> Result<()> {
2477 let mut seen_names: HashSet<String> = HashSet::new();
2478 for (id, project) in projects {
2479 validate_registry_key::<ProjectId>("projects", id)?;
2480 validate_display_name("projects", id, &project.name, "project", &mut seen_names)?;
2481 validate_optional_text(
2482 "projects",
2483 id,
2484 &[
2485 ("description", project.description.as_deref()),
2486 ("status", project.status.as_deref()),
2487 ("lead", project.lead.as_deref()),
2488 ],
2489 )?;
2490 validate_record_dates(
2491 "projects",
2492 id,
2493 project.start_date.as_deref(),
2494 project.due_date.as_deref(),
2495 )?;
2496 }
2497 Ok(())
2498}
2499
2500fn validate_epics(epics: &IndexMap<String, EpicConfiguration>) -> Result<()> {
2518 let mut seen_names: HashSet<String> = HashSet::new();
2519 for (id, epic) in epics {
2520 validate_registry_key::<EpicId>("epics", id)?;
2521 validate_display_name("epics", id, &epic.name, "epic", &mut seen_names)?;
2522 validate_optional_text(
2523 "epics",
2524 id,
2525 &[
2526 ("description", epic.description.as_deref()),
2527 ("status", epic.status.as_deref()),
2528 ("owner", epic.owner.as_deref()),
2529 ("milestone", epic.milestone.as_deref()),
2530 ("cycle", epic.cycle.as_deref()),
2531 ],
2532 )?;
2533 validate_record_dates(
2534 "epics",
2535 id,
2536 epic.start_date.as_deref(),
2537 epic.due_date.as_deref(),
2538 )?;
2539 if let Some(weight) = epic.weight
2540 && weight <= 0
2541 {
2542 return Err(TixError::Config(format!(
2543 "`epics.{id}.weight` must be a positive integer; got {weight}"
2544 )));
2545 }
2546 if let Some(parent) = epic.parent.as_deref() {
2547 EpicId::new(parent)
2548 .map_err(|err| TixError::Config(format!("`epics.{id}.parent`: {err}")))?;
2549 if parent == id {
2550 return Err(TixError::Config(format!(
2551 "`epics.{id}.parent` must not name the epic itself"
2552 )));
2553 }
2554 }
2555 for reference in &epic.projects {
2556 ProjectId::new(reference)
2557 .map_err(|err| TixError::Config(format!("`epics.{id}.projects`: {err}")))?;
2558 }
2559 }
2560 Ok(())
2561}
2562
2563const INITIATIVE_KNOWN_FIELDS: &[&str] = &[
2572 "name",
2573 "outcome",
2574 "owner",
2575 "status",
2576 "priority",
2577 "labels",
2578 "target_date",
2579 "projects",
2580 "epics",
2581 "milestones",
2582];
2583
2584fn validate_initiatives(initiatives: &IndexMap<String, InitiativeConfiguration>) -> Result<()> {
2611 let mut seen_names: HashSet<String> = HashSet::new();
2612 for (id, initiative) in initiatives {
2613 validate_registry_key::<InitiativeId>("initiatives", id)?;
2614 validate_display_name(
2615 "initiatives",
2616 id,
2617 &initiative.name,
2618 "initiative",
2619 &mut seen_names,
2620 )?;
2621 validate_optional_text(
2622 "initiatives",
2623 id,
2624 &[
2625 ("outcome", initiative.outcome.as_deref()),
2626 ("owner", initiative.owner.as_deref()),
2627 ("status", initiative.status.as_deref()),
2628 ],
2629 )?;
2630 for key in initiative.extensions.keys() {
2631 if INITIATIVE_KNOWN_FIELDS.contains(&key) {
2632 return Err(TixError::Config(format!(
2633 "`initiatives.{id}.{key}` collides with a built-in field; rename the extension key"
2634 )));
2635 }
2636 }
2637 if let Some(date) = initiative.target_date.as_deref() {
2638 if !crate::model::validate_date(date) {
2639 return Err(TixError::Config(format!(
2640 "`initiatives.{id}.target_date` (`{date}`) must be a `YYYY-MM-DD` calendar date"
2641 )));
2642 }
2643 }
2644 for label in &initiative.labels {
2645 if label.trim().is_empty() {
2646 return Err(TixError::Config(format!(
2647 "`initiatives.{id}.labels` must not contain a blank value"
2648 )));
2649 }
2650 }
2651 for reference in &initiative.projects {
2652 ProjectId::new(reference)
2653 .map_err(|err| TixError::Config(format!("`initiatives.{id}.projects`: {err}")))?;
2654 }
2655 for reference in &initiative.epics {
2656 EpicId::new(reference)
2657 .map_err(|err| TixError::Config(format!("`initiatives.{id}.epics`: {err}")))?;
2658 }
2659 for reference in &initiative.milestones {
2660 if reference.trim().is_empty() {
2661 return Err(TixError::Config(format!(
2662 "`initiatives.{id}.milestones` must not contain a blank value"
2663 )));
2664 }
2665 }
2666 }
2667 Ok(())
2668}
2669
2670fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
2673 if name.trim().is_empty() {
2674 return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
2675 }
2676 if let Some(start) = &cycle.start_date {
2677 if !crate::model::validate_date(start) {
2678 return Err(TixError::Config(format!(
2679 "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
2680 )));
2681 }
2682 }
2683 if let Some(due) = &cycle.due_date {
2684 if !crate::model::validate_date(due) {
2685 return Err(TixError::Config(format!(
2686 "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
2687 )));
2688 }
2689 if let Some(start) = &cycle.start_date
2690 && due.as_str() < start.as_str()
2691 {
2692 return Err(TixError::Config(format!(
2693 "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
2694 )));
2695 }
2696 }
2697 Ok(())
2698}
2699
2700fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
2709 if name.trim().is_empty() {
2710 return Err(TixError::Config(
2711 "`templates` must not contain a blank name".into(),
2712 ));
2713 }
2714 for (field, values) in [
2715 ("labels", &template.labels),
2716 ("cycles", &template.cycles),
2717 ("milestones", &template.milestones),
2718 ] {
2719 for value in values {
2720 if value.trim().is_empty() {
2721 return Err(TixError::Config(format!(
2722 "`templates.{name}.{field}` must not contain a blank value"
2723 )));
2724 }
2725 if value.contains(',') {
2726 return Err(TixError::Config(format!(
2727 "`templates.{name}.{field}` value `{value}` must not contain a comma \
2728 — the form joins these with `,`"
2729 )));
2730 }
2731 }
2732 }
2733 if let Some(start) = &template.start_date
2734 && !crate::model::validate_date(start)
2735 {
2736 return Err(TixError::Config(format!(
2737 "`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
2738 )));
2739 }
2740 if let Some(due) = &template.due_date {
2741 if !crate::model::validate_date(due) {
2742 return Err(TixError::Config(format!(
2743 "`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
2744 )));
2745 }
2746 if let Some(start) = &template.start_date
2747 && due.as_str() < start.as_str()
2748 {
2749 return Err(TixError::Config(format!(
2750 "`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
2751 )));
2752 }
2753 }
2754 Ok(())
2755}
2756
2757fn validate_categories(categories: &CategoryConfig) -> Result<()> {
2765 if categories.order.is_empty() {
2766 return Err(TixError::Config(
2767 "`categories.order` must not be empty — it is the global vocabulary".into(),
2768 ));
2769 }
2770 for key in &categories.order {
2771 if !CATEGORY_KEYS.contains(&key.as_str()) {
2772 return Err(TixError::Config(format!(
2773 "unknown_category: `categories.order` names `{key}`, which is not in the \
2774 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
2775 board maps columns into it and does not define it.",
2776 CATEGORY_KEYS.join(", ")
2777 )));
2778 }
2779 }
2780 if !categories.contains(&categories.default) {
2781 return Err(TixError::Config(format!(
2782 "unknown_category: `categories.default` is `{}`, which is not in \
2783 `categories.order`",
2784 categories.default
2785 )));
2786 }
2787 if categories.default == COMPLETE_CATEGORY {
2788 return Err(TixError::Config(format!(
2789 "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
2790 without an explicit mapping would resolve to complete, which changes \
2791 completion meaning by accident. Map the completing column explicitly \
2792 instead."
2793 )));
2794 }
2795 Ok(())
2796}
2797
2798fn validate_category_map(
2805 name: &str,
2806 board: &BoardConfiguration,
2807 categories: &CategoryConfig,
2808) -> Result<()> {
2809 for (column, key) in &board.category_map {
2810 if !board.columns.iter().any(|c| c == column) {
2811 return Err(TixError::Config(format!(
2812 "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
2813 `{column}`, which is not one of the board's columns ({})",
2814 board.columns.join(", ")
2815 )));
2816 }
2817 if !categories.contains(key) {
2818 return Err(TixError::Config(format!(
2819 "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
2820 `{key}`, which is not in the vocabulary ({})",
2821 categories.order.join(", ")
2822 )));
2823 }
2824 }
2825 if board.category_map.is_empty() {
2829 return Ok(());
2830 }
2831 let completers: Vec<&String> = board
2832 .category_map
2833 .iter()
2834 .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
2835 .map(|(column, _)| column)
2836 .collect();
2837 match completers.as_slice() {
2838 [] => Err(TixError::Config(format!(
2839 "missing_completion_category: `kanban.{name}.category_map` maps no column \
2840 to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
2841 is ever complete — map exactly one column to it.",
2842 board.category_map.len()
2843 ))),
2844 [_only] => Ok(()),
2845 many => Err(TixError::Config(format!(
2846 "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
2847 ({}); exactly one column may complete work",
2848 many.len(),
2849 many.iter()
2850 .map(|c| c.as_str())
2851 .collect::<Vec<_>>()
2852 .join(", ")
2853 ))),
2854 }
2855}
2856
2857fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
2858 if board.columns.is_empty() {
2859 return Err(TixError::Config(format!(
2860 "`kanban.{name}.columns` must not be empty"
2861 )));
2862 }
2863 if board.columns.iter().any(|c| c.trim().is_empty()) {
2864 return Err(TixError::Config(format!(
2865 "`kanban.{name}.columns` must not contain blank names"
2866 )));
2867 }
2868 let mut seen: HashSet<&str> = HashSet::new();
2869 for column in &board.columns {
2870 if !seen.insert(column.as_str()) {
2871 return Err(TixError::Config(format!(
2872 "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
2873 )));
2874 }
2875 }
2876 if let Some(done) = &board.done_column
2877 && !board.columns.contains(done)
2878 {
2879 return Err(TixError::Config(format!(
2880 "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
2881 )));
2882 }
2883 for hidden in &board.hidden_columns {
2884 if !board.columns.contains(hidden) {
2885 return Err(TixError::Config(format!(
2886 "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
2887 )));
2888 }
2889 }
2890 if board.title_lines == 0 {
2891 return Err(TixError::Config(format!(
2892 "`kanban.{name}.title_lines` must be at least 1"
2893 )));
2894 }
2895 board.wip.validate(name, &board.columns)?;
2898 Ok(())
2899}
2900
2901#[cfg(test)]
2902mod tests {
2903 use super::*;
2904 use std::path::PathBuf;
2905 use tempfile::TempDir;
2906
2907 use crate::templates::{
2908 PromptKind, ReferenceKind, Template, TemplateDefaults, TemplatePrompt, TemplateReference,
2909 TemplateScope,
2910 };
2911
2912 const LEGACY_PRETTY_BOARD: &str = r#"issues_dir = "docs/issues"
2916
2917[kanban.default]
2918columns = [
2919 "todo",
2920 "in progress",
2921 "in review",
2922 "done",
2923]
2924"#;
2925
2926 fn write_toml(dir: &Path, contents: &str) -> PathBuf {
2927 let path = dir.join("tix.toml");
2928 std::fs::write(&path, contents).unwrap();
2929 path
2930 }
2931
2932 #[test]
2933 fn starter_config_carries_a_working_inbox() {
2934 let config = Config::starter();
2935 config
2936 .validate()
2937 .expect("the config `tix init` writes must load");
2938
2939 let board = config.default_config();
2940 for column in [
2941 "triage",
2942 "todo",
2943 "in progress",
2944 "in review",
2945 "done",
2946 "wontfix",
2947 ] {
2948 assert!(
2949 board.columns.iter().any(|c| c == column),
2950 "the starter board is missing `{column}`: {:?}",
2951 board.columns
2952 );
2953 }
2954
2955 let triage = config
2956 .triage
2957 .as_ref()
2958 .expect("the starter config has an inbox");
2959 assert_eq!(triage.entry_status.as_deref(), Some("triage"));
2960 assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
2961 assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
2962 assert_eq!(triage.effective_deferred_status(), Some("triage"));
2965
2966 assert_eq!(
2968 board.category_map.get("wontfix").map(String::as_str),
2969 Some("canceled")
2970 );
2971 assert_eq!(
2972 board.category_map.get("done").map(String::as_str),
2973 Some("completed")
2974 );
2975 for column in ["triage", "todo", "in progress", "in review"] {
2981 assert!(
2982 !board.category_map.contains_key(column),
2983 "`{column}` must stay unmapped, or adjacency will refuse work moving back"
2984 );
2985 }
2986 }
2987
2988 #[test]
2989 fn repos_and_parent_repo_load_round_trip_and_resolve() {
2990 let tmp = TempDir::new().unwrap();
2991 let raw = "issues_dir = \"docs/issues\"\n\
2994 parent_repo = \"../parent\"\n\n\
2995 [repos]\n\
2996 tix = \"../tix\"\n\
2997 website = \"child/website\"\n";
2998 let path = write_toml(tmp.path(), raw);
2999 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3000 assert_eq!(cfg.repos.len(), 2);
3001 assert_eq!(cfg.repos["tix"], PathBuf::from("../tix"));
3002 assert_eq!(cfg.repos["website"], PathBuf::from("child/website"));
3003 assert_eq!(cfg.parent_repo(), Some(Path::new("../parent")));
3004
3005 cfg.save(&path).unwrap();
3007 let reread = Config::load_with_warning(&path, |_| {}).unwrap();
3008 assert_eq!(reread.repos, cfg.repos, "repos round-trip");
3009 assert_eq!(
3010 reread.parent_repo(),
3011 cfg.parent_repo(),
3012 "parent round-trips"
3013 );
3014
3015 let mut resolved = cfg.clone();
3017 resolved.resolve_repo_paths(Path::new("/root"));
3018 assert_eq!(resolved.repos["tix"], PathBuf::from("/root/../tix"));
3019 assert_eq!(
3020 resolved.repos["website"],
3021 PathBuf::from("/root/child/website")
3022 );
3023 assert_eq!(resolved.parent_repo(), Some(Path::new("/root/../parent")));
3024 let mut absolute = Config::default();
3025 absolute.repos.insert("x".into(), PathBuf::from("/abs/x"));
3026 absolute.parent_repo = Some(PathBuf::from("/abs/parent"));
3027 absolute.resolve_repo_paths(Path::new("/root"));
3028 assert_eq!(absolute.repos["x"], PathBuf::from("/abs/x"));
3029 assert_eq!(absolute.parent_repo(), Some(Path::new("/abs/parent")));
3030 }
3031
3032 #[test]
3033 fn types_rename_and_type_colors() {
3034 let tmp = TempDir::new().unwrap();
3035 let path = write_toml(
3037 tmp.path(),
3038 "issues_dir = \"docs/issues\"\ncustom_types = [\"bug\", \"epic\"]\n",
3039 );
3040 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3041 assert_eq!(cfg.types(), vec!["bug", "epic"]);
3042 cfg.save(&path).unwrap();
3043 let after = std::fs::read_to_string(&path).unwrap();
3044 assert!(after.contains("types = ["), "{after}");
3045 assert!(!after.contains("custom_types"), "{after}");
3046
3047 let defaults = Config::default();
3050 assert_eq!(defaults.type_color("bug").as_deref(), Some("red"));
3051 assert_eq!(defaults.type_color("BUG").as_deref(), Some("red"));
3052 assert_eq!(defaults.type_color("nonsense"), None);
3053 let mut overridden = Config::default();
3054 overridden.type_colors.insert("bug".into(), "cyan".into());
3055 assert_eq!(overridden.type_color("Bug").as_deref(), Some("cyan"));
3056 }
3057
3058 #[test]
3059 fn a_blank_repo_path_is_rejected() {
3060 let tmp = TempDir::new().unwrap();
3061 let path = write_toml(
3062 tmp.path(),
3063 "issues_dir = \"docs/issues\"\n\n[repos]\ntix = \"\"\n",
3064 );
3065 let err = Config::load_with_warning(&path, |_| {})
3066 .unwrap_err()
3067 .to_string();
3068 assert!(err.contains("repos.tix"), "got {err}");
3069 }
3070
3071 #[test]
3072 fn an_empty_repos_section_is_not_written() {
3073 let tmp = TempDir::new().unwrap();
3074 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3075 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3076 cfg.save(&path).unwrap();
3077 let after = std::fs::read_to_string(&path).unwrap();
3078 assert!(
3079 !after.contains("[repos]") && !after.contains("parent_repo"),
3080 "an untouched config gains no repos keys on save: {after}"
3081 );
3082 }
3083
3084 #[test]
3085 fn defaults_match_contract() {
3086 let c = Config::default();
3087 let board = c.default_config();
3088 assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
3089 assert_eq!(board.done_column.as_deref(), Some("done"));
3090 assert_eq!(board.hidden_columns, Vec::<String>::new());
3091 assert!(!board.show_mismatch);
3092 assert!(board.wrap_titles);
3093 assert_eq!(board.title_lines, 3);
3094 assert_eq!(board.card_order, [] as [std::string::String; 0]);
3095 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
3096 assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
3097 assert_eq!(c.editor, None);
3098 assert_eq!(c.state.next_number, 0);
3099 assert_eq!(c.export.gitlab, None);
3100 assert_eq!(c.export.linear, None);
3101 }
3102
3103 #[test]
3106 fn decision_pending_is_a_builtin_label_that_needs_no_key() {
3107 let bare = Config::default();
3108 assert_eq!(bare.labels, None);
3109 assert_eq!(bare.labels(), vec!["decision-pending".to_string()]);
3110
3111 let starter = Config::starter();
3114 assert_eq!(starter.labels, None);
3115 assert_eq!(starter.labels(), vec!["decision-pending".to_string()]);
3116 for written in [
3117 toml::to_string_pretty(&bare).unwrap(),
3118 toml::to_string_pretty(&starter).unwrap(),
3119 ] {
3120 assert!(
3121 !written.contains("labels ="),
3122 "no labels key is written:\n{written}"
3123 );
3124 }
3125
3126 let configured = Config {
3128 labels: Some(vec!["decision-pending".into(), "spec-kitty".into()]),
3129 ..Config::default()
3130 };
3131 assert_eq!(
3132 configured.labels(),
3133 vec!["decision-pending".to_string(), "spec-kitty".to_string()]
3134 );
3135 }
3136
3137 #[test]
3138 fn load_returns_defaults_when_file_absent() {
3139 let tmp = TempDir::new().unwrap();
3140 let cfg = Config::load(tmp.path()).unwrap();
3141 assert_eq!(cfg.state.next_number, 0);
3142 assert_eq!(cfg.default_config().columns.len(), 4);
3143 }
3144
3145 #[test]
3146 fn load_synthesizes_default_config_when_kanban_section_missing() {
3147 let tmp = TempDir::new().unwrap();
3148 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3149 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3150 assert_eq!(cfg.kanban.configurations.len(), 1);
3151 assert!(cfg.kanban.configurations.contains_key("default"));
3152 assert_eq!(cfg.default_config().columns.len(), 4);
3153 }
3154
3155 #[test]
3156 fn load_parses_state_and_keeps_editor_and_remote_targets() {
3157 let tmp = TempDir::new().unwrap();
3158 let path = write_toml(
3159 tmp.path(),
3160 r#"issues_dir = "tickets"
3161editor = "nvim"
3162
3163[kanban.default]
3164columns = ["todo", "done"]
3165done_column = "done"
3166
3167[state]
3168next_number = 17
3169
3170[export.gitlab]
3171project = "acme/tix"
3172"#,
3173 );
3174 let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
3175 assert_eq!(
3176 cfg.kanban.configurations.get("default").unwrap().columns,
3177 ["todo", "done"]
3178 );
3179 assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
3180 assert_eq!(cfg.editor.as_deref(), Some("nvim"));
3181 assert_eq!(cfg.state.next_number, 17);
3182 assert_eq!(
3183 cfg.export.gitlab.unwrap().project.as_deref(),
3184 Some("acme/tix")
3185 );
3186 assert_eq!(cfg.export.linear, None);
3187 }
3188
3189 #[test]
3190 fn viewer_is_parsed_and_blank_is_rejected() {
3191 let tmp = TempDir::new().unwrap();
3192 let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
3193 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3194 assert_eq!(cfg.viewer.as_deref(), Some("less"));
3195
3196 let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \" \"\n");
3197 let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
3198 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3199 assert!(err.to_string().contains("viewer"), "{err}");
3200 }
3201
3202 #[test]
3203 fn viewer_is_omitted_from_the_file_when_unset() {
3204 let dir = TempDir::new().unwrap();
3205 let path = dir.path().join("tix.toml");
3206 let mut config = Config::default();
3207 config.save(&path).unwrap();
3208 assert!(
3209 !std::fs::read_to_string(&path).unwrap().contains("viewer"),
3210 "an unset viewer writes no key"
3211 );
3212
3213 config.viewer = Some("less".to_string());
3214 config.save(&path).unwrap();
3215 assert!(
3216 std::fs::read_to_string(&path)
3217 .unwrap()
3218 .contains(r#"viewer = "less""#),
3219 "a set viewer round-trips"
3220 );
3221 }
3222
3223 #[test]
3224 fn empty_columns_rejected_as_config_error() {
3225 let tmp = TempDir::new().unwrap();
3226 let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
3227 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3228 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3229 assert!(err.to_string().contains("kanban.scratch.columns"));
3230 }
3231
3232 #[test]
3233 fn duplicate_columns_rejected_as_config_error() {
3234 let tmp = TempDir::new().unwrap();
3235 let path = write_toml(
3236 tmp.path(),
3237 "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
3238 );
3239 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3240 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3241 assert!(err.to_string().contains("duplicate"));
3242 }
3243
3244 #[test]
3245 fn blank_column_name_rejected() {
3246 let tmp = TempDir::new().unwrap();
3247 let path = write_toml(
3248 tmp.path(),
3249 "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
3250 );
3251 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3252 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3253 }
3254
3255 #[test]
3256 fn done_column_must_be_in_columns() {
3257 let tmp = TempDir::new().unwrap();
3258 let path = write_toml(
3259 tmp.path(),
3260 "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
3261 );
3262 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3263 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3264 assert!(err.to_string().contains("kanban.scratch.done_column"));
3265 }
3266
3267 #[test]
3268 fn hidden_columns_must_be_subset_of_columns() {
3269 let tmp = TempDir::new().unwrap();
3270 let path = write_toml(
3271 tmp.path(),
3272 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
3273 );
3274 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3275 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3276 assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
3277 }
3278
3279 #[test]
3280 fn hidden_done_column_loads_fine() {
3281 let tmp = TempDir::new().unwrap();
3282 let path = write_toml(
3283 tmp.path(),
3284 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
3285 );
3286 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3287 let board = cfg.kanban.configurations.get("scratch").unwrap();
3288 assert_eq!(board.done_column.as_deref(), Some("done"));
3289 assert_eq!(board.hidden_columns, ["done"]);
3290 }
3291
3292 #[test]
3293 fn legacy_top_level_keys_rejected() {
3294 let tmp = TempDir::new().unwrap();
3295 let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
3296 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3297 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3298 assert!(err.to_string().contains("tix.toml"));
3299 assert!(err.to_string().contains("columns"));
3300 }
3301
3302 #[test]
3303 fn token_warning_fires_once_per_remote_with_token() {
3304 let tmp = TempDir::new().unwrap();
3305 let path = write_toml(
3306 tmp.path(),
3307 r#"[export.gitlab]
3308project = "a/b"
3309token = "gl-secret"
3310
3311[export.linear]
3312team = "Eng"
3313token = "lin-secret"
3314"#,
3315 );
3316 let mut warnings: Vec<String> = Vec::new();
3317 let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
3318 assert_eq!(warnings.len(), 2);
3319 assert!(warnings[0].contains("GITLAB_TOKEN"));
3320 assert!(warnings[1].contains("LINEAR_TOKEN"));
3321 for w in &warnings {
3323 assert!(!w.contains("secret"));
3324 }
3325 assert_eq!(
3326 cfg.export.gitlab.unwrap().token.as_deref(),
3327 Some("gl-secret")
3328 );
3329 }
3330
3331 #[test]
3332 fn no_token_means_no_warning() {
3333 let tmp = TempDir::new().unwrap();
3334 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3335 let mut fired = false;
3336 Config::load_with_warning(&path, |_| fired = true).unwrap();
3337 assert!(!fired);
3338 }
3339
3340 #[test]
3341 fn save_never_writes_the_state_section() {
3342 let tmp = TempDir::new().unwrap();
3343 let path = tmp.path().join("tix.toml");
3344 let mut cfg = Config::default();
3347 cfg.state.next_number = 42;
3348 cfg.save(&path).unwrap();
3349
3350 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3351 assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
3352 assert_eq!(
3353 reloaded.default_config().columns,
3354 cfg.default_config().columns
3355 );
3356
3357 let on_disk = std::fs::read_to_string(&path).unwrap();
3358 assert!(
3359 !on_disk.contains("next_number"),
3360 "next_number leaked into tix.toml:\n{on_disk}"
3361 );
3362 assert!(
3363 !on_disk.contains("[state]"),
3364 "state section written:\n{on_disk}"
3365 );
3366 }
3367
3368 #[test]
3369 fn multi_config_roundtrip_preserves_declaration_order() {
3370 let tmp = TempDir::new().unwrap();
3371 let path = write_toml(
3372 tmp.path(),
3373 r#"issues_dir = "docs/issues"
3374
3375[kanban.release]
3376columns = ["planned", "in progress", "done"]
3377done_column = "done"
3378show_mismatch = true
3379
3380[kanban.personal]
3381columns = ["todo", "done"]
3382done_column = "done"
3383hidden_columns = ["done"]
3384"#,
3385 );
3386 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3387 let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
3388 assert_eq!(names, ["release", "personal"]);
3389
3390 assert_eq!(
3392 cfg.default_config().columns,
3393 ["planned", "in progress", "done"]
3394 );
3395
3396 cfg.save(&path).unwrap();
3397 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3398 assert_eq!(
3399 cfg, reloaded,
3400 "order and values survive save/load round trip"
3401 );
3402 let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
3403 assert_eq!(names2, ["release", "personal"]);
3404 }
3405
3406 #[test]
3407 fn save_never_serializes_tokens_back_out() {
3408 let tmp = TempDir::new().unwrap();
3409 let path = tmp.path().join("tix.toml");
3410 let mut cfg = Config::default();
3411 cfg.export.gitlab = Some(RemoteConfig {
3412 token: Some("secret".into()),
3413 project: Some("a/b".into()),
3414 ..RemoteConfig::default()
3415 });
3416 cfg.save(&path).unwrap();
3417
3418 let on_disk = std::fs::read_to_string(&path).unwrap();
3419 assert!(
3420 !on_disk.contains("secret"),
3421 "token leaked to disk:\n{on_disk}"
3422 );
3423 assert!(
3424 on_disk.contains("a/b"),
3425 "non-secret fields survive:\n{on_disk}"
3426 );
3427
3428 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3429 assert_eq!(reloaded.export.gitlab.unwrap().token, None);
3430 }
3431
3432 #[test]
3433 fn save_is_atomic_and_leaves_no_temp_file() {
3434 let tmp = TempDir::new().unwrap();
3435 let path = tmp.path().join("tix.toml");
3436 Config::default().save(&path).unwrap();
3437 let entries: Vec<_> = std::fs::read_dir(tmp.path())
3438 .unwrap()
3439 .filter_map(|e| e.ok())
3440 .collect();
3441 assert_eq!(entries.len(), 1);
3442 assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
3443 }
3444
3445 #[test]
3446 fn default_loadwrites_are_idempotent() {
3447 let tmp = TempDir::new().unwrap();
3448 let path = tmp.path().join("tix.toml");
3449 let cfg = Config::default();
3450 cfg.save(&path).unwrap();
3451 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3452 assert_eq!(cfg, reloaded);
3453 }
3454
3455 #[test]
3456 fn effective_done_column_falls_back_to_last() {
3457 let board = BoardConfiguration {
3458 columns: vec!["todo".into(), "later".into()],
3459 done_column: None,
3460 ..Default::default()
3461 };
3462 assert_eq!(board.effective_done_column(), Some("later"));
3463
3464 let with_done = BoardConfiguration {
3465 columns: vec!["todo".into(), "done".into(), "later".into()],
3466 done_column: None,
3467 ..Default::default()
3468 };
3469 assert_eq!(with_done.effective_done_column(), Some("done"));
3470 }
3471
3472 #[test]
3473 fn project_root_walks_up_the_tree() {
3474 let tmp = TempDir::new().unwrap();
3475 let nested = tmp.path().join("a").join("b");
3476 std::fs::create_dir_all(&nested).unwrap();
3477 std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
3478
3479 assert_eq!(
3480 Config::project_root(&nested),
3481 Some(tmp.path().to_path_buf())
3482 );
3483 assert_eq!(
3484 Config::project_root(tmp.path()),
3485 Some(tmp.path().to_path_buf())
3486 );
3487 }
3488
3489 #[test]
3490 fn project_root_is_none_when_no_toml_exists() {
3491 let tmp = TempDir::new().unwrap();
3492 assert_eq!(Config::project_root(tmp.path()), None);
3493 }
3494
3495 #[test]
3496 fn order_by_parses_all_names_and_defaults_to_desc() {
3497 let tmp = TempDir::new().unwrap();
3498 for name in ["created_at_desc", "created_at_asc", "none"] {
3499 let path = write_toml(
3500 tmp.path(),
3501 &format!(
3502 "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
3503 ),
3504 );
3505 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3506 let board = cfg.kanban.configurations.get("x").unwrap();
3507 assert_eq!(board.order_by.as_str(), name);
3508 }
3509 assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
3510 assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
3511 assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
3512 assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
3513 }
3514
3515 #[test]
3516 fn zero_title_lines_rejected_as_config_error() {
3517 let tmp = TempDir::new().unwrap();
3518 let path = write_toml(
3519 tmp.path(),
3520 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
3521 );
3522 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3523 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3524 assert!(
3525 err.to_string().contains("kanban.scratch.title_lines"),
3526 "error names the offending field: {err}"
3527 );
3528 }
3529
3530 #[test]
3531 fn unknown_order_by_rejected_as_config_error() {
3532 let tmp = TempDir::new().unwrap();
3533 let path = write_toml(
3534 tmp.path(),
3535 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
3536 );
3537 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3538 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3539 }
3540
3541 #[test]
3542 fn board_without_display_keys_loads_default_display_settings() {
3543 let tmp = TempDir::new().unwrap();
3544 let path = write_toml(
3545 tmp.path(),
3546 "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
3547 );
3548 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3549 let board = cfg.default_config();
3550 assert!(board.wrap_titles);
3551 assert_eq!(board.title_lines, 3);
3552 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
3553 }
3554
3555 #[test]
3556 fn display_settings_roundtrip_through_save() {
3557 let tmp = TempDir::new().unwrap();
3558 let path = tmp.path().join("tix.toml");
3559 let mut cfg = Config::default();
3560 {
3561 let board = cfg.kanban.configurations.get_mut("default").unwrap();
3562 board.wrap_titles = false;
3563 board.title_lines = 5;
3564 board.order_by = OrderBy::None;
3565 }
3566 cfg.save(&path).unwrap();
3567 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3568 assert_eq!(
3569 reloaded, cfg,
3570 "display settings survive save/load round trip"
3571 );
3572 let on_disk = std::fs::read_to_string(&path).unwrap();
3573 for key in ["wrap_titles", "title_lines", "order_by"] {
3574 assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
3575 }
3576 }
3577
3578 #[test]
3579 fn manual_card_order_roundtrips_through_save() {
3580 let tmp = TempDir::new().unwrap();
3581 let path = tmp.path().join("tix.toml");
3582 let mut cfg = Config::default();
3583 {
3584 let board = cfg.kanban.configurations.get_mut("default").unwrap();
3585 board.card_order = vec!["a.md".into(), "b.md".into()];
3586 }
3587 cfg.save(&path).unwrap();
3588 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3589 assert_eq!(
3590 reloaded
3591 .kanban
3592 .configurations
3593 .get("default")
3594 .unwrap()
3595 .card_order,
3596 vec!["a.md".to_string(), "b.md".to_string()]
3597 );
3598 let on_disk = std::fs::read_to_string(&path).unwrap();
3599 assert!(
3600 on_disk.contains("card_order"),
3601 "missing card_order in:\n{on_disk}"
3602 );
3603 }
3604
3605 #[test]
3606 fn planning_defaults_match_contract() {
3607 let c = Config::default();
3608 assert_eq!(c.planning.cycle_number, 1);
3609 assert_eq!(c.planning.default_cycle, None);
3610 assert_eq!(c.planning.default_milestone, None);
3611 assert_eq!(c.planning.default_team, None);
3612 assert!(c.teams.is_empty());
3613 assert!(c.cycles.is_empty());
3614 assert_eq!(c.milestones_dir, None);
3615 assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
3616 }
3617
3618 #[test]
3619 fn planning_sections_roundtrip_and_preserve_declaration_order() {
3620 let tmp = TempDir::new().unwrap();
3621 let path = write_toml(
3622 tmp.path(),
3623 r#"issues_dir = "docs/issues"
3624milestones_dir = "docs/milestones"
3625
3626[planning]
3627cycle_number = 1
3628default_cycle = "Cycle 1"
3629default_milestone = "Backlog"
3630default_team = "default"
3631
3632[cycles."Cycle 1"]
3633
3634[cycles."Cycle 2"]
3635start_date = "2026-09-21"
3636due_date = "2026-09-30"
3637
3638[teams.default]
3639members = ["Herb Mason <herb@example.com>", "@alice"]
3640
3641[teams.platform]
3642members = ["bob"]
3643"#,
3644 );
3645 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3646 assert_eq!(cfg.planning.cycle_number, 1);
3647 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
3648 assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
3649 assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
3650 assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
3651
3652 let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
3654 assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
3655 let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
3656 assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
3657 assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
3658 assert_eq!(cycle2.description, None);
3659
3660 let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
3661 assert_eq!(teams, ["default", "platform"]);
3662 assert_eq!(
3663 cfg.teams.get("default").unwrap().members,
3664 ["Herb Mason <herb@example.com>", "@alice"]
3665 );
3666
3667 cfg.save(&path).unwrap();
3669 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3670 assert_eq!(cfg, reloaded);
3671 assert_eq!(
3672 reloaded
3673 .cycles
3674 .keys()
3675 .map(String::as_str)
3676 .collect::<Vec<_>>(),
3677 ["Cycle 1", "Cycle 2"]
3678 );
3679 assert_eq!(
3680 reloaded
3681 .teams
3682 .keys()
3683 .map(String::as_str)
3684 .collect::<Vec<_>>(),
3685 ["default", "platform"]
3686 );
3687 }
3688
3689 const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
3694
3695[categories]
3696order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
3697default = "unstarted"
3698
3699[transitions]
3700terminal_locked = true
3701
3702[kanban.default]
3703columns = ["triage", "wip", "review", "shipped"]
3704done_column = "shipped"
3705category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
3706
3707[export]
3708"#;
3709
3710 #[test]
3711 fn category_sections_parse_and_default_sensibly() {
3712 let tmp = TempDir::new().unwrap();
3713 let path = write_toml(tmp.path(), FULL_CATEGORIES);
3714 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3715 assert_eq!(cfg.categories.order, CATEGORY_KEYS);
3716 assert_eq!(cfg.categories.default, "unstarted");
3717 assert!(cfg.transitions.terminal_locked);
3718
3719 let board = cfg.default_config();
3720 assert_eq!(
3721 board.category_map.get("shipped").map(String::as_str),
3722 Some("completed")
3723 );
3724 assert_eq!(
3725 board.category_map.get("triage").map(String::as_str),
3726 Some("backlog")
3727 );
3728 }
3729
3730 #[test]
3731 fn category_sections_default_when_absent() {
3732 let tmp = TempDir::new().unwrap();
3733 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3734 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3735 assert_eq!(cfg.categories, CategoryConfig::default());
3738 assert_eq!(cfg.transitions, TransitionConfig::default());
3739 assert!(cfg.categories.is_default(), "and is not written back");
3740 assert!(cfg.transitions.is_default());
3741 }
3742
3743 #[test]
3744 fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
3745 let tmp = TempDir::new().unwrap();
3746 let bad_categories = write_toml(
3747 tmp.path(),
3748 "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
3749 );
3750 let err = Config::load_with_warning(&bad_categories, |_| {})
3751 .expect_err("an unknown key in [categories] must be refused");
3752 assert!(err.to_string().contains("unknown field"), "got: {err}");
3753
3754 let tmp2 = TempDir::new().unwrap();
3755 let bad_transitions = write_toml(
3756 tmp2.path(),
3757 "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
3758 );
3759 let err = Config::load_with_warning(&bad_transitions, |_| {})
3760 .expect_err("an unknown key in [transitions] must be refused");
3761 assert!(err.to_string().contains("unknown field"), "got: {err}");
3762 }
3763
3764 #[test]
3765 fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
3766 let tmp = TempDir::new().unwrap();
3767 let path = write_toml(
3768 tmp.path(),
3769 "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
3770 );
3771 let err = Config::load_with_warning(&path, |_| {})
3772 .expect_err("the vocabulary is global and fixed; a new key is not a default");
3773 assert!(err.to_string().contains("unknown_category"), "got: {err}");
3774 }
3775
3776 #[test]
3779 fn a_default_of_completed_is_refused() {
3780 let tmp = TempDir::new().unwrap();
3781 let path = write_toml(
3782 tmp.path(),
3783 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
3784 );
3785 let err = Config::load_with_warning(&path, |_| {})
3786 .expect_err("a completion-changing default must be an escalation");
3787 let msg = err.to_string();
3788 assert!(msg.contains("may not be `completed`"), "got: {msg}");
3789 }
3790
3791 #[test]
3792 fn a_default_outside_the_vocabulary_is_refused() {
3793 let tmp = TempDir::new().unwrap();
3794 let path = write_toml(
3795 tmp.path(),
3796 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
3797 );
3798 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3799 assert!(err.to_string().contains("unknown_category"), "got: {err}");
3800 }
3801
3802 #[test]
3806 fn a_mapped_board_with_no_completion_column_is_refused() {
3807 let tmp = TempDir::new().unwrap();
3808 let path = write_toml(
3809 tmp.path(),
3810 r#"issues_dir = "docs/issues"
3811
3812[kanban.default]
3813columns = ["triage", "wip", "shipped"]
3814done_column = "shipped"
3815category_map = { triage = "backlog", wip = "active" }
3816"#,
3817 );
3818 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3819 assert!(
3820 err.to_string().contains("missing_completion_category"),
3821 "got: {err}"
3822 );
3823 }
3824
3825 #[test]
3826 fn a_board_with_two_completion_columns_is_refused() {
3827 let tmp = TempDir::new().unwrap();
3828 let path = write_toml(
3829 tmp.path(),
3830 r#"issues_dir = "docs/issues"
3831
3832[kanban.default]
3833columns = ["shipped", "cancelled"]
3834done_column = "shipped"
3835category_map = { shipped = "completed", cancelled = "completed" }
3836"#,
3837 );
3838 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3839 let msg = err.to_string();
3840 assert!(
3841 msg.contains("exactly one column may complete"),
3842 "got: {msg}"
3843 );
3844 }
3845
3846 #[test]
3847 fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
3848 let tmp = TempDir::new().unwrap();
3849 let path = write_toml(
3850 tmp.path(),
3851 r#"issues_dir = "docs/issues"
3852
3853[kanban.default]
3854columns = ["triage", "shipped"]
3855done_column = "shipped"
3856category_map = { triage = "backlog", shipped = "completed", nope = "active" }
3857"#,
3858 );
3859 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3860 assert!(
3861 err.to_string().contains("mapping_for_unknown_column"),
3862 "got: {err}"
3863 );
3864 }
3865
3866 #[test]
3867 fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
3868 let tmp = TempDir::new().unwrap();
3869 let path = write_toml(
3870 tmp.path(),
3871 r#"issues_dir = "docs/issues"
3872
3873[kanban.default]
3874columns = ["triage", "shipped"]
3875done_column = "shipped"
3876category_map = { triage = "wontfix", shipped = "completed" }
3877"#,
3878 );
3879 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3880 assert!(err.to_string().contains("unknown_category"), "got: {err}");
3881 }
3882
3883 #[test]
3887 fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
3888 let tmp = TempDir::new().unwrap();
3889 let path = write_toml(
3890 tmp.path(),
3891 r#"issues_dir = "docs/issues"
3892
3893[kanban.default]
3894columns = ["triage", "wip", "shipped"]
3895done_column = "shipped"
3896category_map = { shipped = "completed" }
3897"#,
3898 );
3899 let cfg =
3900 Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
3901 let board = cfg.default_config();
3902 assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
3903 assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
3904 assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
3905 }
3906
3907 #[test]
3913 fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
3914 let tmp = TempDir::new().unwrap();
3915 let path = write_toml(
3916 tmp.path(),
3917 r#"issues_dir = "docs/issues"
3918
3919[kanban.default]
3920columns = ["todo", "in progress", "in review", "done"]
3921done_column = "done"
3922"#,
3923 );
3924 let cfg = Config::load_with_warning(&path, |_| {})
3925 .expect("a legacy board must load, and it does");
3926 let board = cfg.default_config();
3927 assert!(board.category_map.is_empty());
3928 assert_eq!(board.category_for("done", &cfg.categories), "completed");
3929 assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
3930 assert_eq!(
3931 board.category_for("in review", &cfg.categories),
3932 "unstarted",
3933 "and nothing else is silently complete"
3934 );
3935 }
3936
3937 #[test]
3941 fn a_defaulted_config_gains_no_new_keys_on_save() {
3942 let tmp = TempDir::new().unwrap();
3943 let legacy = LEGACY_PRETTY_BOARD;
3944 let path = write_toml(tmp.path(), legacy);
3945 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3946 cfg.save(&path).unwrap();
3947 let after = std::fs::read_to_string(&path).unwrap();
3948 assert_eq!(
3949 after, legacy,
3950 "no `[categories]`, no `[transitions]`, no `category_map` may appear"
3951 );
3952 }
3953
3954 #[test]
3958 fn explicit_default_board_keys_are_normalized_away_by_save() {
3959 let tmp = TempDir::new().unwrap();
3960 let explicit = r#"issues_dir = "docs/issues"
3961
3962[kanban.default]
3963columns = [
3964 "todo",
3965 "in progress",
3966 "in review",
3967 "done",
3968]
3969done_column = "done"
3970hidden_columns = []
3971show_mismatch = false
3972wrap_titles = true
3973title_lines = 3
3974order_by = "created_at_desc"
3975
3976[export]
3977"#;
3978 let path = write_toml(tmp.path(), explicit);
3979 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3980 cfg.save(&path).unwrap();
3981 let after = std::fs::read_to_string(&path).unwrap();
3982 assert_eq!(
3983 after, LEGACY_PRETTY_BOARD,
3984 "a redundant explicit default collapses to the canonical form"
3985 );
3986 for key in [
3987 "done_column",
3988 "hidden_columns",
3989 "show_mismatch",
3990 "wrap_titles",
3991 "title_lines",
3992 "order_by",
3993 "[export]",
3994 ] {
3995 assert!(!after.contains(key), "`{key}` must be omitted:\n{after}");
3996 }
3997 assert_eq!(
3998 cfg,
3999 Config::load_with_warning(&path, |_| {}).unwrap(),
4000 "and the collapsed form reloads to the same config"
4001 );
4002 }
4003
4004 #[test]
4007 fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
4008 let tmp = TempDir::new().unwrap();
4009 let path = write_toml(
4010 tmp.path(),
4011 r#"issues_dir = "docs/issues"
4012
4013[kanban.default]
4014columns = ["triage", "shipped"]
4015done_column = "shipped"
4016category_map = { triage = "backlog", shipped = "completed" }
4017
4018[export]
4019"#,
4020 );
4021 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4022 assert_eq!(cfg.categories, CategoryConfig::default());
4023 cfg.save(&path).unwrap();
4024 let after = std::fs::read_to_string(&path).unwrap();
4025 assert!(
4026 !after.contains("[categories]"),
4027 "the section must stay unmaterialized: {after}"
4028 );
4029 assert!(
4030 after.contains("category_map"),
4031 "while the board's own mapping survives: {after}"
4032 );
4033 }
4034
4035 #[test]
4050 fn a_full_category_config_round_trips_with_its_values_and_order() {
4051 let tmp = TempDir::new().unwrap();
4052 let authored = r#"issues_dir = "docs/issues"
4058
4059[categories]
4060order = ["backlog", "active", "completed", "canceled"]
4061default = "backlog"
4062
4063[transitions]
4064terminal_locked = false
4065
4066[kanban.default]
4067columns = ["triage", "wip", "shipped"]
4068done_column = "shipped"
4069category_map = { triage = "backlog", wip = "active", shipped = "completed" }
4070
4071[export]
4072"#;
4073 let path = write_toml(tmp.path(), authored);
4074 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4075 cfg.save(&path).unwrap();
4076 let after = std::fs::read_to_string(&path).unwrap();
4077 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4078
4079 assert_eq!(
4080 reloaded.categories, cfg.categories,
4081 "the vocabulary and its default survive"
4082 );
4083 assert_eq!(
4084 reloaded.categories.order,
4085 vec!["backlog", "active", "completed", "canceled"],
4086 "and `order` is a Vec, so the declared order is preserved exactly"
4087 );
4088
4089 assert_eq!(
4090 reloaded.default_config().category_map,
4091 cfg.default_config().category_map,
4092 "the mapping's contents survive, in whatever order they are written"
4093 );
4094 assert!(after.contains("[categories]"), "after: {after}");
4102 assert!(after.contains("[transitions]"), "after: {after}");
4103 assert!(
4104 !reloaded.transitions.terminal_locked,
4105 "and the value survives"
4106 );
4107
4108 let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
4115 reloaded_again.save(&path).unwrap();
4116 assert_eq!(
4117 std::fs::read_to_string(&path).unwrap(),
4118 after,
4119 "a second save must be a no-op"
4120 );
4121 }
4122
4123 #[test]
4126 fn two_boards_with_different_columns_map_to_the_same_categories() {
4127 let tmp = TempDir::new().unwrap();
4128 let path = write_toml(
4129 tmp.path(),
4130 r#"issues_dir = "docs/issues"
4131
4132[kanban.alpha]
4133columns = ["triage", "wip", "review", "shipped"]
4134done_column = "shipped"
4135category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
4136
4137[kanban.beta]
4138columns = ["new", "doing", "merged"]
4139done_column = "merged"
4140
4141[export]
4142"#,
4143 );
4144 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4145 let alpha = &cfg.kanban.configurations["alpha"];
4146 let beta = &cfg.kanban.configurations["beta"];
4147 assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
4150 assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
4151 assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
4152 assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
4153 assert!(beta.category_map.is_empty());
4154 }
4155
4156 #[test]
4160 fn canceled_is_terminal_but_not_complete() {
4161 assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
4162 assert!(TERMINAL_CATEGORIES.contains(&"completed"));
4163 assert_eq!(
4164 COMPLETE_CATEGORY, "completed",
4165 "completion is defined by this category alone"
4166 );
4167 assert_ne!(COMPLETE_CATEGORY, "canceled");
4168 let tmp = TempDir::new().unwrap();
4171 let path = write_toml(
4172 tmp.path(),
4173 r#"issues_dir = "docs/issues"
4174
4175[kanban.default]
4176columns = ["triage", "dropped", "shipped"]
4177done_column = "shipped"
4178category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
4179"#,
4180 );
4181 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4182 let board = cfg.default_config();
4183 assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
4184 assert_ne!(
4185 board.category_for("dropped", &cfg.categories),
4186 COMPLETE_CATEGORY
4187 );
4188 }
4189
4190 #[test]
4194 fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
4195 assert!(CATEGORY_KEYS.contains(&"blocked"));
4196 let tmp = TempDir::new().unwrap();
4197 let path = write_toml(
4198 tmp.path(),
4199 r#"issues_dir = "docs/issues"
4200
4201[kanban.default]
4202columns = ["wip", "on hold", "shipped"]
4203done_column = "shipped"
4204category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
4205"#,
4206 );
4207 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4208 let board = cfg.default_config();
4209 assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
4210 assert!(
4212 !cfg.default_config()
4213 .category_map
4214 .values()
4215 .any(|v| v.contains("state")),
4216 "a category is a position, never a derived state"
4217 );
4218 }
4219
4220 #[test]
4221 fn legacy_file_roundtrips_byte_identical() {
4222 let tmp = TempDir::new().unwrap();
4223 let legacy = LEGACY_PRETTY_BOARD;
4227 let path = write_toml(tmp.path(), legacy);
4228 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4229 assert_eq!(cfg.planning, PlanningConfig::default());
4230 assert!(cfg.teams.is_empty());
4231 assert!(cfg.cycles.is_empty());
4232 assert_eq!(cfg.milestones_dir, None);
4233 assert_eq!(
4237 cfg.default_config().columns,
4238 ["todo", "in progress", "in review", "done"]
4239 );
4240 assert_eq!(cfg.default_config().effective_done_column(), Some("done"));
4241 assert_eq!(cfg.default_config().order_by, OrderBy::CreatedAtDesc);
4242 assert_eq!(
4243 cfg.default_config().hidden_columns,
4244 [] as [std::string::String; 0]
4245 );
4246 assert!(!cfg.default_config().show_mismatch);
4247 assert!(cfg.default_config().wrap_titles);
4248 assert_eq!(cfg.default_config().title_lines, DEFAULT_TITLE_LINES);
4249 assert!(!cfg.default_config().category_map.contains_key("done"));
4250
4251 cfg.save(&path).unwrap();
4252 let on_disk = std::fs::read_to_string(&path).unwrap();
4253 assert_eq!(
4254 on_disk, legacy,
4255 "legacy tix.toml must save byte-identical (NFR-003)"
4256 );
4257 assert!(
4258 !on_disk.contains("[export]"),
4259 "an empty `[export]` table must not be materialized:\n{on_disk}"
4260 );
4261 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4262 assert_eq!(cfg, reloaded);
4263 }
4264
4265 #[test]
4266 fn legacy_file_in_any_layout_never_gains_planning_keys() {
4267 let tmp = TempDir::new().unwrap();
4268 let legacy = r#"issues_dir = "docs/issues"
4272[kanban.default]
4273columns = ["todo", "in progress", "in review", "done"]
4274done_column = "done"
4275"#;
4276 let path = write_toml(tmp.path(), legacy);
4277 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4278 cfg.save(&path).unwrap();
4279 let on_disk = std::fs::read_to_string(&path).unwrap();
4280 assert!(!on_disk.contains("milestones_dir"));
4281 assert!(!on_disk.contains("[planning]"));
4282 assert!(!on_disk.contains("[teams]"));
4283 assert!(!on_disk.contains("[cycles"));
4284 }
4285
4286 #[test]
4287 fn planning_default_section_with_partial_keys_loads() {
4288 let tmp = TempDir::new().unwrap();
4289 let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
4290 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4291 assert_eq!(
4292 cfg.planning.cycle_number, 1,
4293 "counter defaults when omitted"
4294 );
4295 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
4296 assert_eq!(cfg.planning.default_milestone, None);
4297 assert_eq!(cfg.planning.default_team, None);
4298 }
4299
4300 #[test]
4301 fn planning_rejects_unknown_keys() {
4302 let tmp = TempDir::new().unwrap();
4303 let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
4304 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4305 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4306 assert!(err.to_string().contains("counter"));
4307 }
4308
4309 #[test]
4310 fn planning_rejects_zero_cycle_number() {
4311 let tmp = TempDir::new().unwrap();
4312 let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
4313 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4314 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4315 assert!(err.to_string().contains("cycle_number"));
4316 }
4317
4318 #[test]
4319 fn cycle_dates_are_validated_and_compared() {
4320 let tmp = TempDir::new().unwrap();
4321 let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
4323 let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
4324 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4325 assert!(err.to_string().contains("start_date"));
4326
4327 let ordered = write_toml(
4329 tmp.path(),
4330 "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
4331 );
4332 let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
4333 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4334 assert!(err.to_string().contains("due_date"));
4335 assert!(err.to_string().contains("start_date"));
4336 }
4337
4338 #[test]
4339 fn cycles_reject_unknown_keys() {
4340 let tmp = TempDir::new().unwrap();
4341 let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
4342 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4343 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4344 assert!(err.to_string().contains("bogus"));
4345 }
4346
4347 #[test]
4348 fn cycle_without_dates_loads_fine() {
4349 let tmp = TempDir::new().unwrap();
4350 let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
4351 let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
4352 assert_eq!(
4353 cfg.cycles.get("Alpha").unwrap().description.as_deref(),
4354 Some("a")
4355 );
4356 }
4357
4358 #[test]
4359 fn teams_validate_members_nonempty_and_unique() {
4360 let tmp = TempDir::new().unwrap();
4361 let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
4362 let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
4363 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4364 assert!(err.to_string().contains("members"));
4365
4366 let dup = write_toml(
4367 tmp.path(),
4368 "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
4369 );
4370 let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
4371 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4372 assert!(err.to_string().contains("unique"));
4373 }
4374
4375 #[test]
4376 fn empty_teams_table_is_disabled_not_error() {
4377 let tmp = TempDir::new().unwrap();
4378 let path = write_toml(tmp.path(), "[teams]\n");
4379 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4380 assert!(cfg.teams.is_empty());
4381 cfg.save(&path).unwrap();
4383 let on_disk = std::fs::read_to_string(&path).unwrap();
4384 assert!(!on_disk.contains("[teams]"));
4385 }
4386
4387 #[test]
4388 fn blank_milestones_dir_rejected() {
4389 let tmp = TempDir::new().unwrap();
4390 let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
4391 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4392 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4393 assert!(err.to_string().contains("milestones_dir"));
4394 }
4395
4396 #[test]
4397 fn sparse_keybindings_roundtrip_without_defaults() {
4398 let tmp = TempDir::new().unwrap();
4399 let path = write_toml(
4400 tmp.path(),
4401 "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
4402 );
4403 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4404 config.save(&path).unwrap();
4405 let contents = std::fs::read_to_string(&path).unwrap();
4406 assert!(contents.contains("[keybindings.board]"));
4407 assert!(!contents.contains("move_left"));
4408 assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
4409 }
4410
4411 #[test]
4412 fn omitted_keybindings_retain_effective_defaults() {
4413 let tmp = TempDir::new().unwrap();
4414 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
4415 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4416 let bindings = config.effective_keybindings().unwrap();
4417 assert_eq!(
4418 bindings.label(
4419 crate::keybindings::InputContext::Board,
4420 crate::keybindings::TuiAction::NewIssue
4421 ),
4422 "n"
4423 );
4424 }
4425
4426 #[test]
4431 fn edit_conflict_keybindings_override_the_prompt() {
4432 use crate::keybindings::{InputContext, TuiAction};
4433 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4434
4435 let tmp = TempDir::new().unwrap();
4436 let path = write_toml(
4437 tmp.path(),
4438 "[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
4439 );
4440 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4441 let bindings = config.effective_keybindings().unwrap();
4442
4443 assert_eq!(
4444 bindings.label(InputContext::EditConflict, TuiAction::Confirm),
4445 "Ctrl+k"
4446 );
4447 assert_eq!(
4448 bindings.action(
4449 InputContext::EditConflict,
4450 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
4451 ),
4452 Some(TuiAction::Confirm)
4453 );
4454 assert_eq!(
4455 bindings.action(
4456 InputContext::EditConflict,
4457 KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
4458 ),
4459 None,
4460 "an override replaces the action's whole alias list"
4461 );
4462 assert_eq!(
4464 bindings.action(
4465 InputContext::EditConflict,
4466 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
4467 ),
4468 Some(TuiAction::Reedit)
4469 );
4470 assert_eq!(
4471 bindings.action(
4472 InputContext::EditConflict,
4473 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
4474 ),
4475 Some(TuiAction::Ignore)
4476 );
4477 }
4478
4479 #[test]
4480 fn keybindings_reject_unknown_fields_and_empty_actions() {
4481 let tmp = TempDir::new().unwrap();
4482 let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
4483 assert!(
4484 Config::load_with_warning(&unknown, |_| {})
4485 .unwrap_err()
4486 .to_string()
4487 .contains("bogus")
4488 );
4489 let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
4490 assert!(
4491 Config::load_with_warning(&empty, |_| {})
4492 .unwrap_err()
4493 .to_string()
4494 .contains("new_issue")
4495 );
4496 }
4497
4498 #[test]
4502 fn shell_mode_switches_are_configurable() {
4503 use crate::keybindings::{InputContext, TuiAction};
4504 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4505
4506 let tmp = TempDir::new().unwrap();
4507 let path = write_toml(
4508 tmp.path(),
4509 "[keybindings.shell]\nswitch_to_milestones = [\"g\"]\nswitch_to_board = [\"f3\", \"b\"]\n",
4510 );
4511 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4512 let bindings = config.effective_keybindings().unwrap();
4513
4514 assert_eq!(
4516 bindings.label(InputContext::Shell, TuiAction::SwitchToMilestones),
4517 "g"
4518 );
4519 assert_eq!(
4520 bindings.label(InputContext::Shell, TuiAction::SwitchToBoard),
4521 "F3 / b"
4522 );
4523 assert_eq!(
4525 bindings.action(
4526 InputContext::Shell,
4527 KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
4528 ),
4529 Some(TuiAction::SwitchToMilestones)
4530 );
4531 assert_eq!(
4532 bindings.action(
4533 InputContext::Shell,
4534 KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
4535 ),
4536 None,
4537 "an override replaces the action's whole alias list"
4538 );
4539 assert_eq!(
4540 bindings.action(
4541 InputContext::Shell,
4542 KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
4543 ),
4544 Some(TuiAction::SwitchToBoard),
4545 "a second alias keeps working"
4546 );
4547
4548 let unknown = write_toml(
4550 tmp.path(),
4551 "[keybindings.shell]\nswitch_to_nowhere = [\"g\"]\n",
4552 );
4553 let error = Config::load_with_warning(&unknown, |_| {})
4554 .unwrap_err()
4555 .to_string();
4556 assert!(
4557 error.contains("switch_to_nowhere"),
4558 "the error names the offending key: {error:?}"
4559 );
4560
4561 let bad_chord = write_toml(
4563 tmp.path(),
4564 "[keybindings.shell]\nswitch_to_milestones = [\"ctrl+nosuchkey\"]\n",
4565 );
4566 let error = Config::load_with_warning(&bad_chord, |_| {})
4567 .unwrap_err()
4568 .to_string();
4569 assert!(
4570 error.contains("switch_to_milestones") && error.contains("nosuchkey"),
4571 "the error names the key and the chord: {error:?}"
4572 );
4573 }
4574
4575 #[test]
4578 fn templates_round_trip_through_save_and_load() {
4579 let tmp = TempDir::new().unwrap();
4580 let path = tmp.path().join("tix.toml");
4581 let mut config = Config::starter();
4582 config.templates.insert(
4583 "bug".into(),
4584 IssueTemplate {
4585 title: Some("Bug: ".into()),
4586 body: Some("## Steps\n".into()),
4587 labels: vec!["bug".into(), "urgent".into()],
4588 status: Some("todo".into()),
4589 cycles: vec!["Default".into()],
4590 start_date: Some("2026-01-01".into()),
4591 due_date: Some("2026-01-02".into()),
4592 ..IssueTemplate::default()
4593 },
4594 );
4595 config.validate().expect("the fixture is a valid config");
4596 config.save(&path).unwrap();
4597
4598 let raw = std::fs::read_to_string(&path).unwrap();
4599 assert!(raw.contains("[templates.bug]"), "{raw}");
4600 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4601 assert_eq!(reloaded.templates, config.templates);
4602 let bug = &reloaded.templates["bug"];
4603 assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
4604 assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
4605 assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
4606 }
4607
4608 #[test]
4609 fn a_config_without_templates_writes_no_section() {
4610 let tmp = TempDir::new().unwrap();
4611 let path = tmp.path().join("tix.toml");
4612 Config::default().save(&path).unwrap();
4613 let raw = std::fs::read_to_string(&path).unwrap();
4614 assert!(
4615 !raw.contains("templates"),
4616 "an absent section must not appear on disk:\n{raw}"
4617 );
4618 assert!(
4619 Config::load_with_warning(&path, |_| {})
4620 .unwrap()
4621 .templates
4622 .is_empty()
4623 );
4624 }
4625
4626 #[test]
4627 fn a_template_value_with_a_comma_is_rejected() {
4628 let tmp = TempDir::new().unwrap();
4629 let path = write_toml(
4630 tmp.path(),
4631 "issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
4632 );
4633 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4634 assert!(
4635 err.to_string().contains("must not contain a comma"),
4636 "got {err}"
4637 );
4638 }
4639
4640 #[test]
4641 fn a_template_with_a_backwards_date_range_is_rejected() {
4642 let tmp = TempDir::new().unwrap();
4643 let path = write_toml(
4644 tmp.path(),
4645 "issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
4646 start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
4647 );
4648 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4649 assert!(err.to_string().contains(">= start_date"), "got {err}");
4650 }
4651
4652 #[test]
4653 fn an_unknown_template_key_is_rejected() {
4654 let tmp = TempDir::new().unwrap();
4655 let path = write_toml(
4656 tmp.path(),
4657 "issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
4658 );
4659 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4660 assert!(err.to_string().contains("unknown field"), "got {err}");
4661 }
4662
4663 #[test]
4666 fn issue_templates_round_trip_through_save_and_load() {
4667 let tmp = TempDir::new().unwrap();
4668 let path = tmp.path().join("tix.toml");
4669 let mut config = Config::starter();
4670 config
4671 .issue_templates
4672 .insert(
4673 "bug",
4674 Template {
4675 name: Some("Bug report".to_string()),
4676 defaults: TemplateDefaults {
4677 labels: vec!["bug".to_string()],
4678 priority: Some("high".to_string()),
4679 ..TemplateDefaults::default()
4680 },
4681 prompts: vec![TemplatePrompt {
4682 key: "priority".to_string(),
4683 label: Some("Priority".to_string()),
4684 kind: PromptKind::Select,
4685 required: true,
4686 default: None,
4687 options_ref: Some("priorities".to_string()),
4688 }],
4689 references: vec![TemplateReference {
4690 kind: ReferenceKind::Label,
4691 value: "bug".to_string(),
4692 required: true,
4693 }],
4694 ..Template::default()
4695 },
4696 )
4697 .unwrap();
4698 config.validate().expect("the fixture is a valid config");
4699 config.save(&path).unwrap();
4700
4701 let raw = std::fs::read_to_string(&path).unwrap();
4702 assert!(raw.contains("[issue_templates.bug]"), "{raw}");
4703 assert!(raw.contains("[[issue_templates.bug.prompts]]"), "{raw}");
4704
4705 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4706 assert_eq!(reloaded.issue_templates, config.issue_templates);
4707 let bug = reloaded.issue_templates.get("bug").unwrap();
4708 assert_eq!(bug.name.as_deref(), Some("Bug report"));
4709 assert_eq!(bug.defaults.labels, ["bug".to_string()]);
4710 assert_eq!(bug.prompts[0].kind, PromptKind::Select);
4711 assert!(bug.prompts[0].required);
4712 assert_eq!(bug.references[0].kind, ReferenceKind::Label);
4713 }
4714
4715 #[test]
4716 fn a_config_without_issue_templates_writes_no_section() {
4717 let tmp = TempDir::new().unwrap();
4718 let path = tmp.path().join("tix.toml");
4719 Config::default().save(&path).unwrap();
4720 let raw = std::fs::read_to_string(&path).unwrap();
4721 assert!(
4722 !raw.contains("issue_templates"),
4723 "an absent section must not appear on disk:\n{raw}"
4724 );
4725 assert!(
4726 Config::load_with_warning(&path, |_| {})
4727 .unwrap()
4728 .issue_templates
4729 .is_empty()
4730 );
4731 }
4732
4733 #[test]
4734 fn a_legacy_config_gains_no_issue_templates_section() {
4735 let tmp = TempDir::new().unwrap();
4736 let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
4737 let before = std::fs::read_to_string(&path).unwrap();
4738 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4739 assert!(
4740 config.issue_templates.is_empty(),
4741 "a legacy config has no stable-ID registry"
4742 );
4743 config.save(&path).unwrap();
4744 assert_eq!(
4745 std::fs::read_to_string(&path).unwrap(),
4746 before,
4747 "a legacy config must round-trip byte-identically"
4748 );
4749 }
4750
4751 #[test]
4752 fn issue_template_declaration_order_is_restored() {
4753 let tmp = TempDir::new().unwrap();
4754 let path = write_toml(
4755 tmp.path(),
4756 "issues_dir = \"docs/issues\"\n\n\
4757 [issue_templates.zeta]\nname = \"Zeta\"\n\n\
4758 [issue_templates.alpha]\nname = \"Alpha\"\n",
4759 );
4760 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4761 assert_eq!(
4762 config.issue_templates.ids().collect::<Vec<_>>(),
4763 ["zeta", "alpha"],
4764 "declaration order is the display order"
4765 );
4766 config.save(&path).unwrap();
4767 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4768 assert_eq!(
4769 reloaded.issue_templates.ids().collect::<Vec<_>>(),
4770 ["zeta", "alpha"]
4771 );
4772 }
4773
4774 #[test]
4775 fn a_rejected_template_write_leaves_tix_toml_unchanged() {
4776 let tmp = TempDir::new().unwrap();
4777 let path = write_toml(
4778 tmp.path(),
4779 "issues_dir = \"docs/issues\"\n\n[issue_templates.bug]\nname = \"Bug report\"\n",
4780 );
4781 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4782 let before = std::fs::read(&path).unwrap();
4783
4784 let mut rejected = config.clone();
4787 rejected.issue_templates.get_mut("bug").unwrap().scope =
4788 TemplateScope::board("no-such-board");
4789 let err = rejected.validate().unwrap_err().to_string();
4790 assert!(
4791 err.contains("issue_templates.bug.scope") && err.contains("no-such-board"),
4792 "{err}"
4793 );
4794 assert_eq!(
4795 std::fs::read(&path).unwrap(),
4796 before,
4797 "a rejected write must not touch tix.toml"
4798 );
4799
4800 let mut duplicate = config.clone();
4803 let err = duplicate
4804 .issue_templates
4805 .insert("bug", Template::default())
4806 .unwrap_err();
4807 assert!(err.to_string().contains("already exists"), "{err}");
4808 assert_eq!(duplicate.issue_templates, config.issue_templates);
4809 assert_eq!(std::fs::read(&path).unwrap(), before);
4810 }
4811
4812 #[test]
4813 fn unknown_issue_template_metadata_round_trips_byte_stably() {
4814 let tmp = TempDir::new().unwrap();
4815 let path = write_toml(
4816 tmp.path(),
4817 "issues_dir = \"docs/issues\"\n\n\
4818 [issue_templates.bug]\nname = \"Bug report\"\n\
4819 reporter_hint = \"include the build id\"\n\n\
4820 [issue_templates.bug.future]\nlevel = 3\n",
4821 );
4822 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4823 let bug = config.issue_templates.get("bug").unwrap();
4824 assert_eq!(
4825 bug.extensions
4826 .get("reporter_hint")
4827 .and_then(toml::Value::as_str),
4828 Some("include the build id")
4829 );
4830 assert!(bug.extensions.get("future").is_some());
4831
4832 config.save(&path).unwrap();
4833 let first = std::fs::read(&path).unwrap();
4834 let text = String::from_utf8(first.clone()).unwrap();
4835 assert!(
4836 text.contains("reporter_hint") && text.contains("future"),
4837 "unknown metadata must survive a save:\n{text}"
4838 );
4839
4840 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4841 reloaded.save(&path).unwrap();
4842 assert_eq!(
4843 std::fs::read(&path).unwrap(),
4844 first,
4845 "a second round trip must be byte-identical"
4846 );
4847 }
4848
4849 #[test]
4852 fn wip_config_round_trips_byte_stably_and_resolves() {
4853 let tmp = TempDir::new().unwrap();
4854 let configured = "issues_dir = \"docs/issues\"\n\n\
4855 [kanban.default]\ncolumns = [\n \"todo\",\n \"in progress\",\n \"done\",\n]\n\n\
4856 [kanban.default.wip.units]\nd = 1\nw = 5\n\n\
4857 [kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
4858 max_estimate = 10\nunit = \"d\"\npolicy = \"block\"\n";
4859 let path = write_toml(tmp.path(), configured);
4860 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4861
4862 let board = config.default_config();
4863 let limit = board
4864 .wip
4865 .effective_limit("in progress")
4866 .expect("the configured limit is in force");
4867 assert_eq!(limit.max_items, Some(3));
4868 assert_eq!(limit.max_estimate, Some(10));
4869 assert_eq!(limit.unit.as_deref(), Some("d"));
4870 assert_eq!(limit.policy, Some(crate::wip::LimitPolicy::Block));
4871 assert_eq!(board.wip.factor("w"), Some(5));
4872 assert!(
4873 board.wip.effective_limit("done").is_none(),
4874 "a column without a limit has none"
4875 );
4876
4877 config.save(&path).unwrap();
4878 assert_eq!(
4879 std::fs::read_to_string(&path).unwrap(),
4880 configured,
4881 "a configured board must round trip byte-for-byte"
4882 );
4883 }
4884
4885 #[test]
4888 fn wip_config_limit_on_an_unknown_column_is_refused() {
4889 let tmp = TempDir::new().unwrap();
4890 let path = write_toml(
4891 tmp.path(),
4892 "issues_dir = \"docs/issues\"\n\n\
4893 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
4894 [kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
4895 policy = \"warn\"\n",
4896 );
4897 let error = Config::load_with_warning(&path, |_| {})
4898 .expect_err("an unknown column is refused")
4899 .to_string();
4900 assert!(
4901 error.contains("kanban.default.wip.limits.`in progress`"),
4902 "{error}"
4903 );
4904 }
4905
4906 #[test]
4909 fn wip_config_absent_limits_add_no_key() {
4910 let tmp = TempDir::new().unwrap();
4911 let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
4912 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4913 assert!(config.default_config().wip.is_empty());
4914 config.save(&path).unwrap();
4915 assert_eq!(
4916 std::fs::read_to_string(&path).unwrap(),
4917 LEGACY_PRETTY_BOARD,
4918 "a board without limits must not gain a `wip` key"
4919 );
4920 }
4921
4922 const TWO_PROJECTS: &str = "issues_dir = \"docs/issues\"\n\n\
4925 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
4926 [projects.\"p-3f9a2c\"]\n\
4927 name = \"Platform hardening\"\n\
4928 description = \"Reduce operational risk across the sync path.\"\n\
4929 status = \"active\"\n\
4930 lead = \"Alice <alice@example.com>\"\n\
4931 start_date = \"2026-09-01\"\n\
4932 due_date = \"2026-12-31\"\n\n\
4933 [projects.\"p-7b1e04\"]\n\
4934 name = \"Docs refresh\"\n";
4935
4936 #[test]
4937 fn project_registry_parses_every_contract_field() {
4938 let tmp = TempDir::new().unwrap();
4939 let path = write_toml(tmp.path(), TWO_PROJECTS);
4940 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4941
4942 let platform = &config.projects["p-3f9a2c"];
4943 assert_eq!(platform.name, "Platform hardening");
4944 assert_eq!(
4945 platform.description.as_deref(),
4946 Some("Reduce operational risk across the sync path.")
4947 );
4948 assert_eq!(platform.status.as_deref(), Some("active"));
4949 assert_eq!(platform.lead.as_deref(), Some("Alice <alice@example.com>"));
4950 assert_eq!(platform.start_date.as_deref(), Some("2026-09-01"));
4951 assert_eq!(platform.due_date.as_deref(), Some("2026-12-31"));
4952
4953 let docs = &config.projects["p-7b1e04"];
4956 assert_eq!(docs.name, "Docs refresh");
4957 assert!(docs.description.is_none() && docs.status.is_none() && docs.lead.is_none());
4958 assert!(docs.start_date.is_none() && docs.due_date.is_none());
4959 }
4960
4961 #[test]
4962 fn project_registry_rejects_an_unknown_field() {
4963 let tmp = TempDir::new().unwrap();
4964 let path = write_toml(
4965 tmp.path(),
4966 "issues_dir = \"docs/issues\"\n\n\
4967 [projects.\"p-1\"]\nname = \"One\"\nownr = \"typo\"\n",
4968 );
4969 let err = Config::load_with_warning(&path, |_| {})
4970 .unwrap_err()
4971 .to_string();
4972 assert!(err.contains("ownr"), "got {err}");
4973 }
4974
4975 #[test]
4976 fn project_declaration_order_is_restored() {
4977 let tmp = TempDir::new().unwrap();
4978 let path = write_toml(
4979 tmp.path(),
4980 "issues_dir = \"docs/issues\"\n\n\
4981 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
4982 [projects.\"p-zeta\"]\nname = \"Zeta\"\n\n\
4983 [projects.\"p-alpha\"]\nname = \"Alpha\"\n",
4984 );
4985 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4986 assert_eq!(
4987 config.projects.keys().collect::<Vec<_>>(),
4988 ["p-zeta", "p-alpha"],
4989 "declaration order is the display order"
4990 );
4991 config.save(&path).unwrap();
4992 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4993 assert_eq!(
4994 reloaded.projects.keys().collect::<Vec<_>>(),
4995 ["p-zeta", "p-alpha"],
4996 "declaration order survives a save/reload through the ordered map"
4997 );
4998 }
4999
5000 #[test]
5001 fn a_legacy_tix_toml_gains_no_projects_section() {
5002 let tmp = TempDir::new().unwrap();
5003 let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5004 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5005 assert!(config.projects.is_empty());
5006 config.save(&path).unwrap();
5007 assert_eq!(
5008 std::fs::read_to_string(&path).unwrap(),
5009 LEGACY_PRETTY_BOARD,
5010 "a repository with no projects must round-trip byte-identically"
5011 );
5012 }
5013
5014 #[test]
5015 fn an_empty_project_record_writes_no_optional_keys() {
5016 let tmp = TempDir::new().unwrap();
5017 let path = write_toml(
5018 tmp.path(),
5019 "issues_dir = \"docs/issues\"\n\n\
5020 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5021 [projects.\"p-1\"]\nname = \"One\"\n",
5022 );
5023 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5024 config.save(&path).unwrap();
5025 let after = std::fs::read_to_string(&path).unwrap();
5026 for key in ["description", "status", "lead", "start_date", "due_date"] {
5027 assert!(
5028 !after.contains(key),
5029 "an absent optional field must not be written: {key} in {after}"
5030 );
5031 }
5032 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5033 assert_eq!(reloaded, config);
5034 }
5035
5036 #[test]
5037 fn a_blank_project_name_is_rejected() {
5038 let tmp = TempDir::new().unwrap();
5039 let path = write_toml(
5040 tmp.path(),
5041 "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" \"\n",
5042 );
5043 let err = Config::load_with_warning(&path, |_| {})
5044 .unwrap_err()
5045 .to_string();
5046 assert!(err.contains("projects.p-1.name"), "got {err}");
5047 }
5048
5049 #[test]
5050 fn duplicate_project_names_are_rejected() {
5051 for second in ["Same", "same"] {
5055 let tmp = TempDir::new().unwrap();
5056 let path = write_toml(
5057 tmp.path(),
5058 &format!(
5059 "issues_dir = \"docs/issues\"\n\n\
5060 [projects.\"p-1\"]\nname = \"Same\"\n\n\
5061 [projects.\"p-2\"]\nname = \"{second}\"\n"
5062 ),
5063 );
5064 let err = Config::load_with_warning(&path, |_| {})
5065 .unwrap_err()
5066 .to_string();
5067 assert!(err.contains("must be unique"), "got {err}");
5068 }
5069 }
5070
5071 #[test]
5072 fn a_padded_project_name_is_rejected() {
5073 let tmp = TempDir::new().unwrap();
5074 let path = write_toml(
5075 tmp.path(),
5076 "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" One \"\n",
5077 );
5078 let err = Config::load_with_warning(&path, |_| {})
5079 .unwrap_err()
5080 .to_string();
5081 assert!(err.contains("leading or trailing whitespace"), "got {err}");
5082 }
5083
5084 #[test]
5085 fn a_blank_project_id_is_rejected() {
5086 let tmp = TempDir::new().unwrap();
5087 let path = write_toml(
5088 tmp.path(),
5089 "issues_dir = \"docs/issues\"\n\n[projects.\"\"]\nname = \"No id\"\n",
5090 );
5091 let err = Config::load_with_warning(&path, |_| {})
5092 .unwrap_err()
5093 .to_string();
5094 assert!(err.contains("must not be blank"), "got {err}");
5095 }
5096
5097 #[test]
5098 fn a_project_id_with_a_structural_character_is_rejected() {
5099 let tmp = TempDir::new().unwrap();
5100 let path = write_toml(
5101 tmp.path(),
5102 "issues_dir = \"docs/issues\"\n\n[projects.\"p.1\"]\nname = \"Dotted\"\n",
5103 );
5104 let err = Config::load_with_warning(&path, |_| {})
5105 .unwrap_err()
5106 .to_string();
5107 assert!(err.contains("contains"), "got {err}");
5108 }
5109
5110 #[test]
5111 fn blank_optional_project_text_is_rejected() {
5112 for key in ["description", "status", "lead"] {
5113 let tmp = TempDir::new().unwrap();
5114 let path = write_toml(
5115 tmp.path(),
5116 &format!(
5117 "issues_dir = \"docs/issues\"\n\n\
5118 [projects.\"p-1\"]\nname = \"One\"\n{key} = \"\"\n"
5119 ),
5120 );
5121 let err = Config::load_with_warning(&path, |_| {})
5122 .unwrap_err()
5123 .to_string();
5124 assert!(err.contains(&format!("projects.p-1.{key}")), "got {err}");
5125 }
5126 }
5127
5128 #[test]
5129 fn a_bad_or_inverted_project_date_is_rejected() {
5130 let tmp = TempDir::new().unwrap();
5131 let bad = write_toml(
5132 tmp.path(),
5133 "issues_dir = \"docs/issues\"\n\n\
5134 [projects.\"p-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
5135 );
5136 let err = Config::load_with_warning(&bad, |_| {})
5137 .unwrap_err()
5138 .to_string();
5139 assert!(err.contains("projects.p-1"), "got {err}");
5140
5141 let inverted = write_toml(
5142 tmp.path(),
5143 "issues_dir = \"docs/issues\"\n\n\
5144 [projects.\"p-1\"]\nname = \"One\"\n\
5145 start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
5146 );
5147 let err = Config::load_with_warning(&inverted, |_| {})
5148 .unwrap_err()
5149 .to_string();
5150 assert!(err.contains("projects.p-1"), "got {err}");
5151 }
5152
5153 #[test]
5154 fn a_valid_project_date_pair_passes() {
5155 let tmp = TempDir::new().unwrap();
5156 let path = write_toml(tmp.path(), TWO_PROJECTS);
5157 Config::load_with_warning(&path, |_| {}).expect("a valid registry must load");
5158 }
5159
5160 #[test]
5162 fn allocated_project_ids_have_the_generated_shape_and_successive_draws_differ() {
5163 let mut config = Config::default();
5164 let first = config.allocate_project_id();
5165 config.projects.insert(
5166 first.to_string(),
5167 ProjectConfiguration {
5168 name: "One".into(),
5169 ..ProjectConfiguration::default()
5170 },
5171 );
5172 let second = config.allocate_project_id();
5173 assert_ne!(first, second, "two allocations must be distinct");
5174 assert_eq!(first.as_str().len(), 18, "`p-` plus 16 hex digits");
5175 assert!(first.as_str().starts_with("p-"));
5176
5177 config.projects.shift_remove(first.as_str());
5182 let third = config.allocate_project_id();
5183 assert_ne!(
5184 third, first,
5185 "a deleted project's id must not be handed out"
5186 );
5187 assert_ne!(third, second);
5188 }
5189
5190 #[test]
5194 fn allocation_redraws_past_a_stored_id() {
5195 let mut config = Config::default();
5196 config.projects.insert(
5197 "p-aaaa".into(),
5198 ProjectConfiguration {
5199 name: "Stored".into(),
5200 ..ProjectConfiguration::default()
5201 },
5202 );
5203 let scripted = ["p-aaaa", "p-bbbb"];
5204 let mut draws = 0;
5205 let allocated = config.allocate_project_id_with(|| {
5206 let drawn = scripted[draws];
5207 draws += 1;
5208 ProjectId::new(drawn).unwrap()
5209 });
5210 assert_eq!(allocated.as_str(), "p-bbbb");
5211 assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5212 }
5213
5214 #[test]
5219 fn schedule_allocation_redraws_past_a_stored_id() {
5220 let mut config = Config::default();
5221 config.cycle_schedules.insert(
5222 "cs-aaaa".into(),
5223 crate::cycle_schedule::config::CycleScheduleConfiguration {
5224 name: "Stored".into(),
5225 ..crate::cycle_schedule::config::CycleScheduleConfiguration::default()
5226 },
5227 );
5228 let scripted = ["cs-aaaa", "cs-bbbb"];
5229 let mut draws = 0;
5230 let allocated = config.allocate_cycle_schedule_id_with(|| {
5231 let drawn = scripted[draws];
5232 draws += 1;
5233 crate::cycle_schedule::config::CycleScheduleId::new(drawn).unwrap()
5234 });
5235 assert_eq!(allocated.as_str(), "cs-bbbb");
5236 assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5237 }
5238
5239 #[test]
5240 fn a_hand_written_project_id_loads_and_is_never_allocated_again() {
5241 let tmp = TempDir::new().unwrap();
5242 let path = write_toml(
5243 tmp.path(),
5244 "issues_dir = \"docs/issues\"\n\n\
5245 [projects.\"p-ffffffffffffffff\"]\nname = \"Hand written\"\n",
5246 );
5247 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5248 assert!(config.projects.contains_key("p-ffffffffffffffff"));
5249 let mut draws = 0;
5252 let allocated = config.allocate_project_id_with(|| {
5253 draws += 1;
5254 if draws < 3 {
5255 ProjectId::new("p-ffffffffffffffff").unwrap()
5256 } else {
5257 ProjectId::new("p-0000000000000000").unwrap()
5258 }
5259 });
5260 assert_eq!(allocated.as_str(), "p-0000000000000000");
5261 assert_eq!(draws, 3);
5262 }
5263
5264 #[test]
5265 fn a_rename_changes_the_label_and_never_the_key() {
5266 let tmp = TempDir::new().unwrap();
5267 let path = write_toml(tmp.path(), TWO_PROJECTS);
5268 let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
5269 config.projects["p-3f9a2c"].name = "Platform hardening (renamed)".into();
5270 config.save(&path).unwrap();
5271
5272 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5273 assert!(
5274 reloaded.projects.contains_key("p-3f9a2c"),
5275 "the id is the key and must survive a rename"
5276 );
5277 assert_eq!(
5278 reloaded.projects["p-3f9a2c"].name,
5279 "Platform hardening (renamed)"
5280 );
5281 }
5282
5283 #[test]
5284 fn project_id_validation_accepts_handles_and_rejects_junk() {
5285 assert!(ProjectId::new("p-platform").is_ok());
5286 assert!(ProjectId::new("p-3f9a2c").is_ok());
5287 assert!(ProjectId::new("").is_err());
5288 assert!(ProjectId::new(" p-1").is_err(), "surrounding whitespace");
5289 assert!(ProjectId::new("p 1").is_err(), "inner whitespace");
5290 assert!(ProjectId::new("p.1").is_err(), "structural `.`");
5291 assert!(ProjectId::new(&"x".repeat(ProjectId::MAX_LEN + 1)).is_err());
5292 }
5293
5294 const TWO_EPICS: &str = "issues_dir = \"docs/issues\"\n\n\
5297 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5298 [epics.\"e-7c4a91\"]\n\
5299 name = \"Harden the sync path\"\n\
5300 description = \"Reduce operational risk across every tracker adapter.\"\n\
5301 status = \"active\"\n\
5302 owner = \"Alice <alice@example.com>\"\n\
5303 start_date = \"2026-09-01\"\n\
5304 due_date = \"2026-12-31\"\n\
5305 weight = 5\n\
5306 parent = \"e-2b8f30\"\n\
5307 projects = [\"p-3f9a2c\"]\n\
5308 milestone = \"2026-Q4\"\n\
5309 cycle = \"October 2026\"\n\n\
5310 [epics.\"e-2b8f30\"]\n\
5311 name = \"Q4 reliability\"\n";
5312
5313 #[test]
5314 fn epic_registry_parses_every_contract_field() {
5315 let tmp = TempDir::new().unwrap();
5316 let path = write_toml(tmp.path(), TWO_EPICS);
5317 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5318
5319 let sync = &config.epics["e-7c4a91"];
5320 assert_eq!(sync.name, "Harden the sync path");
5321 assert_eq!(
5322 sync.description.as_deref(),
5323 Some("Reduce operational risk across every tracker adapter.")
5324 );
5325 assert_eq!(sync.status.as_deref(), Some("active"));
5326 assert_eq!(sync.owner.as_deref(), Some("Alice <alice@example.com>"));
5327 assert_eq!(sync.start_date.as_deref(), Some("2026-09-01"));
5328 assert_eq!(sync.due_date.as_deref(), Some("2026-12-31"));
5329 assert_eq!(sync.weight, Some(5));
5330 assert_eq!(sync.parent.as_deref(), Some("e-2b8f30"));
5331 assert_eq!(sync.projects, ["p-3f9a2c"]);
5332 assert_eq!(sync.milestone.as_deref(), Some("2026-Q4"));
5333 assert_eq!(sync.cycle.as_deref(), Some("October 2026"));
5334
5335 let q4 = &config.epics["e-2b8f30"];
5338 assert_eq!(q4.name, "Q4 reliability");
5339 assert!(q4.description.is_none() && q4.status.is_none() && q4.owner.is_none());
5340 assert!(q4.start_date.is_none() && q4.due_date.is_none());
5341 assert!(q4.weight.is_none() && q4.parent.is_none() && q4.milestone.is_none());
5342 assert!(q4.cycle.is_none());
5343 assert_eq!(q4.projects, [] as [std::string::String; 0]);
5344 }
5345
5346 #[test]
5347 fn epic_registry_rejects_an_unknown_field() {
5348 let tmp = TempDir::new().unwrap();
5349 let path = write_toml(
5350 tmp.path(),
5351 "issues_dir = \"docs/issues\"\n\n\
5352 [epics.\"e-1\"]\nname = \"One\"\nownr = \"typo\"\n",
5353 );
5354 let err = Config::load_with_warning(&path, |_| {})
5355 .unwrap_err()
5356 .to_string();
5357 assert!(err.contains("ownr"), "got {err}");
5358 }
5359
5360 #[test]
5363 fn an_epic_record_takes_weight_but_never_an_estimate_field() {
5364 let tmp = TempDir::new().unwrap();
5365 let path = write_toml(
5366 tmp.path(),
5367 "issues_dir = \"docs/issues\"\n\n\
5368 [epics.\"e-1\"]\nname = \"One\"\nestimate = \"2\"\n",
5369 );
5370 let err = Config::load_with_warning(&path, |_| {})
5371 .unwrap_err()
5372 .to_string();
5373 assert!(err.contains("estimate"), "got {err}");
5374 }
5375
5376 #[test]
5377 fn epic_declaration_order_is_restored() {
5378 let tmp = TempDir::new().unwrap();
5379 let path = write_toml(
5380 tmp.path(),
5381 "issues_dir = \"docs/issues\"\n\n\
5382 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5383 [epics.\"e-zeta\"]\nname = \"Zeta\"\n\n\
5384 [epics.\"e-alpha\"]\nname = \"Alpha\"\n",
5385 );
5386 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5387 assert_eq!(
5388 config.epics.keys().collect::<Vec<_>>(),
5389 ["e-zeta", "e-alpha"],
5390 "declaration order is the display order"
5391 );
5392 config.save(&path).unwrap();
5393 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5394 assert_eq!(
5395 reloaded.epics.keys().collect::<Vec<_>>(),
5396 ["e-zeta", "e-alpha"],
5397 "declaration order survives a save/reload through the ordered map"
5398 );
5399 }
5400
5401 #[test]
5402 fn a_legacy_tix_toml_gains_no_epics_section() {
5403 let tmp = TempDir::new().unwrap();
5404 let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5405 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5406 assert!(config.epics.is_empty());
5407 config.save(&path).unwrap();
5408 let after = std::fs::read_to_string(&path).unwrap();
5409 assert_eq!(
5410 after, LEGACY_PRETTY_BOARD,
5411 "a repository with no epics must round-trip byte-identically"
5412 );
5413 assert!(
5414 !after.contains("[epics"),
5415 "an empty registry writes no section: {after}"
5416 );
5417 }
5418
5419 #[test]
5422 fn epics_and_projects_registries_coexist() {
5423 let tmp = TempDir::new().unwrap();
5424 let path = write_toml(tmp.path(), TWO_PROJECTS);
5425 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5426 assert!(config.epics.is_empty(), "no `[epics]` means no epics");
5427 config.save(&path).unwrap();
5428 let after = std::fs::read_to_string(&path).unwrap();
5429 assert!(!after.contains("[epics"), "got {after}");
5430 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5431 assert_eq!(reloaded.projects, config.projects);
5432 assert!(reloaded.epics.is_empty());
5433
5434 let both_dir = TempDir::new().unwrap();
5435 let both = write_toml(
5436 both_dir.path(),
5437 "issues_dir = \"docs/issues\"\n\n\
5438 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5439 [projects.\"p-3f9a2c\"]\nname = \"Platform\"\n\n\
5440 [epics.\"e-1\"]\nname = \"Harden\"\nprojects = [\"p-3f9a2c\"]\n",
5441 );
5442 let config = Config::load_with_warning(&both, |_| {}).unwrap();
5443 assert_eq!(config.projects.keys().collect::<Vec<_>>(), ["p-3f9a2c"]);
5444 assert_eq!(config.epics.keys().collect::<Vec<_>>(), ["e-1"]);
5445 config.save(&both).unwrap();
5446 let reloaded = Config::load_with_warning(&both, |_| {}).unwrap();
5447 assert_eq!(reloaded.projects, config.projects);
5448 assert_eq!(reloaded.epics, config.epics);
5449 }
5450
5451 #[test]
5452 fn an_empty_epic_record_writes_no_optional_keys() {
5453 let tmp = TempDir::new().unwrap();
5454 let path = write_toml(
5455 tmp.path(),
5456 "issues_dir = \"docs/issues\"\n\n\
5457 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5458 [epics.\"e-1\"]\nname = \"One\"\n",
5459 );
5460 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5461 config.save(&path).unwrap();
5462 let after = std::fs::read_to_string(&path).unwrap();
5463 for key in [
5464 "description",
5465 "status",
5466 "owner",
5467 "start_date",
5468 "due_date",
5469 "weight",
5470 "parent",
5471 "projects",
5472 "milestone",
5473 "cycle",
5474 ] {
5475 assert!(
5476 !after.contains(key),
5477 "an absent optional field must not be written: {key} in {after}"
5478 );
5479 }
5480 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5481 assert_eq!(reloaded, config);
5482 }
5483
5484 #[test]
5485 fn a_blank_epic_name_is_rejected() {
5486 let tmp = TempDir::new().unwrap();
5487 let path = write_toml(
5488 tmp.path(),
5489 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" \"\n",
5490 );
5491 let err = Config::load_with_warning(&path, |_| {})
5492 .unwrap_err()
5493 .to_string();
5494 assert!(err.contains("epics.e-1.name"), "got {err}");
5495 }
5496
5497 #[test]
5498 fn duplicate_epic_names_are_rejected() {
5499 for second in ["Same", "same"] {
5500 let tmp = TempDir::new().unwrap();
5501 let path = write_toml(
5502 tmp.path(),
5503 &format!(
5504 "issues_dir = \"docs/issues\"\n\n\
5505 [epics.\"e-1\"]\nname = \"Same\"\n\n\
5506 [epics.\"e-2\"]\nname = \"{second}\"\n"
5507 ),
5508 );
5509 let err = Config::load_with_warning(&path, |_| {})
5510 .unwrap_err()
5511 .to_string();
5512 assert!(err.contains("must be unique"), "got {err}");
5513 }
5514 }
5515
5516 #[test]
5517 fn a_padded_epic_name_is_rejected() {
5518 let tmp = TempDir::new().unwrap();
5519 let path = write_toml(
5520 tmp.path(),
5521 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" One \"\n",
5522 );
5523 let err = Config::load_with_warning(&path, |_| {})
5524 .unwrap_err()
5525 .to_string();
5526 assert!(err.contains("leading or trailing whitespace"), "got {err}");
5527 }
5528
5529 #[test]
5530 fn a_blank_epic_id_is_rejected() {
5531 let tmp = TempDir::new().unwrap();
5532 let path = write_toml(
5533 tmp.path(),
5534 "issues_dir = \"docs/issues\"\n\n[epics.\"\"]\nname = \"No id\"\n",
5535 );
5536 let err = Config::load_with_warning(&path, |_| {})
5537 .unwrap_err()
5538 .to_string();
5539 assert!(err.contains("must not be blank"), "got {err}");
5540 }
5541
5542 #[test]
5543 fn an_epic_id_with_a_structural_character_is_rejected() {
5544 let tmp = TempDir::new().unwrap();
5545 let path = write_toml(
5546 tmp.path(),
5547 "issues_dir = \"docs/issues\"\n\n[epics.\"e.1\"]\nname = \"Dotted\"\n",
5548 );
5549 let err = Config::load_with_warning(&path, |_| {})
5550 .unwrap_err()
5551 .to_string();
5552 assert!(err.contains("contains"), "got {err}");
5553 }
5554
5555 #[test]
5556 fn blank_optional_epic_text_is_rejected() {
5557 for key in ["description", "status", "owner", "milestone", "cycle"] {
5558 let tmp = TempDir::new().unwrap();
5559 let path = write_toml(
5560 tmp.path(),
5561 &format!(
5562 "issues_dir = \"docs/issues\"\n\n\
5563 [epics.\"e-1\"]\nname = \"One\"\n{key} = \"\"\n"
5564 ),
5565 );
5566 let err = Config::load_with_warning(&path, |_| {})
5567 .unwrap_err()
5568 .to_string();
5569 assert!(err.contains(&format!("epics.e-1.{key}")), "got {err}");
5570 }
5571 }
5572
5573 #[test]
5574 fn a_bad_or_inverted_epic_date_is_rejected() {
5575 let tmp = TempDir::new().unwrap();
5576 let bad = write_toml(
5577 tmp.path(),
5578 "issues_dir = \"docs/issues\"\n\n\
5579 [epics.\"e-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
5580 );
5581 let err = Config::load_with_warning(&bad, |_| {})
5582 .unwrap_err()
5583 .to_string();
5584 assert!(err.contains("epics.e-1"), "got {err}");
5585
5586 let inverted = write_toml(
5587 tmp.path(),
5588 "issues_dir = \"docs/issues\"\n\n\
5589 [epics.\"e-1\"]\nname = \"One\"\n\
5590 start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
5591 );
5592 let err = Config::load_with_warning(&inverted, |_| {})
5593 .unwrap_err()
5594 .to_string();
5595 assert!(err.contains("epics.e-1"), "got {err}");
5596 }
5597
5598 #[test]
5599 fn a_non_positive_or_unparseable_weight_is_rejected() {
5600 for body in ["weight = 0", "weight = -3"] {
5601 let tmp = TempDir::new().unwrap();
5602 let path = write_toml(
5603 tmp.path(),
5604 &format!(
5605 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
5606 ),
5607 );
5608 let err = Config::load_with_warning(&path, |_| {})
5609 .unwrap_err()
5610 .to_string();
5611 assert!(err.contains("positive integer"), "{body}: got {err}");
5612 assert!(err.contains("epics.e-1.weight"), "{body}: got {err}");
5613 }
5614
5615 let tmp = TempDir::new().unwrap();
5617 let path = write_toml(
5618 tmp.path(),
5619 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\nweight = \"big\"\n",
5620 );
5621 let err = Config::load_with_warning(&path, |_| {})
5622 .unwrap_err()
5623 .to_string();
5624 assert!(err.contains("weight"), "got {err}");
5625 }
5626
5627 #[test]
5632 fn epic_references_are_shape_checked_but_never_resolved() {
5633 let tmp = TempDir::new().unwrap();
5634 let path = write_toml(
5635 tmp.path(),
5636 "issues_dir = \"docs/issues\"\n\n\
5637 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5638 [epics.\"e-1\"]\nname = \"Child\"\n\
5639 parent = \"e-missing\"\nprojects = [\"p-missing\"]\n\
5640 milestone = \"2026-Q4\"\ncycle = \"No such cycle\"\n",
5641 );
5642 let config = Config::load_with_warning(&path, |_| {})
5643 .expect("a dangling reference must stay loadable");
5644 let epic = &config.epics["e-1"];
5645 assert_eq!(epic.parent.as_deref(), Some("e-missing"));
5646 assert_eq!(epic.projects, ["p-missing"]);
5647 assert_eq!(epic.milestone.as_deref(), Some("2026-Q4"));
5648 assert_eq!(epic.cycle.as_deref(), Some("No such cycle"));
5649
5650 for (body, needle) in [
5652 ("parent = \"e.1\"", "epics.e-1.parent"),
5653 ("parent = \"\"", "epics.e-1.parent"),
5654 ("parent = \"e-1\"", "must not name the epic itself"),
5655 ("projects = [\"p.1\"]", "epics.e-1.projects"),
5656 ] {
5657 let tmp = TempDir::new().unwrap();
5658 let path = write_toml(
5659 tmp.path(),
5660 &format!(
5661 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
5662 ),
5663 );
5664 let err = Config::load_with_warning(&path, |_| {})
5665 .unwrap_err()
5666 .to_string();
5667 assert!(err.contains(needle), "{body}: got {err}");
5668 }
5669 }
5670
5671 #[test]
5673 fn allocated_epic_ids_have_the_generated_shape_and_successive_draws_differ() {
5674 let mut config = Config::default();
5675 let first = config.allocate_epic_id();
5676 config.epics.insert(
5677 first.to_string(),
5678 EpicConfiguration {
5679 name: "One".into(),
5680 ..EpicConfiguration::default()
5681 },
5682 );
5683 let second = config.allocate_epic_id();
5684 assert_ne!(first, second, "two allocations must be distinct");
5685 assert_eq!(first.as_str().len(), 18, "`e-` plus 16 hex digits");
5686 assert!(first.as_str().starts_with("e-"));
5687
5688 config.epics.shift_remove(first.as_str());
5691 let third = config.allocate_epic_id();
5692 assert_ne!(third, first, "a deleted epic's id must not be handed out");
5693 assert_ne!(third, second);
5694 }
5695
5696 #[test]
5700 fn epic_allocation_redraws_past_a_stored_id() {
5701 let mut config = Config::default();
5702 config.epics.insert(
5703 "e-aaaa".into(),
5704 EpicConfiguration {
5705 name: "Stored".into(),
5706 ..EpicConfiguration::default()
5707 },
5708 );
5709 let scripted = ["e-aaaa", "e-bbbb"];
5710 let mut draws = 0;
5711 let allocated = config.allocate_epic_id_with(|| {
5712 let drawn = scripted[draws];
5713 draws += 1;
5714 EpicId::new(drawn).unwrap()
5715 });
5716 assert_eq!(allocated.as_str(), "e-bbbb");
5717 assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5718 }
5719
5720 #[test]
5721 fn a_hand_written_epic_id_loads_and_is_never_allocated_again() {
5722 let tmp = TempDir::new().unwrap();
5723 let path = write_toml(
5724 tmp.path(),
5725 "issues_dir = \"docs/issues\"\n\n\
5726 [epics.\"e-ffffffffffffffff\"]\nname = \"Hand written\"\n",
5727 );
5728 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5729 assert!(config.epics.contains_key("e-ffffffffffffffff"));
5730 let mut draws = 0;
5733 let allocated = config.allocate_epic_id_with(|| {
5734 draws += 1;
5735 if draws < 3 {
5736 EpicId::new("e-ffffffffffffffff").unwrap()
5737 } else {
5738 EpicId::new("e-0000000000000000").unwrap()
5739 }
5740 });
5741 assert_eq!(allocated.as_str(), "e-0000000000000000");
5742 assert_eq!(draws, 3);
5743 }
5744
5745 #[test]
5746 fn an_epic_rename_changes_the_label_and_never_the_key() {
5747 let tmp = TempDir::new().unwrap();
5748 let path = write_toml(tmp.path(), TWO_EPICS);
5749 let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
5750 config.epics["e-7c4a91"].name = "Harden the sync path (renamed)".into();
5751 config.save(&path).unwrap();
5752
5753 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5754 assert!(
5755 reloaded.epics.contains_key("e-7c4a91"),
5756 "the id is the key and must survive a rename"
5757 );
5758 assert_eq!(
5759 reloaded.epics["e-7c4a91"].name,
5760 "Harden the sync path (renamed)"
5761 );
5762 }
5763
5764 #[test]
5765 fn epic_id_validation_accepts_handles_and_rejects_junk() {
5766 assert!(EpicId::new("e-platform").is_ok());
5767 assert!(EpicId::new("e-7c4a91").is_ok());
5768 assert!(EpicId::new("").is_err());
5769 assert!(EpicId::new(" e-1").is_err(), "surrounding whitespace");
5770 assert!(EpicId::new("e 1").is_err(), "inner whitespace");
5771 assert!(EpicId::new("e.1").is_err(), "structural `.`");
5772 assert!(EpicId::new(&"x".repeat(EpicId::MAX_LEN + 1)).is_err());
5773 }
5774
5775 #[test]
5779 fn the_two_registries_share_the_id_rules() {
5780 assert_eq!(
5781 ProjectId::new("").unwrap_err().to_string(),
5782 "config error: a project id must not be blank"
5783 );
5784 assert_eq!(
5785 EpicId::new("").unwrap_err().to_string(),
5786 "config error: an epic id must not be blank"
5787 );
5788 assert_eq!(
5789 ProjectId::new(" p-1").unwrap_err().to_string(),
5790 "config error: project id ` p-1` has leading or trailing whitespace"
5791 );
5792 assert_eq!(
5793 EpicId::new("e-1 ").unwrap_err().to_string(),
5794 "config error: epic id `e-1 ` has leading or trailing whitespace"
5795 );
5796 assert_eq!(ProjectId::MAX_LEN, EpicId::MAX_LEN);
5797
5798 for handle in ["x-y", "a_b", "a:b", "a-1"] {
5801 assert!(ProjectId::new(handle).is_ok(), "{handle}");
5802 assert!(EpicId::new(handle).is_ok(), "{handle}");
5803 }
5804 for junk in ["", " a", "a b", "a.b", "a[b", "a\"b"] {
5805 assert!(ProjectId::new(junk).is_err(), "{junk}");
5806 assert!(EpicId::new(junk).is_err(), "{junk}");
5807 }
5808 }
5809
5810 const ONE_INITIATIVE: &str = "issues_dir = \"docs/issues\"\n\n\
5811 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5812 [initiatives.\"i-1\"]\nname = \"Grow the platform\"\n";
5813
5814 #[test]
5815 fn initiative_id_validation_accepts_handles_and_rejects_junk() {
5816 assert!(InitiativeId::new("i-outcome").is_ok());
5817 assert!(InitiativeId::new("i-7c4a91").is_ok());
5818 assert!(InitiativeId::new("").is_err());
5819 assert!(InitiativeId::new(" i-1").is_err(), "surrounding whitespace");
5820 assert!(InitiativeId::new("i 1").is_err(), "inner whitespace");
5821 assert!(InitiativeId::new("i.1").is_err(), "structural `.`");
5822 assert!(InitiativeId::new(&"x".repeat(InitiativeId::MAX_LEN + 1)).is_err());
5823 }
5824
5825 #[test]
5828 fn the_three_registries_share_the_id_rules() {
5829 assert_eq!(
5830 InitiativeId::new("").unwrap_err().to_string(),
5831 "config error: an initiative id must not be blank"
5832 );
5833 assert_eq!(
5834 InitiativeId::new(" i-1").unwrap_err().to_string(),
5835 "config error: initiative id ` i-1` has leading or trailing whitespace"
5836 );
5837 assert_eq!(InitiativeId::MAX_LEN, ProjectId::MAX_LEN);
5838 assert_eq!(InitiativeId::MAX_LEN, EpicId::MAX_LEN);
5839 }
5840
5841 #[test]
5842 fn allocated_initiative_ids_have_the_generated_shape_and_successive_draws_differ() {
5843 let mut config = Config::default();
5844 let first = config.allocate_initiative_id();
5845 config.initiatives.insert(
5846 first.to_string(),
5847 InitiativeConfiguration {
5848 name: "One".into(),
5849 ..InitiativeConfiguration::default()
5850 },
5851 );
5852 let second = config.allocate_initiative_id();
5853 assert_ne!(first, second, "two allocations must be distinct");
5854 assert_eq!(first.as_str().len(), 18, "`i-` plus 16 hex digits");
5855 assert!(first.as_str().starts_with("i-"));
5856
5857 config.initiatives.shift_remove(first.as_str());
5860 let third = config.allocate_initiative_id();
5861 assert_ne!(
5862 third, first,
5863 "a deleted initiative's id must not be handed out"
5864 );
5865 assert_ne!(third, second);
5866 }
5867
5868 #[test]
5869 fn initiative_allocation_redraws_past_a_stored_id() {
5870 let mut config = Config::default();
5871 config.initiatives.insert(
5872 "i-aaaa".into(),
5873 InitiativeConfiguration {
5874 name: "Stored".into(),
5875 ..InitiativeConfiguration::default()
5876 },
5877 );
5878 let scripted = ["i-aaaa", "i-bbbb"];
5879 let mut draws = 0;
5880 let allocated = config.allocate_initiative_id_with(|| {
5881 let drawn = scripted[draws];
5882 draws += 1;
5883 InitiativeId::new(drawn).unwrap()
5884 });
5885 assert_eq!(allocated.as_str(), "i-bbbb");
5886 assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5887 }
5888
5889 #[test]
5892 fn an_absent_initiatives_section_and_an_empty_record_write_nothing_extra() {
5893 let tmp = TempDir::new().unwrap();
5894 let legacy = write_toml(
5895 tmp.path(),
5896 "issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
5897 );
5898 let config = Config::load_with_warning(&legacy, |_| {}).unwrap();
5899 assert!(config.initiatives.is_empty());
5900 config.save(&legacy).unwrap();
5901 assert!(
5902 !std::fs::read_to_string(&legacy)
5903 .unwrap()
5904 .contains("[initiatives"),
5905 "a legacy file gains no section"
5906 );
5907
5908 let path = write_toml(tmp.path(), ONE_INITIATIVE);
5909 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5910 config.save(&path).unwrap();
5911 let after = std::fs::read_to_string(&path).unwrap();
5912 for key in [
5913 "outcome",
5914 "owner",
5915 "status",
5916 "priority",
5917 "labels",
5918 "target_date",
5919 "projects",
5920 "epics",
5921 "milestones",
5922 ] {
5923 assert!(
5924 !after.contains(key),
5925 "an absent optional field must not be written: {key} in {after}"
5926 );
5927 }
5928 assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
5929 }
5930
5931 #[test]
5935 fn an_initiative_preserves_unknown_metadata_through_a_round_trip() {
5936 let tmp = TempDir::new().unwrap();
5937 let path = write_toml(
5938 tmp.path(),
5939 "issues_dir = \"docs/issues\"\n\n\
5940 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5941 [initiatives.\"i-1\"]\nname = \"One\"\ncustom_field = \"keep me\"\n\
5942 nested = { answer = 42 }\n",
5943 );
5944 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5945 let extensions = &config.initiatives["i-1"].extensions;
5946 assert_eq!(extensions.len(), 2);
5947 assert_eq!(
5948 extensions.get("custom_field").and_then(toml::Value::as_str),
5949 Some("keep me")
5950 );
5951 config.save(&path).unwrap();
5952 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5953 assert_eq!(reloaded.initiatives["i-1"].extensions, *extensions);
5954 }
5955
5956 #[test]
5957 fn initiative_validation_refuses_blank_names_bad_dates_and_bad_references() {
5958 for (body, needle) in [
5959 ("name = \" \"", "initiatives.i-1.name"),
5960 ("name = \" One \"", "leading or trailing whitespace"),
5961 (
5962 "name = \"One\"\ntarget_date = \"2026-13-01\"",
5963 "initiatives.i-1.target_date",
5964 ),
5965 (
5966 "name = \"One\"\ntarget_date = \"not a date\"",
5967 "initiatives.i-1.target_date",
5968 ),
5969 (
5970 "name = \"One\"\nprojects = [\"p.1\"]",
5971 "initiatives.i-1.projects",
5972 ),
5973 ("name = \"One\"\nepics = [\"e.1\"]", "initiatives.i-1.epics"),
5974 (
5975 "name = \"One\"\nmilestones = [\"\"]",
5976 "initiatives.i-1.milestones",
5977 ),
5978 (
5979 "name = \"One\"\nlabels = [\" \"]",
5980 "initiatives.i-1.labels",
5981 ),
5982 ("name = \"One\"\nowner = \"\"", "initiatives.i-1.owner"),
5983 ] {
5984 let tmp = TempDir::new().unwrap();
5985 let path = write_toml(
5986 tmp.path(),
5987 &format!(
5988 "issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5989 [initiatives.\"i-1\"]\n{body}\n"
5990 ),
5991 );
5992 let err = Config::load_with_warning(&path, |_| {})
5993 .unwrap_err()
5994 .to_string();
5995 assert!(err.contains(needle), "{body}: got {err}");
5996 }
5997 }
5998
5999 #[test]
6000 fn duplicate_initiative_names_are_rejected_case_folded() {
6001 for second in ["Same", "same"] {
6002 let tmp = TempDir::new().unwrap();
6003 let path = write_toml(
6004 tmp.path(),
6005 &format!(
6006 "issues_dir = \"docs/issues\"\n\n\
6007 [initiatives.\"i-1\"]\nname = \"Same\"\n\n\
6008 [initiatives.\"i-2\"]\nname = \"{second}\"\n"
6009 ),
6010 );
6011 let err = Config::load_with_warning(&path, |_| {})
6012 .unwrap_err()
6013 .to_string();
6014 assert!(err.contains("must be unique"), "got {err}");
6015 }
6016 }
6017
6018 #[test]
6021 fn initiative_references_are_shape_checked_but_never_resolved() {
6022 let tmp = TempDir::new().unwrap();
6023 let path = write_toml(
6024 tmp.path(),
6025 "issues_dir = \"docs/issues\"\n\n\
6026 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6027 [initiatives.\"i-1\"]\nname = \"One\"\n\
6028 projects = [\"p-missing\"]\nepics = [\"e-missing\"]\nmilestones = [\"2026-Q4\"]\n",
6029 );
6030 let config = Config::load_with_warning(&path, |_| {})
6031 .expect("a dangling reference must stay loadable");
6032 let initiative = &config.initiatives["i-1"];
6033 assert_eq!(initiative.projects, ["p-missing"]);
6034 assert_eq!(initiative.epics, ["e-missing"]);
6035 assert_eq!(initiative.milestones, ["2026-Q4"]);
6036 }
6037
6038 #[test]
6042 fn initiative_declaration_order_survives_a_save_and_reload() {
6043 let tmp = TempDir::new().unwrap();
6044 let path = write_toml(
6045 tmp.path(),
6046 "issues_dir = \"docs/issues\"\n\n\
6047 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6048 [initiatives.\"i-zulu\"]\nname = \"Zulu\"\n\n\
6049 [initiatives.\"i-alpha\"]\nname = \"Alpha\"\n",
6050 );
6051 let config = Config::load_with_warning(&path, |_| {}).unwrap();
6052 assert_eq!(
6053 config.initiatives.keys().collect::<Vec<_>>(),
6054 ["i-zulu", "i-alpha"]
6055 );
6056 config.save(&path).unwrap();
6057 let written = std::fs::read_to_string(&path).unwrap();
6058 assert!(
6059 written.find("[initiatives.i-zulu]").unwrap()
6060 < written.find("[initiatives.i-alpha]").unwrap(),
6061 "declaration order is preserved on write:\n{written}"
6062 );
6063 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6064 assert_eq!(
6065 reloaded.initiatives.keys().collect::<Vec<_>>(),
6066 ["i-zulu", "i-alpha"]
6067 );
6068 }
6069
6070 #[test]
6074 fn an_initiative_priority_uses_the_shared_shape() {
6075 let tmp = TempDir::new().unwrap();
6076 let path = write_toml(
6077 tmp.path(),
6078 "issues_dir = \"docs/issues\"\n\n\
6079 [initiatives.\"i-1\"]\nname = \"Known\"\npriority = \"HIGH\"\n\n\
6080 [initiatives.\"i-2\"]\nname = \"Unknown\"\npriority = \"urgent-ish\"\n",
6081 );
6082 let config = Config::load_with_warning(&path, |_| {}).unwrap();
6083 assert_eq!(
6084 config.initiatives["i-1"].priority,
6085 Some(crate::model::Priority::High)
6086 );
6087 assert_eq!(
6088 config.initiatives["i-2"].priority,
6089 Some(crate::model::Priority::Other("urgent-ish".into()))
6090 );
6091 config.save(&path).unwrap();
6092 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6093 let written = std::fs::read_to_string(&path).unwrap();
6094 assert!(written.contains("priority = \"high\""), "{written}");
6095 assert!(written.contains("priority = \"urgent-ish\""), "{written}");
6096 assert_eq!(reloaded.initiatives, config.initiatives);
6097 }
6098
6099 #[test]
6104 fn a_shadowing_extension_key_is_refused_by_validation() {
6105 for key in INITIATIVE_KNOWN_FIELDS {
6106 let mut extensions = crate::roadmap::registry::InitiativeExtensions::default();
6107 extensions.insert(*key, toml::Value::String("evil".into()));
6108 let mut config = Config::default();
6109 config.initiatives.insert(
6110 "i-1".into(),
6111 InitiativeConfiguration {
6112 name: "One".into(),
6113 extensions,
6114 ..InitiativeConfiguration::default()
6115 },
6116 );
6117 let err = config.validate().unwrap_err().to_string();
6118 assert!(
6119 err.contains("collides with a built-in field"),
6120 "{key}: got {err}"
6121 );
6122 }
6123
6124 let mut config = Config::default();
6128 config.initiatives.insert(
6129 "i-1".into(),
6130 InitiativeConfiguration {
6131 name: "One".into(),
6132 ..InitiativeConfiguration::default()
6133 },
6134 );
6135 config.validate().unwrap();
6136 }
6137}