|
| 1 | +#!/usr/bin/env elixir |
| 2 | +# Smarter generator that introspects each function's actual arguments. |
| 3 | +# Run: mix run scripts/gen_cli_v2.exs |
| 4 | + |
| 5 | +defmodule GenCliV2 do |
| 6 | + @moduledoc """ |
| 7 | + Generates test files by introspecting each CLI function's actual |
| 8 | + arguments using `Code.fetch_docs/1` and AST analysis. |
| 9 | + """ |
| 10 | + @test_dir "test/ado_cli/cli" |
| 11 | + |
| 12 | + def run do |
| 13 | + File.mkdir_p!(@test_dir) |
| 14 | + |
| 15 | + for {module, fns} <- specs(), {module, _fns} <- ... do |
| 16 | + # placeholder |
| 17 | + :ok |
| 18 | + end |
| 19 | + |
| 20 | + # Simple per-function approach: read the source, find the function |
| 21 | + # definition, extract the destructuring of parsed.arguments and the |
| 22 | + # Map.fetch! / Map.get calls on parsed.options. |
| 23 | + Path.wildcard("lib/ado_cli/cli/*.ex") |
| 24 | + |> Enum.reject(&String.contains?(&1, "helpers.ex")) |
| 25 | + |> Enum.map(&module_name/1) |
| 26 | + |> Enum.each(&generate/1) |
| 27 | + end |
| 28 | + |
| 29 | + defp module_name(path) do |
| 30 | + path |
| 31 | + |> Path.basename(".ex") |
| 32 | + |> String.split("_") |
| 33 | + |> Enum.map_join(".", &String.capitalize/1) |
| 34 | + |> then(&("AdoCli.CLI." <> &1)) |
| 35 | + end |
| 36 | + |
| 37 | + defp generate(module) do |
| 38 | + file = "lib/ado_cli/cli/#{macro_name(module)}.ex" |
| 39 | + source = File.read!(file) |
| 40 | + |
| 41 | + case analyze_module(source) do |
| 42 | + :none -> |
| 43 | + :ok |
| 44 | + |
| 45 | + functions -> |
| 46 | + test_file = "#{@test_dir}/#{macro_name(module)}_test.exs" |
| 47 | + if File.exists?(test_file), do: :ok, else: do_generate(module, functions, test_file) |
| 48 | + end |
| 49 | + end |
| 50 | + |
| 51 | + defp analyze_module(source) do |
| 52 | + # Find all `def NAME(parsed)` functions and extract the body |
| 53 | + regex = ~r/def\s+(\w+)\s*\(parsed\)\s+do(.*?)(?=\n\s*def\s|\n\s*defp\s|\n\s+end\s*$\n|\Z)/m |
| 54 | + |
| 55 | + Regex.scan(regex, source, capture: :all_but_first) |
| 56 | + |> Enum.map(fn [name, body] -> {name, analyze_body(body)} end) |
| 57 | + end |
| 58 | + |
| 59 | + # Parse the function body to find argument keys and HTTP calls |
| 60 | + defp analyze_body(body) do |
| 61 | + args = extract_args(body) |
| 62 | + options = extract_options(body) |
| 63 | + http_call = detect_http_call(body) |
| 64 | + %{args: args, options: options, http_call: http_call} |
| 65 | + end |
| 66 | + |
| 67 | + defp extract_args(body) do |
| 68 | + # Find patterns like: |
| 69 | + # %{project: project, repo_id: repo_id, pr_id: pr_id} = parsed.arguments |
| 70 | + # project = parsed.arguments.project |
| 71 | + # %{name: name} = parsed.arguments |
| 72 | + regex = ~r/parsed\.arguments(?:\.(\w+))?(?:\s*do|\s+as\s+\{([^}]+)\})?/ |
| 73 | + |
| 74 | + case Regex.run(regex, body, capture: :all_but_first) do |
| 75 | + [_, nil, nil] -> [] |
| 76 | + [_, key, nil] -> [key] |
| 77 | + [_, nil, struct] -> parse_struct_keys(struct) |
| 78 | + [_, key, struct] when is_binary(key) and is_binary(struct) -> [key | parse_struct_keys(struct)] |
| 79 | + _ -> [] |
| 80 | + end |
| 81 | + end |
| 82 | + |
| 83 | + defp parse_struct_keys(struct) do |
| 84 | + # %{a: x, b: y} -> [a, b] |
| 85 | + Regex.scan(~r/(\w+):/, struct, capture: :all_but_first) |
| 86 | + |> Enum.map(fn [k] -> k end) |
| 87 | + end |
| 88 | + |
| 89 | + defp extract_options(body) do |
| 90 | + # Find patterns like: |
| 91 | + # value = Map.get(parsed.options, :name) |
| 92 | + # value = Map.fetch!(parsed.options, :name) |
| 93 | + regex1 = ~r/Map\.(?:get|fetch!)\(parsed\.options,\s*:(\w+)\)/ |
| 94 | + |
| 95 | + Regex.scan(regex1, body, capture: :all_but_first) |
| 96 | + |> Enum.map(fn [k] -> k end) |
| 97 | + end |
| 98 | + |
| 99 | + defp detect_http_call(body) do |
| 100 | + cond do |
| 101 | + String.contains?(body, "Client.post(") -> :post |
| 102 | + String.contains?(body, "Client.put(") -> :put |
| 103 | + String.contains?(body, "Client.patch(") -> :patch |
| 104 | + String.contains?(body, "Client.delete(") -> :delete |
| 105 | + String.contains?(body, "Client.get(") -> :get |
| 106 | + true -> nil |
| 107 | + end |
| 108 | + end |
| 109 | + |
| 110 | + defp do_generate(module, functions, test_file) do |
| 111 | + content = render(module, functions) |
| 112 | + File.write!(test_file, content) |
| 113 | + IO.puts(" Generated #{Path.basename(test_file)} (#{length(functions)} functions)") |
| 114 | + end |
| 115 | + |
| 116 | + defp render(module, functions) do |
| 117 | + tests = Enum.map_join(functions, "\n\n", fn {name, info} -> render_test(module, name, info) end) |
| 118 | + |
| 119 | + """ |
| 120 | + defmodule #{module}Test do |
| 121 | + use AdoCli.CLI.TestHelper |
| 122 | + alias #{module} |
| 123 | +
|
| 124 | + #{tests} |
| 125 | + end |
| 126 | + """ |
| 127 | + end |
| 128 | + |
| 129 | + defp render_test(module, fn_name, %{args: args, options: options, http_call: method}) do |
| 130 | + args_map = |
| 131 | + if args == [] do |
| 132 | + "" |
| 133 | + else |
| 134 | + pairs = Enum.map_join(args, ", ", fn k -> "#{k}: 1" end) |
| 135 | + ", arguments: %{#{pairs}}" |
| 136 | + end |
| 137 | + |
| 138 | + options_map = build_options_map(options) |
| 139 | + |
| 140 | + # Try to extract the path from the body |
| 141 | + path = extract_path(method, fn_name) |
| 142 | + |
| 143 | + cond do |
| 144 | + method == nil -> |
| 145 | + # Non-HTTP function (e.g., reads config) |
| 146 | + """ |
| 147 | + test "#{fn_name} works" do |
| 148 | + apply(#{module}, :#{fn_name}, [%{options: %{json: true, #{options_map}}#{args_map}}]) |
| 149 | + assert_receive {:cli_mate_shell, :halt, _}, 500 |
| 150 | + end |
| 151 | + """ |
| 152 | + |
| 153 | + true -> |
| 154 | + method_str = |
| 155 | + case method do |
| 156 | + :get -> "expect_success_json" |
| 157 | + :post -> "expect_post_success" |
| 158 | + :put -> "expect_put_success" |
| 159 | + :patch -> "expect_patch_success" |
| 160 | + :delete -> "expect_delete_success" |
| 161 | + end |
| 162 | + |
| 163 | + body_arg = |
| 164 | + case method do |
| 165 | + :get -> ", ~s({\"value\":[]})" |
| 166 | + m when m in [:post, :put, :patch] -> ", \"\", \"{\\\"id\\\":1}\"" |
| 167 | + :delete -> "" |
| 168 | + end |
| 169 | + |
| 170 | + """ |
| 171 | + describe "#{fn_name}" do |
| 172 | + test "halts 0 on success", %{server: server} do |
| 173 | + #{method_str}(server, "#{path}"#{body_arg}, fn -> |
| 174 | + apply(#{module}, :#{fn_name}, [%{options: %{#{options_map}}#{args_map}}]) |
| 175 | + end) |
| 176 | + end |
| 177 | +
|
| 178 | + test "halts 1 on API error", %{server: server} do |
| 179 | + expect_api_error(server, "#{path}", 500, "{}", fn -> |
| 180 | + apply(#{module}, :#{fn_name}, [%{options: %{#{options_map}}#{args_map}}]) |
| 181 | + end) |
| 182 | + end |
| 183 | + end |
| 184 | + """ |
| 185 | + end |
| 186 | + end |
| 187 | + |
| 188 | + defp build_options_map(options) do |
| 189 | + base = "json: true" |
| 190 | + extras = Enum.map_join(options, ", ", fn k -> "#{k}: default_value_for(#{k})" end) |
| 191 | + if extras == "", do: base, else: base <> ", " <> extras |
| 192 | + end |
| 193 | + |
| 194 | + # Default values for known option keys |
| 195 | + defp default_value_for("name"), do: "\"test\"" |
| 196 | + defp default_value_for("description"), do: "\"test\"" |
| 197 | + defp default_value_for("title"), do: "\"test\"" |
| 198 | + defp default_value_for("type"), do: "\"Bug\"" |
| 199 | + defp default_value_for("text"), do: "\"comment\"" |
| 200 | + defp default_value_for("state"), do: "\"Active\"" |
| 201 | + defp default_value_for("branch"), do: "\"main\"" |
| 202 | + defp default_value_for("path"), do: "\"test.yml\"" |
| 203 | + defp default_value_for("message"), do: "\"test\"" |
| 204 | + defp default_value_for("wiql"), do: "\"SELECT [System.Id] FROM WorkItems\"" |
| 205 | + defp default_value_for("variables"), do: "nil" |
| 206 | + defp default_value_for("tags"), do: "nil" |
| 207 | + defp default_value_for("source"), do: "\"github\"" |
| 208 | + defp default_value_for("endpoint"), do: "\"https://api.github.com\"" |
| 209 | + defp default_value_for("repository"), do: "\"repo\"" |
| 210 | + defp default_value_for(_), do: "1" |
| 211 | + |
| 212 | + # Heuristic path extraction |
| 213 | + defp extract_path(method, fn_name) do |
| 214 | + # For now, just use a placeholder - we'd need to read the actual file |
| 215 | + "/_apis/#{function_to_resource(fn_name)}" |
| 216 | + end |
| 217 | + |
| 218 | + defp function_to_resource("list_areas"), do: "wit/classificationnodes" |
| 219 | + defp function_to_resource("list_pools"), do: "distributedtask/pools" |
| 220 | + defp function_to_resource("list_pipelines"), do: "pipelines" |
| 221 | + defp function_to_resource("list_queues"), do: "distributedtask/queues" |
| 222 | + defp function_to_resource("list_groups"), do: "graph/groups" |
| 223 | + defp function_to_resource("list_repos"), do: "git/repositories" |
| 224 | + defp function_to_resource("list_branches"), do: "git/refs" |
| 225 | + defp function_to_resource("list_artifacts"), do: "pipelines/artifacts" |
| 226 | + defp function_to_resource("list_imports"), do: "git/importRequests" |
| 227 | + defp function_to_resource(_), do: "resource" |
| 228 | +end |
| 229 | + |
| 230 | +GenCliV2.run() |
0 commit comments