221 lines
7.4 KiB
C++
221 lines
7.4 KiB
C++
#include "fgc/Paths.h"
|
|
|
|
#include "fgc/Logger.h"
|
|
|
|
#include <algorithm>
|
|
#include <vector>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
#include <system_error>
|
|
#include <unistd.h>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace fgc::paths {
|
|
|
|
namespace {
|
|
|
|
std::string envOr(const char* name, const std::string& fallback) {
|
|
const char* v = std::getenv(name);
|
|
return (v && *v) ? std::string(v) : fallback;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string expandUser(const std::string& path) {
|
|
if (path.empty()) return path;
|
|
|
|
std::string out;
|
|
out.reserve(path.size());
|
|
|
|
// Leading ~ -> $HOME
|
|
size_t i = 0;
|
|
if (path[0] == '~' && (path.size() == 1 || path[1] == '/')) {
|
|
out += envOr("HOME", "");
|
|
i = 1;
|
|
}
|
|
|
|
// $VAR and ${VAR} expansion
|
|
for (; i < path.size(); ++i) {
|
|
if (path[i] == '$') {
|
|
size_t start = i + 1;
|
|
bool braced = (start < path.size() && path[start] == '{');
|
|
if (braced) ++start;
|
|
size_t end = start;
|
|
while (end < path.size() &&
|
|
(std::isalnum(static_cast<unsigned char>(path[end])) || path[end] == '_')) {
|
|
++end;
|
|
}
|
|
std::string name = path.substr(start, end - start);
|
|
if (!name.empty()) {
|
|
out += envOr(name.c_str(), "");
|
|
i = braced && end < path.size() && path[end] == '}' ? end : end - 1;
|
|
continue;
|
|
}
|
|
}
|
|
out += path[i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string executableDir() {
|
|
std::error_code ec;
|
|
fs::path self = fs::read_symlink("/proc/self/exe", ec);
|
|
if (ec) return {};
|
|
return self.parent_path().string();
|
|
}
|
|
|
|
std::vector<std::string> configSearchPaths(const std::string& cliArg) {
|
|
std::vector<std::string> paths;
|
|
if (!cliArg.empty()) paths.push_back(expandUser(cliArg));
|
|
|
|
if (const char* env = std::getenv("FGC_CONFIG"); env && *env)
|
|
paths.push_back(expandUser(env));
|
|
|
|
paths.push_back("config.ini"); // current working directory
|
|
|
|
if (std::string dir = executableDir(); !dir.empty())
|
|
paths.push_back((fs::path(dir) / "config.ini").string());
|
|
|
|
std::string xdg = envOr("XDG_CONFIG_HOME", expandUser("~/.config"));
|
|
if (!xdg.empty())
|
|
paths.push_back((fs::path(xdg) / "fire_gimbal_control" / "config.ini").string());
|
|
|
|
return paths;
|
|
}
|
|
|
|
std::optional<std::string> resolveConfigPath(const std::string& cliArg) {
|
|
std::error_code ec;
|
|
for (const auto& p : configSearchPaths(cliArg)) {
|
|
if (fs::is_regular_file(p, ec)) return p;
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::vector<std::string> hardwareSearchPaths(const std::string& cliArg,
|
|
const std::string& configPath) {
|
|
std::vector<std::string> paths;
|
|
if (!cliArg.empty()) paths.push_back(expandUser(cliArg));
|
|
|
|
if (const char* env = std::getenv("FGC_HARDWARE"); env && *env)
|
|
paths.push_back(expandUser(env));
|
|
|
|
paths.push_back("/etc/fire_gimbal_control/hardware.ini");
|
|
|
|
// Beside config.ini, so a checkout with a local config.ini picks up a local
|
|
// hardware.ini without needing root or an env var.
|
|
if (!configPath.empty()) {
|
|
const fs::path dir = fs::path(expandUser(configPath)).parent_path();
|
|
paths.push_back((dir.empty() ? fs::path(".") : dir) / "hardware.ini");
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
std::optional<std::string> resolveHardwarePath(const std::string& cliArg,
|
|
const std::string& configPath) {
|
|
std::error_code ec;
|
|
for (const auto& p : hardwareSearchPaths(cliArg, configPath)) {
|
|
if (fs::is_regular_file(p, ec)) return p;
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::string defaultOutputDir() {
|
|
std::string base = envOr("XDG_DATA_HOME", expandUser("~/.local/share"));
|
|
return (fs::path(base) / "fire_gimbal_control" / "images").string();
|
|
}
|
|
|
|
std::string defaultLogDir() {
|
|
std::string base = envOr("XDG_DATA_HOME", expandUser("~/.local/share"));
|
|
return (fs::path(base) / "fire_gimbal_control" / "logs").string();
|
|
}
|
|
|
|
std::string timestampedLogName(const std::string& prefix) {
|
|
std::time_t t = std::time(nullptr);
|
|
std::tm tm{};
|
|
localtime_r(&t, &tm);
|
|
char buf[32];
|
|
std::strftime(buf, sizeof(buf), "%Y%m%d-%H%M%S", &tm);
|
|
return prefix + "_" + buf + ".log";
|
|
}
|
|
|
|
// True for "<prefix>_YYYYMMDD-HHMMSS.log" and nothing else. `want` is the
|
|
// prefix including its trailing underscore.
|
|
static bool isGeneratedReportName(const std::string& name, const std::string& want) {
|
|
const std::string rest = name.substr(want.size());
|
|
// "YYYYMMDD-HHMMSS.log" is exactly 19 characters.
|
|
if (rest.size() != 19) return false;
|
|
if (rest.compare(15, 4, ".log") != 0) return false;
|
|
if (rest[8] != '-') return false;
|
|
for (size_t i = 0; i < 15; ++i) {
|
|
if (i == 8) continue;
|
|
if (!std::isdigit(static_cast<unsigned char>(rest[i]))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void pruneLogFiles(const std::string& prefix, int keep) {
|
|
if (prefix.empty() || keep <= 0) return;
|
|
|
|
std::error_code ec;
|
|
const fs::path dir = defaultLogDir();
|
|
if (!fs::exists(dir, ec)) return;
|
|
|
|
const std::string want = prefix + "_";
|
|
std::vector<std::string> files;
|
|
for (const auto& e : fs::directory_iterator(dir, ec)) {
|
|
if (ec) return;
|
|
if (!e.is_regular_file(ec)) continue;
|
|
const std::string n = e.path().filename().string();
|
|
if (n.rfind(want, 0) != 0) continue;
|
|
// Only files this program GENERATED, i.e. exactly
|
|
// "<prefix>_YYYYMMDD-HHMMSS.log" as timestampedLogName() builds them.
|
|
//
|
|
// Matching the prefix alone is not enough. `[Test] baseline_file` is
|
|
// configurable and may legitimately live here; the default
|
|
// `test_baseline.txt` happens to survive a looser match only because
|
|
// 'b' sorts above the digits of a year, which is luck rather than a
|
|
// guarantee - point it at `test_mybaseline.log` and a prefix-only match
|
|
// would delete the very reference every self-test compares against.
|
|
if (!isGeneratedReportName(n, want)) continue;
|
|
files.push_back(e.path().string());
|
|
}
|
|
if (static_cast<int>(files.size()) <= keep) return;
|
|
|
|
// Names embed YYYYMMDD-HHMMSS, so lexicographic order is chronological.
|
|
std::sort(files.begin(), files.end(), std::greater<std::string>());
|
|
for (size_t i = static_cast<size_t>(keep); i < files.size(); ++i)
|
|
fs::remove(files[i], ec); // best effort
|
|
}
|
|
|
|
std::string writeLogFile(const std::string& name, const std::string& text, int keep) {
|
|
std::error_code ec;
|
|
fs::path dir = defaultLogDir();
|
|
fs::create_directories(dir, ec);
|
|
fs::path path = dir / name;
|
|
FILE* f = std::fopen(path.c_str(), "wb");
|
|
if (!f) {
|
|
LOG_WARN << "could not open logfile " << path.string() << " for writing";
|
|
return "";
|
|
}
|
|
bool ok = std::fwrite(text.data(), 1, text.size(), f) == text.size();
|
|
if (std::fclose(f) != 0) ok = false;
|
|
if (!ok) {
|
|
LOG_WARN << "failed to write logfile " << path.string();
|
|
return "";
|
|
}
|
|
|
|
// Bound this kind of report. The prefix is whatever precedes the first '_',
|
|
// matching timestampedLogName()'s "<prefix>_<stamp>.log". Pruning AFTER the
|
|
// write means the file just created is the newest and is always retained.
|
|
const size_t us = name.find('_');
|
|
if (us != std::string::npos && us > 0) pruneLogFiles(name.substr(0, us), keep);
|
|
|
|
return path.string();
|
|
}
|
|
|
|
} // namespace fgc::paths
|