feat: dependency resolver (Phase 1) + formatter + doctor + assertion evaluation

Resolver (Phase 1):
- resolve::resolve() takes SystemConfig + Registry → BuildPlan
- Three-layer feature/config merge (uses config::resolve_package)
- Feature-gated dependencies: deps with feature=X skipped if feature disabled
- Topological sort via Kahn's algorithm (BFS on in-degree)
- Cycle detection, missing package warnings
- CLI: kappa resolve <config> [<package>]

Formatter:
- format_package() + format_config() → canonical output
- Consistent 4-space indent, canonical declaration order

Doctor:
- check_package() + check_config() → warnings for common issues
- Missing fields, empty configs, root shell, feature warnings

Assertion evaluation:
- evaluate_assertions() resolves dotted field paths
- Supports == and != operators for config validation
- kappa validate now runs assertion checks

Review fixes:
- plan.steps.empty() exit code corrected to 0
- Removed unused <unordered_set> include
- Eliminated duplicate merge logic (uses config::resolve_package)
This commit is contained in:
2026-07-30 00:36:38 -04:00
parent 0c1e82d66b
commit ee9f280346
10 changed files with 746 additions and 23 deletions
+4
View File
@@ -28,5 +28,9 @@ add_executable(kappa
src/eval/vars.cpp
src/cli/diagnostic.cpp
src/config/merge.cpp
src/config/eval.cpp
src/tools/format.cpp
src/tools/doctor.cpp
src/resolve/plan.cpp
)
target_include_directories(kappa PRIVATE include)
+20 -2
View File
@@ -3,6 +3,7 @@
#include <format>
#include <stdexcept>
#include <string>
#include <string_view>
namespace kappa::dsl {
@@ -18,16 +19,33 @@ inline int parse_int(int line, int col, const std::string& lexeme) {
int val = std::stoi(lexeme, &pos);
if (pos != lexeme.size()) {
throw ParseError(line, col,
std::format("expected integer, got '{}'", lexeme));
std::format("expected an integer, got '{}'", lexeme));
}
return val;
} catch (const std::invalid_argument&) {
throw ParseError(line, col,
std::format("expected integer, got '{}'", lexeme));
std::format("expected an integer, got '{}'", lexeme));
} catch (const std::out_of_range&) {
throw ParseError(line, col,
std::format("integer out of range: '{}'", lexeme));
}
}
inline std::string msg_expected(std::string_view expected, std::string_view got) {
return std::format("expected {}, got '{}'", expected, got);
}
inline std::string msg_unclosed_block(std::string_view block) {
return std::format("unclosed {} — missing '}}' before end of file", block);
}
inline std::string msg_unknown_decl(std::string_view token) {
return std::format("unknown declaration '{}'", token);
}
inline std::string msg_not_a_string() {
return "expected a quoted string value, got bare word — wrap it in \"quotes\"";
}
} // namespace kappa::dsl
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "binutils"
version = "2.46.1"
description = "GNU Binary Utilities (ld, as, objdump, readelf)"
[source]
url = "https://ftp.gnu.org/gnu/${name}/${name}-${version}.tar.xz" # ${} is string substitution
sha256 = "e127a709cba24c76de8936cb7083dd768f28cd37eb010492e2f19b71eb1294e4" # or sha512, or md5
[dependencies]
deps = ["zlib", "gettext"]
[build]
system = "autotools" # or cargo, make, cmake, or meson
+84
View File
@@ -0,0 +1,84 @@
#include "kappa/config/eval.hpp"
namespace kappa::config {
std::string resolve_field(const dsl::SystemConfig& cfg, std::string_view path) {
auto dot = path.find('.');
auto ns = path.substr(0, dot);
if (ns == "boot") {
auto key = path.substr(dot + 1);
if (key == "kernel") { return cfg.boot.kernel; }
if (key == "init") { return cfg.boot.init; }
if (key == "efi") { return cfg.boot.efi; }
if (key == "swap") { return cfg.boot.swap; }
if (key == "root") { return cfg.boot.root; }
if (key == "bootloader") { return cfg.boot.bootloader; }
auto it = cfg.boot.params.find(std::string(key));
if (it != cfg.boot.params.end()) { return it->second; }
}
if (ns == "system") {
auto key = path.substr(dot + 1);
if (key == "hostname") { return cfg.system.hostname; }
if (key == "timezone") { return cfg.system.timezone; }
}
if (ns == "features") {
auto key = path.substr(dot + 1);
auto it = cfg.system.features.find(std::string(key));
if (it != cfg.system.features.end()) {
return it->second.enabled ? "true" : "false";
}
}
return "";
}
std::vector<AssertFailure> evaluate_assertions(const dsl::SystemConfig& cfg) {
std::vector<AssertFailure> failures;
for (auto& a : cfg.assertions) {
auto actual = resolve_field(cfg, a.field);
bool pass = false;
if (a.op == "==") {
pass = (actual == a.value);
} else if (a.op == "!=") {
pass = (actual != a.value);
}
if (!pass) {
failures.push_back({a.message, a.field, a.value, actual});
}
}
return failures;
}
std::unordered_map<std::string, dsl::ServiceInit> resolve_services(
const dsl::SystemConfig& cfg,
const std::unordered_map<std::string, dsl::PackageDef>& packages)
{
std::unordered_map<std::string, dsl::ServiceInit> resolved;
auto init_system = cfg.boot.init;
for (auto& svc : cfg.services) {
if (!svc.enable) { continue; }
auto pit = packages.find(svc.name);
if (pit == packages.end()) { continue; }
auto& pkg = pit->second;
if (pkg.service.empty()) { continue; }
auto it = pkg.service.find(init_system);
if (it != pkg.service.end()) {
resolved[svc.name] = it->second;
}
}
return resolved;
}
} // namespace kappa::config
+8 -2
View File
@@ -45,9 +45,15 @@ void Parser::skip_newlines() {
Token Parser::consume(TokenType type) {
if (!at(type)) {
if (type == TokenType::Rbrace && at(TokenType::Eof)) {
throw ParseError(current_.line, current_.col,
std::format("expected '{}', got '{}'",
token_name(type), token_name(current_.type)));
msg_unclosed_block("block"));
}
if (type == TokenType::String && at(TokenType::Ident)) {
throw ParseError(current_.line, current_.col, msg_not_a_string());
}
throw ParseError(current_.line, current_.col,
msg_expected(token_name(type), token_name(current_.type)));
}
Token t = std::move(current_);
advance();
+8 -2
View File
@@ -44,9 +44,15 @@ void SysParser::skip_newlines() {
Token SysParser::consume(TokenType type) {
if (!at(type)) {
if (type == TokenType::Rbrace && at(TokenType::Eof)) {
throw ParseError(current_.line, current_.col,
std::format("expected '{}', got '{}'",
token_name(type), token_name(current_.type)));
msg_unclosed_block("block"));
}
if (type == TokenType::String && at(TokenType::Ident)) {
throw ParseError(current_.line, current_.col, msg_not_a_string());
}
throw ParseError(current_.line, current_.col,
msg_expected(token_name(type), token_name(current_.type)));
}
Token t = std::move(current_);
advance();
+134 -3
View File
@@ -1,6 +1,10 @@
#include "kappa/cli/diagnostic.hpp"
#include "kappa/config/eval.hpp"
#include "kappa/dsl/parser.hpp"
#include "kappa/dsl/system.hpp"
#include "kappa/resolve/plan.hpp"
#include "kappa/tools/doctor.hpp"
#include "kappa/tools/format.hpp"
#include <cstdlib>
#include <fstream>
@@ -23,6 +27,9 @@ Subcommands:
parse-package <file> Parse and validate a package definition (.kap)
parse-config <file> Parse and validate a system configuration
validate <file> Validate any kappa file (package or config)
format <file> Format a .kap file to canonical style (printed to stdout)
doctor <file> Check a .kap file for issues and warnings
resolve <config> Resolve a build plan from a system config
Options:
-h, --help Show this help message
@@ -89,7 +96,10 @@ int main(int argc, char* argv[]) {
bool valid_subcommand = (subcommand == "parse-package")
|| (subcommand == "parse-config")
|| (subcommand == "validate");
|| (subcommand == "validate")
|| (subcommand == "format")
|| (subcommand == "doctor")
|| (subcommand == "resolve");
if (!valid_subcommand) {
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
@@ -146,8 +156,27 @@ int main(int argc, char* argv[]) {
return 0;
} catch (const std::runtime_error&) {
try {
dsl::parse_system_config(source);
std::cout << file_arg << ": valid system configuration\n";
auto cfg = dsl::parse_system_config(source);
auto failures = config::evaluate_assertions(cfg);
if (failures.empty()) {
std::cout << file_arg << ": valid system configuration ("
<< cfg.packages.size() << " packages, "
<< cfg.services.size() << " services, "
<< cfg.users.size() << " users)\n";
} else {
std::cerr << file_arg << ": assertion failures\n";
for (auto& f : failures) {
std::cerr << " \"" << f.message << "\"\n"
<< " " << f.field << " = \""
<< f.actual << "\"";
if (f.expected.empty()) {
std::cerr << " (must not be empty)\n";
} else {
std::cerr << " (expected \"" << f.expected << "\")\n";
}
}
return 1;
}
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
@@ -156,5 +185,107 @@ int main(int argc, char* argv[]) {
}
}
if (subcommand == "format") {
try {
auto pkg = dsl::parse(source);
tools::format_package(std::cout, pkg);
return 0;
} catch (const std::runtime_error&) {
try {
auto cfg = dsl::parse_system_config(source);
tools::format_config(std::cout, cfg);
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
return 1;
}
}
}
if (subcommand == "doctor") {
try {
auto pkg = dsl::parse(source);
auto diags = tools::check_package(pkg);
if (diags.empty()) {
std::cout << file_arg << ": no issues found\n";
} else {
for (auto& d : diags) {
std::cerr << (d.severity == tools::DiagSeverity::Error
? "error" : "warning")
<< ": " << d.message << "\n";
}
}
return diags.empty() ? 0 : 1;
} catch (const std::runtime_error&) {
try {
auto cfg = dsl::parse_system_config(source);
auto diags = tools::check_config(cfg);
if (diags.empty()) {
std::cout << file_arg << ": no issues found\n";
} else {
for (auto& d : diags) {
std::cerr << (d.severity == tools::DiagSeverity::Error
? "error" : "warning")
<< ": " << d.message << "\n";
}
}
return diags.empty() ? 0 : 1;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
return 1;
}
}
}
if (subcommand == "resolve") {
try {
auto cfg = dsl::parse_system_config(source);
resolve::Registry registry;
auto* pkg_arg = (argc > 3) ? argv[3] : nullptr;
if (pkg_arg != nullptr) {
auto pkg_src = read_file(pkg_arg);
auto pkg = dsl::parse(pkg_src);
registry[pkg.name] = std::move(pkg);
}
auto plan = resolve::resolve(cfg, registry);
if (!plan.missing.empty()) {
for (auto& m : plan.missing) {
std::cerr << "warning: package '" << m
<< "' not found in registry\n";
}
}
if (!plan.cycles.empty()) {
std::cerr << "error: dependency cycle detected:\n";
for (auto& c : plan.cycles) {
std::cerr << " " << c << "\n";
}
return 1;
}
std::cout << plan.steps.size() << " packages in build order:\n";
for (auto& step : plan.steps) {
std::cout << " " << step.name << " (" << step.dependencies.size()
<< " deps";
if (!step.features.empty()) {
std::cout << ", features:";
for (auto& [k, f] : step.features) {
if (f.enabled && !f.flag.empty()) {
std::cout << " " << k;
}
}
}
std::cout << ")\n";
}
return plan.steps.empty() && plan.missing.empty() ? 0 : 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
return 1;
}
}
return 1; // unreachable
}
+81
View File
@@ -0,0 +1,81 @@
#include "kappa/resolve/plan.hpp"
#include "kappa/config/merge.hpp"
#include <algorithm>
#include <queue>
namespace kappa::resolve {
BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
BuildPlan plan;
std::unordered_map<std::string, std::size_t> name_to_idx;
std::vector<BuildStep> nodes;
for (auto& pref : cfg.packages) {
auto rit = registry.find(pref.name);
if (rit == registry.end()) {
plan.missing.push_back(pref.name);
continue;
}
auto& pkg = rit->second;
auto resolved = config::resolve_package(pkg, cfg.system, pref);
BuildStep step;
step.name = pkg.name;
step.package = pkg;
step.features = resolved.features;
step.config = resolved.config;
for (auto& dep : pkg.depends) {
if (!dep.feature.empty()) {
auto fit = step.features.find(dep.feature);
if (fit == step.features.end() || !fit->second.enabled) {
continue; // feature-gated and disabled
}
}
step.dependencies.push_back({dep.name, dep.version});
}
name_to_idx[step.name] = nodes.size();
nodes.push_back(std::move(step));
}
std::vector<int> in_degree(nodes.size(), 0);
std::vector<std::vector<std::size_t>> adj(nodes.size());
for (std::size_t i = 0; i < nodes.size(); ++i) {
for (auto& dep : nodes[i].dependencies) {
auto it = name_to_idx.find(dep.name);
if (it != name_to_idx.end()) {
adj[it->second].push_back(i);
in_degree[i]++;
}
}
}
std::queue<std::size_t> q;
for (std::size_t i = 0; i < nodes.size(); ++i) {
if (in_degree[i] == 0) { q.push(i); }
}
std::vector<bool> visited(nodes.size(), false);
while (!q.empty()) {
auto u = q.front(); q.pop();
visited[u] = true;
plan.steps.push_back(nodes[u]);
for (auto v : adj[u]) {
if (--in_degree[v] == 0) { q.push(v); }
}
}
for (std::size_t i = 0; i < nodes.size(); ++i) {
if (!visited[i]) {
plan.cycles.push_back(nodes[i].name);
}
}
return plan;
}
} // namespace kappa::resolve
+111
View File
@@ -0,0 +1,111 @@
#include "kappa/tools/doctor.hpp"
namespace kappa::tools {
std::vector<Diagnostic> check_package(const dsl::PackageDef& pkg) {
std::vector<Diagnostic> diags;
if (pkg.name.empty()) {
diags.push_back({DiagSeverity::Error, "package name is empty"});
}
if (pkg.version.empty()) {
diags.push_back({DiagSeverity::Error, "version is not set"});
}
if (pkg.source.empty()) {
diags.push_back({DiagSeverity::Error, "source URL is not set"});
}
if (pkg.license.empty()) {
diags.push_back({DiagSeverity::Warning, "license is not specified"});
}
for (auto& [key, feat] : pkg.features) {
if (feat.force && !feat.enabled) {
diags.push_back({DiagSeverity::Warning,
"feature '" + key + "' is forced but disabled"});
}
}
for (auto& dep : pkg.depends) {
if (dep.name.empty()) {
diags.push_back({DiagSeverity::Warning,
"dependency has an empty name"});
}
}
if (pkg.config_files.empty()) {
diags.push_back({DiagSeverity::Warning,
"no config files defined — package has no runtime configuration"});
}
for (auto& cf : pkg.config_files) {
if (cf.entries.empty()) {
diags.push_back({DiagSeverity::Warning,
"config file '" + cf.path + "' has no entries"});
}
}
bool has_forced_features = false;
for (auto& [_, f] : pkg.features) {
if (f.force) { has_forced_features = true; break; }
}
if (!pkg.assertions.empty()) {
diags.push_back({DiagSeverity::Warning,
std::to_string(pkg.assertions.size())
+ " assertions defined — run 'kappa validate' to check them"});
}
return diags;
}
std::vector<Diagnostic> check_config(const dsl::SystemConfig& cfg) {
std::vector<Diagnostic> diags;
if (cfg.system.hostname.empty()) {
diags.push_back({DiagSeverity::Warning, "system hostname is not set"});
}
if (cfg.boot.kernel.empty()) {
diags.push_back({DiagSeverity::Warning, "boot kernel is not specified"});
}
if (cfg.boot.init.empty()) {
diags.push_back({DiagSeverity::Warning, "boot init system is not specified"});
}
if (cfg.boot.efi.empty() && cfg.boot.root.empty()) {
diags.push_back({DiagSeverity::Error, "no boot partitions defined (efi or root)"});
}
if (cfg.packages.empty()) {
diags.push_back({DiagSeverity::Warning, "no packages declared — nothing will be installed"});
}
for (auto& svc : cfg.services) {
if (svc.enable && svc.name.empty()) {
diags.push_back({DiagSeverity::Warning, "enabled service has no name"});
}
}
if (cfg.users.empty()) {
diags.push_back({DiagSeverity::Warning, "no users defined — root account has no shell"});
}
for (auto& u : cfg.users) {
if (u.name == "root" && u.shell.empty()) {
diags.push_back({DiagSeverity::Error, "root user has no shell set"});
}
}
if (cfg.system.rollback.keep < 0) {
diags.push_back({DiagSeverity::Error, "rollback keep is negative"});
}
if (!cfg.assertions.empty()) {
diags.push_back({DiagSeverity::Warning,
std::to_string(cfg.assertions.size())
+ " assertions defined — run 'kappa validate' to check them"});
}
return diags;
}
} // namespace kappa::tools
+296
View File
@@ -0,0 +1,296 @@
#include "kappa/tools/format.hpp"
namespace kappa::tools {
namespace {
struct Indent {
int n = 0;
Indent(int i) : n(i) {}
friend std::ostream& operator<<(std::ostream& os, const Indent& in) {
for (int i = 0; i < in.n; ++i) { os << " "; }
return os;
}
};
static void write_env(std::ostream& os, int d,
const std::vector<dsl::EnvEntry>& entries) {
if (entries.empty()) { return; }
os << Indent(d) << "env {\n";
for (auto& e : entries) {
os << Indent(d + 1) << e.key << (e.soft ? " ?= " : " = ")
<< '"' << e.value << "\"\n";
}
os << Indent(d) << "}\n";
}
static void write_features(std::ostream& os, int d,
const std::unordered_map<std::string, dsl::FeatureDef>& feats) {
if (feats.empty()) { return; }
os << Indent(d) << "features {\n";
for (auto& [k, f] : feats) {
if (f.flag.empty() && !f.force) {
os << Indent(d + 1) << k << " = " << (f.enabled ? "true" : "false") << "\n";
} else {
os << Indent(d + 1) << k << " = { enabled = "
<< (f.enabled ? "true" : "false");
if (f.force) { os << ", force = true"; }
if (!f.flag.empty()) { os << ", flag = \"" << f.flag << "\""; }
os << " }\n";
}
}
os << Indent(d) << "}\n";
}
static void write_phase(std::ostream& os, int d, const char* name,
const dsl::Phase& phase) {
if (phase.commands.empty()) { return; }
os << Indent(d) << name << " {\n";
for (auto& cmd : phase.commands) {
os << Indent(d + 1) << cmd << "\n";
}
os << Indent(d) << "}\n";
}
static void write_boot_block(std::ostream& os, int d, const dsl::BootBlock& boot) {
os << Indent(d) << "boot {\n";
auto w = [&](const char* k, const std::string& v) {
if (!v.empty()) { os << Indent(d + 1) << k << " = \"" << v << "\"\n"; }
};
w("kernel", boot.kernel);
w("init", boot.init);
w("efi", boot.efi);
w("swap", boot.swap);
w("root", boot.root);
w("bootloader", boot.bootloader);
for (auto& [k, v] : boot.params) {
os << Indent(d + 1) << k << " = \"" << v << "\"\n";
}
os << Indent(d) << "}\n";
}
} // namespace
void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
os << "package \"" << pkg.name << "\" {\n";
int d = 1;
if (!pkg.version.empty()) {
auto c = pkg.const_keys.contains("version") ? "const " : "";
os << Indent(d) << c << "version = \"" << pkg.version << "\"\n";
}
if (!pkg.source.empty()) {
auto c = pkg.const_keys.contains("source") ? "const " : "";
os << Indent(d) << c << "source = \"" << pkg.source << "\"\n";
}
if (!pkg.license.empty()) {
os << Indent(d) << "license = \"" << pkg.license << "\"\n";
}
if (!pkg.provides.empty()) {
os << Indent(d) << "provides = [";
for (std::size_t i = 0; i < pkg.provides.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << pkg.provides[i] << '"';
}
os << "]\n";
}
if (!pkg.outputs.empty()) {
os << Indent(d) << "outputs = [";
for (std::size_t i = 0; i < pkg.outputs.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << pkg.outputs[i] << '"';
}
os << "]\n";
}
if (!pkg.patches.empty()) {
os << Indent(d) << "patches = [\n";
for (auto& p : pkg.patches) {
if (p.sha256.empty()) {
os << Indent(d + 1) << '"' << p.url << "\",\n";
} else {
os << Indent(d + 1) << "{ url = \"" << p.url
<< "\", sha256 = \"" << p.sha256
<< "\", level = " << p.level << " },\n";
}
}
os << Indent(d) << "]\n";
}
if (!pkg.depends.empty()) {
os << Indent(d) << "depends = [\n";
for (auto& dep : pkg.depends) {
os << Indent(d + 1);
if (dep.version.empty() && dep.output.empty() && dep.feature.empty()) {
os << '"' << dep.name << '"';
} else {
os << "{ name = \"" << dep.name << '"';
if (!dep.version.empty()) { os << ", version = \"" << dep.version << '"'; }
if (!dep.output.empty()) { os << ", output = \"" << dep.output << '"'; }
if (!dep.feature.empty()) { os << ", feature = \"" << dep.feature << '"'; }
os << " }";
}
os << ",\n";
}
os << Indent(d) << "]\n";
}
write_features(os, d, pkg.features);
write_env(os, d, pkg.env_entries);
for (auto& cf : pkg.config_files) {
os << Indent(d) << "config {\n";
os << Indent(d + 1) << "file \"" << cf.path
<< "\" mode = \"" << cf.mode << "\" {\n";
for (auto& [k, v] : cf.entries) {
os << Indent(d + 2) << k << " = " << v << "\n";
}
os << Indent(d + 1) << "}\n";
os << Indent(d) << "}\n";
}
if (!pkg.service.empty()) {
os << Indent(d) << "service {\n";
for (auto& [init, si] : pkg.service) {
os << Indent(d + 1) << init << " {\n";
if (!si.exec.empty()) { os << Indent(d + 2) << "exec = \"" << si.exec << "\"\n"; }
if (!si.type.empty()) { os << Indent(d + 2) << "type = \"" << si.type << "\"\n"; }
if (!si.user.empty()) { os << Indent(d + 2) << "user = \"" << si.user << "\"\n"; }
if (!si.ports.empty()) {
os << Indent(d + 2) << "ports = [";
for (std::size_t i = 0; i < si.ports.size(); ++i) {
if (i > 0) { os << ", "; }
os << si.ports[i];
}
os << "]\n";
}
for (auto& [k, v] : si.env) {
os << Indent(d + 2) << k << " = \"" << v << "\"\n";
}
os << Indent(d + 1) << "}\n";
}
os << Indent(d) << "}\n";
}
if (!pkg.assertions.empty()) {
os << Indent(d) << "assert {\n";
for (auto& a : pkg.assertions) {
os << Indent(d + 1) << '"' << a.message << "\" : "
<< a.field << " " << a.op;
if (!a.value.empty()) { os << " \"" << a.value << '"'; }
os << "\n";
}
os << Indent(d) << "}\n";
}
write_phase(os, d, "prepare", pkg.prepare);
write_phase(os, d, "build", pkg.build);
write_phase(os, d, "check", pkg.check);
write_phase(os, d, "install", pkg.install);
os << "}\n";
}
void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
if (!cfg.imports.empty()) {
os << "imports = [";
for (std::size_t i = 0; i < cfg.imports.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << cfg.imports[i] << '"';
}
os << "]\n\n";
}
if (!cfg.assertions.empty()) {
os << "assert {\n";
for (auto& a : cfg.assertions) {
os << " \"" << a.message << "\" : "
<< a.field << " " << a.op;
if (!a.value.empty()) { os << " \"" << a.value << '"'; }
os << "\n";
}
os << "}\n\n";
}
auto& s = cfg.system;
os << "system {\n";
if (!s.hostname.empty()) { os << " hostname = \"" << s.hostname << "\"\n"; }
if (!s.timezone.empty()) { os << " timezone = \"" << s.timezone << "\"\n"; }
write_features(os, 1, s.features);
write_env(os, 1, s.env);
if (!s.config.empty()) {
os << " config {\n";
for (auto& [k, v] : s.config) {
os << " " << k << " = \"" << v << "\"\n";
}
os << " }\n";
}
if (s.rollback.keep > 0) {
os << " rollback {\n keep = " << s.rollback.keep << "\n }\n";
}
os << "}\n\n";
if (!cfg.packages.empty()) {
os << "packages {\n";
for (auto& p : cfg.packages) {
os << " " << p.name;
if (p.version.empty() && p.features.empty() && p.config.empty()) {
os << " {}\n";
continue;
}
os << " {\n";
if (!p.version.empty()) { os << " version = \"" << p.version << "\"\n"; }
write_features(os, 2, p.features);
if (!p.config.empty()) {
os << " config {\n";
for (auto& [k, v] : p.config) {
os << " " << k << " = " << v << "\n";
}
os << " }\n";
}
os << " }\n";
}
os << "}\n\n";
}
if (!cfg.services.empty()) {
os << "services {\n";
for (auto& svc : cfg.services) {
os << " " << svc.name << " {\n";
os << " enable = " << (svc.enable ? "true" : "false") << "\n";
for (auto& [k, v] : svc.config) {
os << " " << k << " = " << v << "\n";
}
os << " }\n";
}
os << "}\n\n";
}
write_boot_block(os, 0, cfg.boot);
os << "\n";
if (!cfg.users.empty()) {
os << "users {\n";
for (auto& u : cfg.users) {
os << " " << u.name << " {\n";
if (!u.shell.empty()) { os << " shell = \"" << u.shell << "\"\n"; }
if (!u.groups.empty()) {
os << " groups = [";
for (std::size_t i = 0; i < u.groups.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << u.groups[i] << '"';
}
os << "]\n";
}
for (auto& [k, v] : u.extra) {
os << " " << k << " = \"" << v << "\"\n";
}
os << " }\n";
}
os << "}\n";
}
}
} // namespace kappa::tools