Skip to main content

pinto/
config.rs

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