defmodule Mix.Tasks.Spec.Apply do use Mix.Task @shortdoc "Apply proposal patch.json attachments from messages to target files" @moduledoc """ Usage: mix spec.apply --id \ [--source inbox|outbox] \ [--target /path/to/target/repo] \ [--dry-run] [--force] [--diff] \ [--baseline-rev ] [--summary-json] \ [--replace-create] [--allow-type-change] \ [--format pretty|compact] Scans messages of type "proposal" under the chosen source (default: inbox), finds attachments named `patch.json`, and applies JSON Pointer operations (add/replace/remove) to the referenced file(s). Patch format (either inline file or via message ref): { "file": "relative/path.json", # optional; falls back to message.ref.path "base_pointer": "/api", # optional; falls back to message.ref.json_pointer "ops": [ {"op": "replace", "path": "/hash", "value": "..."}, {"op": "add", "path": "/foo/bar", "value": 1}, {"op": "remove", "path": "/old"} ] } """ @impl true def run(argv) do {opts, _, _} = OptionParser.parse(argv, switches: [ id: :string, source: :string, target: :string, dry_run: :boolean, force: :boolean, diff: :boolean, baseline_rev: :string, summary_json: :boolean, replace_create: :boolean, allow_type_change: :boolean, format: :string ] ) id = req!(opts, :id) source = Keyword.get(opts, :source, "inbox") target_root = Keyword.get(opts, :target, File.cwd!()) dry_run = Keyword.get(opts, :dry_run, false) force = Keyword.get(opts, :force, false) show_diff = Keyword.get(opts, :diff, false) baseline_rev_opt = Keyword.get(opts, :baseline_rev, nil) summary_json? = Keyword.get(opts, :summary_json, false) replace_create? = Keyword.get(opts, :replace_create, false) allow_type_change? = Keyword.get(opts, :allow_type_change, false) # Default to compact to minimize diff churn format_choice = Keyword.get(opts, :format, "compact") |> String.downcase() pretty? = format_choice != "compact" req_root = Path.join(["work", "spec_requests", id]) src_dir = Path.join(req_root, source) File.dir?(src_dir) || Mix.raise("Source not found: #{src_dir}") messages = Path.wildcard(Path.join(src_dir, "msg_*.json")) |> Enum.map(&{&1, Jason.decode!(File.read!(&1))}) |> Enum.filter(fn {_p, m} -> m["type"] == "proposal" end) results = if messages == [] do Mix.shell().info("No proposal messages found in #{src_dir}") [] else Enum.reduce(messages, [], fn {msg_path, msg}, acc -> (msg["attachments"] || []) |> Enum.filter(&String.ends_with?(&1, "patch.json")) |> Enum.reduce(acc, fn rel, acc2 -> patch_path = Path.join(req_root, rel) result = apply_patch!( patch_path, msg, target_root, dry_run, force, show_diff, id, baseline_rev_opt, replace_create?, allow_type_change?, pretty? ) info = case result do {:ok, info} when dry_run -> Mix.shell().info( "[DRY-RUN] Would apply #{length(info.paths)} ops to #{info.file} (baseline_ok=#{info.baseline_ok}, baseline_git_ok=#{info.baseline_git_ok})" ) if show_diff, do: print_diff_preview(info) info {:ok, info} -> Mix.shell().info( "Applied #{length(info.paths)} ops to #{info.file} (baseline_ok=#{info.baseline_ok}, baseline_git_ok=#{info.baseline_git_ok}) from #{rel} (msg #{Path.basename(msg_path)})" ) if show_diff, do: print_diff_preview(info) info end acc2 ++ [Map.merge(info, %{message: Path.basename(msg_path), patch: rel, dry_run: dry_run})] end) end) end if summary_json? do summary = %{ id: id, source: source, target_root: target_root, results: results } Mix.shell().info(Jason.encode!(summary, pretty: true)) end :ok end defp apply_patch!( patch_path, msg, target_root, dry_run, force, show_diff, id, baseline_rev_opt, replace_create?, allow_type_change?, pretty? ) do patch = Jason.decode!(File.read!(patch_path)) file_rel = patch["file"] || get_in(msg, ["ref", "path"]) || Mix.raise("Patch missing file and message.ref.path") base_ptr = patch["base_pointer"] || get_in(msg, ["ref", "json_pointer"]) || "" ops = patch["ops"] target_path = Path.join(target_root, file_rel) content = File.read!(target_path) json = Jason.decode!(content) # Baseline check via optional hash in patch baseline_ok = case {patch["baseline_sha256"], patch["baseline_sha1"]} do {nil, nil} -> true {hex, nil} -> compare_hash(:sha256, content, hex) {nil, hex} -> compare_hash(:sha, content, hex) {hex256, _} -> compare_hash(:sha256, content, hex256) end # Optional git-based baseline comparison baseline_rev = patch["baseline_git_rev"] || baseline_rev_opt baseline_git_ok = case baseline_rev do nil -> true rev when is_binary(rev) -> case git_show_file(target_root, rev, file_rel) do {:ok, baseline_content} -> baseline_content == content {:error, _} -> false end end # Also compute hash comparison against the git baseline (detect any content change) baseline_git_hash_ok = case baseline_rev do nil -> true rev when is_binary(rev) -> case git_show_file(target_root, rev, file_rel) do {:ok, baseline_content} -> compare_hash( :sha256, baseline_content, :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) ) {:error, _} -> false end end if (not baseline_ok or not baseline_git_ok or not baseline_git_hash_ok) and not force do Mix.raise( "Baseline mismatch for #{file_rel} (hash_ok=#{baseline_ok}, git_eq=#{baseline_git_ok}, git_hash=#{baseline_git_hash_ok}). Use --force to override." ) end {final, applied_paths} = cond do is_list(ops) -> Enum.reduce(ops, {json, []}, fn op, {acc, paths} -> op_name = Map.fetch!(op, "op") ptr = normalize_ptr(base_ptr, Map.fetch!(op, "path")) # Existence checks for replace/remove case op_name do "replace" -> cond do pointer_exists?(acc, ptr) -> :ok replace_create? -> :ok true -> Mix.raise("Pointer not found for replace: #{ptr}") end "remove" -> if not pointer_exists?(acc, ptr), do: Mix.raise("Pointer not found for remove: #{ptr}") _ -> :ok end # Type check: prevent replacing object/array with scalar (and vice versa) unless allowed effective_op = cond do op_name == "replace" and pointer_exists?(acc, ptr) and not allow_type_change? -> case get_in_pointer(acc, pointer_tokens(ptr)) do {:ok, current} -> v = Map.get(op, "value") if type_tag(current) == type_tag(v), do: op, else: Mix.raise( "Type change not allowed at #{ptr}: #{type_tag(current)} -> #{type_tag(v)}" ) :error -> op end op_name == "replace" and not pointer_exists?(acc, ptr) and replace_create? -> Map.put(op, "op", "add") true -> op end {apply_op!(acc, base_ptr, effective_op), paths ++ [ptr]} end) is_map(patch["merge"]) -> {json_merge(json, patch["merge"]), ["(merge)"]} true -> Mix.raise("Patch missing ops or merge document") end before_str = if pretty?, do: Jason.encode!(json, pretty: true), else: Jason.encode!(json) after_str = if pretty?, do: Jason.encode!(final, pretty: true), else: Jason.encode!(final) info = %{ file: file_rel, baseline_ok: baseline_ok, baseline_git_ok: baseline_git_ok, baseline_git_hash_ok: baseline_git_hash_ok, paths: applied_paths, before: before_str, after: after_str, id: id } if dry_run do if show_diff do {before_path, after_path, out} = write_temp_and_prepare_diff(info) {:ok, Map.merge(info, %{diff_before: before_path, diff_after: after_path, diff: out})} else {:ok, info} end else File.write!(target_path, after_str) if show_diff do {before_path, after_path, out} = write_temp_and_prepare_diff(info) {:ok, Map.merge(info, %{diff_before: before_path, diff_after: after_path, diff: out})} else {:ok, info} end end end defp apply_op!(doc, base, %{"op" => op, "path" => path} = o) do ptr = normalize_ptr(base, path) case op do "add" -> json_put(doc, ptr, Map.fetch!(o, "value"), :add) "replace" -> json_put(doc, ptr, Map.fetch!(o, "value"), :replace) "remove" -> json_remove(doc, ptr) "copy" -> from = normalize_ptr(base, Map.fetch!(o, "from")) case get_in_pointer(doc, pointer_tokens(from)) do {:ok, v} -> mode = if pointer_exists?(doc, ptr), do: :replace, else: :add json_put(doc, ptr, v, mode) :error -> Mix.raise("Pointer not found for copy.from: #{from}") end "move" -> from = normalize_ptr(base, Map.fetch!(o, "from")) case get_in_pointer(doc, pointer_tokens(from)) do {:ok, v} -> mode = if pointer_exists?(doc, ptr), do: :replace, else: :add doc1 = json_remove(doc, from) json_put(doc1, ptr, v, mode) :error -> Mix.raise("Pointer not found for move.from: #{from}") end other -> Mix.raise("Unsupported op: #{inspect(other)}") end end defp normalize_ptr("", p), do: p defp normalize_ptr(nil, p), do: p defp normalize_ptr(base, p) do base = if base == "/", do: "", else: base p = if String.starts_with?(p, "/"), do: p, else: "/" <> p base <> p end # JSON Pointer utilities (very small subset) defp json_put(doc, path, value, mode) when is_binary(path) do tokens = pointer_tokens(path) put_in_pointer(doc, tokens, value, mode) end defp json_remove(doc, path) do tokens = pointer_tokens(path) remove_in_pointer(doc, tokens) end # RFC 7396 (JSON Merge Patch): null deletes, objects merge recursively, other values replace defp json_merge(target, patch) when is_map(target) and is_map(patch) do Enum.reduce(patch, target, fn {k, v}, acc -> cond do is_nil(v) -> Map.delete(acc, k) is_map(v) and is_map(Map.get(acc, k)) -> Map.put(acc, k, json_merge(Map.get(acc, k), v)) true -> Map.put(acc, k, v) end end) end defp json_merge(_target, patch), do: patch defp pointer_tokens(""), do: [] defp pointer_tokens("/"), do: [] defp pointer_tokens(path) do path |> String.trim_leading("/") |> String.split("/", trim: true) |> Enum.map( &String.replace(&1, ["~1", "~0"], fn "~1" -> "/" "~0" -> "~" end) ) |> Enum.map(&decode_index/1) end defp decode_index(seg) do case Integer.parse(seg) do {i, ""} -> {:idx, i} _ -> if seg == "-", do: {:append}, else: {:key, seg} end end defp put_in_pointer(doc, [], _val, _mode), do: doc defp put_in_pointer(doc, [{:key, k}], val, :replace) when is_map(doc), do: Map.put(doc, k, val) defp put_in_pointer(doc, [{:key, k}], val, :add) when is_map(doc), do: Map.put(doc, k, val) defp put_in_pointer(list, [{:idx, i}], val, mode) when is_list(list) do cond do mode == :replace and i < length(list) -> List.replace_at(list, i, val) mode == :add and i == length(list) -> list ++ [val] mode == :add and i < length(list) -> List.insert_at(list, i, val) true -> Mix.raise("Invalid list index #{i} for mode #{mode}") end end defp put_in_pointer(list, [{:append}], val, :add) when is_list(list), do: list ++ [val] defp put_in_pointer(doc, [{:key, k} | rest], val, mode) when is_map(doc) do child = Map.get(doc, k, default_for(rest)) Map.put(doc, k, put_in_pointer(child, rest, val, mode)) end defp put_in_pointer(list, [{:idx, i} | rest], val, mode) when is_list(list) do ensure = if i < length(list), do: Enum.at(list, i), else: default_for(rest) updated = put_in_pointer(ensure, rest, val, mode) cond do i < length(list) -> List.replace_at(list, i, updated) i == length(list) -> list ++ [updated] true -> Mix.raise("Invalid list index #{i}") end end defp put_in_pointer(list, [{:append} | rest], val, mode) when is_list(list) do updated = put_in_pointer(default_for(rest), rest, val, mode) list ++ [updated] end defp put_in_pointer(_other, _rest, _val, _mode), do: Mix.raise("Unsupported structure for pointer") defp remove_in_pointer(doc, [{:key, k}]) when is_map(doc), do: Map.delete(doc, k) defp remove_in_pointer(list, [{:idx, i}]) when is_list(list) and i < length(list), do: List.delete_at(list, i) defp remove_in_pointer(doc, [{:key, k} | rest]) when is_map(doc) do case Map.fetch(doc, k) do {:ok, v} -> Map.put(doc, k, remove_in_pointer(v, rest)) :error -> doc end end defp remove_in_pointer(list, [{:idx, i} | rest]) when is_list(list) and i < length(list) do v = Enum.at(list, i) List.replace_at(list, i, remove_in_pointer(v, rest)) end defp remove_in_pointer(list, [{:append} | _rest]) when is_list(list), do: list defp remove_in_pointer(other, _), do: other defp default_for([{:idx, _} | _]), do: [] defp default_for([{:key, _} | _]), do: %{} defp default_for([{:append} | _]), do: [] defp req!(opts, key), do: Keyword.get(opts, key) || Mix.raise("Missing --#{key}") defp compare_hash(alg, content, expected_hex) do try do actual = :crypto.hash(alg, content) |> Base.encode16(case: :lower) String.downcase(expected_hex) == actual rescue _ -> false end end defp git_show_file(dir, rev, rel_path) do if File.dir?(Path.join(dir, ".git")) do {out, status} = System.cmd("git", ["-C", dir, "show", rev <> ":" <> rel_path], stderr_to_stdout: true) if status == 0, do: {:ok, out}, else: {:error, {:git_error, out}} else {:error, :not_git_repo} end end defp pointer_exists?(doc, path) when is_binary(path) do tokens = pointer_tokens(path) case get_in_pointer(doc, tokens) do {:ok, _v} -> true :error -> false end end defp get_in_pointer(doc, []), do: {:ok, doc} defp get_in_pointer(doc, [{:key, k} | rest]) when is_map(doc) do case Map.fetch(doc, k) do {:ok, v} -> get_in_pointer(v, rest) :error -> :error end end defp get_in_pointer(list, [{:idx, i} | rest]) when is_list(list) do if i < length(list) do v = Enum.at(list, i) get_in_pointer(v, rest) else :error end end defp get_in_pointer(_other, _rest), do: :error defp type_tag(v) when is_map(v), do: :object defp type_tag(v) when is_list(v), do: :array defp type_tag(v) when is_binary(v), do: :string defp type_tag(v) when is_number(v), do: :number defp type_tag(v) when is_boolean(v), do: :boolean defp type_tag(nil), do: :null defp type_tag(_), do: :other defp write_temp_and_prepare_diff(info) do before = info[:before] after_content = info[:after] id = info[:id] file = info[:file] base_dir = Path.join([File.cwd!(), "work", ".tmp", id]) File.mkdir_p!(base_dir) before_path = Path.join(base_dir, Path.basename(file) <> ".before.json") after_path = Path.join(base_dir, Path.basename(file) <> ".after.json") File.write!(before_path, before) File.write!(after_path, after_content) {out, _} = System.cmd("git", ["--no-pager", "diff", "--no-index", "--no-color", before_path, after_path], stderr_to_stdout: true ) {before_path, after_path, out} end defp print_diff_preview(%{file: file} = info) do case write_temp_and_prepare_diff(info) do {before_path, after_path, out} -> Mix.shell().info("\n--- Unified diff (#{file})\n#{before_path} -> #{after_path}\n\n" <> out) _ -> :ok end end end