1use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use indexmap::IndexMap;
15use serde::ser::SerializeStruct;
16use serde::{Deserialize, Serialize, Serializer};
17
18use crate::board_store::BoardStore;
19use crate::cycle_store::{CycleStore, is_implicit_default};
20use crate::error::{Result, TixError};
21use crate::keybindings::{EffectiveBindings, KeyBindings};
22use crate::records::RecordStore;
23
24#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(default, deny_unknown_fields)]
31pub struct RemoteConfig {
32 pub token: Option<String>,
35 pub base_url: Option<String>,
37 pub project: Option<String>,
39 pub team: Option<String>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct ExportConfig {
47 pub gitlab: Option<RemoteConfig>,
49 pub linear: Option<RemoteConfig>,
51}
52
53impl ExportConfig {
54 pub fn is_empty(&self) -> bool {
59 self.gitlab.is_none() && self.linear.is_none()
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
70#[serde(default, deny_unknown_fields)]
71pub struct Team {
72 pub members: Vec<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
80#[serde(default, deny_unknown_fields)]
81pub struct PlanningCycle {
82 pub description: Option<String>,
84 pub start_date: Option<String>,
86 pub due_date: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(default, deny_unknown_fields)]
99pub struct PlanningConfig {
100 pub cycle_number: u32,
102 pub default_cycle: Option<String>,
104 pub default_milestone: Option<String>,
106 pub default_team: Option<String>,
108}
109
110impl PlanningConfig {
111 pub fn is_default(&self) -> bool {
115 self == &Self::default()
116 }
117}
118
119impl Default for PlanningConfig {
120 fn default() -> Self {
121 Self {
122 cycle_number: 1,
123 default_cycle: None,
124 default_milestone: None,
125 default_team: None,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct IssueTemplate {
152 pub title: Option<String>,
155 pub body: Option<String>,
157 pub labels: Vec<String>,
159 pub status: Option<String>,
161 pub priority: Option<String>,
163 pub assignee: Option<String>,
165 pub estimate: Option<String>,
167 pub reviewer: Option<String>,
169 pub cycles: Vec<String>,
171 pub milestones: Vec<String>,
173 pub start_date: Option<String>,
175 pub due_date: Option<String>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
186#[serde(default, deny_unknown_fields)]
187pub struct StateConfig {
188 #[serde(skip_serializing)]
192 pub next_number: u32,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
200pub enum OrderBy {
201 #[default]
203 #[serde(rename = "created_at_desc")]
204 CreatedAtDesc,
205 #[serde(rename = "created_at_asc")]
207 CreatedAtAsc,
208 #[serde(rename = "none")]
210 None,
211}
212
213impl OrderBy {
214 pub fn as_str(self) -> &'static str {
216 match self {
217 Self::CreatedAtDesc => "created_at_desc",
218 Self::CreatedAtAsc => "created_at_asc",
219 Self::None => "none",
220 }
221 }
222
223 pub fn next(self) -> Self {
225 match self {
226 Self::CreatedAtDesc => Self::CreatedAtAsc,
227 Self::CreatedAtAsc => Self::None,
228 Self::None => Self::CreatedAtDesc,
229 }
230 }
231}
232
233pub const CATEGORY_KEYS: &[&str] = &[
244 "backlog",
245 "unstarted",
246 "active",
247 "blocked",
248 "completed",
249 "canceled",
250];
251
252pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
254
255pub const COMPLETE_CATEGORY: &str = "completed";
258
259pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(default, deny_unknown_fields)]
273pub struct CategoryConfig {
274 pub order: Vec<String>,
281 pub default: String,
284}
285
286impl Default for CategoryConfig {
287 fn default() -> Self {
288 Self {
289 order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
290 default: SAFE_DEFAULT_CATEGORY.to_string(),
291 }
292 }
293}
294
295impl CategoryConfig {
296 pub fn is_default(&self) -> bool {
298 self == &Self::default()
299 }
300
301 pub fn contains(&self, key: &str) -> bool {
303 self.order.iter().any(|k| k == key)
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(default, deny_unknown_fields)]
310pub struct TransitionConfig {
311 pub terminal_locked: bool,
317}
318
319impl Default for TransitionConfig {
320 fn default() -> Self {
321 Self {
322 terminal_locked: true,
323 }
324 }
325}
326
327impl TransitionConfig {
328 pub fn is_default(&self) -> bool {
329 self == &Self::default()
330 }
331}
332
333const DEFAULT_TITLE_LINES: usize = 3;
337
338const DEFAULT_DONE_COLUMN: &str = "done";
345
346#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
356#[serde(default, deny_unknown_fields)]
357pub struct BoardConfiguration {
358 pub columns: Vec<String>,
360 pub done_column: Option<String>,
369 pub category_map: IndexMap<String, String>,
378 pub hidden_columns: Vec<String>,
381 pub show_mismatch: bool,
384 pub wrap_titles: bool,
386 pub title_lines: usize,
390 pub card_order: Vec<String>,
394 pub order_by: OrderBy,
397}
398
399impl Default for BoardConfiguration {
400 fn default() -> Self {
401 Self {
402 columns: ["todo", "in progress", "in review", "done"]
403 .into_iter()
404 .map(String::from)
405 .collect(),
406 done_column: Some(DEFAULT_DONE_COLUMN.to_string()),
407 category_map: IndexMap::new(),
408 hidden_columns: Vec::new(),
409 show_mismatch: false,
410 wrap_titles: true,
411 title_lines: DEFAULT_TITLE_LINES,
412 card_order: Vec::new(),
413 order_by: OrderBy::default(),
414 }
415 }
416}
417
418impl Serialize for BoardConfiguration {
419 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
432 where
433 S: Serializer,
434 {
435 let done = self
436 .done_column
437 .as_deref()
438 .filter(|done| *done != DEFAULT_DONE_COLUMN);
439 let order_by = (self.order_by != OrderBy::default()).then_some(self.order_by);
440
441 let mut len = 1; len += usize::from(done.is_some());
443 len += usize::from(!self.category_map.is_empty());
444 len += usize::from(!self.hidden_columns.is_empty());
445 len += usize::from(self.show_mismatch);
446 len += usize::from(!self.wrap_titles);
447 len += usize::from(self.title_lines != DEFAULT_TITLE_LINES);
448 len += usize::from(!self.card_order.is_empty());
449 len += usize::from(order_by.is_some());
450
451 let mut board = serializer.serialize_struct("BoardConfiguration", len)?;
452 board.serialize_field("columns", &self.columns)?;
453 if let Some(done) = done {
454 board.serialize_field("done_column", done)?;
455 }
456 if !self.category_map.is_empty() {
457 board.serialize_field("category_map", &self.category_map)?;
458 }
459 if !self.hidden_columns.is_empty() {
460 board.serialize_field("hidden_columns", &self.hidden_columns)?;
461 }
462 if self.show_mismatch {
463 board.serialize_field("show_mismatch", &self.show_mismatch)?;
464 }
465 if !self.wrap_titles {
466 board.serialize_field("wrap_titles", &self.wrap_titles)?;
467 }
468 if self.title_lines != DEFAULT_TITLE_LINES {
469 board.serialize_field("title_lines", &self.title_lines)?;
470 }
471 if !self.card_order.is_empty() {
472 board.serialize_field("card_order", &self.card_order)?;
473 }
474 if let Some(order_by) = order_by {
475 board.serialize_field("order_by", &order_by)?;
476 }
477 board.end()
478 }
479}
480
481pub fn starter_board() -> BoardConfiguration {
498 BoardConfiguration {
499 columns: [
500 "triage",
501 "todo",
502 "in progress",
503 "in review",
504 "done",
505 "wontfix",
506 ]
507 .into_iter()
508 .map(String::from)
509 .collect(),
510 category_map: IndexMap::from([
511 ("done".to_string(), "completed".to_string()),
512 ("wontfix".to_string(), "canceled".to_string()),
513 ]),
514 ..BoardConfiguration::default()
515 }
516}
517
518impl BoardConfiguration {
519 pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
539 if let Some(mapped) = self.category_map.get(column) {
540 return mapped;
541 }
542 if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
543 return COMPLETE_CATEGORY;
544 }
545 &categories.default
546 }
547
548 pub fn effective_done_column(&self) -> Option<&str> {
551 self.done_column
552 .as_deref()
553 .or_else(|| self.inferred_done_column())
554 }
555
556 fn inferred_done_column(&self) -> Option<&str> {
564 if self.columns.iter().any(|c| c == DEFAULT_DONE_COLUMN) {
565 Some(DEFAULT_DONE_COLUMN)
566 } else {
567 self.columns.last().map(String::as_str)
568 }
569 }
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
583#[serde(default, transparent)]
584pub struct KanbanSection {
585 pub configurations: IndexMap<String, BoardConfiguration>,
587}
588
589impl KanbanSection {
590 pub fn is_empty(&self) -> bool {
594 self.configurations.is_empty()
595 }
596}
597
598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(default, deny_unknown_fields)]
601pub struct Config {
602 #[serde(default, skip_serializing_if = "KanbanSection::is_empty")]
604 pub kanban: KanbanSection,
605 pub issues_dir: PathBuf,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub milestones_dir: Option<PathBuf>,
611 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
613 pub teams: IndexMap<String, Team>,
614 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
616 pub cycles: IndexMap<String, PlanningCycle>,
617 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
622 pub templates: IndexMap<String, IssueTemplate>,
623 #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
625 pub planning: PlanningConfig,
626 #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
629 pub categories: CategoryConfig,
630 #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
632 pub transitions: TransitionConfig,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub triage: Option<crate::triage::TriageConfig>,
642 pub editor: Option<String>,
644 #[serde(skip_serializing_if = "Option::is_none")]
651 pub viewer: Option<String>,
652 pub agent: Option<String>,
656 #[serde(skip_serializing)]
659 pub state: StateConfig,
660 #[serde(default, skip_serializing_if = "ExportConfig::is_empty")]
663 pub export: ExportConfig,
664 #[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
666 pub keybindings: KeyBindings,
667 #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
678 pub custom_attributes: CustomAttributes,
679 #[serde(default = "default_true", skip_serializing_if = "is_true")]
685 pub lint_foreign_repositories: bool,
686 #[serde(default, skip_serializing_if = "LayoutConfig::is_default")]
688 pub layout: LayoutConfig,
689 #[serde(
697 default,
698 skip_serializing_if = "Option::is_none",
699 alias = "custom_types"
700 )]
701 pub types: Option<Vec<String>>,
702 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
708 pub type_colors: IndexMap<String, String>,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub tags_dir: Option<PathBuf>,
716 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
724 pub repos: IndexMap<String, PathBuf>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub parent_repo: Option<PathBuf>,
733}
734
735#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
747#[serde(default, deny_unknown_fields)]
748pub struct CustomAttributes {
749 pub keys: Vec<String>,
753}
754
755impl CustomAttributes {
756 pub fn is_empty(&self) -> bool {
758 self.keys.is_empty()
759 }
760}
761
762fn default_true() -> bool {
764 true
765}
766#[allow(clippy::trivially_copy_pass_by_ref)] fn is_true(value: &bool) -> bool {
770 *value
771}
772
773pub const DEFAULT_TYPES: &[&str] = &[
780 "default",
781 "bug",
782 "feature",
783 "task",
784 "improvement",
785 "documentation",
786 "question",
787 "duplicate",
788 "chore",
789 "open",
790];
791
792pub const DEFAULT_TYPE_COLORS: &[(&str, &str)] = &[
799 ("default", "gray"),
800 ("bug", "red"),
801 ("feature", "green"),
802 ("task", "blue"),
803 ("improvement", "cyan"),
804 ("documentation", "yellow"),
805 ("question", "magenta"),
806 ("duplicate", "dark_gray"),
807 ("chore", "dark_gray"),
808 ("open", "white"),
809];
810
811#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818#[serde(default, deny_unknown_fields)]
819pub struct LayoutConfig {
820 pub directory: String,
822 pub filename: String,
824 pub move_on_change: bool,
826}
827
828impl Default for LayoutConfig {
829 fn default() -> Self {
830 LayoutConfig {
831 directory: "{assignee}/{type}/{status}".to_string(),
832 filename: "{date}-{slug}.md".to_string(),
833 move_on_change: true,
834 }
835 }
836}
837
838impl LayoutConfig {
839 #[must_use]
841 pub fn is_default(&self) -> bool {
842 *self == Self::default()
843 }
844}
845
846impl Default for Config {
847 fn default() -> Self {
848 Config {
849 kanban: KanbanSection {
850 configurations: IndexMap::from([(
851 "default".to_string(),
852 BoardConfiguration::default(),
853 )]),
854 },
855 issues_dir: PathBuf::from("docs/issues"),
856 milestones_dir: None,
857 teams: IndexMap::new(),
858 cycles: IndexMap::new(),
859 templates: IndexMap::new(),
860 planning: PlanningConfig::default(),
861 categories: CategoryConfig::default(),
862 transitions: TransitionConfig::default(),
863 triage: None,
870 editor: None,
871 viewer: None,
872 state: StateConfig::default(),
873 export: ExportConfig::default(),
874 agent: None,
875 keybindings: KeyBindings::default(),
876 custom_attributes: CustomAttributes::default(),
877 lint_foreign_repositories: true,
878 layout: LayoutConfig::default(),
879 types: None,
880 type_colors: IndexMap::new(),
881 tags_dir: None,
882 repos: IndexMap::new(),
883 parent_repo: None,
884 }
885 }
886}
887
888impl Config {
889 #[must_use]
903 pub fn starter() -> Config {
904 let mut config = Config::default();
905 config
906 .kanban
907 .configurations
908 .insert("default".to_string(), starter_board());
909 config.triage = Some(crate::triage::TriageConfig {
910 entry_status: Some("triage".to_string()),
911 accepted_status: Some("todo".to_string()),
912 declined_status: Some("wontfix".to_string()),
913 deferred_status: None,
914 });
915 config.types = Some(DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect());
919 config.tags_dir = Some(PathBuf::from("docs/tags"));
922 config
923 }
924
925 pub(crate) fn validate(&self) -> Result<()> {
929 if self.issues_dir.as_os_str().is_empty() {
930 return Err(TixError::Config("`issues_dir` must not be blank".into()));
931 }
932 if self.milestones_dir().as_os_str().is_empty() {
933 return Err(TixError::Config(
934 "`milestones_dir` must not be blank".into(),
935 ));
936 }
937 if self.tags_dir().as_os_str().is_empty() {
938 return Err(TixError::Config("`tags_dir` must not be blank".into()));
939 }
940 validate_repos(&self.repos)?;
941 if self
942 .parent_repo
943 .as_deref()
944 .is_some_and(|p| p.as_os_str().is_empty())
945 {
946 return Err(TixError::Config("`parent_repo` must not be blank".into()));
947 }
948 if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
953 return Err(TixError::Config(
954 "`viewer` must not be blank; use `auto` for the default chain".into(),
955 ));
956 }
957 validate_planning(&self.planning)?;
958 crate::layout::validate(&self.layout)?;
959 validate_categories(&self.categories)?;
960 self.effective_keybindings()?;
961 if self.kanban.configurations.is_empty() {
962 return Err(TixError::Config(
963 "`[kanban]` must define at least one configuration".into(),
964 ));
965 }
966 for (name, board) in &self.kanban.configurations {
967 validate_board(name, board)?;
968 validate_category_map(name, board, &self.categories)?;
969 }
970 for (label, team) in &self.teams {
971 validate_team(label, team)?;
972 }
973 for (name, cycle) in &self.cycles {
974 validate_cycle(name, cycle)?;
975 }
976 for (name, template) in &self.templates {
977 validate_template(name, template)?;
978 }
979 crate::triage::config::validate(self)?;
985 Ok(())
986 }
987
988 pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
990 KeyBindings::resolve(&self.keybindings)
991 }
992
993 fn restore_declaration_order(&mut self, contents: &str) {
997 crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
998 crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
999 crate::config::restore_section_order(&mut self.teams, contents, "teams");
1000 crate::config::restore_section_order(&mut self.templates, contents, "templates");
1001 }
1002
1003 pub fn custom_attributes(&self) -> Vec<String> {
1009 self.custom_attributes
1010 .keys
1011 .iter()
1012 .map(|k| k.trim().to_ascii_lowercase())
1013 .collect()
1014 }
1015
1016 #[must_use]
1019 pub fn types(&self) -> Vec<String> {
1020 self.types
1021 .clone()
1022 .unwrap_or_else(|| DEFAULT_TYPES.iter().map(|s| (*s).to_string()).collect())
1023 }
1024
1025 #[must_use]
1028 pub fn custom_types(&self) -> Vec<String> {
1029 self.types()
1030 }
1031
1032 #[must_use]
1036 pub fn type_color(&self, type_name: &str) -> Option<String> {
1037 let needle = type_name.trim().to_ascii_lowercase();
1038 self.type_colors
1039 .iter()
1040 .find(|(name, _)| name.to_ascii_lowercase() == needle)
1041 .map(|(_, color)| color.clone())
1042 .or_else(|| {
1043 DEFAULT_TYPE_COLORS
1044 .iter()
1045 .find(|(name, _)| *name == needle)
1046 .map(|(_, color)| (*color).to_string())
1047 })
1048 }
1049
1050 #[must_use]
1054 pub fn attributes(&self) -> Vec<String> {
1055 if self.custom_attributes.keys.is_empty() {
1056 crate::model::KNOWN_FRONTMATTER_KEYS
1057 .iter()
1058 .filter(|key| **key != "links")
1059 .map(|key| (*key).to_string())
1060 .collect()
1061 } else {
1062 self.custom_attributes.keys.clone()
1063 }
1064 }
1065
1066 pub fn milestones_dir(&self) -> &Path {
1069 self.milestones_dir
1070 .as_deref()
1071 .unwrap_or(Path::new("docs/milestones"))
1072 }
1073
1074 pub fn tags_dir(&self) -> &Path {
1077 self.tags_dir.as_deref().unwrap_or(Path::new("docs/tags"))
1078 }
1079
1080 pub fn repos(&self) -> &IndexMap<String, PathBuf> {
1082 &self.repos
1083 }
1084
1085 pub fn parent_repo(&self) -> Option<&Path> {
1087 self.parent_repo.as_deref()
1088 }
1089
1090 pub fn resolve_repo_paths(&mut self, base: &Path) {
1100 if let Some(path) = self.tags_dir.as_ref() {
1101 if path.is_relative() {
1102 self.tags_dir = Some(base.join(path));
1103 }
1104 }
1105 for path in self.repos.values_mut() {
1106 if path.is_relative() {
1107 *path = base.join(&*path);
1108 }
1109 }
1110 if let Some(path) = self.parent_repo.clone() {
1111 if path.is_relative() {
1112 self.parent_repo = Some(base.join(path));
1113 }
1114 }
1115 }
1116
1117 pub fn default_config(&self) -> &BoardConfiguration {
1120 self.kanban
1121 .configurations
1122 .get("default")
1123 .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
1124 .expect("validate() guarantees at least one configuration")
1125 }
1126
1127 pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
1130 self.kanban
1131 .configurations
1132 .iter()
1133 .map(|(name, board)| (name.as_str(), board))
1134 }
1135
1136 pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
1142 let contents = std::fs::read_to_string(path)
1143 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
1144 let mut config: Config = toml::from_str(&contents)
1145 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
1146 config.restore_declaration_order(&contents);
1147 let root = path
1151 .parent()
1152 .filter(|p| !p.as_os_str().is_empty())
1153 .unwrap_or_else(|| Path::new("."));
1154 let board_store = BoardStore::new(root);
1155 if board_store.exists() {
1156 let boards = board_store
1157 .list()
1158 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
1159 if !boards.is_empty() {
1160 config.kanban = KanbanSection {
1161 configurations: boards.into_iter().collect(),
1162 };
1163 }
1164 }
1165 let cycle_store = CycleStore::new(root);
1166 if cycle_store.exists() {
1167 let cycles = cycle_store
1168 .list()
1169 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
1170 config.cycles = cycles
1171 .into_iter()
1172 .map(|record| (record.name.clone(), record.into()))
1173 .collect();
1174 }
1175 if config.kanban.configurations.is_empty() {
1176 config.kanban = KanbanSection {
1177 configurations: IndexMap::from([(
1178 "default".to_string(),
1179 BoardConfiguration::default(),
1180 )]),
1181 };
1182 }
1183 config.validate().map_err(|e| match e {
1184 TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
1185 other => other,
1186 })?;
1187 for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
1188 .into_iter()
1189 .flatten()
1190 {
1191 if remote.token.is_some() {
1192 sink(
1193 "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
1194 );
1195 }
1196 }
1197 Ok(config)
1198 }
1199
1200 pub fn load(project_root: &Path) -> Result<Config> {
1204 let path = project_root.join("tix.toml");
1205 if !path.is_file() {
1206 return Ok(Config::default());
1207 }
1208 Self::load_with_warning(&path, |w| eprintln!("{w}"))
1209 }
1210
1211 pub fn project_root(cwd: &Path) -> Option<PathBuf> {
1214 let mut dir = Some(cwd.to_path_buf());
1215 while let Some(current) = dir {
1216 if current.join("tix.toml").is_file() {
1217 return Some(current);
1218 }
1219 dir = current.parent().map(Path::to_path_buf);
1220 }
1221 None
1222 }
1223
1224 fn sanitized_for_disk(&self) -> Config {
1226 let mut copy = self.clone();
1227 if let Some(gitlab) = copy.export.gitlab.as_mut() {
1228 gitlab.token = None;
1229 }
1230 if let Some(linear) = copy.export.linear.as_mut() {
1231 linear.token = None;
1232 }
1233 copy
1234 }
1235
1236 pub fn save(&self, path: &Path) -> Result<()> {
1242 let dir = match path.parent() {
1243 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
1244 _ => PathBuf::from("."),
1245 };
1246 std::fs::create_dir_all(&dir)?;
1247
1248 let board_store = BoardStore::new(&dir);
1252 let board_store_active = board_store.exists();
1253 if board_store_active {
1254 for (name, board) in &self.kanban.configurations {
1255 let filename = format!("{}.md", crate::slug::slugify(name));
1256 let body = RecordStore::new(board_store.dir()).body_of(&filename);
1257 board_store.save(name, board, body.as_deref())?;
1258 }
1259 }
1260 let cycle_store = CycleStore::new(&dir);
1261 let cycle_store_active = cycle_store.exists();
1262 if cycle_store_active {
1263 for (name, cycle) in &self.cycles {
1264 if is_implicit_default(name, cycle) {
1265 continue;
1266 }
1267 let filename = format!("{}.md", crate::slug::slugify(name));
1268 let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
1269 cycle_store.save(name, cycle, body.as_deref())?;
1270 }
1271 }
1272
1273 let mut disk = self.sanitized_for_disk();
1274 if board_store_active {
1275 disk.kanban = KanbanSection::default();
1276 }
1277 if cycle_store_active {
1278 disk.cycles.clear();
1279 }
1280 let contents = toml::to_string_pretty(&disk)
1281 .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
1282
1283 let file_name = path
1284 .file_name()
1285 .and_then(|n| n.to_str())
1286 .unwrap_or("tix.toml");
1287 let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
1288
1289 let write_result = (|| -> std::io::Result<()> {
1290 let mut f = std::fs::File::create(&tmp)?;
1291 use std::io::Write;
1292 f.write_all(contents.as_bytes())?;
1293 f.sync_all()?;
1294 Ok(())
1295 })();
1296 if let Err(e) = write_result {
1297 let _ = std::fs::remove_file(&tmp);
1298 return Err(TixError::Io(e));
1299 }
1300
1301 if let Err(e) = std::fs::rename(&tmp, path) {
1302 let _ = std::fs::remove_file(&tmp);
1303 return Err(TixError::Io(e));
1304 }
1305 Ok(())
1306 }
1307}
1308
1309fn restore_section_order<V: Clone>(map: &mut IndexMap<String, V>, contents: &str, section: &str) {
1317 let mut order: Vec<String> = Vec::new();
1318 for line in contents.lines() {
1319 let line = line.trim();
1320 if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
1321 && let Some((head, key)) = rest.split_once('.')
1322 && head.trim() == section
1323 && !key.trim().contains('.')
1324 && let Some(name) = unquote_key(key.trim())
1325 && !order.iter().any(|n| n == name)
1326 {
1327 order.push(name.to_string());
1328 }
1329 }
1330 if order.is_empty() {
1331 return;
1332 }
1333 let mut ordered = IndexMap::new();
1334 for name in &order {
1335 if let Some(value) = map.shift_remove(name) {
1336 ordered.insert(name.clone(), value);
1337 }
1338 }
1339 ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
1340 *map = ordered;
1341}
1342
1343fn unquote_key(key: &str) -> Option<&str> {
1346 if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
1347 Some(inner)
1348 } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
1349 Some(inner)
1350 } else {
1351 Some(key)
1352 }
1353}
1354
1355fn validate_planning(planning: &PlanningConfig) -> Result<()> {
1358 if planning.cycle_number == 0 {
1359 return Err(TixError::Config(
1360 "`planning.cycle_number` must be at least 1".into(),
1361 ));
1362 }
1363 Ok(())
1364}
1365
1366fn validate_repos(repos: &IndexMap<String, PathBuf>) -> Result<()> {
1372 for (name, path) in repos {
1373 if name.trim().is_empty() {
1374 return Err(TixError::Config(
1375 "`[repos]` entry names must not be blank".into(),
1376 ));
1377 }
1378 if path.as_os_str().is_empty() {
1379 return Err(TixError::Config(format!(
1380 "`repos.{name}` must name the repository path"
1381 )));
1382 }
1383 }
1384 Ok(())
1385}
1386
1387fn validate_team(label: &str, team: &Team) -> Result<()> {
1390 if label.trim().is_empty() {
1391 return Err(TixError::Config("`teams.<label>` must not be blank".into()));
1392 }
1393 if team.members.is_empty() {
1394 return Err(TixError::Config(format!(
1395 "`teams.{label}.members` must not be empty"
1396 )));
1397 }
1398 let mut seen: HashSet<&str> = HashSet::new();
1399 for member in &team.members {
1400 if !seen.insert(member.as_str()) {
1401 return Err(TixError::Config(format!(
1402 "`teams.{label}.members` must be unique; found duplicate `{member}`"
1403 )));
1404 }
1405 }
1406 Ok(())
1407}
1408
1409fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
1412 if name.trim().is_empty() {
1413 return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
1414 }
1415 if let Some(start) = &cycle.start_date {
1416 if !crate::model::validate_date(start) {
1417 return Err(TixError::Config(format!(
1418 "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
1419 )));
1420 }
1421 }
1422 if let Some(due) = &cycle.due_date {
1423 if !crate::model::validate_date(due) {
1424 return Err(TixError::Config(format!(
1425 "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
1426 )));
1427 }
1428 if let Some(start) = &cycle.start_date
1429 && due.as_str() < start.as_str()
1430 {
1431 return Err(TixError::Config(format!(
1432 "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
1433 )));
1434 }
1435 }
1436 Ok(())
1437}
1438
1439fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
1448 if name.trim().is_empty() {
1449 return Err(TixError::Config(
1450 "`templates` must not contain a blank name".into(),
1451 ));
1452 }
1453 for (field, values) in [
1454 ("labels", &template.labels),
1455 ("cycles", &template.cycles),
1456 ("milestones", &template.milestones),
1457 ] {
1458 for value in values {
1459 if value.trim().is_empty() {
1460 return Err(TixError::Config(format!(
1461 "`templates.{name}.{field}` must not contain a blank value"
1462 )));
1463 }
1464 if value.contains(',') {
1465 return Err(TixError::Config(format!(
1466 "`templates.{name}.{field}` value `{value}` must not contain a comma \
1467 — the form joins these with `,`"
1468 )));
1469 }
1470 }
1471 }
1472 if let Some(start) = &template.start_date
1473 && !crate::model::validate_date(start)
1474 {
1475 return Err(TixError::Config(format!(
1476 "`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
1477 )));
1478 }
1479 if let Some(due) = &template.due_date {
1480 if !crate::model::validate_date(due) {
1481 return Err(TixError::Config(format!(
1482 "`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
1483 )));
1484 }
1485 if let Some(start) = &template.start_date
1486 && due.as_str() < start.as_str()
1487 {
1488 return Err(TixError::Config(format!(
1489 "`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
1490 )));
1491 }
1492 }
1493 Ok(())
1494}
1495
1496fn validate_categories(categories: &CategoryConfig) -> Result<()> {
1504 if categories.order.is_empty() {
1505 return Err(TixError::Config(
1506 "`categories.order` must not be empty — it is the global vocabulary".into(),
1507 ));
1508 }
1509 for key in &categories.order {
1510 if !CATEGORY_KEYS.contains(&key.as_str()) {
1511 return Err(TixError::Config(format!(
1512 "unknown_category: `categories.order` names `{key}`, which is not in the \
1513 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
1514 board maps columns into it and does not define it.",
1515 CATEGORY_KEYS.join(", ")
1516 )));
1517 }
1518 }
1519 if !categories.contains(&categories.default) {
1520 return Err(TixError::Config(format!(
1521 "unknown_category: `categories.default` is `{}`, which is not in \
1522 `categories.order`",
1523 categories.default
1524 )));
1525 }
1526 if categories.default == COMPLETE_CATEGORY {
1527 return Err(TixError::Config(format!(
1528 "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
1529 without an explicit mapping would resolve to complete, which changes \
1530 completion meaning by accident. Map the completing column explicitly \
1531 instead."
1532 )));
1533 }
1534 Ok(())
1535}
1536
1537fn validate_category_map(
1544 name: &str,
1545 board: &BoardConfiguration,
1546 categories: &CategoryConfig,
1547) -> Result<()> {
1548 for (column, key) in &board.category_map {
1549 if !board.columns.iter().any(|c| c == column) {
1550 return Err(TixError::Config(format!(
1551 "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
1552 `{column}`, which is not one of the board's columns ({})",
1553 board.columns.join(", ")
1554 )));
1555 }
1556 if !categories.contains(key) {
1557 return Err(TixError::Config(format!(
1558 "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
1559 `{key}`, which is not in the vocabulary ({})",
1560 categories.order.join(", ")
1561 )));
1562 }
1563 }
1564 if board.category_map.is_empty() {
1568 return Ok(());
1569 }
1570 let completers: Vec<&String> = board
1571 .category_map
1572 .iter()
1573 .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
1574 .map(|(column, _)| column)
1575 .collect();
1576 match completers.as_slice() {
1577 [] => Err(TixError::Config(format!(
1578 "missing_completion_category: `kanban.{name}.category_map` maps no column \
1579 to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
1580 is ever complete — map exactly one column to it.",
1581 board.category_map.len()
1582 ))),
1583 [_only] => Ok(()),
1584 many => Err(TixError::Config(format!(
1585 "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
1586 ({}); exactly one column may complete work",
1587 many.len(),
1588 many.iter()
1589 .map(|c| c.as_str())
1590 .collect::<Vec<_>>()
1591 .join(", ")
1592 ))),
1593 }
1594}
1595
1596fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
1597 if board.columns.is_empty() {
1598 return Err(TixError::Config(format!(
1599 "`kanban.{name}.columns` must not be empty"
1600 )));
1601 }
1602 if board.columns.iter().any(|c| c.trim().is_empty()) {
1603 return Err(TixError::Config(format!(
1604 "`kanban.{name}.columns` must not contain blank names"
1605 )));
1606 }
1607 let mut seen: HashSet<&str> = HashSet::new();
1608 for column in &board.columns {
1609 if !seen.insert(column.as_str()) {
1610 return Err(TixError::Config(format!(
1611 "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
1612 )));
1613 }
1614 }
1615 if let Some(done) = &board.done_column
1616 && !board.columns.contains(done)
1617 {
1618 return Err(TixError::Config(format!(
1619 "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
1620 )));
1621 }
1622 for hidden in &board.hidden_columns {
1623 if !board.columns.contains(hidden) {
1624 return Err(TixError::Config(format!(
1625 "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
1626 )));
1627 }
1628 }
1629 if board.title_lines == 0 {
1630 return Err(TixError::Config(format!(
1631 "`kanban.{name}.title_lines` must be at least 1"
1632 )));
1633 }
1634 Ok(())
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639 use super::*;
1640 use std::path::PathBuf;
1641 use tempfile::TempDir;
1642
1643 const LEGACY_PRETTY_BOARD: &str = r#"issues_dir = "docs/issues"
1647
1648[kanban.default]
1649columns = [
1650 "todo",
1651 "in progress",
1652 "in review",
1653 "done",
1654]
1655"#;
1656
1657 fn write_toml(dir: &Path, contents: &str) -> PathBuf {
1658 let path = dir.join("tix.toml");
1659 std::fs::write(&path, contents).unwrap();
1660 path
1661 }
1662
1663 #[test]
1664 fn starter_config_carries_a_working_inbox() {
1665 let config = Config::starter();
1666 config
1667 .validate()
1668 .expect("the config `tix init` writes must load");
1669
1670 let board = config.default_config();
1671 for column in [
1672 "triage",
1673 "todo",
1674 "in progress",
1675 "in review",
1676 "done",
1677 "wontfix",
1678 ] {
1679 assert!(
1680 board.columns.iter().any(|c| c == column),
1681 "the starter board is missing `{column}`: {:?}",
1682 board.columns
1683 );
1684 }
1685
1686 let triage = config
1687 .triage
1688 .as_ref()
1689 .expect("the starter config has an inbox");
1690 assert_eq!(triage.entry_status.as_deref(), Some("triage"));
1691 assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
1692 assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
1693 assert_eq!(triage.effective_deferred_status(), Some("triage"));
1696
1697 assert_eq!(
1699 board.category_map.get("wontfix").map(String::as_str),
1700 Some("canceled")
1701 );
1702 assert_eq!(
1703 board.category_map.get("done").map(String::as_str),
1704 Some("completed")
1705 );
1706 for column in ["triage", "todo", "in progress", "in review"] {
1712 assert!(
1713 !board.category_map.contains_key(column),
1714 "`{column}` must stay unmapped, or adjacency will refuse work moving back"
1715 );
1716 }
1717 }
1718
1719 #[test]
1720 fn repos_and_parent_repo_load_round_trip_and_resolve() {
1721 let tmp = TempDir::new().unwrap();
1722 let raw = "issues_dir = \"docs/issues\"\n\
1725 parent_repo = \"../parent\"\n\n\
1726 [repos]\n\
1727 tix = \"../tix\"\n\
1728 website = \"child/website\"\n";
1729 let path = write_toml(tmp.path(), raw);
1730 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1731 assert_eq!(cfg.repos.len(), 2);
1732 assert_eq!(cfg.repos["tix"], PathBuf::from("../tix"));
1733 assert_eq!(cfg.repos["website"], PathBuf::from("child/website"));
1734 assert_eq!(cfg.parent_repo(), Some(Path::new("../parent")));
1735
1736 cfg.save(&path).unwrap();
1738 let reread = Config::load_with_warning(&path, |_| {}).unwrap();
1739 assert_eq!(reread.repos, cfg.repos, "repos round-trip");
1740 assert_eq!(
1741 reread.parent_repo(),
1742 cfg.parent_repo(),
1743 "parent round-trips"
1744 );
1745
1746 let mut resolved = cfg.clone();
1748 resolved.resolve_repo_paths(Path::new("/root"));
1749 assert_eq!(resolved.repos["tix"], PathBuf::from("/root/../tix"));
1750 assert_eq!(
1751 resolved.repos["website"],
1752 PathBuf::from("/root/child/website")
1753 );
1754 assert_eq!(resolved.parent_repo(), Some(Path::new("/root/../parent")));
1755 let mut absolute = Config::default();
1756 absolute.repos.insert("x".into(), PathBuf::from("/abs/x"));
1757 absolute.parent_repo = Some(PathBuf::from("/abs/parent"));
1758 absolute.resolve_repo_paths(Path::new("/root"));
1759 assert_eq!(absolute.repos["x"], PathBuf::from("/abs/x"));
1760 assert_eq!(absolute.parent_repo(), Some(Path::new("/abs/parent")));
1761 }
1762
1763 #[test]
1764 fn types_rename_and_type_colors() {
1765 let tmp = TempDir::new().unwrap();
1766 let path = write_toml(
1768 tmp.path(),
1769 "issues_dir = \"docs/issues\"\ncustom_types = [\"bug\", \"epic\"]\n",
1770 );
1771 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1772 assert_eq!(cfg.types(), vec!["bug", "epic"]);
1773 cfg.save(&path).unwrap();
1774 let after = std::fs::read_to_string(&path).unwrap();
1775 assert!(after.contains("types = ["), "{after}");
1776 assert!(!after.contains("custom_types"), "{after}");
1777
1778 let defaults = Config::default();
1781 assert_eq!(defaults.type_color("bug").as_deref(), Some("red"));
1782 assert_eq!(defaults.type_color("BUG").as_deref(), Some("red"));
1783 assert_eq!(defaults.type_color("nonsense"), None);
1784 let mut overridden = Config::default();
1785 overridden.type_colors.insert("bug".into(), "cyan".into());
1786 assert_eq!(overridden.type_color("Bug").as_deref(), Some("cyan"));
1787 }
1788
1789 #[test]
1790 fn a_blank_repo_path_is_rejected() {
1791 let tmp = TempDir::new().unwrap();
1792 let path = write_toml(
1793 tmp.path(),
1794 "issues_dir = \"docs/issues\"\n\n[repos]\ntix = \"\"\n",
1795 );
1796 let err = Config::load_with_warning(&path, |_| {})
1797 .unwrap_err()
1798 .to_string();
1799 assert!(err.contains("repos.tix"), "got {err}");
1800 }
1801
1802 #[test]
1803 fn an_empty_repos_section_is_not_written() {
1804 let tmp = TempDir::new().unwrap();
1805 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1806 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1807 cfg.save(&path).unwrap();
1808 let after = std::fs::read_to_string(&path).unwrap();
1809 assert!(
1810 !after.contains("[repos]") && !after.contains("parent_repo"),
1811 "an untouched config gains no repos keys on save: {after}"
1812 );
1813 }
1814
1815 #[test]
1816 fn defaults_match_contract() {
1817 let c = Config::default();
1818 let board = c.default_config();
1819 assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
1820 assert_eq!(board.done_column.as_deref(), Some("done"));
1821 assert_eq!(board.hidden_columns, Vec::<String>::new());
1822 assert!(!board.show_mismatch);
1823 assert!(board.wrap_titles);
1824 assert_eq!(board.title_lines, 3);
1825 assert!(board.card_order.is_empty());
1826 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
1827 assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
1828 assert_eq!(c.editor, None);
1829 assert_eq!(c.state.next_number, 0);
1830 assert_eq!(c.export.gitlab, None);
1831 assert_eq!(c.export.linear, None);
1832 }
1833
1834 #[test]
1835 fn load_returns_defaults_when_file_absent() {
1836 let tmp = TempDir::new().unwrap();
1837 let cfg = Config::load(tmp.path()).unwrap();
1838 assert_eq!(cfg.state.next_number, 0);
1839 assert_eq!(cfg.default_config().columns.len(), 4);
1840 }
1841
1842 #[test]
1843 fn load_synthesizes_default_config_when_kanban_section_missing() {
1844 let tmp = TempDir::new().unwrap();
1845 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1846 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1847 assert_eq!(cfg.kanban.configurations.len(), 1);
1848 assert!(cfg.kanban.configurations.contains_key("default"));
1849 assert_eq!(cfg.default_config().columns.len(), 4);
1850 }
1851
1852 #[test]
1853 fn load_parses_state_and_keeps_editor_and_remote_targets() {
1854 let tmp = TempDir::new().unwrap();
1855 let path = write_toml(
1856 tmp.path(),
1857 r#"issues_dir = "tickets"
1858editor = "nvim"
1859
1860[kanban.default]
1861columns = ["todo", "done"]
1862done_column = "done"
1863
1864[state]
1865next_number = 17
1866
1867[export.gitlab]
1868project = "acme/tix"
1869"#,
1870 );
1871 let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
1872 assert_eq!(
1873 cfg.kanban.configurations.get("default").unwrap().columns,
1874 ["todo", "done"]
1875 );
1876 assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
1877 assert_eq!(cfg.editor.as_deref(), Some("nvim"));
1878 assert_eq!(cfg.state.next_number, 17);
1879 assert_eq!(
1880 cfg.export.gitlab.unwrap().project.as_deref(),
1881 Some("acme/tix")
1882 );
1883 assert_eq!(cfg.export.linear, None);
1884 }
1885
1886 #[test]
1887 fn viewer_is_parsed_and_blank_is_rejected() {
1888 let tmp = TempDir::new().unwrap();
1889 let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
1890 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1891 assert_eq!(cfg.viewer.as_deref(), Some("less"));
1892
1893 let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \" \"\n");
1894 let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
1895 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1896 assert!(err.to_string().contains("viewer"), "{err}");
1897 }
1898
1899 #[test]
1900 fn viewer_is_omitted_from_the_file_when_unset() {
1901 let dir = TempDir::new().unwrap();
1902 let path = dir.path().join("tix.toml");
1903 let mut config = Config::default();
1904 config.save(&path).unwrap();
1905 assert!(
1906 !std::fs::read_to_string(&path).unwrap().contains("viewer"),
1907 "an unset viewer writes no key"
1908 );
1909
1910 config.viewer = Some("less".to_string());
1911 config.save(&path).unwrap();
1912 assert!(
1913 std::fs::read_to_string(&path)
1914 .unwrap()
1915 .contains(r#"viewer = "less""#),
1916 "a set viewer round-trips"
1917 );
1918 }
1919
1920 #[test]
1921 fn empty_columns_rejected_as_config_error() {
1922 let tmp = TempDir::new().unwrap();
1923 let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
1924 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1925 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1926 assert!(err.to_string().contains("kanban.scratch.columns"));
1927 }
1928
1929 #[test]
1930 fn duplicate_columns_rejected_as_config_error() {
1931 let tmp = TempDir::new().unwrap();
1932 let path = write_toml(
1933 tmp.path(),
1934 "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
1935 );
1936 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1937 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1938 assert!(err.to_string().contains("duplicate"));
1939 }
1940
1941 #[test]
1942 fn blank_column_name_rejected() {
1943 let tmp = TempDir::new().unwrap();
1944 let path = write_toml(
1945 tmp.path(),
1946 "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
1947 );
1948 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1949 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1950 }
1951
1952 #[test]
1953 fn done_column_must_be_in_columns() {
1954 let tmp = TempDir::new().unwrap();
1955 let path = write_toml(
1956 tmp.path(),
1957 "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
1958 );
1959 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1960 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1961 assert!(err.to_string().contains("kanban.scratch.done_column"));
1962 }
1963
1964 #[test]
1965 fn hidden_columns_must_be_subset_of_columns() {
1966 let tmp = TempDir::new().unwrap();
1967 let path = write_toml(
1968 tmp.path(),
1969 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
1970 );
1971 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1972 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1973 assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
1974 }
1975
1976 #[test]
1977 fn hidden_done_column_loads_fine() {
1978 let tmp = TempDir::new().unwrap();
1979 let path = write_toml(
1980 tmp.path(),
1981 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
1982 );
1983 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1984 let board = cfg.kanban.configurations.get("scratch").unwrap();
1985 assert_eq!(board.done_column.as_deref(), Some("done"));
1986 assert_eq!(board.hidden_columns, ["done"]);
1987 }
1988
1989 #[test]
1990 fn legacy_top_level_keys_rejected() {
1991 let tmp = TempDir::new().unwrap();
1992 let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
1993 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1994 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1995 assert!(err.to_string().contains("tix.toml"));
1996 assert!(err.to_string().contains("columns"));
1997 }
1998
1999 #[test]
2000 fn token_warning_fires_once_per_remote_with_token() {
2001 let tmp = TempDir::new().unwrap();
2002 let path = write_toml(
2003 tmp.path(),
2004 r#"[export.gitlab]
2005project = "a/b"
2006token = "gl-secret"
2007
2008[export.linear]
2009team = "Eng"
2010token = "lin-secret"
2011"#,
2012 );
2013 let mut warnings: Vec<String> = Vec::new();
2014 let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
2015 assert_eq!(warnings.len(), 2);
2016 assert!(warnings[0].contains("GITLAB_TOKEN"));
2017 assert!(warnings[1].contains("LINEAR_TOKEN"));
2018 for w in &warnings {
2020 assert!(!w.contains("secret"));
2021 }
2022 assert_eq!(
2023 cfg.export.gitlab.unwrap().token.as_deref(),
2024 Some("gl-secret")
2025 );
2026 }
2027
2028 #[test]
2029 fn no_token_means_no_warning() {
2030 let tmp = TempDir::new().unwrap();
2031 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
2032 let mut fired = false;
2033 Config::load_with_warning(&path, |_| fired = true).unwrap();
2034 assert!(!fired);
2035 }
2036
2037 #[test]
2038 fn save_never_writes_the_state_section() {
2039 let tmp = TempDir::new().unwrap();
2040 let path = tmp.path().join("tix.toml");
2041 let mut cfg = Config::default();
2044 cfg.state.next_number = 42;
2045 cfg.save(&path).unwrap();
2046
2047 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2048 assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
2049 assert_eq!(
2050 reloaded.default_config().columns,
2051 cfg.default_config().columns
2052 );
2053
2054 let on_disk = std::fs::read_to_string(&path).unwrap();
2055 assert!(
2056 !on_disk.contains("next_number"),
2057 "next_number leaked into tix.toml:\n{on_disk}"
2058 );
2059 assert!(
2060 !on_disk.contains("[state]"),
2061 "state section written:\n{on_disk}"
2062 );
2063 }
2064
2065 #[test]
2066 fn multi_config_roundtrip_preserves_declaration_order() {
2067 let tmp = TempDir::new().unwrap();
2068 let path = write_toml(
2069 tmp.path(),
2070 r#"issues_dir = "docs/issues"
2071
2072[kanban.release]
2073columns = ["planned", "in progress", "done"]
2074done_column = "done"
2075show_mismatch = true
2076
2077[kanban.personal]
2078columns = ["todo", "done"]
2079done_column = "done"
2080hidden_columns = ["done"]
2081"#,
2082 );
2083 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2084 let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
2085 assert_eq!(names, ["release", "personal"]);
2086
2087 assert_eq!(
2089 cfg.default_config().columns,
2090 ["planned", "in progress", "done"]
2091 );
2092
2093 cfg.save(&path).unwrap();
2094 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2095 assert_eq!(
2096 cfg, reloaded,
2097 "order and values survive save/load round trip"
2098 );
2099 let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
2100 assert_eq!(names2, ["release", "personal"]);
2101 }
2102
2103 #[test]
2104 fn save_never_serializes_tokens_back_out() {
2105 let tmp = TempDir::new().unwrap();
2106 let path = tmp.path().join("tix.toml");
2107 let mut cfg = Config::default();
2108 cfg.export.gitlab = Some(RemoteConfig {
2109 token: Some("secret".into()),
2110 project: Some("a/b".into()),
2111 ..RemoteConfig::default()
2112 });
2113 cfg.save(&path).unwrap();
2114
2115 let on_disk = std::fs::read_to_string(&path).unwrap();
2116 assert!(
2117 !on_disk.contains("secret"),
2118 "token leaked to disk:\n{on_disk}"
2119 );
2120 assert!(
2121 on_disk.contains("a/b"),
2122 "non-secret fields survive:\n{on_disk}"
2123 );
2124
2125 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2126 assert_eq!(reloaded.export.gitlab.unwrap().token, None);
2127 }
2128
2129 #[test]
2130 fn save_is_atomic_and_leaves_no_temp_file() {
2131 let tmp = TempDir::new().unwrap();
2132 let path = tmp.path().join("tix.toml");
2133 Config::default().save(&path).unwrap();
2134 let entries: Vec<_> = std::fs::read_dir(tmp.path())
2135 .unwrap()
2136 .filter_map(|e| e.ok())
2137 .collect();
2138 assert_eq!(entries.len(), 1);
2139 assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
2140 }
2141
2142 #[test]
2143 fn default_loadwrites_are_idempotent() {
2144 let tmp = TempDir::new().unwrap();
2145 let path = tmp.path().join("tix.toml");
2146 let cfg = Config::default();
2147 cfg.save(&path).unwrap();
2148 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2149 assert_eq!(cfg, reloaded);
2150 }
2151
2152 #[test]
2153 fn effective_done_column_falls_back_to_last() {
2154 let board = BoardConfiguration {
2155 columns: vec!["todo".into(), "later".into()],
2156 done_column: None,
2157 ..Default::default()
2158 };
2159 assert_eq!(board.effective_done_column(), Some("later"));
2160
2161 let with_done = BoardConfiguration {
2162 columns: vec!["todo".into(), "done".into(), "later".into()],
2163 done_column: None,
2164 ..Default::default()
2165 };
2166 assert_eq!(with_done.effective_done_column(), Some("done"));
2167 }
2168
2169 #[test]
2170 fn project_root_walks_up_the_tree() {
2171 let tmp = TempDir::new().unwrap();
2172 let nested = tmp.path().join("a").join("b");
2173 std::fs::create_dir_all(&nested).unwrap();
2174 std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
2175
2176 assert_eq!(
2177 Config::project_root(&nested),
2178 Some(tmp.path().to_path_buf())
2179 );
2180 assert_eq!(
2181 Config::project_root(tmp.path()),
2182 Some(tmp.path().to_path_buf())
2183 );
2184 }
2185
2186 #[test]
2187 fn project_root_is_none_when_no_toml_exists() {
2188 let tmp = TempDir::new().unwrap();
2189 assert_eq!(Config::project_root(tmp.path()), None);
2190 }
2191
2192 #[test]
2193 fn order_by_parses_all_names_and_defaults_to_desc() {
2194 let tmp = TempDir::new().unwrap();
2195 for name in ["created_at_desc", "created_at_asc", "none"] {
2196 let path = write_toml(
2197 tmp.path(),
2198 &format!(
2199 "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
2200 ),
2201 );
2202 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2203 let board = cfg.kanban.configurations.get("x").unwrap();
2204 assert_eq!(board.order_by.as_str(), name);
2205 }
2206 assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
2207 assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
2208 assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
2209 assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
2210 }
2211
2212 #[test]
2213 fn zero_title_lines_rejected_as_config_error() {
2214 let tmp = TempDir::new().unwrap();
2215 let path = write_toml(
2216 tmp.path(),
2217 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
2218 );
2219 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2220 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2221 assert!(
2222 err.to_string().contains("kanban.scratch.title_lines"),
2223 "error names the offending field: {err}"
2224 );
2225 }
2226
2227 #[test]
2228 fn unknown_order_by_rejected_as_config_error() {
2229 let tmp = TempDir::new().unwrap();
2230 let path = write_toml(
2231 tmp.path(),
2232 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
2233 );
2234 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2235 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2236 }
2237
2238 #[test]
2239 fn board_without_display_keys_loads_default_display_settings() {
2240 let tmp = TempDir::new().unwrap();
2241 let path = write_toml(
2242 tmp.path(),
2243 "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
2244 );
2245 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2246 let board = cfg.default_config();
2247 assert!(board.wrap_titles);
2248 assert_eq!(board.title_lines, 3);
2249 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
2250 }
2251
2252 #[test]
2253 fn display_settings_roundtrip_through_save() {
2254 let tmp = TempDir::new().unwrap();
2255 let path = tmp.path().join("tix.toml");
2256 let mut cfg = Config::default();
2257 {
2258 let board = cfg.kanban.configurations.get_mut("default").unwrap();
2259 board.wrap_titles = false;
2260 board.title_lines = 5;
2261 board.order_by = OrderBy::None;
2262 }
2263 cfg.save(&path).unwrap();
2264 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2265 assert_eq!(
2266 reloaded, cfg,
2267 "display settings survive save/load round trip"
2268 );
2269 let on_disk = std::fs::read_to_string(&path).unwrap();
2270 for key in ["wrap_titles", "title_lines", "order_by"] {
2271 assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
2272 }
2273 }
2274
2275 #[test]
2276 fn manual_card_order_roundtrips_through_save() {
2277 let tmp = TempDir::new().unwrap();
2278 let path = tmp.path().join("tix.toml");
2279 let mut cfg = Config::default();
2280 {
2281 let board = cfg.kanban.configurations.get_mut("default").unwrap();
2282 board.card_order = vec!["a.md".into(), "b.md".into()];
2283 }
2284 cfg.save(&path).unwrap();
2285 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2286 assert_eq!(
2287 reloaded
2288 .kanban
2289 .configurations
2290 .get("default")
2291 .unwrap()
2292 .card_order,
2293 vec!["a.md".to_string(), "b.md".to_string()]
2294 );
2295 let on_disk = std::fs::read_to_string(&path).unwrap();
2296 assert!(
2297 on_disk.contains("card_order"),
2298 "missing card_order in:\n{on_disk}"
2299 );
2300 }
2301
2302 #[test]
2303 fn planning_defaults_match_contract() {
2304 let c = Config::default();
2305 assert_eq!(c.planning.cycle_number, 1);
2306 assert_eq!(c.planning.default_cycle, None);
2307 assert_eq!(c.planning.default_milestone, None);
2308 assert_eq!(c.planning.default_team, None);
2309 assert!(c.teams.is_empty());
2310 assert!(c.cycles.is_empty());
2311 assert_eq!(c.milestones_dir, None);
2312 assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
2313 }
2314
2315 #[test]
2316 fn planning_sections_roundtrip_and_preserve_declaration_order() {
2317 let tmp = TempDir::new().unwrap();
2318 let path = write_toml(
2319 tmp.path(),
2320 r#"issues_dir = "docs/issues"
2321milestones_dir = "docs/milestones"
2322
2323[planning]
2324cycle_number = 1
2325default_cycle = "Cycle 1"
2326default_milestone = "Backlog"
2327default_team = "default"
2328
2329[cycles."Cycle 1"]
2330
2331[cycles."Cycle 2"]
2332start_date = "2026-09-21"
2333due_date = "2026-09-30"
2334
2335[teams.default]
2336members = ["Herb Mason <herb@example.com>", "@alice"]
2337
2338[teams.platform]
2339members = ["bob"]
2340"#,
2341 );
2342 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2343 assert_eq!(cfg.planning.cycle_number, 1);
2344 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
2345 assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
2346 assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
2347 assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
2348
2349 let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
2351 assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
2352 let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
2353 assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
2354 assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
2355 assert_eq!(cycle2.description, None);
2356
2357 let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
2358 assert_eq!(teams, ["default", "platform"]);
2359 assert_eq!(
2360 cfg.teams.get("default").unwrap().members,
2361 ["Herb Mason <herb@example.com>", "@alice"]
2362 );
2363
2364 cfg.save(&path).unwrap();
2366 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2367 assert_eq!(cfg, reloaded);
2368 assert_eq!(
2369 reloaded
2370 .cycles
2371 .keys()
2372 .map(String::as_str)
2373 .collect::<Vec<_>>(),
2374 ["Cycle 1", "Cycle 2"]
2375 );
2376 assert_eq!(
2377 reloaded
2378 .teams
2379 .keys()
2380 .map(String::as_str)
2381 .collect::<Vec<_>>(),
2382 ["default", "platform"]
2383 );
2384 }
2385
2386 const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
2391
2392[categories]
2393order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
2394default = "unstarted"
2395
2396[transitions]
2397terminal_locked = true
2398
2399[kanban.default]
2400columns = ["triage", "wip", "review", "shipped"]
2401done_column = "shipped"
2402category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
2403
2404[export]
2405"#;
2406
2407 #[test]
2408 fn category_sections_parse_and_default_sensibly() {
2409 let tmp = TempDir::new().unwrap();
2410 let path = write_toml(tmp.path(), FULL_CATEGORIES);
2411 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2412 assert_eq!(cfg.categories.order, CATEGORY_KEYS);
2413 assert_eq!(cfg.categories.default, "unstarted");
2414 assert!(cfg.transitions.terminal_locked);
2415
2416 let board = cfg.default_config();
2417 assert_eq!(
2418 board.category_map.get("shipped").map(String::as_str),
2419 Some("completed")
2420 );
2421 assert_eq!(
2422 board.category_map.get("triage").map(String::as_str),
2423 Some("backlog")
2424 );
2425 }
2426
2427 #[test]
2428 fn category_sections_default_when_absent() {
2429 let tmp = TempDir::new().unwrap();
2430 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
2431 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2432 assert_eq!(cfg.categories, CategoryConfig::default());
2435 assert_eq!(cfg.transitions, TransitionConfig::default());
2436 assert!(cfg.categories.is_default(), "and is not written back");
2437 assert!(cfg.transitions.is_default());
2438 }
2439
2440 #[test]
2441 fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
2442 let tmp = TempDir::new().unwrap();
2443 let bad_categories = write_toml(
2444 tmp.path(),
2445 "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
2446 );
2447 let err = Config::load_with_warning(&bad_categories, |_| {})
2448 .expect_err("an unknown key in [categories] must be refused");
2449 assert!(err.to_string().contains("unknown field"), "got: {err}");
2450
2451 let tmp2 = TempDir::new().unwrap();
2452 let bad_transitions = write_toml(
2453 tmp2.path(),
2454 "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
2455 );
2456 let err = Config::load_with_warning(&bad_transitions, |_| {})
2457 .expect_err("an unknown key in [transitions] must be refused");
2458 assert!(err.to_string().contains("unknown field"), "got: {err}");
2459 }
2460
2461 #[test]
2462 fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
2463 let tmp = TempDir::new().unwrap();
2464 let path = write_toml(
2465 tmp.path(),
2466 "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
2467 );
2468 let err = Config::load_with_warning(&path, |_| {})
2469 .expect_err("the vocabulary is global and fixed; a new key is not a default");
2470 assert!(err.to_string().contains("unknown_category"), "got: {err}");
2471 }
2472
2473 #[test]
2476 fn a_default_of_completed_is_refused() {
2477 let tmp = TempDir::new().unwrap();
2478 let path = write_toml(
2479 tmp.path(),
2480 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
2481 );
2482 let err = Config::load_with_warning(&path, |_| {})
2483 .expect_err("a completion-changing default must be an escalation");
2484 let msg = err.to_string();
2485 assert!(msg.contains("may not be `completed`"), "got: {msg}");
2486 }
2487
2488 #[test]
2489 fn a_default_outside_the_vocabulary_is_refused() {
2490 let tmp = TempDir::new().unwrap();
2491 let path = write_toml(
2492 tmp.path(),
2493 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
2494 );
2495 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2496 assert!(err.to_string().contains("unknown_category"), "got: {err}");
2497 }
2498
2499 #[test]
2503 fn a_mapped_board_with_no_completion_column_is_refused() {
2504 let tmp = TempDir::new().unwrap();
2505 let path = write_toml(
2506 tmp.path(),
2507 r#"issues_dir = "docs/issues"
2508
2509[kanban.default]
2510columns = ["triage", "wip", "shipped"]
2511done_column = "shipped"
2512category_map = { triage = "backlog", wip = "active" }
2513"#,
2514 );
2515 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2516 assert!(
2517 err.to_string().contains("missing_completion_category"),
2518 "got: {err}"
2519 );
2520 }
2521
2522 #[test]
2523 fn a_board_with_two_completion_columns_is_refused() {
2524 let tmp = TempDir::new().unwrap();
2525 let path = write_toml(
2526 tmp.path(),
2527 r#"issues_dir = "docs/issues"
2528
2529[kanban.default]
2530columns = ["shipped", "cancelled"]
2531done_column = "shipped"
2532category_map = { shipped = "completed", cancelled = "completed" }
2533"#,
2534 );
2535 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2536 let msg = err.to_string();
2537 assert!(
2538 msg.contains("exactly one column may complete"),
2539 "got: {msg}"
2540 );
2541 }
2542
2543 #[test]
2544 fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
2545 let tmp = TempDir::new().unwrap();
2546 let path = write_toml(
2547 tmp.path(),
2548 r#"issues_dir = "docs/issues"
2549
2550[kanban.default]
2551columns = ["triage", "shipped"]
2552done_column = "shipped"
2553category_map = { triage = "backlog", shipped = "completed", nope = "active" }
2554"#,
2555 );
2556 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2557 assert!(
2558 err.to_string().contains("mapping_for_unknown_column"),
2559 "got: {err}"
2560 );
2561 }
2562
2563 #[test]
2564 fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
2565 let tmp = TempDir::new().unwrap();
2566 let path = write_toml(
2567 tmp.path(),
2568 r#"issues_dir = "docs/issues"
2569
2570[kanban.default]
2571columns = ["triage", "shipped"]
2572done_column = "shipped"
2573category_map = { triage = "wontfix", shipped = "completed" }
2574"#,
2575 );
2576 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2577 assert!(err.to_string().contains("unknown_category"), "got: {err}");
2578 }
2579
2580 #[test]
2584 fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
2585 let tmp = TempDir::new().unwrap();
2586 let path = write_toml(
2587 tmp.path(),
2588 r#"issues_dir = "docs/issues"
2589
2590[kanban.default]
2591columns = ["triage", "wip", "shipped"]
2592done_column = "shipped"
2593category_map = { shipped = "completed" }
2594"#,
2595 );
2596 let cfg =
2597 Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
2598 let board = cfg.default_config();
2599 assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
2600 assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
2601 assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
2602 }
2603
2604 #[test]
2610 fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
2611 let tmp = TempDir::new().unwrap();
2612 let path = write_toml(
2613 tmp.path(),
2614 r#"issues_dir = "docs/issues"
2615
2616[kanban.default]
2617columns = ["todo", "in progress", "in review", "done"]
2618done_column = "done"
2619"#,
2620 );
2621 let cfg = Config::load_with_warning(&path, |_| {})
2622 .expect("a legacy board must load, and it does");
2623 let board = cfg.default_config();
2624 assert!(board.category_map.is_empty());
2625 assert_eq!(board.category_for("done", &cfg.categories), "completed");
2626 assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
2627 assert_eq!(
2628 board.category_for("in review", &cfg.categories),
2629 "unstarted",
2630 "and nothing else is silently complete"
2631 );
2632 }
2633
2634 #[test]
2638 fn a_defaulted_config_gains_no_new_keys_on_save() {
2639 let tmp = TempDir::new().unwrap();
2640 let legacy = LEGACY_PRETTY_BOARD;
2641 let path = write_toml(tmp.path(), legacy);
2642 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2643 cfg.save(&path).unwrap();
2644 let after = std::fs::read_to_string(&path).unwrap();
2645 assert_eq!(
2646 after, legacy,
2647 "no `[categories]`, no `[transitions]`, no `category_map` may appear"
2648 );
2649 }
2650
2651 #[test]
2655 fn explicit_default_board_keys_are_normalized_away_by_save() {
2656 let tmp = TempDir::new().unwrap();
2657 let explicit = r#"issues_dir = "docs/issues"
2658
2659[kanban.default]
2660columns = [
2661 "todo",
2662 "in progress",
2663 "in review",
2664 "done",
2665]
2666done_column = "done"
2667hidden_columns = []
2668show_mismatch = false
2669wrap_titles = true
2670title_lines = 3
2671order_by = "created_at_desc"
2672
2673[export]
2674"#;
2675 let path = write_toml(tmp.path(), explicit);
2676 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2677 cfg.save(&path).unwrap();
2678 let after = std::fs::read_to_string(&path).unwrap();
2679 assert_eq!(
2680 after, LEGACY_PRETTY_BOARD,
2681 "a redundant explicit default collapses to the canonical form"
2682 );
2683 for key in [
2684 "done_column",
2685 "hidden_columns",
2686 "show_mismatch",
2687 "wrap_titles",
2688 "title_lines",
2689 "order_by",
2690 "[export]",
2691 ] {
2692 assert!(!after.contains(key), "`{key}` must be omitted:\n{after}");
2693 }
2694 assert_eq!(
2695 cfg,
2696 Config::load_with_warning(&path, |_| {}).unwrap(),
2697 "and the collapsed form reloads to the same config"
2698 );
2699 }
2700
2701 #[test]
2704 fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
2705 let tmp = TempDir::new().unwrap();
2706 let path = write_toml(
2707 tmp.path(),
2708 r#"issues_dir = "docs/issues"
2709
2710[kanban.default]
2711columns = ["triage", "shipped"]
2712done_column = "shipped"
2713category_map = { triage = "backlog", shipped = "completed" }
2714
2715[export]
2716"#,
2717 );
2718 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2719 assert_eq!(cfg.categories, CategoryConfig::default());
2720 cfg.save(&path).unwrap();
2721 let after = std::fs::read_to_string(&path).unwrap();
2722 assert!(
2723 !after.contains("[categories]"),
2724 "the section must stay unmaterialized: {after}"
2725 );
2726 assert!(
2727 after.contains("category_map"),
2728 "while the board's own mapping survives: {after}"
2729 );
2730 }
2731
2732 #[test]
2747 fn a_full_category_config_round_trips_with_its_values_and_order() {
2748 let tmp = TempDir::new().unwrap();
2749 let authored = r#"issues_dir = "docs/issues"
2755
2756[categories]
2757order = ["backlog", "active", "completed", "canceled"]
2758default = "backlog"
2759
2760[transitions]
2761terminal_locked = false
2762
2763[kanban.default]
2764columns = ["triage", "wip", "shipped"]
2765done_column = "shipped"
2766category_map = { triage = "backlog", wip = "active", shipped = "completed" }
2767
2768[export]
2769"#;
2770 let path = write_toml(tmp.path(), authored);
2771 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2772 cfg.save(&path).unwrap();
2773 let after = std::fs::read_to_string(&path).unwrap();
2774 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2775
2776 assert_eq!(
2777 reloaded.categories, cfg.categories,
2778 "the vocabulary and its default survive"
2779 );
2780 assert_eq!(
2781 reloaded.categories.order,
2782 vec!["backlog", "active", "completed", "canceled"],
2783 "and `order` is a Vec, so the declared order is preserved exactly"
2784 );
2785
2786 assert_eq!(
2787 reloaded.default_config().category_map,
2788 cfg.default_config().category_map,
2789 "the mapping's contents survive, in whatever order they are written"
2790 );
2791 assert!(after.contains("[categories]"), "after: {after}");
2799 assert!(after.contains("[transitions]"), "after: {after}");
2800 assert!(
2801 !reloaded.transitions.terminal_locked,
2802 "and the value survives"
2803 );
2804
2805 let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
2812 reloaded_again.save(&path).unwrap();
2813 assert_eq!(
2814 std::fs::read_to_string(&path).unwrap(),
2815 after,
2816 "a second save must be a no-op"
2817 );
2818 }
2819
2820 #[test]
2823 fn two_boards_with_different_columns_map_to_the_same_categories() {
2824 let tmp = TempDir::new().unwrap();
2825 let path = write_toml(
2826 tmp.path(),
2827 r#"issues_dir = "docs/issues"
2828
2829[kanban.alpha]
2830columns = ["triage", "wip", "review", "shipped"]
2831done_column = "shipped"
2832category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
2833
2834[kanban.beta]
2835columns = ["new", "doing", "merged"]
2836done_column = "merged"
2837
2838[export]
2839"#,
2840 );
2841 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2842 let alpha = &cfg.kanban.configurations["alpha"];
2843 let beta = &cfg.kanban.configurations["beta"];
2844 assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
2847 assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
2848 assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
2849 assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
2850 assert!(beta.category_map.is_empty());
2851 }
2852
2853 #[test]
2857 fn canceled_is_terminal_but_not_complete() {
2858 assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
2859 assert!(TERMINAL_CATEGORIES.contains(&"completed"));
2860 assert_eq!(
2861 COMPLETE_CATEGORY, "completed",
2862 "completion is defined by this category alone"
2863 );
2864 assert_ne!(COMPLETE_CATEGORY, "canceled");
2865 let tmp = TempDir::new().unwrap();
2868 let path = write_toml(
2869 tmp.path(),
2870 r#"issues_dir = "docs/issues"
2871
2872[kanban.default]
2873columns = ["triage", "dropped", "shipped"]
2874done_column = "shipped"
2875category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
2876"#,
2877 );
2878 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2879 let board = cfg.default_config();
2880 assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
2881 assert_ne!(
2882 board.category_for("dropped", &cfg.categories),
2883 COMPLETE_CATEGORY
2884 );
2885 }
2886
2887 #[test]
2891 fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
2892 assert!(CATEGORY_KEYS.contains(&"blocked"));
2893 let tmp = TempDir::new().unwrap();
2894 let path = write_toml(
2895 tmp.path(),
2896 r#"issues_dir = "docs/issues"
2897
2898[kanban.default]
2899columns = ["wip", "on hold", "shipped"]
2900done_column = "shipped"
2901category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
2902"#,
2903 );
2904 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2905 let board = cfg.default_config();
2906 assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
2907 assert!(
2909 !cfg.default_config()
2910 .category_map
2911 .values()
2912 .any(|v| v.contains("state")),
2913 "a category is a position, never a derived state"
2914 );
2915 }
2916
2917 #[test]
2918 fn legacy_file_roundtrips_byte_identical() {
2919 let tmp = TempDir::new().unwrap();
2920 let legacy = LEGACY_PRETTY_BOARD;
2924 let path = write_toml(tmp.path(), legacy);
2925 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2926 assert_eq!(cfg.planning, PlanningConfig::default());
2927 assert!(cfg.teams.is_empty());
2928 assert!(cfg.cycles.is_empty());
2929 assert_eq!(cfg.milestones_dir, None);
2930 assert_eq!(
2934 cfg.default_config().columns,
2935 ["todo", "in progress", "in review", "done"]
2936 );
2937 assert_eq!(cfg.default_config().effective_done_column(), Some("done"));
2938 assert_eq!(cfg.default_config().order_by, OrderBy::CreatedAtDesc);
2939 assert!(cfg.default_config().hidden_columns.is_empty());
2940 assert!(!cfg.default_config().show_mismatch);
2941 assert!(cfg.default_config().wrap_titles);
2942 assert_eq!(cfg.default_config().title_lines, DEFAULT_TITLE_LINES);
2943 assert!(!cfg.default_config().category_map.contains_key("done"));
2944
2945 cfg.save(&path).unwrap();
2946 let on_disk = std::fs::read_to_string(&path).unwrap();
2947 assert_eq!(
2948 on_disk, legacy,
2949 "legacy tix.toml must save byte-identical (NFR-003)"
2950 );
2951 assert!(
2952 !on_disk.contains("[export]"),
2953 "an empty `[export]` table must not be materialized:\n{on_disk}"
2954 );
2955 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2956 assert_eq!(cfg, reloaded);
2957 }
2958
2959 #[test]
2960 fn legacy_file_in_any_layout_never_gains_planning_keys() {
2961 let tmp = TempDir::new().unwrap();
2962 let legacy = r#"issues_dir = "docs/issues"
2966[kanban.default]
2967columns = ["todo", "in progress", "in review", "done"]
2968done_column = "done"
2969"#;
2970 let path = write_toml(tmp.path(), legacy);
2971 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2972 cfg.save(&path).unwrap();
2973 let on_disk = std::fs::read_to_string(&path).unwrap();
2974 assert!(!on_disk.contains("milestones_dir"));
2975 assert!(!on_disk.contains("[planning]"));
2976 assert!(!on_disk.contains("[teams]"));
2977 assert!(!on_disk.contains("[cycles"));
2978 }
2979
2980 #[test]
2981 fn planning_default_section_with_partial_keys_loads() {
2982 let tmp = TempDir::new().unwrap();
2983 let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
2984 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2985 assert_eq!(
2986 cfg.planning.cycle_number, 1,
2987 "counter defaults when omitted"
2988 );
2989 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
2990 assert_eq!(cfg.planning.default_milestone, None);
2991 assert_eq!(cfg.planning.default_team, None);
2992 }
2993
2994 #[test]
2995 fn planning_rejects_unknown_keys() {
2996 let tmp = TempDir::new().unwrap();
2997 let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
2998 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2999 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3000 assert!(err.to_string().contains("counter"));
3001 }
3002
3003 #[test]
3004 fn planning_rejects_zero_cycle_number() {
3005 let tmp = TempDir::new().unwrap();
3006 let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
3007 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3008 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3009 assert!(err.to_string().contains("cycle_number"));
3010 }
3011
3012 #[test]
3013 fn cycle_dates_are_validated_and_compared() {
3014 let tmp = TempDir::new().unwrap();
3015 let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
3017 let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
3018 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3019 assert!(err.to_string().contains("start_date"));
3020
3021 let ordered = write_toml(
3023 tmp.path(),
3024 "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
3025 );
3026 let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
3027 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3028 assert!(err.to_string().contains("due_date"));
3029 assert!(err.to_string().contains("start_date"));
3030 }
3031
3032 #[test]
3033 fn cycles_reject_unknown_keys() {
3034 let tmp = TempDir::new().unwrap();
3035 let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
3036 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3037 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3038 assert!(err.to_string().contains("bogus"));
3039 }
3040
3041 #[test]
3042 fn cycle_without_dates_loads_fine() {
3043 let tmp = TempDir::new().unwrap();
3044 let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
3045 let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
3046 assert_eq!(
3047 cfg.cycles.get("Alpha").unwrap().description.as_deref(),
3048 Some("a")
3049 );
3050 }
3051
3052 #[test]
3053 fn teams_validate_members_nonempty_and_unique() {
3054 let tmp = TempDir::new().unwrap();
3055 let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
3056 let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
3057 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3058 assert!(err.to_string().contains("members"));
3059
3060 let dup = write_toml(
3061 tmp.path(),
3062 "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
3063 );
3064 let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
3065 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3066 assert!(err.to_string().contains("unique"));
3067 }
3068
3069 #[test]
3070 fn empty_teams_table_is_disabled_not_error() {
3071 let tmp = TempDir::new().unwrap();
3072 let path = write_toml(tmp.path(), "[teams]\n");
3073 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
3074 assert!(cfg.teams.is_empty());
3075 cfg.save(&path).unwrap();
3077 let on_disk = std::fs::read_to_string(&path).unwrap();
3078 assert!(!on_disk.contains("[teams]"));
3079 }
3080
3081 #[test]
3082 fn blank_milestones_dir_rejected() {
3083 let tmp = TempDir::new().unwrap();
3084 let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
3085 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3086 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
3087 assert!(err.to_string().contains("milestones_dir"));
3088 }
3089
3090 #[test]
3091 fn sparse_keybindings_roundtrip_without_defaults() {
3092 let tmp = TempDir::new().unwrap();
3093 let path = write_toml(
3094 tmp.path(),
3095 "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
3096 );
3097 let config = Config::load_with_warning(&path, |_| {}).unwrap();
3098 config.save(&path).unwrap();
3099 let contents = std::fs::read_to_string(&path).unwrap();
3100 assert!(contents.contains("[keybindings.board]"));
3101 assert!(!contents.contains("move_left"));
3102 assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
3103 }
3104
3105 #[test]
3106 fn omitted_keybindings_retain_effective_defaults() {
3107 let tmp = TempDir::new().unwrap();
3108 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
3109 let config = Config::load_with_warning(&path, |_| {}).unwrap();
3110 let bindings = config.effective_keybindings().unwrap();
3111 assert_eq!(
3112 bindings.label(
3113 crate::keybindings::InputContext::Board,
3114 crate::keybindings::TuiAction::NewIssue
3115 ),
3116 "n"
3117 );
3118 }
3119
3120 #[test]
3125 fn edit_conflict_keybindings_override_the_prompt() {
3126 use crate::keybindings::{InputContext, TuiAction};
3127 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3128
3129 let tmp = TempDir::new().unwrap();
3130 let path = write_toml(
3131 tmp.path(),
3132 "[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
3133 );
3134 let config = Config::load_with_warning(&path, |_| {}).unwrap();
3135 let bindings = config.effective_keybindings().unwrap();
3136
3137 assert_eq!(
3138 bindings.label(InputContext::EditConflict, TuiAction::Confirm),
3139 "Ctrl+k"
3140 );
3141 assert_eq!(
3142 bindings.action(
3143 InputContext::EditConflict,
3144 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
3145 ),
3146 Some(TuiAction::Confirm)
3147 );
3148 assert_eq!(
3149 bindings.action(
3150 InputContext::EditConflict,
3151 KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
3152 ),
3153 None,
3154 "an override replaces the action's whole alias list"
3155 );
3156 assert_eq!(
3158 bindings.action(
3159 InputContext::EditConflict,
3160 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
3161 ),
3162 Some(TuiAction::Reedit)
3163 );
3164 assert_eq!(
3165 bindings.action(
3166 InputContext::EditConflict,
3167 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
3168 ),
3169 Some(TuiAction::Ignore)
3170 );
3171 }
3172
3173 #[test]
3174 fn keybindings_reject_unknown_fields_and_empty_actions() {
3175 let tmp = TempDir::new().unwrap();
3176 let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
3177 assert!(
3178 Config::load_with_warning(&unknown, |_| {})
3179 .unwrap_err()
3180 .to_string()
3181 .contains("bogus")
3182 );
3183 let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
3184 assert!(
3185 Config::load_with_warning(&empty, |_| {})
3186 .unwrap_err()
3187 .to_string()
3188 .contains("new_issue")
3189 );
3190 }
3191
3192 #[test]
3196 fn shell_mode_switches_are_configurable() {
3197 use crate::keybindings::{InputContext, TuiAction};
3198 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3199
3200 let tmp = TempDir::new().unwrap();
3201 let path = write_toml(
3202 tmp.path(),
3203 "[keybindings.shell]\nswitch_to_milestones = [\"g\"]\nswitch_to_board = [\"f3\", \"b\"]\n",
3204 );
3205 let config = Config::load_with_warning(&path, |_| {}).unwrap();
3206 let bindings = config.effective_keybindings().unwrap();
3207
3208 assert_eq!(
3210 bindings.label(InputContext::Shell, TuiAction::SwitchToMilestones),
3211 "g"
3212 );
3213 assert_eq!(
3214 bindings.label(InputContext::Shell, TuiAction::SwitchToBoard),
3215 "F3 / b"
3216 );
3217 assert_eq!(
3219 bindings.action(
3220 InputContext::Shell,
3221 KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)
3222 ),
3223 Some(TuiAction::SwitchToMilestones)
3224 );
3225 assert_eq!(
3226 bindings.action(
3227 InputContext::Shell,
3228 KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)
3229 ),
3230 None,
3231 "an override replaces the action's whole alias list"
3232 );
3233 assert_eq!(
3234 bindings.action(
3235 InputContext::Shell,
3236 KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)
3237 ),
3238 Some(TuiAction::SwitchToBoard),
3239 "a second alias keeps working"
3240 );
3241
3242 let unknown = write_toml(
3244 tmp.path(),
3245 "[keybindings.shell]\nswitch_to_nowhere = [\"g\"]\n",
3246 );
3247 let error = Config::load_with_warning(&unknown, |_| {})
3248 .unwrap_err()
3249 .to_string();
3250 assert!(
3251 error.contains("switch_to_nowhere"),
3252 "the error names the offending key: {error:?}"
3253 );
3254
3255 let bad_chord = write_toml(
3257 tmp.path(),
3258 "[keybindings.shell]\nswitch_to_milestones = [\"ctrl+nosuchkey\"]\n",
3259 );
3260 let error = Config::load_with_warning(&bad_chord, |_| {})
3261 .unwrap_err()
3262 .to_string();
3263 assert!(
3264 error.contains("switch_to_milestones") && error.contains("nosuchkey"),
3265 "the error names the key and the chord: {error:?}"
3266 );
3267 }
3268
3269 #[test]
3272 fn templates_round_trip_through_save_and_load() {
3273 let tmp = TempDir::new().unwrap();
3274 let path = tmp.path().join("tix.toml");
3275 let mut config = Config::starter();
3276 config.templates.insert(
3277 "bug".into(),
3278 IssueTemplate {
3279 title: Some("Bug: ".into()),
3280 body: Some("## Steps\n".into()),
3281 labels: vec!["bug".into(), "urgent".into()],
3282 status: Some("todo".into()),
3283 cycles: vec!["Default".into()],
3284 start_date: Some("2026-01-01".into()),
3285 due_date: Some("2026-01-02".into()),
3286 ..IssueTemplate::default()
3287 },
3288 );
3289 config.validate().expect("the fixture is a valid config");
3290 config.save(&path).unwrap();
3291
3292 let raw = std::fs::read_to_string(&path).unwrap();
3293 assert!(raw.contains("[templates.bug]"), "{raw}");
3294 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
3295 assert_eq!(reloaded.templates, config.templates);
3296 let bug = &reloaded.templates["bug"];
3297 assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
3298 assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
3299 assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
3300 }
3301
3302 #[test]
3303 fn a_config_without_templates_writes_no_section() {
3304 let tmp = TempDir::new().unwrap();
3305 let path = tmp.path().join("tix.toml");
3306 Config::default().save(&path).unwrap();
3307 let raw = std::fs::read_to_string(&path).unwrap();
3308 assert!(
3309 !raw.contains("templates"),
3310 "an absent section must not appear on disk:\n{raw}"
3311 );
3312 assert!(
3313 Config::load_with_warning(&path, |_| {})
3314 .unwrap()
3315 .templates
3316 .is_empty()
3317 );
3318 }
3319
3320 #[test]
3321 fn a_template_value_with_a_comma_is_rejected() {
3322 let tmp = TempDir::new().unwrap();
3323 let path = write_toml(
3324 tmp.path(),
3325 "issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
3326 );
3327 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3328 assert!(
3329 err.to_string().contains("must not contain a comma"),
3330 "got {err}"
3331 );
3332 }
3333
3334 #[test]
3335 fn a_template_with_a_backwards_date_range_is_rejected() {
3336 let tmp = TempDir::new().unwrap();
3337 let path = write_toml(
3338 tmp.path(),
3339 "issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
3340 start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
3341 );
3342 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3343 assert!(err.to_string().contains(">= start_date"), "got {err}");
3344 }
3345
3346 #[test]
3347 fn an_unknown_template_key_is_rejected() {
3348 let tmp = TempDir::new().unwrap();
3349 let path = write_toml(
3350 tmp.path(),
3351 "issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
3352 );
3353 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
3354 assert!(err.to_string().contains("unknown field"), "got {err}");
3355 }
3356}