1use 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
15pub const DEFAULT_COLUMNS: [&str; 4] = ["todo", "in-progress", "review", "done"];
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct Config {
22 pub columns: Vec<String>,
24 pub done_column: String,
33 pub project: Project,
35 pub tui: TuiConfig,
39 pub storage: StorageConfig,
43 pub wip: WipConfig,
47 #[serde(default)]
50 pub display: DisplayConfig,
51 #[serde(default)]
53 pub points: PointsConfig,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(default, deny_unknown_fields)]
63pub struct WipConfig {
64 pub enabled: bool,
68 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
72 pub limits: BTreeMap<String, u32>,
73}
74
75impl Default for WipConfig {
76 fn default() -> Self {
77 Self {
79 enabled: true,
80 limits: BTreeMap::new(),
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
87#[serde(default, deny_unknown_fields)]
88pub struct StorageConfig {
89 pub backend: StorageBackend,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
98#[serde(rename_all = "lowercase")]
99pub enum StorageBackend {
100 #[default]
102 File,
103 Git,
105 #[cfg(feature = "sqlite")]
108 Sqlite,
109}
110
111impl std::fmt::Display for StorageBackend {
112 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct Project {
129 pub name: String,
131 pub key: String,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(default, deny_unknown_fields)]
138pub struct TuiConfig {
139 pub confirm_quit: bool,
141 #[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 Self {
150 confirm_quit: true,
151 hidden_columns: Vec::new(),
152 }
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(default, deny_unknown_fields)]
159pub struct DisplayConfig {
160 pub markdown: bool,
163 #[serde(default)]
165 pub timezone: DisplayTimezone,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
170#[serde(default, deny_unknown_fields)]
171pub struct PointsConfig {
172 pub aggregate_children: bool,
174}
175
176impl Default for DisplayConfig {
177 fn default() -> Self {
178 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 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 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 pub fn validate(&self, path: &Path) -> Result<()> {
233 validate_semantics(path, self)
234 }
235
236 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
250fn 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 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 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 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 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 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 #[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 #[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 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 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}