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 #[serde(default, skip_serializing_if = "BeadsConfig::is_default")]
1415 pub beads: BeadsConfig,
1416}
1417
1418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1424#[serde(default, deny_unknown_fields)]
1425pub struct BeadsConfig {
1426 pub mode: BeadsMode,
1428 pub binary: String,
1430 #[serde(skip_serializing_if = "Option::is_none")]
1436 pub workspace: Option<PathBuf>,
1437 pub id_prefix: String,
1442}
1443
1444impl Default for BeadsConfig {
1445 fn default() -> Self {
1446 BeadsConfig {
1447 mode: BeadsMode::default(),
1448 binary: crate::sync::beads::DEFAULT_BINARY.to_string(),
1449 workspace: None,
1450 id_prefix: crate::sync::beads::DEFAULT_ID_PREFIX.to_string(),
1451 }
1452 }
1453}
1454
1455impl BeadsConfig {
1456 #[must_use]
1461 pub fn is_default(&self) -> bool {
1462 *self == BeadsConfig::default()
1463 }
1464}
1465
1466#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1468#[serde(rename_all = "lowercase")]
1469pub enum BeadsMode {
1470 #[default]
1473 Off,
1474 Mirror,
1477 Sync,
1481}
1482
1483impl BeadsMode {
1484 #[must_use]
1486 pub fn as_str(self) -> &'static str {
1487 match self {
1488 BeadsMode::Off => "off",
1489 BeadsMode::Mirror => "mirror",
1490 BeadsMode::Sync => "sync",
1491 }
1492 }
1493
1494 #[must_use]
1497 pub fn from_key(key: &str) -> Option<Self> {
1498 match key {
1499 "off" => Some(BeadsMode::Off),
1500 "mirror" => Some(BeadsMode::Mirror),
1501 "sync" => Some(BeadsMode::Sync),
1502 _ => None,
1503 }
1504 }
1505
1506 pub const ALL: [BeadsMode; 3] = [BeadsMode::Off, BeadsMode::Mirror, BeadsMode::Sync];
1508}
1509
1510#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1522#[serde(default, deny_unknown_fields)]
1523pub struct CustomAttributes {
1524 pub keys: Vec<String>,
1528}
1529
1530impl CustomAttributes {
1531 pub fn is_empty(&self) -> bool {
1533 self.keys.is_empty()
1534 }
1535}
1536
1537pub(crate) fn default_true() -> bool {
1542 true
1543}
1544#[allow(clippy::trivially_copy_pass_by_ref)] pub(crate) fn is_true(value: &bool) -> bool {
1548 *value
1549}
1550
1551pub const DEFAULT_TYPES: &[&str] = &[
1558 "default",
1559 "bug",
1560 "feature",
1561 "task",
1562 "improvement",
1563 "documentation",
1564 "question",
1565 "duplicate",
1566 "chore",
1567 "open",
1568];
1569
1570pub const BUILTIN_LABELS: &[&str] = &["decision-pending"];
1578
1579pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
1586 ("default", "gray"),
1587 ("bug", "red"),
1588 ("feature", "green"),
1589 ("task", "blue"),
1590 ("improvement", "cyan"),
1591 ("documentation", "yellow"),
1592 ("question", "magenta"),
1593 ("duplicate", "dark_gray"),
1594 ("chore", "dark_gray"),
1595 ("open", "white"),
1596];
1597
1598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1609#[serde(default, deny_unknown_fields)]
1610pub struct MentionsConfig {
1611 pub enabled: bool,
1613 pub dir: PathBuf,
1616}
1617
1618pub const DEFAULT_MENTIONS_DIR: &str = "docs/mentions";
1620
1621impl Default for MentionsConfig {
1622 fn default() -> Self {
1623 MentionsConfig {
1624 enabled: true,
1625 dir: PathBuf::from(DEFAULT_MENTIONS_DIR),
1626 }
1627 }
1628}
1629
1630impl MentionsConfig {
1631 #[must_use]
1633 pub fn is_default(&self) -> bool {
1634 *self == Self::default()
1635 }
1636
1637 #[must_use]
1643 pub fn resolve_dir(&self, issues_dir: &Path) -> PathBuf {
1644 if self.dir.is_absolute() {
1645 return self.dir.clone();
1646 }
1647 match Config::project_root(issues_dir) {
1648 Some(root) => root.join(&self.dir),
1649 None => self.dir.clone(),
1650 }
1651 }
1652}
1653
1654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1661#[serde(default, deny_unknown_fields)]
1662pub struct LayoutConfig {
1663 pub directory: String,
1665 pub filename: String,
1667 pub move_on_change: bool,
1669}
1670
1671impl Default for LayoutConfig {
1672 fn default() -> Self {
1673 LayoutConfig {
1674 directory: "{assignee}/{type}/{status}".to_string(),
1675 filename: "{date}-{id}-{slug}.md".to_string(),
1681 move_on_change: true,
1682 }
1683 }
1684}
1685
1686impl LayoutConfig {
1687 #[must_use]
1689 pub fn is_default(&self) -> bool {
1690 *self == Self::default()
1691 }
1692}
1693
1694impl Default for Config {
1695 fn default() -> Self {
1696 Config {
1697 kanban: KanbanSection {
1698 configurations: IndexMap::from([(
1699 "default".to_string(),
1700 BoardConfiguration::default(),
1701 )]),
1702 },
1703 issues_dir: PathBuf::from("docs/issues"),
1704 milestones_dir: None,
1705 teams: IndexMap::new(),
1706 cycles: IndexMap::new(),
1707 templates: IndexMap::new(),
1708 views: crate::views::registry::ViewRegistry::new(),
1709 health: HealthConfig::default(),
1710 issue_templates: TemplateRegistry::default(),
1711 planning: PlanningConfig::default(),
1712 categories: CategoryConfig::default(),
1713 transitions: TransitionConfig::default(),
1714 triage: None,
1721 editor: None,
1722 viewer: None,
1723 state: StateConfig::default(),
1724 export: ExportConfig::default(),
1725 agent: None,
1726 keybindings: KeyBindings::default(),
1727 custom_attributes: CustomAttributes::default(),
1728 lint_foreign_repositories: true,
1729 tips_on_startup: true,
1730 mentions: MentionsConfig::default(),
1731 layout: LayoutConfig::default(),
1732 types: None,
1733 labels: None,
1734 type_colors: IndexMap::new(),
1735 tags_dir: None,
1736 repos: IndexMap::new(),
1737 parent_repo: None,
1738 projects: IndexMap::new(),
1739 epics: IndexMap::new(),
1740 initiatives: IndexMap::new(),
1741 cycle_schedules: IndexMap::new(),
1742 capacity: CapacityConfig::new(),
1743 beads: BeadsConfig::default(),
1744 }
1745 }
1746}
1747
1748impl Config {
1749 #[must_use]
1763 pub fn starter() -> Config {
1764 let mut config = Config::default();
1765 config
1766 .kanban
1767 .configurations
1768 .insert("default".to_string(), starter_board());
1769 config.triage = Some(crate::triage::TriageConfig {
1770 entry_status: Some("triage".to_string()),
1771 accepted_status: Some("todo".to_string()),
1772 declined_status: Some("wontfix".to_string()),
1773 deferred_status: None,
1774 });
1775 config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
1779 config.tags_dir = Some(PathBuf::from("docs/tags"));
1786 config
1787 }
1788
1789 pub(crate) fn validate(&self) -> Result<()> {
1793 if self.issues_dir.as_os_str().is_empty() {
1794 return Err(TixError::Config("`issues_dir` must not be blank".into()));
1795 }
1796 if self.milestones_dir().as_os_str().is_empty() {
1797 return Err(TixError::Config(
1798 "`milestones_dir` must not be blank".into(),
1799 ));
1800 }
1801 if self.tags_dir().as_os_str().is_empty() {
1802 return Err(TixError::Config("`tags_dir` must not be blank".into()));
1803 }
1804 validate_repos(&self.repos)?;
1805 self.health.policy()?;
1810 if self
1811 .parent_repo
1812 .as_deref()
1813 .is_some_and(|p| p.as_os_str().is_empty())
1814 {
1815 return Err(TixError::Config("`parent_repo` must not be blank".into()));
1816 }
1817 if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
1822 return Err(TixError::Config(
1823 "`viewer` must not be blank; use `auto` for the default chain".into(),
1824 ));
1825 }
1826 validate_planning(&self.planning)?;
1827 crate::layout::validate(&self.layout)?;
1828 validate_categories(&self.categories)?;
1829 self.effective_keybindings()?;
1830 if self.kanban.configurations.is_empty() {
1831 return Err(TixError::Config(
1832 "`[kanban]` must define at least one configuration".into(),
1833 ));
1834 }
1835 for (name, board) in &self.kanban.configurations {
1836 validate_board(name, board)?;
1837 validate_category_map(name, board, &self.categories)?;
1838 }
1839 for (label, team) in &self.teams {
1840 validate_team(label, team)?;
1841 }
1842 for (name, cycle) in &self.cycles {
1843 validate_cycle(name, cycle)?;
1844 }
1845 for (name, template) in &self.templates {
1846 validate_template(name, template)?;
1847 }
1848 validate_projects(&self.projects)?;
1854 validate_epics(&self.epics)?;
1863 validate_initiatives(&self.initiatives)?;
1872 crate::cycle_schedule::config::validate_schedules(&self.cycle_schedules)?;
1873 crate::capacity::config::validate(&self.capacity, self)?;
1883 self.views
1891 .check_structure()
1892 .map_err(|refusal| TixError::Config(refusal.render()))?;
1893 self.issue_templates.validate(self)?;
1896 crate::triage::config::validate(self)?;
1902 Ok(())
1903 }
1904
1905 pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
1907 KeyBindings::resolve(&self.keybindings)
1908 }
1909
1910 #[must_use]
1919 pub fn allocate_project_id(&self) -> ProjectId {
1920 self.allocate_project_id_with(ProjectId::generate)
1921 }
1922
1923 fn allocate_project_id_with(&self, draw: impl FnMut() -> ProjectId) -> ProjectId {
1927 allocate_stable_id(&self.projects, draw)
1928 }
1929
1930 #[must_use]
1938 pub fn allocate_epic_id(&self) -> EpicId {
1939 self.allocate_epic_id_with(EpicId::generate)
1940 }
1941
1942 fn allocate_epic_id_with(&self, draw: impl FnMut() -> EpicId) -> EpicId {
1945 allocate_stable_id(&self.epics, draw)
1946 }
1947
1948 #[must_use]
1956 pub fn allocate_initiative_id(&self) -> InitiativeId {
1957 self.allocate_initiative_id_with(InitiativeId::generate)
1958 }
1959
1960 fn allocate_initiative_id_with(&self, draw: impl FnMut() -> InitiativeId) -> InitiativeId {
1963 allocate_stable_id(&self.initiatives, draw)
1964 }
1965
1966 #[must_use]
1974 pub fn allocate_cycle_schedule_id(&self) -> crate::cycle_schedule::config::CycleScheduleId {
1975 self.allocate_cycle_schedule_id_with(
1976 crate::cycle_schedule::config::CycleScheduleId::generate,
1977 )
1978 }
1979
1980 fn allocate_cycle_schedule_id_with(
1984 &self,
1985 draw: impl FnMut() -> crate::cycle_schedule::config::CycleScheduleId,
1986 ) -> crate::cycle_schedule::config::CycleScheduleId {
1987 allocate_stable_id(&self.cycle_schedules, draw)
1988 }
1989
1990 fn restore_declaration_order(&mut self, contents: &str) {
1994 crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
1995 crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
1996 crate::config::restore_section_order(&mut self.teams, contents, "teams");
1997 crate::config::restore_section_order(&mut self.templates, contents, "templates");
1998 self.views.restore_declaration_order(contents);
1999 crate::config::restore_section_order(
2000 &mut self.issue_templates.templates,
2001 contents,
2002 "issue_templates",
2003 );
2004 crate::config::restore_section_order(&mut self.projects, contents, "projects");
2005 crate::config::restore_section_order(&mut self.epics, contents, "epics");
2006 crate::config::restore_section_order(&mut self.initiatives, contents, "initiatives");
2007 crate::config::restore_section_order(
2008 &mut self.cycle_schedules,
2009 contents,
2010 "cycle_schedules",
2011 );
2012 self.capacity.restore_declaration_order(contents);
2013 }
2014
2015 pub fn custom_attributes(&self) -> Vec<String> {
2021 self.custom_attributes
2022 .keys
2023 .iter()
2024 .map(|k| k.trim().to_ascii_lowercase())
2025 .collect()
2026 }
2027
2028 #[must_use]
2031 pub fn types(&self) -> Vec<String> {
2032 self.types
2033 .clone()
2034 .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
2035 }
2036
2037 #[must_use]
2040 pub fn custom_types(&self) -> Vec<String> {
2041 self.types()
2042 }
2043
2044 #[must_use]
2051 pub fn labels(&self) -> Vec<String> {
2052 let mut out: Vec<String> = BUILTIN_LABELS.iter().map(|s| (*s).to_string()).collect();
2053 if let Some(extra) = &self.labels {
2054 for label in extra {
2055 if !out.contains(label) {
2056 out.push(label.clone());
2057 }
2058 }
2059 }
2060 out
2061 }
2062
2063 #[must_use]
2067 pub fn type_color(&self, type_name: &str) -> Option<String> {
2068 let needle = type_name.trim().to_ascii_lowercase();
2069 self.type_colors
2070 .iter()
2071 .find(|(name, _)| name.to_ascii_lowercase() == needle)
2072 .map(|(_, color)| color.clone())
2073 .or_else(|| {
2074 DEFAULT_TYPE_COLORS
2075 .iter()
2076 .find(|(name, _)| *name == needle)
2077 .map(|(_, color)| (*color).to_string())
2078 })
2079 }
2080
2081 #[must_use]
2085 pub fn attributes(&self) -> Vec<String> {
2086 if self.custom_attributes.keys.is_empty() {
2087 crate::model::KNOWN_FRONTMATTER_KEYS
2088 .iter()
2089 .filter(|key| **key != "links")
2090 .map(|key| (*key).to_string())
2091 .collect()
2092 } else {
2093 self.custom_attributes.keys.clone()
2094 }
2095 }
2096
2097 pub fn milestones_dir(&self) -> &Path {
2100 self.milestones_dir
2101 .as_deref()
2102 .unwrap_or(Path::new("docs/milestones"))
2103 }
2104
2105 pub fn tags_dir(&self) -> &Path {
2108 self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
2109 }
2110
2111 pub fn repos(&self) -> &IndexMap<String, PathBuf> {
2113 &self.repos
2114 }
2115
2116 pub fn parent_repo(&self) -> Option<&Path> {
2118 self.parent_repo.as_deref()
2119 }
2120
2121 pub fn resolve_repo_paths(&mut self, base: &Path) {
2131 if let Some(path) = self.tags_dir.as_ref() {
2132 if path.is_relative() {
2133 self.tags_dir = Some(base.join(path));
2134 }
2135 }
2136 for path in self.repos.values_mut() {
2137 if path.is_relative() {
2138 *path = base.join(&*path);
2139 }
2140 }
2141 if let Some(path) = self.parent_repo.clone() {
2142 if path.is_relative() {
2143 self.parent_repo = Some(base.join(path));
2144 }
2145 }
2146 }
2147
2148 pub fn default_config(&self) -> &BoardConfiguration {
2151 self.kanban
2152 .configurations
2153 .get("default")
2154 .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
2155 .expect("validate() guarantees at least one configuration")
2156 }
2157
2158 pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
2161 self.kanban
2162 .configurations
2163 .iter()
2164 .map(|(name, board)| (name.as_str(), board))
2165 }
2166
2167 pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
2173 let contents = std::fs::read_to_string(path)
2174 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2175 let mut config: Config = toml::from_str(&contents)
2176 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2177 config.restore_declaration_order(&contents);
2178 let root = path
2182 .parent()
2183 .filter(|p| !p.as_os_str().is_empty())
2184 .unwrap_or_else(|| Path::new("."));
2185 let board_store = BoardStore::new(root);
2186 if board_store.exists() {
2187 let boards = board_store
2188 .list()
2189 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2190 if !boards.is_empty() {
2191 let declared = std::mem::take(&mut config.kanban.configurations);
2201 config.kanban = KanbanSection {
2202 configurations: boards
2203 .into_iter()
2204 .map(|(name, mut board)| {
2205 if let Some(from_toml) = declared.get(&name) {
2206 board.wip = from_toml.wip.clone();
2207 }
2208 (name, board)
2209 })
2210 .collect(),
2211 };
2212 }
2213 }
2214 let cycle_store = CycleStore::new(root);
2215 if cycle_store.exists() {
2216 let cycles = cycle_store
2217 .list()
2218 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
2219 config.cycles = cycles
2220 .into_iter()
2221 .map(|record| (record.name.clone(), record.into()))
2222 .collect();
2223 }
2224 if config.kanban.configurations.is_empty() {
2225 config.kanban = KanbanSection {
2226 configurations: IndexMap::from([(
2227 "default".to_string(),
2228 BoardConfiguration::default(),
2229 )]),
2230 };
2231 }
2232 config.validate().map_err(|e| at_file(path, e))?;
2233 crate::capacity::config::validate_cycle_references(&config.capacity, &config, root)
2239 .map_err(|e| at_file(path, e))?;
2240 for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
2241 .into_iter()
2242 .flatten()
2243 {
2244 if remote.token.is_some() {
2245 sink(
2246 "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
2247 );
2248 }
2249 }
2250 Ok(config)
2251 }
2252
2253 pub fn load(project_root: &Path) -> Result<Config> {
2257 let path = project_root.join("tix.toml");
2258 if !path.is_file() {
2259 return Ok(Config::default());
2260 }
2261 Self::load_with_warning(&path, |w| eprintln!("{w}"))
2262 }
2263
2264 pub fn project_root(cwd: &Path) -> Option<PathBuf> {
2267 let mut dir = Some(cwd.to_path_buf());
2268 while let Some(current) = dir {
2269 if current.join("tix.toml").is_file() {
2270 return Some(current);
2271 }
2272 dir = current.parent().map(Path::to_path_buf);
2273 }
2274 None
2275 }
2276
2277 fn sanitized_for_disk(&self) -> Config {
2279 let mut copy = self.clone();
2280 if let Some(gitlab) = copy.export.gitlab.as_mut() {
2281 gitlab.token = None;
2282 }
2283 if let Some(linear) = copy.export.linear.as_mut() {
2284 linear.token = None;
2285 }
2286 copy
2287 }
2288
2289 pub fn save(&self, path: &Path) -> Result<()> {
2295 let dir = match path.parent() {
2296 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
2297 _ => PathBuf::from("."),
2298 };
2299 std::fs::create_dir_all(&dir)?;
2300
2301 let board_store = BoardStore::new(&dir);
2305 let board_store_active = board_store.exists();
2306 if board_store_active {
2307 for (name, board) in &self.kanban.configurations {
2308 let filename = format!("{}.md", crate::slug::slugify(name));
2309 let body = RecordStore::new(board_store.dir()).body_of(&filename);
2310 board_store.save(name, board, body.as_deref())?;
2311 }
2312 }
2313 let cycle_store = CycleStore::new(&dir);
2314 let cycle_store_active = cycle_store.exists();
2315 if cycle_store_active {
2316 for (name, cycle) in &self.cycles {
2317 if is_implicit_default(name, cycle) {
2318 continue;
2319 }
2320 let filename = format!("{}.md", crate::slug::slugify(name));
2321 let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
2322 cycle_store.save(name, cycle, body.as_deref())?;
2323 }
2324 }
2325
2326 let mut disk = self.sanitized_for_disk();
2327 if board_store_active {
2328 disk.kanban = KanbanSection::default();
2329 }
2330 if cycle_store_active {
2331 disk.cycles.clear();
2332 }
2333 let contents = toml::to_string_pretty(&disk)
2334 .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
2335
2336 let file_name = path
2337 .file_name()
2338 .and_then(|n| n.to_str())
2339 .unwrap_or("tix.toml");
2340 let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
2341
2342 let write_result = (|| -> std::io::Result<()> {
2343 let mut f = std::fs::File::create(&tmp)?;
2344 use std::io::Write;
2345 f.write_all(contents.as_bytes())?;
2346 f.sync_all()?;
2347 Ok(())
2348 })();
2349 if let Err(e) = write_result {
2350 let _ = std::fs::remove_file(&tmp);
2351 return Err(TixError::Io(e));
2352 }
2353
2354 if let Err(e) = std::fs::rename(&tmp, path) {
2355 let _ = std::fs::remove_file(&tmp);
2356 return Err(TixError::Io(e));
2357 }
2358 Ok(())
2359 }
2360}
2361
2362fn at_file(path: &Path, error: TixError) -> TixError {
2369 match error {
2370 TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
2371 other => other,
2372 }
2373}
2374
2375pub(crate) fn allocate_stable_id<I: StableId, V>(
2383 registry: &IndexMap<String, V>,
2384 mut draw: impl FnMut() -> I,
2385) -> I {
2386 loop {
2387 let candidate = draw();
2388 if !registry.contains_key(candidate.as_str()) {
2389 return candidate;
2390 }
2391 }
2392}
2393
2394pub(crate) fn restore_section_order<V: Clone>(
2404 map: &mut IndexMap<String, V>,
2405 contents: &str,
2406 section: &str,
2407) {
2408 let mut order: Vec<String> = Vec::new();
2409 for line in contents.lines() {
2410 let line = line.trim();
2411 if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
2412 && let Some((head, key)) = rest.split_once('.')
2413 && head.trim() == section
2414 && !key.trim().contains('.')
2415 && let Some(name) = unquote_key(key.trim())
2416 && !order.iter().any(|n| n == name)
2417 {
2418 order.push(name.to_string());
2419 }
2420 }
2421 if order.is_empty() {
2422 return;
2423 }
2424 let mut ordered = IndexMap::new();
2425 for name in &order {
2426 if let Some(value) = map.shift_remove(name) {
2427 ordered.insert(name.clone(), value);
2428 }
2429 }
2430 ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
2431 *map = ordered;
2432}
2433
2434fn unquote_key(key: &str) -> Option<&str> {
2437 if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
2438 Some(inner)
2439 } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
2440 Some(inner)
2441 } else {
2442 Some(key)
2443 }
2444}
2445
2446fn validate_planning(planning: &PlanningConfig) -> Result<()> {
2449 if planning.cycle_number == 0 {
2450 return Err(TixError::Config(
2451 "`planning.cycle_number` must be at least 1".into(),
2452 ));
2453 }
2454 Ok(())
2455}
2456
2457fn validate_repos(repos: &IndexMap<String, PathBuf>) -> Result<()> {
2463 for (name, path) in repos {
2464 if name.trim().is_empty() {
2465 return Err(TixError::Config(
2466 "`[repos]` entry names must not be blank".into(),
2467 ));
2468 }
2469 if path.as_os_str().is_empty() {
2470 return Err(TixError::Config(format!(
2471 "`repos.{name}` must name the repository path"
2472 )));
2473 }
2474 }
2475 Ok(())
2476}
2477
2478fn validate_team(label: &str, team: &Team) -> Result<()> {
2481 if label.trim().is_empty() {
2482 return Err(TixError::Config("`teams.<label>` must not be blank".into()));
2483 }
2484 if team.members.is_empty() {
2485 return Err(TixError::Config(format!(
2486 "`teams.{label}.members` must not be empty"
2487 )));
2488 }
2489 let mut seen: HashSet<&str> = HashSet::new();
2490 for member in &team.members {
2491 if !seen.insert(member.as_str()) {
2492 return Err(TixError::Config(format!(
2493 "`teams.{label}.members` must be unique; found duplicate `{member}`"
2494 )));
2495 }
2496 }
2497 Ok(())
2498}
2499
2500pub(crate) fn validate_registry_key<I: StableId>(section: &str, id: &str) -> Result<()> {
2508 I::new(id).map_err(|err| TixError::Config(format!("`{section}`: {err}")))?;
2509 Ok(())
2510}
2511
2512pub(crate) fn validate_display_name(
2521 section: &str,
2522 id: &str,
2523 name: &str,
2524 noun: &str,
2525 seen: &mut HashSet<String>,
2526) -> Result<()> {
2527 if name.trim().is_empty() {
2528 return Err(TixError::Config(format!(
2529 "`{section}.{id}.name` must not be blank"
2530 )));
2531 }
2532 if name.trim() != name {
2533 return Err(TixError::Config(format!(
2534 "`{section}.{id}.name` has leading or trailing whitespace"
2535 )));
2536 }
2537 if !seen.insert(name.to_lowercase()) {
2538 return Err(TixError::Config(format!(
2539 "`{section}.{id}.name` (`{name}`) must be unique; another {noun} already uses it"
2540 )));
2541 }
2542 Ok(())
2543}
2544
2545fn validate_optional_text(section: &str, id: &str, fields: &[(&str, Option<&str>)]) -> Result<()> {
2549 for (field, value) in fields {
2550 if value.is_some_and(|v| v.trim().is_empty()) {
2551 return Err(TixError::Config(format!(
2552 "`{section}.{id}.{field}` must not be blank when present"
2553 )));
2554 }
2555 }
2556 Ok(())
2557}
2558
2559fn validate_record_dates(
2563 section: &str,
2564 id: &str,
2565 start_date: Option<&str>,
2566 due_date: Option<&str>,
2567) -> Result<()> {
2568 crate::model::validate_issue_dates(start_date, due_date)
2569 .map_err(|err| TixError::Config(format!("`{section}.{id}`: {err}")))
2570}
2571
2572fn validate_projects(projects: &IndexMap<String, ProjectConfiguration>) -> Result<()> {
2581 let mut seen_names: HashSet<String> = HashSet::new();
2582 for (id, project) in projects {
2583 validate_registry_key::<ProjectId>("projects", id)?;
2584 validate_display_name("projects", id, &project.name, "project", &mut seen_names)?;
2585 validate_optional_text(
2586 "projects",
2587 id,
2588 &[
2589 ("description", project.description.as_deref()),
2590 ("status", project.status.as_deref()),
2591 ("lead", project.lead.as_deref()),
2592 ],
2593 )?;
2594 validate_record_dates(
2595 "projects",
2596 id,
2597 project.start_date.as_deref(),
2598 project.due_date.as_deref(),
2599 )?;
2600 }
2601 Ok(())
2602}
2603
2604fn validate_epics(epics: &IndexMap<String, EpicConfiguration>) -> Result<()> {
2622 let mut seen_names: HashSet<String> = HashSet::new();
2623 for (id, epic) in epics {
2624 validate_registry_key::<EpicId>("epics", id)?;
2625 validate_display_name("epics", id, &epic.name, "epic", &mut seen_names)?;
2626 validate_optional_text(
2627 "epics",
2628 id,
2629 &[
2630 ("description", epic.description.as_deref()),
2631 ("status", epic.status.as_deref()),
2632 ("owner", epic.owner.as_deref()),
2633 ("milestone", epic.milestone.as_deref()),
2634 ("cycle", epic.cycle.as_deref()),
2635 ],
2636 )?;
2637 validate_record_dates(
2638 "epics",
2639 id,
2640 epic.start_date.as_deref(),
2641 epic.due_date.as_deref(),
2642 )?;
2643 if let Some(weight) = epic.weight
2644 && weight <= 0
2645 {
2646 return Err(TixError::Config(format!(
2647 "`epics.{id}.weight` must be a positive integer; got {weight}"
2648 )));
2649 }
2650 if let Some(parent) = epic.parent.as_deref() {
2651 EpicId::new(parent)
2652 .map_err(|err| TixError::Config(format!("`epics.{id}.parent`: {err}")))?;
2653 if parent == id {
2654 return Err(TixError::Config(format!(
2655 "`epics.{id}.parent` must not name the epic itself"
2656 )));
2657 }
2658 }
2659 for reference in &epic.projects {
2660 ProjectId::new(reference)
2661 .map_err(|err| TixError::Config(format!("`epics.{id}.projects`: {err}")))?;
2662 }
2663 }
2664 Ok(())
2665}
2666
2667const INITIATIVE_KNOWN_FIELDS: &[&str] = &[
2676 "name",
2677 "outcome",
2678 "owner",
2679 "status",
2680 "priority",
2681 "labels",
2682 "target_date",
2683 "projects",
2684 "epics",
2685 "milestones",
2686];
2687
2688fn validate_initiatives(initiatives: &IndexMap<String, InitiativeConfiguration>) -> Result<()> {
2715 let mut seen_names: HashSet<String> = HashSet::new();
2716 for (id, initiative) in initiatives {
2717 validate_registry_key::<InitiativeId>("initiatives", id)?;
2718 validate_display_name(
2719 "initiatives",
2720 id,
2721 &initiative.name,
2722 "initiative",
2723 &mut seen_names,
2724 )?;
2725 validate_optional_text(
2726 "initiatives",
2727 id,
2728 &[
2729 ("outcome", initiative.outcome.as_deref()),
2730 ("owner", initiative.owner.as_deref()),
2731 ("status", initiative.status.as_deref()),
2732 ],
2733 )?;
2734 for key in initiative.extensions.keys() {
2735 if INITIATIVE_KNOWN_FIELDS.contains(&key) {
2736 return Err(TixError::Config(format!(
2737 "`initiatives.{id}.{key}` collides with a built-in field; rename the extension key"
2738 )));
2739 }
2740 }
2741 if let Some(date) = initiative.target_date.as_deref() {
2742 if !crate::model::validate_date(date) {
2743 return Err(TixError::Config(format!(
2744 "`initiatives.{id}.target_date` (`{date}`) must be a `YYYY-MM-DD` calendar date"
2745 )));
2746 }
2747 }
2748 for label in &initiative.labels {
2749 if label.trim().is_empty() {
2750 return Err(TixError::Config(format!(
2751 "`initiatives.{id}.labels` must not contain a blank value"
2752 )));
2753 }
2754 }
2755 for reference in &initiative.projects {
2756 ProjectId::new(reference)
2757 .map_err(|err| TixError::Config(format!("`initiatives.{id}.projects`: {err}")))?;
2758 }
2759 for reference in &initiative.epics {
2760 EpicId::new(reference)
2761 .map_err(|err| TixError::Config(format!("`initiatives.{id}.epics`: {err}")))?;
2762 }
2763 for reference in &initiative.milestones {
2764 if reference.trim().is_empty() {
2765 return Err(TixError::Config(format!(
2766 "`initiatives.{id}.milestones` must not contain a blank value"
2767 )));
2768 }
2769 }
2770 }
2771 Ok(())
2772}
2773
2774fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
2777 if name.trim().is_empty() {
2778 return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
2779 }
2780 if let Some(start) = &cycle.start_date {
2781 if !crate::model::validate_date(start) {
2782 return Err(TixError::Config(format!(
2783 "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
2784 )));
2785 }
2786 }
2787 if let Some(due) = &cycle.due_date {
2788 if !crate::model::validate_date(due) {
2789 return Err(TixError::Config(format!(
2790 "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
2791 )));
2792 }
2793 if let Some(start) = &cycle.start_date
2794 && due.as_str() < start.as_str()
2795 {
2796 return Err(TixError::Config(format!(
2797 "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
2798 )));
2799 }
2800 }
2801 Ok(())
2802}
2803
2804fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
2813 if name.trim().is_empty() {
2814 return Err(TixError::Config(
2815 "`templates` must not contain a blank name".into(),
2816 ));
2817 }
2818 for (field, values) in [
2819 ("labels", &template.labels),
2820 ("cycles", &template.cycles),
2821 ("milestones", &template.milestones),
2822 ] {
2823 for value in values {
2824 if value.trim().is_empty() {
2825 return Err(TixError::Config(format!(
2826 "`templates.{name}.{field}` must not contain a blank value"
2827 )));
2828 }
2829 if value.contains(',') {
2830 return Err(TixError::Config(format!(
2831 "`templates.{name}.{field}` value `{value}` must not contain a comma \
2832 — the form joins these with `,`"
2833 )));
2834 }
2835 }
2836 }
2837 if let Some(start) = &template.start_date
2838 && !crate::model::validate_date(start)
2839 {
2840 return Err(TixError::Config(format!(
2841 "`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
2842 )));
2843 }
2844 if let Some(due) = &template.due_date {
2845 if !crate::model::validate_date(due) {
2846 return Err(TixError::Config(format!(
2847 "`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
2848 )));
2849 }
2850 if let Some(start) = &template.start_date
2851 && due.as_str() < start.as_str()
2852 {
2853 return Err(TixError::Config(format!(
2854 "`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
2855 )));
2856 }
2857 }
2858 Ok(())
2859}
2860
2861fn validate_categories(categories: &CategoryConfig) -> Result<()> {
2869 if categories.order.is_empty() {
2870 return Err(TixError::Config(
2871 "`categories.order` must not be empty — it is the global vocabulary".into(),
2872 ));
2873 }
2874 for key in &categories.order {
2875 if !CATEGORY_KEYS.contains(&key.as_str()) {
2876 return Err(TixError::Config(format!(
2877 "unknown_category: `categories.order` names `{key}`, which is not in the \
2878 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
2879 board maps columns into it and does not define it.",
2880 CATEGORY_KEYS.join(", ")
2881 )));
2882 }
2883 }
2884 if !categories.contains(&categories.default) {
2885 return Err(TixError::Config(format!(
2886 "unknown_category: `categories.default` is `{}`, which is not in \
2887 `categories.order`",
2888 categories.default
2889 )));
2890 }
2891 if categories.default == COMPLETE_CATEGORY {
2892 return Err(TixError::Config(format!(
2893 "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
2894 without an explicit mapping would resolve to complete, which changes \
2895 completion meaning by accident. Map the completing column explicitly \
2896 instead."
2897 )));
2898 }
2899 Ok(())
2900}
2901
2902fn validate_category_map(
2909 name: &str,
2910 board: &BoardConfiguration,
2911 categories: &CategoryConfig,
2912) -> Result<()> {
2913 for (column, key) in &board.category_map {
2914 if !board.columns.iter().any(|c| c == column) {
2915 return Err(TixError::Config(format!(
2916 "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
2917 `{column}`, which is not one of the board's columns ({})",
2918 board.columns.join(", ")
2919 )));
2920 }
2921 if !categories.contains(key) {
2922 return Err(TixError::Config(format!(
2923 "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
2924 `{key}`, which is not in the vocabulary ({})",
2925 categories.order.join(", ")
2926 )));
2927 }
2928 }
2929 if board.category_map.is_empty() {
2933 return Ok(());
2934 }
2935 let completers: Vec<&String> = board
2936 .category_map
2937 .iter()
2938 .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
2939 .map(|(column, _)| column)
2940 .collect();
2941 match completers.as_slice() {
2942 [] => Err(TixError::Config(format!(
2943 "missing_completion_category: `kanban.{name}.category_map` maps no column \
2944 to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
2945 is ever complete — map exactly one column to it.",
2946 board.category_map.len()
2947 ))),
2948 [_only] => Ok(()),
2949 many => Err(TixError::Config(format!(
2950 "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
2951 ({}); exactly one column may complete work",
2952 many.len(),
2953 many.iter()
2954 .map(|c| c.as_str())
2955 .collect::<Vec<_>>()
2956 .join(", ")
2957 ))),
2958 }
2959}
2960
2961fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
2962 if board.columns.is_empty() {
2963 return Err(TixError::Config(format!(
2964 "`kanban.{name}.columns` must not be empty"
2965 )));
2966 }
2967 if board.columns.iter().any(|c| c.trim().is_empty()) {
2968 return Err(TixError::Config(format!(
2969 "`kanban.{name}.columns` must not contain blank names"
2970 )));
2971 }
2972 let mut seen: HashSet<&str> = HashSet::new();
2973 for column in &board.columns {
2974 if !seen.insert(column.as_str()) {
2975 return Err(TixError::Config(format!(
2976 "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
2977 )));
2978 }
2979 }
2980 if let Some(done) = &board.done_column
2981 && !board.columns.contains(done)
2982 {
2983 return Err(TixError::Config(format!(
2984 "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
2985 )));
2986 }
2987 for hidden in &board.hidden_columns {
2988 if !board.columns.contains(hidden) {
2989 return Err(TixError::Config(format!(
2990 "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
2991 )));
2992 }
2993 }
2994 if board.title_lines == 0 {
2995 return Err(TixError::Config(format!(
2996 "`kanban.{name}.title_lines` must be at least 1"
2997 )));
2998 }
2999 board.wip.validate(name, &board.columns)?;
3002 Ok(())
3003}
3004
3005#[cfg(test)]
3006mod tests {
3007 use super::*;
3008 use std::path::PathBuf;
3009 use tempfile::TempDir;
3010
3011 use crate::templates::{
3012 PromptKind, ReferenceKind, Template, TemplateDefaults, TemplatePrompt, TemplateReference,
3013 TemplateScope,
3014 };
3015
3016 const LEGACY_PRETTY_BOARD: &str = r#"issues_dir = "docs/issues"
3020
3021[kanban.default]
3022columns = [
3023 "todo",
3024 "in progress",
3025 "in review",
3026 "done",
3027]
3028"#;
3029
3030 fn write_toml(dir: &Path, contents: &str) -> PathBuf {
3031 let path = dir.join("tix.toml");
3032 std::fs::write(&path, contents).unwrap();
3033 path
3034 }
3035
3036 #[test]
3037 fn starter_config_carries_a_working_inbox() {
3038 let config = Config::starter();
3039 config
3040 .validate()
3041 .expect("the config `tix init` writes must load");
3042
3043 let board = config.default_config();
3044 for column in [
3045 "triage",
3046 "todo",
3047 "in progress",
3048 "in review",
3049 "done",
3050 "wontfix",
3051 ] {
3052 assert!(
3053 board.columns.iter().any(|c| c == column),
3054 "the starter board is missing `{column}`: {:?}",
3055 board.columns
3056 );
3057 }
3058
3059 let triage = config
3060 .triage
3061 .as_ref()
3062 .expect("the starter config has an inbox");
3063 assert_eq!(triage.entry_status.as_deref(), Some("triage"));
3064 assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
3065 assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
3066 assert_eq!(triage.effective_deferred_status(), Some("triage"));
3069
3070 assert_eq!(
3072 board.category_map.get("wontfix").map(String::as_str),
3073 Some("canceled")
3074 );
3075 assert_eq!(
3076 board.category_map.get("done").map(String::as_str),
3077 Some("completed")
3078 );
3079 for column in ["triage", "todo", "in progress", "in review"] {
3085 assert!(
3086 !board.category_map.contains_key(column),
3087 "`{column}` must stay unmapped, or adjacency will refuse work moving back"
3088 );
3089 }
3090 }
3091
3092 #[test]
3093 fn repos_and_parent_repo_load_round_trip_and_resolve() {
3094 let tmp = TempDir::new().unwrap();
3095 let raw = "issues_dir = \"docs/issues\"\n\
3098 parent_repo = \"../parent\"\n\n\
3099 [repos]\n\
3100 tix = \"../tix\"\n\
3101 website = \"child/website\"\n";
3102 let path = write_toml(tmp.path(), raw);
3103 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3104 assert_eq!(cfg.repos.len(), 2);
3105 assert_eq!(cfg.repos["tix"], PathBuf::from("../tix"));
3106 assert_eq!(cfg.repos["website"], PathBuf::from("child/website"));
3107 assert_eq!(cfg.parent_repo(), Some(Path::new("../parent")));
3108
3109 cfg.save(&path).unwrap();
3111 let reread = Config::load_with_warning(&path, |_| {}).unwrap();
3112 assert_eq!(reread.repos, cfg.repos, "repos round-trip");
3113 assert_eq!(
3114 reread.parent_repo(),
3115 cfg.parent_repo(),
3116 "parent round-trips"
3117 );
3118
3119 let mut resolved = cfg.clone();
3121 resolved.resolve_repo_paths(Path::new("/root"));
3122 assert_eq!(resolved.repos["tix"], PathBuf::from("/root/../tix"));
3123 assert_eq!(
3124 resolved.repos["website"],
3125 PathBuf::from("/root/child/website")
3126 );
3127 assert_eq!(resolved.parent_repo(), Some(Path::new("/root/../parent")));
3128 let mut absolute = Config::default();
3129 absolute.repos.insert("x".into(), PathBuf::from("/abs/x"));
3130 absolute.parent_repo = Some(PathBuf::from("/abs/parent"));
3131 absolute.resolve_repo_paths(Path::new("/root"));
3132 assert_eq!(absolute.repos["x"], PathBuf::from("/abs/x"));
3133 assert_eq!(absolute.parent_repo(), Some(Path::new("/abs/parent")));
3134 }
3135
3136 #[test]
3137 fn types_rename_and_type_colors() {
3138 let tmp = TempDir::new().unwrap();
3139 let path = write_toml(
3141 tmp.path(),
3142 "issues_dir = \"docs/issues\"\ncustom_types = [\"bug\", \"epic\"]\n",
3143 );
3144 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3145 assert_eq!(cfg.types(), vec!["bug", "epic"]);
3146 cfg.save(&path).unwrap();
3147 let after = std::fs::read_to_string(&path).unwrap();
3148 assert!(after.contains("types = ["), "{after}");
3149 assert!(!after.contains("custom_types"), "{after}");
3150
3151 let defaults = Config::default();
3154 assert_eq!(defaults.type_color("bug").as_deref(), Some("red"));
3155 assert_eq!(defaults.type_color("BUG").as_deref(), Some("red"));
3156 assert_eq!(defaults.type_color("nonsense"), None);
3157 let mut overridden = Config::default();
3158 overridden.type_colors.insert("bug".into(), "cyan".into());
3159 assert_eq!(overridden.type_color("Bug").as_deref(), Some("cyan"));
3160 }
3161
3162 #[test]
3163 fn a_blank_repo_path_is_rejected() {
3164 let tmp = TempDir::new().unwrap();
3165 let path = write_toml(
3166 tmp.path(),
3167 "issues_dir = \"docs/issues\"\n\n[repos]\ntix = \"\"\n",
3168 );
3169 let err = Config::load_with_warning(&path, |_| {})
3170 .unwrap_err()
3171 .to_string();
3172 assert!(err.contains("repos.tix"), "got {err}");
3173 }
3174
3175 #[test]
3176 fn an_empty_repos_section_is_not_written() {
3177 let tmp = TempDir::new().unwrap();
3178 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3179 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3180 cfg.save(&path).unwrap();
3181 let after = std::fs::read_to_string(&path).unwrap();
3182 assert!(
3183 !after.contains("[repos]") && !after.contains("parent_repo"),
3184 "an untouched config gains no repos keys on save: {after}"
3185 );
3186 }
3187
3188 #[test]
3189 fn defaults_match_contract() {
3190 let c = Config::default();
3191 let board = c.default_config();
3192 assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
3193 assert_eq!(board.done_column.as_deref(), Some("done"));
3194 assert_eq!(board.hidden_columns, Vec::<String>::new());
3195 assert!(!board.show_mismatch);
3196 assert!(board.wrap_titles);
3197 assert_eq!(board.title_lines, 3);
3198 assert_eq!(board.card_order, [] as [std::string::String; 0]);
3199 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
3200 assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
3201 assert_eq!(c.editor, None);
3202 assert_eq!(c.state.next_number, 0);
3203 assert_eq!(c.export.gitlab, None);
3204 assert_eq!(c.export.linear, None);
3205 }
3206
3207 #[test]
3210 fn decision_pending_is_a_builtin_label_that_needs_no_key() {
3211 let bare = Config::default();
3212 assert_eq!(bare.labels, None);
3213 assert_eq!(bare.labels(), vec!["decision-pending".to_string()]);
3214
3215 let starter = Config::starter();
3218 assert_eq!(starter.labels, None);
3219 assert_eq!(starter.labels(), vec!["decision-pending".to_string()]);
3220 for written in [
3221 toml::to_string_pretty(&bare).unwrap(),
3222 toml::to_string_pretty(&starter).unwrap(),
3223 ] {
3224 assert!(
3225 !written.contains("labels ="),
3226 "no labels key is written:\n{written}"
3227 );
3228 }
3229
3230 let configured = Config {
3232 labels: Some(vec!["decision-pending".into(), "spec-kitty".into()]),
3233 ..Config::default()
3234 };
3235 assert_eq!(
3236 configured.labels(),
3237 vec!["decision-pending".to_string(), "spec-kitty".to_string()]
3238 );
3239 }
3240
3241 #[test]
3242 fn load_returns_defaults_when_file_absent() {
3243 let tmp = TempDir::new().unwrap();
3244 let cfg = Config::load(tmp.path()).unwrap();
3245 assert_eq!(cfg.state.next_number, 0);
3246 assert_eq!(cfg.default_config().columns.len(), 4);
3247 }
3248
3249 #[test]
3250 fn load_synthesizes_default_config_when_kanban_section_missing() {
3251 let tmp = TempDir::new().unwrap();
3252 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3253 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3254 assert_eq!(cfg.kanban.configurations.len(), 1);
3255 assert!(cfg.kanban.configurations.contains_key("default"));
3256 assert_eq!(cfg.default_config().columns.len(), 4);
3257 }
3258
3259 #[test]
3260 fn load_parses_state_and_keeps_editor_and_remote_targets() {
3261 let tmp = TempDir::new().unwrap();
3262 let path = write_toml(
3263 tmp.path(),
3264 r#"issues_dir = "tickets"
3265editor = "nvim"
3266
3267[kanban.default]
3268columns = ["todo", "done"]
3269done_column = "done"
3270
3271[state]
3272next_number = 17
3273
3274[export.gitlab]
3275project = "acme/tix"
3276"#,
3277 );
3278 let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
3279 assert_eq!(
3280 cfg.kanban.configurations.get("default").unwrap().columns,
3281 ["todo", "done"]
3282 );
3283 assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
3284 assert_eq!(cfg.editor.as_deref(), Some("nvim"));
3285 assert_eq!(cfg.state.next_number, 17);
3286 assert_eq!(
3287 cfg.export.gitlab.unwrap().project.as_deref(),
3288 Some("acme/tix")
3289 );
3290 assert_eq!(cfg.export.linear, None);
3291 }
3292
3293 #[test]
3294 fn viewer_is_parsed_and_blank_is_rejected() {
3295 let tmp = TempDir::new().unwrap();
3296 let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
3297 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3298 assert_eq!(cfg.viewer.as_deref(), Some("less"));
3299
3300 let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \" \"\n");
3301 let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
3302 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3303 assert!(err.to_string().contains("viewer"), "{err}");
3304 }
3305
3306 #[test]
3307 fn viewer_is_omitted_from_the_file_when_unset() {
3308 let dir = TempDir::new().unwrap();
3309 let path = dir.path().join("tix.toml");
3310 let mut config = Config::default();
3311 config.save(&path).unwrap();
3312 assert!(
3313 !std::fs::read_to_string(&path).unwrap().contains("viewer"),
3314 "an unset viewer writes no key"
3315 );
3316
3317 config.viewer = Some("less".to_string());
3318 config.save(&path).unwrap();
3319 assert!(
3320 std::fs::read_to_string(&path)
3321 .unwrap()
3322 .contains(r#"viewer = "less""#),
3323 "a set viewer round-trips"
3324 );
3325 }
3326
3327 #[test]
3328 fn empty_columns_rejected_as_config_error() {
3329 let tmp = TempDir::new().unwrap();
3330 let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
3331 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3332 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3333 assert!(err.to_string().contains("kanban.scratch.columns"));
3334 }
3335
3336 #[test]
3337 fn duplicate_columns_rejected_as_config_error() {
3338 let tmp = TempDir::new().unwrap();
3339 let path = write_toml(
3340 tmp.path(),
3341 "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
3342 );
3343 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3344 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3345 assert!(err.to_string().contains("duplicate"));
3346 }
3347
3348 #[test]
3349 fn blank_column_name_rejected() {
3350 let tmp = TempDir::new().unwrap();
3351 let path = write_toml(
3352 tmp.path(),
3353 "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
3354 );
3355 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3356 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3357 }
3358
3359 #[test]
3360 fn done_column_must_be_in_columns() {
3361 let tmp = TempDir::new().unwrap();
3362 let path = write_toml(
3363 tmp.path(),
3364 "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
3365 );
3366 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3367 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3368 assert!(err.to_string().contains("kanban.scratch.done_column"));
3369 }
3370
3371 #[test]
3372 fn hidden_columns_must_be_subset_of_columns() {
3373 let tmp = TempDir::new().unwrap();
3374 let path = write_toml(
3375 tmp.path(),
3376 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
3377 );
3378 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3379 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3380 assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
3381 }
3382
3383 #[test]
3384 fn hidden_done_column_loads_fine() {
3385 let tmp = TempDir::new().unwrap();
3386 let path = write_toml(
3387 tmp.path(),
3388 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
3389 );
3390 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3391 let board = cfg.kanban.configurations.get("scratch").unwrap();
3392 assert_eq!(board.done_column.as_deref(), Some("done"));
3393 assert_eq!(board.hidden_columns, ["done"]);
3394 }
3395
3396 #[test]
3397 fn legacy_top_level_keys_rejected() {
3398 let tmp = TempDir::new().unwrap();
3399 let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
3400 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3401 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3402 assert!(err.to_string().contains("tix.toml"));
3403 assert!(err.to_string().contains("columns"));
3404 }
3405
3406 #[test]
3407 fn token_warning_fires_once_per_remote_with_token() {
3408 let tmp = TempDir::new().unwrap();
3409 let path = write_toml(
3410 tmp.path(),
3411 r#"[export.gitlab]
3412project = "a/b"
3413token = "gl-secret"
3414
3415[export.linear]
3416team = "Eng"
3417token = "lin-secret"
3418"#,
3419 );
3420 let mut warnings: Vec<String> = Vec::new();
3421 let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
3422 assert_eq!(warnings.len(), 2);
3423 assert!(warnings[0].contains("GITLAB_TOKEN"));
3424 assert!(warnings[1].contains("LINEAR_TOKEN"));
3425 for w in &warnings {
3427 assert!(!w.contains("secret"));
3428 }
3429 assert_eq!(
3430 cfg.export.gitlab.unwrap().token.as_deref(),
3431 Some("gl-secret")
3432 );
3433 }
3434
3435 #[test]
3436 fn no_token_means_no_warning() {
3437 let tmp = TempDir::new().unwrap();
3438 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3439 let mut fired = false;
3440 Config::load_with_warning(&path, |_| fired = true).unwrap();
3441 assert!(!fired);
3442 }
3443
3444 #[test]
3445 fn save_never_writes_the_state_section() {
3446 let tmp = TempDir::new().unwrap();
3447 let path = tmp.path().join("tix.toml");
3448 let mut cfg = Config::default();
3451 cfg.state.next_number = 42;
3452 cfg.save(&path).unwrap();
3453
3454 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3455 assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
3456 assert_eq!(
3457 reloaded.default_config().columns,
3458 cfg.default_config().columns
3459 );
3460
3461 let on_disk = std::fs::read_to_string(&path).unwrap();
3462 assert!(
3463 !on_disk.contains("next_number"),
3464 "next_number leaked into tix.toml:\n{on_disk}"
3465 );
3466 assert!(
3467 !on_disk.contains("[state]"),
3468 "state section written:\n{on_disk}"
3469 );
3470 }
3471
3472 #[test]
3473 fn multi_config_roundtrip_preserves_declaration_order() {
3474 let tmp = TempDir::new().unwrap();
3475 let path = write_toml(
3476 tmp.path(),
3477 r#"issues_dir = "docs/issues"
3478
3479[kanban.release]
3480columns = ["planned", "in progress", "done"]
3481done_column = "done"
3482show_mismatch = true
3483
3484[kanban.personal]
3485columns = ["todo", "done"]
3486done_column = "done"
3487hidden_columns = ["done"]
3488"#,
3489 );
3490 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3491 let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
3492 assert_eq!(names, ["release", "personal"]);
3493
3494 assert_eq!(
3496 cfg.default_config().columns,
3497 ["planned", "in progress", "done"]
3498 );
3499
3500 cfg.save(&path).unwrap();
3501 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3502 assert_eq!(
3503 cfg, reloaded,
3504 "order and values survive save/load round trip"
3505 );
3506 let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
3507 assert_eq!(names2, ["release", "personal"]);
3508 }
3509
3510 #[test]
3511 fn save_never_serializes_tokens_back_out() {
3512 let tmp = TempDir::new().unwrap();
3513 let path = tmp.path().join("tix.toml");
3514 let mut cfg = Config::default();
3515 cfg.export.gitlab = Some(RemoteConfig {
3516 token: Some("secret".into()),
3517 project: Some("a/b".into()),
3518 ..RemoteConfig::default()
3519 });
3520 cfg.save(&path).unwrap();
3521
3522 let on_disk = std::fs::read_to_string(&path).unwrap();
3523 assert!(
3524 !on_disk.contains("secret"),
3525 "token leaked to disk:\n{on_disk}"
3526 );
3527 assert!(
3528 on_disk.contains("a/b"),
3529 "non-secret fields survive:\n{on_disk}"
3530 );
3531
3532 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3533 assert_eq!(reloaded.export.gitlab.unwrap().token, None);
3534 }
3535
3536 #[test]
3537 fn save_is_atomic_and_leaves_no_temp_file() {
3538 let tmp = TempDir::new().unwrap();
3539 let path = tmp.path().join("tix.toml");
3540 Config::default().save(&path).unwrap();
3541 let entries: Vec<_> = std::fs::read_dir(tmp.path())
3542 .unwrap()
3543 .filter_map(|e| e.ok())
3544 .collect();
3545 assert_eq!(entries.len(), 1);
3546 assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
3547 }
3548
3549 #[test]
3550 fn default_loadwrites_are_idempotent() {
3551 let tmp = TempDir::new().unwrap();
3552 let path = tmp.path().join("tix.toml");
3553 let cfg = Config::default();
3554 cfg.save(&path).unwrap();
3555 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3556 assert_eq!(cfg, reloaded);
3557 }
3558
3559 #[test]
3560 fn effective_done_column_falls_back_to_last() {
3561 let board = BoardConfiguration {
3562 columns: vec!["todo".into(), "later".into()],
3563 done_column: None,
3564 ..Default::default()
3565 };
3566 assert_eq!(board.effective_done_column(), Some("later"));
3567
3568 let with_done = BoardConfiguration {
3569 columns: vec!["todo".into(), "done".into(), "later".into()],
3570 done_column: None,
3571 ..Default::default()
3572 };
3573 assert_eq!(with_done.effective_done_column(), Some("done"));
3574 }
3575
3576 #[test]
3577 fn project_root_walks_up_the_tree() {
3578 let tmp = TempDir::new().unwrap();
3579 let nested = tmp.path().join("a").join("b");
3580 std::fs::create_dir_all(&nested).unwrap();
3581 std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
3582
3583 assert_eq!(
3584 Config::project_root(&nested),
3585 Some(tmp.path().to_path_buf())
3586 );
3587 assert_eq!(
3588 Config::project_root(tmp.path()),
3589 Some(tmp.path().to_path_buf())
3590 );
3591 }
3592
3593 #[test]
3594 fn project_root_is_none_when_no_toml_exists() {
3595 let tmp = TempDir::new().unwrap();
3596 assert_eq!(Config::project_root(tmp.path()), None);
3597 }
3598
3599 #[test]
3600 fn order_by_parses_all_names_and_defaults_to_desc() {
3601 let tmp = TempDir::new().unwrap();
3602 for name in ["created_at_desc", "created_at_asc", "none"] {
3603 let path = write_toml(
3604 tmp.path(),
3605 &format!(
3606 "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
3607 ),
3608 );
3609 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3610 let board = cfg.kanban.configurations.get("x").unwrap();
3611 assert_eq!(board.order_by.as_str(), name);
3612 }
3613 assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
3614 assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
3615 assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
3616 assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
3617 }
3618
3619 #[test]
3620 fn zero_title_lines_rejected_as_config_error() {
3621 let tmp = TempDir::new().unwrap();
3622 let path = write_toml(
3623 tmp.path(),
3624 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
3625 );
3626 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3627 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3628 assert!(
3629 err.to_string().contains("kanban.scratch.title_lines"),
3630 "error names the offending field: {err}"
3631 );
3632 }
3633
3634 #[test]
3635 fn unknown_order_by_rejected_as_config_error() {
3636 let tmp = TempDir::new().unwrap();
3637 let path = write_toml(
3638 tmp.path(),
3639 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
3640 );
3641 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3642 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3643 }
3644
3645 #[test]
3646 fn board_without_display_keys_loads_default_display_settings() {
3647 let tmp = TempDir::new().unwrap();
3648 let path = write_toml(
3649 tmp.path(),
3650 "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
3651 );
3652 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3653 let board = cfg.default_config();
3654 assert!(board.wrap_titles);
3655 assert_eq!(board.title_lines, 3);
3656 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
3657 }
3658
3659 #[test]
3660 fn display_settings_roundtrip_through_save() {
3661 let tmp = TempDir::new().unwrap();
3662 let path = tmp.path().join("tix.toml");
3663 let mut cfg = Config::default();
3664 {
3665 let board = cfg.kanban.configurations.get_mut("default").unwrap();
3666 board.wrap_titles = false;
3667 board.title_lines = 5;
3668 board.order_by = OrderBy::None;
3669 }
3670 cfg.save(&path).unwrap();
3671 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3672 assert_eq!(
3673 reloaded, cfg,
3674 "display settings survive save/load round trip"
3675 );
3676 let on_disk = std::fs::read_to_string(&path).unwrap();
3677 for key in ["wrap_titles", "title_lines", "order_by"] {
3678 assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
3679 }
3680 }
3681
3682 #[test]
3683 fn manual_card_order_roundtrips_through_save() {
3684 let tmp = TempDir::new().unwrap();
3685 let path = tmp.path().join("tix.toml");
3686 let mut cfg = Config::default();
3687 {
3688 let board = cfg.kanban.configurations.get_mut("default").unwrap();
3689 board.card_order = vec!["a.md".into(), "b.md".into()];
3690 }
3691 cfg.save(&path).unwrap();
3692 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3693 assert_eq!(
3694 reloaded
3695 .kanban
3696 .configurations
3697 .get("default")
3698 .unwrap()
3699 .card_order,
3700 vec!["a.md".to_string(), "b.md".to_string()]
3701 );
3702 let on_disk = std::fs::read_to_string(&path).unwrap();
3703 assert!(
3704 on_disk.contains("card_order"),
3705 "missing card_order in:\n{on_disk}"
3706 );
3707 }
3708
3709 #[test]
3710 fn planning_defaults_match_contract() {
3711 let c = Config::default();
3712 assert_eq!(c.planning.cycle_number, 1);
3713 assert_eq!(c.planning.default_cycle, None);
3714 assert_eq!(c.planning.default_milestone, None);
3715 assert_eq!(c.planning.default_team, None);
3716 assert!(c.teams.is_empty());
3717 assert!(c.cycles.is_empty());
3718 assert_eq!(c.milestones_dir, None);
3719 assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
3720 }
3721
3722 #[test]
3723 fn planning_sections_roundtrip_and_preserve_declaration_order() {
3724 let tmp = TempDir::new().unwrap();
3725 let path = write_toml(
3726 tmp.path(),
3727 r#"issues_dir = "docs/issues"
3728milestones_dir = "docs/milestones"
3729
3730[planning]
3731cycle_number = 1
3732default_cycle = "Cycle 1"
3733default_milestone = "Backlog"
3734default_team = "default"
3735
3736[cycles."Cycle 1"]
3737
3738[cycles."Cycle 2"]
3739start_date = "2026-09-21"
3740due_date = "2026-09-30"
3741
3742[teams.default]
3743members = ["Herb Mason <herb@example.com>", "@alice"]
3744
3745[teams.platform]
3746members = ["bob"]
3747"#,
3748 );
3749 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3750 assert_eq!(cfg.planning.cycle_number, 1);
3751 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
3752 assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
3753 assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
3754 assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
3755
3756 let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
3758 assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
3759 let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
3760 assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
3761 assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
3762 assert_eq!(cycle2.description, None);
3763
3764 let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
3765 assert_eq!(teams, ["default", "platform"]);
3766 assert_eq!(
3767 cfg.teams.get("default").unwrap().members,
3768 ["Herb Mason <herb@example.com>", "@alice"]
3769 );
3770
3771 cfg.save(&path).unwrap();
3773 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3774 assert_eq!(cfg, reloaded);
3775 assert_eq!(
3776 reloaded
3777 .cycles
3778 .keys()
3779 .map(String::as_str)
3780 .collect::<Vec<_>>(),
3781 ["Cycle 1", "Cycle 2"]
3782 );
3783 assert_eq!(
3784 reloaded
3785 .teams
3786 .keys()
3787 .map(String::as_str)
3788 .collect::<Vec<_>>(),
3789 ["default", "platform"]
3790 );
3791 }
3792
3793 const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
3798
3799[categories]
3800order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
3801default = "unstarted"
3802
3803[transitions]
3804terminal_locked = true
3805
3806[kanban.default]
3807columns = ["triage", "wip", "review", "shipped"]
3808done_column = "shipped"
3809category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
3810
3811[export]
3812"#;
3813
3814 #[test]
3815 fn category_sections_parse_and_default_sensibly() {
3816 let tmp = TempDir::new().unwrap();
3817 let path = write_toml(tmp.path(), FULL_CATEGORIES);
3818 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3819 assert_eq!(cfg.categories.order, CATEGORY_KEYS);
3820 assert_eq!(cfg.categories.default, "unstarted");
3821 assert!(cfg.transitions.terminal_locked);
3822
3823 let board = cfg.default_config();
3824 assert_eq!(
3825 board.category_map.get("shipped").map(String::as_str),
3826 Some("completed")
3827 );
3828 assert_eq!(
3829 board.category_map.get("triage").map(String::as_str),
3830 Some("backlog")
3831 );
3832 }
3833
3834 #[test]
3835 fn category_sections_default_when_absent() {
3836 let tmp = TempDir::new().unwrap();
3837 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3838 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3839 assert_eq!(cfg.categories, CategoryConfig::default());
3842 assert_eq!(cfg.transitions, TransitionConfig::default());
3843 assert!(cfg.categories.is_default(), "and is not written back");
3844 assert!(cfg.transitions.is_default());
3845 }
3846
3847 #[test]
3848 fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
3849 let tmp = TempDir::new().unwrap();
3850 let bad_categories = write_toml(
3851 tmp.path(),
3852 "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
3853 );
3854 let err = Config::load_with_warning(&bad_categories, |_| {})
3855 .expect_err("an unknown key in [categories] must be refused");
3856 assert!(err.to_string().contains("unknown field"), "got: {err}");
3857
3858 let tmp2 = TempDir::new().unwrap();
3859 let bad_transitions = write_toml(
3860 tmp2.path(),
3861 "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
3862 );
3863 let err = Config::load_with_warning(&bad_transitions, |_| {})
3864 .expect_err("an unknown key in [transitions] must be refused");
3865 assert!(err.to_string().contains("unknown field"), "got: {err}");
3866 }
3867
3868 #[test]
3869 fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
3870 let tmp = TempDir::new().unwrap();
3871 let path = write_toml(
3872 tmp.path(),
3873 "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
3874 );
3875 let err = Config::load_with_warning(&path, |_| {})
3876 .expect_err("the vocabulary is global and fixed; a new key is not a default");
3877 assert!(err.to_string().contains("unknown_category"), "got: {err}");
3878 }
3879
3880 #[test]
3883 fn a_default_of_completed_is_refused() {
3884 let tmp = TempDir::new().unwrap();
3885 let path = write_toml(
3886 tmp.path(),
3887 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
3888 );
3889 let err = Config::load_with_warning(&path, |_| {})
3890 .expect_err("a completion-changing default must be an escalation");
3891 let msg = err.to_string();
3892 assert!(msg.contains("may not be `completed`"), "got: {msg}");
3893 }
3894
3895 #[test]
3896 fn a_default_outside_the_vocabulary_is_refused() {
3897 let tmp = TempDir::new().unwrap();
3898 let path = write_toml(
3899 tmp.path(),
3900 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
3901 );
3902 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3903 assert!(err.to_string().contains("unknown_category"), "got: {err}");
3904 }
3905
3906 #[test]
3910 fn a_mapped_board_with_no_completion_column_is_refused() {
3911 let tmp = TempDir::new().unwrap();
3912 let path = write_toml(
3913 tmp.path(),
3914 r#"issues_dir = "docs/issues"
3915
3916[kanban.default]
3917columns = ["triage", "wip", "shipped"]
3918done_column = "shipped"
3919category_map = { triage = "backlog", wip = "active" }
3920"#,
3921 );
3922 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3923 assert!(
3924 err.to_string().contains("missing_completion_category"),
3925 "got: {err}"
3926 );
3927 }
3928
3929 #[test]
3930 fn a_board_with_two_completion_columns_is_refused() {
3931 let tmp = TempDir::new().unwrap();
3932 let path = write_toml(
3933 tmp.path(),
3934 r#"issues_dir = "docs/issues"
3935
3936[kanban.default]
3937columns = ["shipped", "cancelled"]
3938done_column = "shipped"
3939category_map = { shipped = "completed", cancelled = "completed" }
3940"#,
3941 );
3942 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3943 let msg = err.to_string();
3944 assert!(
3945 msg.contains("exactly one column may complete"),
3946 "got: {msg}"
3947 );
3948 }
3949
3950 #[test]
3951 fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
3952 let tmp = TempDir::new().unwrap();
3953 let path = write_toml(
3954 tmp.path(),
3955 r#"issues_dir = "docs/issues"
3956
3957[kanban.default]
3958columns = ["triage", "shipped"]
3959done_column = "shipped"
3960category_map = { triage = "backlog", shipped = "completed", nope = "active" }
3961"#,
3962 );
3963 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3964 assert!(
3965 err.to_string().contains("mapping_for_unknown_column"),
3966 "got: {err}"
3967 );
3968 }
3969
3970 #[test]
3971 fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
3972 let tmp = TempDir::new().unwrap();
3973 let path = write_toml(
3974 tmp.path(),
3975 r#"issues_dir = "docs/issues"
3976
3977[kanban.default]
3978columns = ["triage", "shipped"]
3979done_column = "shipped"
3980category_map = { triage = "wontfix", shipped = "completed" }
3981"#,
3982 );
3983 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
3984 assert!(err.to_string().contains("unknown_category"), "got: {err}");
3985 }
3986
3987 #[test]
3991 fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
3992 let tmp = TempDir::new().unwrap();
3993 let path = write_toml(
3994 tmp.path(),
3995 r#"issues_dir = "docs/issues"
3996
3997[kanban.default]
3998columns = ["triage", "wip", "shipped"]
3999done_column = "shipped"
4000category_map = { shipped = "completed" }
4001"#,
4002 );
4003 let cfg =
4004 Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
4005 let board = cfg.default_config();
4006 assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
4007 assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
4008 assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
4009 }
4010
4011 #[test]
4017 fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
4018 let tmp = TempDir::new().unwrap();
4019 let path = write_toml(
4020 tmp.path(),
4021 r#"issues_dir = "docs/issues"
4022
4023[kanban.default]
4024columns = ["todo", "in progress", "in review", "done"]
4025done_column = "done"
4026"#,
4027 );
4028 let cfg = Config::load_with_warning(&path, |_| {})
4029 .expect("a legacy board must load, and it does");
4030 let board = cfg.default_config();
4031 assert!(board.category_map.is_empty());
4032 assert_eq!(board.category_for("done", &cfg.categories), "completed");
4033 assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
4034 assert_eq!(
4035 board.category_for("in review", &cfg.categories),
4036 "unstarted",
4037 "and nothing else is silently complete"
4038 );
4039 }
4040
4041 #[test]
4045 fn a_defaulted_config_gains_no_new_keys_on_save() {
4046 let tmp = TempDir::new().unwrap();
4047 let legacy = LEGACY_PRETTY_BOARD;
4048 let path = write_toml(tmp.path(), legacy);
4049 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4050 cfg.save(&path).unwrap();
4051 let after = std::fs::read_to_string(&path).unwrap();
4052 assert_eq!(
4053 after, legacy,
4054 "no `[categories]`, no `[transitions]`, no `category_map` may appear"
4055 );
4056 }
4057
4058 #[test]
4062 fn explicit_default_board_keys_are_normalized_away_by_save() {
4063 let tmp = TempDir::new().unwrap();
4064 let explicit = r#"issues_dir = "docs/issues"
4065
4066[kanban.default]
4067columns = [
4068 "todo",
4069 "in progress",
4070 "in review",
4071 "done",
4072]
4073done_column = "done"
4074hidden_columns = []
4075show_mismatch = false
4076wrap_titles = true
4077title_lines = 3
4078order_by = "created_at_desc"
4079
4080[export]
4081"#;
4082 let path = write_toml(tmp.path(), explicit);
4083 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4084 cfg.save(&path).unwrap();
4085 let after = std::fs::read_to_string(&path).unwrap();
4086 assert_eq!(
4087 after, LEGACY_PRETTY_BOARD,
4088 "a redundant explicit default collapses to the canonical form"
4089 );
4090 for key in [
4091 "done_column",
4092 "hidden_columns",
4093 "show_mismatch",
4094 "wrap_titles",
4095 "title_lines",
4096 "order_by",
4097 "[export]",
4098 ] {
4099 assert!(!after.contains(key), "`{key}` must be omitted:\n{after}");
4100 }
4101 assert_eq!(
4102 cfg,
4103 Config::load_with_warning(&path, |_| {}).unwrap(),
4104 "and the collapsed form reloads to the same config"
4105 );
4106 }
4107
4108 #[test]
4111 fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
4112 let tmp = TempDir::new().unwrap();
4113 let path = write_toml(
4114 tmp.path(),
4115 r#"issues_dir = "docs/issues"
4116
4117[kanban.default]
4118columns = ["triage", "shipped"]
4119done_column = "shipped"
4120category_map = { triage = "backlog", shipped = "completed" }
4121
4122[export]
4123"#,
4124 );
4125 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4126 assert_eq!(cfg.categories, CategoryConfig::default());
4127 cfg.save(&path).unwrap();
4128 let after = std::fs::read_to_string(&path).unwrap();
4129 assert!(
4130 !after.contains("[categories]"),
4131 "the section must stay unmaterialized: {after}"
4132 );
4133 assert!(
4134 after.contains("category_map"),
4135 "while the board's own mapping survives: {after}"
4136 );
4137 }
4138
4139 #[test]
4154 fn a_full_category_config_round_trips_with_its_values_and_order() {
4155 let tmp = TempDir::new().unwrap();
4156 let authored = r#"issues_dir = "docs/issues"
4162
4163[categories]
4164order = ["backlog", "active", "completed", "canceled"]
4165default = "backlog"
4166
4167[transitions]
4168terminal_locked = false
4169
4170[kanban.default]
4171columns = ["triage", "wip", "shipped"]
4172done_column = "shipped"
4173category_map = { triage = "backlog", wip = "active", shipped = "completed" }
4174
4175[export]
4176"#;
4177 let path = write_toml(tmp.path(), authored);
4178 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4179 cfg.save(&path).unwrap();
4180 let after = std::fs::read_to_string(&path).unwrap();
4181 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4182
4183 assert_eq!(
4184 reloaded.categories, cfg.categories,
4185 "the vocabulary and its default survive"
4186 );
4187 assert_eq!(
4188 reloaded.categories.order,
4189 vec!["backlog", "active", "completed", "canceled"],
4190 "and `order` is a Vec, so the declared order is preserved exactly"
4191 );
4192
4193 assert_eq!(
4194 reloaded.default_config().category_map,
4195 cfg.default_config().category_map,
4196 "the mapping's contents survive, in whatever order they are written"
4197 );
4198 assert!(after.contains("[categories]"), "after: {after}");
4206 assert!(after.contains("[transitions]"), "after: {after}");
4207 assert!(
4208 !reloaded.transitions.terminal_locked,
4209 "and the value survives"
4210 );
4211
4212 let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
4219 reloaded_again.save(&path).unwrap();
4220 assert_eq!(
4221 std::fs::read_to_string(&path).unwrap(),
4222 after,
4223 "a second save must be a no-op"
4224 );
4225 }
4226
4227 #[test]
4230 fn two_boards_with_different_columns_map_to_the_same_categories() {
4231 let tmp = TempDir::new().unwrap();
4232 let path = write_toml(
4233 tmp.path(),
4234 r#"issues_dir = "docs/issues"
4235
4236[kanban.alpha]
4237columns = ["triage", "wip", "review", "shipped"]
4238done_column = "shipped"
4239category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
4240
4241[kanban.beta]
4242columns = ["new", "doing", "merged"]
4243done_column = "merged"
4244
4245[export]
4246"#,
4247 );
4248 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4249 let alpha = &cfg.kanban.configurations["alpha"];
4250 let beta = &cfg.kanban.configurations["beta"];
4251 assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
4254 assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
4255 assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
4256 assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
4257 assert!(beta.category_map.is_empty());
4258 }
4259
4260 #[test]
4264 fn canceled_is_terminal_but_not_complete() {
4265 assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
4266 assert!(TERMINAL_CATEGORIES.contains(&"completed"));
4267 assert_eq!(
4268 COMPLETE_CATEGORY, "completed",
4269 "completion is defined by this category alone"
4270 );
4271 assert_ne!(COMPLETE_CATEGORY, "canceled");
4272 let tmp = TempDir::new().unwrap();
4275 let path = write_toml(
4276 tmp.path(),
4277 r#"issues_dir = "docs/issues"
4278
4279[kanban.default]
4280columns = ["triage", "dropped", "shipped"]
4281done_column = "shipped"
4282category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
4283"#,
4284 );
4285 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4286 let board = cfg.default_config();
4287 assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
4288 assert_ne!(
4289 board.category_for("dropped", &cfg.categories),
4290 COMPLETE_CATEGORY
4291 );
4292 }
4293
4294 #[test]
4298 fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
4299 assert!(CATEGORY_KEYS.contains(&"blocked"));
4300 let tmp = TempDir::new().unwrap();
4301 let path = write_toml(
4302 tmp.path(),
4303 r#"issues_dir = "docs/issues"
4304
4305[kanban.default]
4306columns = ["wip", "on hold", "shipped"]
4307done_column = "shipped"
4308category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
4309"#,
4310 );
4311 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4312 let board = cfg.default_config();
4313 assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
4314 assert!(
4316 !cfg.default_config()
4317 .category_map
4318 .values()
4319 .any(|v| v.contains("state")),
4320 "a category is a position, never a derived state"
4321 );
4322 }
4323
4324 #[test]
4325 fn legacy_file_roundtrips_byte_identical() {
4326 let tmp = TempDir::new().unwrap();
4327 let legacy = LEGACY_PRETTY_BOARD;
4331 let path = write_toml(tmp.path(), legacy);
4332 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4333 assert_eq!(cfg.planning, PlanningConfig::default());
4334 assert!(cfg.teams.is_empty());
4335 assert!(cfg.cycles.is_empty());
4336 assert_eq!(cfg.milestones_dir, None);
4337 assert_eq!(
4341 cfg.default_config().columns,
4342 ["todo", "in progress", "in review", "done"]
4343 );
4344 assert_eq!(cfg.default_config().effective_done_column(), Some("done"));
4345 assert_eq!(cfg.default_config().order_by, OrderBy::CreatedAtDesc);
4346 assert_eq!(
4347 cfg.default_config().hidden_columns,
4348 [] as [std::string::String; 0]
4349 );
4350 assert!(!cfg.default_config().show_mismatch);
4351 assert!(cfg.default_config().wrap_titles);
4352 assert_eq!(cfg.default_config().title_lines, DEFAULT_TITLE_LINES);
4353 assert!(!cfg.default_config().category_map.contains_key("done"));
4354
4355 cfg.save(&path).unwrap();
4356 let on_disk = std::fs::read_to_string(&path).unwrap();
4357 assert_eq!(
4358 on_disk, legacy,
4359 "legacy tix.toml must save byte-identical (NFR-003)"
4360 );
4361 assert!(
4362 !on_disk.contains("[export]"),
4363 "an empty `[export]` table must not be materialized:\n{on_disk}"
4364 );
4365 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4366 assert_eq!(cfg, reloaded);
4367 }
4368
4369 #[test]
4370 fn legacy_file_in_any_layout_never_gains_planning_keys() {
4371 let tmp = TempDir::new().unwrap();
4372 let legacy = r#"issues_dir = "docs/issues"
4376[kanban.default]
4377columns = ["todo", "in progress", "in review", "done"]
4378done_column = "done"
4379"#;
4380 let path = write_toml(tmp.path(), legacy);
4381 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4382 cfg.save(&path).unwrap();
4383 let on_disk = std::fs::read_to_string(&path).unwrap();
4384 assert!(!on_disk.contains("milestones_dir"));
4385 assert!(!on_disk.contains("[planning]"));
4386 assert!(!on_disk.contains("[teams]"));
4387 assert!(!on_disk.contains("[cycles"));
4388 }
4389
4390 #[test]
4391 fn planning_default_section_with_partial_keys_loads() {
4392 let tmp = TempDir::new().unwrap();
4393 let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
4394 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4395 assert_eq!(
4396 cfg.planning.cycle_number, 1,
4397 "counter defaults when omitted"
4398 );
4399 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
4400 assert_eq!(cfg.planning.default_milestone, None);
4401 assert_eq!(cfg.planning.default_team, None);
4402 }
4403
4404 #[test]
4405 fn planning_rejects_unknown_keys() {
4406 let tmp = TempDir::new().unwrap();
4407 let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
4408 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4409 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4410 assert!(err.to_string().contains("counter"));
4411 }
4412
4413 #[test]
4414 fn planning_rejects_zero_cycle_number() {
4415 let tmp = TempDir::new().unwrap();
4416 let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
4417 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4418 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4419 assert!(err.to_string().contains("cycle_number"));
4420 }
4421
4422 #[test]
4423 fn cycle_dates_are_validated_and_compared() {
4424 let tmp = TempDir::new().unwrap();
4425 let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
4427 let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
4428 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4429 assert!(err.to_string().contains("start_date"));
4430
4431 let ordered = write_toml(
4433 tmp.path(),
4434 "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
4435 );
4436 let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
4437 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4438 assert!(err.to_string().contains("due_date"));
4439 assert!(err.to_string().contains("start_date"));
4440 }
4441
4442 #[test]
4443 fn cycles_reject_unknown_keys() {
4444 let tmp = TempDir::new().unwrap();
4445 let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
4446 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4447 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4448 assert!(err.to_string().contains("bogus"));
4449 }
4450
4451 #[test]
4452 fn cycle_without_dates_loads_fine() {
4453 let tmp = TempDir::new().unwrap();
4454 let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
4455 let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
4456 assert_eq!(
4457 cfg.cycles.get("Alpha").unwrap().description.as_deref(),
4458 Some("a")
4459 );
4460 }
4461
4462 #[test]
4463 fn teams_validate_members_nonempty_and_unique() {
4464 let tmp = TempDir::new().unwrap();
4465 let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
4466 let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
4467 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4468 assert!(err.to_string().contains("members"));
4469
4470 let dup = write_toml(
4471 tmp.path(),
4472 "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
4473 );
4474 let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
4475 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4476 assert!(err.to_string().contains("unique"));
4477 }
4478
4479 #[test]
4480 fn empty_teams_table_is_disabled_not_error() {
4481 let tmp = TempDir::new().unwrap();
4482 let path = write_toml(tmp.path(), "[teams]\n");
4483 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
4484 assert!(cfg.teams.is_empty());
4485 cfg.save(&path).unwrap();
4487 let on_disk = std::fs::read_to_string(&path).unwrap();
4488 assert!(!on_disk.contains("[teams]"));
4489 }
4490
4491 #[test]
4492 fn blank_milestones_dir_rejected() {
4493 let tmp = TempDir::new().unwrap();
4494 let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
4495 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4496 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
4497 assert!(err.to_string().contains("milestones_dir"));
4498 }
4499
4500 #[test]
4501 fn sparse_keybindings_roundtrip_without_defaults() {
4502 let tmp = TempDir::new().unwrap();
4503 let path = write_toml(
4504 tmp.path(),
4505 "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
4506 );
4507 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4508 config.save(&path).unwrap();
4509 let contents = std::fs::read_to_string(&path).unwrap();
4510 assert!(contents.contains("[keybindings.board]"));
4511 assert!(!contents.contains("move_left"));
4512 assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
4513 }
4514
4515 #[test]
4516 fn omitted_keybindings_retain_effective_defaults() {
4517 let tmp = TempDir::new().unwrap();
4518 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
4519 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4520 let bindings = config.effective_keybindings().unwrap();
4521 assert_eq!(
4522 bindings.label(
4523 crate::keybindings::InputContext::Board,
4524 crate::keybindings::TuiAction::NewIssue
4525 ),
4526 "n"
4527 );
4528 }
4529
4530 #[test]
4535 fn edit_conflict_keybindings_override_the_prompt() {
4536 use crate::keybindings::{InputContext, TuiAction};
4537 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4538
4539 let tmp = TempDir::new().unwrap();
4540 let path = write_toml(
4541 tmp.path(),
4542 "[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
4543 );
4544 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4545 let bindings = config.effective_keybindings().unwrap();
4546
4547 assert_eq!(
4548 bindings.label(InputContext::EditConflict, TuiAction::Confirm),
4549 "Ctrl+k"
4550 );
4551 assert_eq!(
4552 bindings.action(
4553 InputContext::EditConflict,
4554 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
4555 ),
4556 Some(TuiAction::Confirm)
4557 );
4558 assert_eq!(
4559 bindings.action(
4560 InputContext::EditConflict,
4561 KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
4562 ),
4563 None,
4564 "an override replaces the action's whole alias list"
4565 );
4566 assert_eq!(
4568 bindings.action(
4569 InputContext::EditConflict,
4570 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
4571 ),
4572 Some(TuiAction::Reedit)
4573 );
4574 assert_eq!(
4575 bindings.action(
4576 InputContext::EditConflict,
4577 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
4578 ),
4579 Some(TuiAction::Ignore)
4580 );
4581 }
4582
4583 #[test]
4584 fn keybindings_reject_unknown_fields_and_empty_actions() {
4585 let tmp = TempDir::new().unwrap();
4586 let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
4587 assert!(
4588 Config::load_with_warning(&unknown, |_| {})
4589 .unwrap_err()
4590 .to_string()
4591 .contains("bogus")
4592 );
4593 let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
4594 assert!(
4595 Config::load_with_warning(&empty, |_| {})
4596 .unwrap_err()
4597 .to_string()
4598 .contains("new_issue")
4599 );
4600 }
4601
4602 #[test]
4606 fn shell_mode_switches_are_configurable() {
4607 use crate::keybindings::{InputContext, TuiAction};
4608 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4609
4610 let tmp = TempDir::new().unwrap();
4611 let path = write_toml(
4612 tmp.path(),
4613 "[keybindings.shell]\nswitch_to_milestones = [\"g\"]\nswitch_to_board = [\"f3\", \"b\"]\n",
4614 );
4615 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4616 let bindings = config.effective_keybindings().unwrap();
4617
4618 assert_eq!(
4620 bindings.label(InputContext::Shell, TuiAction::SwitchToMilestones),
4621 "g"
4622 );
4623 assert_eq!(
4624 bindings.label(InputContext::Shell, TuiAction::SwitchToBoard),
4625 "F3 / b"
4626 );
4627 assert_eq!(
4629 bindings.action(
4630 InputContext::Shell,
4631 KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
4632 ),
4633 Some(TuiAction::SwitchToMilestones)
4634 );
4635 assert_eq!(
4636 bindings.action(
4637 InputContext::Shell,
4638 KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
4639 ),
4640 None,
4641 "an override replaces the action's whole alias list"
4642 );
4643 assert_eq!(
4644 bindings.action(
4645 InputContext::Shell,
4646 KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
4647 ),
4648 Some(TuiAction::SwitchToBoard),
4649 "a second alias keeps working"
4650 );
4651
4652 let unknown = write_toml(
4654 tmp.path(),
4655 "[keybindings.shell]\nswitch_to_nowhere = [\"g\"]\n",
4656 );
4657 let error = Config::load_with_warning(&unknown, |_| {})
4658 .unwrap_err()
4659 .to_string();
4660 assert!(
4661 error.contains("switch_to_nowhere"),
4662 "the error names the offending key: {error:?}"
4663 );
4664
4665 let bad_chord = write_toml(
4667 tmp.path(),
4668 "[keybindings.shell]\nswitch_to_milestones = [\"ctrl+nosuchkey\"]\n",
4669 );
4670 let error = Config::load_with_warning(&bad_chord, |_| {})
4671 .unwrap_err()
4672 .to_string();
4673 assert!(
4674 error.contains("switch_to_milestones") && error.contains("nosuchkey"),
4675 "the error names the key and the chord: {error:?}"
4676 );
4677 }
4678
4679 #[test]
4682 fn templates_round_trip_through_save_and_load() {
4683 let tmp = TempDir::new().unwrap();
4684 let path = tmp.path().join("tix.toml");
4685 let mut config = Config::starter();
4686 config.templates.insert(
4687 "bug".into(),
4688 IssueTemplate {
4689 title: Some("Bug: ".into()),
4690 body: Some("## Steps\n".into()),
4691 labels: vec!["bug".into(), "urgent".into()],
4692 status: Some("todo".into()),
4693 cycles: vec!["Default".into()],
4694 start_date: Some("2026-01-01".into()),
4695 due_date: Some("2026-01-02".into()),
4696 ..IssueTemplate::default()
4697 },
4698 );
4699 config.validate().expect("the fixture is a valid config");
4700 config.save(&path).unwrap();
4701
4702 let raw = std::fs::read_to_string(&path).unwrap();
4703 assert!(raw.contains("[templates.bug]"), "{raw}");
4704 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4705 assert_eq!(reloaded.templates, config.templates);
4706 let bug = &reloaded.templates["bug"];
4707 assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
4708 assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
4709 assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
4710 }
4711
4712 #[test]
4713 fn a_config_without_templates_writes_no_section() {
4714 let tmp = TempDir::new().unwrap();
4715 let path = tmp.path().join("tix.toml");
4716 Config::default().save(&path).unwrap();
4717 let raw = std::fs::read_to_string(&path).unwrap();
4718 assert!(
4719 !raw.contains("templates"),
4720 "an absent section must not appear on disk:\n{raw}"
4721 );
4722 assert!(
4723 Config::load_with_warning(&path, |_| {})
4724 .unwrap()
4725 .templates
4726 .is_empty()
4727 );
4728 }
4729
4730 #[test]
4731 fn a_template_value_with_a_comma_is_rejected() {
4732 let tmp = TempDir::new().unwrap();
4733 let path = write_toml(
4734 tmp.path(),
4735 "issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
4736 );
4737 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4738 assert!(
4739 err.to_string().contains("must not contain a comma"),
4740 "got {err}"
4741 );
4742 }
4743
4744 #[test]
4745 fn a_template_with_a_backwards_date_range_is_rejected() {
4746 let tmp = TempDir::new().unwrap();
4747 let path = write_toml(
4748 tmp.path(),
4749 "issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
4750 start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
4751 );
4752 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4753 assert!(err.to_string().contains(">= start_date"), "got {err}");
4754 }
4755
4756 #[test]
4757 fn an_unknown_template_key_is_rejected() {
4758 let tmp = TempDir::new().unwrap();
4759 let path = write_toml(
4760 tmp.path(),
4761 "issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
4762 );
4763 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
4764 assert!(err.to_string().contains("unknown field"), "got {err}");
4765 }
4766
4767 #[test]
4770 fn issue_templates_round_trip_through_save_and_load() {
4771 let tmp = TempDir::new().unwrap();
4772 let path = tmp.path().join("tix.toml");
4773 let mut config = Config::starter();
4774 config
4775 .issue_templates
4776 .insert(
4777 "bug",
4778 Template {
4779 name: Some("Bug report".to_string()),
4780 defaults: TemplateDefaults {
4781 labels: vec!["bug".to_string()],
4782 priority: Some("high".to_string()),
4783 ..TemplateDefaults::default()
4784 },
4785 prompts: vec![TemplatePrompt {
4786 key: "priority".to_string(),
4787 label: Some("Priority".to_string()),
4788 kind: PromptKind::Select,
4789 required: true,
4790 default: None,
4791 options_ref: Some("priorities".to_string()),
4792 }],
4793 references: vec![TemplateReference {
4794 kind: ReferenceKind::Label,
4795 value: "bug".to_string(),
4796 required: true,
4797 }],
4798 ..Template::default()
4799 },
4800 )
4801 .unwrap();
4802 config.validate().expect("the fixture is a valid config");
4803 config.save(&path).unwrap();
4804
4805 let raw = std::fs::read_to_string(&path).unwrap();
4806 assert!(raw.contains("[issue_templates.bug]"), "{raw}");
4807 assert!(raw.contains("[[issue_templates.bug.prompts]]"), "{raw}");
4808
4809 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4810 assert_eq!(reloaded.issue_templates, config.issue_templates);
4811 let bug = reloaded.issue_templates.get("bug").unwrap();
4812 assert_eq!(bug.name.as_deref(), Some("Bug report"));
4813 assert_eq!(bug.defaults.labels, ["bug".to_string()]);
4814 assert_eq!(bug.prompts[0].kind, PromptKind::Select);
4815 assert!(bug.prompts[0].required);
4816 assert_eq!(bug.references[0].kind, ReferenceKind::Label);
4817 }
4818
4819 #[test]
4820 fn a_config_without_issue_templates_writes_no_section() {
4821 let tmp = TempDir::new().unwrap();
4822 let path = tmp.path().join("tix.toml");
4823 Config::default().save(&path).unwrap();
4824 let raw = std::fs::read_to_string(&path).unwrap();
4825 assert!(
4826 !raw.contains("issue_templates"),
4827 "an absent section must not appear on disk:\n{raw}"
4828 );
4829 assert!(
4830 Config::load_with_warning(&path, |_| {})
4831 .unwrap()
4832 .issue_templates
4833 .is_empty()
4834 );
4835 }
4836
4837 #[test]
4838 fn a_legacy_config_gains_no_issue_templates_section() {
4839 let tmp = TempDir::new().unwrap();
4840 let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
4841 let before = std::fs::read_to_string(&path).unwrap();
4842 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4843 assert!(
4844 config.issue_templates.is_empty(),
4845 "a legacy config has no stable-ID registry"
4846 );
4847 config.save(&path).unwrap();
4848 assert_eq!(
4849 std::fs::read_to_string(&path).unwrap(),
4850 before,
4851 "a legacy config must round-trip byte-identically"
4852 );
4853 }
4854
4855 #[test]
4856 fn issue_template_declaration_order_is_restored() {
4857 let tmp = TempDir::new().unwrap();
4858 let path = write_toml(
4859 tmp.path(),
4860 "issues_dir = \"docs/issues\"\n\n\
4861 [issue_templates.zeta]\nname = \"Zeta\"\n\n\
4862 [issue_templates.alpha]\nname = \"Alpha\"\n",
4863 );
4864 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4865 assert_eq!(
4866 config.issue_templates.ids().collect::<Vec<_>>(),
4867 ["zeta", "alpha"],
4868 "declaration order is the display order"
4869 );
4870 config.save(&path).unwrap();
4871 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4872 assert_eq!(
4873 reloaded.issue_templates.ids().collect::<Vec<_>>(),
4874 ["zeta", "alpha"]
4875 );
4876 }
4877
4878 #[test]
4879 fn a_rejected_template_write_leaves_tix_toml_unchanged() {
4880 let tmp = TempDir::new().unwrap();
4881 let path = write_toml(
4882 tmp.path(),
4883 "issues_dir = \"docs/issues\"\n\n[issue_templates.bug]\nname = \"Bug report\"\n",
4884 );
4885 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4886 let before = std::fs::read(&path).unwrap();
4887
4888 let mut rejected = config.clone();
4891 rejected.issue_templates.get_mut("bug").unwrap().scope =
4892 TemplateScope::board("no-such-board");
4893 let err = rejected.validate().unwrap_err().to_string();
4894 assert!(
4895 err.contains("issue_templates.bug.scope") && err.contains("no-such-board"),
4896 "{err}"
4897 );
4898 assert_eq!(
4899 std::fs::read(&path).unwrap(),
4900 before,
4901 "a rejected write must not touch tix.toml"
4902 );
4903
4904 let mut duplicate = config.clone();
4907 let err = duplicate
4908 .issue_templates
4909 .insert("bug", Template::default())
4910 .unwrap_err();
4911 assert!(err.to_string().contains("already exists"), "{err}");
4912 assert_eq!(duplicate.issue_templates, config.issue_templates);
4913 assert_eq!(std::fs::read(&path).unwrap(), before);
4914 }
4915
4916 #[test]
4917 fn unknown_issue_template_metadata_round_trips_byte_stably() {
4918 let tmp = TempDir::new().unwrap();
4919 let path = write_toml(
4920 tmp.path(),
4921 "issues_dir = \"docs/issues\"\n\n\
4922 [issue_templates.bug]\nname = \"Bug report\"\n\
4923 reporter_hint = \"include the build id\"\n\n\
4924 [issue_templates.bug.future]\nlevel = 3\n",
4925 );
4926 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4927 let bug = config.issue_templates.get("bug").unwrap();
4928 assert_eq!(
4929 bug.extensions
4930 .get("reporter_hint")
4931 .and_then(toml::Value::as_str),
4932 Some("include the build id")
4933 );
4934 assert!(bug.extensions.get("future").is_some());
4935
4936 config.save(&path).unwrap();
4937 let first = std::fs::read(&path).unwrap();
4938 let text = String::from_utf8(first.clone()).unwrap();
4939 assert!(
4940 text.contains("reporter_hint") && text.contains("future"),
4941 "unknown metadata must survive a save:\n{text}"
4942 );
4943
4944 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
4945 reloaded.save(&path).unwrap();
4946 assert_eq!(
4947 std::fs::read(&path).unwrap(),
4948 first,
4949 "a second round trip must be byte-identical"
4950 );
4951 }
4952
4953 #[test]
4956 fn wip_config_round_trips_byte_stably_and_resolves() {
4957 let tmp = TempDir::new().unwrap();
4958 let configured = "issues_dir = \"docs/issues\"\n\n\
4959 [kanban.default]\ncolumns = [\n \"todo\",\n \"in progress\",\n \"done\",\n]\n\n\
4960 [kanban.default.wip.units]\nd = 1\nw = 5\n\n\
4961 [kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
4962 max_estimate = 10\nunit = \"d\"\npolicy = \"block\"\n";
4963 let path = write_toml(tmp.path(), configured);
4964 let config = Config::load_with_warning(&path, |_| {}).unwrap();
4965
4966 let board = config.default_config();
4967 let limit = board
4968 .wip
4969 .effective_limit("in progress")
4970 .expect("the configured limit is in force");
4971 assert_eq!(limit.max_items, Some(3));
4972 assert_eq!(limit.max_estimate, Some(10));
4973 assert_eq!(limit.unit.as_deref(), Some("d"));
4974 assert_eq!(limit.policy, Some(crate::wip::LimitPolicy::Block));
4975 assert_eq!(board.wip.factor("w"), Some(5));
4976 assert!(
4977 board.wip.effective_limit("done").is_none(),
4978 "a column without a limit has none"
4979 );
4980
4981 config.save(&path).unwrap();
4982 assert_eq!(
4983 std::fs::read_to_string(&path).unwrap(),
4984 configured,
4985 "a configured board must round trip byte-for-byte"
4986 );
4987 }
4988
4989 #[test]
4992 fn wip_config_limit_on_an_unknown_column_is_refused() {
4993 let tmp = TempDir::new().unwrap();
4994 let path = write_toml(
4995 tmp.path(),
4996 "issues_dir = \"docs/issues\"\n\n\
4997 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
4998 [kanban.default.wip.limits.\"in progress\"]\nmax_items = 3\n\
4999 policy = \"warn\"\n",
5000 );
5001 let error = Config::load_with_warning(&path, |_| {})
5002 .expect_err("an unknown column is refused")
5003 .to_string();
5004 assert!(
5005 error.contains("kanban.default.wip.limits.`in progress`"),
5006 "{error}"
5007 );
5008 }
5009
5010 #[test]
5013 fn wip_config_absent_limits_add_no_key() {
5014 let tmp = TempDir::new().unwrap();
5015 let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5016 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5017 assert!(config.default_config().wip.is_empty());
5018 config.save(&path).unwrap();
5019 assert_eq!(
5020 std::fs::read_to_string(&path).unwrap(),
5021 LEGACY_PRETTY_BOARD,
5022 "a board without limits must not gain a `wip` key"
5023 );
5024 }
5025
5026 const TWO_PROJECTS: &str = "issues_dir = \"docs/issues\"\n\n\
5029 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5030 [projects.\"p-3f9a2c\"]\n\
5031 name = \"Platform hardening\"\n\
5032 description = \"Reduce operational risk across the sync path.\"\n\
5033 status = \"active\"\n\
5034 lead = \"Alice <alice@example.com>\"\n\
5035 start_date = \"2026-09-01\"\n\
5036 due_date = \"2026-12-31\"\n\n\
5037 [projects.\"p-7b1e04\"]\n\
5038 name = \"Docs refresh\"\n";
5039
5040 #[test]
5041 fn project_registry_parses_every_contract_field() {
5042 let tmp = TempDir::new().unwrap();
5043 let path = write_toml(tmp.path(), TWO_PROJECTS);
5044 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5045
5046 let platform = &config.projects["p-3f9a2c"];
5047 assert_eq!(platform.name, "Platform hardening");
5048 assert_eq!(
5049 platform.description.as_deref(),
5050 Some("Reduce operational risk across the sync path.")
5051 );
5052 assert_eq!(platform.status.as_deref(), Some("active"));
5053 assert_eq!(platform.lead.as_deref(), Some("Alice <alice@example.com>"));
5054 assert_eq!(platform.start_date.as_deref(), Some("2026-09-01"));
5055 assert_eq!(platform.due_date.as_deref(), Some("2026-12-31"));
5056
5057 let docs = &config.projects["p-7b1e04"];
5060 assert_eq!(docs.name, "Docs refresh");
5061 assert!(docs.description.is_none() && docs.status.is_none() && docs.lead.is_none());
5062 assert!(docs.start_date.is_none() && docs.due_date.is_none());
5063 }
5064
5065 #[test]
5066 fn project_registry_rejects_an_unknown_field() {
5067 let tmp = TempDir::new().unwrap();
5068 let path = write_toml(
5069 tmp.path(),
5070 "issues_dir = \"docs/issues\"\n\n\
5071 [projects.\"p-1\"]\nname = \"One\"\nownr = \"typo\"\n",
5072 );
5073 let err = Config::load_with_warning(&path, |_| {})
5074 .unwrap_err()
5075 .to_string();
5076 assert!(err.contains("ownr"), "got {err}");
5077 }
5078
5079 #[test]
5080 fn project_declaration_order_is_restored() {
5081 let tmp = TempDir::new().unwrap();
5082 let path = write_toml(
5083 tmp.path(),
5084 "issues_dir = \"docs/issues\"\n\n\
5085 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5086 [projects.\"p-zeta\"]\nname = \"Zeta\"\n\n\
5087 [projects.\"p-alpha\"]\nname = \"Alpha\"\n",
5088 );
5089 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5090 assert_eq!(
5091 config.projects.keys().collect::<Vec<_>>(),
5092 ["p-zeta", "p-alpha"],
5093 "declaration order is the display order"
5094 );
5095 config.save(&path).unwrap();
5096 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5097 assert_eq!(
5098 reloaded.projects.keys().collect::<Vec<_>>(),
5099 ["p-zeta", "p-alpha"],
5100 "declaration order survives a save/reload through the ordered map"
5101 );
5102 }
5103
5104 #[test]
5105 fn a_legacy_tix_toml_gains_no_projects_section() {
5106 let tmp = TempDir::new().unwrap();
5107 let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5108 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5109 assert!(config.projects.is_empty());
5110 config.save(&path).unwrap();
5111 assert_eq!(
5112 std::fs::read_to_string(&path).unwrap(),
5113 LEGACY_PRETTY_BOARD,
5114 "a repository with no projects must round-trip byte-identically"
5115 );
5116 }
5117
5118 #[test]
5119 fn an_empty_project_record_writes_no_optional_keys() {
5120 let tmp = TempDir::new().unwrap();
5121 let path = write_toml(
5122 tmp.path(),
5123 "issues_dir = \"docs/issues\"\n\n\
5124 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5125 [projects.\"p-1\"]\nname = \"One\"\n",
5126 );
5127 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5128 config.save(&path).unwrap();
5129 let after = std::fs::read_to_string(&path).unwrap();
5130 for key in ["description", "status", "lead", "start_date", "due_date"] {
5131 assert!(
5132 !after.contains(key),
5133 "an absent optional field must not be written: {key} in {after}"
5134 );
5135 }
5136 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5137 assert_eq!(reloaded, config);
5138 }
5139
5140 #[test]
5141 fn a_blank_project_name_is_rejected() {
5142 let tmp = TempDir::new().unwrap();
5143 let path = write_toml(
5144 tmp.path(),
5145 "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" \"\n",
5146 );
5147 let err = Config::load_with_warning(&path, |_| {})
5148 .unwrap_err()
5149 .to_string();
5150 assert!(err.contains("projects.p-1.name"), "got {err}");
5151 }
5152
5153 #[test]
5154 fn duplicate_project_names_are_rejected() {
5155 for second in ["Same", "same"] {
5159 let tmp = TempDir::new().unwrap();
5160 let path = write_toml(
5161 tmp.path(),
5162 &format!(
5163 "issues_dir = \"docs/issues\"\n\n\
5164 [projects.\"p-1\"]\nname = \"Same\"\n\n\
5165 [projects.\"p-2\"]\nname = \"{second}\"\n"
5166 ),
5167 );
5168 let err = Config::load_with_warning(&path, |_| {})
5169 .unwrap_err()
5170 .to_string();
5171 assert!(err.contains("must be unique"), "got {err}");
5172 }
5173 }
5174
5175 #[test]
5176 fn a_padded_project_name_is_rejected() {
5177 let tmp = TempDir::new().unwrap();
5178 let path = write_toml(
5179 tmp.path(),
5180 "issues_dir = \"docs/issues\"\n\n[projects.\"p-1\"]\nname = \" One \"\n",
5181 );
5182 let err = Config::load_with_warning(&path, |_| {})
5183 .unwrap_err()
5184 .to_string();
5185 assert!(err.contains("leading or trailing whitespace"), "got {err}");
5186 }
5187
5188 #[test]
5189 fn a_blank_project_id_is_rejected() {
5190 let tmp = TempDir::new().unwrap();
5191 let path = write_toml(
5192 tmp.path(),
5193 "issues_dir = \"docs/issues\"\n\n[projects.\"\"]\nname = \"No id\"\n",
5194 );
5195 let err = Config::load_with_warning(&path, |_| {})
5196 .unwrap_err()
5197 .to_string();
5198 assert!(err.contains("must not be blank"), "got {err}");
5199 }
5200
5201 #[test]
5202 fn a_project_id_with_a_structural_character_is_rejected() {
5203 let tmp = TempDir::new().unwrap();
5204 let path = write_toml(
5205 tmp.path(),
5206 "issues_dir = \"docs/issues\"\n\n[projects.\"p.1\"]\nname = \"Dotted\"\n",
5207 );
5208 let err = Config::load_with_warning(&path, |_| {})
5209 .unwrap_err()
5210 .to_string();
5211 assert!(err.contains("contains"), "got {err}");
5212 }
5213
5214 #[test]
5215 fn blank_optional_project_text_is_rejected() {
5216 for key in ["description", "status", "lead"] {
5217 let tmp = TempDir::new().unwrap();
5218 let path = write_toml(
5219 tmp.path(),
5220 &format!(
5221 "issues_dir = \"docs/issues\"\n\n\
5222 [projects.\"p-1\"]\nname = \"One\"\n{key} = \"\"\n"
5223 ),
5224 );
5225 let err = Config::load_with_warning(&path, |_| {})
5226 .unwrap_err()
5227 .to_string();
5228 assert!(err.contains(&format!("projects.p-1.{key}")), "got {err}");
5229 }
5230 }
5231
5232 #[test]
5233 fn a_bad_or_inverted_project_date_is_rejected() {
5234 let tmp = TempDir::new().unwrap();
5235 let bad = write_toml(
5236 tmp.path(),
5237 "issues_dir = \"docs/issues\"\n\n\
5238 [projects.\"p-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
5239 );
5240 let err = Config::load_with_warning(&bad, |_| {})
5241 .unwrap_err()
5242 .to_string();
5243 assert!(err.contains("projects.p-1"), "got {err}");
5244
5245 let inverted = write_toml(
5246 tmp.path(),
5247 "issues_dir = \"docs/issues\"\n\n\
5248 [projects.\"p-1\"]\nname = \"One\"\n\
5249 start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
5250 );
5251 let err = Config::load_with_warning(&inverted, |_| {})
5252 .unwrap_err()
5253 .to_string();
5254 assert!(err.contains("projects.p-1"), "got {err}");
5255 }
5256
5257 #[test]
5258 fn a_valid_project_date_pair_passes() {
5259 let tmp = TempDir::new().unwrap();
5260 let path = write_toml(tmp.path(), TWO_PROJECTS);
5261 Config::load_with_warning(&path, |_| {}).expect("a valid registry must load");
5262 }
5263
5264 #[test]
5266 fn allocated_project_ids_have_the_generated_shape_and_successive_draws_differ() {
5267 let mut config = Config::default();
5268 let first = config.allocate_project_id();
5269 config.projects.insert(
5270 first.to_string(),
5271 ProjectConfiguration {
5272 name: "One".into(),
5273 ..ProjectConfiguration::default()
5274 },
5275 );
5276 let second = config.allocate_project_id();
5277 assert_ne!(first, second, "two allocations must be distinct");
5278 assert_eq!(first.as_str().len(), 18, "`p-` plus 16 hex digits");
5279 assert!(first.as_str().starts_with("p-"));
5280
5281 config.projects.shift_remove(first.as_str());
5286 let third = config.allocate_project_id();
5287 assert_ne!(
5288 third, first,
5289 "a deleted project's id must not be handed out"
5290 );
5291 assert_ne!(third, second);
5292 }
5293
5294 #[test]
5298 fn allocation_redraws_past_a_stored_id() {
5299 let mut config = Config::default();
5300 config.projects.insert(
5301 "p-aaaa".into(),
5302 ProjectConfiguration {
5303 name: "Stored".into(),
5304 ..ProjectConfiguration::default()
5305 },
5306 );
5307 let scripted = ["p-aaaa", "p-bbbb"];
5308 let mut draws = 0;
5309 let allocated = config.allocate_project_id_with(|| {
5310 let drawn = scripted[draws];
5311 draws += 1;
5312 ProjectId::new(drawn).unwrap()
5313 });
5314 assert_eq!(allocated.as_str(), "p-bbbb");
5315 assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5316 }
5317
5318 #[test]
5323 fn schedule_allocation_redraws_past_a_stored_id() {
5324 let mut config = Config::default();
5325 config.cycle_schedules.insert(
5326 "cs-aaaa".into(),
5327 crate::cycle_schedule::config::CycleScheduleConfiguration {
5328 name: "Stored".into(),
5329 ..crate::cycle_schedule::config::CycleScheduleConfiguration::default()
5330 },
5331 );
5332 let scripted = ["cs-aaaa", "cs-bbbb"];
5333 let mut draws = 0;
5334 let allocated = config.allocate_cycle_schedule_id_with(|| {
5335 let drawn = scripted[draws];
5336 draws += 1;
5337 crate::cycle_schedule::config::CycleScheduleId::new(drawn).unwrap()
5338 });
5339 assert_eq!(allocated.as_str(), "cs-bbbb");
5340 assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5341 }
5342
5343 #[test]
5344 fn a_hand_written_project_id_loads_and_is_never_allocated_again() {
5345 let tmp = TempDir::new().unwrap();
5346 let path = write_toml(
5347 tmp.path(),
5348 "issues_dir = \"docs/issues\"\n\n\
5349 [projects.\"p-ffffffffffffffff\"]\nname = \"Hand written\"\n",
5350 );
5351 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5352 assert!(config.projects.contains_key("p-ffffffffffffffff"));
5353 let mut draws = 0;
5356 let allocated = config.allocate_project_id_with(|| {
5357 draws += 1;
5358 if draws < 3 {
5359 ProjectId::new("p-ffffffffffffffff").unwrap()
5360 } else {
5361 ProjectId::new("p-0000000000000000").unwrap()
5362 }
5363 });
5364 assert_eq!(allocated.as_str(), "p-0000000000000000");
5365 assert_eq!(draws, 3);
5366 }
5367
5368 #[test]
5369 fn a_rename_changes_the_label_and_never_the_key() {
5370 let tmp = TempDir::new().unwrap();
5371 let path = write_toml(tmp.path(), TWO_PROJECTS);
5372 let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
5373 config.projects["p-3f9a2c"].name = "Platform hardening (renamed)".into();
5374 config.save(&path).unwrap();
5375
5376 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5377 assert!(
5378 reloaded.projects.contains_key("p-3f9a2c"),
5379 "the id is the key and must survive a rename"
5380 );
5381 assert_eq!(
5382 reloaded.projects["p-3f9a2c"].name,
5383 "Platform hardening (renamed)"
5384 );
5385 }
5386
5387 #[test]
5388 fn project_id_validation_accepts_handles_and_rejects_junk() {
5389 assert!(ProjectId::new("p-platform").is_ok());
5390 assert!(ProjectId::new("p-3f9a2c").is_ok());
5391 assert!(ProjectId::new("").is_err());
5392 assert!(ProjectId::new(" p-1").is_err(), "surrounding whitespace");
5393 assert!(ProjectId::new("p 1").is_err(), "inner whitespace");
5394 assert!(ProjectId::new("p.1").is_err(), "structural `.`");
5395 assert!(ProjectId::new(&"x".repeat(ProjectId::MAX_LEN + 1)).is_err());
5396 }
5397
5398 const TWO_EPICS: &str = "issues_dir = \"docs/issues\"\n\n\
5401 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5402 [epics.\"e-7c4a91\"]\n\
5403 name = \"Harden the sync path\"\n\
5404 description = \"Reduce operational risk across every tracker adapter.\"\n\
5405 status = \"active\"\n\
5406 owner = \"Alice <alice@example.com>\"\n\
5407 start_date = \"2026-09-01\"\n\
5408 due_date = \"2026-12-31\"\n\
5409 weight = 5\n\
5410 parent = \"e-2b8f30\"\n\
5411 projects = [\"p-3f9a2c\"]\n\
5412 milestone = \"2026-Q4\"\n\
5413 cycle = \"October 2026\"\n\n\
5414 [epics.\"e-2b8f30\"]\n\
5415 name = \"Q4 reliability\"\n";
5416
5417 #[test]
5418 fn epic_registry_parses_every_contract_field() {
5419 let tmp = TempDir::new().unwrap();
5420 let path = write_toml(tmp.path(), TWO_EPICS);
5421 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5422
5423 let sync = &config.epics["e-7c4a91"];
5424 assert_eq!(sync.name, "Harden the sync path");
5425 assert_eq!(
5426 sync.description.as_deref(),
5427 Some("Reduce operational risk across every tracker adapter.")
5428 );
5429 assert_eq!(sync.status.as_deref(), Some("active"));
5430 assert_eq!(sync.owner.as_deref(), Some("Alice <alice@example.com>"));
5431 assert_eq!(sync.start_date.as_deref(), Some("2026-09-01"));
5432 assert_eq!(sync.due_date.as_deref(), Some("2026-12-31"));
5433 assert_eq!(sync.weight, Some(5));
5434 assert_eq!(sync.parent.as_deref(), Some("e-2b8f30"));
5435 assert_eq!(sync.projects, ["p-3f9a2c"]);
5436 assert_eq!(sync.milestone.as_deref(), Some("2026-Q4"));
5437 assert_eq!(sync.cycle.as_deref(), Some("October 2026"));
5438
5439 let q4 = &config.epics["e-2b8f30"];
5442 assert_eq!(q4.name, "Q4 reliability");
5443 assert!(q4.description.is_none() && q4.status.is_none() && q4.owner.is_none());
5444 assert!(q4.start_date.is_none() && q4.due_date.is_none());
5445 assert!(q4.weight.is_none() && q4.parent.is_none() && q4.milestone.is_none());
5446 assert!(q4.cycle.is_none());
5447 assert_eq!(q4.projects, [] as [std::string::String; 0]);
5448 }
5449
5450 #[test]
5451 fn epic_registry_rejects_an_unknown_field() {
5452 let tmp = TempDir::new().unwrap();
5453 let path = write_toml(
5454 tmp.path(),
5455 "issues_dir = \"docs/issues\"\n\n\
5456 [epics.\"e-1\"]\nname = \"One\"\nownr = \"typo\"\n",
5457 );
5458 let err = Config::load_with_warning(&path, |_| {})
5459 .unwrap_err()
5460 .to_string();
5461 assert!(err.contains("ownr"), "got {err}");
5462 }
5463
5464 #[test]
5467 fn an_epic_record_takes_weight_but_never_an_estimate_field() {
5468 let tmp = TempDir::new().unwrap();
5469 let path = write_toml(
5470 tmp.path(),
5471 "issues_dir = \"docs/issues\"\n\n\
5472 [epics.\"e-1\"]\nname = \"One\"\nestimate = \"2\"\n",
5473 );
5474 let err = Config::load_with_warning(&path, |_| {})
5475 .unwrap_err()
5476 .to_string();
5477 assert!(err.contains("estimate"), "got {err}");
5478 }
5479
5480 #[test]
5481 fn epic_declaration_order_is_restored() {
5482 let tmp = TempDir::new().unwrap();
5483 let path = write_toml(
5484 tmp.path(),
5485 "issues_dir = \"docs/issues\"\n\n\
5486 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5487 [epics.\"e-zeta\"]\nname = \"Zeta\"\n\n\
5488 [epics.\"e-alpha\"]\nname = \"Alpha\"\n",
5489 );
5490 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5491 assert_eq!(
5492 config.epics.keys().collect::<Vec<_>>(),
5493 ["e-zeta", "e-alpha"],
5494 "declaration order is the display order"
5495 );
5496 config.save(&path).unwrap();
5497 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5498 assert_eq!(
5499 reloaded.epics.keys().collect::<Vec<_>>(),
5500 ["e-zeta", "e-alpha"],
5501 "declaration order survives a save/reload through the ordered map"
5502 );
5503 }
5504
5505 #[test]
5506 fn a_legacy_tix_toml_gains_no_epics_section() {
5507 let tmp = TempDir::new().unwrap();
5508 let path = write_toml(tmp.path(), LEGACY_PRETTY_BOARD);
5509 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5510 assert!(config.epics.is_empty());
5511 config.save(&path).unwrap();
5512 let after = std::fs::read_to_string(&path).unwrap();
5513 assert_eq!(
5514 after, LEGACY_PRETTY_BOARD,
5515 "a repository with no epics must round-trip byte-identically"
5516 );
5517 assert!(
5518 !after.contains("[epics"),
5519 "an empty registry writes no section: {after}"
5520 );
5521 }
5522
5523 #[test]
5526 fn epics_and_projects_registries_coexist() {
5527 let tmp = TempDir::new().unwrap();
5528 let path = write_toml(tmp.path(), TWO_PROJECTS);
5529 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5530 assert!(config.epics.is_empty(), "no `[epics]` means no epics");
5531 config.save(&path).unwrap();
5532 let after = std::fs::read_to_string(&path).unwrap();
5533 assert!(!after.contains("[epics"), "got {after}");
5534 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5535 assert_eq!(reloaded.projects, config.projects);
5536 assert!(reloaded.epics.is_empty());
5537
5538 let both_dir = TempDir::new().unwrap();
5539 let both = write_toml(
5540 both_dir.path(),
5541 "issues_dir = \"docs/issues\"\n\n\
5542 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5543 [projects.\"p-3f9a2c\"]\nname = \"Platform\"\n\n\
5544 [epics.\"e-1\"]\nname = \"Harden\"\nprojects = [\"p-3f9a2c\"]\n",
5545 );
5546 let config = Config::load_with_warning(&both, |_| {}).unwrap();
5547 assert_eq!(config.projects.keys().collect::<Vec<_>>(), ["p-3f9a2c"]);
5548 assert_eq!(config.epics.keys().collect::<Vec<_>>(), ["e-1"]);
5549 config.save(&both).unwrap();
5550 let reloaded = Config::load_with_warning(&both, |_| {}).unwrap();
5551 assert_eq!(reloaded.projects, config.projects);
5552 assert_eq!(reloaded.epics, config.epics);
5553 }
5554
5555 #[test]
5556 fn an_empty_epic_record_writes_no_optional_keys() {
5557 let tmp = TempDir::new().unwrap();
5558 let path = write_toml(
5559 tmp.path(),
5560 "issues_dir = \"docs/issues\"\n\n\
5561 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5562 [epics.\"e-1\"]\nname = \"One\"\n",
5563 );
5564 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5565 config.save(&path).unwrap();
5566 let after = std::fs::read_to_string(&path).unwrap();
5567 for key in [
5568 "description",
5569 "status",
5570 "owner",
5571 "start_date",
5572 "due_date",
5573 "weight",
5574 "parent",
5575 "projects",
5576 "milestone",
5577 "cycle",
5578 ] {
5579 assert!(
5580 !after.contains(key),
5581 "an absent optional field must not be written: {key} in {after}"
5582 );
5583 }
5584 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5585 assert_eq!(reloaded, config);
5586 }
5587
5588 #[test]
5589 fn a_blank_epic_name_is_rejected() {
5590 let tmp = TempDir::new().unwrap();
5591 let path = write_toml(
5592 tmp.path(),
5593 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" \"\n",
5594 );
5595 let err = Config::load_with_warning(&path, |_| {})
5596 .unwrap_err()
5597 .to_string();
5598 assert!(err.contains("epics.e-1.name"), "got {err}");
5599 }
5600
5601 #[test]
5602 fn duplicate_epic_names_are_rejected() {
5603 for second in ["Same", "same"] {
5604 let tmp = TempDir::new().unwrap();
5605 let path = write_toml(
5606 tmp.path(),
5607 &format!(
5608 "issues_dir = \"docs/issues\"\n\n\
5609 [epics.\"e-1\"]\nname = \"Same\"\n\n\
5610 [epics.\"e-2\"]\nname = \"{second}\"\n"
5611 ),
5612 );
5613 let err = Config::load_with_warning(&path, |_| {})
5614 .unwrap_err()
5615 .to_string();
5616 assert!(err.contains("must be unique"), "got {err}");
5617 }
5618 }
5619
5620 #[test]
5621 fn a_padded_epic_name_is_rejected() {
5622 let tmp = TempDir::new().unwrap();
5623 let path = write_toml(
5624 tmp.path(),
5625 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \" One \"\n",
5626 );
5627 let err = Config::load_with_warning(&path, |_| {})
5628 .unwrap_err()
5629 .to_string();
5630 assert!(err.contains("leading or trailing whitespace"), "got {err}");
5631 }
5632
5633 #[test]
5634 fn a_blank_epic_id_is_rejected() {
5635 let tmp = TempDir::new().unwrap();
5636 let path = write_toml(
5637 tmp.path(),
5638 "issues_dir = \"docs/issues\"\n\n[epics.\"\"]\nname = \"No id\"\n",
5639 );
5640 let err = Config::load_with_warning(&path, |_| {})
5641 .unwrap_err()
5642 .to_string();
5643 assert!(err.contains("must not be blank"), "got {err}");
5644 }
5645
5646 #[test]
5647 fn an_epic_id_with_a_structural_character_is_rejected() {
5648 let tmp = TempDir::new().unwrap();
5649 let path = write_toml(
5650 tmp.path(),
5651 "issues_dir = \"docs/issues\"\n\n[epics.\"e.1\"]\nname = \"Dotted\"\n",
5652 );
5653 let err = Config::load_with_warning(&path, |_| {})
5654 .unwrap_err()
5655 .to_string();
5656 assert!(err.contains("contains"), "got {err}");
5657 }
5658
5659 #[test]
5660 fn blank_optional_epic_text_is_rejected() {
5661 for key in ["description", "status", "owner", "milestone", "cycle"] {
5662 let tmp = TempDir::new().unwrap();
5663 let path = write_toml(
5664 tmp.path(),
5665 &format!(
5666 "issues_dir = \"docs/issues\"\n\n\
5667 [epics.\"e-1\"]\nname = \"One\"\n{key} = \"\"\n"
5668 ),
5669 );
5670 let err = Config::load_with_warning(&path, |_| {})
5671 .unwrap_err()
5672 .to_string();
5673 assert!(err.contains(&format!("epics.e-1.{key}")), "got {err}");
5674 }
5675 }
5676
5677 #[test]
5678 fn a_bad_or_inverted_epic_date_is_rejected() {
5679 let tmp = TempDir::new().unwrap();
5680 let bad = write_toml(
5681 tmp.path(),
5682 "issues_dir = \"docs/issues\"\n\n\
5683 [epics.\"e-1\"]\nname = \"One\"\nstart_date = \"2026-13-01\"\n",
5684 );
5685 let err = Config::load_with_warning(&bad, |_| {})
5686 .unwrap_err()
5687 .to_string();
5688 assert!(err.contains("epics.e-1"), "got {err}");
5689
5690 let inverted = write_toml(
5691 tmp.path(),
5692 "issues_dir = \"docs/issues\"\n\n\
5693 [epics.\"e-1\"]\nname = \"One\"\n\
5694 start_date = \"2026-12-01\"\ndue_date = \"2026-01-01\"\n",
5695 );
5696 let err = Config::load_with_warning(&inverted, |_| {})
5697 .unwrap_err()
5698 .to_string();
5699 assert!(err.contains("epics.e-1"), "got {err}");
5700 }
5701
5702 #[test]
5703 fn a_non_positive_or_unparseable_weight_is_rejected() {
5704 for body in ["weight = 0", "weight = -3"] {
5705 let tmp = TempDir::new().unwrap();
5706 let path = write_toml(
5707 tmp.path(),
5708 &format!(
5709 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
5710 ),
5711 );
5712 let err = Config::load_with_warning(&path, |_| {})
5713 .unwrap_err()
5714 .to_string();
5715 assert!(err.contains("positive integer"), "{body}: got {err}");
5716 assert!(err.contains("epics.e-1.weight"), "{body}: got {err}");
5717 }
5718
5719 let tmp = TempDir::new().unwrap();
5721 let path = write_toml(
5722 tmp.path(),
5723 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\nweight = \"big\"\n",
5724 );
5725 let err = Config::load_with_warning(&path, |_| {})
5726 .unwrap_err()
5727 .to_string();
5728 assert!(err.contains("weight"), "got {err}");
5729 }
5730
5731 #[test]
5736 fn epic_references_are_shape_checked_but_never_resolved() {
5737 let tmp = TempDir::new().unwrap();
5738 let path = write_toml(
5739 tmp.path(),
5740 "issues_dir = \"docs/issues\"\n\n\
5741 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5742 [epics.\"e-1\"]\nname = \"Child\"\n\
5743 parent = \"e-missing\"\nprojects = [\"p-missing\"]\n\
5744 milestone = \"2026-Q4\"\ncycle = \"No such cycle\"\n",
5745 );
5746 let config = Config::load_with_warning(&path, |_| {})
5747 .expect("a dangling reference must stay loadable");
5748 let epic = &config.epics["e-1"];
5749 assert_eq!(epic.parent.as_deref(), Some("e-missing"));
5750 assert_eq!(epic.projects, ["p-missing"]);
5751 assert_eq!(epic.milestone.as_deref(), Some("2026-Q4"));
5752 assert_eq!(epic.cycle.as_deref(), Some("No such cycle"));
5753
5754 for (body, needle) in [
5756 ("parent = \"e.1\"", "epics.e-1.parent"),
5757 ("parent = \"\"", "epics.e-1.parent"),
5758 ("parent = \"e-1\"", "must not name the epic itself"),
5759 ("projects = [\"p.1\"]", "epics.e-1.projects"),
5760 ] {
5761 let tmp = TempDir::new().unwrap();
5762 let path = write_toml(
5763 tmp.path(),
5764 &format!(
5765 "issues_dir = \"docs/issues\"\n\n[epics.\"e-1\"]\nname = \"One\"\n{body}\n"
5766 ),
5767 );
5768 let err = Config::load_with_warning(&path, |_| {})
5769 .unwrap_err()
5770 .to_string();
5771 assert!(err.contains(needle), "{body}: got {err}");
5772 }
5773 }
5774
5775 #[test]
5777 fn allocated_epic_ids_have_the_generated_shape_and_successive_draws_differ() {
5778 let mut config = Config::default();
5779 let first = config.allocate_epic_id();
5780 config.epics.insert(
5781 first.to_string(),
5782 EpicConfiguration {
5783 name: "One".into(),
5784 ..EpicConfiguration::default()
5785 },
5786 );
5787 let second = config.allocate_epic_id();
5788 assert_ne!(first, second, "two allocations must be distinct");
5789 assert_eq!(first.as_str().len(), 18, "`e-` plus 16 hex digits");
5790 assert!(first.as_str().starts_with("e-"));
5791
5792 config.epics.shift_remove(first.as_str());
5795 let third = config.allocate_epic_id();
5796 assert_ne!(third, first, "a deleted epic's id must not be handed out");
5797 assert_ne!(third, second);
5798 }
5799
5800 #[test]
5804 fn epic_allocation_redraws_past_a_stored_id() {
5805 let mut config = Config::default();
5806 config.epics.insert(
5807 "e-aaaa".into(),
5808 EpicConfiguration {
5809 name: "Stored".into(),
5810 ..EpicConfiguration::default()
5811 },
5812 );
5813 let scripted = ["e-aaaa", "e-bbbb"];
5814 let mut draws = 0;
5815 let allocated = config.allocate_epic_id_with(|| {
5816 let drawn = scripted[draws];
5817 draws += 1;
5818 EpicId::new(drawn).unwrap()
5819 });
5820 assert_eq!(allocated.as_str(), "e-bbbb");
5821 assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5822 }
5823
5824 #[test]
5825 fn a_hand_written_epic_id_loads_and_is_never_allocated_again() {
5826 let tmp = TempDir::new().unwrap();
5827 let path = write_toml(
5828 tmp.path(),
5829 "issues_dir = \"docs/issues\"\n\n\
5830 [epics.\"e-ffffffffffffffff\"]\nname = \"Hand written\"\n",
5831 );
5832 let config = Config::load_with_warning(&path, |_| {}).unwrap();
5833 assert!(config.epics.contains_key("e-ffffffffffffffff"));
5834 let mut draws = 0;
5837 let allocated = config.allocate_epic_id_with(|| {
5838 draws += 1;
5839 if draws < 3 {
5840 EpicId::new("e-ffffffffffffffff").unwrap()
5841 } else {
5842 EpicId::new("e-0000000000000000").unwrap()
5843 }
5844 });
5845 assert_eq!(allocated.as_str(), "e-0000000000000000");
5846 assert_eq!(draws, 3);
5847 }
5848
5849 #[test]
5850 fn an_epic_rename_changes_the_label_and_never_the_key() {
5851 let tmp = TempDir::new().unwrap();
5852 let path = write_toml(tmp.path(), TWO_EPICS);
5853 let mut config = Config::load_with_warning(&path, |_| {}).unwrap();
5854 config.epics["e-7c4a91"].name = "Harden the sync path (renamed)".into();
5855 config.save(&path).unwrap();
5856
5857 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
5858 assert!(
5859 reloaded.epics.contains_key("e-7c4a91"),
5860 "the id is the key and must survive a rename"
5861 );
5862 assert_eq!(
5863 reloaded.epics["e-7c4a91"].name,
5864 "Harden the sync path (renamed)"
5865 );
5866 }
5867
5868 #[test]
5869 fn epic_id_validation_accepts_handles_and_rejects_junk() {
5870 assert!(EpicId::new("e-platform").is_ok());
5871 assert!(EpicId::new("e-7c4a91").is_ok());
5872 assert!(EpicId::new("").is_err());
5873 assert!(EpicId::new(" e-1").is_err(), "surrounding whitespace");
5874 assert!(EpicId::new("e 1").is_err(), "inner whitespace");
5875 assert!(EpicId::new("e.1").is_err(), "structural `.`");
5876 assert!(EpicId::new(&"x".repeat(EpicId::MAX_LEN + 1)).is_err());
5877 }
5878
5879 #[test]
5883 fn the_two_registries_share_the_id_rules() {
5884 assert_eq!(
5885 ProjectId::new("").unwrap_err().to_string(),
5886 "config error: a project id must not be blank"
5887 );
5888 assert_eq!(
5889 EpicId::new("").unwrap_err().to_string(),
5890 "config error: an epic id must not be blank"
5891 );
5892 assert_eq!(
5893 ProjectId::new(" p-1").unwrap_err().to_string(),
5894 "config error: project id ` p-1` has leading or trailing whitespace"
5895 );
5896 assert_eq!(
5897 EpicId::new("e-1 ").unwrap_err().to_string(),
5898 "config error: epic id `e-1 ` has leading or trailing whitespace"
5899 );
5900 assert_eq!(ProjectId::MAX_LEN, EpicId::MAX_LEN);
5901
5902 for handle in ["x-y", "a_b", "a:b", "a-1"] {
5905 assert!(ProjectId::new(handle).is_ok(), "{handle}");
5906 assert!(EpicId::new(handle).is_ok(), "{handle}");
5907 }
5908 for junk in ["", " a", "a b", "a.b", "a[b", "a\"b"] {
5909 assert!(ProjectId::new(junk).is_err(), "{junk}");
5910 assert!(EpicId::new(junk).is_err(), "{junk}");
5911 }
5912 }
5913
5914 const ONE_INITIATIVE: &str = "issues_dir = \"docs/issues\"\n\n\
5915 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
5916 [initiatives.\"i-1\"]\nname = \"Grow the platform\"\n";
5917
5918 #[test]
5919 fn initiative_id_validation_accepts_handles_and_rejects_junk() {
5920 assert!(InitiativeId::new("i-outcome").is_ok());
5921 assert!(InitiativeId::new("i-7c4a91").is_ok());
5922 assert!(InitiativeId::new("").is_err());
5923 assert!(InitiativeId::new(" i-1").is_err(), "surrounding whitespace");
5924 assert!(InitiativeId::new("i 1").is_err(), "inner whitespace");
5925 assert!(InitiativeId::new("i.1").is_err(), "structural `.`");
5926 assert!(InitiativeId::new(&"x".repeat(InitiativeId::MAX_LEN + 1)).is_err());
5927 }
5928
5929 #[test]
5932 fn the_three_registries_share_the_id_rules() {
5933 assert_eq!(
5934 InitiativeId::new("").unwrap_err().to_string(),
5935 "config error: an initiative id must not be blank"
5936 );
5937 assert_eq!(
5938 InitiativeId::new(" i-1").unwrap_err().to_string(),
5939 "config error: initiative id ` i-1` has leading or trailing whitespace"
5940 );
5941 assert_eq!(InitiativeId::MAX_LEN, ProjectId::MAX_LEN);
5942 assert_eq!(InitiativeId::MAX_LEN, EpicId::MAX_LEN);
5943 }
5944
5945 #[test]
5946 fn allocated_initiative_ids_have_the_generated_shape_and_successive_draws_differ() {
5947 let mut config = Config::default();
5948 let first = config.allocate_initiative_id();
5949 config.initiatives.insert(
5950 first.to_string(),
5951 InitiativeConfiguration {
5952 name: "One".into(),
5953 ..InitiativeConfiguration::default()
5954 },
5955 );
5956 let second = config.allocate_initiative_id();
5957 assert_ne!(first, second, "two allocations must be distinct");
5958 assert_eq!(first.as_str().len(), 18, "`i-` plus 16 hex digits");
5959 assert!(first.as_str().starts_with("i-"));
5960
5961 config.initiatives.shift_remove(first.as_str());
5964 let third = config.allocate_initiative_id();
5965 assert_ne!(
5966 third, first,
5967 "a deleted initiative's id must not be handed out"
5968 );
5969 assert_ne!(third, second);
5970 }
5971
5972 #[test]
5973 fn initiative_allocation_redraws_past_a_stored_id() {
5974 let mut config = Config::default();
5975 config.initiatives.insert(
5976 "i-aaaa".into(),
5977 InitiativeConfiguration {
5978 name: "Stored".into(),
5979 ..InitiativeConfiguration::default()
5980 },
5981 );
5982 let scripted = ["i-aaaa", "i-bbbb"];
5983 let mut draws = 0;
5984 let allocated = config.allocate_initiative_id_with(|| {
5985 let drawn = scripted[draws];
5986 draws += 1;
5987 InitiativeId::new(drawn).unwrap()
5988 });
5989 assert_eq!(allocated.as_str(), "i-bbbb");
5990 assert_eq!(draws, 2, "the stored id must have been drawn and redrawn");
5991 }
5992
5993 #[test]
5996 fn an_absent_initiatives_section_and_an_empty_record_write_nothing_extra() {
5997 let tmp = TempDir::new().unwrap();
5998 let legacy = write_toml(
5999 tmp.path(),
6000 "issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
6001 );
6002 let config = Config::load_with_warning(&legacy, |_| {}).unwrap();
6003 assert!(config.initiatives.is_empty());
6004 config.save(&legacy).unwrap();
6005 assert!(
6006 !std::fs::read_to_string(&legacy)
6007 .unwrap()
6008 .contains("[initiatives"),
6009 "a legacy file gains no section"
6010 );
6011
6012 let path = write_toml(tmp.path(), ONE_INITIATIVE);
6013 let config = Config::load_with_warning(&path, |_| {}).unwrap();
6014 config.save(&path).unwrap();
6015 let after = std::fs::read_to_string(&path).unwrap();
6016 for key in [
6017 "outcome",
6018 "owner",
6019 "status",
6020 "priority",
6021 "labels",
6022 "target_date",
6023 "projects",
6024 "epics",
6025 "milestones",
6026 ] {
6027 assert!(
6028 !after.contains(key),
6029 "an absent optional field must not be written: {key} in {after}"
6030 );
6031 }
6032 assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
6033 }
6034
6035 #[test]
6039 fn an_initiative_preserves_unknown_metadata_through_a_round_trip() {
6040 let tmp = TempDir::new().unwrap();
6041 let path = write_toml(
6042 tmp.path(),
6043 "issues_dir = \"docs/issues\"\n\n\
6044 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6045 [initiatives.\"i-1\"]\nname = \"One\"\ncustom_field = \"keep me\"\n\
6046 nested = { answer = 42 }\n",
6047 );
6048 let config = Config::load_with_warning(&path, |_| {}).unwrap();
6049 let extensions = &config.initiatives["i-1"].extensions;
6050 assert_eq!(extensions.len(), 2);
6051 assert_eq!(
6052 extensions.get("custom_field").and_then(toml::Value::as_str),
6053 Some("keep me")
6054 );
6055 config.save(&path).unwrap();
6056 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6057 assert_eq!(reloaded.initiatives["i-1"].extensions, *extensions);
6058 }
6059
6060 #[test]
6061 fn initiative_validation_refuses_blank_names_bad_dates_and_bad_references() {
6062 for (body, needle) in [
6063 ("name = \" \"", "initiatives.i-1.name"),
6064 ("name = \" One \"", "leading or trailing whitespace"),
6065 (
6066 "name = \"One\"\ntarget_date = \"2026-13-01\"",
6067 "initiatives.i-1.target_date",
6068 ),
6069 (
6070 "name = \"One\"\ntarget_date = \"not a date\"",
6071 "initiatives.i-1.target_date",
6072 ),
6073 (
6074 "name = \"One\"\nprojects = [\"p.1\"]",
6075 "initiatives.i-1.projects",
6076 ),
6077 ("name = \"One\"\nepics = [\"e.1\"]", "initiatives.i-1.epics"),
6078 (
6079 "name = \"One\"\nmilestones = [\"\"]",
6080 "initiatives.i-1.milestones",
6081 ),
6082 (
6083 "name = \"One\"\nlabels = [\" \"]",
6084 "initiatives.i-1.labels",
6085 ),
6086 ("name = \"One\"\nowner = \"\"", "initiatives.i-1.owner"),
6087 ] {
6088 let tmp = TempDir::new().unwrap();
6089 let path = write_toml(
6090 tmp.path(),
6091 &format!(
6092 "issues_dir = \"docs/issues\"\n\n[kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6093 [initiatives.\"i-1\"]\n{body}\n"
6094 ),
6095 );
6096 let err = Config::load_with_warning(&path, |_| {})
6097 .unwrap_err()
6098 .to_string();
6099 assert!(err.contains(needle), "{body}: got {err}");
6100 }
6101 }
6102
6103 #[test]
6104 fn duplicate_initiative_names_are_rejected_case_folded() {
6105 for second in ["Same", "same"] {
6106 let tmp = TempDir::new().unwrap();
6107 let path = write_toml(
6108 tmp.path(),
6109 &format!(
6110 "issues_dir = \"docs/issues\"\n\n\
6111 [initiatives.\"i-1\"]\nname = \"Same\"\n\n\
6112 [initiatives.\"i-2\"]\nname = \"{second}\"\n"
6113 ),
6114 );
6115 let err = Config::load_with_warning(&path, |_| {})
6116 .unwrap_err()
6117 .to_string();
6118 assert!(err.contains("must be unique"), "got {err}");
6119 }
6120 }
6121
6122 #[test]
6125 fn initiative_references_are_shape_checked_but_never_resolved() {
6126 let tmp = TempDir::new().unwrap();
6127 let path = write_toml(
6128 tmp.path(),
6129 "issues_dir = \"docs/issues\"\n\n\
6130 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6131 [initiatives.\"i-1\"]\nname = \"One\"\n\
6132 projects = [\"p-missing\"]\nepics = [\"e-missing\"]\nmilestones = [\"2026-Q4\"]\n",
6133 );
6134 let config = Config::load_with_warning(&path, |_| {})
6135 .expect("a dangling reference must stay loadable");
6136 let initiative = &config.initiatives["i-1"];
6137 assert_eq!(initiative.projects, ["p-missing"]);
6138 assert_eq!(initiative.epics, ["e-missing"]);
6139 assert_eq!(initiative.milestones, ["2026-Q4"]);
6140 }
6141
6142 #[test]
6146 fn initiative_declaration_order_survives_a_save_and_reload() {
6147 let tmp = TempDir::new().unwrap();
6148 let path = write_toml(
6149 tmp.path(),
6150 "issues_dir = \"docs/issues\"\n\n\
6151 [kanban.default]\ncolumns = [\"todo\", \"done\"]\n\n\
6152 [initiatives.\"i-zulu\"]\nname = \"Zulu\"\n\n\
6153 [initiatives.\"i-alpha\"]\nname = \"Alpha\"\n",
6154 );
6155 let config = Config::load_with_warning(&path, |_| {}).unwrap();
6156 assert_eq!(
6157 config.initiatives.keys().collect::<Vec<_>>(),
6158 ["i-zulu", "i-alpha"]
6159 );
6160 config.save(&path).unwrap();
6161 let written = std::fs::read_to_string(&path).unwrap();
6162 assert!(
6163 written.find("[initiatives.i-zulu]").unwrap()
6164 < written.find("[initiatives.i-alpha]").unwrap(),
6165 "declaration order is preserved on write:\n{written}"
6166 );
6167 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6168 assert_eq!(
6169 reloaded.initiatives.keys().collect::<Vec<_>>(),
6170 ["i-zulu", "i-alpha"]
6171 );
6172 }
6173
6174 #[test]
6178 fn an_initiative_priority_uses_the_shared_shape() {
6179 let tmp = TempDir::new().unwrap();
6180 let path = write_toml(
6181 tmp.path(),
6182 "issues_dir = \"docs/issues\"\n\n\
6183 [initiatives.\"i-1\"]\nname = \"Known\"\npriority = \"HIGH\"\n\n\
6184 [initiatives.\"i-2\"]\nname = \"Unknown\"\npriority = \"urgent-ish\"\n",
6185 );
6186 let config = Config::load_with_warning(&path, |_| {}).unwrap();
6187 assert_eq!(
6188 config.initiatives["i-1"].priority,
6189 Some(crate::model::Priority::High)
6190 );
6191 assert_eq!(
6192 config.initiatives["i-2"].priority,
6193 Some(crate::model::Priority::Other("urgent-ish".into()))
6194 );
6195 config.save(&path).unwrap();
6196 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
6197 let written = std::fs::read_to_string(&path).unwrap();
6198 assert!(written.contains("priority = \"high\""), "{written}");
6199 assert!(written.contains("priority = \"urgent-ish\""), "{written}");
6200 assert_eq!(reloaded.initiatives, config.initiatives);
6201 }
6202
6203 #[test]
6208 fn a_shadowing_extension_key_is_refused_by_validation() {
6209 for key in INITIATIVE_KNOWN_FIELDS {
6210 let mut extensions = crate::roadmap::registry::InitiativeExtensions::default();
6211 extensions.insert(*key, toml::Value::String("evil".into()));
6212 let mut config = Config::default();
6213 config.initiatives.insert(
6214 "i-1".into(),
6215 InitiativeConfiguration {
6216 name: "One".into(),
6217 extensions,
6218 ..InitiativeConfiguration::default()
6219 },
6220 );
6221 let err = config.validate().unwrap_err().to_string();
6222 assert!(
6223 err.contains("collides with a built-in field"),
6224 "{key}: got {err}"
6225 );
6226 }
6227
6228 let mut config = Config::default();
6232 config.initiatives.insert(
6233 "i-1".into(),
6234 InitiativeConfiguration {
6235 name: "One".into(),
6236 ..InitiativeConfiguration::default()
6237 },
6238 );
6239 config.validate().unwrap();
6240 }
6241}