fwt_software/tools/fgc_config.cpp

305 lines
12 KiB
C++

// fgc-config - write and edit a tower's config.ini and hardware.ini so that
// what lands on disk is always a file fire_gimbal_control will accept.
//
// fgc-config seed --example <config.example.ini> --out <config.ini>
// [--hardware <hardware.ini>] [--set Section.key=value]...
// [--drop Section.key=note]... [--dry-run]
// fgc-config set <file> --config <config.ini> [--hardware <hardware.ini>]
// [--set Section.key=value]... [--drop Section.key=note]...
// [--provenance <text>] [--dry-run]
// fgc-config get <file> Section.key
// fgc-config check --config <config.ini> [--hardware <hardware.ini>]
//
// seed makes a NEW config.ini from the example: applies the --set values, and
// replaces every key that hardware.ini sets with a one-line comment
// saying so. Refuses if the output exists - a tower's config.ini holds
// its calibration, and is edited with `set`, never regenerated.
// set edits one of the two files in place: comments and order are kept, and
// each changed key keeps its previous value as a ";prev" comment. --drop
// comments a key out (without its value), e.g. one hardware.ini now owns.
// get prints a value as the program reads it (nothing, exit 1, if unset).
// check validates the pair and changes nothing.
//
// Every write is validated FIRST, by loading the candidate through the same
// ConfigLoader the program uses (with the other file layered as it will be),
// and by refusing any line too long for inih to read whole. Only then is it
// renamed into place, so a failed write leaves the old file untouched.
// --dry-run prints the candidate instead of writing it; the installer diffs it.
//
// Exit: 0 ok, 1 usage or refused, 2 the result would not load.
#include "fgc/Config.h"
#include "fgc/IniEdit.h"
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <sstream>
#include <string>
#include <vector>
namespace fs = std::filesystem;
using namespace fgc;
namespace {
constexpr int kUsage = 1;
constexpr int kInvalid = 2;
int usage(const std::string& why) {
if (!why.empty()) std::cerr << "fgc-config: " << why << "\n";
std::cerr << "usage:\n"
" fgc-config seed --example E --out O [--hardware H] [--set S.k=v]... "
"[--drop S.k=note]... [--dry-run]\n"
" fgc-config set FILE --config C [--hardware H] [--set S.k=v]... "
"[--drop S.k=note]... [--provenance P] [--dry-run]\n"
" fgc-config get FILE S.k\n"
" fgc-config check --config C [--hardware H]\n";
return kUsage;
}
bool readFile(const std::string& path, std::string& out) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::ostringstream ss;
ss << f.rdbuf();
out = ss.str();
return true;
}
bool writeFile(const std::string& path, const std::string& text) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
f << text;
f.flush();
return static_cast<bool>(f);
}
bool splitAssign(const std::string& s, std::pair<std::string, std::string>& out) {
const size_t eq = s.find('=');
if (eq == std::string::npos || eq == 0) return false;
out = {s.substr(0, eq), s.substr(eq + 1)};
return true;
}
struct Args {
std::string cmd, file, example, out, config, hardware, provenance, key;
std::vector<std::pair<std::string, std::string>> sets;
std::map<std::string, std::string> drops;
bool dry_run = false;
};
// Load the pair the way the program will. `cfg`/`hw` are the paths to load; the
// candidate being written is passed in their place. Returns false and prints why.
bool loads(const std::string& cfg, const std::string& hw) {
try {
(void)ConfigLoader::loadFromFile(cfg, hw);
return true;
} catch (const std::exception& e) {
std::cerr << "fgc-config: the result would not load: " << e.what() << "\n";
return false;
}
}
bool longLinesOk(const std::string& text, const std::string& what) {
if (int n = firstOverlongIniLine(text)) {
std::cerr << "fgc-config: " << what << " line " << n << " is longer than "
<< iniMaxLineContent()
<< " characters; inih would split it and misread the rest of the file\n";
return false;
}
return true;
}
// A sibling temp file, so the final rename stays on one filesystem and is atomic.
std::string tempBeside(const std::string& path) {
return (fs::path(path).parent_path() / ("." + fs::path(path).filename().string() +
".fgc-config.tmp")).string();
}
// Validate `text` as the new `target`, then write it. `other` is the file layered
// with it (hardware.ini for a config.ini, config.ini for a hardware.ini), and
// `target_is_hw` says which role the target plays.
int commit(const std::string& target, const std::string& text, const std::string& other,
bool target_is_hw, bool dry_run) {
if (!longLinesOk(text, target)) return kInvalid;
const std::string tmp = tempBeside(target);
if (!writeFile(tmp, text)) {
std::cerr << "fgc-config: cannot write " << tmp << "\n";
return kUsage;
}
const bool ok = target_is_hw ? loads(other, tmp) : loads(tmp, other);
if (!ok || dry_run) {
std::error_code ec;
fs::remove(tmp, ec);
if (ok) std::cout << text;
return ok ? 0 : kInvalid;
}
std::error_code ec;
if (fs::exists(target))
fs::permissions(tmp, fs::status(target).permissions(), ec);
else
fs::permissions(tmp, fs::perms::owner_read | fs::perms::owner_write |
fs::perms::group_read | fs::perms::others_read, ec);
fs::rename(tmp, target, ec);
if (ec) {
std::cerr << "fgc-config: cannot replace " << target << ": " << ec.message() << "\n";
fs::remove(tmp, ec);
return kUsage;
}
std::cerr << "fgc-config: wrote " << target << "\n";
return 0;
}
int cmdSeed(const Args& a) {
if (a.example.empty() || a.out.empty()) return usage("seed needs --example and --out");
if (fs::exists(a.out) && !a.dry_run) {
std::cerr << "fgc-config: " << a.out << " exists. A tower's config.ini holds its "
"calibration: edit it with `fgc-config set`, never regenerate it\n";
return kUsage;
}
std::string text;
if (!readFile(a.example, text)) return usage("cannot read " + a.example);
std::map<std::string, std::string> drops = a.drops;
if (!a.hardware.empty()) {
std::string hw;
if (!readFile(a.hardware, hw)) return usage("cannot read " + a.hardware);
try {
const auto hw_kv = parseIniString(hw);
for (const auto& [k, v] : hw_kv) {
(void)v;
drops.emplace(k, "is set in hardware.ini, which wins over this file");
}
// The enable flags exist for towers WITHOUT a hardware.ini. Where the
// file declares the sensor, the declaration decides and a flag beside
// it is warned about at every start, so a new tower does not get one.
if (hw_kv.count("Hardware.imu"))
drops.emplace("Features.enable_imu", "follows [Hardware] imu in hardware.ini");
if (hw_kv.count("Hardware.env"))
drops.emplace("Features.enable_env", "follows [Hardware] env in hardware.ini");
} catch (const std::exception& e) {
std::cerr << "fgc-config: " << a.hardware << " " << e.what() << "\n";
return kInvalid;
}
}
std::vector<std::string> dropped;
text = dropIniKeys(text, drops, &dropped);
try {
text = applyIniSets(text, a.sets, /*provenance=*/"");
} catch (const std::exception& e) {
return usage(e.what());
}
for (const auto& k : dropped)
std::cerr << "fgc-config: commented out " << k << " (" << drops.at(k) << ")\n";
// The candidate is validated beside its final location, so profile_dir and
// other relative paths resolve exactly as they will for the program.
return commit(a.out, text, a.hardware, /*target_is_hw=*/false, a.dry_run);
}
int cmdSet(const Args& a) {
if (a.file.empty() || a.config.empty()) return usage("set needs FILE and --config");
if (a.sets.empty() && a.drops.empty()) return usage("set needs at least one --set or --drop");
std::error_code ec;
const bool is_cfg = fs::equivalent(a.file, a.config, ec);
const bool is_hw = !a.hardware.empty() && fs::equivalent(a.file, a.hardware, ec);
if (!is_cfg && !is_hw) return usage("FILE must be the --config or the --hardware file");
std::string text;
if (!readFile(a.file, text)) return usage("cannot read " + a.file);
std::vector<std::string> dropped;
text = dropIniKeys(text, a.drops, &dropped);
for (const auto& k : dropped)
std::cerr << "fgc-config: commented out " << k << " (" << a.drops.at(k) << ")\n";
try {
text = applyIniSets(text, a.sets, a.provenance);
} catch (const std::exception& e) {
return usage(e.what());
}
return is_hw ? commit(a.hardware, text, a.config, true, a.dry_run)
: commit(a.config, text, a.hardware, false, a.dry_run);
}
int cmdGet(const Args& a) {
if (a.file.empty() || a.key.empty()) return usage("get needs FILE and Section.key");
std::string text;
if (!readFile(a.file, text)) return usage("cannot read " + a.file);
try {
const auto kv = parseIniString(text);
auto it = kv.find(a.key);
if (it == kv.end()) return kUsage;
std::cout << it->second << "\n";
return 0;
} catch (const std::exception& e) {
std::cerr << "fgc-config: " << a.file << " " << e.what() << "\n";
return kInvalid;
}
}
int cmdCheck(const Args& a) {
if (a.config.empty()) return usage("check needs --config");
for (const std::string& f : {a.config, a.hardware}) {
if (f.empty()) continue;
std::string text;
if (!readFile(f, text)) return usage("cannot read " + f);
if (!longLinesOk(text, f)) return kInvalid;
}
if (!loads(a.config, a.hardware)) return kInvalid;
std::cerr << "fgc-config: " << a.config << (a.hardware.empty() ? "" : " + " + a.hardware)
<< " load cleanly\n";
return 0;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) return usage("");
Args a;
a.cmd = argv[1];
std::vector<std::string> positional;
for (int i = 2; i < argc; ++i) {
const std::string s = argv[i];
auto value = [&](std::string& dst) {
if (i + 1 >= argc) return false;
dst = argv[++i];
return true;
};
std::pair<std::string, std::string> kv;
std::string v;
if (s == "--example") { if (!value(a.example)) return usage(s + " needs a value"); }
else if (s == "--out") { if (!value(a.out)) return usage(s + " needs a value"); }
else if (s == "--config") { if (!value(a.config)) return usage(s + " needs a value"); }
else if (s == "--hardware") { if (!value(a.hardware)) return usage(s + " needs a value"); }
else if (s == "--provenance") { if (!value(a.provenance)) return usage(s + " needs a value"); }
else if (s == "--dry-run") { a.dry_run = true; }
else if (s == "--set") {
if (!value(v) || !splitAssign(v, kv)) return usage("--set takes Section.key=value");
a.sets.push_back(kv);
} else if (s == "--drop") {
if (!value(v) || !splitAssign(v, kv)) return usage("--drop takes Section.key=note");
a.drops[kv.first] = kv.second;
} else if (s.rfind("--", 0) == 0) {
return usage("unknown option " + s);
} else {
positional.push_back(s);
}
}
if (a.cmd == "seed" || a.cmd == "check") {
if (!positional.empty()) return usage("unexpected argument " + positional[0]);
} else if (a.cmd == "set") {
if (positional.size() != 1) return usage("set takes exactly one FILE");
a.file = positional[0];
} else if (a.cmd == "get") {
if (positional.size() != 2) return usage("get takes FILE and Section.key");
a.file = positional[0];
a.key = positional[1];
}
if (a.cmd == "seed") return cmdSeed(a);
if (a.cmd == "set") return cmdSet(a);
if (a.cmd == "get") return cmdGet(a);
if (a.cmd == "check") return cmdCheck(a);
return usage("unknown command '" + a.cmd + "'");
}