From 3010bb25ebd8a85a6531cd6b877865e24fa33c95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Wed, 3 Jun 2026 19:23:41 +0200 Subject: [PATCH] core/ui: extract export into src/app (frontend-agnostic), thin TUI command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of separating business logic from the TUI. The export command built the CSV/ODS file inside its lambda, mixed with Print/ShowError/dialog calls. Move all of it — CSV + ODS building, sheet-name sanitising, file writing — into src/app/export.{hpp,cpp} (namespace app, no FTXUI/console dependency): export_connections(const System*, path, format) -> ExportResult. The TUI command is now a thin wrapper (resolve args/dialog, call the core, render). The core is unit-tested without any UI (test_export); 342 assertions pass. Co-Authored-By: Claude Opus 4.8 --- src/app/export.cpp | 190 +++++++++++++++++++++++++++++++ src/app/export.hpp | 34 ++++++ src/tui/commands_export.cpp | 219 +++++------------------------------- tests/test_export.cpp | 76 +++++++++++++ 4 files changed, 327 insertions(+), 192 deletions(-) create mode 100644 src/app/export.cpp create mode 100644 src/app/export.hpp create mode 100644 tests/test_export.cpp diff --git a/src/app/export.cpp b/src/app/export.cpp new file mode 100644 index 0000000..c828b6d --- /dev/null +++ b/src/app/export.cpp @@ -0,0 +1,190 @@ +#include "app/export.hpp" + +#include "imports/ods_writer.hpp" +#include "system/connect.hpp" +#include "system/modules.hpp" +#include "system/parts.hpp" +#include "system/pins.hpp" +#include "system/signal_type.hpp" +#include "system/signals.hpp" +#include "system/system.hpp" + +#include +#include +#include + +namespace app { + +namespace { + +std::string to_lower(std::string s) +{ + for (char &c : s) c = (char)std::tolower((unsigned char)c); + return s; +} + +// Minimal CSV quoter — wraps in "…" and doubles internal quotes when the field +// contains a comma, quote, or newline. +std::string csv_quote(const std::string &s) +{ + if (s.find_first_of(",\"\n") == std::string::npos) + return s; + std::string out = "\""; + for (char c : s) { if (c == '"') out += '"'; out += c; } + out += '"'; + return out; +} + +// Flatten one pin into the string slots an export row uses. +void pin_side(Pin *p, std::string &mod, std::string &part, std::string &pin, + std::string &sig, std::string &type, std::string &suspect) +{ + if (!p) { mod = part = pin = sig = type = suspect = ""; return; } + mod = (p->prnt && p->prnt->prnt) ? p->prnt->prnt->name : ""; + part = p->prnt ? p->prnt->name : ""; + pin = p->name; + Signal *s = p->signal(); + if (!s) { + sig = ""; type = "(NC)"; suspect = ""; + } else { + sig = s->name; + type = signal_type_name(s->type); + suspect = (infer_signal_type(s->name) == SignalType::Power + && s->type == SignalType::Other) ? "yes" : "no"; + } +} + +// Excel rejects /\?*:[] in sheet names; ODS forbids < > & in raw table names. +// Sanitise to underscores and clip to Excel's 31-char hard limit. +std::string sanitise_sheet_name(std::string name) +{ + for (char &ch : name) + if (ch == '/' || ch == '\\' || ch == '?' || ch == '*' || ch == ':' + || ch == '[' || ch == ']' || ch == '<' || ch == '>' || ch == '&') + ch = '_'; + if (name.size() > 31) name = name.substr(0, 31); + return name; +} + +ExportResult write_ods(const System *sys, const std::string &path) +{ + ExportResult r; + OdsWriter w; + for (auto &ckv : *sys->connections()) { + Connection *c = ckv.second; + OdsSheet *s = w.add_sheet(sanitise_sheet_name(c->name)); + + std::string lmod = c->m1 ? c->m1->name : ""; + std::string lprt = c->p1 ? c->p1->name : ""; + std::string rmod = c->m2 ? c->m2->name : ""; + std::string rprt = c->p2 ? c->p2->name : ""; + + // Meta header: label/value rows, a blank row, then the column headers. + auto meta = [&](int row, const std::string &k, const std::string &v) { + s->set(row, 0, k); + s->set(row, 1, v); + }; + meta(0, "Connection", c->name); + meta(1, "Transform", c->transform_name); + meta(2, "Left", lmod + " / " + lprt); + meta(3, "Right", rmod + " / " + rprt); + // Row 4 left blank by design. + + const int HDR = 5; + s->set_header_row(HDR); + const char *hdr[] = { + "left_pin", "left_signal", "left_type", "left_suspect", + "right_pin", "right_signal", "right_type", "right_suspect", + "type_mismatch"}; + for (int i = 0; i < 9; ++i) s->set(HDR, i, hdr[i]); + + int row = HDR + 1; + for (auto &wp : c->pin_map) { + std::string lm, lp, ln, ls, lt, lsus; + std::string rm, rp, rn, rs, rt, rsus; + pin_side(wp.first, lm, lp, ln, ls, lt, lsus); + pin_side(wp.second, rm, rp, rn, rs, rt, rsus); + std::string tm = (lt != "(NC)" && rt != "(NC)" && lt != rt) ? "yes" : "no"; + s->set(row, 0, ln); s->set(row, 1, ls); + s->set(row, 2, lt); s->set(row, 3, lsus); + s->set(row, 4, rn); s->set(row, 5, rs); + s->set(row, 6, rt); s->set(row, 7, rsus); + s->set(row, 8, tm); + ++row; ++r.rows; + } + } + + std::string err; + if (!w.save(path, err)) { + r.error = err; + return r; + } + r.ok = true; + r.sheets = (int)sys->connections()->size(); + return r; +} + +ExportResult write_csv(const System *sys, const std::string &path) +{ + ExportResult r; + std::ofstream f(path); + if (!f) { + r.error = "cannot open '" + path + "' for writing"; + return r; + } + + // One rectangular table: each row repeats the per-connection constants so + // the file stays parser-friendly (pandas / awk / spreadsheet). + f << "connection,transform," + "left_module,left_part," + "left_pin,left_signal,left_type,left_suspect," + "right_module,right_part," + "right_pin,right_signal,right_type,right_suspect," + "type_mismatch\n"; + + for (auto &ckv : *sys->connections()) { + Connection *c = ckv.second; + for (auto &wp : c->pin_map) { + std::string lm, lp, ln, ls, lt, lsus; + std::string rm, rp, rn, rs, rt, rsus; + pin_side(wp.first, lm, lp, ln, ls, lt, lsus); + pin_side(wp.second, rm, rp, rn, rs, rt, rsus); + std::string tm = (lt != "(NC)" && rt != "(NC)" && lt != rt) ? "yes" : "no"; + f << csv_quote(c->name) << ',' + << csv_quote(c->transform_name) << ',' + << csv_quote(lm) << ',' << csv_quote(lp) << ',' + << csv_quote(ln) << ',' << csv_quote(ls) << ',' + << csv_quote(lt) << ',' << csv_quote(lsus) << ',' + << csv_quote(rm) << ',' << csv_quote(rp) << ',' + << csv_quote(rn) << ',' << csv_quote(rs) << ',' + << csv_quote(rt) << ',' << csv_quote(rsus) << ',' + << tm << '\n'; + ++r.rows; + } + } + r.ok = f.good(); + if (!r.ok) r.error = "write error on '" + path + "'"; + return r; +} + +} // namespace + +bool export_format_from_path(const std::string &path, ExportFormat &out) +{ + size_t dot = path.rfind('.'); + std::string ext = (dot == std::string::npos) ? "" : to_lower(path.substr(dot)); + if (ext == ".csv") { out = ExportFormat::Csv; return true; } + if (ext == ".ods") { out = ExportFormat::Ods; return true; } + return false; +} + +ExportResult export_connections(const System *sys, const std::string &path, + ExportFormat format) +{ + ExportResult r; + if (!sys) { r.error = "no system"; return r; } + return (format == ExportFormat::Ods) ? write_ods(sys, path) + : write_csv(sys, path); +} + +} // namespace app diff --git a/src/app/export.hpp b/src/app/export.hpp new file mode 100644 index 0000000..c756c2b --- /dev/null +++ b/src/app/export.hpp @@ -0,0 +1,34 @@ +#ifndef _APP_EXPORT_HPP_ +#define _APP_EXPORT_HPP_ + +#include + +class System; + +// Application layer: UI-independent operations that any frontend (TUI, GUI, …) +// can call. No console, no dialogs, no FTXUI — just System in, result out. +namespace app { + +enum class ExportFormat { Csv, Ods }; + +// Outcome of an export. The only side effect is writing the target file; the +// caller renders `error` / the stats however it likes. +struct ExportResult { + bool ok = false; + std::string error; ///< human-readable, set when !ok + int sheets = 0; ///< ODS: number of sheets (one per connection); 0 for CSV + int rows = 0; ///< wires written +}; + +// Map a filename extension (.csv / .ods, case-insensitive) to a format. +// Returns false if the extension is neither. +bool export_format_from_path(const std::string &path, ExportFormat &out); + +// Export the system's connections to `path` in `format`. Builds the file and +// returns stats or an error. Pure core — safe to call from any frontend. +ExportResult export_connections(const System *sys, const std::string &path, + ExportFormat format); + +} // namespace app + +#endif // _APP_EXPORT_HPP_ diff --git a/src/tui/commands_export.cpp b/src/tui/commands_export.cpp index 1c50c33..75cf1b2 100644 --- a/src/tui/commands_export.cpp +++ b/src/tui/commands_export.cpp @@ -1,64 +1,25 @@ #include "tui/tui.hpp" -#include "tui/tui_helpers.hpp" -#include "imports/ods_writer.hpp" +#include "app/export.hpp" #include "system/connect.hpp" -#include "system/modules.hpp" -#include "system/parts.hpp" -#include "system/pins.hpp" -#include "system/signal_type.hpp" -#include "system/signals.hpp" #include "system/system.hpp" -#include #include #include -namespace { - -// Minimal CSV quoter — wraps in `"…"` and doubles internal quotes when -// the field contains a comma, quote, or newline. Local to this file. -std::string csv_quote(const std::string &s) { - bool needs = s.find_first_of(",\"\n") != std::string::npos; - if (!needs) return s; - std::string out = "\""; - for (char c : s) { if (c == '"') out += '"'; out += c; } - out += '"'; - return out; -} - -// Flatten one pin into the 6 string slots the export row uses. -void pin_side(Pin *p, std::string &mod, std::string &part, - std::string &pin, std::string &sig, - std::string &type, std::string &suspect) { - if (!p) { mod = part = pin = sig = type = suspect = ""; return; } - mod = (p->prnt && p->prnt->prnt) ? p->prnt->prnt->name : ""; - part = p->prnt ? p->prnt->name : ""; - pin = p->name; - Signal *s = p->signal(); - if (!s) { - sig = ""; type = "(NC)"; suspect = ""; - } else { - sig = s->name; - type = signal_type_name(s->type); - suspect = (infer_signal_type(s->name) == SignalType::Power - && s->type == SignalType::Other) ? "yes" : "no"; - } -} - -} // namespace - +// Thin UI wrapper around app::export_connections — this file only resolves +// arguments / the file dialog and renders the result. All the actual export +// (CSV / ODS building, file writing) lives in src/app/export.cpp. void Tui::RegisterExportCommands() { commands["export"] = { {{"kind [connections]", Completion::None}, {"filename (.csv)", Completion::Path}}, [this](const std::vector &args) { if (!sys) { Print("no system: run 'new' first."); return; } + if (args.empty()) { - // Bare → reuse the generic file dialog. Filters give a - // one-keystroke CSV/ODS toggle; picking either rewrites - // the filename's extension, and the action below - // dispatches on that extension. + // Bare → the generic file dialog. The CSV/ODS filter rewrites + // the extension; the action below dispatches on it. OpenFileDialog( "Export — connections", "export.connections", @@ -76,155 +37,29 @@ void Tui::RegisterExportCommands() { const std::string &kind = args[0]; const std::string &path = args[1]; - if (kind == "connections") { - // Accepted extensions: `.csv` (flat file) and `.ods` - // (one sheet per connection). Anything else is an error. - std::string ext; - { - size_t dot = path.rfind('.'); - if (dot != std::string::npos) ext = ToLower(path.substr(dot)); - } - bool ods = (ext == ".ods"); - bool csv = (ext == ".csv"); - if (!ods && !csv) { - ShowError("export: unknown extension '" - + (ext.empty() ? std::string("(none)") : ext) - + "'\nAccepted: .csv, .ods"); - return; - } - - if (ods) { - OdsWriter w; - int total = 0; - for (auto &ckv : *sys->connections()) { - Connection *c = ckv.second; - // Sheet names: Excel rejects /\?*:[] characters, - // ODS forbids < > & in raw cell/table names. - // Sanitise to underscores; clip to 31 chars - // (Excel's hard limit). - std::string sname = c->name; - for (char &ch : sname) - if (ch == '/' || ch == '\\' || ch == '?' || ch == '*' - || ch == ':' || ch == '[' || ch == ']' - || ch == '<' || ch == '>' || ch == '&') ch = '_'; - if (sname.size() > 31) sname = sname.substr(0, 31); - OdsSheet *s = w.add_sheet(sname); - - // Pull the constants for this connection once. - // `transform`, the left module/part, and the - // right module/part don't vary across the wires - // of a single connection — putting them in - // every row was repetitive. - std::string lmod, lprt; - std::string rmod, rprt; - if (c->m1) lmod = c->m1->name; - if (c->p1) lprt = c->p1->name; - if (c->m2) rmod = c->m2->name; - if (c->p2) rprt = c->p2->name; - - // Meta header above the table: 5 rows of label / - // value, then a blank, then the column headers - // on row 6 (index 5). - auto meta = [&](int r, const std::string &k, - const std::string &v) { - s->set(r, 0, k); - s->set(r, 1, v); - }; - meta(0, "Connection", c->name); - meta(1, "Transform", c->transform_name); - meta(2, "Left", lmod + " / " + lprt); - meta(3, "Right", rmod + " / " + rprt); - // Row 4 left blank by design. - - const int HDR = 5; - s->set_header_row(HDR); - const char *hdr[] = { - "left_pin", "left_signal", "left_type", "left_suspect", - "right_pin", "right_signal", "right_type", "right_suspect", - "type_mismatch"}; - for (int i = 0; i < 9; ++i) s->set(HDR, i, hdr[i]); - - int row = HDR + 1; - for (auto &wp : c->pin_map) { - std::string lm, lp, ln, ls, lt, lsus; - std::string rm, rp, rn, rs, rt, rsus; - pin_side(wp.first, lm, lp, ln, ls, lt, lsus); - pin_side(wp.second, rm, rp, rn, rs, rt, rsus); - // `type_mismatch = yes` when both sides have a - // real signal AND their types disagree (e.g. - // Power ↔ Gnd, or Power ↔ Other). - std::string tm = (lt != "(NC)" && rt != "(NC)" && lt != rt) - ? "yes" : "no"; - s->set(row, 0, ln); s->set(row, 1, ls); - s->set(row, 2, lt); s->set(row, 3, lsus); - s->set(row, 4, rn); s->set(row, 5, rs); - s->set(row, 6, rt); s->set(row, 7, rsus); - s->set(row, 8, tm); - ++row; ++total; - } - } - std::string err; - if (!w.save(path, err)) { - ShowError("export (.ods) failed:\n" + err); - return; - } - Print("export connections (.ods): " - + std::to_string(sys->connections()->size()) - + " sheet(s), " + std::to_string(total) - + " wire(s) → " + path); - return; - } - - // Classic flat CSV: a single rectangular table — one - // header line, N data rows, every row carries the per- - // connection constants too. The 9 right-most column - // names match the ODS sheet headers exactly; the 5 - // leading ones (connection, transform, left_module, - // left_part, right_module, right_part) correspond to - // the ODS meta block (Connection, Transform, Left, - // Right). Repeating the constants per row keeps the - // file parser-friendly (pandas / awk / spreadsheet). - std::ofstream f(path); - if (!f) { - ShowError("export: cannot open '" + path + "' for writing"); - return; - } - f << "connection,transform," - "left_module,left_part," - "left_pin,left_signal,left_type,left_suspect," - "right_module,right_part," - "right_pin,right_signal,right_type,right_suspect," - "type_mismatch\n"; - - int rows = 0; - for (auto &ckv : *sys->connections()) { - Connection *c = ckv.second; - for (auto &wp : c->pin_map) { - std::string lm, lp, ln, ls, lt, lsus; - std::string rm, rp, rn, rs, rt, rsus; - pin_side(wp.first, lm, lp, ln, ls, lt, lsus); - pin_side(wp.second, rm, rp, rn, rs, rt, rsus); - std::string tm = "no"; - if (lt != "(NC)" && rt != "(NC)" && lt != rt) tm = "yes"; - f << csv_quote(c->name) << ',' - << csv_quote(c->transform_name) << ',' - << csv_quote(lm) << ',' << csv_quote(lp) << ',' - << csv_quote(ln) << ',' << csv_quote(ls) << ',' - << csv_quote(lt) << ',' << csv_quote(lsus) << ',' - << csv_quote(rm) << ',' << csv_quote(rp) << ',' - << csv_quote(rn) << ',' << csv_quote(rs) << ',' - << csv_quote(rt) << ',' << csv_quote(rsus) << ',' - << tm << '\n'; - ++rows; - } - } - Print("export connections (.csv): " + std::to_string(rows) - + " wire(s) → " + path); + if (kind != "connections") { + ShowError("export: unknown kind '" + kind + "'\n" + "Known kinds: connections"); return; } - ShowError("export: unknown kind '" + kind + "'\n" - "Known kinds: connections"); + app::ExportFormat fmt; + if (!app::export_format_from_path(path, fmt)) { + ShowError("export: unknown extension — accepted: .csv, .ods"); + return; + } + + app::ExportResult r = app::export_connections(sys.get(), path, fmt); + if (!r.ok) { + ShowError("export failed:\n" + r.error); + return; + } + + const bool ods = (fmt == app::ExportFormat::Ods); + std::string msg = ods ? "export connections (.ods): " + : "export connections (.csv): "; + if (ods) msg += std::to_string(r.sheets) + " sheet(s), "; + Print(msg + std::to_string(r.rows) + " wire(s) → " + path); }, /*prompt_for_missing=*/ false, "export structured data to CSV / ODS (kinds: connections; " diff --git a/tests/test_export.cpp b/tests/test_export.cpp new file mode 100644 index 0000000..549fccc --- /dev/null +++ b/tests/test_export.cpp @@ -0,0 +1,76 @@ +#include + +#include "app/export.hpp" +#include "system/connect.hpp" +#include "system/modules.hpp" +#include "system/parts.hpp" +#include "system/pins.hpp" +#include "system/signals.hpp" +#include "system/system.hpp" + +#include +#include +#include +#include + +namespace { + +std::string slurp(const std::string &path) +{ + std::ifstream f(path); + std::stringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +} // namespace + +TEST_CASE("export_format_from_path maps extensions") { + app::ExportFormat f; + CHECK(app::export_format_from_path("a.csv", f)); + CHECK(f == app::ExportFormat::Csv); + CHECK(app::export_format_from_path("a.ODS", f)); + CHECK(f == app::ExportFormat::Ods); + CHECK_FALSE(app::export_format_from_path("a.txt", f)); + CHECK_FALSE(app::export_format_from_path("noext", f)); +} + +TEST_CASE("export_connections writes a flat CSV (no UI needed)") { + // Two cards, one wired pin pair via a connection. + System sys; + Module *a = sys.modules()->merge("A"); + Module *b = sys.modules()->merge("B"); + Part *ja = new Part("J1"); a->add(ja); + Part *jb = new Part("P1"); b->add(jb); + Pin *pa = new Pin("1"); ja->add(pa); + Pin *pb = new Pin("1"); jb->add(pb); + Signal *sa = a->signals->merge("NETA"); sa->add(pa); pa->connect(sa); + Signal *sb = b->signals->merge("NETB"); sb->add(pb); pb->connect(sb); + + Connection *c = new Connection("A.J1<->B.P1", a, ja, b, jb); + c->transform_name = "identity"; + c->pin_map.emplace_back(pa, pb); + sys.connections()->add(c); + + const char *path = "test_export_out.csv"; + app::ExportResult r = app::export_connections(&sys, path, app::ExportFormat::Csv); + CHECK(r.ok); + CHECK(r.rows == 1); + + std::string out = slurp(path); + CHECK(out.find("connection,transform,") == 0); // header present + CHECK(out.find("A.J1<->B.P1") != std::string::npos); // connection name + CHECK(out.find("identity") != std::string::npos); // transform + CHECK(out.find("NETA") != std::string::npos); // left signal + CHECK(out.find("NETB") != std::string::npos); // right signal + + std::remove(path); +} + +TEST_CASE("export_connections reports a bad path instead of crashing") { + System sys; + app::ExportResult r = app::export_connections( + &sys, "/nonexistent-dir-xyz/out.csv", app::ExportFormat::Csv); + CHECK_FALSE(r.ok); + CHECK_FALSE(r.error.empty()); +}