Monorepo management for opam overlays
at main 278 lines 8.9 kB view raw
1(** Unified configuration for monopam. 2 3 Configuration is stored in TOML format at ~/.config/monopam/opamverse.toml *) 4 5let app_name = "monopam" 6 7(** {1 Package Overrides} *) 8 9module Package_config = struct 10 type t = { branch : string option } 11 12 let branch t = t.branch 13 14 let codec : t Tomlt.t = 15 Tomlt.( 16 Table.( 17 obj (fun branch -> { branch }) 18 |> opt_mem "branch" string ~enc:(fun c -> c.branch) 19 |> finish)) 20end 21 22(** {1 Paths Configuration} *) 23 24type paths = { 25 mono : string; (** Monorepo directory (default: "mono") *) 26 src : string; (** Source checkouts directory (default: "src") *) 27 verse : string; (** Verse directory (default: "verse") *) 28} 29 30let default_paths = { mono = "mono"; src = "src"; verse = "verse" } 31 32(** {1 Main Configuration Type} *) 33 34type t = { 35 (* Workspace structure *) 36 root : Fpath.t; 37 paths : paths; 38 (* Identity *) 39 handle : string; 40 knot : string; (** Git push server hostname (e.g., "git.recoil.org") *) 41 (* Package overrides *) 42 packages : (string * Package_config.t) list; 43} 44 45(** {1 Accessors} *) 46 47let root t = t.root 48let handle t = t.handle 49let knot t = t.knot 50let paths t = t.paths 51let packages t = t.packages 52let package_config t name = List.assoc_opt name t.packages 53 54(* Derived paths *) 55let default_branch = "main" 56let mono_path t = Fpath.(t.root / t.paths.mono) 57let src_path t = Fpath.(t.root / t.paths.src) 58let opam_repo_path t = Fpath.(t.root / "opam-repo") 59let verse_path t = Fpath.(t.root / t.paths.verse) 60 61(* Aliases for backwards compatibility with old Config.Paths module *) 62module Paths = struct 63 let opam_repo = opam_repo_path 64 let checkouts = src_path 65 let monorepo = mono_path 66end 67 68(** {1 XDG Paths} *) 69 70let xdg_config_home () = 71 match Sys.getenv_opt "XDG_CONFIG_HOME" with 72 | Some dir when dir <> "" -> Fpath.v dir 73 | _ -> ( 74 match Sys.getenv_opt "HOME" with 75 | Some home -> Fpath.(v home / ".config") 76 | None -> Fpath.v "/tmp") 77 78let xdg_data_home () = 79 match Sys.getenv_opt "XDG_DATA_HOME" with 80 | Some dir when dir <> "" -> Fpath.v dir 81 | _ -> ( 82 match Sys.getenv_opt "HOME" with 83 | Some home -> Fpath.(v home / ".local" / "share") 84 | None -> Fpath.v "/tmp") 85 86let xdg_cache_home () = 87 match Sys.getenv_opt "XDG_CACHE_HOME" with 88 | Some dir when dir <> "" -> Fpath.v dir 89 | _ -> 90 match Sys.getenv_opt "HOME" with 91 | Some home -> Fpath.(v home / ".cache") 92 | None -> Fpath.v "/tmp" 93 94let config_dir () = Fpath.(xdg_config_home () / app_name) 95let data_dir () = Fpath.(xdg_data_home () / app_name) 96let cache_dir () = Fpath.(xdg_cache_home () / app_name) 97let config_file () = Fpath.(config_dir () / "opamverse.toml") 98let registry_path () = Fpath.(data_dir () / "opamverse-registry") 99 100(** {1 Construction} *) 101 102(** Derive knot (git push server) from handle. 103 E.g., "anil.recoil.org" -> "git.recoil.org" *) 104let default_knot_from_handle handle = 105 match String.index_opt handle '.' with 106 | None -> "git." ^ handle (* fallback *) 107 | Some i -> 108 let domain = String.sub handle (i + 1) (String.length handle - i - 1) in 109 "git." ^ domain 110 111let create ~root ~handle ?knot ?(packages = []) ?(paths = default_paths) () = 112 let knot = match knot with Some k -> k | None -> default_knot_from_handle handle in 113 { root; handle; knot; packages; paths } 114 115let with_package_override t ~name ?branch:branch_opt () = 116 let existing = List.assoc_opt name t.packages in 117 let existing_branch = Option.bind existing Package_config.branch in 118 let new_branch = 119 match branch_opt with Some _ -> branch_opt | None -> existing_branch 120 in 121 let pkg_config = Package_config.{ branch = new_branch } in 122 let packages = (name, pkg_config) :: List.remove_assoc name t.packages in 123 { t with packages } 124 125(** {1 TOML Codecs} *) 126 127let expand_tilde s = 128 if String.length s > 0 && s.[0] = '~' then 129 match Sys.getenv_opt "HOME" with 130 | Some home -> 131 if String.length s = 1 then home 132 else if s.[1] = '/' then home ^ String.sub s 1 (String.length s - 1) 133 else s 134 | None -> s 135 else s 136 137let fpath_codec : Fpath.t Tomlt.t = 138 Tomlt.map 139 ~dec:(fun s -> 140 let s = expand_tilde s in 141 match Fpath.of_string s with Ok p -> p | Error (`Msg m) -> failwith m) 142 ~enc:Fpath.to_string Tomlt.string 143 144let paths_codec : paths Tomlt.t = 145 Tomlt.( 146 Table.( 147 obj (fun mono src verse -> 148 { mono = Option.value ~default:default_paths.mono mono; 149 src = Option.value ~default:default_paths.src src; 150 verse = Option.value ~default:default_paths.verse verse }) 151 |> opt_mem "mono" string ~enc:(fun p -> Some p.mono) 152 |> opt_mem "src" string ~enc:(fun p -> Some p.src) 153 |> opt_mem "verse" string ~enc:(fun p -> Some p.verse) 154 |> finish)) 155 156(* TOML structure: 157 [workspace] 158 root = "~/tangled" 159 160 [identity] 161 handle = "anil.recoil.org" 162 knot = "git.recoil.org" 163 164 [paths] 165 mono = "mono" 166 src = "src" 167 168 [packages.braid] 169 branch = "backport-fix" 170*) 171 172type workspace_section = { w_root : Fpath.t } 173type identity_section = { i_handle : string; i_knot : string option } 174 175let default_knot = "git.recoil.org" 176 177let workspace_codec : workspace_section Tomlt.t = 178 Tomlt.( 179 Table.( 180 obj (fun w_root -> { w_root }) 181 |> mem "root" fpath_codec ~enc:(fun w -> w.w_root) 182 |> finish)) 183 184let identity_codec : identity_section Tomlt.t = 185 Tomlt.( 186 Table.( 187 obj (fun i_handle i_knot -> { i_handle; i_knot }) 188 |> mem "handle" string ~enc:(fun i -> i.i_handle) 189 |> opt_mem "knot" string ~enc:(fun i -> i.i_knot) 190 |> finish)) 191 192(* Codec for the [packages] table which contains subtree->override mappings *) 193let packages_table_codec : (string * Package_config.t) list Tomlt.t = 194 Tomlt.( 195 Table.( 196 obj (fun pkgs -> pkgs) 197 |> keep_unknown ~enc:(fun pkgs -> pkgs) 198 (Mems.assoc Package_config.codec) 199 |> finish)) 200 201let codec : t Tomlt.t = 202 Tomlt.( 203 Table.( 204 obj (fun workspace identity packages paths -> 205 let packages = Option.value ~default:[] packages in 206 let paths = Option.value ~default:default_paths paths in 207 let knot = Option.value ~default:default_knot identity.i_knot in 208 { root = workspace.w_root; handle = identity.i_handle; knot; packages; paths }) 209 |> mem "workspace" workspace_codec ~enc:(fun t -> { w_root = t.root }) 210 |> mem "identity" identity_codec ~enc:(fun t -> { i_handle = t.handle; i_knot = Some t.knot }) 211 |> opt_mem "packages" packages_table_codec 212 ~enc:(fun t -> if t.packages = [] then None else Some t.packages) 213 |> opt_mem "paths" paths_codec 214 ~enc:(fun t -> if t.paths = default_paths then None else Some t.paths) 215 |> finish)) 216 217(** {1 Validation} *) 218 219type validation_error = 220 | Path_not_found of string * Fpath.t 221 | Not_a_directory of string * Fpath.t 222 | Not_an_opam_repo of Fpath.t 223 | Invalid_path of string * string 224 | Relative_path of string * Fpath.t 225 226let pp_validation_error ppf = function 227 | Path_not_found (field, path) -> 228 Fmt.pf ppf "%s path does not exist: %a" field Fpath.pp path 229 | Not_a_directory (field, path) -> 230 Fmt.pf ppf "%s path is not a directory: %a" field Fpath.pp path 231 | Not_an_opam_repo path -> 232 Fmt.pf ppf 233 "opam_repo is not a valid opam repository (missing packages/ \ 234 directory): %a" 235 Fpath.pp path 236 | Invalid_path (field, msg) -> Fmt.pf ppf "%s has invalid path: %s" field msg 237 | Relative_path (field, path) -> 238 Fmt.pf ppf 239 "%s must be an absolute path, got: %a\n\ 240 Hint: Use an absolute path starting with / or ~/" 241 field Fpath.pp path 242 243(** {1 Loading and Saving} *) 244 245let load ~fs () = 246 let path = config_file () in 247 let path_str = Fpath.to_string path in 248 let eio_path = Eio.Path.(fs / path_str) in 249 match Eio.Path.kind ~follow:true eio_path with 250 | `Regular_file -> ( 251 try Ok (Tomlt_eio.decode_path_exn codec ~fs path_str) with 252 | Failure msg -> Error (Printf.sprintf "Invalid config: %s" msg) 253 | exn -> Error (Printf.sprintf "Error loading config: %s" (Printexc.to_string exn))) 254 | _ -> Error (Printf.sprintf "Config file not found: %s" path_str) 255 | exception _ -> Error (Printf.sprintf "Config file not found: %s" path_str) 256 257let save ~fs t = 258 let dir = config_dir () in 259 let path = config_file () in 260 try 261 (* Ensure XDG config directory exists *) 262 let dir_path = Eio.Path.(fs / Fpath.to_string dir) in 263 (try Eio.Path.mkdirs ~perm:0o755 dir_path with Eio.Io _ -> ()); 264 Tomlt_eio.encode_path codec t ~fs (Fpath.to_string path); 265 Ok () 266 with Eio.Io _ as e -> Error (Printexc.to_string e) 267 268(** {1 Pretty Printing} *) 269 270let pp ppf t = 271 Fmt.pf ppf 272 "@[<v>@[<hov 2>workspace:@ root=%a@]@,\ 273 @[<hov 2>identity:@ handle=%s@ knot=%s@]@,\ 274 @[<hov 2>paths:@ mono=%s@ src=%s@ verse=%s@]@,\ 275 packages=%d@]" 276 Fpath.pp t.root t.handle t.knot 277 t.paths.mono t.paths.src t.paths.verse 278 (List.length t.packages)