diff --git a/src/am.lua b/src/am.lua index 501e4f5..b1952f5 100644 --- a/src/am.lua +++ b/src/am.lua @@ -283,8 +283,12 @@ end ---@param file string? ---@param path string ---@param value any ----@param output_format "json"|"hjson"? -function am.modify_file(mode, file, path, value, output_format) +---@param content_type "json"|"hjson"? +function am.modify_file(mode, file, path, value, content_type) + if type(content_type) ~= "string" then + content_type = "hjson" + end + -- split path by dot local path_parts = {} for part in string.gmatch(path, "[^%.]+") do @@ -292,13 +296,13 @@ function am.modify_file(mode, file, path, value, output_format) end -- try parse value as json/hjson - if type(value) == "string" then + if table.includes({ "json", "hjson" }, content_type) and type(value) == "string" then local parsed_value, err = hjson.parse(value) ami_assert(err == nil, "failed to parse value: " .. tostring(err), EXIT_MODIFY_ERROR) value = parsed_value end - local ok, err = ami_util.modify_file(mode, file, path_parts, value, output_format) + local ok, err = ami_util.modify_file(mode, file, path_parts, value, content_type) ami_assert(ok, "failed to modify configuration: " .. tostring(err), EXIT_MODIFY_ERROR) log_success"Requested modification applied." end diff --git a/src/ami/internals/interface/app.lua b/src/ami/internals/interface/app.lua index 3b0d192..e4977c6 100644 --- a/src/ami/internals/interface/app.lua +++ b/src/ami/internals/interface/app.lua @@ -43,7 +43,7 @@ local function new(options) index = 0, description = "ami 'info' sub command", summary = implementation_status .. " Prints runtime info and status of the app", - action = violation_fallback + action = violation_fallback, }, setup = { index = 1, @@ -52,24 +52,24 @@ local function new(options) options = { environment = { index = 0, - aliases = {"env"}, - description = "Creates application environment" + aliases = { "env" }, + description = "Creates application environment", }, app = { index = 1, - description = "Generates app folder structure and files" + description = "Generates app folder structure and files", }, configure = { index = 2, - description = "Configures application and renders templates" + description = "Configures application and renders templates", }, ["no-validate"] = { index = 3, - description = "Disables platform and configuration validation" - } + description = "Disables platform and configuration validation", + }, }, -- (options, command, args, cli) - action = function(options) + action = function (options) local no_options = #table.keys(options) == 0 if no_options or options.environment then @@ -84,9 +84,9 @@ local function new(options) end if (no_options or options.configure) and not am.app.__are_templates_generated() then - am.app.render() + am.app.render() end - end + end, }, validate = { index = 2, @@ -95,42 +95,42 @@ local function new(options) options = { platform = { index = 1, - description = "Validates application platform" + description = "Validates application platform", }, configuration = { index = 2, - description = "Validates application configuration" - } + description = "Validates application configuration", + }, }, - action = violation_fallback + action = violation_fallback, }, start = { index = 3, - aliases = {"s"}, + aliases = { "s" }, description = "ami 'start' sub command ", summary = implementation_status .. " Starts the app", -- (options, command, args, cli) - action = violation_fallback + action = violation_fallback, }, stop = { index = 4, description = "ami 'stop' sub command", summary = implementation_status .. " Stops the app", -- (options, command, args, cli) - action = violation_fallback + action = violation_fallback, }, update = { index = 5, description = "ami 'update' command", summary = "Updates the app or returns setup required", -- (options, command, args, cli) - action = function() + action = function () local available, id, ver = am.app.is_update_available() if available then ami_error("Found new version " .. ver .. " of " .. id .. ", please run setup...", EXIT_SETUP_REQUIRED) end - log_info("Application is up to date.") - end + log_info"Application is up to date." + end, }, remove = { index = 6, @@ -138,24 +138,26 @@ local function new(options) summary = "Remove the app or parts based on options", options = { all = { - description = "Removes entire application keeping only app.hjson" + description = "Removes entire application keeping only app.hjson", + }, + force = { + description = "Forces removal of application", + hidden = true, }, - force = { - description = "Forces removal of application", - hidden = true, - } }, -- (options, command, args, cli) - action = function(options) - ami_assert(am.__has_app_specific_interface or options.force, "you are trying to remove app, but app specific removal routine is not available. Use '--force' to force removal", EXIT_APP_REMOVE_ERROR) - if options.all then + action = function (options) + ami_assert(am.__has_app_specific_interface or options.force, + "you are trying to remove app, but app specific removal routine is not available. Use '--force' to force removal", + EXIT_APP_REMOVE_ERROR) + if options.all then am.app.remove() - log_success("Application removed.") + log_success"Application removed." else am.app.remove_data() - log_success("Application data removed.") + log_success"Application data removed." end - end + end, }, modify = { description = "ami 'modify' sub command", @@ -164,43 +166,60 @@ local function new(options) options = { file = { index = 1, - description = "Path to configuration file to modify (Defaults to app.h/json)" + aliases = { "f" }, + description = "Path to configuration file to modify (Defaults to app.h/json)", }, set = { description = "Sets value at path", - type = "boolean" + type = "boolean", }, unset = { description = "Unsets value at path", - type = "boolean" + type = "boolean", }, add = { description = "Adds value to list or dictionary", - type = "boolean" + type = "boolean", }, remove = { description = "Removes value from list or dictionary", - type = "boolean" + type = "boolean", + }, + json = { + description = "Forces file to be treated as JSON", + type = "boolean", + }, + hjson = { + description = "Forces file to be treated as HJSON (default)", + type = "boolean", }, - ["json-output"] = { - description = "Outputs value as JSON (not HJSON)", - type = "boolean" - } }, action = function (options, _, args) - ami_assert(#args > 1 or (#args == 1 and options.unset), "invalid arguments to modify command - needs path and value or --unset and path", EXIT_MODIFY_ERROR) - options.set = options.set or (not options.unset and not options.add and not options.remove) -- default to set + ami_assert(#args > 1 or (#args == 1 and options.unset), + "invalid arguments to modify command - needs path and value or --unset and path", EXIT_MODIFY_ERROR) + options.set = options.set or + (not options.unset and not options.add and not options.remove) -- default to set - local options_without_file = table.filter(options, function(key, v) return key ~= "file" and v == true end) - ami_assert(#table.keys(options_without_file) <= 1, "only one modification mode can be specified", EXIT_MODIFY_ERROR) + local modify_options = table.filter(options, function (key, v) + return v and not table.includes({ "file", "json", "hjson" }, key) + end) + ami_assert(#table.keys(modify_options) <= 1, "only one modification mode can be specified", + EXIT_MODIFY_ERROR) local mode = "auto" - if #table.keys(options_without_file) == 1 then - mode = table.keys(options_without_file)[1] + if #table.keys(modify_options) == 1 then + mode = table.keys(modify_options)[1] end - am.modify_file(mode, options.file, args[1].value, #args > 1 and args[2].value or nil) - end - }, + ami_assert(not (options.json and options.hjson), + "only one format flag (--json or --hjson) can be specified", EXIT_MODIFY_ERROR) + local content_type = "hjson" + if options.json then + content_type = "json" + end + + am.modify_file(mode, options.file, args[1].value, #args > 1 and args[2].value or nil, content_type) + end, + }, show = { description = "ami 'show' sub command", summary = "Shows value from app configuration file", @@ -208,19 +227,19 @@ local function new(options) options = { file = { index = 1, - description = "Path to configuration file to show from (Defaults to app.h/json)" - } + description = "Path to configuration file to show from (Defaults to app.h/json)", + }, }, action = function (options, _, args) am.show_file(options.file, type(args) == "table" and #args > 0 and args[1].value or nil) - end + end, }, about = { index = 7, description = "ami 'about' sub command", summary = implementation_status .. " Prints informations about app", -- (options, command, args, cli) - action = violation_fallback + action = violation_fallback, }, pack = { description = "ami 'pack' sub command", @@ -228,20 +247,20 @@ local function new(options) options = { output = { index = 1, - aliases = {"o"}, - description = "Output path for the archive" + aliases = { "o" }, + description = "Output path for the archive", }, light = { index = 2, - description = "If used the archive will not include application data" - } + description = "If used the archive will not include application data", + }, }, action = function (options) - am.app.pack({ + am.app.pack{ destination = options.output, - mode = options.light and "light" or "full" - }) - end + mode = options.light and "light" or "full", + } + end, }, unpack = { description = "ami 'unpack' sub command", @@ -250,19 +269,19 @@ local function new(options) options = { source = { index = 1, - description = "Path to the archive" - } + description = "Path to the archive", + }, }, action = function (options) options.__rerun = table.get(options, "__rerun", true) am.app.unpack(options) - log_success("application unpacked") - end - } + log_success"application unpacked" + end, + }, } - return base + return base end return { - new = new + new = new, } diff --git a/src/ami/internals/util.lua b/src/ami/internals/util.lua index b4d7d76..d087623 100644 --- a/src/ami/internals/util.lua +++ b/src/ami/internals/util.lua @@ -215,22 +215,26 @@ end ---@param file string? ---@param path string[] ---@param value any ----@param output_format "json"|"hjson"? +---@param content_type "json"|"hjson"? ---@return boolean?, string? -function util.modify_file(mode, file, path, value, output_format) +function util.modify_file(mode, file, path, value, content_type) if type(mode) ~= "string" then mode = "auto" end - if type(output_format) ~= "string" then - output_format = "hjson" + if type(content_type) ~= "string" then + content_type = "hjson" + else + if not table.includes({ "hjson", "json" }, content_type) then + return nil, "content type must be either 'hjson' or 'json'" + end end if type(file) ~= "string" then file, _ = find_default_modify_file() - if type(file) ~= "string" then return nil, "no valid configuration file found to modify" end + if type(file) ~= "string" then return nil, "no valid configuration file found to modify" end end local raw_content, err = fs.read_file(file --[[@as string ]]) - if not raw_content then + if not raw_content then if table.includes({ "auto", "set" }, mode) then raw_content = "{}" elseif table.includes({ "add" }, mode) then @@ -238,11 +242,11 @@ function util.modify_file(mode, file, path, value, output_format) else return nil, err or "failed to read configuration file" end - end + end local content, err = hjson.parse(raw_content --[[@as string ]]) - if not content then return nil, "failed to parse configuration file '" .. tostring(file) .. "': " .. tostring(err) end + if not content then return nil, "failed to parse configuration file '" .. tostring(file) .. "': " .. tostring(err) end - if not modify_handlers[mode] then return nil, "invalid modify mode: " .. tostring(mode) end + if not modify_handlers[mode] then return nil, "invalid modify mode: " .. tostring(mode) end local default = value if table.includes({"add", "remove"}, mode) then @@ -251,23 +255,28 @@ function util.modify_file(mode, file, path, value, output_format) local current_value = table.get(content, path, default) local new_value, err = modify_handlers[mode](current_value, value) - if not new_value and err then return nil, "modification failed: " .. tostring(err) end + if not new_value and err then return nil, "modification failed: " .. tostring(err) end local result, err = table.set(content, path, new_value) if err == "cannot set nested value on a non-table object" then return nil, "cannot set nested value on a non-table value at path: " .. table.concat(path, ".") end - if not result then return nil, "failed to set new value in configuration" end + if not result then return nil, "failed to set new value in configuration" end + + local marshallers = { + hjson = hjson.stringify, + json = hjson.stringify_to_json + } - local marshal_fn = output_format == "json" and hjson.stringify_to_json or hjson.stringify + local marshal_fn = marshallers[content_type] local new_raw_content, err = marshal_fn(result, { indent = "\t", sort_keys = true }) - if not new_raw_content then return nil, "failed to serialize modified configuration: " .. tostring(err) end + if not new_raw_content then return nil, "failed to serialize modified configuration: " .. tostring(err) end local ok, err = fs.write_file(file .. ".new" --[[@as string ]], new_raw_content --[[@as string ]]) - if not ok then return nil, "failed to write modified configuration to file '" .. tostring(file) .. ".new': " .. tostring(err) end + if not ok then return nil, "failed to write modified configuration to file '" .. tostring(file) .. ".new': " .. tostring(err) end -- replace original file local ok, err = os.rename(file .. ".new" --[[@as string ]], file --[[@as string ]]) - if not ok then return nil, "failed to replace original configuration file '" .. tostring(file) .. "': " .. tostring(err) end - return true + if not ok then return nil, "failed to replace original configuration file '" .. tostring(file) .. "': " .. tostring(err) end + return true end ---checks configurations diff --git a/tests/test/modify_n_show.lua b/tests/test/modify_n_show.lua index 09fb0d9..f8e02f1 100644 --- a/tests/test/modify_n_show.lua +++ b/tests/test/modify_n_show.lua @@ -383,3 +383,58 @@ add_fail_test(test, "syntax and logic errors", { expected_error = "unknown option", }, }) + +-- Test format flags +test["format flags: json and hjson output"] = function () + local test_dir = "tests/tmp/ami_test_format" + fs.mkdirp(test_dir) + fs.remove(test_dir, { recurse = true, content_only = true }) + + -- Test with --json flag + local json_file = path.combine(test_dir, "test.json") + fs.write_file(json_file, "{}") + + local args_json = { "--log-level=error", "--path=" .. test_dir, "modify", "--file=test.json", "--json", + "test.key", "value123" } + local ok, err = pcall(ami, table.unpack(args_json)) + os.chdir(default_cwd) + test.assert(ok, "Failed to modify with --json: " .. tostring(err)) + + -- Check that the file is in JSON format (no comments, strict JSON) + local json_content = fs.read_file(path.combine(test_dir, "test.json")) + test.assert(json_content ~= nil, "Failed to read json file") + -- JSON format should not have comments and should use quotes for keys + test.assert(json_content:find'"test"' ~= nil, "JSON output should have quoted keys") + + -- Test with --hjson flag (explicit, same as default) + local hjson_file = path.combine(test_dir, "test.hjson") + fs.write_file(hjson_file, "{}") + + local args_hjson = { "--log-level=error", "--path=" .. test_dir, "modify", "--file=test.hjson", "--hjson", + "test.key", "value456" } + local ok, err = pcall(ami, table.unpack(args_hjson)) + os.chdir(default_cwd) + test.assert(ok, "Failed to modify with --hjson: " .. tostring(err)) + + -- Check that the file is in HJSON format + local hjson_content = fs.read_file(path.combine(test_dir, "test.hjson")) + test.assert(hjson_content ~= nil, "Failed to read hjson file") + + -- Test that using both --json and --hjson fails + local both_file = path.combine(test_dir, "test_both.hjson") + fs.write_file(both_file, "{}") + + local args_both = { "--log-level=error", "--path=" .. test_dir, "modify", "--file=test_both.hjson", + "--json", "--hjson", "test.key", "value789" } + local ok, err = pcall(ami, table.unpack(args_both)) + os.chdir(default_cwd) + test.assert(not ok, "Should fail when both --json and --hjson are specified") + test.assert(tostring(err):find"only one format flag" ~= nil, + "Error message should mention only one format flag can be specified") + + os.chdir(default_cwd) +end + +if not TEST then + test.summary() +end