Skip to main content

pinto/
config.rs

1//! Board configuration (`.pinto/config.toml`).
2//!
3//! Stores Kanban columns (the workflow) and project settings using TOML, the same format used by
4//! item frontmatter.
5
6use crate::backlog::ItemId;
7use crate::error::{Error, Result};
8use crate::kanban_keys::KeyBindings;
9use crate::timezone::DisplayTimezone;
10use serde::{Deserialize, Serialize};
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13use tokio::fs;
14
15/// Default workflow columns, from left to right.
16pub const DEFAULT_COLUMNS: [&str; 4] = ["todo", "in-progress", "review", "done"];
17
18/// Board settings.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct Config {
22    /// Kanban columns (workflow states).
23    pub columns: Vec<String>,
24    /// Completion column name. Items in this column are explicitly sorted by completion time
25    /// (`done_at`, newest first) when the board is displayed.
26    ///
27    /// The completion column is selected by name, independently of its position in `columns`.
28    /// Completion-time sorting applies only to this column.
29    ///
30    /// This field is required in `config.toml`; `init` writes `done`, the last default column.
31    /// TOML places it before the `[project]` table, so keep it in this position.
32    pub done_column: String,
33    /// Project information.
34    pub project: Project,
35    /// Configuring Interactive Kanban (TUI). `init` writes out default values.
36    ///
37    /// In TOML, it is written as a table after `[project]`.
38    pub tui: TuiConfig,
39    /// Persistence backend selection. `init` writes out the default (file).
40    ///
41    /// In TOML, it is written out as a table at the end.
42    pub storage: StorageConfig,
43    /// WIP (work in progress) limit. `init` writes out the default (enabled, no restrictions).
44    ///
45    /// In TOML, it is written out as a table at the end.
46    pub wip: WipConfig,
47    /// Display settings shared by `show` and the Kanban details popup. Missing
48    /// entries use the built-in defaults; Markdown rendering is enabled by default.
49    #[serde(default)]
50    pub display: DisplayConfig,
51    /// Optional story-point aggregation for parent PBIs.
52    #[serde(default)]
53    pub points: PointsConfig,
54}
55
56/// Work-in-progress (WIP) limit settings.
57///
58/// Set a per-column upper limit in [`WipConfig::limits`]. The default is `enabled = true` with no
59/// limits, so behavior is unchanged until a limit is configured. Set `enabled = false` to disable
60/// checking for the project.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(default, deny_unknown_fields)]
63pub struct WipConfig {
64    /// Whether WIP limit checking is enabled; `false` disables it for the project.
65    ///
66    /// This scalar is serialized before the [`WipConfig::limits`] table.
67    pub enabled: bool,
68    /// Map of column names to maximum concurrent PBI counts. A column without an entry is unlimited.
69    ///
70    /// `BTreeMap` keeps `[wip.limits]` deterministic; omit the table when it is empty.
71    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
72    pub limits: BTreeMap<String, u32>,
73}
74
75impl Default for WipConfig {
76    fn default() -> Self {
77        // Enable checking by default; with no limits configured, it produces no warnings.
78        Self {
79            enabled: true,
80            limits: BTreeMap::new(),
81        }
82    }
83}
84
85/// Persistence backend configuration.
86#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
87#[serde(default, deny_unknown_fields)]
88pub struct StorageConfig {
89    /// Type of destination backend.
90    pub backend: StorageBackend,
91}
92
93/// Storage backend selected for the board.
94///
95/// All backends preserve the local-first, Git-friendly workflow. Serde rejects unknown values and
96/// `Config::load` reports a clear parse error.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum StorageBackend {
100    /// Local files (the default), stored as Markdown under `.pinto/`.
101    #[default]
102    File,
103    /// Git backend; commit each change operation in addition to saving the files.
104    Git,
105    /// SQLite backend (optional `sqlite` feature), stored in `.pinto/board.sqlite3`. Use
106    /// `migrate` to move between backends; builds without the feature reject `sqlite` as unknown.
107    #[cfg(feature = "sqlite")]
108    Sqlite,
109}
110
111impl std::fmt::Display for StorageBackend {
112    /// Match the value written to `config.toml` (`rename_all = "lowercase"` of serde).
113    /// Use the same notation for message display and migration command argument interpretation.
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        let s = match self {
116            StorageBackend::File => "file",
117            StorageBackend::Git => "git",
118            #[cfg(feature = "sqlite")]
119            StorageBackend::Sqlite => "sqlite",
120        };
121        f.write_str(s)
122    }
123}
124
125/// Project information.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct Project {
129    /// Display name.
130    pub name: String,
131    /// PBI ID prefix (e.g. `T`).
132    pub key: String,
133}
134
135/// Configuring Interactive Kanban (TUI).
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(default, deny_unknown_fields)]
138pub struct TuiConfig {
139    /// Whether to display a confirmation popup when exiting (`q`). `false` terminates immediately without confirmation.
140    pub confirm_quit: bool,
141    /// Workflow columns hidden from the default Kanban display. Explicit `kanban --column` values override this list.
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub hidden_columns: Vec<String>,
144    /// Key assignments for Kanban operations. Missing entries use the built-in defaults.
145    pub key_bindings: KeyBindings,
146}
147
148impl Default for TuiConfig {
149    fn default() -> Self {
150        // To prevent accidental termination, the default setting is to confirm (opt-out possible in settings).
151        Self {
152            confirm_quit: true,
153            hidden_columns: Vec::new(),
154            key_bindings: KeyBindings::default(),
155        }
156    }
157}
158
159/// Display settings shared by `show` and the interactive Kanban details popup.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(default, deny_unknown_fields)]
162pub struct DisplayConfig {
163    /// Render PBI bodies as Markdown (styled headings, bullets, code) instead of
164    /// raw text. `true` by default; set `false` to use plain text.
165    pub markdown: bool,
166    /// Human-readable timestamp timezone: `local`, `UTC`, or a fixed `±HH:MM` offset.
167    #[serde(default)]
168    pub timezone: DisplayTimezone,
169}
170
171/// Story-point calculation settings.
172#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
173#[serde(default, deny_unknown_fields)]
174pub struct PointsConfig {
175    /// Replace a parent PBI's displayed points with the sum of its active descendants.
176    pub aggregate_children: bool,
177}
178
179impl Default for DisplayConfig {
180    fn default() -> Self {
181        // Markdown rendering is the standard, readable display (opt-out available).
182        Self {
183            markdown: true,
184            timezone: DisplayTimezone::Local,
185        }
186    }
187}
188
189impl Default for Config {
190    fn default() -> Self {
191        Self {
192            columns: DEFAULT_COLUMNS
193                .iter()
194                .map(std::string::ToString::to_string)
195                .collect(),
196            // Specify the completion column of the default workflow (`done` at the end of `DEFAULT_COLUMNS`).
197            done_column: DEFAULT_COLUMNS
198                .last()
199                .map(std::string::ToString::to_string)
200                .unwrap_or_else(|| "done".to_string()),
201            project: Project {
202                name: "pinto".to_string(),
203                key: "T".to_string(),
204            },
205            tui: TuiConfig::default(),
206            storage: StorageConfig::default(),
207            wip: WipConfig::default(),
208            display: DisplayConfig::default(),
209            points: PointsConfig::default(),
210        }
211    }
212}
213
214impl Config {
215    /// Load configuration from TOML file (I/O is asynchronous).
216    pub async fn load(path: &Path) -> Result<Config> {
217        let text = fs::read_to_string(path)
218            .await
219            .map_err(|e| Error::io(path, &e))?;
220        let document: toml::Value =
221            toml::from_str(&text).map_err(|e| Error::parse(path, e.to_string()))?;
222        validate_known_fields(&document).map_err(|message| Error::parse(path, message))?;
223        let mut config: Config =
224            toml::from_str(&text).map_err(|e| Error::parse(path, e.to_string()))?;
225        validate_semantics(path, &config)?;
226        config.tui.key_bindings = config.tui.key_bindings.with_defaults();
227        config
228            .tui
229            .key_bindings
230            .validate()
231            .map_err(|e| Error::parse(path, format!("[tui.key_bindings] {e}")))?;
232        Ok(config)
233    }
234
235    /// Export settings to TOML file (create parent directory if necessary, I/O is asynchronous).
236    ///
237    /// Writing is performed by replacing the temporary file → `rename` with corruption resistance ([`crate::storage::atomic_write`]).
238    pub async fn save(&self, path: &Path) -> Result<()> {
239        let text = toml::to_string_pretty(self).map_err(|e| Error::parse(path, e.to_string()))?;
240        if let Some(parent) = path.parent() {
241            fs::create_dir_all(parent)
242                .await
243                .map_err(|e| Error::io(parent, &e))?;
244        }
245        crate::storage::atomic_write(path, &text).await
246    }
247}
248
249/// Reject unknown fields before serde turns the document into typed settings.
250///
251/// `deny_unknown_fields` is also present on every fixed-shape settings table. This
252/// preflight is what lets errors identify the TOML table and field rather than only
253/// reporting a generic serde field name.
254fn validate_known_fields(document: &toml::Value) -> std::result::Result<(), String> {
255    let Some(root) = document.as_table() else {
256        return Ok(());
257    };
258
259    reject_unknown_fields(
260        root,
261        "config",
262        &[
263            "columns",
264            "done_column",
265            "project",
266            "tui",
267            "storage",
268            "wip",
269            "display",
270            "points",
271        ],
272    )?;
273    validate_nested_fields(root, "project", "[project]", &["name", "key"])?;
274    validate_nested_fields(
275        root,
276        "tui",
277        "[tui]",
278        &["confirm_quit", "hidden_columns", "key_bindings"],
279    )?;
280    validate_nested_fields(root, "storage", "[storage]", &["backend"])?;
281    validate_nested_fields(root, "wip", "[wip]", &["enabled", "limits"])?;
282    validate_nested_fields(root, "display", "[display]", &["markdown", "timezone"])?;
283    validate_nested_fields(root, "points", "[points]", &["aggregate_children"])?;
284    Ok(())
285}
286
287fn validate_nested_fields(
288    root: &toml::map::Map<String, toml::Value>,
289    field: &str,
290    path: &str,
291    allowed: &[&str],
292) -> std::result::Result<(), String> {
293    let Some(table) = root.get(field).and_then(toml::Value::as_table) else {
294        return Ok(());
295    };
296    reject_unknown_fields(table, path, allowed)
297}
298
299fn reject_unknown_fields(
300    table: &toml::map::Map<String, toml::Value>,
301    path: &str,
302    allowed: &[&str],
303) -> std::result::Result<(), String> {
304    if let Some(field) = table
305        .keys()
306        .find(|field| !allowed.contains(&field.as_str()))
307    {
308        return Err(format!(
309            "unknown configuration field {path}.{field:?}; remove it or check the documented schema"
310        ));
311    }
312    Ok(())
313}
314
315fn validate_semantics(path: &Path, config: &Config) -> Result<()> {
316    if config.columns.is_empty() {
317        return Err(Error::parse(
318            path,
319            "columns must contain at least one non-blank column",
320        ));
321    }
322
323    let mut columns = BTreeSet::new();
324    for (index, column) in config.columns.iter().enumerate() {
325        if column.trim().is_empty() {
326            return Err(Error::parse(
327                path,
328                format!("[columns][{index}] must not be blank"),
329            ));
330        }
331        if !columns.insert(column) {
332            return Err(Error::parse(
333                path,
334                format!("[columns][{index}] is a duplicate of column {column:?}"),
335            ));
336        }
337    }
338
339    if !config
340        .columns
341        .iter()
342        .any(|column| column == &config.done_column)
343    {
344        return Err(Error::parse(
345            path,
346            format!(
347                "done_column {:?} is not included in columns",
348                config.done_column
349            ),
350        ));
351    }
352    if let Some(hidden) = config
353        .tui
354        .hidden_columns
355        .iter()
356        .find(|hidden| !config.columns.iter().any(|column| column == *hidden))
357    {
358        return Err(Error::parse(
359            path,
360            format!(
361                "[tui] hidden_columns contains unknown status {hidden:?}; remove it or add it to columns"
362            ),
363        ));
364    }
365    if let Some((column, _)) = config.wip.limits.iter().find(|(column, _)| {
366        !config
367            .columns
368            .iter()
369            .any(|configured| configured == *column)
370    }) {
371        return Err(Error::parse(
372            path,
373            format!(
374                "[wip.limits] column {column:?} is not included in columns; remove it or add it to columns"
375            ),
376        ));
377    }
378    if config.project.name.trim().is_empty() {
379        return Err(Error::parse(
380            path,
381            "[project].name must not be empty or whitespace",
382        ));
383    }
384    if ItemId::try_new(&config.project.key, 1).is_err() {
385        return Err(Error::parse(
386            path,
387            format!(
388                "[project].key {:?} is not a safe item-id prefix; use ASCII letters only",
389                config.project.key
390            ),
391        ));
392    }
393    Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use tempfile::TempDir;
400
401    /// A complete `config.toml` of the current spec. All sections are required, so use them as the basis for the test.
402    /// You can add individual sections (e.g. `[wip.limits]`) by concatenating `extra` to the end.
403    fn complete_config(extra: &str) -> String {
404        format!(
405            "columns = [\"todo\", \"in-progress\", \"review\", \"done\"]\n\
406             done_column = \"done\"\n\n\
407             [project]\nname = \"x\"\nkey = \"T\"\n\n\
408             [tui]\nconfirm_quit = true\n\n\
409             [storage]\nbackend = \"file\"\n\n\
410             [wip]\nenabled = true\n{extra}"
411        )
412    }
413
414    #[test]
415    fn default_has_standard_columns_and_key() {
416        let c = Config::default();
417        assert_eq!(c.columns, ["todo", "in-progress", "review", "done"]);
418        assert_eq!(c.project.key, "T");
419    }
420
421    #[test]
422    fn default_done_column_is_last_column() {
423        let c = Config::default();
424        assert_eq!(c.done_column, "done");
425    }
426
427    #[test]
428    fn default_tui_confirms_quit() {
429        assert!(Config::default().tui.confirm_quit);
430    }
431
432    #[test]
433    fn default_tui_shows_all_columns() {
434        assert!(Config::default().tui.hidden_columns.is_empty());
435    }
436
437    #[test]
438    fn default_display_renders_markdown() {
439        assert!(Config::default().display.markdown);
440        assert_eq!(
441            Config::default().display.timezone,
442            crate::timezone::DisplayTimezone::Local
443        );
444    }
445
446    #[test]
447    fn child_point_aggregation_is_disabled_by_default() {
448        assert!(!Config::default().points.aggregate_children);
449    }
450
451    #[tokio::test]
452    async fn load_reads_explicit_child_point_aggregation_opt_in() {
453        let dir = TempDir::new().expect("temp dir");
454        let path = dir.path().join("config.toml");
455        std::fs::write(
456            &path,
457            format!(
458                "{}\n[points]\naggregate_children = true\n",
459                complete_config("")
460            ),
461        )
462        .expect("write");
463
464        let loaded = Config::load(&path).await.expect("load succeeds");
465
466        assert!(loaded.points.aggregate_children);
467    }
468
469    #[tokio::test]
470    async fn load_rejects_unknown_point_setting_with_a_configuration_path() {
471        let dir = TempDir::new().expect("temp dir");
472        let path = dir.path().join("config.toml");
473        std::fs::write(
474            &path,
475            format!(
476                "{}\n[points]\naggregate_chidren = true\n",
477                complete_config("")
478            ),
479        )
480        .expect("write");
481
482        let error = Config::load(&path)
483            .await
484            .expect_err("unknown points setting rejected");
485        let message = error.to_string();
486        assert!(message.contains("[points]"), "field path: {message}");
487        assert!(
488            message.contains("aggregate_chidren"),
489            "field name: {message}"
490        );
491    }
492
493    #[tokio::test]
494    async fn load_defaults_display_markdown_when_section_absent() {
495        let dir = TempDir::new().expect("temp dir");
496        let path = dir.path().join("config.toml");
497        std::fs::write(&path, complete_config("")).expect("write");
498        let loaded = Config::load(&path).await.expect("load succeeds");
499        assert!(loaded.display.markdown, "absent [display] defaults to on");
500        assert_eq!(
501            loaded.display.timezone,
502            crate::timezone::DisplayTimezone::Local,
503            "absent [display] uses the local timezone"
504        );
505    }
506
507    #[tokio::test]
508    async fn load_respects_display_markdown_opt_out() {
509        let dir = TempDir::new().expect("temp dir");
510        let path = dir.path().join("config.toml");
511        std::fs::write(
512            &path,
513            format!(
514                "{}
515[display]
516markdown = false
517",
518                complete_config("")
519            ),
520        )
521        .expect("write");
522        let loaded = Config::load(&path).await.expect("load succeeds");
523        assert!(
524            !loaded.display.markdown,
525            "[display] markdown=false opts out"
526        );
527    }
528
529    #[tokio::test]
530    async fn load_rejects_an_invalid_display_timezone_with_guidance() {
531        let dir = TempDir::new().expect("temp dir");
532        let path = dir.path().join("config.toml");
533        std::fs::write(
534            &path,
535            format!(
536                "{}\n[display]\ntimezone = \"Mars/Base\"\n",
537                complete_config("")
538            ),
539        )
540        .expect("write");
541
542        let error = Config::load(&path).await.expect_err("timezone rejected");
543        let message = error.to_string();
544        assert!(message.contains("local"), "mentions local: {message}");
545        assert!(message.contains("UTC"), "mentions UTC: {message}");
546        assert!(
547            message.contains("HH:MM"),
548            "mentions offset format: {message}"
549        );
550    }
551
552    #[tokio::test]
553    async fn load_rejects_unknown_nested_fields_with_a_configuration_path() {
554        let dir = TempDir::new().expect("temp dir");
555        let path = dir.path().join("config.toml");
556        std::fs::write(
557            &path,
558            format!("{}\n[display]\ntimezome = \"UTC\"\n", complete_config("")),
559        )
560        .expect("write");
561
562        let error = Config::load(&path)
563            .await
564            .expect_err("unknown display field rejected");
565        let message = error.to_string();
566        assert!(message.contains("display"), "field path: {message}");
567        assert!(message.contains("timezome"), "field name: {message}");
568    }
569
570    #[tokio::test]
571    async fn load_rejects_unknown_top_level_fields() {
572        let dir = TempDir::new().expect("temp dir");
573        let path = dir.path().join("config.toml");
574        std::fs::write(&path, format!("unknown = true\n{}", complete_config(""))).expect("write");
575
576        let error = Config::load(&path)
577            .await
578            .expect_err("unknown top-level field rejected");
579        let message = error.to_string();
580        assert!(message.contains("unknown"), "field name: {message}");
581    }
582
583    #[tokio::test]
584    async fn load_rejects_blank_and_duplicate_columns() {
585        let dir = TempDir::new().expect("temp dir");
586        let path = dir.path().join("config.toml");
587
588        for (columns, expected) in [
589            ("[]", "at least one"),
590            ("[\"todo\", \" \"]", "blank"),
591            ("[\"todo\", \"todo\"]", "duplicate"),
592        ] {
593            let config = complete_config("")
594                .replace(
595                    "columns = [\"todo\", \"in-progress\", \"review\", \"done\"]",
596                    &format!("columns = {columns}"),
597                )
598                .replace("done_column = \"done\"", "done_column = \"todo\"");
599            std::fs::write(&path, config).expect("write");
600
601            let error = Config::load(&path)
602                .await
603                .expect_err("invalid columns rejected");
604            assert!(
605                error.to_string().contains(expected),
606                "{expected} columns: {error}"
607            );
608        }
609    }
610
611    #[tokio::test]
612    async fn load_rejects_wip_limits_for_unknown_columns() {
613        let dir = TempDir::new().expect("temp dir");
614        let path = dir.path().join("config.toml");
615        std::fs::write(
616            &path,
617            format!("{}\n[wip.limits]\nmissing = 1\n", complete_config("")),
618        )
619        .expect("write");
620
621        let error = Config::load(&path)
622            .await
623            .expect_err("unknown WIP column rejected");
624        let message = error.to_string();
625        assert!(message.contains("wip.limits"), "field path: {message}");
626        assert!(message.contains("missing"), "column name: {message}");
627    }
628
629    #[tokio::test]
630    async fn load_rejects_blank_project_name() {
631        let dir = TempDir::new().expect("temp dir");
632        let path = dir.path().join("config.toml");
633        std::fs::write(
634            &path,
635            complete_config("").replace("name = \"x\"", "name = \"  \""),
636        )
637        .expect("write");
638
639        let error = Config::load(&path)
640            .await
641            .expect_err("blank project name rejected");
642        let message = error.to_string();
643        assert!(message.contains("[project].name"), "field path: {message}");
644        assert!(message.contains("empty"), "guidance: {message}");
645    }
646
647    #[tokio::test]
648    async fn load_reads_tui_hidden_columns() {
649        let dir = TempDir::new().expect("temp dir");
650        let path = dir.path().join("config.toml");
651        let text = complete_config("").replace(
652            "[tui]\nconfirm_quit = true",
653            "[tui]\nconfirm_quit = true\nhidden_columns = [\"in-progress\", \"review\"]",
654        );
655        std::fs::write(&path, text).expect("write");
656
657        let loaded = Config::load(&path).await.expect("load succeeds");
658
659        assert_eq!(loaded.tui.hidden_columns, ["in-progress", "review"]);
660    }
661
662    #[tokio::test]
663    async fn load_rejects_unknown_tui_hidden_column_with_guidance() {
664        let dir = TempDir::new().expect("temp dir");
665        let path = dir.path().join("config.toml");
666        let text = complete_config("").replace(
667            "[tui]\nconfirm_quit = true",
668            "[tui]\nconfirm_quit = true\nhidden_columns = [\"missing\"]",
669        );
670        std::fs::write(&path, text).expect("write");
671
672        let error = Config::load(&path)
673            .await
674            .expect_err("unknown column rejected");
675        let message = error.to_string();
676
677        assert!(
678            message.contains("hidden_columns"),
679            "mentions the setting: {message}"
680        );
681        assert!(
682            message.contains("missing"),
683            "mentions the invalid value: {message}"
684        );
685        assert!(
686            message.contains("columns"),
687            "explains how to fix it: {message}"
688        );
689    }
690
691    #[test]
692    fn default_tui_key_bindings_cover_the_existing_kanban_controls() {
693        let bindings = Config::default().tui.key_bindings;
694
695        assert_eq!(
696            bindings.keys(crate::kanban_keys::KeyAction::Quit),
697            ["q".to_string(), "Esc".to_string()]
698        );
699        assert_eq!(
700            bindings.keys(crate::kanban_keys::KeyAction::SelectLeft),
701            ["h".to_string(), "Left".to_string()]
702        );
703    }
704
705    #[tokio::test]
706    async fn load_partial_key_bindings_fills_unconfigured_actions() {
707        let dir = TempDir::new().expect("temp dir");
708        let path = dir.path().join("config.toml");
709        std::fs::write(
710            &path,
711            format!(
712                "{}\n[tui.key_bindings]\nquit = [\"Ctrl+a\", \"Esc\"]\n",
713                complete_config("")
714            ),
715        )
716        .expect("write");
717
718        let loaded = Config::load(&path).await.expect("load succeeds");
719        assert_eq!(
720            loaded
721                .tui
722                .key_bindings
723                .keys(crate::kanban_keys::KeyAction::Quit),
724            ["Ctrl+a".to_string(), "Esc".to_string()]
725        );
726        assert_eq!(
727            loaded
728                .tui
729                .key_bindings
730                .keys(crate::kanban_keys::KeyAction::SelectLeft),
731            ["h".to_string(), "Left".to_string()]
732        );
733    }
734
735    #[tokio::test]
736    async fn invalid_key_binding_reports_the_operation_and_fix_syntax() {
737        let dir = TempDir::new().expect("temp dir");
738        let path = dir.path().join("config.toml");
739        std::fs::write(
740            &path,
741            format!(
742                "{}\n[tui.key_bindings]\nedit = [\"Controlled+a\"]\n",
743                complete_config("")
744            ),
745        )
746        .expect("write");
747
748        let error = Config::load(&path).await.expect_err("invalid key rejected");
749        let message = error.to_string();
750        assert!(message.contains("edit"));
751        assert!(message.contains("Ctrl"));
752    }
753
754    #[tokio::test]
755    async fn load_without_tui_table_is_error() {
756        // `[tui]` is required in the current specification. Do not ignore the omissions and treat them as parse errors.
757        let dir = TempDir::new().expect("temp dir");
758        let path = dir.path().join("config.toml");
759        std::fs::write(
760            &path,
761            "columns = [\"todo\", \"done\"]\ndone_column = \"done\"\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[storage]\nbackend = \"file\"\n\n[wip]\nenabled = true\n",
762        )
763        .expect("write");
764
765        let err = Config::load(&path)
766            .await
767            .expect_err("missing [tui] rejected");
768        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
769    }
770
771    #[tokio::test]
772    async fn load_tui_confirm_quit_false_overrides_default() {
773        let dir = TempDir::new().expect("temp dir");
774        let path = dir.path().join("config.toml");
775        std::fs::write(
776            &path,
777            "columns = [\"todo\", \"in-progress\", \"review\", \"done\"]\ndone_column = \"done\"\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[tui]\nconfirm_quit = false\n\n[storage]\nbackend = \"file\"\n\n[wip]\nenabled = true\n",
778        )
779        .expect("write");
780
781        let loaded = Config::load(&path).await.expect("load succeeds");
782        assert!(!loaded.tui.confirm_quit);
783    }
784
785    #[test]
786    fn default_storage_backend_is_file() {
787        assert_eq!(Config::default().storage.backend, StorageBackend::File);
788    }
789
790    #[test]
791    fn known_field_preflight_accepts_every_serialized_config_field() {
792        // Keep the diagnostic preflight in sync with serde's schema whenever a serializable
793        // configuration field is added. Without this check, a field could be accepted by serde
794        // but rejected by the more specific preflight error path.
795        let text = toml::to_string(&Config::default()).expect("default config serializes");
796        let document = toml::from_str(&text).expect("serialized config is valid TOML");
797
798        validate_known_fields(&document).expect("serialized config fields are all allowlisted");
799    }
800
801    #[tokio::test]
802    async fn load_without_storage_table_is_error() {
803        // `[storage]` is required in the current specifications. Any omissions will result in a parsing error.
804        let dir = TempDir::new().expect("temp dir");
805        let path = dir.path().join("config.toml");
806        std::fs::write(
807            &path,
808            "columns = [\"todo\", \"done\"]\ndone_column = \"done\"\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[tui]\nconfirm_quit = true\n\n[wip]\nenabled = true\n",
809        )
810        .expect("write");
811
812        let err = Config::load(&path)
813            .await
814            .expect_err("missing [storage] rejected");
815        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
816    }
817
818    #[tokio::test]
819    async fn load_explicit_file_backend() {
820        let dir = TempDir::new().expect("temp dir");
821        let path = dir.path().join("config.toml");
822        std::fs::write(&path, complete_config("")).expect("write");
823
824        let loaded = Config::load(&path).await.expect("load succeeds");
825        assert_eq!(loaded.storage.backend, StorageBackend::File);
826    }
827
828    #[tokio::test]
829    async fn load_rejects_an_unsafe_project_key_before_any_backend_opens() {
830        let dir = TempDir::new().expect("temp dir");
831        let path = dir.path().join("config.toml");
832        for key in [
833            "123",
834            "p9",
835            "bug_fix",
836            "PROJ-1",
837            "../outside",
838            "/tmp/outside",
839            "T\\outside",
840            "T space",
841        ] {
842            let toml_key = key.replace('\\', "\\\\");
843            let config =
844                complete_config("").replace("key = \"T\"", &format!("key = \"{toml_key}\""));
845            std::fs::write(&path, config).expect("write");
846
847            let err = Config::load(&path)
848                .await
849                .expect_err("unsafe project key rejected");
850            assert!(
851                err.to_string().contains("safe item-id prefix"),
852                "key {key:?}: {err}"
853            );
854        }
855    }
856
857    #[tokio::test]
858    async fn load_unknown_backend_is_a_clear_error() {
859        // Don't ignore unknown backend values and make them clear errors. `sqlite` is enabled when the function is enabled.
860        // Always verify with an unknown fictitious value to ensure it is a legitimate value.
861        let dir = TempDir::new().expect("temp dir");
862        let path = dir.path().join("config.toml");
863        std::fs::write(
864            &path,
865            "columns = [\"todo\", \"done\"]\ndone_column = \"done\"\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[tui]\nconfirm_quit = true\n\n[storage]\nbackend = \"postgres\"\n\n[wip]\nenabled = true\n",
866        )
867        .expect("write");
868
869        let err = Config::load(&path)
870            .await
871            .expect_err("unknown backend rejected");
872        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
873    }
874
875    /// In builds with the `sqlite` feature disabled, `backend = "sqlite"` is rejected as an unknown value.
876    #[cfg(not(feature = "sqlite"))]
877    #[tokio::test]
878    async fn load_sqlite_backend_rejected_without_feature() {
879        let dir = TempDir::new().expect("temp dir");
880        let path = dir.path().join("config.toml");
881        std::fs::write(
882            &path,
883            "columns = [\"todo\", \"done\"]\ndone_column = \"done\"\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[tui]\nconfirm_quit = true\n\n[storage]\nbackend = \"sqlite\"\n\n[wip]\nenabled = true\n",
884        )
885        .expect("write");
886
887        let err = Config::load(&path)
888            .await
889            .expect_err("sqlite rejected without feature");
890        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
891    }
892
893    /// In a build with the `sqlite` feature enabled, `backend = "sqlite"` can be read as a legal value.
894    #[cfg(feature = "sqlite")]
895    #[tokio::test]
896    async fn load_sqlite_backend_accepted_with_feature() {
897        let dir = TempDir::new().expect("temp dir");
898        let path = dir.path().join("config.toml");
899        std::fs::write(
900            &path,
901            "columns = [\"todo\", \"done\"]\ndone_column = \"done\"\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[tui]\nconfirm_quit = true\n\n[storage]\nbackend = \"sqlite\"\n\n[wip]\nenabled = true\n",
902        )
903        .expect("write");
904
905        let loaded = Config::load(&path).await.expect("load succeeds");
906        assert_eq!(loaded.storage.backend, StorageBackend::Sqlite);
907    }
908
909    #[test]
910    fn default_wip_is_enabled_with_no_limits() {
911        let c = Config::default();
912        assert!(c.wip.enabled);
913        assert!(c.wip.limits.is_empty());
914    }
915
916    #[tokio::test]
917    async fn load_without_wip_table_is_error() {
918        // `[wip]` is required in the current specification. Any omissions will result in a parsing error.
919        let dir = TempDir::new().expect("temp dir");
920        let path = dir.path().join("config.toml");
921        std::fs::write(
922            &path,
923            "columns = [\"todo\", \"done\"]\ndone_column = \"done\"\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[tui]\nconfirm_quit = true\n\n[storage]\nbackend = \"file\"\n",
924        )
925        .expect("write");
926
927        let err = Config::load(&path)
928            .await
929            .expect_err("missing [wip] rejected");
930        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
931    }
932
933    #[tokio::test]
934    async fn load_wip_limits_and_enabled_flag() {
935        let dir = TempDir::new().expect("temp dir");
936        let path = dir.path().join("config.toml");
937        std::fs::write(
938            &path,
939            "columns = [\"todo\", \"in-progress\", \"done\"]\ndone_column = \"done\"\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[tui]\nconfirm_quit = true\n\n[storage]\nbackend = \"file\"\n\n[wip]\nenabled = false\n\n[wip.limits]\nin-progress = 3\n",
940        )
941        .expect("write");
942
943        let loaded = Config::load(&path).await.expect("load succeeds");
944        assert!(!loaded.wip.enabled);
945        assert_eq!(loaded.wip.limits.get("in-progress"), Some(&3));
946    }
947
948    #[tokio::test]
949    async fn save_then_load_roundtrips_wip_limits() {
950        let dir = TempDir::new().expect("temp dir");
951        let path = dir.path().join("config.toml");
952        let mut config = Config::default();
953        config.wip.limits.insert("in-progress".to_string(), 2);
954        config.wip.limits.insert("review".to_string(), 1);
955
956        config.save(&path).await.expect("save succeeds");
957        let loaded = Config::load(&path).await.expect("load succeeds");
958
959        assert_eq!(loaded, config);
960    }
961
962    #[tokio::test]
963    async fn load_without_done_column_is_error() {
964        // done_column is required in the current specification. Any omissions will result in a parsing error.
965        let dir = TempDir::new().expect("temp dir");
966        let path = dir.path().join("config.toml");
967        std::fs::write(
968            &path,
969            "columns = [\"todo\", \"done\"]\n\n[project]\nname = \"x\"\nkey = \"T\"\n\n[tui]\nconfirm_quit = true\n\n[storage]\nbackend = \"file\"\n\n[wip]\nenabled = true\n",
970        )
971        .expect("write");
972
973        let err = Config::load(&path)
974            .await
975            .expect_err("missing done_column rejected");
976        assert!(matches!(err, Error::Parse { .. }), "got {err:?}");
977    }
978
979    #[tokio::test]
980    async fn load_rejects_done_column_outside_workflow() {
981        let dir = TempDir::new().expect("temp dir");
982        let path = dir.path().join("config.toml");
983        let config = complete_config("")
984            .replace(
985                "columns = [\"todo\", \"in-progress\", \"review\", \"done\"]",
986                "columns = [\"todo\"]",
987            )
988            .replace("done_column = \"done\"", "done_column = \"missing\"");
989        std::fs::write(&path, config).expect("write");
990
991        let error = Config::load(&path).await.expect_err("invalid done column");
992        assert!(error.to_string().contains("done_column"));
993    }
994
995    #[tokio::test]
996    async fn save_then_load_roundtrips() {
997        let dir = TempDir::new().expect("temp dir");
998        let path = dir.path().join("config.toml");
999        let config = Config::default();
1000
1001        config.save(&path).await.expect("save succeeds");
1002        let loaded = Config::load(&path).await.expect("load succeeds");
1003
1004        assert_eq!(loaded, config);
1005    }
1006
1007    #[tokio::test]
1008    async fn save_creates_missing_parent_dirs() {
1009        let dir = TempDir::new().expect("temp dir");
1010        let path = dir.path().join("nested").join("config.toml");
1011
1012        Config::default()
1013            .save(&path)
1014            .await
1015            .expect("save creates parents");
1016        assert!(path.is_file());
1017    }
1018
1019    #[tokio::test]
1020    async fn load_invalid_toml_errors_without_panic() {
1021        let dir = TempDir::new().expect("temp dir");
1022        let path = dir.path().join("config.toml");
1023        std::fs::write(&path, "columns = \nthis is not valid toml").expect("write");
1024
1025        assert!(Config::load(&path).await.is_err());
1026    }
1027}