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