Skip to main content

tix/
config.rs

1//! `tix.toml` config schema, defaults, loading, saving and validation.
2//!
3//! Mirrors `contracts/tix-toml.md` and `data-model.md` §Config. The
4//! [`Config`] type is the Serde surface for `tix.toml`; [`StateConfig`]
5//! carries the legacy `[state].next_number` counter, which is tolerated on
6//! load and never written back out (issue #52 — the issue number is
7//! resolved at creation time by scanning the live issue set). Loading emits
8//! a warning whenever a cleartext token is read from the file, and
9//! [`Config::save`] never serializes tokens back to disk.
10
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use indexmap::IndexMap;
15use serde::{Deserialize, Serialize};
16
17use crate::error::{Result, TixError};
18
19/// Remote tracker credentials/targets embedded in `tix.toml`.
20///
21/// `token` values are read-only: they are never serialized back out and are
22/// never echoed in logs or errors. `base_url` may override the default
23/// endpoint for self-hosted GitLab instances.
24#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
25#[serde(default, deny_unknown_fields)]
26pub struct RemoteConfig {
27    /// Optional cleartext token; prefer the `GITLAB_TOKEN`/`LINEAR_TOKEN`
28    /// env vars. Never serialized back to disk.
29    pub token: Option<String>,
30    /// Optional base URL (e.g. self-hosted GitLab).
31    pub base_url: Option<String>,
32    /// GitLab project path (e.g. `acme/tix`) passed to `glab`.
33    pub project: Option<String>,
34    /// Linear team name passed to `linear-cli`.
35    pub team: Option<String>,
36}
37
38/// The `[export]` section: per-target remote configuration.
39#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
40#[serde(default, deny_unknown_fields)]
41pub struct ExportConfig {
42    /// GitLab remote target.
43    pub gitlab: Option<RemoteConfig>,
44    /// Linear remote target.
45    pub linear: Option<RemoteConfig>,
46}
47
48/// The `[state]` section — deprecated runtime bookkeeping that is still
49/// tolerated on load for legacy `tix.toml` files but is never serialized.
50///
51/// The monotonic `next_number` counter this held was removed in issue #52:
52/// issue numbers are now resolved at creation time by scanning the live
53/// issue set, so [`Config::save`] writes no `[state]` section at all.
54#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
55#[serde(default, deny_unknown_fields)]
56pub struct StateConfig {
57    /// Legacy monotonic issue-number counter (C-007). Read-only — kept so
58    /// old `tix.toml` files with `[state].next_number` still load, and
59    /// never persisted once saved again.
60    #[serde(skip_serializing)]
61    pub next_number: u32,
62}
63
64/// A named board configuration (`[kanban.<name>]` in `tix.toml`).
65///
66/// Carries the ordered column list plus per-configuration metadata: the done
67/// column, columns hidden from the rendered board, and whether the computed
68/// "Mismatched" pseudo-column is shown (C-002 / DM-01M2MH4D84NHG0JVWEPR6ZH4VR).
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(default, deny_unknown_fields)]
71pub struct BoardConfiguration {
72    /// Ordered, case-sensitive, non-empty, unique column names.
73    pub columns: Vec<String>,
74    /// The column treated as "done" (commit prompting). Defaults to `"done"`
75    /// when that name is present in `columns`, else the last configured
76    /// column (contracts/tix-toml.md field rules).
77    pub done_column: Option<String>,
78    /// Columns hidden from the rendered board. Must be a subset of `columns`;
79    /// their cards stay in board state and remain reachable via filters/search.
80    pub hidden_columns: Vec<String>,
81    /// Whether the computed "Mismatched" pseudo-column is rendered. Default
82    /// `false`. Never a configured column.
83    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    /// The resolved done column: the explicit `done_column`, else `"done"`
102    /// when present in `columns`, else the last configured column.
103    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/// The `[kanban]` section: named configurations in declaration order.
116///
117/// `#[serde(transparent)]` maps the TOML table `[kanban.<name>]` straight
118/// onto the [`IndexMap`]. The `toml` parser sorts table keys, so
119/// [`Config::load_with_warning`] re-orders the map from the source
120/// document's `[kanban.<name>]` header order via
121/// [`restore_declaration_order`] (D-02: declaration order = tab order,
122/// validated by the round-trip test). The map is never empty after load —
123/// [`Config::load_with_warning`] synthesizes the shipped `default`
124/// configuration when `tix.toml` omits the section.
125#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
126#[serde(default, transparent)]
127pub struct KanbanSection {
128    /// Named configurations, keyed by name, in declaration order.
129    pub configurations: IndexMap<String, BoardConfiguration>,
130}
131
132/// The project configuration file (`tix.toml`).
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(default, deny_unknown_fields)]
135pub struct Config {
136    /// Named board configurations (`[kanban]`). Always ≥1 after load.
137    pub kanban: KanbanSection,
138    /// Directory holding issue files, relative to the repo root.
139    pub issues_dir: PathBuf,
140    /// Optional `$EDITOR` override for full-file editing.
141    pub editor: Option<String>,
142    /// Configured AI agent command (opencode, claude, etc.) launched with
143    /// the issue body as its specification. Mirrors the `editor` resolution
144    /// chain (explicit → `$TIX_AGENT` → `$AGENT` env → PATH fallback).
145    pub agent: Option<String>,
146    /// Persisted runtime state (`[state]`). Tolerated on load for legacy
147    /// files, never written back to disk (issue #52).
148    #[serde(skip_serializing)]
149    pub state: StateConfig,
150    /// External tracker targets (`[export]`).
151    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    /// Validate the invariant rules from `contracts/tix-toml.md`, returning
174    /// the `Config` error variant with a human message. Errors name the
175    /// offending configuration as `kanban.<name>.<field>` (NFR-003).
176    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    /// Restore the canonical declaration order of named configurations,
192    /// which the TOML parser loses (it sorts table keys). The order comes
193    /// from `[kanban.<name>]` section headers in the source document.
194    fn restore_declaration_order(&mut self, contents: &str) {
195        crate::config::restore_declaration_order(&mut self.kanban, contents);
196    }
197
198    /// The configuration used by the CLI and other single-config surfaces:
199    /// the one named `default` if present, else the first declared.
200    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    /// Named configurations in declaration order (tab order = declaration
209    /// order, D-02).
210    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    /// Load a `tix.toml` at `path`, emitting a warning through `sink` for
218    /// every cleartext token read from the file.
219    ///
220    /// Parse and validation failures surface as [`TixError::Config`] with
221    /// the file's path and (where the TOML parser reports it) line.
222    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    /// Load the config for a project root: `tix.toml` at the root if
254    /// present, otherwise the defaults. An absent file is *not* an error —
255    /// the CLI layer decides whether to nag about `tix init`.
256    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    /// Find the project root for `cwd` by walking upward for a `tix.toml`.
265    /// Returns `None` when no ancestor (including `cwd` itself) holds one.
266    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    /// A copy of this config with every token stripped, for writing.
278    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    /// Write `tix.toml` atomically (temp file + rename in the same
290    /// directory, with an `fsync` before the rename — NFR-006).
291    ///
292    /// Tokens are never written to disk, so a round-trip `load` → `save`
293    /// drops any cleartext token (expected: prefer env vars).
294    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
331/// Reorder `configurations` to the declaration order of `[kanban.<name>]`
332/// section headers in the source document (D-02: declaration order = tab
333/// order). The `toml` crate sorts table keys on deserialize, so the parser's
334/// order is not trustworthy; this returns to the document's byte order.
335fn 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
368/// Per-configuration validation (NFR-003): non-empty unique non-blank
369/// columns, `done_column` ⊆ columns, `hidden_columns` ⊆ columns.
370fn 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        // token values must never leak into the warning
584        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        // Simulate a legacy load: `[state].next_number` is kept in memory
607        // but must never be written back out (issue #52).
608        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        // default_config falls back to the first declared when no "default".
653        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}