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, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(default, deny_unknown_fields)]
71pub struct BoardConfiguration {
72 pub columns: Vec<String>,
74 pub done_column: Option<String>,
78 pub hidden_columns: Vec<String>,
81 pub show_mismatch: bool,
84}
85
86impl Default for BoardConfiguration {
87 fn default() -> Self {
88 Self {
89 columns: ["todo", "in progress", "in review", "done"]
90 .into_iter()
91 .map(String::from)
92 .collect(),
93 done_column: Some("done".to_string()),
94 hidden_columns: Vec::new(),
95 show_mismatch: false,
96 }
97 }
98}
99
100impl BoardConfiguration {
101 pub fn effective_done_column(&self) -> Option<&str> {
104 if let Some(done) = self.done_column.as_deref() {
105 return Some(done);
106 }
107 if self.columns.iter().any(|c| c == "done") {
108 Some("done")
109 } else {
110 self.columns.last().map(String::as_str)
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
126#[serde(default, transparent)]
127pub struct KanbanSection {
128 pub configurations: IndexMap<String, BoardConfiguration>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(default, deny_unknown_fields)]
135pub struct Config {
136 pub kanban: KanbanSection,
138 pub issues_dir: PathBuf,
140 pub editor: Option<String>,
142 pub agent: Option<String>,
146 #[serde(skip_serializing)]
149 pub state: StateConfig,
150 pub export: ExportConfig,
152}
153
154impl Default for Config {
155 fn default() -> Self {
156 Config {
157 kanban: KanbanSection {
158 configurations: IndexMap::from([(
159 "default".to_string(),
160 BoardConfiguration::default(),
161 )]),
162 },
163 issues_dir: PathBuf::from("docs/issues"),
164 editor: None,
165 state: StateConfig::default(),
166 export: ExportConfig::default(),
167 agent: None,
168 }
169 }
170}
171
172impl Config {
173 fn validate(&self) -> Result<()> {
177 if self.issues_dir.as_os_str().is_empty() {
178 return Err(TixError::Config("`issues_dir` must not be blank".into()));
179 }
180 if self.kanban.configurations.is_empty() {
181 return Err(TixError::Config(
182 "`[kanban]` must define at least one configuration".into(),
183 ));
184 }
185 for (name, board) in &self.kanban.configurations {
186 validate_board(name, board)?;
187 }
188 Ok(())
189 }
190
191 fn restore_declaration_order(&mut self, contents: &str) {
195 crate::config::restore_declaration_order(&mut self.kanban, contents);
196 }
197
198 pub fn default_config(&self) -> &BoardConfiguration {
201 self.kanban
202 .configurations
203 .get("default")
204 .or_else(|| self.kanban.configurations.first().map(|(_, board)| board))
205 .expect("validate() guarantees at least one configuration")
206 }
207
208 pub fn configs(&self) -> impl Iterator<Item = (&str, &BoardConfiguration)> {
211 self.kanban
212 .configurations
213 .iter()
214 .map(|(name, board)| (name.as_str(), board))
215 }
216
217 pub fn load_with_warning(path: &Path, mut sink: impl FnMut(&str)) -> Result<Config> {
223 let contents = std::fs::read_to_string(path)
224 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
225 let mut config: Config = toml::from_str(&contents)
226 .map_err(|e| TixError::Config(format!("{}: {e}", path.display())))?;
227 config.restore_declaration_order(&contents);
228 if config.kanban.configurations.is_empty() {
229 config.kanban = KanbanSection {
230 configurations: IndexMap::from([(
231 "default".to_string(),
232 BoardConfiguration::default(),
233 )]),
234 };
235 }
236 config.validate().map_err(|e| match e {
237 TixError::Config(msg) => TixError::Config(format!("{}: {msg}", path.display())),
238 other => other,
239 })?;
240 for remote in [config.export.gitlab.as_ref(), config.export.linear.as_ref()]
241 .into_iter()
242 .flatten()
243 {
244 if remote.token.is_some() {
245 sink(
246 "Warning: token found in tix.toml — prefer GITLAB_TOKEN / LINEAR_TOKEN env vars",
247 );
248 }
249 }
250 Ok(config)
251 }
252
253 pub fn load(project_root: &Path) -> Result<Config> {
257 let path = project_root.join("tix.toml");
258 if !path.is_file() {
259 return Ok(Config::default());
260 }
261 Self::load_with_warning(&path, |w| eprintln!("{w}"))
262 }
263
264 pub fn project_root(cwd: &Path) -> Option<PathBuf> {
267 let mut dir = Some(cwd.to_path_buf());
268 while let Some(current) = dir {
269 if current.join("tix.toml").is_file() {
270 return Some(current);
271 }
272 dir = current.parent().map(Path::to_path_buf);
273 }
274 None
275 }
276
277 fn sanitized_for_disk(&self) -> Config {
279 let mut copy = self.clone();
280 if let Some(gitlab) = copy.export.gitlab.as_mut() {
281 gitlab.token = None;
282 }
283 if let Some(linear) = copy.export.linear.as_mut() {
284 linear.token = None;
285 }
286 copy
287 }
288
289 pub fn save(&self, path: &Path) -> Result<()> {
295 let dir = match path.parent() {
296 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
297 _ => PathBuf::from("."),
298 };
299 std::fs::create_dir_all(&dir)?;
300
301 let disk = self.sanitized_for_disk();
302 let contents = toml::to_string_pretty(&disk)
303 .map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
304
305 let file_name = path
306 .file_name()
307 .and_then(|n| n.to_str())
308 .unwrap_or("tix.toml");
309 let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
310
311 let write_result = (|| -> std::io::Result<()> {
312 let mut f = std::fs::File::create(&tmp)?;
313 use std::io::Write;
314 f.write_all(contents.as_bytes())?;
315 f.sync_all()?;
316 Ok(())
317 })();
318 if let Err(e) = write_result {
319 let _ = std::fs::remove_file(&tmp);
320 return Err(TixError::Io(e));
321 }
322
323 if let Err(e) = std::fs::rename(&tmp, path) {
324 let _ = std::fs::remove_file(&tmp);
325 return Err(TixError::Io(e));
326 }
327 Ok(())
328 }
329}
330
331fn restore_declaration_order(section: &mut KanbanSection, contents: &str) {
336 let mut order: Vec<String> = Vec::new();
337 for line in contents.lines() {
338 let line = line.trim();
339 if let Some(rest) = line.strip_prefix("[").and_then(|s| s.strip_suffix("]")) {
340 let mut parts = rest.split('.').map(str::trim);
341 if parts.next() == Some("kanban")
342 && let Some(name) = parts.next()
343 && parts.next().is_none()
344 && !order.iter().any(|n| n == name)
345 {
346 order.push(name.to_string());
347 }
348 }
349 }
350 if order.is_empty() {
351 return;
352 }
353 let mut ordered = IndexMap::new();
354 for name in &order {
355 if let Some(board) = section.configurations.shift_remove(name) {
356 ordered.insert(name.clone(), board);
357 }
358 }
359 ordered.extend(
360 section
361 .configurations
362 .iter()
363 .map(|(n, b)| (n.clone(), b.clone())),
364 );
365 section.configurations = ordered;
366}
367
368fn validate_board(name: &str, board: &BoardConfiguration) -> Result<()> {
371 if board.columns.is_empty() {
372 return Err(TixError::Config(format!(
373 "`kanban.{name}.columns` must not be empty"
374 )));
375 }
376 if board.columns.iter().any(|c| c.trim().is_empty()) {
377 return Err(TixError::Config(format!(
378 "`kanban.{name}.columns` must not contain blank names"
379 )));
380 }
381 let mut seen: HashSet<&str> = HashSet::new();
382 for column in &board.columns {
383 if !seen.insert(column.as_str()) {
384 return Err(TixError::Config(format!(
385 "`kanban.{name}.columns` must be unique; found duplicate `{column}`"
386 )));
387 }
388 }
389 if let Some(done) = &board.done_column
390 && !board.columns.contains(done)
391 {
392 return Err(TixError::Config(format!(
393 "`kanban.{name}.done_column` (`{done}`) must be one of the configured columns"
394 )));
395 }
396 for hidden in &board.hidden_columns {
397 if !board.columns.contains(hidden) {
398 return Err(TixError::Config(format!(
399 "`kanban.{name}.hidden_columns` (`{hidden}`) must be a subset of `columns`"
400 )));
401 }
402 }
403 Ok(())
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use std::path::PathBuf;
410 use tempfile::TempDir;
411
412 fn write_toml(dir: &Path, contents: &str) -> PathBuf {
413 let path = dir.join("tix.toml");
414 std::fs::write(&path, contents).unwrap();
415 path
416 }
417
418 #[test]
419 fn defaults_match_contract() {
420 let c = Config::default();
421 let board = c.default_config();
422 assert_eq!(board.columns, ["todo", "in progress", "in review", "done"]);
423 assert_eq!(board.done_column.as_deref(), Some("done"));
424 assert_eq!(board.hidden_columns, Vec::<String>::new());
425 assert!(!board.show_mismatch);
426 assert_eq!(c.issues_dir, PathBuf::from("docs/issues"));
427 assert_eq!(c.editor, None);
428 assert_eq!(c.state.next_number, 0);
429 assert_eq!(c.export.gitlab, None);
430 assert_eq!(c.export.linear, None);
431 }
432
433 #[test]
434 fn load_returns_defaults_when_file_absent() {
435 let tmp = TempDir::new().unwrap();
436 let cfg = Config::load(tmp.path()).unwrap();
437 assert_eq!(cfg.state.next_number, 0);
438 assert_eq!(cfg.default_config().columns.len(), 4);
439 }
440
441 #[test]
442 fn load_synthesizes_default_config_when_kanban_section_missing() {
443 let tmp = TempDir::new().unwrap();
444 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
445 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
446 assert_eq!(cfg.kanban.configurations.len(), 1);
447 assert!(cfg.kanban.configurations.contains_key("default"));
448 assert_eq!(cfg.default_config().columns.len(), 4);
449 }
450
451 #[test]
452 fn load_parses_state_and_keeps_editor_and_remote_targets() {
453 let tmp = TempDir::new().unwrap();
454 let path = write_toml(
455 tmp.path(),
456 r#"issues_dir = "tickets"
457editor = "nvim"
458
459[kanban.default]
460columns = ["todo", "done"]
461done_column = "done"
462
463[state]
464next_number = 17
465
466[export.gitlab]
467project = "acme/tix"
468"#,
469 );
470 let cfg = Config::load_with_warning(&path, |_| panic!("no token, no warning")).unwrap();
471 assert_eq!(
472 cfg.kanban.configurations.get("default").unwrap().columns,
473 ["todo", "done"]
474 );
475 assert_eq!(cfg.issues_dir, PathBuf::from("tickets"));
476 assert_eq!(cfg.editor.as_deref(), Some("nvim"));
477 assert_eq!(cfg.state.next_number, 17);
478 assert_eq!(
479 cfg.export.gitlab.unwrap().project.as_deref(),
480 Some("acme/tix")
481 );
482 assert_eq!(cfg.export.linear, None);
483 }
484
485 #[test]
486 fn empty_columns_rejected_as_config_error() {
487 let tmp = TempDir::new().unwrap();
488 let path = write_toml(tmp.path(), "[kanban.scratch]\ncolumns = []\n");
489 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
490 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
491 assert!(err.to_string().contains("kanban.scratch.columns"));
492 }
493
494 #[test]
495 fn duplicate_columns_rejected_as_config_error() {
496 let tmp = TempDir::new().unwrap();
497 let path = write_toml(
498 tmp.path(),
499 "[kanban.scratch]\ncolumns = [\"todo\", \"todo\"]\n",
500 );
501 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
502 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
503 assert!(err.to_string().contains("duplicate"));
504 }
505
506 #[test]
507 fn blank_column_name_rejected() {
508 let tmp = TempDir::new().unwrap();
509 let path = write_toml(
510 tmp.path(),
511 "[kanban.scratch]\ncolumns = [\"todo\", \" \"]\n",
512 );
513 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
514 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
515 }
516
517 #[test]
518 fn done_column_must_be_in_columns() {
519 let tmp = TempDir::new().unwrap();
520 let path = write_toml(
521 tmp.path(),
522 "[kanban.scratch]\ncolumns = [\"todo\", \"in progress\"]\ndone_column = \"in review\"\n",
523 );
524 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
525 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
526 assert!(err.to_string().contains("kanban.scratch.done_column"));
527 }
528
529 #[test]
530 fn hidden_columns_must_be_subset_of_columns() {
531 let tmp = TempDir::new().unwrap();
532 let path = write_toml(
533 tmp.path(),
534 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\nhidden_columns = [\"nope\"]\n",
535 );
536 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
537 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
538 assert!(err.to_string().contains("kanban.scratch.hidden_columns"));
539 }
540
541 #[test]
542 fn hidden_done_column_loads_fine() {
543 let tmp = TempDir::new().unwrap();
544 let path = write_toml(
545 tmp.path(),
546 "[kanban.scratch]\ncolumns = [\"todo\", \"done\"]\ndone_column = \"done\"\nhidden_columns = [\"done\"]\n",
547 );
548 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
549 let board = cfg.kanban.configurations.get("scratch").unwrap();
550 assert_eq!(board.done_column.as_deref(), Some("done"));
551 assert_eq!(board.hidden_columns, ["done"]);
552 }
553
554 #[test]
555 fn legacy_top_level_keys_rejected() {
556 let tmp = TempDir::new().unwrap();
557 let path = write_toml(tmp.path(), "columns = [\"todo\", \"done\"]\n");
558 let err = Config::load_with_warning(&path, |_| {}).unwrap_err();
559 assert!(matches!(err, TixError::Config(_)), "got {err:?}");
560 assert!(err.to_string().contains("tix.toml"));
561 assert!(err.to_string().contains("columns"));
562 }
563
564 #[test]
565 fn token_warning_fires_once_per_remote_with_token() {
566 let tmp = TempDir::new().unwrap();
567 let path = write_toml(
568 tmp.path(),
569 r#"[export.gitlab]
570project = "a/b"
571token = "gl-secret"
572
573[export.linear]
574team = "Eng"
575token = "lin-secret"
576"#,
577 );
578 let mut warnings: Vec<String> = Vec::new();
579 let cfg = Config::load_with_warning(&path, |w| warnings.push(w.to_string())).unwrap();
580 assert_eq!(warnings.len(), 2);
581 assert!(warnings[0].contains("GITLAB_TOKEN"));
582 assert!(warnings[1].contains("LINEAR_TOKEN"));
583 for w in &warnings {
585 assert!(!w.contains("secret"));
586 }
587 assert_eq!(
588 cfg.export.gitlab.unwrap().token.as_deref(),
589 Some("gl-secret")
590 );
591 }
592
593 #[test]
594 fn no_token_means_no_warning() {
595 let tmp = TempDir::new().unwrap();
596 let path = write_toml(tmp.path(), "issues_dir = \"docs/issues\"\n");
597 let mut fired = false;
598 Config::load_with_warning(&path, |_| fired = true).unwrap();
599 assert!(!fired);
600 }
601
602 #[test]
603 fn save_never_writes_the_state_section() {
604 let tmp = TempDir::new().unwrap();
605 let path = tmp.path().join("tix.toml");
606 let mut cfg = Config::default();
609 cfg.state.next_number = 42;
610 cfg.save(&path).unwrap();
611
612 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
613 assert_eq!(reloaded.state.next_number, 0, "state is never persisted");
614 assert_eq!(
615 reloaded.default_config().columns,
616 cfg.default_config().columns
617 );
618
619 let on_disk = std::fs::read_to_string(&path).unwrap();
620 assert!(
621 !on_disk.contains("next_number"),
622 "next_number leaked into tix.toml:\n{on_disk}"
623 );
624 assert!(
625 !on_disk.contains("[state]"),
626 "state section written:\n{on_disk}"
627 );
628 }
629
630 #[test]
631 fn multi_config_roundtrip_preserves_declaration_order() {
632 let tmp = TempDir::new().unwrap();
633 let path = write_toml(
634 tmp.path(),
635 r#"issues_dir = "docs/issues"
636
637[kanban.release]
638columns = ["planned", "in progress", "done"]
639done_column = "done"
640show_mismatch = true
641
642[kanban.personal]
643columns = ["todo", "done"]
644done_column = "done"
645hidden_columns = ["done"]
646"#,
647 );
648 let cfg = Config::load_with_warning(&path, |_| {}).unwrap();
649 let names: Vec<&str> = cfg.configs().map(|(n, _)| n).collect();
650 assert_eq!(names, ["release", "personal"]);
651
652 assert_eq!(
654 cfg.default_config().columns,
655 ["planned", "in progress", "done"]
656 );
657
658 cfg.save(&path).unwrap();
659 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
660 assert_eq!(
661 cfg, reloaded,
662 "order and values survive save/load round trip"
663 );
664 let names2: Vec<&str> = reloaded.configs().map(|(n, _)| n).collect();
665 assert_eq!(names2, ["release", "personal"]);
666 }
667
668 #[test]
669 fn save_never_serializes_tokens_back_out() {
670 let tmp = TempDir::new().unwrap();
671 let path = tmp.path().join("tix.toml");
672 let mut cfg = Config::default();
673 cfg.export.gitlab = Some(RemoteConfig {
674 token: Some("secret".into()),
675 project: Some("a/b".into()),
676 ..RemoteConfig::default()
677 });
678 cfg.save(&path).unwrap();
679
680 let on_disk = std::fs::read_to_string(&path).unwrap();
681 assert!(
682 !on_disk.contains("secret"),
683 "token leaked to disk:\n{on_disk}"
684 );
685 assert!(
686 on_disk.contains("a/b"),
687 "non-secret fields survive:\n{on_disk}"
688 );
689
690 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
691 assert_eq!(reloaded.export.gitlab.unwrap().token, None);
692 }
693
694 #[test]
695 fn save_is_atomic_and_leaves_no_temp_file() {
696 let tmp = TempDir::new().unwrap();
697 let path = tmp.path().join("tix.toml");
698 Config::default().save(&path).unwrap();
699 let entries: Vec<_> = std::fs::read_dir(tmp.path())
700 .unwrap()
701 .filter_map(|e| e.ok())
702 .collect();
703 assert_eq!(entries.len(), 1);
704 assert_eq!(entries[0].file_name().to_string_lossy(), "tix.toml");
705 }
706
707 #[test]
708 fn default_loadwrites_are_idempotent() {
709 let tmp = TempDir::new().unwrap();
710 let path = tmp.path().join("tix.toml");
711 let cfg = Config::default();
712 cfg.save(&path).unwrap();
713 let reloaded = Config::load_with_warning(&path, |_| {}).unwrap();
714 assert_eq!(cfg, reloaded);
715 }
716
717 #[test]
718 fn effective_done_column_falls_back_to_last() {
719 let board = BoardConfiguration {
720 columns: vec!["todo".into(), "later".into()],
721 done_column: None,
722 hidden_columns: vec![],
723 show_mismatch: false,
724 };
725 assert_eq!(board.effective_done_column(), Some("later"));
726
727 let with_done = BoardConfiguration {
728 columns: vec!["todo".into(), "done".into(), "later".into()],
729 done_column: None,
730 hidden_columns: vec![],
731 show_mismatch: false,
732 };
733 assert_eq!(with_done.effective_done_column(), Some("done"));
734 }
735
736 #[test]
737 fn project_root_walks_up_the_tree() {
738 let tmp = TempDir::new().unwrap();
739 let nested = tmp.path().join("a").join("b");
740 std::fs::create_dir_all(&nested).unwrap();
741 std::fs::write(tmp.path().join("tix.toml"), "issues_dir = \"tix\"\n").unwrap();
742
743 assert_eq!(
744 Config::project_root(&nested),
745 Some(tmp.path().to_path_buf())
746 );
747 assert_eq!(
748 Config::project_root(tmp.path()),
749 Some(tmp.path().to_path_buf())
750 );
751 }
752
753 #[test]
754 fn project_root_is_none_when_no_toml_exists() {
755 let tmp = TempDir::new().unwrap();
756 assert_eq!(Config::project_root(tmp.path()), None);
757 }
758}