// 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 --out // [--hardware ] [--set Section.key=value]... // [--drop Section.key=note]... [--dry-run] // fgc-config set --config [--hardware ] // [--set Section.key=value]... [--drop Section.key=note]... // [--provenance ] [--dry-run] // fgc-config get Section.key // fgc-config check --config [--hardware ] // // 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 #include #include #include #include #include #include #include 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(f); } bool splitAssign(const std::string& s, std::pair& 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> sets; std::map 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 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 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 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 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 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 + "'"); }