rustploy-gui 0.1.21

Rustploy — desktop client (glacier-ui) for the Rustploy PaaS daemon
--!strict
--!nolint FunctionUnused
-- handlers/bundle_import.luau — passo "Importar" do wizard "Novo serviço":
-- recria aqui um serviço exportado de outro servidor. O destino é SEMPRE o
-- projeto aberto (ctx.selected_project_id), como numa criação qualquer — não
-- há escolha de projeto. Ver docs/plano-copiar-servico-entre-servidores.md.
--
-- Duas fases, como no daemon: `import_analyze` (dry_run: nada é gravado;
-- mostra o que não veio e o que precisa de decisão) e `import_create` (analisa
-- de novo com o que foi digitado e só então cria — sem deploy).

local State = require("../state")
local Helpers = require("../helpers")

-- Escolhas e valores digitados sobrevivem às re-análises (o relatório muda,
-- as decisões do usuário não).
local choices: { [string]: string } = {}
local status: { any } = {}
local service_values: { [string]: string } = {}
local project_values: { [string]: string } = {}
local secret_vals: { [string]: string } = {}
-- Git providers deste servidor (carregados sob demanda)
local providers: { any }? = nil

local STATE_LABEL: { [string]: string } = {
    New = "nova",
    Same = "igual",
    Conflict = "em conflito",
}
local CHOICE_LABEL: { [string]: string } = {
    Overwrite = "sobrescrever a do projeto",
    ServiceOnly = "só neste serviço",
    Ignore = "não trazer",
}

local function choice_label(status_entry: any, choice: string): string
    if choice == "Keep" then
        return status_entry.state == "New" and "criar no projeto"
            or "manter a do projeto"
    end
    return CHOICE_LABEL[choice] or choice
end

local function options_for(status_entry: any): { string }
    if status_entry.state == "Conflict" then
        return { "Keep", "Overwrite", "ServiceOnly", "Ignore" }
    end
    return { "Keep", "ServiceOnly", "Ignore" }
end

-- "CHAVE=valor" por linha ⇄ tabela. Texto pré-preenchido com as chaves que
-- faltam, o usuário só completa o valor.
local function parse_key_value_lines(
    text: string?,
    target: { [string]: string }
): ()
    for line in string.gmatch((text or "") .. "\n", "([^\n]*)\n") do
        local key, value = line:match("^%s*([^=%s]+)%s*=(.*)$")
        if key then
            target[key] = Helpers.trim(value)
        end
    end
end

local function key_value_text(
    keys: { any },
    values_by_key: { [string]: string }
): string
    local out = {}
    for _, key in ipairs(keys) do
        table.insert(
            out,
            tostring(key) .. "=" .. (values_by_key[tostring(key)] or "")
        )
    end
    return table.concat(out, "\n")
end

local function nonempty(values: { [string]: string }): { [string]: string }?
    local out: { [string]: string } = {}
    local has_any = false
    for key, value in pairs(values) do
        if value ~= "" then
            out[key] = value
            has_any = true
        end
    end
    return has_any and out or nil
end

local function build_import_request(dry_run: boolean): any
    parse_key_value_lines(ctx.import_service_text, service_values)
    parse_key_value_lines(ctx.import_project_text, project_values)
    parse_key_value_lines(ctx.import_secret_text, secret_vals)

    local name = Helpers.trim(ctx.import_name)
    local provider = ctx.import_provider_id or ""
    local project_env: { [string]: string } = {}
    for key, choice in pairs(choices) do
        project_env[key] = choice
    end

    -- Mapas vazios ficam de fora: o daemon tem `serde(default)` e o `{}` Lua
    -- seria ambíguo (objeto ou array) no JSON.
    return {
        yaml = ctx.import_text or "",
        project_id = ctx.selected_project_id or "",
        name = name ~= "" and name or nil,
        drop_domains = Helpers.is_true(ctx.import_drop_domains),
        git_provider_id = provider ~= "" and provider or nil,
        vars = nonempty(service_values),
        project_vars = nonempty(project_values),
        secrets = nonempty(secret_vals),
        project_env = next(project_env) ~= nil and project_env or nil,
        deploy = Helpers.is_true(ctx.import_deploy),
        dry_run = dry_run,
    }
end

local function load_providers(api: any): ()
    local git_provider_list_ok, git_provider_list_response =
        api:rpc("GitProviderList")
    local provider_options = json.array {}
    if
        git_provider_list_ok
        and type(git_provider_list_response) == "table"
        and git_provider_list_response.GitProviders
    then
        local git_providers = git_provider_list_response.GitProviders
        for _, git_provider in ipairs(git_providers) do
            table.insert(
                provider_options,
                { id = git_provider.id, label = git_provider.name }
            )
        end
    end
    providers = provider_options
end

local function render_providers(): ()
    local rows = json.array {}
    for _, provider in ipairs(providers or {}) do
        table.insert(rows, {
            id = provider.id,
            label = provider.label,
            mark = (ctx.import_provider_id == provider.id) and "◉" or "○",
        })
    end
    ctx.import_providers = json.encode(rows)
end

local function show_report(report: any): ()
    ctx.import_report = "true"
    ctx.import_conflict = report.name_conflict and "true" or "false"
    if Helpers.trim(ctx.import_name) == "" then
        ctx.import_name = report.service_name
    end

    local warns = json.array {}
    for _, warning in ipairs(report.warnings or {}) do
        table.insert(warns, { text = "• " .. tostring(warning.message) })
    end
    ctx.import_warnings = json.encode(warns)
    ctx.import_has_warnings = #warns > 0 and "true" or "false"

    ctx.import_service_text =
        key_value_text(report.missing_service_vars or {}, service_values)
    ctx.import_project_text =
        key_value_text(report.missing_project_vars or {}, project_values)
    ctx.import_secret_text =
        key_value_text(report.missing_secrets or {}, secret_vals)
    ctx.import_has_service_missing = #(report.missing_service_vars or {}) > 0
            and "true"
        or "false"
    ctx.import_has_project_missing = #(report.missing_project_vars or {}) > 0
            and "true"
        or "false"
    ctx.import_has_secret_missing = #(report.missing_secrets or {}) > 0
            and "true"
        or "false"
    ctx.import_has_missing = (
        #(report.missing_service_vars or {})
            + #(report.missing_project_vars or {})
        > 0
    )
            and "true"
        or "false"

    ctx.import_missing_provider = report.missing_git_provider or ""

    status = report.project_env or {}
    local rows = json.array {}
    for _, status_entry in ipairs(status) do
        if not choices[status_entry.key] then
            choices[status_entry.key] = status_entry.choice
        end
        local info = (status_entry.is_secret and "secret · " or "")
            .. (STATE_LABEL[status_entry.state] or status_entry.state)
        table.insert(rows, {
            key = status_entry.key,
            info = info,
            can_choose = status_entry.state ~= "Same" and "true" or "false",
            choice = choice_label(status_entry, choices[status_entry.key]),
        })
    end
    ctx.import_project_env = json.encode(rows)
    ctx.import_has_project_env = #rows > 0 and "true" or "false"
end

-- Pré-visualização (nada é gravado). Devolve se o relatório chegou.
local function analyze_core(): boolean
    local api = State.api
    if not api then
        return false
    end
    if Helpers.trim(ctx.import_text) == "" then
        ctx.import_error = "escolha um arquivo ou cole o texto do pacote"
        return false
    end

    ctx.import_error = ""
    ctx.import_message = "analisando…"
    ctx.import_busy = "true"
    local service_import_ok, service_import_response =
        api:rpc_checked { ServiceImport = build_import_request(true) }
    ctx.import_busy = ""
    ctx.import_message = ""

    if
        not service_import_ok
        or type(service_import_response) ~= "table"
        or not service_import_response.ServiceImportReport
    then
        ctx.import_report = "false"
        ctx.import_error = "erro: " .. tostring(service_import_response)
        return false
    end

    local report = service_import_response.ServiceImportReport
    if report.missing_git_provider and providers == nil then
        load_providers(api)
    end
    show_report(report)
    render_providers()
    return true
end

function import_analyze(): ()
    analyze_core()
end

-- "Escolher arquivo…": lê o .yml e já analisa.
function import_pick_file(): ()
    local path = open_file {
        title = "Importar serviço (.yml)",
        filters = { { name = "YAML", extensions = { "yml", "yaml" } } },
    }
    if not path then
        return
    end

    local read = fetch("file://" .. path)
    if not read.ok then
        ctx.import_error = "erro ao ler o arquivo: " .. tostring(read.error)
        return
    end

    ctx.import_text = read.body
    ctx.import_file = path:match("([^/\\]+)$") or path
    ctx.import_name = ""
    analyze_core()
end

-- "Trocar pacote": volta ao início, sem lixo do pacote anterior.
function import_clear(): ()
    choices = {}
    status = {}
    service_values = {}
    project_values = {}
    secret_vals = {}
    providers = nil
    ctx.import_text = ""
    ctx.import_file = ""
    ctx.import_name = ""
    ctx.import_report = "false"
    ctx.import_error = ""
    ctx.import_message = ""
    ctx.import_provider_id = ""
    ctx.import_drop_domains = "false"
    ctx.import_deploy = "false"
end

-- Passa para a próxima escolha da variável do projeto (ciclo). Sobrescrever
-- mexe no projeto e vale para os outros serviços dele: pede confirmação.
function import_choice(key: string): ()
    local matched_status: any = nil
    for _, status_entry in ipairs(status) do
        if status_entry.key == key then
            matched_status = status_entry
        end
    end
    if not matched_status then
        return
    end

    local options = options_for(matched_status)
    local current_choice = choices[key] or matched_status.choice
    local current_index = 1
    for index, option in ipairs(options) do
        if option == current_choice then
            current_index = index
        end
    end

    local next_choice = options[(current_index % #options) + 1]
    if next_choice == "Overwrite" then
        local confirmed = confirm {
            title = "Sobrescrever no projeto?",
            message = "Trocar "
                .. key
                .. "no projeto altera o valor que os OUTROS serviços do "
                .. "projeto "
                .. "também enxergam.",
            confirm_label = "Sobrescrever",
            destructive = true,
        }
        if not confirmed then
            next_choice = options[((current_index + 1) % #options) + 1]
        end
    end

    choices[key] = next_choice
    analyze_core()
end

function import_use_provider(provider_id: string): ()
    ctx.import_provider_id = (ctx.import_provider_id == provider_id) and ""
        or provider_id
    analyze_core()
end

-- Analisa de novo com o que foi digitado e só cria se estiver limpo.
function import_create(): ()
    local api = State.api
    if not api then
        return
    end
    if not analyze_core() then
        return
    end
    if Helpers.is_true(ctx.import_conflict) then
        ctx.import_error =
            "já existe um serviço com esse nome neste projeto — escolha outro"
        return
    end
    if Helpers.is_true(ctx.import_has_missing) then
        ctx.import_error = "preencha os valores obrigatórios acima"
        return
    end

    ctx.import_error = ""
    ctx.import_message = "criando…"
    ctx.import_busy = "true"
    local service_import_ok, service_import_response =
        api:rpc_checked { ServiceImport = build_import_request(false) }
    ctx.import_busy = ""
    ctx.import_message = ""

    if
        not service_import_ok
        or type(service_import_response) ~= "table"
        or not service_import_response.ServiceImportReport
    then
        ctx.import_error = "erro: " .. tostring(service_import_response)
        return
    end

    local report = service_import_response.ServiceImportReport
    broadcast("service_created", {
        project_id = ctx.selected_project_id or "",
        name = report.service_name,
    })
    close_window()
end