(*
 * Copyright (c) 2013-2020 Thomas Gazagnaire <thomas@gazagnaire.org>
 * Copyright (c) 2013-2020 Anil Madhavapeddy <anil@recoil.org>
 * Copyright (c) 2015-2020 Gabriel Radanne <drupyog@zoho.com>
 * Copyright (c) 2019-2020 Etienne Millon <etienne@tarides.com>
 *
 * Permission to use, copy, modify, and distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 *)

let src = Logs.Src.create "functoria.action" ~doc:"functoria library"

module Log = (val Logs.src_log src : Logs.LOG)
open Astring

type 'a or_err = ('a, Rresult.R.msg) result
type tmp_name_pat = Bos.OS.File.tmp_name_pat

type 'a with_output = {
  mode : int option;
  path : Fpath.t;
  purpose : string;
  contents : Format.formatter -> 'a;
  append : bool;
}

type channel = [ `Null | `Fmt of Format.formatter ]
type cmd = { cmd : Bos.Cmd.t; err : channel; out : channel; trim : bool }
type ls = { root : Fpath.t; filter : Fpath.t -> bool }

type _ command =
  | Rmdir : Fpath.t -> unit command
  | Mkdir : Fpath.t -> bool command
  | Ls : ls -> Fpath.t list command
  | Rm : Fpath.t -> unit command
  | Is_file : Fpath.t -> bool command
  | Is_dir : Fpath.t -> bool command
  | Size_of : Fpath.t -> int option command
  | Run_cmd : cmd -> unit command
  | Run_cmd_out : cmd -> string command
  | Run_cmd_cli : Bos.Cmd.t -> unit command
  | Get_var : string -> string option command
  | Set_var : string * string option -> unit command
  | With_dir : Fpath.t * (unit -> 'a t) -> 'a command
  | Pwd : Fpath.t command
  | Tmp_file : int option * tmp_name_pat -> Fpath.t command
  | Write_file : Fpath.t * string -> unit command
  | Read_file : Fpath.t -> string command
  | With_output : 'a with_output -> 'a command

and _ t =
  | Done : 'a -> 'a t
  | Fail : string -> 'a t
  | Run : 'r command * ('r -> 'a t) -> 'a t

let ok x = Done x
let error e = Fail e
let errorf fmt = Fmt.kstr error fmt

let rec bind ~f = function
  | Done r -> f r
  | Fail s -> Fail s
  | Run (c, k) ->
      let k2 r = bind ~f (k r) in
      Run (c, k2)

let map ~f x = bind x ~f:(fun y -> ok (f y))
let rec seq = function [] -> ok () | h :: t -> bind ~f:(fun () -> seq t) h
let wrap x = Run (x, ok)
let ( ! ) = Fpath.normalize
let rm path = wrap @@ Rm !path
let rmdir path = wrap @@ Rmdir !path
let mkdir path = wrap @@ Mkdir !path
let ls path filter = wrap @@ Ls { root = !path; filter }
let with_dir path f = wrap @@ With_dir (!path, f)
let pwd () = wrap @@ Pwd
let is_file path = wrap @@ Is_file !path
let is_dir path = wrap @@ Is_dir !path
let size_of path = wrap @@ Size_of !path
let set_var c v = wrap @@ Set_var (c, v)
let get_var c = wrap @@ Get_var c

let run_cmd ?(err = `Fmt Fmt.stderr) ?(out = `Fmt Fmt.stdout) cmd =
  wrap @@ Run_cmd { cmd; out; err; trim = false }

let run_cmd_out ?(err = `Fmt Fmt.stderr) cmd =
  wrap @@ Run_cmd_out { cmd; out = `Null; err; trim = true }

let run_cmd_cli cmd = wrap @@ Run_cmd_cli cmd
let write_file path contents = wrap @@ Write_file (!path, contents)
let read_file path = wrap @@ Read_file !path
let tmp_file ?mode pat = wrap @@ Tmp_file (mode, pat)

let with_output ?mode ?(append = false) ~path ~purpose contents =
  wrap @@ With_output { append; mode; path; purpose; contents }

let pfo ppf s = match ppf with `Null -> () | `Fmt ppf -> Fmt.pf ppf "%s%!" s

let interpret_cmd { cmd; err; out; trim } =
  Log.debug (fun l -> l "RUN: %a" Bos.Cmd.pp cmd);
  let open Rresult in
  let err =
    match err with
    | `Null -> Ok (Bos.OS.Cmd.err_null, fun () -> Ok ())
    | `Fmt ppf ->
        Bos.OS.File.tmp "cmd-err-%s" >>| fun path ->
        let flush () = Bos.OS.File.read path >>| fun s -> Fmt.pf ppf "%s%!" s in
        (Bos.OS.Cmd.err_file path, flush)
  in
  err >>= fun (err, flush_err) ->
  let res = Bos.OS.Cmd.run_out ~err cmd in
  let res = Bos.OS.Cmd.out_string ~trim res in
  res >>= fun (str_out, _) ->
  pfo out str_out;
  flush_err () >>= fun () -> Bos.OS.Cmd.success res

let interpret_cmd_cli cmd =
  Log.debug (fun l -> l "RUN-CLI: %a" Bos.Cmd.pp cmd);
  let res = Bos.OS.Cmd.run_out cmd in
  match Bos.OS.Cmd.out_stdout res with
  | Ok ((), (_, `Exited 0)) -> Ok ()
  | Ok ((), (_, `Exited _)) -> Error (`Msg "")
  | failure -> Bos.OS.Cmd.success failure

let rec interpret_command : type r. r command -> r or_err = function
  | Rmdir path ->
      Log.debug (fun l -> l "rmdir %a" Fpath.pp path);
      Bos.OS.Dir.delete ~recurse:true path
  | Mkdir path ->
      Log.debug (fun l -> l "mkdir %a" Fpath.pp path);
      Bos.OS.Dir.create ~path:true path
  | Ls { root; filter } ->
      let open Rresult in
      Log.debug (fun l -> l "ls %a" Fpath.pp root);
      Bos.OS.Path.matches ~dotfiles:true Fpath.(root / "$(file)")
      >>| fun files -> List.filter filter files
  | Rm path ->
      Log.debug (fun l -> l "rm %a" Fpath.pp path);
      Bos.OS.File.delete ~must_exist:false path
  | Is_file path ->
      Log.debug (fun l -> l "is-file %a" Fpath.pp path);
      Bos.OS.File.exists path
  | Is_dir path ->
      Log.debug (fun l -> l "is-dir %a" Fpath.pp path);
      Bos.OS.Dir.exists path
  | Size_of path -> (
      Log.debug (fun l -> l "size-of %a" Fpath.pp path);
      match Bos.OS.Path.stat path with
      | Ok s -> Ok (Some s.Unix.st_size)
      | _ -> Ok None)
  | Run_cmd cmd -> Rresult.(interpret_cmd cmd >>| fun _ -> ())
  | Run_cmd_out cmd -> interpret_cmd cmd
  | Run_cmd_cli cmd -> interpret_cmd_cli cmd
  | Set_var (c, v) ->
      Log.debug (fun l ->
          l "set_var %s %a" c Fmt.(option ~none:(any "<unset>") string) v);
      Bos.OS.Env.set_var c v
  | Get_var c ->
      Log.debug (fun l -> l "get_var %s" c);
      Ok (Bos.OS.Env.var c)
  | With_dir (dir, f) ->
      let f () = run (f ()) in
      let open Rresult in
      Bos.OS.Dir.current () >>= fun old ->
      Log.debug (fun l -> l "entering %a" Fpath.pp dir);
      Rresult.R.join @@ Bos.OS.Dir.with_current dir f () >>| fun r ->
      Log.debug (fun l -> l "entering %a" Fpath.pp old);
      r
  | Pwd ->
      Log.debug (fun l -> l "pwd");
      Bos.OS.Dir.current ()
  | Write_file (path, contents) ->
      Log.debug (fun l -> l "write %a" Fpath.pp path);
      Bos.OS.File.write path contents
  | Read_file path ->
      Log.debug (fun l -> l "read-file %a" Fpath.pp path);
      Bos.OS.File.read path
  | Tmp_file (mode, pat) ->
      Log.debug (fun l -> l "tmp-file %s" Fmt.(str pat "*"));
      Bos.OS.File.tmp ?mode pat
  | With_output { mode; path; purpose; contents; append } -> (
      try
        let oc =
          let path = Fpath.to_string path in
          let mode = match mode with None -> 0o666 | Some m -> m in
          if append then
            open_out_gen [ Open_wronly; Open_append; Open_text ] mode path
          else open_out path
        in
        let ppf = Format.formatter_of_out_channel oc in
        let r = contents ppf in
        Fmt.pf ppf "%!";
        flush oc;
        close_out oc;
        Ok r
      with e ->
        Rresult.R.error_msgf "couldn't open output channel for %s: %a" purpose
          Fmt.exn e)

and run : type r. r t -> r or_err = function
  | Done r -> Ok r
  | Fail f -> Error (`Msg f)
  | Run (cmd, k) -> Rresult.R.bind (interpret_command cmd) (fun x -> run @@ k x)

type files = [ `Passtrough of Fpath.t | `Files of (Fpath.t * string) list ]

let default_exec cmd =
  let cmd =
    Fmt.str "$(%a)\n" Fmt.(list ~sep:(any " ") string) (Bos.Cmd.to_list cmd)
  in
  Some (cmd, "")

(* (simple) virtual environment *)
module Env : sig
  type t

  val eq : t -> t -> bool
  val pp : t Fmt.t
  val diff_files : old:t -> t -> Fpath.Set.t
  val pwd : t -> Fpath.t
  val chdir : t -> Fpath.t -> t
  val ls : t -> Fpath.t -> Fpath.t list option

  val v :
    ?exec:(Bos.Cmd.t -> (string * string) option) ->
    ?env:(string * string) list ->
    ?pwd:Fpath.t ->
    ?files:files ->
    unit ->
    t

  val exec : t -> Bos.Cmd.t -> (string * string) option
  val is_file : t -> Fpath.t -> bool
  val is_dir : t -> Fpath.t -> bool
  val mkdir : t -> Fpath.t -> (t * bool) option
  val rm : t -> Fpath.t -> (t * bool) option
  val rmdir : t -> Fpath.t -> t
  val size_of : t -> Fpath.t -> int option
  val write : t -> Fpath.t -> string -> t
  val read : t -> Fpath.t -> string option
  val tmp_file : t -> tmp_name_pat -> Fpath.t
  val set_var : t -> string -> string option -> t
  val get_var : t -> string -> string option
end = struct
  type t = {
    files : string Fpath.Map.t;
    pwd : Fpath.t;
    env : string String.Map.t;
    exec : Bos.Cmd.t -> (string * string) option;
  }

  let diff_files ~old t =
    let to_set t =
      Fpath.Map.fold
        (fun f _ acc ->
          match Fpath.rem_prefix t.pwd f with
          | None -> acc
          | Some f -> Fpath.Set.add f acc)
        t.files Fpath.Set.empty
    in
    Fpath.Set.diff (to_set t) (to_set old)

  let scan dir =
    (let open Rresult in
     Bos.OS.Path.fold ~dotfiles:true ~elements:`Files ~traverse:`Any
       (fun file files ->
         files >>= fun files ->
         Bos.OS.File.read file >>| fun c -> (file, c) :: files)
       (Ok []) [ dir ])
    |> Rresult.R.join
    |> Rresult.R.error_msg_to_invalid_arg

  let v ?(exec = default_exec) ?env ?pwd ?(files = `Files []) () =
    let env =
      match env with Some e -> String.Map.of_list e | None -> String.Map.empty
    in
    let pwd = match pwd with None -> Fpath.v "/" | Some p -> p in
    let files =
      let files =
        match files with `Passtrough dir -> scan dir | `Files files -> files
      in
      let files =
        List.map
          (fun (f, c) ->
            match Fpath.is_rel f with
            | false -> (f, c)
            | true -> (Fpath.(pwd // f), c))
          files
      in
      List.map (fun (f, c) -> (Fpath.normalize f, c)) files
    in
    { files = Fpath.Map.of_list files; pwd; env; exec }

  let eq x y =
    Fpath.Map.equal ( = ) x.files y.files
    && Fpath.equal x.pwd y.pwd
    && String.Map.equal ( = ) x.env y.env

  let pp =
    let open Fmt.Dump in
    record
      [
        field "files" (fun t -> t.files) (Fpath.Map.dump string);
        field "pwd" (fun t -> t.pwd) Fpath.dump;
        field "env" (fun t -> t.env) (String.Map.dump string);
      ]

  let pwd t = t.pwd
  let exec t cmd = t.exec cmd

  let mk_path t path =
    match (Fpath.to_string t.pwd, Fpath.is_rel path) with
    | _, true -> Fpath.(normalize @@ (t.pwd // path))
    | _, false -> Fpath.normalize path

  let chdir t path =
    let pwd = mk_path t path in
    { t with pwd }

  let is_root path = Fpath.to_string path = "/"

  let mkdir t path =
    let path = mk_path t path in
    if is_root path then Some (t, false)
    else
      match Fpath.Map.find path t.files with
      | Some f when f <> "<DIR>" -> None
      | r ->
          let t = { t with files = Fpath.Map.add path "<DIR>" t.files } in
          Some (t, r = None)

  let rmdir t path =
    let path = mk_path t path in
    let files =
      Fpath.Map.filter
        (fun f _ ->
          let f = mk_path t f in
          let b = not (Fpath.is_prefix path f) in
          b)
        t.files
    in
    { t with files }

  let ls t path =
    let root = mk_path t path in
    match Fpath.Map.find root t.files with
    | Some "<DIR>" -> Some []
    | Some _ -> Some [ path ]
    | None -> (
        Fpath.Map.fold
          (fun file _ acc ->
            let file = mk_path t file in
            match Fpath.relativize ~root file with
            | None -> acc
            | Some f -> f :: acc)
          t.files []
        |> function
        | [] -> None
        | x -> Some (List.rev x))

  let write t path f =
    let path = mk_path t path in
    { t with files = Fpath.Map.add path f t.files }

  let read t path =
    let path = mk_path t path in
    Fpath.Map.find path t.files

  let tmp_file t pat =
    let rec aux n =
      let dir = Fpath.v "/tmp" in
      let file = Fpath.(dir / Fmt.str pat (string_of_int n)) in
      if Fpath.Map.mem file t.files then aux (n + 1) else file
    in
    aux 0

  let is_dir t path =
    let path = mk_path t path in
    match Fpath.Map.find path t.files with
    | Some "<DIR>" -> true
    | Some _ -> false
    | None ->
        Fpath.Map.exists
          (fun f _ ->
            let f = mk_path t f in
            Fpath.is_prefix path f)
          t.files

  let is_file t path =
    let path = mk_path t path in
    match Fpath.Map.find path t.files with
    | Some "<DIR>" | None -> false
    | Some _ -> true

  let rm t path =
    let path = mk_path t path in
    match Fpath.Map.find path t.files with
    | Some "<DIR>" -> None
    | Some _ -> Some ({ t with files = Fpath.Map.remove path t.files }, true)
    | None -> if is_dir t path then None else Some (t, false)

  let size_of t path =
    let path = mk_path t path in
    match Fpath.Map.find path t.files with
    | None -> None
    | Some "<DIR>" -> Some 0
    | Some f -> Some (String.length f)

  let set_var t c = function
    | None -> { t with env = String.Map.remove c t.env }
    | Some v -> { t with env = String.Map.add c v t.env }

  let get_var t c = String.Map.find c t.env
end

let error_msg = Rresult.R.error_msgf

type env = Env.t

let env = Env.v

type 'a domain = { result : 'a or_err; env : Env.t; logs : string list }

let pp_or_err pp_a = Rresult.R.pp ~error:Rresult.R.pp_msg ~ok:pp_a
let eq_or_err eq_a = Rresult.R.equal ~error:( = ) ~ok:eq_a

let pp_domain pp_a =
  let open Fmt.Dump in
  record
    [
      field "result" (fun t -> t.result) (pp_or_err pp_a);
      field "env" (fun t -> t.env) Env.pp;
      field "logs" (fun t -> t.logs) Fmt.Dump.(list string);
    ]

let eq_domain eq a b =
  eq_or_err eq a.result b.result && Env.eq a.env b.env && a.logs = b.logs

let dom result env logs = { result; env; logs }

let interpret_dry_cmd env { cmd; err; out; _ } : string domain =
  Log.debug (fun l -> l "Run_cmd '%a'" Bos.Cmd.pp cmd);
  let log x = Fmt.str "Run_cmd '%a' (%s)" Bos.Cmd.pp cmd x in
  match Env.exec env cmd with
  | None -> dom (error_msg "'%a' not found" Bos.Cmd.pp cmd) env [ log "error" ]
  | Some (o, e) ->
      pfo out o;
      pfo err e;
      dom (Ok o) env [ log "ok" ]

let interpret_dry_cmd_cli env cmd : unit domain =
  Log.debug (fun l -> l "Run_cmd_cli '%a'" Bos.Cmd.pp cmd);
  let log x = Fmt.str "Run_cmd_cli '%a' (%s)" Bos.Cmd.pp cmd x in
  match Env.exec env cmd with
  | None -> dom (error_msg "'%a' not found" Bos.Cmd.pp cmd) env [ log "error" ]
  | Some _ -> dom (Ok ()) env [ log "ok" ]

let rec interpret_dry : type r. env:Env.t -> r command -> r domain =
 fun ~env -> function
  | Mkdir path -> (
      Log.debug (fun l -> l "Mkdir %a" Fpath.pp path);
      let log s = Fmt.str "Mkdir %a (%s)" Fpath.pp path s in
      match Env.mkdir env path with
      | Some (env, true) -> dom (Ok true) env [ log "created" ]
      | Some (env, false) -> dom (Ok false) env [ log "already exists" ]
      | None ->
          dom
            (error_msg "a file named '%a' already exists" Fpath.pp path)
            env
            [ log "error" ])
  | Rmdir path ->
      Log.debug (fun l -> l "Rmdir %a" Fpath.pp path);
      let log s = Fmt.str "Rmdir %a (%s)" Fpath.pp path s in
      if Env.is_dir env path || Env.is_file env path then
        dom (Ok ()) (Env.rmdir env path) [ log "removed" ]
      else dom (Ok ()) env [ log "no-op" ]
  | Ls { root; filter } -> (
      Log.debug (fun l -> l "Ls %a" Fpath.pp root);
      let logs fmt = Fmt.kstr (Fmt.str "Ls %a (%s)" Fpath.pp root) fmt in
      match Env.ls env root with
      | None ->
          dom
            (error_msg "%a: no such file or directory" Fpath.pp root)
            env
            [ logs "error" ]
      | Some es -> (
          match List.filter filter es with
          | ([] | [ _ ]) as e ->
              dom (Ok e) env [ logs "%d entry" (List.length e) ]
          | es -> dom (Ok es) env [ logs "%d entries" (List.length es) ]))
  | Rm path -> (
      Log.debug (fun l -> l "Rm %a" Fpath.pp path);
      let log s = Fmt.str "Rm %a (%s)" Fpath.pp path s in
      match Env.rm env path with
      | Some (env, b) ->
          dom (Ok ()) env [ log (if b then "removed" else "no-op") ]
      | None ->
          dom (error_msg "%a is a directory" Fpath.pp path) env [ log "error" ])
  | Is_file path ->
      Log.debug (fun l -> l "Is_file %a" Fpath.pp path);
      let r = Env.is_file env path in
      dom (Ok r) env [ Fmt.str "Is_file? %a -> %b" Fpath.pp path r ]
  | Is_dir path ->
      Log.debug (fun l -> l "Is_dir %a" Fpath.pp path);
      let r = Env.is_dir env path in
      dom (Ok r) env [ Fmt.str "Is_dir? %a -> %b" Fpath.pp path r ]
  | Size_of path ->
      Log.debug (fun l -> l "Size_of %a" Fpath.pp path);
      let r = Env.size_of env path in
      dom (Ok r) env
        [
          Fmt.str "Size_of %a -> %a" Fpath.pp path
            Fmt.(option ~none:(any "error") int)
            r;
        ]
  | Run_cmd cmd -> (
      let domain = interpret_dry_cmd env cmd in
      match domain.result with
      | Ok _ -> { domain with result = Ok () }
      | Error _ as r -> { domain with result = r })
  | Run_cmd_out cmd -> interpret_dry_cmd env cmd
  | Run_cmd_cli cmd -> interpret_dry_cmd_cli env cmd
  | Write_file (path, s) ->
      Log.debug (fun l -> l "Write_file %a" Fpath.pp path);
      dom (Ok ()) (Env.write env path s)
        [ Fmt.str "Write to %a (%d bytes)" Fpath.pp path (String.length s) ]
  | Read_file path -> (
      Log.debug (fun l -> l "Read_file %a" Fpath.pp path);
      match Env.read env path with
      | None ->
          let log = Fmt.str "Read: %a" Fpath.pp path in
          dom (error_msg "read_file: file does not exist") env [ log ]
      | Some r ->
          let log =
            Fmt.str "Read %a (%d bytes)" Fpath.pp path (String.length r)
          in
          dom (Ok r) env [ log ])
  | Tmp_file (_, pat) ->
      Log.debug (fun l -> l "Tmp_file %s" Fmt.(str pat "*"));
      let r = Env.tmp_file env pat in
      dom (Ok r) env [ Fmt.str "Tmp_file -> %a" Fpath.pp r ]
  | Set_var (c, v) ->
      Log.debug (fun l ->
          l "Set_var %s %a" c Fmt.(option ~none:(any "<none>") string) v);
      let env = Env.set_var env c v in
      let log =
        Fmt.str "Set_var %s %a" c Fmt.(option ~none:(any "<unset>") string) v
      in
      dom (Ok ()) env [ log ]
  | Get_var c ->
      Log.debug (fun l -> l "Get_var %s" c);
      let v = Env.get_var env c in
      let log =
        Fmt.str "Get_var %s -> %a" c
          Fmt.(option ~none:(any "<not set>") string)
          v
      in
      dom (Ok v) env [ log ]
  | With_dir (dir, f) ->
      Log.debug (fun l -> l "With_dir %a" Fpath.pp dir);
      let old = Env.pwd env in
      let env = Env.chdir env dir in
      let domain = dry_run ~env (f ()) in
      let env = Env.chdir domain.env old in
      let log =
        Fmt.str "With_dir %a [%a]" Fpath.pp dir
          Fmt.(vbox ~indent:2 (list ~sep:(any "@,") string))
          domain.logs
      in
      { domain with env; logs = [ log ] }
  | Pwd ->
      Log.debug (fun l -> l "Pwd");
      let r = Env.pwd env in
      dom (Ok r) env [ Fmt.str "Pwd -> %a" Fpath.pp r ]
  | With_output { mode; path; purpose; contents; append } ->
      let pp_append ppf () = if append then Fmt.string ppf "[append]" else () in
      Log.debug (fun l ->
          l "With_output%a %a (%s)" pp_append () Fpath.pp path purpose);
      let buf = Buffer.create 0 in
      let fmt = Format.formatter_of_buffer buf in
      let pp_mode fmt = function
        | None -> Format.fprintf fmt "default"
        | Some n -> Format.fprintf fmt "%#o" n
      in
      let r = contents fmt in
      Fmt.pf fmt "%!";
      let f = Buffer.contents buf in
      let log =
        Fmt.str "Write to %a (mode: %a, purpose: %s)" Fpath.pp path pp_mode mode
          purpose
      in
      dom (Ok r) (Env.write env path f) [ log ]

and dry_run : type r. env:Env.t -> r t -> r domain =
 fun ~env t ->
  let rec go t ~env log =
    match t with
    | Done r -> dom (Ok r) env log
    | Fail e -> dom (Error (`Msg e)) env log
    | Run (cmd, k) -> (
        let domain = interpret_dry ~env cmd in
        let new_log = List.rev domain.logs @ log in
        match domain.result with
        | Ok x -> go (k x) ~env:domain.env new_log
        | Error _ as e -> dom e domain.env new_log)
  in
  let domain = go t ~env [] in
  { domain with logs = List.rev domain.logs }

let dry_run ?(env = env ()) t = dry_run ~env t

let dry_run_trace ?env t =
  let domain = dry_run ?env t in
  List.iter print_endline domain.logs

let generated_files ?(env = env ~exec:(fun _ -> None) ()) t =
  let domain = dry_run ~env t in
  Env.diff_files ~old:env domain.env

module Infix = struct
  let ( >>= ) x f = bind ~f x
  let ( >|= ) x f = map ~f x
end

module Syntax = struct
  open Infix

  let ( let* ) = ( >>= )
  let ( let+ ) = ( >|= )
end

module List = struct
  open Infix

  let iter ~f l = List.fold_left (fun acc e -> acc >>= fun () -> f e) (ok ()) l

  let map ~f l =
    List.fold_left
      (fun acc e ->
        acc >>= fun acc ->
        f e >|= fun e -> e :: acc)
      (ok []) l
end
