1use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Result, TixError};
18
19#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
25#[serde(default, deny_unknown_fields)]
26pub struct RemoteConfig {
27 pub token: Option<String>,
30 pub base_url: Option<String>,
32 pub project: Option<String>,
34 pub team: Option<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
40#[serde(default, deny_unknown_fields)]
41pub struct ExportConfig {
42 pub gitlab: Option<RemoteConfig>,
44 pub linear: Option<RemoteConfig>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
55#[serde(default, deny_unknown_fields)]
56pub struct StateConfig {
57 #[serde(skip_serializing)]
61 pub next_number: u32,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
69pub enum OrderBy {
70 #[default]
72 #[serde(rename = "created_at_desc")]
73 CreatedAtDesc,
74 #[serde(rename = "created_at_asc")]
76 CreatedAtAsc,
77 #[serde(rename = "none")]
79 None,
80}
81
82impl OrderBy {
83 pub fn as_str(self) -> &'static str {
85 match self {
86 Self::CreatedAtDesc => "created_at_desc",
87 Self::CreatedAtAsc => "created_at_asc",
88 Self::None => "none",
89 }
90 }
91
92 pub fn next(self) -> Self {
94 match self {
95 Self::CreatedAtDesc => Self::CreatedAtAsc,
96 Self::CreatedAtAsc => Self::None,
97 Self::None => Self::CreatedAtDesc,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(default, deny_unknown_fields)]
109pub struct BoardConfiguration {
110 pub columns: Vec<String>,
112 pub done_column: Option<String>,
116 pub hidden_columns: Vec<String>,
119 pub show_mismatch: bool,
122 pub wrap_titles: bool,
124 pub title_lines: usize,
128 pub order_by: OrderBy,
131}
132
133impl Default for BoardConfiguration {
134 fn default() -> Self {
135 Self {
136 columns: ["todo", "in progress", "in review", "done"]
137 .into_iter()
138 .map(String::from)
139 .collect(),
140 done_column: Some("done".to_string()),
141 hidden_columns: Vec::new(),
142 show_mismatch: false,
143 wrap_titles: true,
144 title_lines: 3,
145 order_by: OrderBy::default(),
146 }
147 }
148}
149
150impl BoardConfiguration {
151 pub fn effective_done_column(&self) -> Option<&str> {
154 if let Some(done) = self.done_column.as_deref() {
155 return Some(done);
156 }
157 if self.columns.iter().any(|c| c == "done") {
158 Some("done")
159 } else {
160 self.columns.last().map(String::as_str)
161 }
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
176#[serde(default, transparent)]
177pub struct KanbanSection {
178 pub configurations: IndexMap<String, BoardConfiguration>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(default, deny_unknown_fields)]
185pub struct Config {
186 pub kanban: KanbanSection,
188 pub issues_dir: PathBuf,
190 pub editor: Option<String>,
192 pub agent: Option<String>,
196 #[serde(skip_serializing)]
199 pub state: StateConfig,
200 pub export: ExportConfig,
202}
203
204impl Default for Config {
205 fn default() -> Self {
206 Config {
207 kanban: KanbanSection {
208 configurations: IndexMap::from([(
209 "default".to_string(),
210 BoardConfiguration::default(),
211 )]),
212 },
213 issues_dir: PathBuf::from("docs/issues"),
214 editor: None,
215 state: StateConfig::default(),
216 export: ExportConfig::default(),
217 agent: None,
218 }
219 }
220}
221
222impl Config {
223 fn validate(&self) -> Result<()> {
227 if self.issues_dir.as_os_str().is_empty() {
228 return Err(TixError::Config("`issues_dir` must not be blank".into()));
229 }
230 if self.kanban.configurations.is_empty() {
231 return Err(TixError::Config(
232 "`[kanban]` must define at least one configuration".into(),
233 ));
234 }
235 for (name, board) in &self.kanban.configurations {
236 validate_board(name, board)?;
237 }
238 Ok(())
239 }
240
241 fn restore_declaration_order(&mut self, contents: &str) {
245 crate::config::restore_declaration_order(&mut self.kanban, contents);
246 }
247
248 pub fn default_config(&self) -> &BoardConfiguration {
251 self.kanban
252 .configurations
253 .get("default")
254 .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
255 .expect("validate() guarantees at least one configuration")
256 }
257
258 pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
261 self.kanban
262 .configurations
263 .iter()
264 .map(|(name, board)| (name.as_str(), board))
265 }
266
267 pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
273 let contents = std::fs::read_to_string(path)
274 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
275 let mut config: Config = toml::from_str(&contents)
276 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
277 config.restore_declaration_order(&contents);
278 if config.kanban.configurations.is_empty() {
279 config.kanban = KanbanSection {
280 configurations: IndexMap::from([(
281 "default".to_string(),
282 BoardConfiguration::default(),
283 )]),
284 };
285 }
286 config.validate().map_err(|e| match e {
287 TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
288 other => other,
289 })?;
290 for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
291 .into_iter()
292 .flatten()
293 {
294 if remote.token.is_some() {
295 sink(
296 "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
297 );
298 }
299 }
300 Ok(config)
301 }
302
303 pub fn load(project_root: &Path) -> Result<Config> {
307 let path = project_root.join("tix.toml");
308 if !path.is_file() {
309 return Ok(Config::default());
310 }
311 Self::load_with_warning(&path, |w| eprintln!("{w}"))
312 }
313
314 pub fn project_root(cwd: &Path) -> Option<PathBuf> {
317 let mut dir = Some(cwd.to_path_buf());
318 while let Some(current) = dir {
319 if current.join("tix.toml").is_file() {
320 return Some(current);
321 }
322 dir = current.parent().map(Path::to_path_buf);
323 }
324 None
325 }
326
327 fn sanitized_for_disk(&self) -> Config {
329 let mut copy = self.clone();
330 if let Some(gitlab) = copy.export.gitlab.as_mut() {
331 gitlab.token = None;
332 }
333 if let Some(linear) = copy.export.linear.as_mut() {
334 linear.token = None;
335 }
336 copy
337 }
338
339 pub fn save(&self, path: &Path) -> Result<()> {
345 let dir = match path.parent() {
346 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
347 _ => PathBuf::from("."),
348 };
349 std::fs::create_dir_all(&dir)?;
350
351 let disk = self.sanitized_for_disk();
352 let contents = toml::to_string_pretty(&disk)
353 .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
354
355 let file_name = path
356 .file_name()
357 .and_then(|n| n.to_str())
358 .unwrap_or("tix.toml");
359 let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
360
361 let write_result = (|| -> std::io::Result<()> {
362 let mut f = std::fs::File::create(&tmp)?;
363 use std::io::Write;
364 f.write_all(contents.as_bytes())?;
365 f.sync_all()?;
366 Ok(())
367 })();
368 if let Err(e) = write_result {
369 let _ = std::fs::remove_file(&tmp);
370 return Err(TixError::Io(e));
371 }
372
373 if let Err(e) = std::fs::rename(&tmp, path) {
374 let _ = std::fs::remove_file(&tmp);
375 return Err(TixError::Io(e));
376 }
377 Ok(())
378 }
379}
380
381fn restore_declaration_order(section: &mut KanbanSection, contents: &str) {
386 let mut order: Vec<String> = Vec::new();
387 for line in contents.lines() {
388 let line = line.trim();
389 if let Some(rest) = line.strip_prefix("[").and_then(|s| s.strip_suffix("]")) {
390 let mut parts = rest.split('.').map(str::trim);
391 if parts.next() == Some("kanban")
392 && let Some(name) = parts.next()
393 && parts.next().is_none()
394 && !order.iter().any(|n| n == name)
395 {
396 order.push(name.to_string());
397 }
398 }
399 }
400 if order.is_empty() {
401 return;
402 }
403 let mut ordered = IndexMap::new();
404 for name in &order {
405 if let Some(board) = section.configurations.shift_remove(name) {
406 ordered.insert(name.clone(), board);
407 }
408 }
409 ordered.extend(
410 section
411 .configurations
412 .iter()
413 .map(|(n, b)| (n.clone(), b.clone())),
414 );
415 section.configurations = ordered;
416}
417
418fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
421 if board.columns.is_empty() {
422 return Err(TixError::Config(format!(
423 "`kanban.{name}.columns` must not be empty"
424 )));
425 }
426 if board.columns.iter().any(|c| c.trim().is_empty()) {
427 return Err(TixError::Config(format!(
428 "`kanban.{name}.columns` must not contain blank names"
429 )));
430 }
431 let mut seen: HashSet<&str> = HashSet::new();
432 for column in &board.columns {
433 if !seen.insert(column.as_str()) {
434 return Err(TixError::Config(format!(
435 "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
436 )));
437 }
438 }
439 if let Some(done) = &board.done_column
440 && !board.columns.contains(done)
441 {
442 return Err(TixError::Config(format!(
443 "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
444 )));
445 }
446 for hidden in &board.hidden_columns {
447 if !board.columns.contains(hidden) {
448 return Err(TixError::Config(format!(
449 "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
450 )));
451 }
452 }
453 if board.title_lines == 0 {
454 return Err(TixError::Config(format!(
455 "`kanban.{name}.title_lines` must be at least 1"
456 )));
457 }
458 Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use std::path::PathBuf;
465 use tempfile::TempDir;
466
467 fn write_toml(dir: &Path, contents: &str) -> PathBuf {
468 let path = dir.join("tix.toml");
469 std::fs::write(&path, contents).unwrap();
470 path
471 }
472
473 #[test]
474 fn defaults_match_contract() {
475 let c = Config::default();
476 let board = c.default_config();
477 assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
478 assert_eq!(board.done_column.as_deref(), Some("done"));
479 assert_eq!(board.hidden_columns, Vec::<String>::new());
480 assert!(!board.show_mismatch);
481 assert!(board.wrap_titles);
482 assert_eq!(board.title_lines, 3);
483 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
484 assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
485 assert_eq!(c.editor, None);
486 assert_eq!(c.state.next_number, 0);
487 assert_eq!(c.export.gitlab, None);
488 assert_eq!(c.export.linear, None);
489 }
490
491 #[test]
492 fn load_returns_defaults_when_file_absent() {
493 let tmp = TempDir::new().unwrap();
494 let cfg = Config::load(tmp.path()).unwrap();
495 assert_eq!(cfg.state.next_number, 0);
496 assert_eq!(cfg.default_config().columns.len(), 4);
497 }
498
499 #[test]
500 fn load_synthesizes_default_config_when_kanban_section_missing() {
501 let tmp = TempDir::new().unwrap();
502 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
503 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
504 assert_eq!(cfg.kanban.configurations.len(), 1);
505 assert!(cfg.kanban.configurations.contains_key("default"));
506 assert_eq!(cfg.default_config().columns.len(), 4);
507 }
508
509 #[test]
510 fn load_parses_state_and_keeps_editor_and_remote_targets() {
511 let tmp = TempDir::new().unwrap();
512 let path = write_toml(
513 tmp.path(),
514 r#"issues_dir = "tickets"
515editor = "nvim"
516
517[kanban.default]
518columns = ["todo", "done"]
519done_column = "done"
520
521[state]
522next_number = 17
523
524[export.gitlab]
525project = "acme/tix"
526"#,
527 );
528 let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
529 assert_eq!(
530 cfg.kanban.configurations.get("default").unwrap().columns,
531 ["todo", "done"]
532 );
533 assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
534 assert_eq!(cfg.editor.as_deref(), Some("nvim"));
535 assert_eq!(cfg.state.next_number, 17);
536 assert_eq!(
537 cfg.export.gitlab.unwrap().project.as_deref(),
538 Some("acme/tix")
539 );
540 assert_eq!(cfg.export.linear, None);
541 }
542
543 #[test]
544 fn empty_columns_rejected_as_config_error() {
545 let tmp = TempDir::new().unwrap();
546 let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
547 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
548 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
549 assert!(err.to_string().contains("kanban.scratch.columns"));
550 }
551
552 #[test]
553 fn duplicate_columns_rejected_as_config_error() {
554 let tmp = TempDir::new().unwrap();
555 let path = write_toml(
556 tmp.path(),
557 "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
558 );
559 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
560 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
561 assert!(err.to_string().contains("duplicate"));
562 }
563
564 #[test]
565 fn blank_column_name_rejected() {
566 let tmp = TempDir::new().unwrap();
567 let path = write_toml(
568 tmp.path(),
569 "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
570 );
571 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
572 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
573 }
574
575 #[test]
576 fn done_column_must_be_in_columns() {
577 let tmp = TempDir::new().unwrap();
578 let path = write_toml(
579 tmp.path(),
580 "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
581 );
582 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
583 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
584 assert!(err.to_string().contains("kanban.scratch.done_column"));
585 }
586
587 #[test]
588 fn hidden_columns_must_be_subset_of_columns() {
589 let tmp = TempDir::new().unwrap();
590 let path = write_toml(
591 tmp.path(),
592 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
593 );
594 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
595 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
596 assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
597 }
598
599 #[test]
600 fn hidden_done_column_loads_fine() {
601 let tmp = TempDir::new().unwrap();
602 let path = write_toml(
603 tmp.path(),
604 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
605 );
606 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
607 let board = cfg.kanban.configurations.get("scratch").unwrap();
608 assert_eq!(board.done_column.as_deref(), Some("done"));
609 assert_eq!(board.hidden_columns, ["done"]);
610 }
611
612 #[test]
613 fn legacy_top_level_keys_rejected() {
614 let tmp = TempDir::new().unwrap();
615 let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
616 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
617 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
618 assert!(err.to_string().contains("tix.toml"));
619 assert!(err.to_string().contains("columns"));
620 }
621
622 #[test]
623 fn token_warning_fires_once_per_remote_with_token() {
624 let tmp = TempDir::new().unwrap();
625 let path = write_toml(
626 tmp.path(),
627 r#"[export.gitlab]
628project = "a/b"
629token = "gl-secret"
630
631[export.linear]
632team = "Eng"
633token = "lin-secret"
634"#,
635 );
636 let mut warnings: Vec<String> = Vec::new();
637 let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
638 assert_eq!(warnings.len(), 2);
639 assert!(warnings[0].contains("GITLAB_TOKEN"));
640 assert!(warnings[1].contains("LINEAR_TOKEN"));
641 for w in &warnings {
643 assert!(!w.contains("secret"));
644 }
645 assert_eq!(
646 cfg.export.gitlab.unwrap().token.as_deref(),
647 Some("gl-secret")
648 );
649 }
650
651 #[test]
652 fn no_token_means_no_warning() {
653 let tmp = TempDir::new().unwrap();
654 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
655 let mut fired = false;
656 Config::load_with_warning(&path, |_| fired = true).unwrap();
657 assert!(!fired);
658 }
659
660 #[test]
661 fn save_never_writes_the_state_section() {
662 let tmp = TempDir::new().unwrap();
663 let path = tmp.path().join("tix.toml");
664 let mut cfg = Config::default();
667 cfg.state.next_number = 42;
668 cfg.save(&path).unwrap();
669
670 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
671 assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
672 assert_eq!(
673 reloaded.default_config().columns,
674 cfg.default_config().columns
675 );
676
677 let on_disk = std::fs::read_to_string(&path).unwrap();
678 assert!(
679 !on_disk.contains("next_number"),
680 "next_number leaked into tix.toml:\n{on_disk}"
681 );
682 assert!(
683 !on_disk.contains("[state]"),
684 "state section written:\n{on_disk}"
685 );
686 }
687
688 #[test]
689 fn multi_config_roundtrip_preserves_declaration_order() {
690 let tmp = TempDir::new().unwrap();
691 let path = write_toml(
692 tmp.path(),
693 r#"issues_dir = "docs/issues"
694
695[kanban.release]
696columns = ["planned", "in progress", "done"]
697done_column = "done"
698show_mismatch = true
699
700[kanban.personal]
701columns = ["todo", "done"]
702done_column = "done"
703hidden_columns = ["done"]
704"#,
705 );
706 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
707 let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
708 assert_eq!(names, ["release", "personal"]);
709
710 assert_eq!(
712 cfg.default_config().columns,
713 ["planned", "in progress", "done"]
714 );
715
716 cfg.save(&path).unwrap();
717 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
718 assert_eq!(
719 cfg, reloaded,
720 "order and values survive save/load round trip"
721 );
722 let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
723 assert_eq!(names2, ["release", "personal"]);
724 }
725
726 #[test]
727 fn save_never_serializes_tokens_back_out() {
728 let tmp = TempDir::new().unwrap();
729 let path = tmp.path().join("tix.toml");
730 let mut cfg = Config::default();
731 cfg.export.gitlab = Some(RemoteConfig {
732 token: Some("secret".into()),
733 project: Some("a/b".into()),
734 ..RemoteConfig::default()
735 });
736 cfg.save(&path).unwrap();
737
738 let on_disk = std::fs::read_to_string(&path).unwrap();
739 assert!(
740 !on_disk.contains("secret"),
741 "token leaked to disk:\n{on_disk}"
742 );
743 assert!(
744 on_disk.contains("a/b"),
745 "non-secret fields survive:\n{on_disk}"
746 );
747
748 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
749 assert_eq!(reloaded.export.gitlab.unwrap().token, None);
750 }
751
752 #[test]
753 fn save_is_atomic_and_leaves_no_temp_file() {
754 let tmp = TempDir::new().unwrap();
755 let path = tmp.path().join("tix.toml");
756 Config::default().save(&path).unwrap();
757 let entries: Vec<_> = std::fs::read_dir(tmp.path())
758 .unwrap()
759 .filter_map(|e| e.ok())
760 .collect();
761 assert_eq!(entries.len(), 1);
762 assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
763 }
764
765 #[test]
766 fn default_loadwrites_are_idempotent() {
767 let tmp = TempDir::new().unwrap();
768 let path = tmp.path().join("tix.toml");
769 let cfg = Config::default();
770 cfg.save(&path).unwrap();
771 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
772 assert_eq!(cfg, reloaded);
773 }
774
775 #[test]
776 fn effective_done_column_falls_back_to_last() {
777 let board = BoardConfiguration {
778 columns: vec!["todo".into(), "later".into()],
779 done_column: None,
780 ..Default::default()
781 };
782 assert_eq!(board.effective_done_column(), Some("later"));
783
784 let with_done = BoardConfiguration {
785 columns: vec!["todo".into(), "done".into(), "later".into()],
786 done_column: None,
787 ..Default::default()
788 };
789 assert_eq!(with_done.effective_done_column(), Some("done"));
790 }
791
792 #[test]
793 fn project_root_walks_up_the_tree() {
794 let tmp = TempDir::new().unwrap();
795 let nested = tmp.path().join("a").join("b");
796 std::fs::create_dir_all(&nested).unwrap();
797 std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
798
799 assert_eq!(
800 Config::project_root(&nested),
801 Some(tmp.path().to_path_buf())
802 );
803 assert_eq!(
804 Config::project_root(tmp.path()),
805 Some(tmp.path().to_path_buf())
806 );
807 }
808
809 #[test]
810 fn project_root_is_none_when_no_toml_exists() {
811 let tmp = TempDir::new().unwrap();
812 assert_eq!(Config::project_root(tmp.path()), None);
813 }
814
815 #[test]
816 fn order_by_parses_all_names_and_defaults_to_desc() {
817 let tmp = TempDir::new().unwrap();
818 for name in ["created_at_desc", "created_at_asc", "none"] {
819 let path = write_toml(
820 tmp.path(),
821 &format!(
822 "[kanban.x]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"{name}\"\n"
823 ),
824 );
825 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
826 let board = cfg.kanban.configurations.get("x").unwrap();
827 assert_eq!(board.order_by.as_str(), name);
828 }
829 assert_eq!(OrderBy::default(), OrderBy::CreatedAtDesc);
830 assert_eq!(OrderBy::CreatedAtDesc.next(), OrderBy::CreatedAtAsc);
831 assert_eq!(OrderBy::CreatedAtAsc.next(), OrderBy::None);
832 assert_eq!(OrderBy::None.next(), OrderBy::CreatedAtDesc);
833 }
834
835 #[test]
836 fn zero_title_lines_rejected_as_config_error() {
837 let tmp = TempDir::new().unwrap();
838 let path = write_toml(
839 tmp.path(),
840 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\ntitle_lines = 0\n",
841 );
842 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
843 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
844 assert!(
845 err.to_string().contains("kanban.scratch.title_lines"),
846 "error names the offending field: {err}"
847 );
848 }
849
850 #[test]
851 fn unknown_order_by_rejected_as_config_error() {
852 let tmp = TempDir::new().unwrap();
853 let path = write_toml(
854 tmp.path(),
855 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\norder_by = \"bogus\"\n",
856 );
857 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
858 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
859 }
860
861 #[test]
862 fn board_without_display_keys_loads_default_display_settings() {
863 let tmp = TempDir::new().unwrap();
864 let path = write_toml(
865 tmp.path(),
866 "[kanban.default]\ncolumns = [\"todo\", \"done\"]\n",
867 );
868 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
869 let board = cfg.default_config();
870 assert!(board.wrap_titles);
871 assert_eq!(board.title_lines, 3);
872 assert_eq!(board.order_by, OrderBy::CreatedAtDesc);
873 }
874
875 #[test]
876 fn display_settings_roundtrip_through_save() {
877 let tmp = TempDir::new().unwrap();
878 let path = tmp.path().join("tix.toml");
879 let mut cfg = Config::default();
880 {
881 let board = cfg.kanban.configurations.get_mut("default").unwrap();
882 board.wrap_titles = false;
883 board.title_lines = 5;
884 board.order_by = OrderBy::None;
885 }
886 cfg.save(&path).unwrap();
887 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
888 assert_eq!(
889 reloaded, cfg,
890 "display settings survive save/load round trip"
891 );
892 let on_disk = std::fs::read_to_string(&path).unwrap();
893 for key in ["wrap_titles", "title_lines", "order_by"] {
894 assert!(on_disk.contains(key), "missing `{key}` in:\n{on_disk}");
895 }
896 }
897}