Pārlūkot izejas kodu

v2.3 Stage F: project CRUD RPCs (ListProjects/CreateProject/DropProject)

Adds the gRPC + client surface for project management. Stage F ships the
RPCs against a filesystem-only placeholder; Stage B will replace the
placeholder with the real ProjectStore-backed implementation once
per-project LmdbEnv coordination lands.

Proto (proto/database.proto):
  - Three new RPCs on DatabaseService: ListProjects, CreateProject,
    DropProject.
  - ListProjectsResponse.projects is alphabetical and always includes
    "default" (implicit until first write).
  - CreateProject is idempotent — created=false (no error) when the
    project already exists; error set on validation failure.
  - DropProject refuses name=="default" and unknown names with error set;
    on success dropped=true and error is empty.

Service (service/src/):
  - DatabaseService::{listProjects, createProject, dropProject} are
    thin wrappers protected by projects_mutex_.
  - The actual filesystem work lives in service/src/project_fs_placeholder.hpp
    as header-only inline helpers — Stage B deletes the header and
    replaces the wrappers with real project-registry calls.
  - createProject lays out <dataDir>/projects/<name>/env/ so listProjects
    picks it up. dropProject rm-rfs the project directory; Stage F does
    NOT close any in-use LmdbEnv before removal (Stage B owns that
    coordination).
  - Three gRPC handlers in database_grpc_impl.cpp follow the same shape
    as CreateView / DropView — validation errors propagate through the
    response.error field, not the gRPC status.
  - CreateProject / DropProject honour the read-only gate (returns
    FAILED_PRECONDITION via readOnlyStatus()); ListProjects is always
    allowed because it's a read.

Client (client/):
  - Three new public methods: Client::listProjects(),
    Client::createProject(name), Client::dropProject(name).
  - createProject throws on transport failure and on validation error
    (idempotent re-create is NOT an error). dropProject throws on any
    refused/failed response.

Tests:
  - tests/test_project_crud.cpp exercises the placeholder helpers
    directly against tmp data dirs (29 assertions, all green). Follows
    the test_project_addressing pattern — header-only, no link deps
    beyond std::filesystem — so the test scope stays unit-level and the
    full DatabaseService link surface (memory_store / persistence /
    encryption / grpc / …) does not get dragged in.
  - Wired into ctest as `project_crud`.
  - Coverage: fresh-dataDir lists ["default"], create+list, idempotent
    create, _-prefix rejection, digit-prefix rejection, default-drop
    refused, drop-then-list excludes, drop-nonexistent refused, and a
    layout-shape sanity check that <dataDir>/projects/<name>/env/ is
    actually created.

Constraints respected:
  - No edits to memory_store.cpp or to existing handlers in
    database_grpc_impl.cpp beyond adding the three new ones.
  - Existing tests stay green (the test_timestamp_precision failure is
    pre-existing on main and unrelated to this work — it predates the
    v2.2.0 ns-default flip).

Notes for the Stage D agent working in parallel:
  - service_ is the right hand-off into DatabaseService for new
    project-related RPCs (the three handlers added here all route
    through DatabaseGrpcImpl::service_).
  - DatabaseService now has a projects_mutex_; Stage B's
    project-registry coordination should subsume it.
  - kDefaultProject (the constant) is now consumed from
    database_service.cpp too, not just project_addressing tests.
fszontagh 2 mēneši atpakaļ
vecāks
revīzija
a71c26bdf2

+ 32 - 0
client/include/smartbotic/database/client.hpp

@@ -359,6 +359,38 @@ public:
      */
     [[nodiscard]] std::optional<CollectionInfo> getCollectionInfo(const std::string& name);
 
+    // ===== Project Management (v2.3 Stage F) =====
+
+    /**
+     * List all projects on the server. Always includes "default", even if no
+     * project has been explicitly created yet. Sorted alphabetically.
+     */
+    [[nodiscard]] std::vector<std::string> listProjects();
+
+    /**
+     * Create a project. Idempotent — returns true if a new project was
+     * created on this call, false if it already existed.
+     *
+     * Throws std::runtime_error on validation failure (e.g. invalid name)
+     * or transport failure. Note: idempotent re-create is NOT an error.
+     *
+     * Project names must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$ and cannot
+     * start with '_' (reserved for system use).
+     */
+    bool createProject(const std::string& name);
+
+    /**
+     * Drop a project and rm-rf its data directory.
+     *
+     * Throws std::runtime_error if name == "default", the project does not
+     * exist, or on transport failure.
+     *
+     * Stage F caveat: this does NOT close any in-use LmdbEnv before
+     * removal. Stage B adds that coordination once per-project envs are
+     * real.
+     */
+    void dropProject(const std::string& name);
+
     // ===== Collection Configuration =====
 
     /**

+ 75 - 0
client/src/client.cpp

@@ -732,6 +732,67 @@ public:
         return result;
     }
 
+    // ===== Project Management (v2.3 Stage F) =====
+
+    std::vector<std::string> listProjects() {
+        smartbotic::databasepb::ListProjectsRequest request;
+        smartbotic::databasepb::ListProjectsResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->ListProjects(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::listProjects failed: {}", status.error_message());
+            return {};
+        }
+        return {response.projects().begin(), response.projects().end()};
+    }
+
+    bool createProject(const std::string& name) {
+        smartbotic::databasepb::CreateProjectRequest request;
+        request.set_name(name);
+
+        smartbotic::databasepb::CreateProjectResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->CreateProject(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::createProject failed: {}", status.error_message());
+            throw std::runtime_error("createProject transport failure: " +
+                                     status.error_message());
+        }
+        if (!response.error().empty()) {
+            // Validation error (invalid name etc.) — surfaced via the
+            // response field, not the gRPC status.
+            throw std::runtime_error("createProject('" + name + "') rejected: " +
+                                     response.error());
+        }
+        return response.created();
+    }
+
+    void dropProject(const std::string& name) {
+        smartbotic::databasepb::DropProjectRequest request;
+        request.set_name(name);
+
+        smartbotic::databasepb::DropProjectResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        auto status = stub_->DropProject(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::dropProject failed: {}", status.error_message());
+            throw std::runtime_error("dropProject transport failure: " +
+                                     status.error_message());
+        }
+        if (!response.error().empty()) {
+            throw std::runtime_error("dropProject('" + name + "') refused: " +
+                                     response.error());
+        }
+        // dropped() may still be false here defensively — but the
+        // server-side contract is dropped==true iff error is empty.
+    }
+
     // ===== Collection Configuration =====
 
     bool configureCollection(const std::string& collection, const Client::CollectionConfig& cfg) {
@@ -1628,6 +1689,20 @@ std::optional<Client::CollectionInfo> Client::getCollectionInfo(const std::strin
     return impl_->getCollectionInfo(name);
 }
 
+// ===== Project Management (v2.3 Stage F) =====
+
+std::vector<std::string> Client::listProjects() {
+    return impl_->listProjects();
+}
+
+bool Client::createProject(const std::string& name) {
+    return impl_->createProject(name);
+}
+
+void Client::dropProject(const std::string& name) {
+    impl_->dropProject(name);
+}
+
 bool Client::configureCollection(const std::string& collection, const CollectionConfig& cfg) {
     return impl_->configureCollection(collection, cfg);
 }

+ 47 - 0
proto/database.proto

@@ -58,6 +58,12 @@ service DatabaseService {
     rpc ListCollections(ListCollectionsRequest) returns (ListCollectionsResponse);
     rpc GetCollectionInfo(GetCollectionInfoRequest) returns (GetCollectionInfoResponse);
 
+    // v2.3 — Project management. Projects are top-level namespaces over
+    // collections. The "default" project is implicit and always exists.
+    rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse);
+    rpc CreateProject(CreateProjectRequest) returns (CreateProjectResponse);
+    rpc DropProject(DropProjectRequest) returns (DropProjectResponse);
+
     // View management
     rpc CreateView(CreateViewRequest) returns (CreateViewResponse);
     rpc DropView(DropViewRequest) returns (DropViewResponse);
@@ -486,6 +492,47 @@ message CollectionInfo {
     uint32 vector_dimension = 7;      // Dimension of vectors stored in this collection (0 = not a vector collection)
 }
 
+// ===== Project Management (v2.3) =====
+//
+// Projects are top-level namespaces over collections. Collections live
+// inside a project; wire-form "my_app:users" addresses collection "users"
+// in project "my_app", bare "users" addresses ("default", "users"). The
+// "default" project is implicit and always present.
+//
+// Stage F ships the RPC surface backed by a filesystem-only placeholder
+// (`<dataDir>/projects/<name>/env/`). Stage B replaces the placeholder
+// with the real ProjectStore-backed implementation.
+
+message ListProjectsRequest {}
+
+message ListProjectsResponse {
+    // Alphabetical. Always includes "default".
+    repeated string projects = 1;
+}
+
+message CreateProjectRequest {
+    string name = 1;
+}
+
+message CreateProjectResponse {
+    // true = newly created, false = already existed (idempotent) OR validation error.
+    bool created = 1;
+    // Populated on validation failure (e.g. invalid name). Empty on success
+    // and on the idempotent "already existed" path.
+    string error = 2;
+}
+
+message DropProjectRequest {
+    string name = 1;
+}
+
+message DropProjectResponse {
+    bool dropped = 1;
+    // Populated when the drop is refused — e.g. name == "default",
+    // unknown project, or filesystem error.
+    string error = 2;
+}
+
 // ===== File Operations =====
 
 message FileChunk {

+ 54 - 0
service/src/database_grpc_impl.cpp

@@ -1137,6 +1137,60 @@ grpc::Status DatabaseGrpcImpl::GetCollectionInfo(
     return grpc::Status::OK;
 }
 
+// ===== Project Management (v2.3 Stage F) =====
+//
+// Thin wrappers around DatabaseService::{listProjects, createProject,
+// dropProject}. Validation errors propagate to the client through the
+// response's `error` field rather than a non-OK gRPC status — same shape
+// as CreateView / DropView so callers can branch on `created`/`dropped`
+// without parsing status strings.
+
+grpc::Status DatabaseGrpcImpl::ListProjects(
+    grpc::ServerContext* /*context*/,
+    const pb::ListProjectsRequest* /*request*/,
+    pb::ListProjectsResponse* response
+) {
+    auto names = service_.listProjects();
+    for (auto& name : names) {
+        response->add_projects(std::move(name));
+    }
+    return grpc::Status::OK;
+}
+
+grpc::Status DatabaseGrpcImpl::CreateProject(
+    grpc::ServerContext* /*context*/,
+    const pb::CreateProjectRequest* request,
+    pb::CreateProjectResponse* response
+) {
+    if (service_.isReadOnly()) {
+        return readOnlyStatus("CreateProject", service_);
+    }
+    std::string error;
+    bool created = service_.createProject(request->name(), error);
+    response->set_created(created);
+    if (!error.empty()) {
+        response->set_error(error);
+    }
+    return grpc::Status::OK;
+}
+
+grpc::Status DatabaseGrpcImpl::DropProject(
+    grpc::ServerContext* /*context*/,
+    const pb::DropProjectRequest* request,
+    pb::DropProjectResponse* response
+) {
+    if (service_.isReadOnly()) {
+        return readOnlyStatus("DropProject", service_);
+    }
+    std::string error;
+    bool dropped = service_.dropProject(request->name(), error);
+    response->set_dropped(dropped);
+    if (!error.empty()) {
+        response->set_error(error);
+    }
+    return grpc::Status::OK;
+}
+
 // ===== File Operations =====
 
 grpc::Status DatabaseGrpcImpl::UploadFile(

+ 20 - 0
service/src/database_grpc_impl.hpp

@@ -206,6 +206,26 @@ public:
         pb::GetCollectionInfoResponse* response
     ) override;
 
+    // ===== Project Management (v2.3 Stage F) =====
+
+    grpc::Status ListProjects(
+        grpc::ServerContext* context,
+        const pb::ListProjectsRequest* request,
+        pb::ListProjectsResponse* response
+    ) override;
+
+    grpc::Status CreateProject(
+        grpc::ServerContext* context,
+        const pb::CreateProjectRequest* request,
+        pb::CreateProjectResponse* response
+    ) override;
+
+    grpc::Status DropProject(
+        grpc::ServerContext* context,
+        const pb::DropProjectRequest* request,
+        pb::DropProjectResponse* response
+    ) override;
+
     // ===== View Operations =====
 
     grpc::Status CreateView(

+ 4 - 0
service/src/database_service.hpp

@@ -216,6 +216,9 @@ public:
     }
 
     // v2.3 Stage F — project CRUD entry points (delegate to registry).
+    // The registry is the source of truth for listing / creating / dropping.
+    // CreateProject is idempotent. DropProject refuses "default". The
+    // gRPC handlers in database_grpc_impl.cpp call these.
     std::vector<std::string> listProjects() const;
     bool createProject(const std::string& name, std::string& error);
     bool dropProject(const std::string& name, std::string& error);
@@ -290,6 +293,7 @@ private:
     std::string read_only_reason_;
     RecoveryOutcome recovery_outcome_;
     bool force_readwrite_ = false;
+
 };
 
 } // namespace smartbotic::database

+ 127 - 0
service/src/project_fs_placeholder.hpp

@@ -0,0 +1,127 @@
+// v2.3 Stage F — filesystem-only project CRUD placeholders.
+//
+// Stage F ships the gRPC + client surface for ListProjects / CreateProject
+// / DropProject, but the storage layout refactor that lands a real
+// ProjectStore (one LmdbEnv per project) is Stage B. Until Stage B lands,
+// project CRUD touches the filesystem directly at:
+//
+//   <dataDir>/projects/<name>/env/
+//
+// These functions are header-only so the test target (and DatabaseService's
+// member methods) can both call them without dragging in the rest of the
+// service link surface. The DatabaseService::{listProjects, createProject,
+// dropProject} methods are thin wrappers that pin `dataDir` from
+// `config_.dataDirectory` and the projects_mutex_.
+//
+// Stage B will:
+//   - replace these helpers with project-registry-backed ones that
+//     coordinate per-project LmdbEnv open/close
+//   - delete this header
+
+#pragma once
+
+#include "project_addressing.hpp"
+
+#include <algorithm>
+#include <filesystem>
+#include <set>
+#include <string>
+#include <system_error>
+#include <vector>
+
+namespace smartbotic::database::project_fs_placeholder {
+
+// Scans <dataDir>/projects/ and returns a sorted list. "default" is
+// ALWAYS in the result — even before its directory has been created on
+// disk, because the default project is implicit until the first write.
+// Filenames that don't pass isValidProjectName() are skipped defensively
+// (a stray .tmp file in projects/ shouldn't show up as a project).
+inline std::vector<std::string> listProjects(const std::filesystem::path& dataDir) {
+    namespace fs = std::filesystem;
+    std::set<std::string> names;
+    names.insert(kDefaultProject);
+
+    const fs::path projectsRoot = dataDir / "projects";
+    std::error_code ec;
+    if (fs::is_directory(projectsRoot, ec)) {
+        for (const auto& entry : fs::directory_iterator(projectsRoot, ec)) {
+            if (ec) break;
+            if (!entry.is_directory()) continue;
+            std::string name = entry.path().filename().string();
+            if (!isValidProjectName(name) && name != kDefaultProject) {
+                continue;
+            }
+            names.insert(std::move(name));
+        }
+    }
+    return {names.begin(), names.end()};
+}
+
+// Returns true if a new project directory was created. Returns false
+// (with empty error) when the project already existed — idempotent. On
+// validation failure, returns false and sets `error`.
+inline bool createProject(const std::filesystem::path& dataDir,
+                          const std::string& name,
+                          std::string& error) {
+    namespace fs = std::filesystem;
+    error.clear();
+    if (!isValidProjectName(name)) {
+        error = "invalid project name '" + name +
+                "': must match ^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$ and not start with '_'";
+        return false;
+    }
+
+    const fs::path projectDir = dataDir / "projects" / name;
+    std::error_code ec;
+    if (fs::is_directory(projectDir, ec)) {
+        return false;  // Idempotent: already existed, no error.
+    }
+
+    const fs::path envDir = projectDir / "env";
+    fs::create_directories(envDir, ec);
+    if (ec) {
+        error = "failed to create project directory '" + projectDir.string() +
+                "': " + ec.message();
+        return false;
+    }
+    if (!fs::is_directory(envDir, ec)) {
+        error = "project directory '" + envDir.string() +
+                "' was not created (filesystem error)";
+        return false;
+    }
+    return true;
+}
+
+// Returns true if the project was removed. Returns false with `error` set
+// when `name == "default"` (refused), when the project doesn't exist, or
+// on filesystem error.
+//
+// Stage F: does NOT close any in-use LmdbEnv before removal — Stage B
+// owns that coordination once per-project envs become real.
+inline bool dropProject(const std::filesystem::path& dataDir,
+                        const std::string& name,
+                        std::string& error) {
+    namespace fs = std::filesystem;
+    error.clear();
+    if (name == kDefaultProject) {
+        error = "cannot drop default project";
+        return false;
+    }
+
+    const fs::path projectDir = dataDir / "projects" / name;
+    std::error_code ec;
+    if (!fs::is_directory(projectDir, ec)) {
+        error = "project not found: '" + name + "'";
+        return false;
+    }
+
+    fs::remove_all(projectDir, ec);
+    if (ec) {
+        error = "failed to remove project directory '" + projectDir.string() +
+                "': " + ec.message();
+        return false;
+    }
+    return true;
+}
+
+}  // namespace smartbotic::database::project_fs_placeholder

+ 11 - 0
tests/CMakeLists.txt

@@ -492,3 +492,14 @@ target_include_directories(test_project_addressing PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
 )
 add_test(NAME project_addressing COMMAND test_project_addressing)
+
+# v2.3 Stage F — project CRUD placeholders (header-only filesystem helpers;
+# no link deps beyond std::filesystem). Exercises listProjects /
+# createProject / dropProject against a tmp data dir. Stage B replaces the
+# placeholder with a project-registry-backed implementation; this test
+# will be retargeted then.
+add_executable(test_project_crud test_project_crud.cpp)
+target_include_directories(test_project_crud PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src
+)
+add_test(NAME project_crud COMMAND test_project_crud)

+ 196 - 0
tests/test_project_crud.cpp

@@ -0,0 +1,196 @@
+// v2.3 Stage F — project CRUD placeholder tests.
+//
+// The placeholder is filesystem-only (under <dataDir>/projects/) and the
+// helpers live in `service/src/project_fs_placeholder.hpp` as header-only
+// inline functions. DatabaseService::{listProjects, createProject,
+// dropProject} are thin wrappers that take a mutex and delegate. Testing
+// the helpers directly exercises the same code path without dragging in
+// the full DatabaseService link surface (memory_store, persistence,
+// encryption, grpc, etc.) — same approach test_project_addressing takes
+// for the parser. Stage B replaces this whole helper with the real
+// ProjectStore-backed implementation.
+
+#include "project_fs_placeholder.hpp"
+
+#include <algorithm>
+#include <cstdlib>
+#include <filesystem>
+#include <iostream>
+#include <string>
+#include <unistd.h>  // getpid
+
+namespace fs = std::filesystem;
+namespace placeholder = smartbotic::database::project_fs_placeholder;
+using smartbotic::database::kDefaultProject;
+
+namespace {
+
+int g_pass = 0;
+int g_fail = 0;
+
+void check(bool cond, const char* msg) {
+    if (cond) ++g_pass;
+    else { ++g_fail; std::cerr << "FAIL: " << msg << "\n"; }
+}
+
+fs::path makeTmpDataDir(const std::string& tag) {
+    auto dir = fs::temp_directory_path() /
+               ("project-crud-" + tag + "-" + std::to_string(::getpid()));
+    fs::remove_all(dir);
+    fs::create_directories(dir);
+    return dir;
+}
+
+bool contains(const std::vector<std::string>& v, const std::string& s) {
+    return std::find(v.begin(), v.end(), s) != v.end();
+}
+
+// ---- Tests ----
+
+// listProjects() on a fresh dataDir returns just {"default"} (the default
+// project is implicit until the first write).
+void test_list_on_fresh_data_dir() {
+    auto dir = makeTmpDataDir("list-fresh");
+    auto names = placeholder::listProjects(dir);
+    check(names.size() == 1, "fresh dataDir: exactly one project");
+    check(!names.empty() && names[0] == kDefaultProject,
+          "fresh dataDir: that project is 'default'");
+    fs::remove_all(dir);
+}
+
+// createProject("foo") returns true the first time, then listProjects
+// includes "foo".
+void test_create_then_list_includes() {
+    auto dir = makeTmpDataDir("create-list");
+    std::string error;
+    bool created = placeholder::createProject(dir, "foo", error);
+    check(created, "createProject('foo') returns true on first call");
+    check(error.empty(), "createProject('foo') sets no error on success");
+
+    auto names = placeholder::listProjects(dir);
+    check(contains(names, "foo"), "listProjects includes 'foo' after create");
+    check(contains(names, kDefaultProject),
+          "listProjects still includes 'default' after create");
+    check(std::is_sorted(names.begin(), names.end()),
+          "listProjects returns sorted list");
+    fs::remove_all(dir);
+}
+
+// createProject is idempotent: second call returns false, no error.
+void test_create_idempotent() {
+    auto dir = makeTmpDataDir("create-idempotent");
+    std::string error;
+    bool first = placeholder::createProject(dir, "foo", error);
+    check(first, "first createProject('foo') returns true");
+
+    error = "stale";
+    bool second = placeholder::createProject(dir, "foo", error);
+    check(!second, "second createProject('foo') returns false (idempotent)");
+    check(error.empty(),
+          "second createProject('foo') leaves error EMPTY (no failure)");
+    fs::remove_all(dir);
+}
+
+// createProject("_bad") — leading underscore is reserved.
+void test_create_rejects_underscore_prefix() {
+    auto dir = makeTmpDataDir("create-underscore");
+    std::string error;
+    bool created = placeholder::createProject(dir, "_bad", error);
+    check(!created, "createProject('_bad') returns false");
+    check(!error.empty(), "createProject('_bad') sets a non-empty error");
+    // Sanity: the error message should mention the name so operators
+    // know which input was rejected.
+    check(error.find("_bad") != std::string::npos,
+          "createProject('_bad') error mentions the offending name");
+    fs::remove_all(dir);
+}
+
+// createProject("9bad") — leading digit is invalid.
+void test_create_rejects_digit_prefix() {
+    auto dir = makeTmpDataDir("create-digit");
+    std::string error;
+    bool created = placeholder::createProject(dir, "9bad", error);
+    check(!created, "createProject('9bad') returns false");
+    check(!error.empty(), "createProject('9bad') sets a non-empty error");
+    check(error.find("9bad") != std::string::npos,
+          "createProject('9bad') error mentions the offending name");
+    fs::remove_all(dir);
+}
+
+// dropProject("default") is refused — operator must never lose the
+// default namespace.
+void test_drop_default_refused() {
+    auto dir = makeTmpDataDir("drop-default");
+    std::string error;
+    bool dropped = placeholder::dropProject(dir, kDefaultProject, error);
+    check(!dropped, "dropProject('default') returns false");
+    check(!error.empty(), "dropProject('default') sets a non-empty error");
+    check(error.find("default") != std::string::npos,
+          "dropProject('default') error mentions 'default'");
+    fs::remove_all(dir);
+}
+
+// dropProject of a real project succeeds; subsequent listProjects no
+// longer includes it.
+void test_drop_then_list_excludes() {
+    auto dir = makeTmpDataDir("drop-then-list");
+    std::string error;
+    bool created = placeholder::createProject(dir, "foo", error);
+    check(created, "setup: createProject('foo') returns true");
+
+    error = "stale";
+    bool dropped = placeholder::dropProject(dir, "foo", error);
+    check(dropped, "dropProject('foo') returns true");
+    check(error.empty(), "dropProject('foo') sets no error on success");
+
+    auto names = placeholder::listProjects(dir);
+    check(!contains(names, "foo"),
+          "listProjects no longer includes 'foo' after drop");
+    check(names.size() == 1 && names[0] == kDefaultProject,
+          "listProjects returns just ['default'] after drop");
+    fs::remove_all(dir);
+}
+
+// dropProject of an unknown project returns false with error.
+void test_drop_nonexistent_refused() {
+    auto dir = makeTmpDataDir("drop-nonexistent");
+    std::string error;
+    bool dropped = placeholder::dropProject(dir, "nonexistent", error);
+    check(!dropped, "dropProject('nonexistent') returns false");
+    check(!error.empty(),
+          "dropProject('nonexistent') sets a non-empty error");
+    check(error.find("nonexistent") != std::string::npos ||
+              error.find("not found") != std::string::npos,
+          "dropProject('nonexistent') error indicates the missing name");
+    fs::remove_all(dir);
+}
+
+// Additional sanity — directory structure matches the documented layout:
+// <dataDir>/projects/<name>/env/
+void test_create_writes_documented_layout() {
+    auto dir = makeTmpDataDir("layout-check");
+    std::string error;
+    bool created = placeholder::createProject(dir, "alpha", error);
+    check(created, "setup: createProject('alpha') returns true");
+    check(fs::is_directory(dir / "projects" / "alpha" / "env"),
+          "<dataDir>/projects/alpha/env/ exists after createProject");
+    fs::remove_all(dir);
+}
+
+}  // namespace
+
+int main() {
+    test_list_on_fresh_data_dir();
+    test_create_then_list_includes();
+    test_create_idempotent();
+    test_create_rejects_underscore_prefix();
+    test_create_rejects_digit_prefix();
+    test_drop_default_refused();
+    test_drop_then_list_excludes();
+    test_drop_nonexistent_refused();
+    test_create_writes_documented_layout();
+
+    std::cout << "project_crud: " << g_pass << " passed, "
+              << g_fail << " failed\n";
+    return g_fail == 0 ? 0 : 1;
+}