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/// Card ordering for the rendered board (`[kanban.<name>].order_by`).
65///
66/// Applied per column in the board builders; the store's underlying order is
67/// untouched so the CLI keeps its own sequencing (board-display FR-02).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
69pub enum OrderBy {
70    /// Most recently created issues first (the default).
71    #[default]
72    #[serde(rename = "created_at_desc")]
73    CreatedAtDesc,
74    /// Oldest issues first.
75    #[serde(rename = "created_at_asc")]
76    CreatedAtAsc,
77    /// Preserve the underlying store (filename/created-date ascending) order.
78    #[serde(rename = "none")]
79    None,
80}
81
82impl OrderBy {
83    /// The TOML spelling of this variant.
84    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    /// The next variant when cycling the setting in the TUI settings modal.
93    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/// A named board configuration (`[kanban.<name>]` in `tix.toml`).
103///
104/// Carries the ordered column list plus per-configuration metadata: the done
105/// column, columns hidden from the rendered board, and whether the computed
106/// "Mismatched" pseudo-column is shown (C-002 / DM-01M2MH4D84NHG0JVWEPR6ZH4VR).
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(default, deny_unknown_fields)]
109pub struct BoardConfiguration {
110    /// Ordered, case-sensitive, non-empty, unique column names.
111    pub columns: Vec<String>,
112    /// The column treated as "done" (commit prompting). Defaults to `"done"`
113    /// when that name is present in `columns`, else the last configured
114    /// column (contracts/tix-toml.md field rules).
115    pub done_column: Option<String>,
116    /// Columns hidden from the rendered board. Must be a subset of `columns`;
117    /// their cards stay in board state and remain reachable via filters/search.
118    pub hidden_columns: Vec<String>,
119    /// Whether the computed "Mismatched" pseudo-column is rendered. Default
120    /// `false`. Never a configured column.
121    pub show_mismatch: bool,
122    /// Whether long card titles wrap onto multiple lines. Default `true`.
123    pub wrap_titles: bool,
124    /// The maximum number of lines a wrapped card may occupy. Default `3`;
125    /// validated `>= 1` in [`validate_board`]. Ignored when `wrap_titles`
126    /// is `false`.
127    pub title_lines: usize,
128    /// Ordering of cards within each rendered column. Default
129    /// `created_at_desc` (most recently created first).
130    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    /// The resolved done column: the explicit `done_column`, else `"done"`
152    /// when present in `columns`, else the last configured column.
153    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/// The `[kanban]` section: named configurations in declaration order.
166///
167/// `#[serde(transparent)]` maps the TOML table `[kanban.<name>]` straight
168/// onto the [`IndexMap`]. The `toml` parser sorts table keys, so
169/// [`Config::load_with_warning`] re-orders the map from the source
170/// document's `[kanban.<name>]` header order via
171/// [`restore_declaration_order`] (D-02: declaration order = tab order,
172/// validated by the round-trip test). The map is never empty after load —
173/// [`Config::load_with_warning`] synthesizes the shipped `default`
174/// configuration when `tix.toml` omits the section.
175#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
176#[serde(default, transparent)]
177pub struct KanbanSection {
178    /// Named configurations, keyed by name, in declaration order.
179    pub configurations: IndexMap<String, BoardConfiguration>,
180}
181
182/// The project configuration file (`tix.toml`).
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(default, deny_unknown_fields)]
185pub struct Config {
186    /// Named board configurations (`[kanban]`). Always ≥1 after load.
187    pub kanban: KanbanSection,
188    /// Directory holding issue files, relative to the repo root.
189    pub issues_dir: PathBuf,
190    /// Optional `$EDITOR` override for full-file editing.
191    pub editor: Option<String>,
192    /// Configured AI agent command (opencode, claude, etc.) launched with
193    /// the issue body as its specification. Mirrors the `editor` resolution
194    /// chain (explicit → `$TIX_AGENT` → `$AGENT` env → PATH fallback).
195    pub agent: Option<String>,
196    /// Persisted runtime state (`[state]`). Tolerated on load for legacy
197    /// files, never written back to disk (issue #52).
198    #[serde(skip_serializing)]
199    pub state: StateConfig,
200    /// External tracker targets (`[export]`).
201    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    /// Validate the invariant rules from `contracts/tix-toml.md`, returning
224    /// the `Config` error variant with a human message. Errors name the
225    /// offending configuration as `kanban.<name>.<field>` (NFR-003).
226    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    /// Restore the canonical declaration order of named configurations,
242    /// which the TOML parser loses (it sorts table keys). The order comes
243    /// from `[kanban.<name>]` section headers in the source document.
244    fn restore_declaration_order(&mut self, contents: &str) {
245        crate::config::restore_declaration_order(&mut self.kanban, contents);
246    }
247
248    /// The configuration used by the CLI and other single-config surfaces:
249    /// the one named `default` if present, else the first declared.
250    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    /// Named configurations in declaration order (tab order = declaration
259    /// order, D-02).
260    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    /// Load a `tix.toml` at `path`, emitting a warning through `sink` for
268    /// every cleartext token read from the file.
269    ///
270    /// Parse and validation failures surface as [`TixError::Config`] with
271    /// the file's path and (where the TOML parser reports it) line.
272    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    /// Load the config for a project root: `tix.toml` at the root if
304    /// present, otherwise the defaults. An absent file is *not* an error —
305    /// the CLI layer decides whether to nag about `tix init`.
306    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    /// Find the project root for `cwd` by walking upward for a `tix.toml`.
315    /// Returns `None` when no ancestor (including `cwd` itself) holds one.
316    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    /// A copy of this config with every token stripped, for writing.
328    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    /// Write `tix.toml` atomically (temp file + rename in the same
340    /// directory, with an `fsync` before the rename — NFR-006).
341    ///
342    /// Tokens are never written to disk, so a round-trip `load` → `save`
343    /// drops any cleartext token (expected: prefer env vars).
344    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
381/// Reorder `configurations` to the declaration order of `[kanban.<name>]`
382/// section headers in the source document (D-02: declaration order = tab
383/// order). The `toml` crate sorts table keys on deserialize, so the parser's
384/// order is not trustworthy; this returns to the document's byte order.
385fn 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
418/// Per-configuration validation (NFR-003): non-empty unique non-blank
419/// columns, `done_column` ⊆ columns, `hidden_columns` ⊆ columns.
420fn 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        // token values must never leak into the warning
642        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        // Simulate a legacy load: `[state].next_number` is kept in memory
665        // but must never be written back out (issue #52).
666        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        // default_config falls back to the first declared when no "default".
711        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}