feat Accept partial configuration files

Three keys were required even though every field has a default, so a file overriding only the port was rejected with "missing key". All keys are now optional; a present key with the wrong type is still an error.

Commit
a399401d477e5fbee48b048999bb6d32311bef73
Author
Claude Fable 5 (high reasoning) <claude-fable-5@agents.anthropic.invalid>
Author date
Committer
Claude Fable 5 (high reasoning) <claude-fable-5@agents.anthropic.invalid>
Committer date
Changed files
lib/config.ml
index 66a69d4d..fcfbf211 100644..100644
@@ -86,27 +86,14 @@
86 86 Out_channel.with_open_text file (fun channel ->
87 87 Printer.string_of_table table |> Printf.fprintf channel "%s\n")
88 88
89 Removed: (* Key lookups come in two flavours. [required_*] fails when the key is absent,
90 Removed: for settings with no sensible default; [optional_*] falls back to [~default].
91 Removed: Both fail when the key is present but holds the wrong type, so a typo is
92 Removed: never silently ignored.
89 Added: (* Key lookups fall back to [~default] when the key is absent, so a partial
90 Added: file overriding one setting is valid. A key that is present but holds the
91 Added: wrong type is always an error, so a typo is never silently ignored.
93 92
94 93 These are deliberately not named [*_opt]: by OCaml convention that suffix
95 94 means the function returns an option, whereas here the optionality is the
96 95 key's, not the result's. *)
97 96
98 Removed: let required_string table key =
99 Removed: match Types.Table.find_opt (Min.key key) table with
100 Removed: | Some (Types.TString value) -> Ok value
101 Removed: | Some _ -> Error (Invalid_value ("expected string for key: " ^ key))
102 Removed: | None -> Error (Invalid_value ("missing key: " ^ key))
103 Removed:
104 Removed: let required_int table key =
105 Removed: match Types.Table.find_opt (Min.key key) table with
106 Removed: | Some (Types.TInt value) -> Ok value
107 Removed: | Some _ -> Error (Invalid_value ("expected int for key: " ^ key))
108 Removed: | None -> Error (Invalid_value ("missing key: " ^ key))
109 Removed:
110 97 let optional_string table key ~default =
111 98 match Types.Table.find_opt (Min.key key) table with
112 99 | Some (Types.TString value) -> Ok value
@@ -131,12 +118,19 @@
131 118
132 119 let of_table table =
133 120 let ( let* ) = Result.bind in
134 Removed: let* git_project_root = required_string table "git_project_root" in
121 Added: let* git_project_root =
122 Added: optional_string table "git_project_root" ~default:default.git_project_root
123 Added: in
135 124 let* user_name =
136 125 optional_string table "user_name" ~default:default.user_name
137 126 in
138 Removed: let* default_branch = required_string table "default_branch" in
139 Removed: let* commits_max_displayed = required_int table "commits_max_displayed" in
127 Added: let* default_branch =
128 Added: optional_string table "default_branch" ~default:default.default_branch
129 Added: in
130 Added: let* commits_max_displayed =
131 Added: optional_int table "commits_max_displayed"
132 Added: ~default:default.commits_max_displayed
133 Added: in
140 134 (* [ogit_root_title] is the current key; a bare [title] is still accepted so
141 135 that configuration files predating the rename keep working. *)
142 136 let* root_title =
test/test_config.ml
index bf35c67c..e9ee3b38 100644..100644
@@ -54,6 +54,37 @@
54 54 Alcotest.(check int) "default port" 8081 config.port
55 55 | Error error -> fail_config_error error)
56 56
57 Added: (* Every key has a default, so a file overriding a single setting is valid;
58 Added: the untouched settings keep their defaults. *)
59 Added: let test_partial_file () =
60 Added: with_temp_file "ogit-partial" ".toml" (fun file ->
61 Added: Out_channel.with_open_text file (fun channel ->
62 Added: output_string channel "port = 9999\n");
63 Added: match Ogit.Config.read_file ~file () with
64 Added: | Ok config ->
65 Added: Alcotest.(check int) "overridden port" 9999 config.port;
66 Added: Alcotest.(check string)
67 Added: "default root" Ogit.Config.default.git_project_root
68 Added: config.git_project_root;
69 Added: Alcotest.(check string)
70 Added: "default branch" Ogit.Config.default.default_branch
71 Added: config.default_branch;
72 Added: Alcotest.(check int)
73 Added: "default commits" Ogit.Config.default.commits_max_displayed
74 Added: config.commits_max_displayed
75 Added: | Error error -> fail_config_error error)
76 Added:
77 Added: (* A key that is present with the wrong type is still an error: only absence
78 Added: falls back to the default. *)
79 Added: let test_mistyped_key () =
80 Added: with_temp_file "ogit-mistyped" ".toml" (fun file ->
81 Added: Out_channel.with_open_text file (fun channel ->
82 Added: output_string channel "git_project_root = 5\n");
83 Added: match Ogit.Config.read_file ~file () with
84 Added: | Error (Ogit.Config.Invalid_value _) -> ()
85 Added: | Error error -> fail_config_error error
86 Added: | Ok _ -> Alcotest.fail "mistyped key was accepted")
87 Added:
57 88 let test_malformed () =
58 89 with_temp_file "ogit-malformed" ".toml" (fun file ->
59 90 Out_channel.with_open_text file (fun channel ->
@@ -142,6 +173,8 @@
142 173 [
143 174 Alcotest.test_case "round trip" `Quick test_round_trip;
144 175 Alcotest.test_case "backward compat" `Quick test_backward_compat;
176 Added: Alcotest.test_case "partial file" `Quick test_partial_file;
177 Added: Alcotest.test_case "mistyped key" `Quick test_mistyped_key;
145 178 Alcotest.test_case "malformed" `Quick test_malformed;
146 179 Alcotest.test_case "invalid value" `Quick test_invalid_value;
147 180 Alcotest.test_case "port too high" `Quick test_invalid_port_high;