OpenADS — M1 DBF Read Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Open a CDX-typed DBF table read-only via the ACE C ABI, walk it with AdsGotoTop / AdsSkip, and read scalar field values with AdsGetField. End-to-end test: a Harbour-style sequence of L1 entry-point calls returns the same data the fixture writes byte-for-byte.
Architecture: L1 ABI thunks → L2 Connection / HandleRegistry → L4 Table and Cursor over a fresh CdxDriver that internally reuses a dbf_common parser shared with future NTX / VFP drivers. Read-only path only: no writes, no .cdx index reading (M3), no memo (M4). Fixtures are produced programmatically through the L5 File abstraction so the suite stays hermetic.
Tech Stack: Same as M0 (C++17, CMake, doctest). No new third-party dependencies.
File structure for this milestone
Created in M1:
OpenADS/
├── include/openads/
│ └── ace.h # public ACE header subset for M1
├── src/
│ ├── abi/
│ │ ├── ace_types.h # UNSIGNED8/16/32, ADSHANDLE, AE_*
│ │ ├── ace_exports.cpp # extern "C" thunks for the M1 subset
│ │ ├── last_error.h
│ │ ├── last_error.cpp # thread-local AdsGetLastError state
│ │ ├── charset.h
│ │ └── charset.cpp # OEM/ANSI passthrough (real conv in M4)
│ ├── session/
│ │ ├── handle_registry.h
│ │ ├── handle_registry.cpp
│ │ ├── connection.h
│ │ └── connection.cpp
│ ├── engine/
│ │ ├── table.h
│ │ ├── table.cpp
│ │ ├── cursor.h
│ │ └── cursor.cpp
│ └── drivers/
│ ├── driver_trait.h # minimal: open/close/read_record_raw
│ ├── dbf_common.h
│ ├── dbf_common.cpp # header + field-descriptor parsers
│ └── cdx/
│ ├── cdx_driver.h
│ └── cdx_driver.cpp # wraps dbf_common, marks ADS_CDX
└── tests/unit/
├── dbf_header_test.cpp
├── dbf_field_test.cpp
├── dbf_record_test.cpp
├── engine_table_test.cpp
├── engine_cursor_test.cpp
├── session_handle_registry_test.cpp
├── session_connection_test.cpp
└── abi_smoke_test.cpp
Boundaries:
dbf_commonowns DBF header / field / record parsing. Future NTX / VFP drivers reuse it.cdx/cdx_driveris a thin wrapper that delegates everything todbf_commonand only labels itself with the ACE table-type constant.Tableexposes a read-only navigation surface (goto_top/goto_bottom/skip/recno/eof/bof/field_count/field_descriptor/read_field). It does not know about ACE types — that translation lives in L1.Cursoris a thin forward-only view over aTable(single one per table here; SQL-driven cursors come in M7).HandleRegistryis a thread-safe slot table mappingADSHANDLE→ tagged pointer (Connection*/Table*/Cursor*).ace_exports.cppis the only file that includes both<openads/ace.h>and the internal C++ headers; it does the marshalling.
Task 1: Wire new module subdirectories into the build
Files:
- Modify:
c:/OpenADS/src/CMakeLists.txt -
Modify:
c:/OpenADS/tests/CMakeLists.txt - Step 1: Update
src/CMakeLists.txtto compile the new modules
Replace the contents of c:/OpenADS/src/CMakeLists.txt with:
add_library(openads_core STATIC
util/log.cpp
platform/path.cpp
platform/time.cpp
platform/thread.cpp
abi/ace_exports.cpp
abi/last_error.cpp
abi/charset.cpp
session/handle_registry.cpp
session/connection.cpp
engine/table.cpp
engine/cursor.cpp
drivers/dbf_common.cpp
drivers/cdx/cdx_driver.cpp
)
if(WIN32)
target_sources(openads_core PRIVATE
platform/file_win32.cpp
platform/lock_win32.cpp
platform/mmap_win32.cpp
)
else()
target_sources(openads_core PRIVATE
platform/file_posix.cpp
platform/lock_posix.cpp
platform/mmap_posix.cpp
)
endif()
target_include_directories(openads_core
PUBLIC
${CMAKE_SOURCE_DIR}/include
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
if(UNIX)
target_link_libraries(openads_core PUBLIC pthread)
endif()
- Step 2: Update
tests/CMakeLists.txtto register the new test files
Replace the contents of c:/OpenADS/tests/CMakeLists.txt with:
add_executable(openads_unit_tests
unit/doctest_main.cpp
unit/util_result_test.cpp
unit/util_span_test.cpp
unit/util_log_test.cpp
unit/platform_file_test.cpp
unit/platform_lock_test.cpp
unit/platform_mmap_test.cpp
unit/platform_path_test.cpp
unit/platform_time_test.cpp
unit/platform_thread_test.cpp
unit/dbf_header_test.cpp
unit/dbf_field_test.cpp
unit/dbf_record_test.cpp
unit/engine_table_test.cpp
unit/engine_cursor_test.cpp
unit/session_handle_registry_test.cpp
unit/session_connection_test.cpp
unit/abi_smoke_test.cpp
)
target_include_directories(openads_unit_tests SYSTEM PRIVATE
${CMAKE_SOURCE_DIR}/third_party/doctest
)
target_include_directories(openads_unit_tests PRIVATE
${CMAKE_SOURCE_DIR}/src
)
target_link_libraries(openads_unit_tests PRIVATE openads_core)
add_test(NAME openads_unit_tests COMMAND openads_unit_tests)
- Step 3: Stub all new files so the build passes
For each test file, write the placeholder line:
#include "doctest.h"
c:/OpenADS/tests/unit/dbf_header_test.cppc:/OpenADS/tests/unit/dbf_field_test.cppc:/OpenADS/tests/unit/dbf_record_test.cppc:/OpenADS/tests/unit/engine_table_test.cppc:/OpenADS/tests/unit/engine_cursor_test.cppc:/OpenADS/tests/unit/session_handle_registry_test.cppc:/OpenADS/tests/unit/session_connection_test.cppc:/OpenADS/tests/unit/abi_smoke_test.cpp
For each new source file, write the placeholder line:
// placeholder, real content lands in a later task
c:/OpenADS/src/abi/ace_exports.cppc:/OpenADS/src/abi/last_error.cppc:/OpenADS/src/abi/charset.cppc:/OpenADS/src/session/handle_registry.cppc:/OpenADS/src/session/connection.cppc:/OpenADS/src/engine/table.cppc:/OpenADS/src/engine/cursor.cppc:/OpenADS/src/drivers/dbf_common.cpp-
c:/OpenADS/src/drivers/cdx/cdx_driver.cpp - Step 4: Configure and build to verify the wiring
Run:
cmake --preset default
cmake --build build/default --config Release
Expected: success.
- Step 5: Run tests to confirm prior coverage still passes
Run:
ctest --preset default -C Release --output-on-failure
build/default/tests/Release/openads_unit_tests.exe
Expected: 27 cases pass (same as M0 closing state).
- Step 6: Commit
git add src/CMakeLists.txt tests/CMakeLists.txt src/abi src/session src/engine src/drivers tests/unit/dbf_header_test.cpp tests/unit/dbf_field_test.cpp tests/unit/dbf_record_test.cpp tests/unit/engine_table_test.cpp tests/unit/engine_cursor_test.cpp tests/unit/session_handle_registry_test.cpp tests/unit/session_connection_test.cpp tests/unit/abi_smoke_test.cpp
git commit -m "build: wire M1 module skeleton into CMake"
Task 2: DBF header parser
Files:
- Create:
c:/OpenADS/src/drivers/dbf_common.h - Modify:
c:/OpenADS/src/drivers/dbf_common.cpp -
Modify:
c:/OpenADS/tests/unit/dbf_header_test.cpp - Step 1: Write the failing tests
Replace c:/OpenADS/tests/unit/dbf_header_test.cpp:
#include "doctest.h"
#include "drivers/dbf_common.h"
#include <array>
#include <cstdint>
using openads::drivers::DbfHeader;
using openads::drivers::parse_dbf_header;
namespace {
// Minimal valid 32-byte DBF header for version 0x03 (Clipper / dBase III).
// byte 0 : version
// bytes 1-3 : last update YY MM DD
// bytes 4-7 : record count (uint32 little endian)
// bytes 8-9 : header length (uint16 little endian)
// bytes 10-11 : record length (uint16 little endian)
// bytes 12-31 : reserved / flags
std::array<std::uint8_t, 32> sample_header() {
std::array<std::uint8_t, 32> h{};
h[0] = 0x03;
h[1] = 124; // year offset 2024
h[2] = 1;
h[3] = 31;
// record count = 5
h[4] = 0x05; h[5] = 0; h[6] = 0; h[7] = 0;
// header length = 0x41 (65 = 32 + 32 + 1 terminator) — single field
h[8] = 0x41; h[9] = 0;
// record length = 11 (10-char field + 1 deletion byte)
h[10] = 0x0B; h[11] = 0;
return h;
}
} // namespace
TEST_CASE("DBF header parser extracts version, recno count, sizes") {
auto bytes = sample_header();
auto parsed = parse_dbf_header(bytes.data(), bytes.size());
REQUIRE(parsed.has_value());
DbfHeader h = parsed.value();
CHECK(h.version == 0x03);
CHECK(h.record_count == 5);
CHECK(h.header_length == 0x41);
CHECK(h.record_length == 0x0B);
CHECK(h.last_update_year == 2024);
CHECK(h.last_update_month == 1);
CHECK(h.last_update_day == 31);
}
TEST_CASE("DBF header parser rejects buffers shorter than 32 bytes") {
std::array<std::uint8_t, 16> too_small{};
auto parsed = parse_dbf_header(too_small.data(), too_small.size());
CHECK_FALSE(parsed.has_value());
}
TEST_CASE("DBF header parser maps version 0x30 to VFP family") {
auto bytes = sample_header();
bytes[0] = 0x30;
auto parsed = parse_dbf_header(bytes.data(), bytes.size());
REQUIRE(parsed.has_value());
CHECK(parsed.value().family == openads::drivers::DbfFamily::Vfp);
}
TEST_CASE("DBF header parser maps version 0x03 to Clipper family") {
auto bytes = sample_header();
auto parsed = parse_dbf_header(bytes.data(), bytes.size());
REQUIRE(parsed.has_value());
CHECK(parsed.value().family == openads::drivers::DbfFamily::Clipper);
}
- Step 2: Run tests to verify they fail to compile
Run:
cmake --build build/default --config Release
Expected: compile error, drivers/dbf_common.h not found.
- Step 3: Write
drivers/dbf_common.h
#pragma once
#include "util/result.h"
#include <cstddef>
#include <cstdint>
namespace openads::drivers {
enum class DbfFamily {
Clipper, // 0x03 (no memo) / 0x83 (with memo)
Vfp, // 0x30 / 0x31
Unknown
};
struct DbfHeader {
std::uint8_t version = 0;
std::uint16_t last_update_year = 0;
std::uint8_t last_update_month = 0;
std::uint8_t last_update_day = 0;
std::uint32_t record_count = 0;
std::uint16_t header_length = 0;
std::uint16_t record_length = 0;
DbfFamily family = DbfFamily::Unknown;
};
util::Result<DbfHeader> parse_dbf_header(const std::uint8_t* data,
std::size_t size);
} // namespace openads::drivers
- Step 4: Implement
drivers/dbf_common.cpp
Replace the placeholder with:
#include "drivers/dbf_common.h"
namespace openads::drivers {
namespace {
DbfFamily classify(std::uint8_t version) {
switch (version) {
case 0x03: case 0x83:
return DbfFamily::Clipper;
case 0x30: case 0x31: case 0x32:
return DbfFamily::Vfp;
default:
return DbfFamily::Unknown;
}
}
std::uint16_t read_u16_le(const std::uint8_t* p) {
return static_cast<std::uint16_t>(p[0]) |
static_cast<std::uint16_t>(p[1] << 8);
}
std::uint32_t read_u32_le(const std::uint8_t* p) {
return static_cast<std::uint32_t>(p[0]) |
(static_cast<std::uint32_t>(p[1]) << 8) |
(static_cast<std::uint32_t>(p[2]) << 16) |
(static_cast<std::uint32_t>(p[3]) << 24);
}
} // namespace
util::Result<DbfHeader> parse_dbf_header(const std::uint8_t* data,
std::size_t size) {
if (size < 32) {
return util::Error{5103, 0, "DBF header smaller than 32 bytes", ""};
}
DbfHeader h;
h.version = data[0];
// YY in DBF header is years since 1900. Apply that base.
h.last_update_year = static_cast<std::uint16_t>(1900 + data[1]);
h.last_update_month = data[2];
h.last_update_day = data[3];
h.record_count = read_u32_le(data + 4);
h.header_length = read_u16_le(data + 8);
h.record_length = read_u16_le(data + 10);
h.family = classify(h.version);
return h;
}
} // namespace openads::drivers
- Step 5: Run tests to verify they pass
Run:
cmake --build build/default --config Release
build/default/tests/Release/openads_unit_tests.exe
Expected: 4 new test cases pass alongside the previous 27.
- Step 6: Commit
git add src/drivers/dbf_common.h src/drivers/dbf_common.cpp tests/unit/dbf_header_test.cpp
git commit -m "feat(drivers): DBF header parser shared by CDX/NTX/VFP"
Task 3: DBF field descriptors parser
Files:
- Modify:
c:/OpenADS/src/drivers/dbf_common.h - Modify:
c:/OpenADS/src/drivers/dbf_common.cpp -
Modify:
c:/OpenADS/tests/unit/dbf_field_test.cpp - Step 1: Write the failing tests
Replace c:/OpenADS/tests/unit/dbf_field_test.cpp:
#include "doctest.h"
#include "drivers/dbf_common.h"
#include <array>
#include <cstdint>
#include <cstring>
#include <vector>
using openads::drivers::DbfField;
using openads::drivers::DbfFieldType;
using openads::drivers::parse_dbf_fields;
namespace {
// Build an N-field descriptor block of 32 bytes per field plus the
// trailing 0x0D terminator. Each descriptor:
// bytes 0-10 : ASCII name padded with NUL
// byte 11 : ASCII type letter
// bytes 12-15: field data offset (uint32 LE) — VFP only, ignored
// byte 16 : field length
// byte 17 : decimal places
// bytes 18-31: reserved
std::vector<std::uint8_t> build_descriptors(
const std::vector<std::tuple<const char*, char, std::uint8_t,
std::uint8_t>>& fields) {
std::vector<std::uint8_t> out(fields.size() * 32 + 1, 0);
for (std::size_t i = 0; i < fields.size(); ++i) {
std::uint8_t* slot = out.data() + i * 32;
const auto& [name, type, len, dec] = fields[i];
std::strncpy(reinterpret_cast<char*>(slot), name, 11);
slot[11] = static_cast<std::uint8_t>(type);
slot[16] = len;
slot[17] = dec;
}
out.back() = 0x0D;
return out;
}
} // namespace
TEST_CASE("Field parser reads name, type, length, decimals") {
auto buf = build_descriptors({
{"NAME", 'C', 20, 0},
{"BALANCE", 'N', 12, 2},
{"BORN", 'D', 8, 0},
{"ACTIVE", 'L', 1, 0},
});
auto parsed = parse_dbf_fields(buf.data(), buf.size());
REQUIRE(parsed.has_value());
auto fields = parsed.value();
REQUIRE(fields.size() == 4);
CHECK(fields[0].name == "NAME");
CHECK(fields[0].type == DbfFieldType::Character);
CHECK(fields[0].length == 20);
CHECK(fields[0].decimals == 0);
CHECK(fields[1].name == "BALANCE");
CHECK(fields[1].type == DbfFieldType::Numeric);
CHECK(fields[1].length == 12);
CHECK(fields[1].decimals == 2);
CHECK(fields[2].type == DbfFieldType::Date);
CHECK(fields[2].length == 8);
CHECK(fields[3].type == DbfFieldType::Logical);
CHECK(fields[3].length == 1);
}
TEST_CASE("Field parser stops at the 0x0D terminator") {
auto buf = build_descriptors({
{"ONLY", 'C', 5, 0},
});
auto parsed = parse_dbf_fields(buf.data(), buf.size());
REQUIRE(parsed.has_value());
CHECK(parsed.value().size() == 1);
}
TEST_CASE("Field parser flags unknown types as Unknown but still records them") {
auto buf = build_descriptors({
{"WEIRD", 'Q', 3, 0},
});
auto parsed = parse_dbf_fields(buf.data(), buf.size());
REQUIRE(parsed.has_value());
REQUIRE(parsed.value().size() == 1);
CHECK(parsed.value()[0].type == DbfFieldType::Unknown);
CHECK(parsed.value()[0].raw_type == 'Q');
}
TEST_CASE("Field parser computes record offset for each field") {
auto buf = build_descriptors({
{"A", 'C', 5, 0},
{"B", 'C', 7, 0},
{"C", 'N', 3, 0},
});
auto parsed = parse_dbf_fields(buf.data(), buf.size());
REQUIRE(parsed.has_value());
auto fields = parsed.value();
CHECK(fields[0].record_offset == 1); // 1 = past deletion byte
CHECK(fields[1].record_offset == 1 + 5);
CHECK(fields[2].record_offset == 1 + 5 + 7);
}
- Step 2: Run tests to verify they fail to compile
Run:
cmake --build build/default --config Release
Expected: compile error, parse_dbf_fields not declared.
- Step 3: Append to
drivers/dbf_common.h
Open c:/OpenADS/src/drivers/dbf_common.h and add the following just before the closing namespace brace:
enum class DbfFieldType {
Character,
Numeric,
Float,
Date,
DateTime,
Logical,
Memo,
Integer, // VFP I (4-byte int)
Currency, // VFP Y
Double, // VFP B
Unknown
};
struct DbfField {
std::string name;
DbfFieldType type = DbfFieldType::Unknown;
char raw_type = '\0';
std::uint8_t length = 0;
std::uint8_t decimals = 0;
std::uint16_t record_offset = 0; // includes the leading deletion byte
};
util::Result<std::vector<DbfField>>
parse_dbf_fields(const std::uint8_t* data, std::size_t size);
Also add the corresponding includes at the top of dbf_common.h:
#include <string>
#include <vector>
- Step 4: Append to
drivers/dbf_common.cpp
Append the implementation:
namespace openads::drivers {
namespace {
DbfFieldType classify_field(char raw) {
switch (raw) {
case 'C': return DbfFieldType::Character;
case 'N': return DbfFieldType::Numeric;
case 'F': return DbfFieldType::Float;
case 'D': return DbfFieldType::Date;
case 'T': return DbfFieldType::DateTime;
case 'L': return DbfFieldType::Logical;
case 'M': return DbfFieldType::Memo;
case 'I': return DbfFieldType::Integer;
case 'Y': return DbfFieldType::Currency;
case 'B': return DbfFieldType::Double;
default: return DbfFieldType::Unknown;
}
}
} // namespace
util::Result<std::vector<DbfField>>
parse_dbf_fields(const std::uint8_t* data, std::size_t size) {
std::vector<DbfField> out;
std::uint16_t offset = 1; // skip leading deletion byte
std::size_t pos = 0;
while (pos + 32 <= size) {
if (data[pos] == 0x0D) break;
DbfField f;
// name: NUL-terminated within first 11 bytes
const char* raw_name = reinterpret_cast<const char*>(data + pos);
std::size_t name_len = 0;
while (name_len < 11 && raw_name[name_len] != '\0') ++name_len;
f.name.assign(raw_name, name_len);
f.raw_type = static_cast<char>(data[pos + 11]);
f.type = classify_field(f.raw_type);
f.length = data[pos + 16];
f.decimals = data[pos + 17];
f.record_offset = offset;
offset = static_cast<std::uint16_t>(offset + f.length);
out.push_back(std::move(f));
pos += 32;
}
return out;
}
} // namespace openads::drivers
Note that the new namespace block sits in the same translation unit as the existing one — they will be merged by the compiler. Keep the existing block intact above this addition.
- Step 5: Run tests to verify they pass
Run:
cmake --build build/default --config Release
build/default/tests/Release/openads_unit_tests.exe
Expected: 4 new test cases pass.
- Step 6: Commit
git add src/drivers/dbf_common.h src/drivers/dbf_common.cpp tests/unit/dbf_field_test.cpp
git commit -m "feat(drivers): DBF field-descriptor parser with record offsets"
Task 4: Record reader and field decoder
Files:
- Modify:
c:/OpenADS/src/drivers/dbf_common.h - Modify:
c:/OpenADS/src/drivers/dbf_common.cpp -
Modify:
c:/OpenADS/tests/unit/dbf_record_test.cpp - Step 1: Write the failing tests
Replace c:/OpenADS/tests/unit/dbf_record_test.cpp:
#include "doctest.h"
#include "drivers/dbf_common.h"
#include <cstdint>
#include <string>
#include <vector>
using openads::drivers::DbfField;
using openads::drivers::DbfFieldType;
using openads::drivers::decode_field;
using openads::drivers::record_is_deleted;
TEST_CASE("Character field decodes ASCII trimmed of trailing spaces") {
DbfField f;
f.type = DbfFieldType::Character;
f.length = 10;
f.record_offset = 1;
std::vector<std::uint8_t> rec = {' ',
'C', 'l', 'i', 'p', 'p', 'e', 'r', ' ', ' ', ' '};
auto v = decode_field(f, rec.data(), rec.size());
REQUIRE(v.has_value());
CHECK(v.value().as_string == "Clipper");
}
TEST_CASE("Numeric field decodes an integer-looking value") {
DbfField f;
f.type = DbfFieldType::Numeric;
f.length = 6;
f.decimals = 0;
f.record_offset = 1;
std::vector<std::uint8_t> rec = {' ',
' ', ' ', ' ', '4', '2', '0'};
auto v = decode_field(f, rec.data(), rec.size());
REQUIRE(v.has_value());
CHECK(v.value().as_double == doctest::Approx(420.0));
}
TEST_CASE("Numeric field decodes a fractional value") {
DbfField f;
f.type = DbfFieldType::Numeric;
f.length = 7;
f.decimals = 2;
f.record_offset = 1;
std::vector<std::uint8_t> rec = {' ',
' ', ' ', '3', '.', '1', '4', ' '};
auto v = decode_field(f, rec.data(), rec.size());
REQUIRE(v.has_value());
CHECK(v.value().as_double == doctest::Approx(3.14));
}
TEST_CASE("Logical field decodes T/F/Y/N") {
DbfField f;
f.type = DbfFieldType::Logical;
f.length = 1;
f.record_offset = 1;
for (auto [byte, expected] : std::initializer_list<std::pair<char, bool>>{
{'T', true}, {'t', true}, {'Y', true}, {'y', true},
{'F', false}, {'f', false}, {'N', false}, {'n', false},
{'?', false}, {' ', false}}) {
std::vector<std::uint8_t> rec = {' ',
static_cast<std::uint8_t>(byte)};
auto v = decode_field(f, rec.data(), rec.size());
REQUIRE(v.has_value());
CHECK(v.value().as_bool == expected);
}
}
TEST_CASE("Date field decodes YYYYMMDD ASCII") {
DbfField f;
f.type = DbfFieldType::Date;
f.length = 8;
f.record_offset = 1;
std::vector<std::uint8_t> rec = {' ',
'2', '0', '2', '6', '0', '5', '0', '3'};
auto v = decode_field(f, rec.data(), rec.size());
REQUIRE(v.has_value());
CHECK(v.value().as_string == "20260503");
}
TEST_CASE("Memo field returns an empty placeholder in M1") {
DbfField f;
f.type = DbfFieldType::Memo;
f.length = 10;
f.record_offset = 1;
std::vector<std::uint8_t> rec = {' ',
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'};
auto v = decode_field(f, rec.data(), rec.size());
REQUIRE(v.has_value());
CHECK(v.value().as_string.empty());
}
TEST_CASE("record_is_deleted reads the deletion byte") {
std::vector<std::uint8_t> alive = {' ', 'a', 'b', 'c'};
std::vector<std::uint8_t> deleted = {'*', 'a', 'b', 'c'};
CHECK_FALSE(record_is_deleted(alive.data(), alive.size()));
CHECK (record_is_deleted(deleted.data(), deleted.size()));
}
- Step 2: Run tests to verify they fail to compile
Run:
cmake --build build/default --config Release
Expected: compile error, decode_field not declared.
- Step 3: Extend
drivers/dbf_common.h
Append before the closing namespace brace:
struct DbfFieldValue {
std::string as_string;
double as_double = 0.0;
bool as_bool = false;
bool is_null = false;
};
util::Result<DbfFieldValue> decode_field(const DbfField& field,
const std::uint8_t* record_buf,
std::size_t record_size);
bool record_is_deleted(const std::uint8_t* record_buf,
std::size_t record_size) noexcept;
- Step 4: Extend
drivers/dbf_common.cpp
Append:
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <cstring>
namespace openads::drivers {
namespace {
std::string make_string(const std::uint8_t* p, std::size_t n) {
std::string s(reinterpret_cast<const char*>(p), n);
// trim trailing spaces (DBF convention for fixed-width chars)
while (!s.empty() && s.back() == ' ') s.pop_back();
return s;
}
double parse_numeric(const std::uint8_t* p, std::size_t n) {
// DBF numerics are right-aligned ASCII with optional sign and dot.
// Copy into a NUL-terminated stack buffer for strtod.
char tmp[64];
if (n >= sizeof(tmp)) n = sizeof(tmp) - 1;
std::memcpy(tmp, p, n);
tmp[n] = '\0';
char* end = nullptr;
return std::strtod(tmp, &end);
}
} // namespace
util::Result<DbfFieldValue> decode_field(const DbfField& field,
const std::uint8_t* record_buf,
std::size_t record_size) {
DbfFieldValue v;
if (field.record_offset + field.length > record_size) {
return util::Error{5000, 0, "field range past record buffer", ""};
}
const std::uint8_t* p = record_buf + field.record_offset;
switch (field.type) {
case DbfFieldType::Character:
v.as_string = make_string(p, field.length);
break;
case DbfFieldType::Numeric:
case DbfFieldType::Float:
v.as_double = parse_numeric(p, field.length);
v.as_string = make_string(p, field.length);
break;
case DbfFieldType::Date:
case DbfFieldType::DateTime:
v.as_string = make_string(p, field.length);
break;
case DbfFieldType::Logical: {
char c = static_cast<char>(p[0]);
v.as_bool = (c == 'T' || c == 't' || c == 'Y' || c == 'y');
v.as_string = std::string(1, c);
break;
}
case DbfFieldType::Memo:
// M1 deliberately does not load memo blocks; they land in M4.
v.as_string.clear();
break;
case DbfFieldType::Integer:
case DbfFieldType::Currency:
case DbfFieldType::Double:
case DbfFieldType::Unknown:
v.as_string = make_string(p, field.length);
break;
}
return v;
}
bool record_is_deleted(const std::uint8_t* record_buf,
std::size_t record_size) noexcept {
if (record_size == 0) return false;
return record_buf[0] == '*';
}
} // namespace openads::drivers
- Step 5: Run tests to verify they pass
Run:
cmake --build build/default --config Release
build/default/tests/Release/openads_unit_tests.exe
Expected: 7 new test cases pass.
- Step 6: Commit
git add src/drivers/dbf_common.h src/drivers/dbf_common.cpp tests/unit/dbf_record_test.cpp
git commit -m "feat(drivers): DBF record decoder for C/N/F/D/T/L (memo deferred)"
Task 5: HandleRegistry
Files:
- Create:
c:/OpenADS/src/session/handle_registry.h - Modify:
c:/OpenADS/src/session/handle_registry.cpp -
Modify:
c:/OpenADS/tests/unit/session_handle_registry_test.cpp - Step 1: Write the failing tests
Replace c:/OpenADS/tests/unit/session_handle_registry_test.cpp:
#include "doctest.h"
#include "session/handle_registry.h"
using openads::session::HandleKind;
using openads::session::HandleRegistry;
TEST_CASE("HandleRegistry assigns distinct, non-zero handles") {
HandleRegistry reg;
int a = 1, b = 2;
auto h1 = reg.register_object(HandleKind::Connection, &a);
auto h2 = reg.register_object(HandleKind::Connection, &b);
CHECK(h1 != 0);
CHECK(h2 != 0);
CHECK(h1 != h2);
}
TEST_CASE("HandleRegistry resolves and respects the kind tag") {
HandleRegistry reg;
int conn_obj = 0;
int tbl_obj = 0;
auto hc = reg.register_object(HandleKind::Connection, &conn_obj);
auto ht = reg.register_object(HandleKind::Table, &tbl_obj);
CHECK(reg.lookup<int>(hc, HandleKind::Connection) == &conn_obj);
CHECK(reg.lookup<int>(ht, HandleKind::Table) == &tbl_obj);
// Wrong-kind lookup returns nullptr.
CHECK(reg.lookup<int>(hc, HandleKind::Table) == nullptr);
}
TEST_CASE("HandleRegistry returns nullptr for unknown handles") {
HandleRegistry reg;
CHECK(reg.lookup<int>(0, HandleKind::Connection) == nullptr);
CHECK(reg.lookup<int>(99999999, HandleKind::Connection) == nullptr);
}
TEST_CASE("HandleRegistry releases handles") {
HandleRegistry reg;
int o = 0;
auto h = reg.register_object(HandleKind::Table, &o);
REQUIRE(reg.lookup<int>(h, HandleKind::Table) != nullptr);
reg.release(h);
CHECK(reg.lookup<int>(h, HandleKind::Table) == nullptr);
}
- Step 2: Run tests to verify they fail to compile
Run:
cmake --build build/default --config Release
Expected: compile error, session/handle_registry.h not found.
- Step 3: Implement
session/handle_registry.h
#pragma once
#include <cstdint>
#include <mutex>
#include <unordered_map>
namespace openads::session {
enum class HandleKind {
None = 0,
Connection = 1,
Table = 2,
Cursor = 3,
Statement = 4
};
using Handle = std::uint64_t;
class HandleRegistry {
public:
Handle register_object(HandleKind kind, void* ptr);
void release(Handle h);
template <class T>
T* lookup(Handle h, HandleKind kind) const {
std::lock_guard<std::mutex> lk(mu_);
auto it = slots_.find(h);
if (it == slots_.end()) return nullptr;
if (it->second.kind != kind) return nullptr;
return static_cast<T*>(it->second.ptr);
}
private:
struct Slot { HandleKind kind = HandleKind::None; void* ptr = nullptr; };
mutable std::mutex mu_;
std::unordered_map<Handle, Slot> slots_;
Handle next_ = 1;
};
} // namespace openads::session
- Step 4: Implement
session/handle_registry.cpp
Replace the placeholder with:
#include "session/handle_registry.h"
namespace openads::session {
Handle HandleRegistry::register_object(HandleKind kind, void* ptr) {
std::lock_guard<std::mutex> lk(mu_);
Handle h = next_++;
slots_[h] = Slot{kind, ptr};
return h;
}
void HandleRegistry::release(Handle h) {
std::lock_guard<std::mutex> lk(mu_);
slots_.erase(h);
}
} // namespace openads::session
- Step 5: Run tests to verify they pass
Run:
cmake --build build/default --config Release
build/default/tests/Release/openads_unit_tests.exe
Expected: 4 new test cases pass.
- Step 6: Commit
git add src/session/handle_registry.h src/session/handle_registry.cpp tests/unit/session_handle_registry_test.cpp
git commit -m "feat(session): kind-tagged HandleRegistry with thread-safe slot map"
Task 6: Engine Table (read-only navigation)
Files:
- Create:
c:/OpenADS/src/engine/table.h - Modify:
c:/OpenADS/src/engine/table.cpp - Create:
c:/OpenADS/src/drivers/driver_trait.h - Modify:
c:/OpenADS/src/drivers/cdx/cdx_driver.h(create) - Modify:
c:/OpenADS/src/drivers/cdx/cdx_driver.cpp -
Modify:
c:/OpenADS/tests/unit/engine_table_test.cpp - Step 1: Write the failing tests
Replace c:/OpenADS/tests/unit/engine_table_test.cpp:
#include "doctest.h"
#include "engine/table.h"
#include <array>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <vector>
namespace fs = std::filesystem;
using openads::engine::Table;
using openads::engine::TableType;
namespace {
// Build a tiny DBF on disk:
// - version 0x03
// - 3 records, single 5-char field "NAME"
fs::path make_fixture(const char* tag) {
auto p = fs::temp_directory_path() / (std::string("openads_m1_") + tag);
fs::remove(p);
std::vector<std::uint8_t> file;
// Header (32 bytes)
std::array<std::uint8_t, 32> hdr{};
hdr[0] = 0x03;
hdr[1] = 124; hdr[2] = 1; hdr[3] = 31;
hdr[4] = 3; hdr[5] = 0; hdr[6] = 0; hdr[7] = 0; // recno count
hdr[8] = 32 + 32 + 1; hdr[9] = 0; // header length
hdr[10] = 1 + 5; hdr[11] = 0; // record length
file.insert(file.end(), hdr.begin(), hdr.end());
// One field descriptor (32 bytes) + 0x0D terminator
std::array<std::uint8_t, 32> fd{};
std::strncpy(reinterpret_cast<char*>(fd.data()), "NAME", 11);
fd[11] = 'C';
fd[16] = 5;
file.insert(file.end(), fd.begin(), fd.end());
file.push_back(0x0D);
// 3 records of 6 bytes each (deletion byte + 5-char name)
auto push_rec = [&](char d, const char* name) {
file.push_back(static_cast<std::uint8_t>(d));
for (int i = 0; i < 5; ++i) {
file.push_back(static_cast<std::uint8_t>(
i < static_cast<int>(std::strlen(name)) ? name[i] : ' '));
}
};
push_rec(' ', "AAA");
push_rec(' ', "BBBB");
push_rec(' ', "CCCCC");
// EOF marker (0x1A) — optional in spec, present in conformance fixtures
file.push_back(0x1A);
std::ofstream(p, std::ios::binary).write(
reinterpret_cast<const char*>(file.data()),
static_cast<std::streamsize>(file.size()));
return p;
}
} // namespace
TEST_CASE("Table opens a CDX-typed DBF and reports counts") {
auto p = make_fixture("table_counts");
auto opened = Table::open(p.string(), TableType::Cdx);
REQUIRE(opened.has_value());
Table t = std::move(opened).value();
CHECK(t.field_count() == 1);
CHECK(t.record_count() == 3);
CHECK(t.field_descriptor(0).name == "NAME");
fs::remove(p);
}
TEST_CASE("Table navigates top / skip / bottom and tracks BOF/EOF") {
auto p = make_fixture("table_nav");
auto opened = Table::open(p.string(), TableType::Cdx);
REQUIRE(opened.has_value());
Table t = std::move(opened).value();
REQUIRE(t.goto_top().has_value());
CHECK(t.recno() == 1);
CHECK_FALSE(t.eof());
CHECK_FALSE(t.bof());
REQUIRE(t.skip(1).has_value());
CHECK(t.recno() == 2);
REQUIRE(t.skip(5).has_value());
CHECK(t.eof());
REQUIRE(t.goto_bottom().has_value());
CHECK(t.recno() == 3);
REQUIRE(t.skip(-10).has_value());
CHECK(t.bof());
fs::remove(p);
}
TEST_CASE("Table reads field values by index") {
auto p = make_fixture("table_field");
auto opened = Table::open(p.string(), TableType::Cdx);
REQUIRE(opened.has_value());
Table t = std::move(opened).value();
REQUIRE(t.goto_top().has_value());
auto v0 = t.read_field(0);
REQUIRE(v0.has_value());
CHECK(v0.value().as_string == "AAA");
REQUIRE(t.skip(1).has_value());
auto v1 = t.read_field(0);
REQUIRE(v1.has_value());
CHECK(v1.value().as_string == "BBBB");
REQUIRE(t.skip(1).has_value());
auto v2 = t.read_field(0);
REQUIRE(v2.has_value());
CHECK(v2.value().as_string == "CCCCC");
fs::remove(p);
}
- Step 2: Run tests to verify they fail to compile
Run:
cmake --build build/default --config Release
Expected: compile error, engine/table.h not found.
- Step 3: Write
drivers/driver_trait.h
#pragma once
#include "drivers/dbf_common.h"
#include "platform/file.h"
#include "util/result.h"
#include <cstdint>
#include <string>
#include <vector>
namespace openads::drivers {
// Minimal driver surface required by M1. Drivers will grow as later
// milestones add write, index, memo, encryption, and so on.
class IDriver {
public:
virtual ~IDriver() = default;
virtual util::Result<void>
open(const std::string& path) = 0;
virtual std::uint32_t record_count() const noexcept = 0;
virtual std::uint16_t record_length() const noexcept = 0;
virtual const std::vector<DbfField>& fields() const noexcept = 0;
// Reads a single record's raw bytes (including the deletion byte).
// recno is 1-based as in xBase. recno == 0 is invalid.
virtual util::Result<std::vector<std::uint8_t>>
read_record_raw(std::uint32_t recno) = 0;
};
} // namespace openads::drivers
- Step 4: Write
drivers/cdx/cdx_driver.h
#pragma once
#include "drivers/driver_trait.h"
#include "platform/file.h"
namespace openads::drivers::cdx {
class CdxDriver final : public IDriver {
public:
util::Result<void> open(const std::string& path) override;
std::uint32_t record_count() const noexcept override { return rec_count_; }
std::uint16_t record_length() const noexcept override { return rec_len_; }
const std::vector<DbfField>& fields() const noexcept override { return fields_; }
util::Result<std::vector<std::uint8_t>>
read_record_raw(std::uint32_t recno) override;
private:
platform::File file_;
std::vector<DbfField> fields_;
std::uint32_t rec_count_ = 0;
std::uint16_t rec_len_ = 0;
std::uint16_t hdr_len_ = 0;
};
} // namespace openads::drivers::cdx
- Step 5: Replace
drivers/cdx/cdx_driver.cpp
#include "drivers/cdx/cdx_driver.h"
#include <vector>
namespace openads::drivers::cdx {
util::Result<void> CdxDriver::open(const std::string& path) {
auto fres = platform::File::open(path, platform::OpenMode::ReadOnly);
if (!fres) return fres.error();
file_ = std::move(fres).value();
// Read the 32-byte header.
std::uint8_t hdr_buf[32]{};
auto got = file_.read_at(0, hdr_buf, sizeof(hdr_buf));
if (!got) return got.error();
if (got.value() < 32) {
return util::Error{5103, 0, "DBF header truncated", path};
}
auto hdr = parse_dbf_header(hdr_buf, sizeof(hdr_buf));
if (!hdr) return hdr.error();
rec_count_ = hdr.value().record_count;
rec_len_ = hdr.value().record_length;
hdr_len_ = hdr.value().header_length;
// Read the field-descriptor block (header_length - 32 bytes).
if (hdr_len_ < 33) {
return util::Error{5103, 0, "DBF header length below 33 bytes", path};
}
std::size_t fd_size = hdr_len_ - 32;
std::vector<std::uint8_t> fd_buf(fd_size, 0);
auto fd_got = file_.read_at(32, fd_buf.data(), fd_buf.size());
if (!fd_got) return fd_got.error();
if (fd_got.value() < fd_buf.size()) {
return util::Error{5103, 0, "field-descriptor block truncated", path};
}
auto fields = parse_dbf_fields(fd_buf.data(), fd_buf.size());
if (!fields) return fields.error();
fields_ = std::move(fields).value();
return {};
}
util::Result<std::vector<std::uint8_t>>
CdxDriver::read_record_raw(std::uint32_t recno) {
if (recno == 0 || recno > rec_count_) {
return util::Error{5000, 0, "record number out of range", ""};
}
std::vector<std::uint8_t> buf(rec_len_, 0);
std::uint64_t offset = static_cast<std::uint64_t>(hdr_len_) +
static_cast<std::uint64_t>(recno - 1) *
static_cast<std::uint64_t>(rec_len_);
auto got = file_.read_at(offset, buf.data(), buf.size());
if (!got) return got.error();
if (got.value() < buf.size()) {
return util::Error{5000, 0, "short read on record body", ""};
}
return buf;
}
} // namespace openads::drivers::cdx
- Step 6: Write
engine/table.h
#pragma once
#include "drivers/driver_trait.h"
#include "drivers/dbf_common.h"
#include "util/result.h"
#include <cstdint>
#include <memory>
#include <string>
namespace openads::engine {
enum class TableType { Cdx, Ntx, Adt, Vfp };
class Table {
public:
Table() = default;
Table(const Table&) = delete;
Table& operator=(const Table&) = delete;
Table(Table&&) noexcept = default;
Table& operator=(Table&&) noexcept = default;
~Table() = default;
static util::Result<Table> open(const std::string& path, TableType type);
std::uint16_t field_count() const noexcept;
const drivers::DbfField& field_descriptor(std::uint16_t idx) const;
std::uint32_t record_count() const noexcept;
std::uint32_t recno() const noexcept { return recno_; }
bool eof() const noexcept { return state_ == State::Eof; }
bool bof() const noexcept { return state_ == State::Bof; }
util::Result<void> goto_top();
util::Result<void> goto_bottom();
util::Result<void> goto_record(std::uint32_t recno);
util::Result<void> skip(std::int32_t delta);
util::Result<drivers::DbfFieldValue>
read_field(std::uint16_t field_index);
private:
enum class State { Bof, Positioned, Eof };
explicit Table(std::unique_ptr<drivers::IDriver> drv) noexcept
: driver_(std::move(drv)) {}
util::Result<void> ensure_record_loaded_();
util::Result<void> load_record_(std::uint32_t recno);
std::unique_ptr<drivers::IDriver> driver_;
State state_ = State::Bof;
std::uint32_t recno_ = 0;
std::vector<std::uint8_t> record_buf_;
};
} // namespace openads::engine
- Step 7: Implement
engine/table.cpp
Replace the placeholder with:
#include "engine/table.h"
#include "drivers/cdx/cdx_driver.h"
#include <utility>
namespace openads::engine {
util::Result<Table> Table::open(const std::string& path, TableType type) {
std::unique_ptr<drivers::IDriver> drv;
switch (type) {
case TableType::Cdx:
drv = std::make_unique<drivers::cdx::CdxDriver>();
break;
case TableType::Ntx:
case TableType::Adt:
case TableType::Vfp:
return util::Error{5004, 0,
"table type not yet supported in M1", path};
}
if (auto r = drv->open(path); !r) return r.error();
return Table{std::move(drv)};
}
std::uint16_t Table::field_count() const noexcept {
return static_cast<std::uint16_t>(driver_->fields().size());
}
const drivers::DbfField& Table::field_descriptor(std::uint16_t idx) const {
return driver_->fields().at(idx);
}
std::uint32_t Table::record_count() const noexcept {
return driver_->record_count();
}
util::Result<void> Table::load_record_(std::uint32_t recno) {
auto buf = driver_->read_record_raw(recno);
if (!buf) return buf.error();
record_buf_ = std::move(buf).value();
recno_ = recno;
state_ = State::Positioned;
return {};
}
util::Result<void> Table::goto_top() {
if (driver_->record_count() == 0) {
state_ = State::Eof;
recno_ = 0;
return {};
}
return load_record_(1);
}
util::Result<void> Table::goto_bottom() {
auto n = driver_->record_count();
if (n == 0) {
state_ = State::Eof;
recno_ = 0;
return {};
}
return load_record_(n);
}
util::Result<void> Table::goto_record(std::uint32_t recno) {
if (recno == 0 || recno > driver_->record_count()) {
state_ = State::Eof;
recno_ = 0;
return util::Error{5000, 0, "recno out of range", ""};
}
return load_record_(recno);
}
util::Result<void> Table::skip(std::int32_t delta) {
auto n = driver_->record_count();
if (n == 0) { state_ = State::Eof; recno_ = 0; return {}; }
std::int64_t target = static_cast<std::int64_t>(recno_) + delta;
if (state_ == State::Bof && delta > 0) target = delta;
if (target < 1) {
state_ = State::Bof;
recno_ = 0;
return {};
}
if (target > static_cast<std::int64_t>(n)) {
state_ = State::Eof;
recno_ = n + 1;
return {};
}
return load_record_(static_cast<std::uint32_t>(target));
}
util::Result<drivers::DbfFieldValue>
Table::read_field(std::uint16_t field_index) {
if (state_ != State::Positioned) {
return util::Error{5000, 0, "table not positioned on a record", ""};
}
if (field_index >= driver_->fields().size()) {
return util::Error{5063, 0, "field index out of range", ""};
}
return drivers::decode_field(driver_->fields().at(field_index),
record_buf_.data(), record_buf_.size());
}
} // namespace openads::engine
- Step 8: Run tests to verify they pass
Run:
cmake --build build/default --config Release
build/default/tests/Release/openads_unit_tests.exe
Expected: 3 new test cases pass.
- Step 9: Commit
git add src/drivers/driver_trait.h src/drivers/cdx/cdx_driver.h src/drivers/cdx/cdx_driver.cpp src/engine/table.h src/engine/table.cpp tests/unit/engine_table_test.cpp
git commit -m "feat(engine): read-only Table backed by CdxDriver over DBF"
Task 7: Engine Cursor (forward iterator over a Table)
Files:
- Create:
c:/OpenADS/src/engine/cursor.h - Modify:
c:/OpenADS/src/engine/cursor.cpp -
Modify:
c:/OpenADS/tests/unit/engine_cursor_test.cpp - Step 1: Write the failing tests
Replace c:/OpenADS/tests/unit/engine_cursor_test.cpp:
#include "doctest.h"
#include "engine/cursor.h"
#include "engine/table.h"
#include <array>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace fs = std::filesystem;
using openads::engine::Cursor;
using openads::engine::Table;
using openads::engine::TableType;
namespace {
fs::path make_fixture() {
auto p = fs::temp_directory_path() / "openads_m1_cursor.dbf";
fs::remove(p);
std::vector<std::uint8_t> file;
std::array<std::uint8_t, 32> hdr{};
hdr[0] = 0x03;
hdr[4] = 2; // 2 records
hdr[8] = 32 + 32 + 1; hdr[9] = 0;
hdr[10] = 1 + 4; hdr[11] = 0;
file.insert(file.end(), hdr.begin(), hdr.end());
std::array<std::uint8_t, 32> fd{};
std::strncpy(reinterpret_cast<char*>(fd.data()), "TAG", 11);
fd[11] = 'C';
fd[16] = 4;
file.insert(file.end(), fd.begin(), fd.end());
file.push_back(0x0D);
auto push_rec = [&](const char* name) {
file.push_back(' ');
for (int i = 0; i < 4; ++i) {
file.push_back(static_cast<std::uint8_t>(
i < static_cast<int>(std::strlen(name)) ? name[i] : ' '));
}
};
push_rec("X");
push_rec("YZ");
file.push_back(0x1A);
std::ofstream(p, std::ios::binary).write(
reinterpret_cast<const char*>(file.data()),
static_cast<std::streamsize>(file.size()));
return p;
}
} // namespace
TEST_CASE("Cursor walks all live records once") {
auto p = make_fixture();
auto t = Table::open(p.string(), TableType::Cdx);
REQUIRE(t.has_value());
Table table = std::move(t).value();
Cursor c(table);
std::vector<std::string> seen;
while (auto row = c.next()) {
auto v = row->read_field(0);
REQUIRE(v.has_value());
seen.push_back(v.value().as_string);
}
CHECK(seen == std::vector<std::string>{"X", "YZ"});
fs::remove(p);
}
- Step 2: Run tests to verify they fail to compile
Run:
cmake --build build/default --config Release
Expected: compile error, engine/cursor.h not found.
- Step 3: Implement
engine/cursor.h
#pragma once
#include "engine/table.h"
#include <optional>
namespace openads::engine {
// Forward iteration view of a Table. The Cursor borrows the Table; the
// caller owns the Table lifetime. `next()` returns a non-owning pointer
// to the same Table positioned on the next live record, or std::nullopt
// at EOF.
class Cursor {
public:
explicit Cursor(Table& t) noexcept : table_(&t) {}
std::optional<Table*> next();
private:
Table* table_;
bool started_ = false;
};
} // namespace openads::engine
- Step 4: Implement
engine/cursor.cpp
Replace placeholder with:
#include "engine/cursor.h"
namespace openads::engine {
std::optional<Table*> Cursor::next() {
if (!started_) {
started_ = true;
if (!table_->goto_top()) return std::nullopt;
} else {
if (!table_->skip(1)) return std::nullopt;
}
if (table_->eof()) return std::nullopt;
return table_;
}
} // namespace openads::engine
- Step 5: Run tests to verify they pass
Run:
cmake --build build/default --config Release
build/default/tests/Release/openads_unit_tests.exe
Expected: 1 new test case passes.
- Step 6: Commit
git add src/engine/cursor.h src/engine/cursor.cpp tests/unit/engine_cursor_test.cpp
git commit -m "feat(engine): forward-only Cursor over Table"
Task 8: Session Connection
Files:
- Create:
c:/OpenADS/src/session/connection.h - Modify:
c:/OpenADS/src/session/connection.cpp -
Modify:
c:/OpenADS/tests/unit/session_connection_test.cpp - Step 1: Write the failing tests
Replace c:/OpenADS/tests/unit/session_connection_test.cpp:
#include "doctest.h"
#include "session/connection.h"
#include <array>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
namespace fs = std::filesystem;
using openads::engine::TableType;
using openads::session::Connection;
namespace {
fs::path tmp_dir(const char* tag) {
auto p = fs::temp_directory_path() / (std::string("openads_m1_conn_") + tag);
fs::remove_all(p);
fs::create_directories(p);
return p;
}
void write_minimal_dbf(const fs::path& p) {
std::vector<std::uint8_t> file;
std::array<std::uint8_t, 32> hdr{};
hdr[0] = 0x03;
hdr[4] = 1;
hdr[8] = 32 + 32 + 1; hdr[9] = 0;
hdr[10] = 1 + 3; hdr[11] = 0;
file.insert(file.end(), hdr.begin(), hdr.end());
std::array<std::uint8_t, 32> fd{};
std::strncpy(reinterpret_cast<char*>(fd.data()), "TAG", 11);
fd[11] = 'C';
fd[16] = 3;
file.insert(file.end(), fd.begin(), fd.end());
file.push_back(0x0D);
file.push_back(' '); file.push_back('a'); file.push_back('b'); file.push_back('c');
file.push_back(0x1A);
std::ofstream(p, std::ios::binary).write(
reinterpret_cast<const char*>(file.data()),
static_cast<std::streamsize>(file.size()));
}
} // namespace
TEST_CASE("Connection opens against a directory") {
auto dir = tmp_dir("open");
auto opened = Connection::open(dir.string());
REQUIRE(opened.has_value());
fs::remove_all(dir);
}
TEST_CASE("Connection opens a CDX-typed table by relative name") {
auto dir = tmp_dir("table");
write_minimal_dbf(dir / "data.dbf");
auto opened = Connection::open(dir.string());
REQUIRE(opened.has_value());
Connection c = std::move(opened).value();
auto th = c.open_table("data.dbf", TableType::Cdx);
REQUIRE(th.has_value());
auto* table = c.lookup_table(th.value());
REQUIRE(table != nullptr);
CHECK(table->field_count() == 1);
CHECK(table->record_count() == 1);
c.close_table(th.value());
CHECK(c.lookup_table(th.value()) == nullptr);
fs::remove_all(dir);
}
- Step 2: Run tests to verify they fail to compile
Run:
cmake --build build/default --config Release
Expected: compile error, session/connection.h not found.
- Step 3: Write
session/connection.h
#pragma once
#include "engine/table.h"
#include "session/handle_registry.h"
#include "util/result.h"
#include <memory>
#include <string>
#include <unordered_map>
namespace openads::session {
class Connection {
public:
Connection() = default;
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
Connection(Connection&&) noexcept = default;
Connection& operator=(Connection&&) noexcept = default;
static util::Result<Connection> open(const std::string& data_dir);
util::Result<Handle>
open_table(const std::string& relative_path,
engine::TableType type);
void close_table(Handle h);
engine::Table* lookup_table(Handle h);
const std::string& data_dir() const noexcept { return data_dir_; }
private:
std::string data_dir_;
HandleRegistry registry_;
std::unordered_map<Handle, std::unique_ptr<engine::Table>> tables_;
};
} // namespace openads::session
- Step 4: Implement
session/connection.cpp
Replace placeholder with:
#include "session/connection.h"
#include "platform/path.h"
#include <filesystem>
#include <utility>
namespace openads::session {
util::Result<Connection> Connection::open(const std::string& data_dir) {
namespace fs = std::filesystem;
std::error_code ec;
if (!fs::is_directory(data_dir, ec)) {
return util::Error{5103, 0, "data directory not found", data_dir};
}
Connection c;
c.data_dir_ = data_dir;
return c;
}
util::Result<Handle> Connection::open_table(const std::string& relative_path,
engine::TableType type) {
namespace fs = std::filesystem;
fs::path full = fs::path(data_dir_) / relative_path;
auto resolved = platform::resolve_case_insensitive(full.string());
auto t = engine::Table::open(resolved, type);
if (!t) return t.error();
auto holder = std::make_unique<engine::Table>(std::move(t).value());
engine::Table* raw = holder.get();
Handle h = registry_.register_object(HandleKind::Table, raw);
tables_.emplace(h, std::move(holder));
return h;
}
void Connection::close_table(Handle h) {
registry_.release(h);
tables_.erase(h);
}
engine::Table* Connection::lookup_table(Handle h) {
return registry_.lookup<engine::Table>(h, HandleKind::Table);
}
} // namespace openads::session
- Step 5: Run tests to verify they pass
Run:
cmake --build build/default --config Release
build/default/tests/Release/openads_unit_tests.exe
Expected: 2 new test cases pass.
- Step 6: Commit
git add src/session/connection.h src/session/connection.cpp tests/unit/session_connection_test.cpp
git commit -m "feat(session): Connection with table registry over a data directory"
Task 9: Public ACE header subset and ABI thunks
Files:
- Create:
c:/OpenADS/include/openads/ace.h - Create:
c:/OpenADS/src/abi/ace_types.h - Create:
c:/OpenADS/src/abi/last_error.h - Modify:
c:/OpenADS/src/abi/last_error.cpp - Create:
c:/OpenADS/src/abi/charset.h - Modify:
c:/OpenADS/src/abi/charset.cpp -
Modify:
c:/OpenADS/src/abi/ace_exports.cpp - Step 1: Write
include/openads/ace.h(M1 subset)
#pragma once
// OpenADS ACE-compatible C ABI — phase 1, milestone M1 subset.
// See openads/error.h for AE_* error codes.
#include <stdint.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef uint8_t UNSIGNED8;
typedef uint16_t UNSIGNED16;
typedef uint32_t UNSIGNED32;
typedef int32_t SIGNED32;
typedef uint64_t ADSHANDLE;
#define ADS_DEFAULT 0
#define ADS_NTX 1
#define ADS_CDX 2
#define ADS_ADT 3
#define ADS_VFP 4
#define ADS_LOCAL_SERVER 1
#define ADS_REMOTE_SERVER 2
UNSIGNED32 AdsConnect60 (UNSIGNED8* pucServer, UNSIGNED16 usServerType,
UNSIGNED8* pucUserName, UNSIGNED8* pucPassword,
UNSIGNED32 ulOptions, ADSHANDLE* phConnect);
UNSIGNED32 AdsDisconnect (ADSHANDLE hConnect);
UNSIGNED32 AdsOpenTable (ADSHANDLE hConnect,
UNSIGNED8* pucName,
UNSIGNED8* pucAlias,
UNSIGNED16 usTableType,
UNSIGNED16 usCharType,
UNSIGNED16 usLockType,
UNSIGNED16 usCheckRights,
UNSIGNED16 usMode,
ADSHANDLE* phTable);
UNSIGNED32 AdsCloseTable (ADSHANDLE hTable);
UNSIGNED32 AdsGotoTop (ADSHANDLE hTable);
UNSIGNED32 AdsGotoBottom (ADSHANDLE hTable);
UNSIGNED32 AdsSkip (ADSHANDLE hTable, SIGNED32 lRows);
UNSIGNED32 AdsAtEOF (ADSHANDLE hTable, UNSIGNED16* pbAtEnd);
UNSIGNED32 AdsAtBOF (ADSHANDLE hTable, UNSIGNED16* pbAtBegin);
UNSIGNED32 AdsGetField (ADSHANDLE hTable, UNSIGNED8* pucField,
UNSIGNED8* pucBuf, UNSIGNED32* pulLen,
UNSIGNED16 usOption);
UNSIGNED32 AdsGetFieldName (ADSHANDLE hTable, UNSIGNED16 usFieldNum,
UNSIGNED8* pucBuf, UNSIGNED16* pusLen);
UNSIGNED32 AdsGetNumFields (ADSHANDLE hTable, UNSIGNED16* pusFields);
UNSIGNED32 AdsGetFieldType (ADSHANDLE hTable, UNSIGNED8* pucField,
UNSIGNED16* pusType);
UNSIGNED32 AdsGetFieldLength(ADSHANDLE hTable, UNSIGNED8* pucField,
UNSIGNED32* pulLen);
UNSIGNED32 AdsGetRecordNum (ADSHANDLE hTable, UNSIGNED16 bFilterOption,
UNSIGNED32* pulRecordNum);
UNSIGNED32 AdsGetRecordCount(ADSHANDLE hTable, UNSIGNED16 bFilterOption,
UNSIGNED32* pulRecordCount);
UNSIGNED32 AdsGetLastError (UNSIGNED32* pulCode, UNSIGNED8* pucBuf,
UNSIGNED16* pusBufLen);
UNSIGNED32 AdsGetVersion (UNSIGNED32* pulMajor, UNSIGNED32* pulMinor,
UNSIGNED32* pulLetter, UNSIGNED32* pulDesc);
#define ADS_FIELD_TYPE_CHAR 1
#define ADS_FIELD_TYPE_NUMERIC 2
#define ADS_FIELD_TYPE_LOGICAL 3
#define ADS_FIELD_TYPE_DATE 4
#define ADS_FIELD_TYPE_DATETIME 5
#define ADS_FIELD_TYPE_MEMO 6
#define ADS_FIELD_TYPE_INTEGER 7
#define ADS_FIELD_TYPE_DOUBLE 8
#define ADS_FIELD_TYPE_CURRENCY 9
#define ADS_FIELD_TYPE_UNKNOWN 99
#ifdef __cplusplus
}
#endif
- Step 2: Write
src/abi/ace_types.h
#pragma once
#include "openads/ace.h"
- Step 3: Write
src/abi/last_error.h
#pragma once
#include "util/result.h"
#include <cstdint>
#include <string>
namespace openads::abi {
void set_last_error(const util::Error& e) noexcept;
void clear_last_error() noexcept;
std::int32_t last_error_code() noexcept;
const std::string& last_error_message() noexcept;
} // namespace openads::abi
- Step 4: Replace
src/abi/last_error.cpp
#include "abi/last_error.h"
namespace openads::abi {
namespace {
thread_local std::int32_t g_code = 0;
thread_local std::string g_msg;
} // namespace
void set_last_error(const util::Error& e) noexcept {
g_code = e.code;
g_msg = e.message;
}
void clear_last_error() noexcept {
g_code = 0;
g_msg.clear();
}
std::int32_t last_error_code() noexcept { return g_code; }
const std::string& last_error_message() noexcept { return g_msg; }
} // namespace openads::abi
- Step 5: Write
src/abi/charset.h
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace openads::abi {
// Phase 1 placeholder: M1 treats input/output as already-correct byte
// sequences. Real OEM/ANSI/UTF translation lands in M4 alongside the
// `*W` entry-point variants.
std::string to_internal(const std::uint8_t* p, std::size_t n);
void copy_to_caller(std::uint8_t* dst, std::uint16_t* dst_len_inout,
const std::string& src) noexcept;
} // namespace openads::abi
- Step 6: Replace
src/abi/charset.cpp
#include "abi/charset.h"
#include <algorithm>
#include <cstring>
namespace openads::abi {
std::string to_internal(const std::uint8_t* p, std::size_t n) {
if (p == nullptr) return {};
if (n == 0) {
// ACE convention: NUL-terminated when length is 0.
n = std::strlen(reinterpret_cast<const char*>(p));
}
return std::string(reinterpret_cast<const char*>(p), n);
}
void copy_to_caller(std::uint8_t* dst, std::uint16_t* dst_len_inout,
const std::string& src) noexcept {
if (dst == nullptr || dst_len_inout == nullptr) return;
std::uint16_t cap = *dst_len_inout;
std::uint16_t n = static_cast<std::uint16_t>(
std::min<std::size_t>(src.size(), cap == 0 ? 0 : cap - 1));
std::memcpy(dst, src.data(), n);
if (cap > 0) dst[n] = '\0';
*dst_len_inout = n;
}
} // namespace openads::abi
- Step 7: Replace
src/abi/ace_exports.cpp
#include "openads/ace.h"
#include "openads/error.h"
#include "abi/charset.h"
#include "abi/last_error.h"
#include "engine/table.h"
#include "session/connection.h"
#include "session/handle_registry.h"
#include "drivers/dbf_common.h"
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
#include <unordered_map>
namespace {
using openads::engine::Table;
using openads::session::Connection;
using openads::session::Handle;
using openads::session::HandleKind;
struct ProcessState {
std::mutex mu;
openads::session::HandleRegistry registry;
std::unordered_map<Handle, std::unique_ptr<Connection>> conns;
};
ProcessState& state() {
static ProcessState s;
return s;
}
UNSIGNED32 ok() {
openads::abi::clear_last_error();
return openads::AE_SUCCESS;
}
UNSIGNED32 fail(const openads::util::Error& e) {
openads::abi::set_last_error(e);
return static_cast<UNSIGNED32>(e.code);
}
UNSIGNED32 fail(int code, const char* msg) {
return fail(openads::util::Error{code, 0, msg ? msg : "", ""});
}
openads::engine::TableType map_type(UNSIGNED16 t) {
switch (t) {
case ADS_NTX: return openads::engine::TableType::Ntx;
case ADS_CDX: return openads::engine::TableType::Cdx;
case ADS_ADT: return openads::engine::TableType::Adt;
case ADS_VFP: return openads::engine::TableType::Vfp;
default: return openads::engine::TableType::Cdx;
}
}
UNSIGNED16 map_field_type(openads::drivers::DbfFieldType t) {
using openads::drivers::DbfFieldType;
switch (t) {
case DbfFieldType::Character: return ADS_FIELD_TYPE_CHAR;
case DbfFieldType::Numeric:
case DbfFieldType::Float: return ADS_FIELD_TYPE_NUMERIC;
case DbfFieldType::Logical: return ADS_FIELD_TYPE_LOGICAL;
case DbfFieldType::Date: return ADS_FIELD_TYPE_DATE;
case DbfFieldType::DateTime: return ADS_FIELD_TYPE_DATETIME;
case DbfFieldType::Memo: return ADS_FIELD_TYPE_MEMO;
case DbfFieldType::Integer: return ADS_FIELD_TYPE_INTEGER;
case DbfFieldType::Currency: return ADS_FIELD_TYPE_CURRENCY;
case DbfFieldType::Double: return ADS_FIELD_TYPE_DOUBLE;
case DbfFieldType::Unknown: return ADS_FIELD_TYPE_UNKNOWN;
}
return ADS_FIELD_TYPE_UNKNOWN;
}
const openads::drivers::DbfField*
find_field(Table* tbl, const std::string& name) {
for (std::uint16_t i = 0; i < tbl->field_count(); ++i) {
const auto& f = tbl->field_descriptor(i);
if (f.name == name) return &f;
}
return nullptr;
}
} // namespace
extern "C" {
UNSIGNED32 AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 /*usServerType*/,
UNSIGNED8* /*pucUser*/, UNSIGNED8* /*pucPwd*/,
UNSIGNED32 /*ulOptions*/, ADSHANDLE* phConnect) {
if (phConnect == nullptr) return fail(openads::AE_INTERNAL_ERROR,
"phConnect is null");
auto path = openads::abi::to_internal(pucServer, 0);
auto opened = Connection::open(path);
if (!opened) return fail(opened.error());
auto holder = std::make_unique<Connection>(std::move(opened).value());
Connection* raw = holder.get();
auto& s = state();
std::lock_guard<std::mutex> lk(s.mu);
Handle h = s.registry.register_object(HandleKind::Connection, raw);
s.conns.emplace(h, std::move(holder));
*phConnect = h;
return ok();
}
UNSIGNED32 AdsDisconnect(ADSHANDLE hConnect) {
auto& s = state();
std::lock_guard<std::mutex> lk(s.mu);
s.registry.release(hConnect);
s.conns.erase(hConnect);
return ok();
}
UNSIGNED32 AdsOpenTable(ADSHANDLE hConnect,
UNSIGNED8* pucName,
UNSIGNED8* /*pucAlias*/,
UNSIGNED16 usTableType,
UNSIGNED16 /*usCharType*/,
UNSIGNED16 /*usLockType*/,
UNSIGNED16 /*usCheckRights*/,
UNSIGNED16 /*usMode*/,
ADSHANDLE* phTable) {
if (phTable == nullptr) return fail(openads::AE_INTERNAL_ERROR,
"phTable is null");
auto& s = state();
std::lock_guard<std::mutex> lk(s.mu);
auto* conn = s.registry.lookup<Connection>(hConnect, HandleKind::Connection);
if (conn == nullptr) return fail(openads::AE_INVALID_CONNECTION_HANDLE,
"unknown connection");
auto name = openads::abi::to_internal(pucName, 0);
auto t = conn->open_table(name, map_type(usTableType));
if (!t) return fail(t.error());
*phTable = t.value();
return ok();
}
UNSIGNED32 AdsCloseTable(ADSHANDLE hTable) {
auto& s = state();
std::lock_guard<std::mutex> lk(s.mu);
// We don't know which connection owns this handle; iterate.
for (auto& [h, conn] : s.conns) { (void)h; conn->close_table(hTable); }
return ok();
}
namespace {
Table* get_table(ADSHANDLE h) {
auto& s = state();
return s.registry.lookup<Table>(h, HandleKind::Table);
}
} // namespace
UNSIGNED32 AdsGotoTop(ADSHANDLE hTable) {
Table* t = get_table(hTable);
if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table");
auto r = t->goto_top();
if (!r) return fail(r.error());
return ok();
}
UNSIGNED32 AdsGotoBottom(ADSHANDLE hTable) {
Table* t = get_table(hTable);
if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table");
auto r = t->goto_bottom();
if (!r) return fail(r.error());
return ok();
}
UNSIGNED32 AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) {
Table* t = get_table(hTable);
if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table");
auto r = t->skip(lRows);
if (!r) return fail(r.error());
return ok();
}
UNSIGNED32 AdsAtEOF(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) {
Table* t = get_table(hTable);
if (!t || pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, "");
*pbAtEnd = t->eof() ? 1 : 0;
return ok();
}
UNSIGNED32 AdsAtBOF(ADSHANDLE hTable, UNSIGNED16* pbAtBegin) {
Table* t = get_table(hTable);
if (!t || pbAtBegin == nullptr) return fail(openads::AE_INTERNAL_ERROR, "");
*pbAtBegin = t->bof() ? 1 : 0;
return ok();
}
UNSIGNED32 AdsGetNumFields(ADSHANDLE hTable, UNSIGNED16* pusFields) {
Table* t = get_table(hTable);
if (!t || pusFields == nullptr) return fail(openads::AE_INTERNAL_ERROR, "");
*pusFields = t->field_count();
return ok();
}
UNSIGNED32 AdsGetFieldName(ADSHANDLE hTable, UNSIGNED16 usFieldNum,
UNSIGNED8* pucBuf, UNSIGNED16* pusLen) {
Table* t = get_table(hTable);
if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table");
if (usFieldNum == 0 || usFieldNum > t->field_count()) {
return fail(openads::AE_COLUMN_NOT_FOUND, "field index out of range");
}
const auto& f = t->field_descriptor(usFieldNum - 1);
openads::abi::copy_to_caller(pucBuf, pusLen, f.name);
return ok();
}
UNSIGNED32 AdsGetFieldType(ADSHANDLE hTable, UNSIGNED8* pucField,
UNSIGNED16* pusType) {
Table* t = get_table(hTable);
if (!t || pusType == nullptr) return fail(openads::AE_INTERNAL_ERROR, "");
auto name = openads::abi::to_internal(pucField, 0);
const auto* f = find_field(t, name);
if (f == nullptr) return fail(openads::AE_COLUMN_NOT_FOUND, name.c_str());
*pusType = map_field_type(f->type);
return ok();
}
UNSIGNED32 AdsGetFieldLength(ADSHANDLE hTable, UNSIGNED8* pucField,
UNSIGNED32* pulLen) {
Table* t = get_table(hTable);
if (!t || pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, "");
auto name = openads::abi::to_internal(pucField, 0);
const auto* f = find_field(t, name);
if (f == nullptr) return fail(openads::AE_COLUMN_NOT_FOUND, name.c_str());
*pulLen = f->length;
return ok();
}
UNSIGNED32 AdsGetRecordNum(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/,
UNSIGNED32* pulRecordNum) {
Table* t = get_table(hTable);
if (!t || pulRecordNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, "");
*pulRecordNum = t->recno();
return ok();
}
UNSIGNED32 AdsGetRecordCount(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/,
UNSIGNED32* pulRecordCount) {
Table* t = get_table(hTable);
if (!t || pulRecordCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, "");
*pulRecordCount = t->record_count();
return ok();
}
UNSIGNED32 AdsGetField(ADSHANDLE hTable, UNSIGNED8* pucField,
UNSIGNED8* pucBuf, UNSIGNED32* pulLen,
UNSIGNED16 /*usOption*/) {
Table* t = get_table(hTable);
if (!t || pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, "");
auto name = openads::abi::to_internal(pucField, 0);
std::uint16_t idx = 0;
bool found = false;
for (std::uint16_t i = 0; i < t->field_count(); ++i) {
if (t->field_descriptor(i).name == name) { idx = i; found = true; break; }
}
if (!found) return fail(openads::AE_COLUMN_NOT_FOUND, name.c_str());
auto v = t->read_field(idx);
if (!v) return fail(v.error());
UNSIGNED16 cap = static_cast<UNSIGNED16>(
*pulLen > 0xFFFFu ? 0xFFFFu : *pulLen);
UNSIGNED16 cap_inout = cap;
openads::abi::copy_to_caller(pucBuf, &cap_inout, v.value().as_string);
*pulLen = cap_inout;
return ok();
}
UNSIGNED32 AdsGetLastError(UNSIGNED32* pulCode, UNSIGNED8* pucBuf,
UNSIGNED16* pusBufLen) {
if (pulCode != nullptr) *pulCode = static_cast<UNSIGNED32>(
openads::abi::last_error_code());
if (pucBuf != nullptr && pusBufLen != nullptr) {
openads::abi::copy_to_caller(pucBuf, pusBufLen,
openads::abi::last_error_message());
}
return openads::AE_SUCCESS;
}
UNSIGNED32 AdsGetVersion(UNSIGNED32* pulMajor, UNSIGNED32* pulMinor,
UNSIGNED32* pulLetter, UNSIGNED32* pulDesc) {
if (pulMajor != nullptr) *pulMajor = 0;
if (pulMinor != nullptr) *pulMinor = 0;
if (pulLetter != nullptr) *pulLetter = 'a';
if (pulDesc != nullptr) *pulDesc = 1;
return openads::AE_SUCCESS;
}
} // extern "C"
- Step 8: Build to verify the ABI layer compiles
Run:
cmake --build build/default --config Release
Expected: success.
- Step 9: Commit
git add include/openads/ace.h src/abi/ace_types.h src/abi/last_error.h src/abi/last_error.cpp src/abi/charset.h src/abi/charset.cpp src/abi/ace_exports.cpp
git commit -m "feat(abi): ACE C ABI thunks for the M1 read-only entry points"
Task 10: ABI smoke test
Files:
-
Modify:
c:/OpenADS/tests/unit/abi_smoke_test.cpp -
Step 1: Write the end-to-end test
Replace c:/OpenADS/tests/unit/abi_smoke_test.cpp:
#include "doctest.h"
#include "openads/ace.h"
#include "openads/error.h"
#include <array>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace fs = std::filesystem;
namespace {
fs::path make_dbf(const fs::path& dir, const char* leaf) {
fs::create_directories(dir);
auto p = dir / leaf;
fs::remove(p);
std::vector<std::uint8_t> file;
std::array<std::uint8_t, 32> hdr{};
hdr[0] = 0x03;
hdr[4] = 2; // 2 records
hdr[8] = 32 + 32 + 1; hdr[9] = 0;
hdr[10] = 1 + 4; hdr[11] = 0;
file.insert(file.end(), hdr.begin(), hdr.end());
std::array<std::uint8_t, 32> fd{};
std::strncpy(reinterpret_cast<char*>(fd.data()), "TAG", 11);
fd[11] = 'C';
fd[16] = 4;
file.insert(file.end(), fd.begin(), fd.end());
file.push_back(0x0D);
auto push_rec = [&](const char* name) {
file.push_back(' ');
for (int i = 0; i < 4; ++i) {
file.push_back(static_cast<std::uint8_t>(
i < static_cast<int>(std::strlen(name)) ? name[i] : ' '));
}
};
push_rec("AB");
push_rec("CDEF");
file.push_back(0x1A);
std::ofstream(p, std::ios::binary).write(
reinterpret_cast<const char*>(file.data()),
static_cast<std::streamsize>(file.size()));
return p;
}
} // namespace
TEST_CASE("ABI smoke: open dir, open table, walk records, read field") {
const auto dir = fs::temp_directory_path() / "openads_m1_abi";
fs::remove_all(dir);
make_dbf(dir, "data.dbf");
ADSHANDLE hConn = 0;
UNSIGNED8 server_buf[256];
std::memcpy(server_buf, dir.string().c_str(), dir.string().size() + 1);
REQUIRE(AdsConnect60(server_buf, ADS_LOCAL_SERVER,
nullptr, nullptr, 0, &hConn) == 0);
ADSHANDLE hTable = 0;
UNSIGNED8 name_buf[64] = "data.dbf";
REQUIRE(AdsOpenTable(hConn, name_buf, nullptr, ADS_CDX,
0, 0, 0, 0, &hTable) == 0);
UNSIGNED16 fields = 0;
REQUIRE(AdsGetNumFields(hTable, &fields) == 0);
CHECK(fields == 1);
UNSIGNED32 rec_count = 0;
REQUIRE(AdsGetRecordCount(hTable, 0, &rec_count) == 0);
CHECK(rec_count == 2);
REQUIRE(AdsGotoTop(hTable) == 0);
auto read_first_field = [&](std::string& out) {
UNSIGNED8 fname[64] = "TAG";
UNSIGNED8 buf[64] = {0};
UNSIGNED32 cap = sizeof(buf);
UNSIGNED32 r = AdsGetField(hTable, fname, buf, &cap, 0);
REQUIRE(r == 0);
out.assign(reinterpret_cast<const char*>(buf), cap);
};
std::string row1;
read_first_field(row1);
CHECK(row1 == "AB");
REQUIRE(AdsSkip(hTable, 1) == 0);
std::string row2;
read_first_field(row2);
CHECK(row2 == "CDEF");
REQUIRE(AdsSkip(hTable, 1) == 0);
UNSIGNED16 at_eof = 0;
REQUIRE(AdsAtEOF(hTable, &at_eof) == 0);
CHECK(at_eof == 1);
REQUIRE(AdsCloseTable(hTable) == 0);
REQUIRE(AdsDisconnect(hConn) == 0);
fs::remove_all(dir);
}
- Step 2: Run tests to verify the new case passes
Run:
cmake --build build/default --config Release
build/default/tests/Release/openads_unit_tests.exe
Expected: 1 new test case passes; the suite total grows by 1.
- Step 3: Commit
git add tests/unit/abi_smoke_test.cpp
git commit -m "test(abi): end-to-end smoke through AdsConnect60..AdsGetField"
Task 11: Update README and tag M1
Files:
-
Modify:
c:/OpenADS/README.md -
Step 1: Mark M1 done in the milestone table
Inside the ## Next steps table, replace the M1 row with:
| **M1 — DBF read (CDX)** | [`2026-05-03-openads-m1-dbf-read.md`](docs/superpowers/plans/2026-05-03-openads-m1-dbf-read.md) | **Done.** Read-only DBF (`ADS_CDX` typed) via `AdsConnect60` / `AdsOpenTable` / `AdsGotoTop` / `AdsSkip` / `AdsGetField` and friends. No memo (M4), no index (M3), no write (M2). |
- Step 2: Commit, tag, push
Run from c:/OpenADS:
git add README.md
git commit -m "docs: mark M1 milestone done"
git tag m1-done
git push origin main --tags
Done
At the end of M1:
- The 18 ACE C ABI entry points listed in this plan all return real data over a CDX-typed DBF.
- A self-contained doctest (
abi_smoke_test.cpp) walks a fixture DBF end-to-end through the L1 ABI and verifies field values byte-for-byte. - ~17 new test cases pass on top of the 27 from M0.
- The repository is ready for M2 (DBF write + LockMgr + NTX driver) to land on top of
dbf_common,Table,Connection, and the L1 thunks established here.