1use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Result, TixError};
18use crate::keybindings::{EffectiveBindings, KeyBindings};
19
20#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
26#[serde(default, deny_unknown_fields)]
27pub struct RemoteConfig {
28 pub token: Option<String>,
31 pub base_url: Option<String>,
33 pub project: Option<String>,
35 pub team: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
41#[serde(default, deny_unknown_fields)]
42pub struct ExportConfig {
43 pub gitlab: Option<RemoteConfig>,
45 pub linear: Option<RemoteConfig>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
56#[serde(default, deny_unknown_fields)]
57pub struct Team {
58 pub members: Vec<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
66#[serde(default, deny_unknown_fields)]
67pub struct PlanningCycle {
68 pub description: Option<String>,
70 pub start_date: Option<String>,
72 pub due_date: Option<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(default, deny_unknown_fields)]
85pub struct PlanningConfig {
86 pub cycle_number: u32,
88 pub default_cycle: Option<String>,
90 pub default_milestone: Option<String>,
92 pub default_team: Option<String>,
94}
95
96impl PlanningConfig {
97 pub fn is_default(&self) -> bool {
101 self == &Self::default()
102 }
103}
104
105impl Default for PlanningConfig {
106 fn default() -> Self {
107 Self {
108 cycle_number: 1,
109 default_cycle: None,
110 default_milestone: None,
111 default_team: None,
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
123#[serde(default, deny_unknown_fields)]
124pub struct StateConfig {
125 #[serde(skip_serializing)]
129 pub next_number: u32,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
137pub enum OrderBy {
138 #[default]
140 #[serde(rename = "created_at_desc")]
141 CreatedAtDesc,
142 #[serde(rename = "created_at_asc")]
144 CreatedAtAsc,
145 #[serde(rename = "none")]
147 None,
148}
149
150impl OrderBy {
151 pub fn as_str(self) -> &'static str {
153 match self {
154 Self::CreatedAtDesc => "created_at_desc",
155 Self::CreatedAtAsc => "created_at_asc",
156 Self::None => "none",
157 }
158 }
159
160 pub fn next(self) -> Self {
162 match self {
163 Self::CreatedAtDesc => Self::CreatedAtAsc,
164 Self::CreatedAtAsc => Self::None,
165 Self::None => Self::CreatedAtDesc,
166 }
167 }
168}
169
170pub const CATEGORY_KEYS: &[&str] = &[
181 "backlog",
182 "unstarted",
183 "active",
184 "blocked",
185 "completed",
186 "canceled",
187];
188
189pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
191
192pub const COMPLETE_CATEGORY: &str = "completed";
195
196pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210pub struct CategoryConfig {
211 pub order: Vec<String>,
218 pub default: String,
221}
222
223impl Default for CategoryConfig {
224 fn default() -> Self {
225 Self {
226 order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
227 default: SAFE_DEFAULT_CATEGORY.to_string(),
228 }
229 }
230}
231
232impl CategoryConfig {
233 pub fn is_default(&self) -> bool {
235 self == &Self::default()
236 }
237
238 pub fn contains(&self, key: &str) -> bool {
240 self.order.iter().any(|k| k == key)
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(default, deny_unknown_fields)]
247pub struct TransitionConfig {
248 pub terminal_locked: bool,
254}
255
256impl Default for TransitionConfig {
257 fn default() -> Self {
258 Self {
259 terminal_locked: true,
260 }
261 }
262}
263
264impl TransitionConfig {
265 pub fn is_default(&self) -> bool {
266 self == &Self::default()
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(default, deny_unknown_fields)]
277pub struct BoardConfiguration {
278 pub columns: Vec<String>,
280 pub done_column: Option<String>,
289 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
298 pub category_map: IndexMap<String, String>,
299 pub hidden_columns: Vec<String>,
302 pub show_mismatch: bool,
305 pub wrap_titles: bool,
307 pub title_lines: usize,
311 #[serde(skip_serializing_if = "Vec::is_empty")]
315 pub card_order: Vec<String>,
316 pub order_by: OrderBy,
319}
320
321impl Default for BoardConfiguration {
322 fn default() -> Self {
323 Self {
324 columns: ["todo", "in progress", "in review", "done"]
325 .into_iter()
326 .map(String::from)
327 .collect(),
328 done_column: Some("done".to_string()),
329 category_map: IndexMap::new(),
330 hidden_columns: Vec::new(),
331 show_mismatch: false,
332 wrap_titles: true,
333 title_lines: 3,
334 card_order: Vec::new(),
335 order_by: OrderBy::default(),
336 }
337 }
338}
339
340pub fn starter_board() -> BoardConfiguration {
357 BoardConfiguration {
358 columns: [
359 "triage",
360 "todo",
361 "in progress",
362 "in review",
363 "done",
364 "wontfix",
365 ]
366 .into_iter()
367 .map(String::from)
368 .collect(),
369 category_map: IndexMap::from([
370 ("done".to_string(), "completed".to_string()),
371 ("wontfix".to_string(), "canceled".to_string()),
372 ]),
373 ..BoardConfiguration::default()
374 }
375}
376
377impl BoardConfiguration {
378 pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
398 if let Some(mapped) = self.category_map.get(column) {
399 return mapped;
400 }
401 if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
402 return COMPLETE_CATEGORY;
403 }
404 &categories.default
405 }
406
407 pub fn effective_done_column(&self) -> Option<&str> {
410 if let Some(done) = self.done_column.as_deref() {
411 return Some(done);
412 }
413 if self.columns.iter().any(|c| c == "done") {
414 Some("done")
415 } else {
416 self.columns.last().map(String::as_str)
417 }
418 }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
432#[serde(default, transparent)]
433pub struct KanbanSection {
434 pub configurations: IndexMap<String, BoardConfiguration>,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(default, deny_unknown_fields)]
441pub struct Config {
442 pub kanban: KanbanSection,
444 pub issues_dir: PathBuf,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub milestones_dir: Option<PathBuf>,
450 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
452 pub teams: IndexMap<String, Team>,
453 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
455 pub cycles: IndexMap<String, PlanningCycle>,
456 #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
458 pub planning: PlanningConfig,
459 #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
462 pub categories: CategoryConfig,
463 #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
465 pub transitions: TransitionConfig,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub triage: Option<crate::triage::TriageConfig>,
475 pub editor: Option<String>,
477 pub agent: Option<String>,
481 #[serde(skip_serializing)]
484 pub state: StateConfig,
485 pub export: ExportConfig,
487 #[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
489 pub keybindings: KeyBindings,
490 #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
501 pub custom_attributes: CustomAttributes,
502}
503
504#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
516#[serde(default, deny_unknown_fields)]
517pub struct CustomAttributes {
518 pub keys: Vec<String>,
522}
523
524impl CustomAttributes {
525 pub fn is_empty(&self) -> bool {
527 self.keys.is_empty()
528 }
529}
530
531impl Default for Config {
532 fn default() -> Self {
533 Config {
534 kanban: KanbanSection {
535 configurations: IndexMap::from([(
536 "default".to_string(),
537 BoardConfiguration::default(),
538 )]),
539 },
540 issues_dir: PathBuf::from("docs/issues"),
541 milestones_dir: None,
542 teams: IndexMap::new(),
543 cycles: IndexMap::new(),
544 planning: PlanningConfig::default(),
545 categories: CategoryConfig::default(),
546 transitions: TransitionConfig::default(),
547 triage: None,
554 editor: None,
555 state: StateConfig::default(),
556 export: ExportConfig::default(),
557 agent: None,
558 keybindings: KeyBindings::default(),
559 custom_attributes: CustomAttributes::default(),
560 }
561 }
562}
563
564impl Config {
565 #[must_use]
579 pub fn starter() -> Config {
580 let mut config = Config::default();
581 config
582 .kanban
583 .configurations
584 .insert("default".to_string(), starter_board());
585 config.triage = Some(crate::triage::TriageConfig {
586 entry_status: Some("triage".to_string()),
587 accepted_status: Some("todo".to_string()),
588 declined_status: Some("wontfix".to_string()),
589 deferred_status: None,
590 });
591 config
592 }
593
594 pub(crate) fn validate(&self) -> Result<()> {
598 if self.issues_dir.as_os_str().is_empty() {
599 return Err(TixError::Config("`issues_dir` must not be blank".into()));
600 }
601 if self.milestones_dir().as_os_str().is_empty() {
602 return Err(TixError::Config(
603 "`milestones_dir` must not be blank".into(),
604 ));
605 }
606 validate_planning(&self.planning)?;
607 validate_categories(&self.categories)?;
608 self.effective_keybindings()?;
609 if self.kanban.configurations.is_empty() {
610 return Err(TixError::Config(
611 "`[kanban]` must define at least one configuration".into(),
612 ));
613 }
614 for (name, board) in &self.kanban.configurations {
615 validate_board(name, board)?;
616 validate_category_map(name, board, &self.categories)?;
617 }
618 for (label, team) in &self.teams {
619 validate_team(label, team)?;
620 }
621 for (name, cycle) in &self.cycles {
622 validate_cycle(name, cycle)?;
623 }
624 crate::triage::config::validate(self)?;
630 Ok(())
631 }
632
633 pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
635 KeyBindings::resolve(&self.keybindings)
636 }
637
638 fn restore_declaration_order(&mut self, contents: &str) {
642 crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
643 crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
644 crate::config::restore_section_order(&mut self.teams, contents, "teams");
645 }
646
647 pub fn custom_attributes(&self) -> Vec<String> {
653 self.custom_attributes
654 .keys
655 .iter()
656 .map(|k| k.trim().to_ascii_lowercase())
657 .collect()
658 }
659
660 pub fn milestones_dir(&self) -> &Path {
663 self.milestones_dir
664 .as_deref()
665 .unwrap_or(Path::new("docs/milestones"))
666 }
667
668 pub fn default_config(&self) -> &BoardConfiguration {
671 self.kanban
672 .configurations
673 .get("default")
674 .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
675 .expect("validate() guarantees at least one configuration")
676 }
677
678 pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
681 self.kanban
682 .configurations
683 .iter()
684 .map(|(name, board)| (name.as_str(), board))
685 }
686
687 pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
693 let contents = std::fs::read_to_string(path)
694 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
695 let mut config: Config = toml::from_str(&contents)
696 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
697 config.restore_declaration_order(&contents);
698 if config.kanban.configurations.is_empty() {
699 config.kanban = KanbanSection {
700 configurations: IndexMap::from([(
701 "default".to_string(),
702 BoardConfiguration::default(),
703 )]),
704 };
705 }
706 config.validate().map_err(|e| match e {
707 TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
708 other => other,
709 })?;
710 for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
711 .into_iter()
712 .flatten()
713 {
714 if remote.token.is_some() {
715 sink(
716 "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
717 );
718 }
719 }
720 Ok(config)
721 }
722
723 pub fn load(project_root: &Path) -> Result<Config> {
727 let path = project_root.join("tix.toml");
728 if !path.is_file() {
729 return Ok(Config::default());
730 }
731 Self::load_with_warning(&path, |w| eprintln!("{w}"))
732 }
733
734 pub fn project_root(cwd: &Path) -> Option<PathBuf> {
737 let mut dir = Some(cwd.to_path_buf());
738 while let Some(current) = dir {
739 if current.join("tix.toml").is_file() {
740 return Some(current);
741 }
742 dir = current.parent().map(Path::to_path_buf);
743 }
744 None
745 }
746
747 fn sanitized_for_disk(&self) -> Config {
749 let mut copy = self.clone();
750 if let Some(gitlab) = copy.export.gitlab.as_mut() {
751 gitlab.token = None;
752 }
753 if let Some(linear) = copy.export.linear.as_mut() {
754 linear.token = None;
755 }
756 copy
757 }
758
759 pub fn save(&self, path: &Path) -> Result<()> {
765 let dir = match path.parent() {
766 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
767 _ => PathBuf::from("."),
768 };
769 std::fs::create_dir_all(&dir)?;
770
771 let disk = self.sanitized_for_disk();
772 let contents = toml::to_string_pretty(&disk)
773 .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
774
775 let file_name = path
776 .file_name()
777 .and_then(|n| n.to_str())
778 .unwrap_or("tix.toml");
779 let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
780
781 let write_result = (|| -> std::io::Result<()> {
782 let mut f = std::fs::File::create(&tmp)?;
783 use std::io::Write;
784 f.write_all(contents.as_bytes())?;
785 f.sync_all()?;
786 Ok(())
787 })();
788 if let Err(e) = write_result {
789 let _ = std::fs::remove_file(&tmp);
790 return Err(TixError::Io(e));
791 }
792
793 if let Err(e) = std::fs::rename(&tmp, path) {
794 let _ = std::fs::remove_file(&tmp);
795 return Err(TixError::Io(e));
796 }
797 Ok(())
798 }
799}
800
801fn restore_section_order<V: Clone>(map: &mut IndexMap<String, V>, contents: &str, section: &str) {
809 let mut order: Vec<String> = Vec::new();
810 for line in contents.lines() {
811 let line = line.trim();
812 if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
813 && let Some((head, key)) = rest.split_once('.')
814 && head.trim() == section
815 && !key.trim().contains('.')
816 && let Some(name) = unquote_key(key.trim())
817 && !order.iter().any(|n| n == name)
818 {
819 order.push(name.to_string());
820 }
821 }
822 if order.is_empty() {
823 return;
824 }
825 let mut ordered = IndexMap::new();
826 for name in &order {
827 if let Some(value) = map.shift_remove(name) {
828 ordered.insert(name.clone(), value);
829 }
830 }
831 ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
832 *map = ordered;
833}
834
835fn unquote_key(key: &str) -> Option<&str> {
838 if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
839 Some(inner)
840 } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
841 Some(inner)
842 } else {
843 Some(key)
844 }
845}
846
847fn validate_planning(planning: &PlanningConfig) -> Result<()> {
850 if planning.cycle_number == 0 {
851 return Err(TixError::Config(
852 "`planning.cycle_number` must be at least 1".into(),
853 ));
854 }
855 Ok(())
856}
857
858fn validate_team(label: &str, team: &Team) -> Result<()> {
861 if label.trim().is_empty() {
862 return Err(TixError::Config("`teams.<label>` must not be blank".into()));
863 }
864 if team.members.is_empty() {
865 return Err(TixError::Config(format!(
866 "`teams.{label}.members` must not be empty"
867 )));
868 }
869 let mut seen: HashSet<&str> = HashSet::new();
870 for member in &team.members {
871 if !seen.insert(member.as_str()) {
872 return Err(TixError::Config(format!(
873 "`teams.{label}.members` must be unique; found duplicate `{member}`"
874 )));
875 }
876 }
877 Ok(())
878}
879
880fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
883 if name.trim().is_empty() {
884 return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
885 }
886 if let Some(start) = &cycle.start_date {
887 if !crate::model::validate_date(start) {
888 return Err(TixError::Config(format!(
889 "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
890 )));
891 }
892 }
893 if let Some(due) = &cycle.due_date {
894 if !crate::model::validate_date(due) {
895 return Err(TixError::Config(format!(
896 "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
897 )));
898 }
899 if let Some(start) = &cycle.start_date
900 && due.as_str() < start.as_str()
901 {
902 return Err(TixError::Config(format!(
903 "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
904 )));
905 }
906 }
907 Ok(())
908}
909
910fn validate_categories(categories: &CategoryConfig) -> Result<()> {
918 if categories.order.is_empty() {
919 return Err(TixError::Config(
920 "`categories.order` must not be empty — it is the global vocabulary".into(),
921 ));
922 }
923 for key in &categories.order {
924 if !CATEGORY_KEYS.contains(&key.as_str()) {
925 return Err(TixError::Config(format!(
926 "unknown_category: `categories.order` names `{key}`, which is not in the \
927 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
928 board maps columns into it and does not define it.",
929 CATEGORY_KEYS.join(", ")
930 )));
931 }
932 }
933 if !categories.contains(&categories.default) {
934 return Err(TixError::Config(format!(
935 "unknown_category: `categories.default` is `{}`, which is not in \
936 `categories.order`",
937 categories.default
938 )));
939 }
940 if categories.default == COMPLETE_CATEGORY {
941 return Err(TixError::Config(format!(
942 "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
943 without an explicit mapping would resolve to complete, which changes \
944 completion meaning by accident. Map the completing column explicitly \
945 instead."
946 )));
947 }
948 Ok(())
949}
950
951fn validate_category_map(
958 name: &str,
959 board: &BoardConfiguration,
960 categories: &CategoryConfig,
961) -> Result<()> {
962 for (column, key) in &board.category_map {
963 if !board.columns.iter().any(|c| c == column) {
964 return Err(TixError::Config(format!(
965 "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
966 `{column}`, which is not one of the board's columns ({})",
967 board.columns.join(", ")
968 )));
969 }
970 if !categories.contains(key) {
971 return Err(TixError::Config(format!(
972 "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
973 `{key}`, which is not in the vocabulary ({})",
974 categories.order.join(", ")
975 )));
976 }
977 }
978 if board.category_map.is_empty() {
982 return Ok(());
983 }
984 let completers: Vec<&String> = board
985 .category_map
986 .iter()
987 .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
988 .map(|(column, _)| column)
989 .collect();
990 match completers.as_slice() {
991 [] => Err(TixError::Config(format!(
992 "missing_completion_category: `kanban.{name}.category_map` maps no column \
993 to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
994 is ever complete — map exactly one column to it.",
995 board.category_map.len()
996 ))),
997 [_only] => Ok(()),
998 many => Err(TixError::Config(format!(
999 "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
1000 ({}); exactly one column may complete work",
1001 many.len(),
1002 many.iter()
1003 .map(|c| c.as_str())
1004 .collect::<Vec<_>>()
1005 .join(", ")
1006 ))),
1007 }
1008}
1009
1010fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
1011 if board.columns.is_empty() {
1012 return Err(TixError::Config(format!(
1013 "`kanban.{name}.columns` must not be empty"
1014 )));
1015 }
1016 if board.columns.iter().any(|c| c.trim().is_empty()) {
1017 return Err(TixError::Config(format!(
1018 "`kanban.{name}.columns` must not contain blank names"
1019 )));
1020 }
1021 let mut seen: HashSet<&str> = HashSet::new();
1022 for column in &board.columns {
1023 if !seen.insert(column.as_str()) {
1024 return Err(TixError::Config(format!(
1025 "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
1026 )));
1027 }
1028 }
1029 if let Some(done) = &board.done_column
1030 && !board.columns.contains(done)
1031 {
1032 return Err(TixError::Config(format!(
1033 "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
1034 )));
1035 }
1036 for hidden in &board.hidden_columns {
1037 if !board.columns.contains(hidden) {
1038 return Err(TixError::Config(format!(
1039 "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
1040 )));
1041 }
1042 }
1043 if board.title_lines == 0 {
1044 return Err(TixError::Config(format!(
1045 "`kanban.{name}.title_lines` must be at least 1"
1046 )));
1047 }
1048 Ok(())
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054 use std::path::PathBuf;
1055 use tempfile::TempDir;
1056
1057 fn write_toml(dir: &Path, contents: &str) -> PathBuf {
1058 let path = dir.join("tix.toml");
1059 std::fs::write(&path, contents).unwrap();
1060 path
1061 }
1062
1063 #[test]
1064 fn starter_config_carries_a_working_inbox() {
1065 let config = Config::starter();
1066 config
1067 .validate()
1068 .expect("the config `tix init` writes must load");
1069
1070 let board = config.default_config();
1071 for column in [
1072 "triage",
1073 "todo",
1074 "in progress",
1075 "in review",
1076 "done",
1077 "wontfix",
1078 ] {
1079 assert!(
1080 board.columns.iter().any(|c| c == column),
1081 "the starter board is missing `{column}`: {:?}",
1082 board.columns
1083 );
1084 }
1085
1086 let triage = config
1087 .triage
1088 .as_ref()
1089 .expect("the starter config has an inbox");
1090 assert_eq!(triage.entry_status.as_deref(), Some("triage"));
1091 assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
1092 assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
1093 assert_eq!(triage.effective_deferred_status(), Some("triage"));
1096
1097 assert_eq!(
1099 board.category_map.get("wontfix").map(String::as_str),
1100 Some("canceled")
1101 );
1102 assert_eq!(
1103 board.category_map.get("done").map(String::as_str),
1104 Some("completed")
1105 );
1106 for column in ["triage", "todo", "in progress", "in review"] {
1112 assert!(
1113 !board.category_map.contains_key(column),
1114 "`{column}` must stay unmapped, or adjacency will refuse work moving back"
1115 );
1116 }
1117 }
1118
1119 #[test]
1120 fn defaults_match_contract() {
1121 let c = Config::default();
1122 let board = c.default_config();
1123 assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
1124 assert_eq!(board.done_column.as_deref(), Some("done"));
1125 assert_eq!(board.hidden_columns, Vec::<String>::new());
1126 assert!(!board.show_mismatch);
1127 assert!(board.wrap_titles);
1128 assert_eq!(board.title_lines, 3);
1129 assert!(board.card_order.is_empty());
1130 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
1131 assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
1132 assert_eq!(c.editor, None);
1133 assert_eq!(c.state.next_number, 0);
1134 assert_eq!(c.export.gitlab, None);
1135 assert_eq!(c.export.linear, None);
1136 }
1137
1138 #[test]
1139 fn load_returns_defaults_when_file_absent() {
1140 let tmp = TempDir::new().unwrap();
1141 let cfg = Config::load(tmp.path()).unwrap();
1142 assert_eq!(cfg.state.next_number, 0);
1143 assert_eq!(cfg.default_config().columns.len(), 4);
1144 }
1145
1146 #[test]
1147 fn load_synthesizes_default_config_when_kanban_section_missing() {
1148 let tmp = TempDir::new().unwrap();
1149 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1150 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1151 assert_eq!(cfg.kanban.configurations.len(), 1);
1152 assert!(cfg.kanban.configurations.contains_key("default"));
1153 assert_eq!(cfg.default_config().columns.len(), 4);
1154 }
1155
1156 #[test]
1157 fn load_parses_state_and_keeps_editor_and_remote_targets() {
1158 let tmp = TempDir::new().unwrap();
1159 let path = write_toml(
1160 tmp.path(),
1161 r#"issues_dir = "tickets"
1162editor = "nvim"
1163
1164[kanban.default]
1165columns = ["todo", "done"]
1166done_column = "done"
1167
1168[state]
1169next_number = 17
1170
1171[export.gitlab]
1172project = "acme/tix"
1173"#,
1174 );
1175 let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
1176 assert_eq!(
1177 cfg.kanban.configurations.get("default").unwrap().columns,
1178 ["todo", "done"]
1179 );
1180 assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
1181 assert_eq!(cfg.editor.as_deref(), Some("nvim"));
1182 assert_eq!(cfg.state.next_number, 17);
1183 assert_eq!(
1184 cfg.export.gitlab.unwrap().project.as_deref(),
1185 Some("acme/tix")
1186 );
1187 assert_eq!(cfg.export.linear, None);
1188 }
1189
1190 #[test]
1191 fn empty_columns_rejected_as_config_error() {
1192 let tmp = TempDir::new().unwrap();
1193 let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
1194 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1195 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1196 assert!(err.to_string().contains("kanban.scratch.columns"));
1197 }
1198
1199 #[test]
1200 fn duplicate_columns_rejected_as_config_error() {
1201 let tmp = TempDir::new().unwrap();
1202 let path = write_toml(
1203 tmp.path(),
1204 "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
1205 );
1206 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1207 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1208 assert!(err.to_string().contains("duplicate"));
1209 }
1210
1211 #[test]
1212 fn blank_column_name_rejected() {
1213 let tmp = TempDir::new().unwrap();
1214 let path = write_toml(
1215 tmp.path(),
1216 "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
1217 );
1218 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1219 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1220 }
1221
1222 #[test]
1223 fn done_column_must_be_in_columns() {
1224 let tmp = TempDir::new().unwrap();
1225 let path = write_toml(
1226 tmp.path(),
1227 "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
1228 );
1229 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1230 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1231 assert!(err.to_string().contains("kanban.scratch.done_column"));
1232 }
1233
1234 #[test]
1235 fn hidden_columns_must_be_subset_of_columns() {
1236 let tmp = TempDir::new().unwrap();
1237 let path = write_toml(
1238 tmp.path(),
1239 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
1240 );
1241 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1242 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1243 assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
1244 }
1245
1246 #[test]
1247 fn hidden_done_column_loads_fine() {
1248 let tmp = TempDir::new().unwrap();
1249 let path = write_toml(
1250 tmp.path(),
1251 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
1252 );
1253 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1254 let board = cfg.kanban.configurations.get("scratch").unwrap();
1255 assert_eq!(board.done_column.as_deref(), Some("done"));
1256 assert_eq!(board.hidden_columns, ["done"]);
1257 }
1258
1259 #[test]
1260 fn legacy_top_level_keys_rejected() {
1261 let tmp = TempDir::new().unwrap();
1262 let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
1263 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1264 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1265 assert!(err.to_string().contains("tix.toml"));
1266 assert!(err.to_string().contains("columns"));
1267 }
1268
1269 #[test]
1270 fn token_warning_fires_once_per_remote_with_token() {
1271 let tmp = TempDir::new().unwrap();
1272 let path = write_toml(
1273 tmp.path(),
1274 r#"[export.gitlab]
1275project = "a/b"
1276token = "gl-secret"
1277
1278[export.linear]
1279team = "Eng"
1280token = "lin-secret"
1281"#,
1282 );
1283 let mut warnings: Vec<String> = Vec::new();
1284 let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
1285 assert_eq!(warnings.len(), 2);
1286 assert!(warnings[0].contains("GITLAB_TOKEN"));
1287 assert!(warnings[1].contains("LINEAR_TOKEN"));
1288 for w in &warnings {
1290 assert!(!w.contains("secret"));
1291 }
1292 assert_eq!(
1293 cfg.export.gitlab.unwrap().token.as_deref(),
1294 Some("gl-secret")
1295 );
1296 }
1297
1298 #[test]
1299 fn no_token_means_no_warning() {
1300 let tmp = TempDir::new().unwrap();
1301 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1302 let mut fired = false;
1303 Config::load_with_warning(&path, |_| fired = true).unwrap();
1304 assert!(!fired);
1305 }
1306
1307 #[test]
1308 fn save_never_writes_the_state_section() {
1309 let tmp = TempDir::new().unwrap();
1310 let path = tmp.path().join("tix.toml");
1311 let mut cfg = Config::default();
1314 cfg.state.next_number = 42;
1315 cfg.save(&path).unwrap();
1316
1317 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1318 assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
1319 assert_eq!(
1320 reloaded.default_config().columns,
1321 cfg.default_config().columns
1322 );
1323
1324 let on_disk = std::fs::read_to_string(&path).unwrap();
1325 assert!(
1326 !on_disk.contains("next_number"),
1327 "next_number leaked into tix.toml:\n{on_disk}"
1328 );
1329 assert!(
1330 !on_disk.contains("[state]"),
1331 "state section written:\n{on_disk}"
1332 );
1333 }
1334
1335 #[test]
1336 fn multi_config_roundtrip_preserves_declaration_order() {
1337 let tmp = TempDir::new().unwrap();
1338 let path = write_toml(
1339 tmp.path(),
1340 r#"issues_dir = "docs/issues"
1341
1342[kanban.release]
1343columns = ["planned", "in progress", "done"]
1344done_column = "done"
1345show_mismatch = true
1346
1347[kanban.personal]
1348columns = ["todo", "done"]
1349done_column = "done"
1350hidden_columns = ["done"]
1351"#,
1352 );
1353 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1354 let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
1355 assert_eq!(names, ["release", "personal"]);
1356
1357 assert_eq!(
1359 cfg.default_config().columns,
1360 ["planned", "in progress", "done"]
1361 );
1362
1363 cfg.save(&path).unwrap();
1364 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1365 assert_eq!(
1366 cfg, reloaded,
1367 "order and values survive save/load round trip"
1368 );
1369 let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
1370 assert_eq!(names2, ["release", "personal"]);
1371 }
1372
1373 #[test]
1374 fn save_never_serializes_tokens_back_out() {
1375 let tmp = TempDir::new().unwrap();
1376 let path = tmp.path().join("tix.toml");
1377 let mut cfg = Config::default();
1378 cfg.export.gitlab = Some(RemoteConfig {
1379 token: Some("secret".into()),
1380 project: Some("a/b".into()),
1381 ..RemoteConfig::default()
1382 });
1383 cfg.save(&path).unwrap();
1384
1385 let on_disk = std::fs::read_to_string(&path).unwrap();
1386 assert!(
1387 !on_disk.contains("secret"),
1388 "token leaked to disk:\n{on_disk}"
1389 );
1390 assert!(
1391 on_disk.contains("a/b"),
1392 "non-secret fields survive:\n{on_disk}"
1393 );
1394
1395 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1396 assert_eq!(reloaded.export.gitlab.unwrap().token, None);
1397 }
1398
1399 #[test]
1400 fn save_is_atomic_and_leaves_no_temp_file() {
1401 let tmp = TempDir::new().unwrap();
1402 let path = tmp.path().join("tix.toml");
1403 Config::default().save(&path).unwrap();
1404 let entries: Vec<_> = std::fs::read_dir(tmp.path())
1405 .unwrap()
1406 .filter_map(|e| e.ok())
1407 .collect();
1408 assert_eq!(entries.len(), 1);
1409 assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
1410 }
1411
1412 #[test]
1413 fn default_loadwrites_are_idempotent() {
1414 let tmp = TempDir::new().unwrap();
1415 let path = tmp.path().join("tix.toml");
1416 let cfg = Config::default();
1417 cfg.save(&path).unwrap();
1418 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1419 assert_eq!(cfg, reloaded);
1420 }
1421
1422 #[test]
1423 fn effective_done_column_falls_back_to_last() {
1424 let board = BoardConfiguration {
1425 columns: vec!["todo".into(), "later".into()],
1426 done_column: None,
1427 ..Default::default()
1428 };
1429 assert_eq!(board.effective_done_column(), Some("later"));
1430
1431 let with_done = BoardConfiguration {
1432 columns: vec!["todo".into(), "done".into(), "later".into()],
1433 done_column: None,
1434 ..Default::default()
1435 };
1436 assert_eq!(with_done.effective_done_column(), Some("done"));
1437 }
1438
1439 #[test]
1440 fn project_root_walks_up_the_tree() {
1441 let tmp = TempDir::new().unwrap();
1442 let nested = tmp.path().join("a").join("b");
1443 std::fs::create_dir_all(&nested).unwrap();
1444 std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
1445
1446 assert_eq!(
1447 Config::project_root(&nested),
1448 Some(tmp.path().to_path_buf())
1449 );
1450 assert_eq!(
1451 Config::project_root(tmp.path()),
1452 Some(tmp.path().to_path_buf())
1453 );
1454 }
1455
1456 #[test]
1457 fn project_root_is_none_when_no_toml_exists() {
1458 let tmp = TempDir::new().unwrap();
1459 assert_eq!(Config::project_root(tmp.path()), None);
1460 }
1461
1462 #[test]
1463 fn order_by_parses_all_names_and_defaults_to_desc() {
1464 let tmp = TempDir::new().unwrap();
1465 for name in ["created_at_desc", "created_at_asc", "none"] {
1466 let path = write_toml(
1467 tmp.path(),
1468 &format!(
1469 "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
1470 ),
1471 );
1472 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1473 let board = cfg.kanban.configurations.get("x").unwrap();
1474 assert_eq!(board.order_by.as_str(), name);
1475 }
1476 assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
1477 assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
1478 assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
1479 assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
1480 }
1481
1482 #[test]
1483 fn zero_title_lines_rejected_as_config_error() {
1484 let tmp = TempDir::new().unwrap();
1485 let path = write_toml(
1486 tmp.path(),
1487 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
1488 );
1489 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1490 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1491 assert!(
1492 err.to_string().contains("kanban.scratch.title_lines"),
1493 "error names the offending field: {err}"
1494 );
1495 }
1496
1497 #[test]
1498 fn unknown_order_by_rejected_as_config_error() {
1499 let tmp = TempDir::new().unwrap();
1500 let path = write_toml(
1501 tmp.path(),
1502 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
1503 );
1504 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1505 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1506 }
1507
1508 #[test]
1509 fn board_without_display_keys_loads_default_display_settings() {
1510 let tmp = TempDir::new().unwrap();
1511 let path = write_toml(
1512 tmp.path(),
1513 "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
1514 );
1515 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1516 let board = cfg.default_config();
1517 assert!(board.wrap_titles);
1518 assert_eq!(board.title_lines, 3);
1519 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
1520 }
1521
1522 #[test]
1523 fn display_settings_roundtrip_through_save() {
1524 let tmp = TempDir::new().unwrap();
1525 let path = tmp.path().join("tix.toml");
1526 let mut cfg = Config::default();
1527 {
1528 let board = cfg.kanban.configurations.get_mut("default").unwrap();
1529 board.wrap_titles = false;
1530 board.title_lines = 5;
1531 board.order_by = OrderBy::None;
1532 }
1533 cfg.save(&path).unwrap();
1534 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1535 assert_eq!(
1536 reloaded, cfg,
1537 "display settings survive save/load round trip"
1538 );
1539 let on_disk = std::fs::read_to_string(&path).unwrap();
1540 for key in ["wrap_titles", "title_lines", "order_by"] {
1541 assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
1542 }
1543 }
1544
1545 #[test]
1546 fn manual_card_order_roundtrips_through_save() {
1547 let tmp = TempDir::new().unwrap();
1548 let path = tmp.path().join("tix.toml");
1549 let mut cfg = Config::default();
1550 {
1551 let board = cfg.kanban.configurations.get_mut("default").unwrap();
1552 board.card_order = vec!["a.md".into(), "b.md".into()];
1553 }
1554 cfg.save(&path).unwrap();
1555 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1556 assert_eq!(
1557 reloaded
1558 .kanban
1559 .configurations
1560 .get("default")
1561 .unwrap()
1562 .card_order,
1563 vec!["a.md".to_string(), "b.md".to_string()]
1564 );
1565 let on_disk = std::fs::read_to_string(&path).unwrap();
1566 assert!(
1567 on_disk.contains("card_order"),
1568 "missing card_order in:\n{on_disk}"
1569 );
1570 }
1571
1572 #[test]
1573 fn planning_defaults_match_contract() {
1574 let c = Config::default();
1575 assert_eq!(c.planning.cycle_number, 1);
1576 assert_eq!(c.planning.default_cycle, None);
1577 assert_eq!(c.planning.default_milestone, None);
1578 assert_eq!(c.planning.default_team, None);
1579 assert!(c.teams.is_empty());
1580 assert!(c.cycles.is_empty());
1581 assert_eq!(c.milestones_dir, None);
1582 assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
1583 }
1584
1585 #[test]
1586 fn planning_sections_roundtrip_and_preserve_declaration_order() {
1587 let tmp = TempDir::new().unwrap();
1588 let path = write_toml(
1589 tmp.path(),
1590 r#"issues_dir = "docs/issues"
1591milestones_dir = "docs/milestones"
1592
1593[planning]
1594cycle_number = 1
1595default_cycle = "Cycle 1"
1596default_milestone = "Backlog"
1597default_team = "default"
1598
1599[cycles."Cycle 1"]
1600
1601[cycles."Cycle 2"]
1602start_date = "2026-09-21"
1603due_date = "2026-09-30"
1604
1605[teams.default]
1606members = ["Herb Mason <herb@example.com>", "@alice"]
1607
1608[teams.platform]
1609members = ["bob"]
1610"#,
1611 );
1612 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1613 assert_eq!(cfg.planning.cycle_number, 1);
1614 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
1615 assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
1616 assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
1617 assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
1618
1619 let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
1621 assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
1622 let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
1623 assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
1624 assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
1625 assert_eq!(cycle2.description, None);
1626
1627 let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
1628 assert_eq!(teams, ["default", "platform"]);
1629 assert_eq!(
1630 cfg.teams.get("default").unwrap().members,
1631 ["Herb Mason <herb@example.com>", "@alice"]
1632 );
1633
1634 cfg.save(&path).unwrap();
1636 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1637 assert_eq!(cfg, reloaded);
1638 assert_eq!(
1639 reloaded
1640 .cycles
1641 .keys()
1642 .map(String::as_str)
1643 .collect::<Vec<_>>(),
1644 ["Cycle 1", "Cycle 2"]
1645 );
1646 assert_eq!(
1647 reloaded
1648 .teams
1649 .keys()
1650 .map(String::as_str)
1651 .collect::<Vec<_>>(),
1652 ["default", "platform"]
1653 );
1654 }
1655
1656 const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
1661
1662[categories]
1663order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
1664default = "unstarted"
1665
1666[transitions]
1667terminal_locked = true
1668
1669[kanban.default]
1670columns = ["triage", "wip", "review", "shipped"]
1671done_column = "shipped"
1672category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
1673
1674[export]
1675"#;
1676
1677 #[test]
1678 fn category_sections_parse_and_default_sensibly() {
1679 let tmp = TempDir::new().unwrap();
1680 let path = write_toml(tmp.path(), FULL_CATEGORIES);
1681 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1682 assert_eq!(cfg.categories.order, CATEGORY_KEYS);
1683 assert_eq!(cfg.categories.default, "unstarted");
1684 assert!(cfg.transitions.terminal_locked);
1685
1686 let board = cfg.default_config();
1687 assert_eq!(
1688 board.category_map.get("shipped").map(String::as_str),
1689 Some("completed")
1690 );
1691 assert_eq!(
1692 board.category_map.get("triage").map(String::as_str),
1693 Some("backlog")
1694 );
1695 }
1696
1697 #[test]
1698 fn category_sections_default_when_absent() {
1699 let tmp = TempDir::new().unwrap();
1700 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1701 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1702 assert_eq!(cfg.categories, CategoryConfig::default());
1705 assert_eq!(cfg.transitions, TransitionConfig::default());
1706 assert!(cfg.categories.is_default(), "and is not written back");
1707 assert!(cfg.transitions.is_default());
1708 }
1709
1710 #[test]
1711 fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
1712 let tmp = TempDir::new().unwrap();
1713 let bad_categories = write_toml(
1714 tmp.path(),
1715 "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
1716 );
1717 let err = Config::load_with_warning(&bad_categories, |_| {})
1718 .expect_err("an unknown key in [categories] must be refused");
1719 assert!(err.to_string().contains("unknown field"), "got: {err}");
1720
1721 let tmp2 = TempDir::new().unwrap();
1722 let bad_transitions = write_toml(
1723 tmp2.path(),
1724 "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
1725 );
1726 let err = Config::load_with_warning(&bad_transitions, |_| {})
1727 .expect_err("an unknown key in [transitions] must be refused");
1728 assert!(err.to_string().contains("unknown field"), "got: {err}");
1729 }
1730
1731 #[test]
1732 fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
1733 let tmp = TempDir::new().unwrap();
1734 let path = write_toml(
1735 tmp.path(),
1736 "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
1737 );
1738 let err = Config::load_with_warning(&path, |_| {})
1739 .expect_err("the vocabulary is global and fixed; a new key is not a default");
1740 assert!(err.to_string().contains("unknown_category"), "got: {err}");
1741 }
1742
1743 #[test]
1746 fn a_default_of_completed_is_refused() {
1747 let tmp = TempDir::new().unwrap();
1748 let path = write_toml(
1749 tmp.path(),
1750 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
1751 );
1752 let err = Config::load_with_warning(&path, |_| {})
1753 .expect_err("a completion-changing default must be an escalation");
1754 let msg = err.to_string();
1755 assert!(msg.contains("may not be `completed`"), "got: {msg}");
1756 }
1757
1758 #[test]
1759 fn a_default_outside_the_vocabulary_is_refused() {
1760 let tmp = TempDir::new().unwrap();
1761 let path = write_toml(
1762 tmp.path(),
1763 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
1764 );
1765 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1766 assert!(err.to_string().contains("unknown_category"), "got: {err}");
1767 }
1768
1769 #[test]
1773 fn a_mapped_board_with_no_completion_column_is_refused() {
1774 let tmp = TempDir::new().unwrap();
1775 let path = write_toml(
1776 tmp.path(),
1777 r#"issues_dir = "docs/issues"
1778
1779[kanban.default]
1780columns = ["triage", "wip", "shipped"]
1781done_column = "shipped"
1782category_map = { triage = "backlog", wip = "active" }
1783"#,
1784 );
1785 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1786 assert!(
1787 err.to_string().contains("missing_completion_category"),
1788 "got: {err}"
1789 );
1790 }
1791
1792 #[test]
1793 fn a_board_with_two_completion_columns_is_refused() {
1794 let tmp = TempDir::new().unwrap();
1795 let path = write_toml(
1796 tmp.path(),
1797 r#"issues_dir = "docs/issues"
1798
1799[kanban.default]
1800columns = ["shipped", "cancelled"]
1801done_column = "shipped"
1802category_map = { shipped = "completed", cancelled = "completed" }
1803"#,
1804 );
1805 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1806 let msg = err.to_string();
1807 assert!(
1808 msg.contains("exactly one column may complete"),
1809 "got: {msg}"
1810 );
1811 }
1812
1813 #[test]
1814 fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
1815 let tmp = TempDir::new().unwrap();
1816 let path = write_toml(
1817 tmp.path(),
1818 r#"issues_dir = "docs/issues"
1819
1820[kanban.default]
1821columns = ["triage", "shipped"]
1822done_column = "shipped"
1823category_map = { triage = "backlog", shipped = "completed", nope = "active" }
1824"#,
1825 );
1826 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1827 assert!(
1828 err.to_string().contains("mapping_for_unknown_column"),
1829 "got: {err}"
1830 );
1831 }
1832
1833 #[test]
1834 fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
1835 let tmp = TempDir::new().unwrap();
1836 let path = write_toml(
1837 tmp.path(),
1838 r#"issues_dir = "docs/issues"
1839
1840[kanban.default]
1841columns = ["triage", "shipped"]
1842done_column = "shipped"
1843category_map = { triage = "wontfix", shipped = "completed" }
1844"#,
1845 );
1846 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
1847 assert!(err.to_string().contains("unknown_category"), "got: {err}");
1848 }
1849
1850 #[test]
1854 fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
1855 let tmp = TempDir::new().unwrap();
1856 let path = write_toml(
1857 tmp.path(),
1858 r#"issues_dir = "docs/issues"
1859
1860[kanban.default]
1861columns = ["triage", "wip", "shipped"]
1862done_column = "shipped"
1863category_map = { shipped = "completed" }
1864"#,
1865 );
1866 let cfg =
1867 Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
1868 let board = cfg.default_config();
1869 assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
1870 assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
1871 assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
1872 }
1873
1874 #[test]
1880 fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
1881 let tmp = TempDir::new().unwrap();
1882 let path = write_toml(
1883 tmp.path(),
1884 r#"issues_dir = "docs/issues"
1885
1886[kanban.default]
1887columns = ["todo", "in progress", "in review", "done"]
1888done_column = "done"
1889"#,
1890 );
1891 let cfg = Config::load_with_warning(&path, |_| {})
1892 .expect("a legacy board must load, and it does");
1893 let board = cfg.default_config();
1894 assert!(board.category_map.is_empty());
1895 assert_eq!(board.category_for("done", &cfg.categories), "completed");
1896 assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
1897 assert_eq!(
1898 board.category_for("in review", &cfg.categories),
1899 "unstarted",
1900 "and nothing else is silently complete"
1901 );
1902 }
1903
1904 #[test]
1908 fn a_defaulted_config_gains_no_new_keys_on_save() {
1909 let tmp = TempDir::new().unwrap();
1910 let legacy = r#"issues_dir = "docs/issues"
1911
1912[kanban.default]
1913columns = [
1914 "todo",
1915 "in progress",
1916 "in review",
1917 "done",
1918]
1919done_column = "done"
1920hidden_columns = []
1921show_mismatch = false
1922wrap_titles = true
1923title_lines = 3
1924order_by = "created_at_desc"
1925
1926[export]
1927"#;
1928 let path = write_toml(tmp.path(), legacy);
1929 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1930 cfg.save(&path).unwrap();
1931 let after = std::fs::read_to_string(&path).unwrap();
1932 assert_eq!(
1933 after, legacy,
1934 "no `[categories]`, no `[transitions]`, no `category_map` may appear"
1935 );
1936 }
1937
1938 #[test]
1941 fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
1942 let tmp = TempDir::new().unwrap();
1943 let path = write_toml(
1944 tmp.path(),
1945 r#"issues_dir = "docs/issues"
1946
1947[kanban.default]
1948columns = ["triage", "shipped"]
1949done_column = "shipped"
1950category_map = { triage = "backlog", shipped = "completed" }
1951
1952[export]
1953"#,
1954 );
1955 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1956 assert_eq!(cfg.categories, CategoryConfig::default());
1957 cfg.save(&path).unwrap();
1958 let after = std::fs::read_to_string(&path).unwrap();
1959 assert!(
1960 !after.contains("[categories]"),
1961 "the section must stay unmaterialized: {after}"
1962 );
1963 assert!(
1964 after.contains("category_map"),
1965 "while the board's own mapping survives: {after}"
1966 );
1967 }
1968
1969 #[test]
1984 fn a_full_category_config_round_trips_with_its_values_and_order() {
1985 let tmp = TempDir::new().unwrap();
1986 let authored = r#"issues_dir = "docs/issues"
1992
1993[categories]
1994order = ["backlog", "active", "completed", "canceled"]
1995default = "backlog"
1996
1997[transitions]
1998terminal_locked = false
1999
2000[kanban.default]
2001columns = ["triage", "wip", "shipped"]
2002done_column = "shipped"
2003category_map = { triage = "backlog", wip = "active", shipped = "completed" }
2004
2005[export]
2006"#;
2007 let path = write_toml(tmp.path(), authored);
2008 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2009 cfg.save(&path).unwrap();
2010 let after = std::fs::read_to_string(&path).unwrap();
2011 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2012
2013 assert_eq!(
2014 reloaded.categories, cfg.categories,
2015 "the vocabulary and its default survive"
2016 );
2017 assert_eq!(
2018 reloaded.categories.order,
2019 vec!["backlog", "active", "completed", "canceled"],
2020 "and `order` is a Vec, so the declared order is preserved exactly"
2021 );
2022
2023 assert_eq!(
2024 reloaded.default_config().category_map,
2025 cfg.default_config().category_map,
2026 "the mapping's contents survive, in whatever order they are written"
2027 );
2028 assert!(after.contains("[categories]"), "after: {after}");
2036 assert!(after.contains("[transitions]"), "after: {after}");
2037 assert!(
2038 !reloaded.transitions.terminal_locked,
2039 "and the value survives"
2040 );
2041
2042 let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
2049 reloaded_again.save(&path).unwrap();
2050 assert_eq!(
2051 std::fs::read_to_string(&path).unwrap(),
2052 after,
2053 "a second save must be a no-op"
2054 );
2055 }
2056
2057 #[test]
2060 fn two_boards_with_different_columns_map_to_the_same_categories() {
2061 let tmp = TempDir::new().unwrap();
2062 let path = write_toml(
2063 tmp.path(),
2064 r#"issues_dir = "docs/issues"
2065
2066[kanban.alpha]
2067columns = ["triage", "wip", "review", "shipped"]
2068done_column = "shipped"
2069category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
2070
2071[kanban.beta]
2072columns = ["new", "doing", "merged"]
2073done_column = "merged"
2074
2075[export]
2076"#,
2077 );
2078 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2079 let alpha = &cfg.kanban.configurations["alpha"];
2080 let beta = &cfg.kanban.configurations["beta"];
2081 assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
2084 assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
2085 assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
2086 assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
2087 assert!(beta.category_map.is_empty());
2088 }
2089
2090 #[test]
2094 fn canceled_is_terminal_but_not_complete() {
2095 assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
2096 assert!(TERMINAL_CATEGORIES.contains(&"completed"));
2097 assert_eq!(
2098 COMPLETE_CATEGORY, "completed",
2099 "completion is defined by this category alone"
2100 );
2101 assert_ne!(COMPLETE_CATEGORY, "canceled");
2102 let tmp = TempDir::new().unwrap();
2105 let path = write_toml(
2106 tmp.path(),
2107 r#"issues_dir = "docs/issues"
2108
2109[kanban.default]
2110columns = ["triage", "dropped", "shipped"]
2111done_column = "shipped"
2112category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
2113"#,
2114 );
2115 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2116 let board = cfg.default_config();
2117 assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
2118 assert_ne!(
2119 board.category_for("dropped", &cfg.categories),
2120 COMPLETE_CATEGORY
2121 );
2122 }
2123
2124 #[test]
2128 fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
2129 assert!(CATEGORY_KEYS.contains(&"blocked"));
2130 let tmp = TempDir::new().unwrap();
2131 let path = write_toml(
2132 tmp.path(),
2133 r#"issues_dir = "docs/issues"
2134
2135[kanban.default]
2136columns = ["wip", "on hold", "shipped"]
2137done_column = "shipped"
2138category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
2139"#,
2140 );
2141 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2142 let board = cfg.default_config();
2143 assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
2144 assert!(
2146 !cfg.default_config()
2147 .category_map
2148 .values()
2149 .any(|v| v.contains("state")),
2150 "a category is a position, never a derived state"
2151 );
2152 }
2153
2154 #[test]
2155 fn legacy_file_roundtrips_byte_identical() {
2156 let tmp = TempDir::new().unwrap();
2157 let legacy = r#"issues_dir = "docs/issues"
2161
2162[kanban.default]
2163columns = [
2164 "todo",
2165 "in progress",
2166 "in review",
2167 "done",
2168]
2169done_column = "done"
2170hidden_columns = []
2171show_mismatch = false
2172wrap_titles = true
2173title_lines = 3
2174order_by = "created_at_desc"
2175
2176[export]
2177"#;
2178 let path = write_toml(tmp.path(), legacy);
2179 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2180 assert_eq!(cfg.planning, PlanningConfig::default());
2181 assert!(cfg.teams.is_empty());
2182 assert!(cfg.cycles.is_empty());
2183 assert_eq!(cfg.milestones_dir, None);
2184
2185 cfg.save(&path).unwrap();
2186 let on_disk = std::fs::read_to_string(&path).unwrap();
2187 assert_eq!(
2188 on_disk, legacy,
2189 "legacy tix.toml must save byte-identical (NFR-003)"
2190 );
2191 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2192 assert_eq!(cfg, reloaded);
2193 }
2194
2195 #[test]
2196 fn legacy_file_in_any_layout_never_gains_planning_keys() {
2197 let tmp = TempDir::new().unwrap();
2198 let legacy = r#"issues_dir = "docs/issues"
2202[kanban.default]
2203columns = ["todo", "in progress", "in review", "done"]
2204done_column = "done"
2205"#;
2206 let path = write_toml(tmp.path(), legacy);
2207 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2208 cfg.save(&path).unwrap();
2209 let on_disk = std::fs::read_to_string(&path).unwrap();
2210 assert!(!on_disk.contains("milestones_dir"));
2211 assert!(!on_disk.contains("[planning]"));
2212 assert!(!on_disk.contains("[teams]"));
2213 assert!(!on_disk.contains("[cycles"));
2214 }
2215
2216 #[test]
2217 fn planning_default_section_with_partial_keys_loads() {
2218 let tmp = TempDir::new().unwrap();
2219 let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
2220 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2221 assert_eq!(
2222 cfg.planning.cycle_number, 1,
2223 "counter defaults when omitted"
2224 );
2225 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
2226 assert_eq!(cfg.planning.default_milestone, None);
2227 assert_eq!(cfg.planning.default_team, None);
2228 }
2229
2230 #[test]
2231 fn planning_rejects_unknown_keys() {
2232 let tmp = TempDir::new().unwrap();
2233 let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
2234 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2235 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2236 assert!(err.to_string().contains("counter"));
2237 }
2238
2239 #[test]
2240 fn planning_rejects_zero_cycle_number() {
2241 let tmp = TempDir::new().unwrap();
2242 let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
2243 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2244 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2245 assert!(err.to_string().contains("cycle_number"));
2246 }
2247
2248 #[test]
2249 fn cycle_dates_are_validated_and_compared() {
2250 let tmp = TempDir::new().unwrap();
2251 let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
2253 let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
2254 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2255 assert!(err.to_string().contains("start_date"));
2256
2257 let ordered = write_toml(
2259 tmp.path(),
2260 "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
2261 );
2262 let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
2263 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2264 assert!(err.to_string().contains("due_date"));
2265 assert!(err.to_string().contains("start_date"));
2266 }
2267
2268 #[test]
2269 fn cycles_reject_unknown_keys() {
2270 let tmp = TempDir::new().unwrap();
2271 let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
2272 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2273 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2274 assert!(err.to_string().contains("bogus"));
2275 }
2276
2277 #[test]
2278 fn cycle_without_dates_loads_fine() {
2279 let tmp = TempDir::new().unwrap();
2280 let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
2281 let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
2282 assert_eq!(
2283 cfg.cycles.get("Alpha").unwrap().description.as_deref(),
2284 Some("a")
2285 );
2286 }
2287
2288 #[test]
2289 fn teams_validate_members_nonempty_and_unique() {
2290 let tmp = TempDir::new().unwrap();
2291 let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
2292 let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
2293 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2294 assert!(err.to_string().contains("members"));
2295
2296 let dup = write_toml(
2297 tmp.path(),
2298 "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
2299 );
2300 let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
2301 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2302 assert!(err.to_string().contains("unique"));
2303 }
2304
2305 #[test]
2306 fn empty_teams_table_is_disabled_not_error() {
2307 let tmp = TempDir::new().unwrap();
2308 let path = write_toml(tmp.path(), "[teams]\n");
2309 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2310 assert!(cfg.teams.is_empty());
2311 cfg.save(&path).unwrap();
2313 let on_disk = std::fs::read_to_string(&path).unwrap();
2314 assert!(!on_disk.contains("[teams]"));
2315 }
2316
2317 #[test]
2318 fn blank_milestones_dir_rejected() {
2319 let tmp = TempDir::new().unwrap();
2320 let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
2321 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2322 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2323 assert!(err.to_string().contains("milestones_dir"));
2324 }
2325
2326 #[test]
2327 fn sparse_keybindings_roundtrip_without_defaults() {
2328 let tmp = TempDir::new().unwrap();
2329 let path = write_toml(
2330 tmp.path(),
2331 "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
2332 );
2333 let config = Config::load_with_warning(&path, |_| {}).unwrap();
2334 config.save(&path).unwrap();
2335 let contents = std::fs::read_to_string(&path).unwrap();
2336 assert!(contents.contains("[keybindings.board]"));
2337 assert!(!contents.contains("move_left"));
2338 assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
2339 }
2340
2341 #[test]
2342 fn omitted_keybindings_retain_effective_defaults() {
2343 let tmp = TempDir::new().unwrap();
2344 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
2345 let config = Config::load_with_warning(&path, |_| {}).unwrap();
2346 let bindings = config.effective_keybindings().unwrap();
2347 assert_eq!(
2348 bindings.label(
2349 crate::keybindings::InputContext::Board,
2350 crate::keybindings::TuiAction::NewIssue
2351 ),
2352 "n"
2353 );
2354 }
2355
2356 #[test]
2357 fn keybindings_reject_unknown_fields_and_empty_actions() {
2358 let tmp = TempDir::new().unwrap();
2359 let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
2360 assert!(
2361 Config::load_with_warning(&unknown, |_| {})
2362 .unwrap_err()
2363 .to_string()
2364 .contains("bogus")
2365 );
2366 let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
2367 assert!(
2368 Config::load_with_warning(&empty, |_| {})
2369 .unwrap_err()
2370 .to_string()
2371 .contains("new_issue")
2372 );
2373 }
2374}