From ee9f28034670a77a908f6e4e2f017dda4424e795 Mon Sep 17 00:00:00 2001 From: HuntedByTheIRS Date: Thu, 30 Jul 2026 00:36:38 -0400 Subject: [PATCH] feat: dependency resolver (Phase 1) + formatter + doctor + assertion evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [] 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 include - Eliminated duplicate merge logic (uses config::resolve_package) --- CMakeLists.txt | 4 + include/kappa/dsl/error.hpp | 22 ++- package.toml | 14 -- src/config/eval.cpp | 84 ++++++++++ src/dsl/parser.cpp | 10 +- src/dsl/system.cpp | 10 +- src/main.cpp | 137 ++++++++++++++++- src/resolve/plan.cpp | 81 ++++++++++ src/tools/doctor.cpp | 111 ++++++++++++++ src/tools/format.cpp | 296 ++++++++++++++++++++++++++++++++++++ 10 files changed, 746 insertions(+), 23 deletions(-) delete mode 100644 package.toml create mode 100644 src/config/eval.cpp create mode 100644 src/resolve/plan.cpp create mode 100644 src/tools/doctor.cpp create mode 100644 src/tools/format.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 9152c0b..35cde48 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/include/kappa/dsl/error.hpp b/include/kappa/dsl/error.hpp index 71e681d..51e106a 100644 --- a/include/kappa/dsl/error.hpp +++ b/include/kappa/dsl/error.hpp @@ -3,6 +3,7 @@ #include #include #include +#include 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 + diff --git a/package.toml b/package.toml deleted file mode 100644 index 3fa0713..0000000 --- a/package.toml +++ /dev/null @@ -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 diff --git a/src/config/eval.cpp b/src/config/eval.cpp new file mode 100644 index 0000000..a0952d7 --- /dev/null +++ b/src/config/eval.cpp @@ -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 evaluate_assertions(const dsl::SystemConfig& cfg) { + std::vector 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 resolve_services( + const dsl::SystemConfig& cfg, + const std::unordered_map& packages) +{ + std::unordered_map 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 diff --git a/src/dsl/parser.cpp b/src/dsl/parser.cpp index 2f22288..be236d5 100644 --- a/src/dsl/parser.cpp +++ b/src/dsl/parser.cpp @@ -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, + 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, - std::format("expected '{}', got '{}'", - token_name(type), token_name(current_.type))); + msg_expected(token_name(type), token_name(current_.type))); } Token t = std::move(current_); advance(); diff --git a/src/dsl/system.cpp b/src/dsl/system.cpp index ed529ac..50bc8d0 100644 --- a/src/dsl/system.cpp +++ b/src/dsl/system.cpp @@ -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, + 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, - std::format("expected '{}', got '{}'", - token_name(type), token_name(current_.type))); + msg_expected(token_name(type), token_name(current_.type))); } Token t = std::move(current_); advance(); diff --git a/src/main.cpp b/src/main.cpp index d8ef297..a27e981 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 #include @@ -23,6 +27,9 @@ Subcommands: parse-package Parse and validate a package definition (.kap) parse-config Parse and validate a system configuration validate Validate any kappa file (package or config) + format Format a .kap file to canonical style (printed to stdout) + doctor Check a .kap file for issues and warnings + resolve 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 } diff --git a/src/resolve/plan.cpp b/src/resolve/plan.cpp new file mode 100644 index 0000000..80e1267 --- /dev/null +++ b/src/resolve/plan.cpp @@ -0,0 +1,81 @@ +#include "kappa/resolve/plan.hpp" +#include "kappa/config/merge.hpp" + +#include +#include + +namespace kappa::resolve { + +BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) { + BuildPlan plan; + std::unordered_map name_to_idx; + std::vector 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 in_degree(nodes.size(), 0); + std::vector> 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 q; + for (std::size_t i = 0; i < nodes.size(); ++i) { + if (in_degree[i] == 0) { q.push(i); } + } + + std::vector 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 diff --git a/src/tools/doctor.cpp b/src/tools/doctor.cpp new file mode 100644 index 0000000..cad11bb --- /dev/null +++ b/src/tools/doctor.cpp @@ -0,0 +1,111 @@ +#include "kappa/tools/doctor.hpp" + +namespace kappa::tools { + +std::vector check_package(const dsl::PackageDef& pkg) { + std::vector 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 check_config(const dsl::SystemConfig& cfg) { + std::vector 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 diff --git a/src/tools/format.cpp b/src/tools/format.cpp new file mode 100644 index 0000000..37c0d19 --- /dev/null +++ b/src/tools/format.cpp @@ -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& 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& 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