1use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16
17use crate::board_store::BoardStore;
18use crate::cycle_store::{CycleStore, is_implicit_default};
19use crate::error::{Result, TixError};
20use crate::keybindings::{EffectiveBindings, KeyBindings};
21use crate::records::RecordStore;
22
23#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
29#[serde(default, deny_unknown_fields)]
30pub struct RemoteConfig {
31 pub token: Option<String>,
34 pub base_url: Option<String>,
36 pub project: Option<String>,
38 pub team: Option<String>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45pub struct ExportConfig {
46 pub gitlab: Option<RemoteConfig>,
48 pub linear: Option<RemoteConfig>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
59#[serde(default, deny_unknown_fields)]
60pub struct Team {
61 pub members: Vec<String>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
69#[serde(default, deny_unknown_fields)]
70pub struct PlanningCycle {
71 pub description: Option<String>,
73 pub start_date: Option<String>,
75 pub due_date: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(default, deny_unknown_fields)]
88pub struct PlanningConfig {
89 pub cycle_number: u32,
91 pub default_cycle: Option<String>,
93 pub default_milestone: Option<String>,
95 pub default_team: Option<String>,
97}
98
99impl PlanningConfig {
100 pub fn is_default(&self) -> bool {
104 self == &Self::default()
105 }
106}
107
108impl Default for PlanningConfig {
109 fn default() -> Self {
110 Self {
111 cycle_number: 1,
112 default_cycle: None,
113 default_milestone: None,
114 default_team: None,
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
139#[serde(default, deny_unknown_fields)]
140pub struct IssueTemplate {
141 pub title: Option<String>,
144 pub body: Option<String>,
146 pub labels: Vec<String>,
148 pub status: Option<String>,
150 pub priority: Option<String>,
152 pub assignee: Option<String>,
154 pub estimate: Option<String>,
156 pub reviewer: Option<String>,
158 pub cycles: Vec<String>,
160 pub milestones: Vec<String>,
162 pub start_date: Option<String>,
164 pub due_date: Option<String>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
175#[serde(default, deny_unknown_fields)]
176pub struct StateConfig {
177 #[serde(skip_serializing)]
181 pub next_number: u32,
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
189pub enum OrderBy {
190 #[default]
192 #[serde(rename = "created_at_desc")]
193 CreatedAtDesc,
194 #[serde(rename = "created_at_asc")]
196 CreatedAtAsc,
197 #[serde(rename = "none")]
199 None,
200}
201
202impl OrderBy {
203 pub fn as_str(self) -> &'static str {
205 match self {
206 Self::CreatedAtDesc => "created_at_desc",
207 Self::CreatedAtAsc => "created_at_asc",
208 Self::None => "none",
209 }
210 }
211
212 pub fn next(self) -> Self {
214 match self {
215 Self::CreatedAtDesc => Self::CreatedAtAsc,
216 Self::CreatedAtAsc => Self::None,
217 Self::None => Self::CreatedAtDesc,
218 }
219 }
220}
221
222pub const CATEGORY_KEYS: &[&str] = &[
233 "backlog",
234 "unstarted",
235 "active",
236 "blocked",
237 "completed",
238 "canceled",
239];
240
241pub const TERMINAL_CATEGORIES: &[&str] = &["completed", "canceled"];
243
244pub const COMPLETE_CATEGORY: &str = "completed";
247
248pub const SAFE_DEFAULT_CATEGORY: &str = "unstarted";
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(default, deny_unknown_fields)]
262pub struct CategoryConfig {
263 pub order: Vec<String>,
270 pub default: String,
273}
274
275impl Default for CategoryConfig {
276 fn default() -> Self {
277 Self {
278 order: CATEGORY_KEYS.iter().map(|k| (*k).to_string()).collect(),
279 default: SAFE_DEFAULT_CATEGORY.to_string(),
280 }
281 }
282}
283
284impl CategoryConfig {
285 pub fn is_default(&self) -> bool {
287 self == &Self::default()
288 }
289
290 pub fn contains(&self, key: &str) -> bool {
292 self.order.iter().any(|k| k == key)
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298#[serde(default, deny_unknown_fields)]
299pub struct TransitionConfig {
300 pub terminal_locked: bool,
306}
307
308impl Default for TransitionConfig {
309 fn default() -> Self {
310 Self {
311 terminal_locked: true,
312 }
313 }
314}
315
316impl TransitionConfig {
317 pub fn is_default(&self) -> bool {
318 self == &Self::default()
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(default, deny_unknown_fields)]
329pub struct BoardConfiguration {
330 pub columns: Vec<String>,
332 pub done_column: Option<String>,
341 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
350 pub category_map: IndexMap<String, String>,
351 pub hidden_columns: Vec<String>,
354 pub show_mismatch: bool,
357 pub wrap_titles: bool,
359 pub title_lines: usize,
363 #[serde(skip_serializing_if = "Vec::is_empty")]
367 pub card_order: Vec<String>,
368 pub order_by: OrderBy,
371}
372
373impl Default for BoardConfiguration {
374 fn default() -> Self {
375 Self {
376 columns: ["todo", "in progress", "in review", "done"]
377 .into_iter()
378 .map(String::from)
379 .collect(),
380 done_column: Some("done".to_string()),
381 category_map: IndexMap::new(),
382 hidden_columns: Vec::new(),
383 show_mismatch: false,
384 wrap_titles: true,
385 title_lines: 3,
386 card_order: Vec::new(),
387 order_by: OrderBy::default(),
388 }
389 }
390}
391
392pub fn starter_board() -> BoardConfiguration {
409 BoardConfiguration {
410 columns: [
411 "triage",
412 "todo",
413 "in progress",
414 "in review",
415 "done",
416 "wontfix",
417 ]
418 .into_iter()
419 .map(String::from)
420 .collect(),
421 category_map: IndexMap::from([
422 ("done".to_string(), "completed".to_string()),
423 ("wontfix".to_string(), "canceled".to_string()),
424 ]),
425 ..BoardConfiguration::default()
426 }
427}
428
429impl BoardConfiguration {
430 pub fn category_for<'c>(&'c self, column: &str, categories: &'c CategoryConfig) -> &'c str {
450 if let Some(mapped) = self.category_map.get(column) {
451 return mapped;
452 }
453 if self.category_map.is_empty() && self.effective_done_column() == Some(column) {
454 return COMPLETE_CATEGORY;
455 }
456 &categories.default
457 }
458
459 pub fn effective_done_column(&self) -> Option<&str> {
462 if let Some(done) = self.done_column.as_deref() {
463 return Some(done);
464 }
465 if self.columns.iter().any(|c| c == "done") {
466 Some("done")
467 } else {
468 self.columns.last().map(String::as_str)
469 }
470 }
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
484#[serde(default, transparent)]
485pub struct KanbanSection {
486 pub configurations: IndexMap<String, BoardConfiguration>,
488}
489
490impl KanbanSection {
491 pub fn is_empty(&self) -> bool {
495 self.configurations.is_empty()
496 }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(default, deny_unknown_fields)]
502pub struct Config {
503 #[serde(default, skip_serializing_if = "KanbanSection::is_empty")]
505 pub kanban: KanbanSection,
506 pub issues_dir: PathBuf,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub milestones_dir: Option<PathBuf>,
512 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
514 pub teams: IndexMap<String, Team>,
515 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
517 pub cycles: IndexMap<String, PlanningCycle>,
518 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
523 pub templates: IndexMap<String, IssueTemplate>,
524 #[serde(default, skip_serializing_if = "PlanningConfig::is_default")]
526 pub planning: PlanningConfig,
527 #[serde(default, skip_serializing_if = "CategoryConfig::is_default")]
530 pub categories: CategoryConfig,
531 #[serde(default, skip_serializing_if = "TransitionConfig::is_default")]
533 pub transitions: TransitionConfig,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub triage: Option<crate::triage::TriageConfig>,
543 pub editor: Option<String>,
545 #[serde(skip_serializing_if = "Option::is_none")]
552 pub viewer: Option<String>,
553 pub agent: Option<String>,
557 #[serde(skip_serializing)]
560 pub state: StateConfig,
561 pub export: ExportConfig,
563 #[serde(default, skip_serializing_if = "KeyBindings::is_empty")]
565 pub keybindings: KeyBindings,
566 #[serde(default, skip_serializing_if = "CustomAttributes::is_empty")]
577 pub custom_attributes: CustomAttributes,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
592#[serde(default, deny_unknown_fields)]
593pub struct CustomAttributes {
594 pub keys: Vec<String>,
598}
599
600impl CustomAttributes {
601 pub fn is_empty(&self) -> bool {
603 self.keys.is_empty()
604 }
605}
606
607impl Default for Config {
608 fn default() -> Self {
609 Config {
610 kanban: KanbanSection {
611 configurations: IndexMap::from([(
612 "default".to_string(),
613 BoardConfiguration::default(),
614 )]),
615 },
616 issues_dir: PathBuf::from("docs/issues"),
617 milestones_dir: None,
618 teams: IndexMap::new(),
619 cycles: IndexMap::new(),
620 templates: IndexMap::new(),
621 planning: PlanningConfig::default(),
622 categories: CategoryConfig::default(),
623 transitions: TransitionConfig::default(),
624 triage: None,
631 editor: None,
632 viewer: None,
633 state: StateConfig::default(),
634 export: ExportConfig::default(),
635 agent: None,
636 keybindings: KeyBindings::default(),
637 custom_attributes: CustomAttributes::default(),
638 }
639 }
640}
641
642impl Config {
643 #[must_use]
657 pub fn starter() -> Config {
658 let mut config = Config::default();
659 config
660 .kanban
661 .configurations
662 .insert("default".to_string(), starter_board());
663 config.triage = Some(crate::triage::TriageConfig {
664 entry_status: Some("triage".to_string()),
665 accepted_status: Some("todo".to_string()),
666 declined_status: Some("wontfix".to_string()),
667 deferred_status: None,
668 });
669 config
670 }
671
672 pub(crate) fn validate(&self) -> Result<()> {
676 if self.issues_dir.as_os_str().is_empty() {
677 return Err(TixError::Config("`issues_dir` must not be blank".into()));
678 }
679 if self.milestones_dir().as_os_str().is_empty() {
680 return Err(TixError::Config(
681 "`milestones_dir` must not be blank".into(),
682 ));
683 }
684 if self.viewer.as_deref().is_some_and(|v| v.trim().is_empty()) {
689 return Err(TixError::Config(
690 "`viewer` must not be blank; use `auto` for the default chain".into(),
691 ));
692 }
693 validate_planning(&self.planning)?;
694 validate_categories(&self.categories)?;
695 self.effective_keybindings()?;
696 if self.kanban.configurations.is_empty() {
697 return Err(TixError::Config(
698 "`[kanban]` must define at least one configuration".into(),
699 ));
700 }
701 for (name, board) in &self.kanban.configurations {
702 validate_board(name, board)?;
703 validate_category_map(name, board, &self.categories)?;
704 }
705 for (label, team) in &self.teams {
706 validate_team(label, team)?;
707 }
708 for (name, cycle) in &self.cycles {
709 validate_cycle(name, cycle)?;
710 }
711 for (name, template) in &self.templates {
712 validate_template(name, template)?;
713 }
714 crate::triage::config::validate(self)?;
720 Ok(())
721 }
722
723 pub fn effective_keybindings(&self) -> Result<EffectiveBindings> {
725 KeyBindings::resolve(&self.keybindings)
726 }
727
728 fn restore_declaration_order(&mut self, contents: &str) {
732 crate::config::restore_section_order(&mut self.kanban.configurations, contents, "kanban");
733 crate::config::restore_section_order(&mut self.cycles, contents, "cycles");
734 crate::config::restore_section_order(&mut self.teams, contents, "teams");
735 crate::config::restore_section_order(&mut self.templates, contents, "templates");
736 }
737
738 pub fn custom_attributes(&self) -> Vec<String> {
744 self.custom_attributes
745 .keys
746 .iter()
747 .map(|k| k.trim().to_ascii_lowercase())
748 .collect()
749 }
750
751 pub fn milestones_dir(&self) -> &Path {
754 self.milestones_dir
755 .as_deref()
756 .unwrap_or(Path::new("docs/milestones"))
757 }
758
759 pub fn default_config(&self) -> &BoardConfiguration {
762 self.kanban
763 .configurations
764 .get("default")
765 .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
766 .expect("validate() guarantees at least one configuration")
767 }
768
769 pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
772 self.kanban
773 .configurations
774 .iter()
775 .map(|(name, board)| (name.as_str(), board))
776 }
777
778 pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
784 let contents = std::fs::read_to_string(path)
785 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
786 let mut config: Config = toml::from_str(&contents)
787 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
788 config.restore_declaration_order(&contents);
789 let root = path
793 .parent()
794 .filter(|p| !p.as_os_str().is_empty())
795 .unwrap_or_else(|| Path::new("."));
796 let board_store = BoardStore::new(root);
797 if board_store.exists() {
798 let boards = board_store
799 .list()
800 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
801 if !boards.is_empty() {
802 config.kanban = KanbanSection {
803 configurations: boards.into_iter().collect(),
804 };
805 }
806 }
807 let cycle_store = CycleStore::new(root);
808 if cycle_store.exists() {
809 let cycles = cycle_store
810 .list()
811 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
812 config.cycles = cycles
813 .into_iter()
814 .map(|record| (record.name.clone(), record.into()))
815 .collect();
816 }
817 if config.kanban.configurations.is_empty() {
818 config.kanban = KanbanSection {
819 configurations: IndexMap::from([(
820 "default".to_string(),
821 BoardConfiguration::default(),
822 )]),
823 };
824 }
825 config.validate().map_err(|e| match e {
826 TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
827 other => other,
828 })?;
829 for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
830 .into_iter()
831 .flatten()
832 {
833 if remote.token.is_some() {
834 sink(
835 "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
836 );
837 }
838 }
839 Ok(config)
840 }
841
842 pub fn load(project_root: &Path) -> Result<Config> {
846 let path = project_root.join("tix.toml");
847 if !path.is_file() {
848 return Ok(Config::default());
849 }
850 Self::load_with_warning(&path, |w| eprintln!("{w}"))
851 }
852
853 pub fn project_root(cwd: &Path) -> Option<PathBuf> {
856 let mut dir = Some(cwd.to_path_buf());
857 while let Some(current) = dir {
858 if current.join("tix.toml").is_file() {
859 return Some(current);
860 }
861 dir = current.parent().map(Path::to_path_buf);
862 }
863 None
864 }
865
866 fn sanitized_for_disk(&self) -> Config {
868 let mut copy = self.clone();
869 if let Some(gitlab) = copy.export.gitlab.as_mut() {
870 gitlab.token = None;
871 }
872 if let Some(linear) = copy.export.linear.as_mut() {
873 linear.token = None;
874 }
875 copy
876 }
877
878 pub fn save(&self, path: &Path) -> Result<()> {
884 let dir = match path.parent() {
885 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
886 _ => PathBuf::from("."),
887 };
888 std::fs::create_dir_all(&dir)?;
889
890 let board_store = BoardStore::new(&dir);
894 let board_store_active = board_store.exists();
895 if board_store_active {
896 for (name, board) in &self.kanban.configurations {
897 let filename = format!("{}.md", crate::slug::slugify(name));
898 let body = RecordStore::new(board_store.dir()).body_of(&filename);
899 board_store.save(name, board, body.as_deref())?;
900 }
901 }
902 let cycle_store = CycleStore::new(&dir);
903 let cycle_store_active = cycle_store.exists();
904 if cycle_store_active {
905 for (name, cycle) in &self.cycles {
906 if is_implicit_default(name, cycle) {
907 continue;
908 }
909 let filename = format!("{}.md", crate::slug::slugify(name));
910 let body = RecordStore::new(cycle_store.dir()).body_of(&filename);
911 cycle_store.save(name, cycle, body.as_deref())?;
912 }
913 }
914
915 let mut disk = self.sanitized_for_disk();
916 if board_store_active {
917 disk.kanban = KanbanSection::default();
918 }
919 if cycle_store_active {
920 disk.cycles.clear();
921 }
922 let contents = toml::to_string_pretty(&disk)
923 .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
924
925 let file_name = path
926 .file_name()
927 .and_then(|n| n.to_str())
928 .unwrap_or("tix.toml");
929 let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
930
931 let write_result = (|| -> std::io::Result<()> {
932 let mut f = std::fs::File::create(&tmp)?;
933 use std::io::Write;
934 f.write_all(contents.as_bytes())?;
935 f.sync_all()?;
936 Ok(())
937 })();
938 if let Err(e) = write_result {
939 let _ = std::fs::remove_file(&tmp);
940 return Err(TixError::Io(e));
941 }
942
943 if let Err(e) = std::fs::rename(&tmp, path) {
944 let _ = std::fs::remove_file(&tmp);
945 return Err(TixError::Io(e));
946 }
947 Ok(())
948 }
949}
950
951fn restore_section_order<V: Clone>(map: &mut IndexMap<String, V>, contents: &str, section: &str) {
959 let mut order: Vec<String> = Vec::new();
960 for line in contents.lines() {
961 let line = line.trim();
962 if let Some(rest) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']'))
963 && let Some((head, key)) = rest.split_once('.')
964 && head.trim() == section
965 && !key.trim().contains('.')
966 && let Some(name) = unquote_key(key.trim())
967 && !order.iter().any(|n| n == name)
968 {
969 order.push(name.to_string());
970 }
971 }
972 if order.is_empty() {
973 return;
974 }
975 let mut ordered = IndexMap::new();
976 for name in &order {
977 if let Some(value) = map.shift_remove(name) {
978 ordered.insert(name.clone(), value);
979 }
980 }
981 ordered.extend(map.iter().map(|(n, v)| (n.clone(), (*v).clone())));
982 *map = ordered;
983}
984
985fn unquote_key(key: &str) -> Option<&str> {
988 if let Some(inner) = key.strip_prefix('"').and_then(|k| k.strip_suffix('"')) {
989 Some(inner)
990 } else if let Some(inner) = key.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')) {
991 Some(inner)
992 } else {
993 Some(key)
994 }
995}
996
997fn validate_planning(planning: &PlanningConfig) -> Result<()> {
1000 if planning.cycle_number == 0 {
1001 return Err(TixError::Config(
1002 "`planning.cycle_number` must be at least 1".into(),
1003 ));
1004 }
1005 Ok(())
1006}
1007
1008fn validate_team(label: &str, team: &Team) -> Result<()> {
1011 if label.trim().is_empty() {
1012 return Err(TixError::Config("`teams.<label>` must not be blank".into()));
1013 }
1014 if team.members.is_empty() {
1015 return Err(TixError::Config(format!(
1016 "`teams.{label}.members` must not be empty"
1017 )));
1018 }
1019 let mut seen: HashSet<&str> = HashSet::new();
1020 for member in &team.members {
1021 if !seen.insert(member.as_str()) {
1022 return Err(TixError::Config(format!(
1023 "`teams.{label}.members` must be unique; found duplicate `{member}`"
1024 )));
1025 }
1026 }
1027 Ok(())
1028}
1029
1030fn validate_cycle(name: &str, cycle: &PlanningCycle) -> Result<()> {
1033 if name.trim().is_empty() {
1034 return Err(TixError::Config("`cycles.<name>` must not be blank".into()));
1035 }
1036 if let Some(start) = &cycle.start_date {
1037 if !crate::model::validate_date(start) {
1038 return Err(TixError::Config(format!(
1039 "`cycles.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
1040 )));
1041 }
1042 }
1043 if let Some(due) = &cycle.due_date {
1044 if !crate::model::validate_date(due) {
1045 return Err(TixError::Config(format!(
1046 "`cycles.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
1047 )));
1048 }
1049 if let Some(start) = &cycle.start_date
1050 && due.as_str() < start.as_str()
1051 {
1052 return Err(TixError::Config(format!(
1053 "`cycles.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
1054 )));
1055 }
1056 }
1057 Ok(())
1058}
1059
1060fn validate_template(name: &str, template: &IssueTemplate) -> Result<()> {
1069 if name.trim().is_empty() {
1070 return Err(TixError::Config(
1071 "`templates` must not contain a blank name".into(),
1072 ));
1073 }
1074 for (field, values) in [
1075 ("labels", &template.labels),
1076 ("cycles", &template.cycles),
1077 ("milestones", &template.milestones),
1078 ] {
1079 for value in values {
1080 if value.trim().is_empty() {
1081 return Err(TixError::Config(format!(
1082 "`templates.{name}.{field}` must not contain a blank value"
1083 )));
1084 }
1085 if value.contains(',') {
1086 return Err(TixError::Config(format!(
1087 "`templates.{name}.{field}` value `{value}` must not contain a comma \
1088 — the form joins these with `,`"
1089 )));
1090 }
1091 }
1092 }
1093 if let Some(start) = &template.start_date
1094 && !crate::model::validate_date(start)
1095 {
1096 return Err(TixError::Config(format!(
1097 "`templates.{name}.start_date` (`{start}`) must be a `YYYY-MM-DD` calendar date"
1098 )));
1099 }
1100 if let Some(due) = &template.due_date {
1101 if !crate::model::validate_date(due) {
1102 return Err(TixError::Config(format!(
1103 "`templates.{name}.due_date` (`{due}`) must be a `YYYY-MM-DD` calendar date"
1104 )));
1105 }
1106 if let Some(start) = &template.start_date
1107 && due.as_str() < start.as_str()
1108 {
1109 return Err(TixError::Config(format!(
1110 "`templates.{name}.due_date` (`{due}`) must be `>= start_date` (`{start}`)"
1111 )));
1112 }
1113 }
1114 Ok(())
1115}
1116
1117fn validate_categories(categories: &CategoryConfig) -> Result<()> {
1125 if categories.order.is_empty() {
1126 return Err(TixError::Config(
1127 "`categories.order` must not be empty — it is the global vocabulary".into(),
1128 ));
1129 }
1130 for key in &categories.order {
1131 if !CATEGORY_KEYS.contains(&key.as_str()) {
1132 return Err(TixError::Config(format!(
1133 "unknown_category: `categories.order` names `{key}`, which is not in the \
1134 global vocabulary ({}). The vocabulary is fixed and global (C-002); a \
1135 board maps columns into it and does not define it.",
1136 CATEGORY_KEYS.join(", ")
1137 )));
1138 }
1139 }
1140 if !categories.contains(&categories.default) {
1141 return Err(TixError::Config(format!(
1142 "unknown_category: `categories.default` is `{}`, which is not in \
1143 `categories.order`",
1144 categories.default
1145 )));
1146 }
1147 if categories.default == COMPLETE_CATEGORY {
1148 return Err(TixError::Config(format!(
1149 "`categories.default` may not be `{COMPLETE_CATEGORY}`. Every column \
1150 without an explicit mapping would resolve to complete, which changes \
1151 completion meaning by accident. Map the completing column explicitly \
1152 instead."
1153 )));
1154 }
1155 Ok(())
1156}
1157
1158fn validate_category_map(
1165 name: &str,
1166 board: &BoardConfiguration,
1167 categories: &CategoryConfig,
1168) -> Result<()> {
1169 for (column, key) in &board.category_map {
1170 if !board.columns.iter().any(|c| c == column) {
1171 return Err(TixError::Config(format!(
1172 "mapping_for_unknown_column: `kanban.{name}.category_map` maps \
1173 `{column}`, which is not one of the board's columns ({})",
1174 board.columns.join(", ")
1175 )));
1176 }
1177 if !categories.contains(key) {
1178 return Err(TixError::Config(format!(
1179 "unknown_category: `kanban.{name}.category_map` maps `{column}` to \
1180 `{key}`, which is not in the vocabulary ({})",
1181 categories.order.join(", ")
1182 )));
1183 }
1184 }
1185 if board.category_map.is_empty() {
1189 return Ok(());
1190 }
1191 let completers: Vec<&String> = board
1192 .category_map
1193 .iter()
1194 .filter(|(_, key)| key.as_str() == COMPLETE_CATEGORY)
1195 .map(|(column, _)| column)
1196 .collect();
1197 match completers.as_slice() {
1198 [] => Err(TixError::Config(format!(
1199 "missing_completion_category: `kanban.{name}.category_map` maps no column \
1200 to `{COMPLETE_CATEGORY}` ({} mapped). Without one, no card on this board \
1201 is ever complete — map exactly one column to it.",
1202 board.category_map.len()
1203 ))),
1204 [_only] => Ok(()),
1205 many => Err(TixError::Config(format!(
1206 "`kanban.{name}.category_map` maps {} columns to `{COMPLETE_CATEGORY}` \
1207 ({}); exactly one column may complete work",
1208 many.len(),
1209 many.iter()
1210 .map(|c| c.as_str())
1211 .collect::<Vec<_>>()
1212 .join(", ")
1213 ))),
1214 }
1215}
1216
1217fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
1218 if board.columns.is_empty() {
1219 return Err(TixError::Config(format!(
1220 "`kanban.{name}.columns` must not be empty"
1221 )));
1222 }
1223 if board.columns.iter().any(|c| c.trim().is_empty()) {
1224 return Err(TixError::Config(format!(
1225 "`kanban.{name}.columns` must not contain blank names"
1226 )));
1227 }
1228 let mut seen: HashSet<&str> = HashSet::new();
1229 for column in &board.columns {
1230 if !seen.insert(column.as_str()) {
1231 return Err(TixError::Config(format!(
1232 "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
1233 )));
1234 }
1235 }
1236 if let Some(done) = &board.done_column
1237 && !board.columns.contains(done)
1238 {
1239 return Err(TixError::Config(format!(
1240 "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
1241 )));
1242 }
1243 for hidden in &board.hidden_columns {
1244 if !board.columns.contains(hidden) {
1245 return Err(TixError::Config(format!(
1246 "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
1247 )));
1248 }
1249 }
1250 if board.title_lines == 0 {
1251 return Err(TixError::Config(format!(
1252 "`kanban.{name}.title_lines` must be at least 1"
1253 )));
1254 }
1255 Ok(())
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use super::*;
1261 use std::path::PathBuf;
1262 use tempfile::TempDir;
1263
1264 fn write_toml(dir: &Path, contents: &str) -> PathBuf {
1265 let path = dir.join("tix.toml");
1266 std::fs::write(&path, contents).unwrap();
1267 path
1268 }
1269
1270 #[test]
1271 fn starter_config_carries_a_working_inbox() {
1272 let config = Config::starter();
1273 config
1274 .validate()
1275 .expect("the config `tix init` writes must load");
1276
1277 let board = config.default_config();
1278 for column in [
1279 "triage",
1280 "todo",
1281 "in progress",
1282 "in review",
1283 "done",
1284 "wontfix",
1285 ] {
1286 assert!(
1287 board.columns.iter().any(|c| c == column),
1288 "the starter board is missing `{column}`: {:?}",
1289 board.columns
1290 );
1291 }
1292
1293 let triage = config
1294 .triage
1295 .as_ref()
1296 .expect("the starter config has an inbox");
1297 assert_eq!(triage.entry_status.as_deref(), Some("triage"));
1298 assert_eq!(triage.accepted_status.as_deref(), Some("todo"));
1299 assert_eq!(triage.declined_status.as_deref(), Some("wontfix"));
1300 assert_eq!(triage.effective_deferred_status(), Some("triage"));
1303
1304 assert_eq!(
1306 board.category_map.get("wontfix").map(String::as_str),
1307 Some("canceled")
1308 );
1309 assert_eq!(
1310 board.category_map.get("done").map(String::as_str),
1311 Some("completed")
1312 );
1313 for column in ["triage", "todo", "in progress", "in review"] {
1319 assert!(
1320 !board.category_map.contains_key(column),
1321 "`{column}` must stay unmapped, or adjacency will refuse work moving back"
1322 );
1323 }
1324 }
1325
1326 #[test]
1327 fn defaults_match_contract() {
1328 let c = Config::default();
1329 let board = c.default_config();
1330 assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
1331 assert_eq!(board.done_column.as_deref(), Some("done"));
1332 assert_eq!(board.hidden_columns, Vec::<String>::new());
1333 assert!(!board.show_mismatch);
1334 assert!(board.wrap_titles);
1335 assert_eq!(board.title_lines, 3);
1336 assert!(board.card_order.is_empty());
1337 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
1338 assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
1339 assert_eq!(c.editor, None);
1340 assert_eq!(c.state.next_number, 0);
1341 assert_eq!(c.export.gitlab, None);
1342 assert_eq!(c.export.linear, None);
1343 }
1344
1345 #[test]
1346 fn load_returns_defaults_when_file_absent() {
1347 let tmp = TempDir::new().unwrap();
1348 let cfg = Config::load(tmp.path()).unwrap();
1349 assert_eq!(cfg.state.next_number, 0);
1350 assert_eq!(cfg.default_config().columns.len(), 4);
1351 }
1352
1353 #[test]
1354 fn load_synthesizes_default_config_when_kanban_section_missing() {
1355 let tmp = TempDir::new().unwrap();
1356 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1357 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1358 assert_eq!(cfg.kanban.configurations.len(), 1);
1359 assert!(cfg.kanban.configurations.contains_key("default"));
1360 assert_eq!(cfg.default_config().columns.len(), 4);
1361 }
1362
1363 #[test]
1364 fn load_parses_state_and_keeps_editor_and_remote_targets() {
1365 let tmp = TempDir::new().unwrap();
1366 let path = write_toml(
1367 tmp.path(),
1368 r#"issues_dir = "tickets"
1369editor = "nvim"
1370
1371[kanban.default]
1372columns = ["todo", "done"]
1373done_column = "done"
1374
1375[state]
1376next_number = 17
1377
1378[export.gitlab]
1379project = "acme/tix"
1380"#,
1381 );
1382 let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
1383 assert_eq!(
1384 cfg.kanban.configurations.get("default").unwrap().columns,
1385 ["todo", "done"]
1386 );
1387 assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
1388 assert_eq!(cfg.editor.as_deref(), Some("nvim"));
1389 assert_eq!(cfg.state.next_number, 17);
1390 assert_eq!(
1391 cfg.export.gitlab.unwrap().project.as_deref(),
1392 Some("acme/tix")
1393 );
1394 assert_eq!(cfg.export.linear, None);
1395 }
1396
1397 #[test]
1398 fn viewer_is_parsed_and_blank_is_rejected() {
1399 let tmp = TempDir::new().unwrap();
1400 let path = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \"less\"\n");
1401 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1402 assert_eq!(cfg.viewer.as_deref(), Some("less"));
1403
1404 let blank = write_toml(tmp.path(), "issues_dir = \"tickets\"\nviewer = \" \"\n");
1405 let err = Config::load_with_warning(&blank, |_| {}).unwrap_err();
1406 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1407 assert!(err.to_string().contains("viewer"), "{err}");
1408 }
1409
1410 #[test]
1411 fn viewer_is_omitted_from_the_file_when_unset() {
1412 let dir = TempDir::new().unwrap();
1413 let path = dir.path().join("tix.toml");
1414 let mut config = Config::default();
1415 config.save(&path).unwrap();
1416 assert!(
1417 !std::fs::read_to_string(&path).unwrap().contains("viewer"),
1418 "an unset viewer writes no key"
1419 );
1420
1421 config.viewer = Some("less".to_string());
1422 config.save(&path).unwrap();
1423 assert!(
1424 std::fs::read_to_string(&path)
1425 .unwrap()
1426 .contains(r#"viewer = "less""#),
1427 "a set viewer round-trips"
1428 );
1429 }
1430
1431 #[test]
1432 fn empty_columns_rejected_as_config_error() {
1433 let tmp = TempDir::new().unwrap();
1434 let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
1435 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1436 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1437 assert!(err.to_string().contains("kanban.scratch.columns"));
1438 }
1439
1440 #[test]
1441 fn duplicate_columns_rejected_as_config_error() {
1442 let tmp = TempDir::new().unwrap();
1443 let path = write_toml(
1444 tmp.path(),
1445 "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
1446 );
1447 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1448 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1449 assert!(err.to_string().contains("duplicate"));
1450 }
1451
1452 #[test]
1453 fn blank_column_name_rejected() {
1454 let tmp = TempDir::new().unwrap();
1455 let path = write_toml(
1456 tmp.path(),
1457 "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
1458 );
1459 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1460 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1461 }
1462
1463 #[test]
1464 fn done_column_must_be_in_columns() {
1465 let tmp = TempDir::new().unwrap();
1466 let path = write_toml(
1467 tmp.path(),
1468 "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
1469 );
1470 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1471 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1472 assert!(err.to_string().contains("kanban.scratch.done_column"));
1473 }
1474
1475 #[test]
1476 fn hidden_columns_must_be_subset_of_columns() {
1477 let tmp = TempDir::new().unwrap();
1478 let path = write_toml(
1479 tmp.path(),
1480 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
1481 );
1482 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1483 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1484 assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
1485 }
1486
1487 #[test]
1488 fn hidden_done_column_loads_fine() {
1489 let tmp = TempDir::new().unwrap();
1490 let path = write_toml(
1491 tmp.path(),
1492 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
1493 );
1494 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1495 let board = cfg.kanban.configurations.get("scratch").unwrap();
1496 assert_eq!(board.done_column.as_deref(), Some("done"));
1497 assert_eq!(board.hidden_columns, ["done"]);
1498 }
1499
1500 #[test]
1501 fn legacy_top_level_keys_rejected() {
1502 let tmp = TempDir::new().unwrap();
1503 let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
1504 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1505 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1506 assert!(err.to_string().contains("tix.toml"));
1507 assert!(err.to_string().contains("columns"));
1508 }
1509
1510 #[test]
1511 fn token_warning_fires_once_per_remote_with_token() {
1512 let tmp = TempDir::new().unwrap();
1513 let path = write_toml(
1514 tmp.path(),
1515 r#"[export.gitlab]
1516project = "a/b"
1517token = "gl-secret"
1518
1519[export.linear]
1520team = "Eng"
1521token = "lin-secret"
1522"#,
1523 );
1524 let mut warnings: Vec<String> = Vec::new();
1525 let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
1526 assert_eq!(warnings.len(), 2);
1527 assert!(warnings[0].contains("GITLAB_TOKEN"));
1528 assert!(warnings[1].contains("LINEAR_TOKEN"));
1529 for w in &warnings {
1531 assert!(!w.contains("secret"));
1532 }
1533 assert_eq!(
1534 cfg.export.gitlab.unwrap().token.as_deref(),
1535 Some("gl-secret")
1536 );
1537 }
1538
1539 #[test]
1540 fn no_token_means_no_warning() {
1541 let tmp = TempDir::new().unwrap();
1542 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1543 let mut fired = false;
1544 Config::load_with_warning(&path, |_| fired = true).unwrap();
1545 assert!(!fired);
1546 }
1547
1548 #[test]
1549 fn save_never_writes_the_state_section() {
1550 let tmp = TempDir::new().unwrap();
1551 let path = tmp.path().join("tix.toml");
1552 let mut cfg = Config::default();
1555 cfg.state.next_number = 42;
1556 cfg.save(&path).unwrap();
1557
1558 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1559 assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
1560 assert_eq!(
1561 reloaded.default_config().columns,
1562 cfg.default_config().columns
1563 );
1564
1565 let on_disk = std::fs::read_to_string(&path).unwrap();
1566 assert!(
1567 !on_disk.contains("next_number"),
1568 "next_number leaked into tix.toml:\n{on_disk}"
1569 );
1570 assert!(
1571 !on_disk.contains("[state]"),
1572 "state section written:\n{on_disk}"
1573 );
1574 }
1575
1576 #[test]
1577 fn multi_config_roundtrip_preserves_declaration_order() {
1578 let tmp = TempDir::new().unwrap();
1579 let path = write_toml(
1580 tmp.path(),
1581 r#"issues_dir = "docs/issues"
1582
1583[kanban.release]
1584columns = ["planned", "in progress", "done"]
1585done_column = "done"
1586show_mismatch = true
1587
1588[kanban.personal]
1589columns = ["todo", "done"]
1590done_column = "done"
1591hidden_columns = ["done"]
1592"#,
1593 );
1594 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1595 let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
1596 assert_eq!(names, ["release", "personal"]);
1597
1598 assert_eq!(
1600 cfg.default_config().columns,
1601 ["planned", "in progress", "done"]
1602 );
1603
1604 cfg.save(&path).unwrap();
1605 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1606 assert_eq!(
1607 cfg, reloaded,
1608 "order and values survive save/load round trip"
1609 );
1610 let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
1611 assert_eq!(names2, ["release", "personal"]);
1612 }
1613
1614 #[test]
1615 fn save_never_serializes_tokens_back_out() {
1616 let tmp = TempDir::new().unwrap();
1617 let path = tmp.path().join("tix.toml");
1618 let mut cfg = Config::default();
1619 cfg.export.gitlab = Some(RemoteConfig {
1620 token: Some("secret".into()),
1621 project: Some("a/b".into()),
1622 ..RemoteConfig::default()
1623 });
1624 cfg.save(&path).unwrap();
1625
1626 let on_disk = std::fs::read_to_string(&path).unwrap();
1627 assert!(
1628 !on_disk.contains("secret"),
1629 "token leaked to disk:\n{on_disk}"
1630 );
1631 assert!(
1632 on_disk.contains("a/b"),
1633 "non-secret fields survive:\n{on_disk}"
1634 );
1635
1636 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1637 assert_eq!(reloaded.export.gitlab.unwrap().token, None);
1638 }
1639
1640 #[test]
1641 fn save_is_atomic_and_leaves_no_temp_file() {
1642 let tmp = TempDir::new().unwrap();
1643 let path = tmp.path().join("tix.toml");
1644 Config::default().save(&path).unwrap();
1645 let entries: Vec<_> = std::fs::read_dir(tmp.path())
1646 .unwrap()
1647 .filter_map(|e| e.ok())
1648 .collect();
1649 assert_eq!(entries.len(), 1);
1650 assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
1651 }
1652
1653 #[test]
1654 fn default_loadwrites_are_idempotent() {
1655 let tmp = TempDir::new().unwrap();
1656 let path = tmp.path().join("tix.toml");
1657 let cfg = Config::default();
1658 cfg.save(&path).unwrap();
1659 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1660 assert_eq!(cfg, reloaded);
1661 }
1662
1663 #[test]
1664 fn effective_done_column_falls_back_to_last() {
1665 let board = BoardConfiguration {
1666 columns: vec!["todo".into(), "later".into()],
1667 done_column: None,
1668 ..Default::default()
1669 };
1670 assert_eq!(board.effective_done_column(), Some("later"));
1671
1672 let with_done = BoardConfiguration {
1673 columns: vec!["todo".into(), "done".into(), "later".into()],
1674 done_column: None,
1675 ..Default::default()
1676 };
1677 assert_eq!(with_done.effective_done_column(), Some("done"));
1678 }
1679
1680 #[test]
1681 fn project_root_walks_up_the_tree() {
1682 let tmp = TempDir::new().unwrap();
1683 let nested = tmp.path().join("a").join("b");
1684 std::fs::create_dir_all(&nested).unwrap();
1685 std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
1686
1687 assert_eq!(
1688 Config::project_root(&nested),
1689 Some(tmp.path().to_path_buf())
1690 );
1691 assert_eq!(
1692 Config::project_root(tmp.path()),
1693 Some(tmp.path().to_path_buf())
1694 );
1695 }
1696
1697 #[test]
1698 fn project_root_is_none_when_no_toml_exists() {
1699 let tmp = TempDir::new().unwrap();
1700 assert_eq!(Config::project_root(tmp.path()), None);
1701 }
1702
1703 #[test]
1704 fn order_by_parses_all_names_and_defaults_to_desc() {
1705 let tmp = TempDir::new().unwrap();
1706 for name in ["created_at_desc", "created_at_asc", "none"] {
1707 let path = write_toml(
1708 tmp.path(),
1709 &format!(
1710 "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
1711 ),
1712 );
1713 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1714 let board = cfg.kanban.configurations.get("x").unwrap();
1715 assert_eq!(board.order_by.as_str(), name);
1716 }
1717 assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
1718 assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
1719 assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
1720 assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
1721 }
1722
1723 #[test]
1724 fn zero_title_lines_rejected_as_config_error() {
1725 let tmp = TempDir::new().unwrap();
1726 let path = write_toml(
1727 tmp.path(),
1728 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
1729 );
1730 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1731 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1732 assert!(
1733 err.to_string().contains("kanban.scratch.title_lines"),
1734 "error names the offending field: {err}"
1735 );
1736 }
1737
1738 #[test]
1739 fn unknown_order_by_rejected_as_config_error() {
1740 let tmp = TempDir::new().unwrap();
1741 let path = write_toml(
1742 tmp.path(),
1743 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
1744 );
1745 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
1746 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
1747 }
1748
1749 #[test]
1750 fn board_without_display_keys_loads_default_display_settings() {
1751 let tmp = TempDir::new().unwrap();
1752 let path = write_toml(
1753 tmp.path(),
1754 "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
1755 );
1756 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1757 let board = cfg.default_config();
1758 assert!(board.wrap_titles);
1759 assert_eq!(board.title_lines, 3);
1760 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
1761 }
1762
1763 #[test]
1764 fn display_settings_roundtrip_through_save() {
1765 let tmp = TempDir::new().unwrap();
1766 let path = tmp.path().join("tix.toml");
1767 let mut cfg = Config::default();
1768 {
1769 let board = cfg.kanban.configurations.get_mut("default").unwrap();
1770 board.wrap_titles = false;
1771 board.title_lines = 5;
1772 board.order_by = OrderBy::None;
1773 }
1774 cfg.save(&path).unwrap();
1775 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1776 assert_eq!(
1777 reloaded, cfg,
1778 "display settings survive save/load round trip"
1779 );
1780 let on_disk = std::fs::read_to_string(&path).unwrap();
1781 for key in ["wrap_titles", "title_lines", "order_by"] {
1782 assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
1783 }
1784 }
1785
1786 #[test]
1787 fn manual_card_order_roundtrips_through_save() {
1788 let tmp = TempDir::new().unwrap();
1789 let path = tmp.path().join("tix.toml");
1790 let mut cfg = Config::default();
1791 {
1792 let board = cfg.kanban.configurations.get_mut("default").unwrap();
1793 board.card_order = vec!["a.md".into(), "b.md".into()];
1794 }
1795 cfg.save(&path).unwrap();
1796 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1797 assert_eq!(
1798 reloaded
1799 .kanban
1800 .configurations
1801 .get("default")
1802 .unwrap()
1803 .card_order,
1804 vec!["a.md".to_string(), "b.md".to_string()]
1805 );
1806 let on_disk = std::fs::read_to_string(&path).unwrap();
1807 assert!(
1808 on_disk.contains("card_order"),
1809 "missing card_order in:\n{on_disk}"
1810 );
1811 }
1812
1813 #[test]
1814 fn planning_defaults_match_contract() {
1815 let c = Config::default();
1816 assert_eq!(c.planning.cycle_number, 1);
1817 assert_eq!(c.planning.default_cycle, None);
1818 assert_eq!(c.planning.default_milestone, None);
1819 assert_eq!(c.planning.default_team, None);
1820 assert!(c.teams.is_empty());
1821 assert!(c.cycles.is_empty());
1822 assert_eq!(c.milestones_dir, None);
1823 assert_eq!(c.milestones_dir(), Path::new("docs/milestones"));
1824 }
1825
1826 #[test]
1827 fn planning_sections_roundtrip_and_preserve_declaration_order() {
1828 let tmp = TempDir::new().unwrap();
1829 let path = write_toml(
1830 tmp.path(),
1831 r#"issues_dir = "docs/issues"
1832milestones_dir = "docs/milestones"
1833
1834[planning]
1835cycle_number = 1
1836default_cycle = "Cycle 1"
1837default_milestone = "Backlog"
1838default_team = "default"
1839
1840[cycles."Cycle 1"]
1841
1842[cycles."Cycle 2"]
1843start_date = "2026-09-21"
1844due_date = "2026-09-30"
1845
1846[teams.default]
1847members = ["Herb Mason <herb@example.com>", "@alice"]
1848
1849[teams.platform]
1850members = ["bob"]
1851"#,
1852 );
1853 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1854 assert_eq!(cfg.planning.cycle_number, 1);
1855 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
1856 assert_eq!(cfg.planning.default_milestone.as_deref(), Some("Backlog"));
1857 assert_eq!(cfg.planning.default_team.as_deref(), Some("default"));
1858 assert_eq!(cfg.milestones_dir(), Path::new("docs/milestones"));
1859
1860 let cycles: Vec<&str> = cfg.cycles.keys().map(String::as_str).collect();
1862 assert_eq!(cycles, ["Cycle 1", "Cycle 2"]);
1863 let cycle2 = cfg.cycles.get("Cycle 2").unwrap();
1864 assert_eq!(cycle2.start_date.as_deref(), Some("2026-09-21"));
1865 assert_eq!(cycle2.due_date.as_deref(), Some("2026-09-30"));
1866 assert_eq!(cycle2.description, None);
1867
1868 let teams: Vec<&str> = cfg.teams.keys().map(String::as_str).collect();
1869 assert_eq!(teams, ["default", "platform"]);
1870 assert_eq!(
1871 cfg.teams.get("default").unwrap().members,
1872 ["Herb Mason <herb@example.com>", "@alice"]
1873 );
1874
1875 cfg.save(&path).unwrap();
1877 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
1878 assert_eq!(cfg, reloaded);
1879 assert_eq!(
1880 reloaded
1881 .cycles
1882 .keys()
1883 .map(String::as_str)
1884 .collect::<Vec<_>>(),
1885 ["Cycle 1", "Cycle 2"]
1886 );
1887 assert_eq!(
1888 reloaded
1889 .teams
1890 .keys()
1891 .map(String::as_str)
1892 .collect::<Vec<_>>(),
1893 ["default", "platform"]
1894 );
1895 }
1896
1897 const FULL_CATEGORIES: &str = r#"issues_dir = "docs/issues"
1902
1903[categories]
1904order = ["backlog", "unstarted", "active", "blocked", "completed", "canceled"]
1905default = "unstarted"
1906
1907[transitions]
1908terminal_locked = true
1909
1910[kanban.default]
1911columns = ["triage", "wip", "review", "shipped"]
1912done_column = "shipped"
1913category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
1914
1915[export]
1916"#;
1917
1918 #[test]
1919 fn category_sections_parse_and_default_sensibly() {
1920 let tmp = TempDir::new().unwrap();
1921 let path = write_toml(tmp.path(), FULL_CATEGORIES);
1922 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1923 assert_eq!(cfg.categories.order, CATEGORY_KEYS);
1924 assert_eq!(cfg.categories.default, "unstarted");
1925 assert!(cfg.transitions.terminal_locked);
1926
1927 let board = cfg.default_config();
1928 assert_eq!(
1929 board.category_map.get("shipped").map(String::as_str),
1930 Some("completed")
1931 );
1932 assert_eq!(
1933 board.category_map.get("triage").map(String::as_str),
1934 Some("backlog")
1935 );
1936 }
1937
1938 #[test]
1939 fn category_sections_default_when_absent() {
1940 let tmp = TempDir::new().unwrap();
1941 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
1942 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
1943 assert_eq!(cfg.categories, CategoryConfig::default());
1946 assert_eq!(cfg.transitions, TransitionConfig::default());
1947 assert!(cfg.categories.is_default(), "and is not written back");
1948 assert!(cfg.transitions.is_default());
1949 }
1950
1951 #[test]
1952 fn deny_unknown_fields_rejects_an_unknown_key_in_each_new_section() {
1953 let tmp = TempDir::new().unwrap();
1954 let bad_categories = write_toml(
1955 tmp.path(),
1956 "issues_dir = \"docs/issues\"\n\n[categories]\nnope = 1\n",
1957 );
1958 let err = Config::load_with_warning(&bad_categories, |_| {})
1959 .expect_err("an unknown key in [categories] must be refused");
1960 assert!(err.to_string().contains("unknown field"), "got: {err}");
1961
1962 let tmp2 = TempDir::new().unwrap();
1963 let bad_transitions = write_toml(
1964 tmp2.path(),
1965 "issues_dir = \"docs/issues\"\n\n[transitions]\nnope = 1\n",
1966 );
1967 let err = Config::load_with_warning(&bad_transitions, |_| {})
1968 .expect_err("an unknown key in [transitions] must be refused");
1969 assert!(err.to_string().contains("unknown field"), "got: {err}");
1970 }
1971
1972 #[test]
1973 fn a_configured_vocabulary_naming_an_unknown_key_is_refused() {
1974 let tmp = TempDir::new().unwrap();
1975 let path = write_toml(
1976 tmp.path(),
1977 "issues_dir = \"docs/issues\"\n\n[categories]\norder = [\"backlog\", \"wontfix\"]\n",
1978 );
1979 let err = Config::load_with_warning(&path, |_| {})
1980 .expect_err("the vocabulary is global and fixed; a new key is not a default");
1981 assert!(err.to_string().contains("unknown_category"), "got: {err}");
1982 }
1983
1984 #[test]
1987 fn a_default_of_completed_is_refused() {
1988 let tmp = TempDir::new().unwrap();
1989 let path = write_toml(
1990 tmp.path(),
1991 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"completed\"\n",
1992 );
1993 let err = Config::load_with_warning(&path, |_| {})
1994 .expect_err("a completion-changing default must be an escalation");
1995 let msg = err.to_string();
1996 assert!(msg.contains("may not be `completed`"), "got: {msg}");
1997 }
1998
1999 #[test]
2000 fn a_default_outside_the_vocabulary_is_refused() {
2001 let tmp = TempDir::new().unwrap();
2002 let path = write_toml(
2003 tmp.path(),
2004 "issues_dir = \"docs/issues\"\n\n[categories]\ndefault = \"wontfix\"\n",
2005 );
2006 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2007 assert!(err.to_string().contains("unknown_category"), "got: {err}");
2008 }
2009
2010 #[test]
2014 fn a_mapped_board_with_no_completion_column_is_refused() {
2015 let tmp = TempDir::new().unwrap();
2016 let path = write_toml(
2017 tmp.path(),
2018 r#"issues_dir = "docs/issues"
2019
2020[kanban.default]
2021columns = ["triage", "wip", "shipped"]
2022done_column = "shipped"
2023category_map = { triage = "backlog", wip = "active" }
2024"#,
2025 );
2026 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2027 assert!(
2028 err.to_string().contains("missing_completion_category"),
2029 "got: {err}"
2030 );
2031 }
2032
2033 #[test]
2034 fn a_board_with_two_completion_columns_is_refused() {
2035 let tmp = TempDir::new().unwrap();
2036 let path = write_toml(
2037 tmp.path(),
2038 r#"issues_dir = "docs/issues"
2039
2040[kanban.default]
2041columns = ["shipped", "cancelled"]
2042done_column = "shipped"
2043category_map = { shipped = "completed", cancelled = "completed" }
2044"#,
2045 );
2046 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2047 let msg = err.to_string();
2048 assert!(
2049 msg.contains("exactly one column may complete"),
2050 "got: {msg}"
2051 );
2052 }
2053
2054 #[test]
2055 fn a_mapping_for_a_column_the_board_does_not_configure_is_refused() {
2056 let tmp = TempDir::new().unwrap();
2057 let path = write_toml(
2058 tmp.path(),
2059 r#"issues_dir = "docs/issues"
2060
2061[kanban.default]
2062columns = ["triage", "shipped"]
2063done_column = "shipped"
2064category_map = { triage = "backlog", shipped = "completed", nope = "active" }
2065"#,
2066 );
2067 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2068 assert!(
2069 err.to_string().contains("mapping_for_unknown_column"),
2070 "got: {err}"
2071 );
2072 }
2073
2074 #[test]
2075 fn a_mapping_to_a_category_outside_the_vocabulary_is_refused() {
2076 let tmp = TempDir::new().unwrap();
2077 let path = write_toml(
2078 tmp.path(),
2079 r#"issues_dir = "docs/issues"
2080
2081[kanban.default]
2082columns = ["triage", "shipped"]
2083done_column = "shipped"
2084category_map = { triage = "wontfix", shipped = "completed" }
2085"#,
2086 );
2087 let err = Config::load_with_warning(&path, |_| {}).expect_err("got: none");
2088 assert!(err.to_string().contains("unknown_category"), "got: {err}");
2089 }
2090
2091 #[test]
2095 fn an_unmapped_column_is_accepted_and_resolves_by_the_safe_default() {
2096 let tmp = TempDir::new().unwrap();
2097 let path = write_toml(
2098 tmp.path(),
2099 r#"issues_dir = "docs/issues"
2100
2101[kanban.default]
2102columns = ["triage", "wip", "shipped"]
2103done_column = "shipped"
2104category_map = { shipped = "completed" }
2105"#,
2106 );
2107 let cfg =
2108 Config::load_with_warning(&path, |_| {}).expect("an unmapped column is not an error");
2109 let board = cfg.default_config();
2110 assert_eq!(board.category_for("triage", &cfg.categories), "unstarted");
2111 assert_eq!(board.category_for("wip", &cfg.categories), "unstarted");
2112 assert_eq!(board.category_for("shipped", &cfg.categories), "completed");
2113 }
2114
2115 #[test]
2121 fn a_legacy_board_with_no_map_takes_done_column_as_completed() {
2122 let tmp = TempDir::new().unwrap();
2123 let path = write_toml(
2124 tmp.path(),
2125 r#"issues_dir = "docs/issues"
2126
2127[kanban.default]
2128columns = ["todo", "in progress", "in review", "done"]
2129done_column = "done"
2130"#,
2131 );
2132 let cfg = Config::load_with_warning(&path, |_| {})
2133 .expect("a legacy board must load, and it does");
2134 let board = cfg.default_config();
2135 assert!(board.category_map.is_empty());
2136 assert_eq!(board.category_for("done", &cfg.categories), "completed");
2137 assert_eq!(board.category_for("todo", &cfg.categories), "unstarted");
2138 assert_eq!(
2139 board.category_for("in review", &cfg.categories),
2140 "unstarted",
2141 "and nothing else is silently complete"
2142 );
2143 }
2144
2145 #[test]
2149 fn a_defaulted_config_gains_no_new_keys_on_save() {
2150 let tmp = TempDir::new().unwrap();
2151 let legacy = r#"issues_dir = "docs/issues"
2152
2153[kanban.default]
2154columns = [
2155 "todo",
2156 "in progress",
2157 "in review",
2158 "done",
2159]
2160done_column = "done"
2161hidden_columns = []
2162show_mismatch = false
2163wrap_titles = true
2164title_lines = 3
2165order_by = "created_at_desc"
2166
2167[export]
2168"#;
2169 let path = write_toml(tmp.path(), legacy);
2170 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2171 cfg.save(&path).unwrap();
2172 let after = std::fs::read_to_string(&path).unwrap();
2173 assert_eq!(
2174 after, legacy,
2175 "no `[categories]`, no `[transitions]`, no `category_map` may appear"
2176 );
2177 }
2178
2179 #[test]
2182 fn a_board_mapping_without_a_categories_section_uses_the_built_in_vocabulary() {
2183 let tmp = TempDir::new().unwrap();
2184 let path = write_toml(
2185 tmp.path(),
2186 r#"issues_dir = "docs/issues"
2187
2188[kanban.default]
2189columns = ["triage", "shipped"]
2190done_column = "shipped"
2191category_map = { triage = "backlog", shipped = "completed" }
2192
2193[export]
2194"#,
2195 );
2196 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2197 assert_eq!(cfg.categories, CategoryConfig::default());
2198 cfg.save(&path).unwrap();
2199 let after = std::fs::read_to_string(&path).unwrap();
2200 assert!(
2201 !after.contains("[categories]"),
2202 "the section must stay unmaterialized: {after}"
2203 );
2204 assert!(
2205 after.contains("category_map"),
2206 "while the board's own mapping survives: {after}"
2207 );
2208 }
2209
2210 #[test]
2225 fn a_full_category_config_round_trips_with_its_values_and_order() {
2226 let tmp = TempDir::new().unwrap();
2227 let authored = r#"issues_dir = "docs/issues"
2233
2234[categories]
2235order = ["backlog", "active", "completed", "canceled"]
2236default = "backlog"
2237
2238[transitions]
2239terminal_locked = false
2240
2241[kanban.default]
2242columns = ["triage", "wip", "shipped"]
2243done_column = "shipped"
2244category_map = { triage = "backlog", wip = "active", shipped = "completed" }
2245
2246[export]
2247"#;
2248 let path = write_toml(tmp.path(), authored);
2249 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2250 cfg.save(&path).unwrap();
2251 let after = std::fs::read_to_string(&path).unwrap();
2252 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2253
2254 assert_eq!(
2255 reloaded.categories, cfg.categories,
2256 "the vocabulary and its default survive"
2257 );
2258 assert_eq!(
2259 reloaded.categories.order,
2260 vec!["backlog", "active", "completed", "canceled"],
2261 "and `order` is a Vec, so the declared order is preserved exactly"
2262 );
2263
2264 assert_eq!(
2265 reloaded.default_config().category_map,
2266 cfg.default_config().category_map,
2267 "the mapping's contents survive, in whatever order they are written"
2268 );
2269 assert!(after.contains("[categories]"), "after: {after}");
2277 assert!(after.contains("[transitions]"), "after: {after}");
2278 assert!(
2279 !reloaded.transitions.terminal_locked,
2280 "and the value survives"
2281 );
2282
2283 let reloaded_again = Config::load_with_warning(&path, |_| {}).unwrap();
2290 reloaded_again.save(&path).unwrap();
2291 assert_eq!(
2292 std::fs::read_to_string(&path).unwrap(),
2293 after,
2294 "a second save must be a no-op"
2295 );
2296 }
2297
2298 #[test]
2301 fn two_boards_with_different_columns_map_to_the_same_categories() {
2302 let tmp = TempDir::new().unwrap();
2303 let path = write_toml(
2304 tmp.path(),
2305 r#"issues_dir = "docs/issues"
2306
2307[kanban.alpha]
2308columns = ["triage", "wip", "review", "shipped"]
2309done_column = "shipped"
2310category_map = { triage = "backlog", wip = "active", review = "active", shipped = "completed" }
2311
2312[kanban.beta]
2313columns = ["new", "doing", "merged"]
2314done_column = "merged"
2315
2316[export]
2317"#,
2318 );
2319 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2320 let alpha = &cfg.kanban.configurations["alpha"];
2321 let beta = &cfg.kanban.configurations["beta"];
2322 assert_eq!(alpha.category_for("shipped", &cfg.categories), "completed");
2325 assert_eq!(beta.category_for("merged", &cfg.categories), "completed");
2326 assert_eq!(alpha.category_for("wip", &cfg.categories), "active");
2327 assert_eq!(beta.category_for("doing", &cfg.categories), "unstarted");
2328 assert!(beta.category_map.is_empty());
2329 }
2330
2331 #[test]
2335 fn canceled_is_terminal_but_not_complete() {
2336 assert!(TERMINAL_CATEGORIES.contains(&"canceled"));
2337 assert!(TERMINAL_CATEGORIES.contains(&"completed"));
2338 assert_eq!(
2339 COMPLETE_CATEGORY, "completed",
2340 "completion is defined by this category alone"
2341 );
2342 assert_ne!(COMPLETE_CATEGORY, "canceled");
2343 let tmp = TempDir::new().unwrap();
2346 let path = write_toml(
2347 tmp.path(),
2348 r#"issues_dir = "docs/issues"
2349
2350[kanban.default]
2351columns = ["triage", "dropped", "shipped"]
2352done_column = "shipped"
2353category_map = { triage = "backlog", dropped = "canceled", shipped = "completed" }
2354"#,
2355 );
2356 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2357 let board = cfg.default_config();
2358 assert_eq!(board.category_for("dropped", &cfg.categories), "canceled");
2359 assert_ne!(
2360 board.category_for("dropped", &cfg.categories),
2361 COMPLETE_CATEGORY
2362 );
2363 }
2364
2365 #[test]
2369 fn blocked_as_a_category_is_mappable_and_is_its_own_key() {
2370 assert!(CATEGORY_KEYS.contains(&"blocked"));
2371 let tmp = TempDir::new().unwrap();
2372 let path = write_toml(
2373 tmp.path(),
2374 r#"issues_dir = "docs/issues"
2375
2376[kanban.default]
2377columns = ["wip", "on hold", "shipped"]
2378done_column = "shipped"
2379category_map = { wip = "active", "on hold" = "blocked", shipped = "completed" }
2380"#,
2381 );
2382 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2383 let board = cfg.default_config();
2384 assert_eq!(board.category_for("on hold", &cfg.categories), "blocked");
2385 assert!(
2387 !cfg.default_config()
2388 .category_map
2389 .values()
2390 .any(|v| v.contains("state")),
2391 "a category is a position, never a derived state"
2392 );
2393 }
2394
2395 #[test]
2396 fn legacy_file_roundtrips_byte_identical() {
2397 let tmp = TempDir::new().unwrap();
2398 let legacy = r#"issues_dir = "docs/issues"
2402
2403[kanban.default]
2404columns = [
2405 "todo",
2406 "in progress",
2407 "in review",
2408 "done",
2409]
2410done_column = "done"
2411hidden_columns = []
2412show_mismatch = false
2413wrap_titles = true
2414title_lines = 3
2415order_by = "created_at_desc"
2416
2417[export]
2418"#;
2419 let path = write_toml(tmp.path(), legacy);
2420 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2421 assert_eq!(cfg.planning, PlanningConfig::default());
2422 assert!(cfg.teams.is_empty());
2423 assert!(cfg.cycles.is_empty());
2424 assert_eq!(cfg.milestones_dir, None);
2425
2426 cfg.save(&path).unwrap();
2427 let on_disk = std::fs::read_to_string(&path).unwrap();
2428 assert_eq!(
2429 on_disk, legacy,
2430 "legacy tix.toml must save byte-identical (NFR-003)"
2431 );
2432 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2433 assert_eq!(cfg, reloaded);
2434 }
2435
2436 #[test]
2437 fn legacy_file_in_any_layout_never_gains_planning_keys() {
2438 let tmp = TempDir::new().unwrap();
2439 let legacy = r#"issues_dir = "docs/issues"
2443[kanban.default]
2444columns = ["todo", "in progress", "in review", "done"]
2445done_column = "done"
2446"#;
2447 let path = write_toml(tmp.path(), legacy);
2448 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2449 cfg.save(&path).unwrap();
2450 let on_disk = std::fs::read_to_string(&path).unwrap();
2451 assert!(!on_disk.contains("milestones_dir"));
2452 assert!(!on_disk.contains("[planning]"));
2453 assert!(!on_disk.contains("[teams]"));
2454 assert!(!on_disk.contains("[cycles"));
2455 }
2456
2457 #[test]
2458 fn planning_default_section_with_partial_keys_loads() {
2459 let tmp = TempDir::new().unwrap();
2460 let path = write_toml(tmp.path(), "[planning]\ndefault_cycle = \"Cycle 1\"\n");
2461 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2462 assert_eq!(
2463 cfg.planning.cycle_number, 1,
2464 "counter defaults when omitted"
2465 );
2466 assert_eq!(cfg.planning.default_cycle.as_deref(), Some("Cycle 1"));
2467 assert_eq!(cfg.planning.default_milestone, None);
2468 assert_eq!(cfg.planning.default_team, None);
2469 }
2470
2471 #[test]
2472 fn planning_rejects_unknown_keys() {
2473 let tmp = TempDir::new().unwrap();
2474 let path = write_toml(tmp.path(), "[planning]\ncounter = 5\n");
2475 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2476 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2477 assert!(err.to_string().contains("counter"));
2478 }
2479
2480 #[test]
2481 fn planning_rejects_zero_cycle_number() {
2482 let tmp = TempDir::new().unwrap();
2483 let path = write_toml(tmp.path(), "[planning]\ncycle_number = 0\n");
2484 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2485 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2486 assert!(err.to_string().contains("cycle_number"));
2487 }
2488
2489 #[test]
2490 fn cycle_dates_are_validated_and_compared() {
2491 let tmp = TempDir::new().unwrap();
2492 let bad = write_toml(tmp.path(), "[cycles.x]\nstart_date = \"2026-13-01\"\n");
2494 let err = Config::load_with_warning(&bad, |_| {}).unwrap_err();
2495 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2496 assert!(err.to_string().contains("start_date"));
2497
2498 let ordered = write_toml(
2500 tmp.path(),
2501 "[cycles.x]\nstart_date = \"2026-09-30\"\ndue_date = \"2026-09-01\"\n",
2502 );
2503 let err = Config::load_with_warning(&ordered, |_| {}).unwrap_err();
2504 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2505 assert!(err.to_string().contains("due_date"));
2506 assert!(err.to_string().contains("start_date"));
2507 }
2508
2509 #[test]
2510 fn cycles_reject_unknown_keys() {
2511 let tmp = TempDir::new().unwrap();
2512 let path = write_toml(tmp.path(), "[cycles.X]\nbogus = 1\n");
2513 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2514 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2515 assert!(err.to_string().contains("bogus"));
2516 }
2517
2518 #[test]
2519 fn cycle_without_dates_loads_fine() {
2520 let tmp = TempDir::new().unwrap();
2521 let ok = write_toml(tmp.path(), "[cycles.Alpha]\ndescription = \"a\"\n");
2522 let cfg = Config::load_with_warning(&ok, |_| {}).unwrap();
2523 assert_eq!(
2524 cfg.cycles.get("Alpha").unwrap().description.as_deref(),
2525 Some("a")
2526 );
2527 }
2528
2529 #[test]
2530 fn teams_validate_members_nonempty_and_unique() {
2531 let tmp = TempDir::new().unwrap();
2532 let empty = write_toml(tmp.path(), "[teams.dev]\nmembers = []\n");
2533 let err = Config::load_with_warning(&empty, |_| {}).unwrap_err();
2534 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2535 assert!(err.to_string().contains("members"));
2536
2537 let dup = write_toml(
2538 tmp.path(),
2539 "[teams.dev]\nmembers = [\"alice\", \"alice\"]\n",
2540 );
2541 let err = Config::load_with_warning(&dup, |_| {}).unwrap_err();
2542 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2543 assert!(err.to_string().contains("unique"));
2544 }
2545
2546 #[test]
2547 fn empty_teams_table_is_disabled_not_error() {
2548 let tmp = TempDir::new().unwrap();
2549 let path = write_toml(tmp.path(), "[teams]\n");
2550 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
2551 assert!(cfg.teams.is_empty());
2552 cfg.save(&path).unwrap();
2554 let on_disk = std::fs::read_to_string(&path).unwrap();
2555 assert!(!on_disk.contains("[teams]"));
2556 }
2557
2558 #[test]
2559 fn blank_milestones_dir_rejected() {
2560 let tmp = TempDir::new().unwrap();
2561 let path = write_toml(tmp.path(), "milestones_dir = \"\"\n");
2562 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2563 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
2564 assert!(err.to_string().contains("milestones_dir"));
2565 }
2566
2567 #[test]
2568 fn sparse_keybindings_roundtrip_without_defaults() {
2569 let tmp = TempDir::new().unwrap();
2570 let path = write_toml(
2571 tmp.path(),
2572 "[keybindings.board]\nnew_issue = [\"ctrl+n\", \"f4\"]\n",
2573 );
2574 let config = Config::load_with_warning(&path, |_| {}).unwrap();
2575 config.save(&path).unwrap();
2576 let contents = std::fs::read_to_string(&path).unwrap();
2577 assert!(contents.contains("[keybindings.board]"));
2578 assert!(!contents.contains("move_left"));
2579 assert_eq!(Config::load_with_warning(&path, |_| {}).unwrap(), config);
2580 }
2581
2582 #[test]
2583 fn omitted_keybindings_retain_effective_defaults() {
2584 let tmp = TempDir::new().unwrap();
2585 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
2586 let config = Config::load_with_warning(&path, |_| {}).unwrap();
2587 let bindings = config.effective_keybindings().unwrap();
2588 assert_eq!(
2589 bindings.label(
2590 crate::keybindings::InputContext::Board,
2591 crate::keybindings::TuiAction::NewIssue
2592 ),
2593 "n"
2594 );
2595 }
2596
2597 #[test]
2602 fn edit_conflict_keybindings_override_the_prompt() {
2603 use crate::keybindings::{InputContext, TuiAction};
2604 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2605
2606 let tmp = TempDir::new().unwrap();
2607 let path = write_toml(
2608 tmp.path(),
2609 "[keybindings.edit_conflict]\nconfirm = [\"ctrl+k\"]\nignore = [\"escape\"]\n",
2610 );
2611 let config = Config::load_with_warning(&path, |_| {}).unwrap();
2612 let bindings = config.effective_keybindings().unwrap();
2613
2614 assert_eq!(
2615 bindings.label(InputContext::EditConflict, TuiAction::Confirm),
2616 "Ctrl+k"
2617 );
2618 assert_eq!(
2619 bindings.action(
2620 InputContext::EditConflict,
2621 KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)
2622 ),
2623 Some(TuiAction::Confirm)
2624 );
2625 assert_eq!(
2626 bindings.action(
2627 InputContext::EditConflict,
2628 KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)
2629 ),
2630 None,
2631 "an override replaces the action's whole alias list"
2632 );
2633 assert_eq!(
2635 bindings.action(
2636 InputContext::EditConflict,
2637 KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)
2638 ),
2639 Some(TuiAction::Reedit)
2640 );
2641 assert_eq!(
2642 bindings.action(
2643 InputContext::EditConflict,
2644 KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
2645 ),
2646 Some(TuiAction::Ignore)
2647 );
2648 }
2649
2650 #[test]
2651 fn keybindings_reject_unknown_fields_and_empty_actions() {
2652 let tmp = TempDir::new().unwrap();
2653 let unknown = write_toml(tmp.path(), "[keybindings.board]\nbogus = [\"x\"]\n");
2654 assert!(
2655 Config::load_with_warning(&unknown, |_| {})
2656 .unwrap_err()
2657 .to_string()
2658 .contains("bogus")
2659 );
2660 let empty = write_toml(tmp.path(), "[keybindings.board]\nnew_issue = []\n");
2661 assert!(
2662 Config::load_with_warning(&empty, |_| {})
2663 .unwrap_err()
2664 .to_string()
2665 .contains("new_issue")
2666 );
2667 }
2668
2669 #[test]
2672 fn templates_round_trip_through_save_and_load() {
2673 let tmp = TempDir::new().unwrap();
2674 let path = tmp.path().join("tix.toml");
2675 let mut config = Config::starter();
2676 config.templates.insert(
2677 "bug".into(),
2678 IssueTemplate {
2679 title: Some("Bug: ".into()),
2680 body: Some("## Steps\n".into()),
2681 labels: vec!["bug".into(), "urgent".into()],
2682 status: Some("todo".into()),
2683 cycles: vec!["Default".into()],
2684 start_date: Some("2026-01-01".into()),
2685 due_date: Some("2026-01-02".into()),
2686 ..IssueTemplate::default()
2687 },
2688 );
2689 config.validate().expect("the fixture is a valid config");
2690 config.save(&path).unwrap();
2691
2692 let raw = std::fs::read_to_string(&path).unwrap();
2693 assert!(raw.contains("[templates.bug]"), "{raw}");
2694 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
2695 assert_eq!(reloaded.templates, config.templates);
2696 let bug = &reloaded.templates["bug"];
2697 assert_eq!(bug.labels, ["bug".to_string(), "urgent".to_string()]);
2698 assert_eq!(bug.body.as_deref(), Some("## Steps\n"));
2699 assert_eq!(bug.due_date.as_deref(), Some("2026-01-02"));
2700 }
2701
2702 #[test]
2703 fn a_config_without_templates_writes_no_section() {
2704 let tmp = TempDir::new().unwrap();
2705 let path = tmp.path().join("tix.toml");
2706 Config::default().save(&path).unwrap();
2707 let raw = std::fs::read_to_string(&path).unwrap();
2708 assert!(
2709 !raw.contains("templates"),
2710 "an absent section must not appear on disk:\n{raw}"
2711 );
2712 assert!(
2713 Config::load_with_warning(&path, |_| {})
2714 .unwrap()
2715 .templates
2716 .is_empty()
2717 );
2718 }
2719
2720 #[test]
2721 fn a_template_value_with_a_comma_is_rejected() {
2722 let tmp = TempDir::new().unwrap();
2723 let path = write_toml(
2724 tmp.path(),
2725 "issues_dir = \"docs/issues\"\n\n[templates.bug]\nlabels = [\"bug,urgent\"]\n",
2726 );
2727 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2728 assert!(
2729 err.to_string().contains("must not contain a comma"),
2730 "got {err}"
2731 );
2732 }
2733
2734 #[test]
2735 fn a_template_with_a_backwards_date_range_is_rejected() {
2736 let tmp = TempDir::new().unwrap();
2737 let path = write_toml(
2738 tmp.path(),
2739 "issues_dir = \"docs/issues\"\n\n[templates.bug]\n\
2740 start_date = \"2026-02-01\"\ndue_date = \"2026-01-01\"\n",
2741 );
2742 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2743 assert!(err.to_string().contains(">= start_date"), "got {err}");
2744 }
2745
2746 #[test]
2747 fn an_unknown_template_key_is_rejected() {
2748 let tmp = TempDir::new().unwrap();
2749 let path = write_toml(
2750 tmp.path(),
2751 "issues_dir = \"docs/issues\"\n\n[templates.bug]\ntitel = \"typo\"\n",
2752 );
2753 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
2754 assert!(err.to_string().contains("unknown field"), "got {err}");
2755 }
2756}