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