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 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
240fn 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 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 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 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 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 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 #[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 #[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 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 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}