(** Reading and validating the TOML configuration. The file is looked up at [$OGIT_CONFIG], then [$XDG_CONFIG_HOME/ogit/config.toml], then [/etc/ogit/config.toml]. When no file is chosen explicitly a missing one is fine and {!default} applies; a file that exists but is malformed or invalid is always an error, so a mistake never silently degrades into defaults. [config.toml] at the repository root documents every variable and doubles as the reference for its default. Adding a field here means updating that file in the same commit. {!to_table} and {!write_file} support the [ogit-write-config] executable, which emits a config file pre-filled with the defaults. *) open Toml type t = { user_name : string; default_branch : string; git_project_root : string; commits_max_displayed : int; root_title : string; nav_logo : string; host : string; port : int; favorite_repositories : string list; archived_repositories : string list; } type load_error = | Not_found of string | Parse_error of string | Invalid_value of string | Io_error of string let environment_value name = match Sys.getenv_opt name with Some "" | None -> None | value -> value let default = { user_name = ""; default_branch = "main"; git_project_root = "/srv/git"; commits_max_displayed = 10; root_title = ""; nav_logo = "/static/git_icon.svg"; host = "127.0.0.1"; port = 8081; favorite_repositories = []; archived_repositories = []; } let locate_config_file () = match environment_value "OGIT_CONFIG" with | Some file -> file | None -> ( match environment_value "XDG_CONFIG_HOME" with | Some config_home -> Filename.concat (Filename.concat config_home "ogit") "config.toml" | None -> "/etc/ogit/config.toml") let to_table t = let open Types in let string_list_to_value lst = TArray (if lst = [] then NodeEmpty else NodeString lst) in List.map (fun (key, value) -> (Min.key key, value)) [ ("user_name", TString t.user_name); ("default_branch", TString t.default_branch); ("git_project_root", TString t.git_project_root); ("commits_max_displayed", TInt t.commits_max_displayed); ("ogit_root_title", TString t.root_title); ("nav_logo", TString t.nav_logo); ("host", TString t.host); ("port", TInt t.port); ("favorite_repositories", string_list_to_value t.favorite_repositories); ("archived_repositories", string_list_to_value t.archived_repositories); ] |> Min.of_key_values let write_file ?file table = let file = Option.value file ~default:(locate_config_file ()) in Out_channel.with_open_text file (fun channel -> Printer.string_of_table table |> Printf.fprintf channel "%s\n") (* Key lookups fall back to [~default] when the key is absent, so a partial file overriding one setting is valid. A key that is present but holds the wrong type is always an error, so a typo is never silently ignored. These are deliberately not named [*_opt]: by OCaml convention that suffix means the function returns an option, whereas here the optionality is the key's, not the result's. *) let optional_string table key ~default = match Types.Table.find_opt (Min.key key) table with | Some (Types.TString value) -> Ok value | Some _ -> Error (Invalid_value ("expected string for key: " ^ key)) | None -> Ok default let optional_int table key ~default = match Types.Table.find_opt (Min.key key) table with | Some (Types.TInt value) -> Ok value | Some _ -> Error (Invalid_value ("expected int for key: " ^ key)) | None -> Ok default (* An absent list and an empty list mean the same thing here, so this needs no [~default]. *) let optional_string_list table key = match Types.Table.find_opt (Min.key key) table with | Some (Types.TArray (Types.NodeString values)) -> Ok values | Some (Types.TArray Types.NodeEmpty) -> Ok [] | Some _ -> Error (Invalid_value ("expected array of strings for key: " ^ key)) | None -> Ok [] let of_table table = let ( let* ) = Result.bind in let* git_project_root = optional_string table "git_project_root" ~default:default.git_project_root in let* user_name = optional_string table "user_name" ~default:default.user_name in let* default_branch = optional_string table "default_branch" ~default:default.default_branch in let* commits_max_displayed = optional_int table "commits_max_displayed" ~default:default.commits_max_displayed in (* [ogit_root_title] is the current key; a bare [title] is still accepted so that configuration files predating the rename keep working. *) let* root_title = match Types.Table.find_opt (Min.key "ogit_root_title") table with | Some _ -> optional_string table "ogit_root_title" ~default:default.root_title | None -> optional_string table "title" ~default:default.root_title in let* nav_logo = optional_string table "nav_logo" ~default:default.nav_logo in let* host = optional_string table "host" ~default:default.host in let* port = optional_int table "port" ~default:default.port in let* favorite_repositories = optional_string_list table "favorite_repositories" in let* archived_repositories = optional_string_list table "archived_repositories" in if commits_max_displayed <= 0 then Error (Invalid_value "commits_max_displayed must be positive") else if port < 1 || port > 65535 then Error (Invalid_value "port must be between 1 and 65535") else Ok { git_project_root; user_name; default_branch; commits_max_displayed; root_title; nav_logo; host; port; favorite_repositories; archived_repositories; } let io_error file error = Io_error (Printf.sprintf "%s: %s" file (Unix.error_message error)) let file_exists file = try ignore (Unix.stat file); Ok () with | Unix.Unix_error ((Unix.ENOENT | Unix.ENOTDIR), _, _) -> Error (Not_found file) | Unix.Unix_error (error, _, _) -> Error (io_error file error) let read_file ?file () = let file = Option.value file ~default:(locate_config_file ()) in let ( let* ) = Result.bind in let* () = file_exists file in try match Toml.Parser.from_filename file with | `Error (message, location) -> Error (Parse_error (Printf.sprintf "%s: %s at line %d" location.source message location.line)) | `Ok table -> of_table table with Sys_error message -> Error (Io_error message) let implicit_config_files () = match environment_value "XDG_CONFIG_HOME" with | Some config_home -> [ Filename.concat (Filename.concat config_home "ogit") "config.toml"; "/etc/ogit/config.toml"; ] | None -> [ "/etc/ogit/config.toml" ] let load () = match environment_value "OGIT_CONFIG" with | Some file -> read_file ~file () | None -> let rec first_existing = function | [] -> Ok default | file :: rest -> ( match read_file ~file () with | Error (Not_found _) -> first_existing rest | result -> result) in first_existing (implicit_config_files ()) (* [pp_load_error] and [show_load_error] follow the convention established by ppx_deriving: [pp_] is the Format-based printer that composes with "%a", and [show_] is the string-producing convenience built on it. Naming them as a pair signals that relationship, which [load_error_to_string] did not. *) let pp_load_error formatter = function | Not_found file -> Format.fprintf formatter "configuration file not found: %s" file | Parse_error message -> Format.fprintf formatter "invalid configuration: %s" message | Invalid_value message -> Format.fprintf formatter "invalid configuration value: %s" message | Io_error message -> Format.fprintf formatter "could not read configuration: %s" message let show_load_error error = Format.asprintf "%a" pp_load_error error