CLI: let --export-settings - write the merged config JSON to stdout (#15698)

`--export-settings` already writes the merged config as JSON at the
right point in the CLI flow. Passing `-` now writes that same document
to stdout, so scripts can inspect the effective config without a temp
file. This replaces #14605.

- `ConfigBase::save_to_json` gains a stream overload. The file overload
serializes through it before opening the file, so the output format is
unchanged, and a config that cannot be serialized (invalid UTF-8) now
leaves the existing file untouched instead of truncating it.
- On stdout, invalid UTF-8 in string values is written as U+FFFD instead
of ending the process with an uncaught `type_error`. Files keep the
strict behaviour.
- To keep stdout pure JSON, `-` is rejected up front (stderr message,
`CLI_INVALID_PARAMS`, shell status 254) when combined with an action or
transform that can write to stdout or does real work: `--info`,
`--help`, `--orient`, slicing and exporting. Options that do nothing
without a slice (`--uptodate`, `--min-save`, `--pipe`, ...) are still
accepted.
- The one unconditional stdout write on a success path, "skip locked
instance" during arrange, now goes to the log.
- Every other value, including the default `output.json`, behaves as
before.

Tests in `tests/libslic3r/test_config.cpp`: the stream output equals the
file output and keeps the tab-indented format; invalid UTF-8 throws on
the strict path and is replaced when asked; a failed save leaves the
previous file intact.
This commit is contained in:
HanifKoh
2026-09-15 13:16:22 +08:00
committed by GitHub
5 changed files with 99 additions and 9 deletions
+25 -2
View File
@@ -1387,6 +1387,25 @@ int CLI::run(int argc, char **argv)
if (downward_check_option)
downward_check = downward_check_option->value;
// --export-settings - writes its JSON to stdout, so reject every action or transform that may write there
// too (--info, --help, --orient, slicing and exporting). The allowed ones do nothing when nothing is
// sliced or exported.
if (std::find(m_actions.begin(), m_actions.end(), "export_settings") != m_actions.end() && m_config.opt_string("export_settings") == "-") {
static const std::set<std::string> stdout_compatible = { "export_settings", "uptodate", "load_defaultfila", "min_save",
"mtcpp", "mstpp", "no_check", "normative_check", "pipe" };
for (const std::vector<std::string> *opt_keys : { &m_actions, &m_transforms }) {
for (const std::string &opt_key : *opt_keys) {
if (stdout_compatible.count(opt_key) == 0) {
std::string flag = opt_key;
std::replace(flag.begin(), flag.end(), '_', '-');
boost::nowide::cerr << "--export-settings - cannot be combined with --" << flag << std::endl;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
}
}
}
bool start_gui = m_actions.empty() && !downward_check;
if (start_gui) {
BOOST_LOG_TRIVIAL(info) << "no action, start gui directly" << std::endl;
@@ -5348,7 +5367,7 @@ int CLI::run(int argc, char **argv)
//skip this object due to be locked in plate
ap.itemid = locked_aps.size();
locked_aps.emplace_back(ap);
boost::nowide::cout <<__FUNCTION__ << boost::format(": skip locked instance, obj_id %1%, instance_id %2%") % oidx % inst_idx;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": skip locked instance, obj_id %1%, instance_id %2%") % oidx % inst_idx;
}
}
}
@@ -5937,7 +5956,11 @@ int CLI::run(int argc, char **argv)
//FIXME check for mixing the FFF / SLA parameters.
// or better save fff_print_config vs. sla_print_config
//m_print_config.save(m_config.opt_string("save"));
m_print_config.save_to_json(m_config.opt_string(opt_key), std::string("project_settings"), std::string("project"), std::string(SoftFever_VERSION));
const std::string &settings_file = m_config.opt_string(opt_key);
if (settings_file == "-")
m_print_config.save_to_json(boost::nowide::cout, "project_settings", "project", SoftFever_VERSION, /*replace_invalid_utf8=*/true);
else
m_print_config.save_to_json(settings_file, std::string("project_settings"), std::string("project"), std::string(SoftFever_VERSION));
} else if (opt_key == "info") {
// --info works on unrepaired model
for (Model &model : m_models) {
+15 -6
View File
@@ -7,6 +7,7 @@
#include <algorithm>
#include <assert.h>
#include <fstream>
#include <sstream>
#include <iostream>
#include <iomanip>
#include <regex>
@@ -1515,6 +1516,19 @@ std::optional<PluginCapabilityRef> parse_capability_ref(const std::string& value
//BBS: add json support
void ConfigBase::save_to_json(const std::string &file, const std::string &name, const std::string &from, const std::string &version) const
{
// Serialize first: if that throws (invalid UTF-8), the existing file stays untouched.
std::ostringstream ss;
this->save_to_json(ss, name, from, version);
boost::nowide::ofstream c;
c.open(file, std::ios::out | std::ios::trunc);
c << ss.str();
c.close();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", saved config to %1%\n")%file;
}
void ConfigBase::save_to_json(std::ostream &os, const std::string &name, const std::string &from, const std::string &version, bool replace_invalid_utf8) const
{
json j;
//record the headers
@@ -1561,12 +1575,7 @@ void ConfigBase::save_to_json(const std::string &file, const std::string &name,
j["plugins"] = unique_refs;
}
boost::nowide::ofstream c;
c.open(file, std::ios::out | std::ios::trunc);
c << j.dump(1, '\t') << std::endl;
c.close();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ":" <<__LINE__ << boost::format(", saved config to %1%\n")%file;
os << j.dump(1, '\t', false, replace_invalid_utf8 ? json::error_handler_t::replace : json::error_handler_t::strict) << std::endl;
}
void ConfigBase::save(const std::string &file) const
+3
View File
@@ -2825,6 +2825,9 @@ public:
//BBS: add json support
void save_to_json(const std::string &file, const std::string &name, const std::string &from, const std::string &version) const;
// Same document, written to a stream. Invalid UTF-8 in a string value throws nlohmann's type_error unless
// replace_invalid_utf8 is set, which writes U+FFFD instead (for callers such as stdout with no handler).
void save_to_json(std::ostream &os, const std::string &name, const std::string &from, const std::string &version, bool replace_invalid_utf8 = false) const;
// Rebuild the in-memory "plugins" manifest (the "name;uuid;capability" references the plugin
// dispatchers consume) from the plugin-backed options via the registered resolver. save_to_json()
+1 -1
View File
@@ -11916,7 +11916,7 @@ CLIActionsConfigDef::CLIActionsConfigDef()
def = this->add("export_settings", coString);
def->label = L("Export Settings");
def->tooltip = L("This exports settings to a file.");
def->tooltip = L("This exports settings to a file. Use - to write them to stdout.");
def->cli_params = "settings.json";
def->set_default_value(new ConfigOptionString("output.json"));
+55
View File
@@ -15,6 +15,8 @@
#include <boost/nowide/fstream.hpp>
#include <nlohmann/json.hpp>
#include <sstream>
using namespace Slic3r;
SCENARIO("Generic config validation performs as expected.", "[Config]") {
@@ -488,6 +490,59 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
}
TEST_CASE("save_to_json writes the same document to a stream as to a file", "[Config]") {
DynamicPrintConfig config;
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
config.set_key_value("wall_loops", new ConfigOptionInt(3));
config.set_key_value("filament_type", new ConfigOptionStrings({ "PLA", "PETG" }));
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28\nG1 Z5"));
ScopedTemporaryFile tmp(".json");
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
std::string file_contents;
{
boost::nowide::ifstream ifs(tmp.string());
file_contents.assign(std::istreambuf_iterator<char>(ifs), std::istreambuf_iterator<char>());
}
// The file format: one tab per nesting level and a trailing newline.
REQUIRE_FALSE(file_contents.empty());
CHECK(file_contents.rfind("{\n\t\"", 0) == 0);
CHECK(file_contents.back() == '\n');
std::ostringstream strict, replaced;
config.save_to_json(strict, "test_preset", "User", "1.0.0.0");
config.save_to_json(replaced, "test_preset", "User", "1.0.0.0", true);
CHECK(strict.str() == file_contents);
CHECK(replaced.str() == file_contents);
CHECK(nlohmann::json::parse(strict.str())["machine_start_gcode"] == "G28\nG1 Z5");
}
TEST_CASE("save_to_json replaces invalid UTF-8 in a stream only when asked", "[Config]") {
DynamicPrintConfig config;
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28 ; \xff"));
std::ostringstream strict, replaced;
CHECK_THROWS_AS(config.save_to_json(strict, "test_preset", "User", "1.0.0.0"), nlohmann::json::type_error);
REQUIRE_NOTHROW(config.save_to_json(replaced, "test_preset", "User", "1.0.0.0", true));
CHECK(nlohmann::json::parse(replaced.str())["machine_start_gcode"] == "G28 ; \xEF\xBF\xBD");
}
TEST_CASE("save_to_json leaves an existing file untouched when the config cannot be serialized", "[Config]") {
DynamicPrintConfig config;
config.set_key_value("machine_start_gcode", new ConfigOptionString("G28 ; \xff"));
ScopedTemporaryFile tmp(".json");
{
boost::nowide::ofstream ofs(tmp.string());
ofs << "previous";
}
CHECK_THROWS_AS(config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0"), nlohmann::json::type_error);
boost::nowide::ifstream ifs(tmp.string());
const std::string contents((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>());
CHECK(contents == "previous");
}
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
const std::vector<std::string> refs = {
"master_plugin;;header-stamp",