diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 85dc42e7..e5734297 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -587,12 +587,45 @@ the new support directory rather than reusing the parent's explicit override. The parent's environment and other inherited variables remain unchanged. The native New Workspace sheet shows the resolved directory, a separate-window diagram titled with the typed name, and workspace-isolation details. Invalid or -colliding names stay inline with Create & Open disabled; an empty draft stays -quiet, and Cancel discards it. Ctrl+A selects the complete focused name field +colliding names stay inline with Create & Open disabled. Only a literal empty +draft stays quiet; a nonempty whitespace-only draft reports +"Give the workspace a name — letters and numbers." Cancel discards the draft. +The diagram trims Foundation `.whitespaces`, including zero-width space, but +not `.newlines`; trimming affects only the preview, never the raw name passed +to validation or creation. Ctrl+A selects the complete focused name field without changing ordinary typing or AltGr handling. Creation and launch still -use the existing workspace lifecycle and identity guards. Windows keeps its -existing ASCII normalization and 48-character limit rather than adopting the -macOS transliteration and truncation rules. +use the existing workspace lifecycle and identity guards. + +New workspace names follow macOS's operation order: transliterate to Latin, +strip nonspacing diacritics, lowercase, map characters other than ASCII letters +and numbers to separators, collapse separators, and take the first 32 characters. The cut +can end in a hyphen. Preview, submit-time validation, and creation share this +creation-only rule, including file/directory collisions after transliteration +or truncation. Windows absolute-path validation and filesystem errors still +apply; the Darwin socket-path limit does not apply to Windows named pipes. +Inline collision errors and the submit-to-create race status identify the actual +creation slug: "A workspace named cafe-zurich already exists." These messages +own their storage and propagate allocation failures; operational and +Windows-specific path errors keep their existing failure routes. +The native implementation uses Windows' built-in ICU C API, loaded only from +System32 (the combined DLL is available since Windows 10 1903, before the +Windows 11 baseline and Server 2022/2025 CI hosts). Missing ICU, missing exports, +or failed transforms report an operational error, never an ASCII fallback. +The 27 checked naming fixtures were produced by compiling the actual shared +`Workspace.slug` method with pinned Swift/Foundation 6.3.3. They establish finite +fixture equivalence, not equality for every Unicode character across OS/ICU +versions, nor a live native input or paired macOS visual qualification. +The actual macOS preview method and shared naming-error enum were also compiled +with that pinned Foundation: 12 preview/validation examples and an exhaustive +19-scalar `.whitespaces` set agree with the existing `SketchPromotion.trim` +helper used by this sheet. Headless tests separately retain newlines, refuse +invalid drafts without creating directories, and cover owned-message and preview +lifetimes under allocation failure. This does not establish live input, glyph +rendering, or native dialog qualification. +Existing directories retain their original names, paths, identities, and +discovery/activation behavior; nothing is migrated or renamed. Explicit Rename +keeps its existing ASCII normalization and 48-character limit. + Selecting the current workspace does nothing; selecting an identified running workspace restores its exact window rather than the first GraphCode window. `Ctrl+Alt+PageUp` / `Ctrl+Alt+PageDown` and Previous/Next Workspace cycle only diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 57493cdf..201b7bb5 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -10189,12 +10189,22 @@ pub const App = struct { defer result.deinit(self.allocator); if (!self.ensureWorkspaceIdentity()) return; var workspace = WorkspaceLifecycle.create(self.allocator, result.name, home) catch |err| { - self.setStatus(switch (err) { - error.EmptyName => "Workspace name is required", - error.NameTooLong => "Workspace name is too long", - error.NameTaken, error.PathAlreadyExists => "That workspace already exists", - else => "Workspace could not be created", - }); + const problem: ?WorkspaceLifecycle.NameError = switch (err) { + error.EmptyName => error.EmptyName, + error.InvalidName => error.InvalidName, + error.NameTooLong => error.NameTooLong, + error.NameTaken, error.PathAlreadyExists => error.NameTaken, + else => null, + }; + if (problem) |name_problem| { + const message = WorkspaceLifecycle.nameProblemMessage(self.allocator, name_problem, result.name) catch |message_error| { + std.log.err("Workspace creation failed ({s}); naming error message failed: {s}", .{ @errorName(err), @errorName(message_error) }); + self.setStatus("Workspace name error could not be displayed"); + return; + }; + defer self.allocator.free(message); + self.setStatus(message); + } else self.setStatus("Workspace could not be created"); return; }; defer workspace.deinit(self.allocator); diff --git a/graphcode-windows/src/NewWorkspaceForm.zig b/graphcode-windows/src/NewWorkspaceForm.zig index 9394d9fa..38464753 100644 --- a/graphcode-windows/src/NewWorkspaceForm.zig +++ b/graphcode-windows/src/NewWorkspaceForm.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Lifecycle = @import("WorkspaceLifecycle.zig"); +const SketchPromotion = @import("SketchPromotion.zig"); const Sheet = @import("SheetFramework.zig"); const Dpi = @import("Dpi.zig"); const Win32 = @import("Win32.zig"); @@ -22,40 +23,47 @@ pub const State = struct { allocator: std.mem.Allocator, home: []const u8, current_name: []const u8, - validator: Validator = &Lifecycle.validateName, + validator: Validator = &Lifecycle.validateCreationName, draft: []u8 = &.{}, path: []u8 = &.{}, - problem: ?[]const u8 = null, + problem: ?[]u8 = null, + preview: []const u8 = "Client Work", pub fn deinit(self: *State) void { self.allocator.free(self.draft); self.allocator.free(self.path); + if (self.problem) |problem| self.allocator.free(problem); } pub fn update(self: *State, input: []const u8) !void { const draft = try self.allocator.dupe(u8, input); errdefer self.allocator.free(draft); + const typed = try SketchPromotion.trim(draft, false); var path: []u8 = &.{}; errdefer self.allocator.free(path); - var problem: ?[]const u8 = null; - if (std.mem.trim(u8, input, " \t\r\n").len != 0) { + var problem: ?[]u8 = null; + errdefer if (problem) |message| self.allocator.free(message); + if (input.len != 0) { if (self.validator(self.allocator, input, self.home)) |normalized| { defer self.allocator.free(normalized); path = try Lifecycle.workspacePath(self.allocator, normalized, self.home); } else |err| { - problem = switch (err) { - error.EmptyName, error.InvalidName => "Use letters or numbers in the workspace name", - error.NameTooLong => "Workspace name is too long", - error.NameTaken, error.PathAlreadyExists => "That workspace already exists", + problem = try Lifecycle.nameProblemMessage(self.allocator, switch (err) { + error.EmptyName => error.EmptyName, + error.InvalidName => error.InvalidName, + error.NameTooLong => error.NameTooLong, + error.NameTaken, error.PathAlreadyExists => error.NameTaken, else => return err, - }; + }, input); } } self.allocator.free(self.draft); self.allocator.free(self.path); + if (self.problem) |message| self.allocator.free(message); self.draft = draft; self.path = path; self.problem = problem; + self.preview = if (typed.len == 0) "Client Work" else typed; } pub fn canCreate(self: *const State) bool { @@ -63,8 +71,7 @@ pub const State = struct { } pub fn previewName(self: *const State) []const u8 { - const typed = std.mem.trim(u8, self.draft, " \t\r\n"); - return if (typed.len == 0) "Client Work" else typed; + return self.preview; } pub fn submit(self: *State) !?Result { @@ -510,7 +517,7 @@ const TestValidation = struct { calls += 1; if (failure) return error.AccessDenied; if (collision) return error.NameTaken; - return Lifecycle.normalizeName(allocator, input); + return Lifecycle.normalizeCreationName(allocator, input); } fn state() State { @@ -524,7 +531,7 @@ const TestValidation = struct { test "new workspace empty draft is quiet and cannot submit" { var state = TestValidation.state(); defer state.deinit(); - try state.update(" "); + try state.update(""); try std.testing.expect(state.problem == null); try std.testing.expect(!state.canCreate()); try std.testing.expectEqualStrings("Client Work", state.previewName()); @@ -533,6 +540,151 @@ test "new workspace empty draft is quiet and cannot submit" { try std.testing.expectEqual(@as(usize, 0), TestValidation.calls); } +test "new workspace naming whitespace parity matches actual Foundation preview and raw validation" { + const cases = [_]struct { input: []const u8, preview: []const u8, empty_slug: bool }{ + .{ .input = "", .preview = "Client Work", .empty_slug = true }, + .{ .input = " ", .preview = "Client Work", .empty_slug = true }, + .{ .input = "\t", .preview = "Client Work", .empty_slug = true }, + .{ .input = "\n", .preview = "\n", .empty_slug = true }, + .{ .input = "\r\n", .preview = "\r\n", .empty_slug = true }, + .{ .input = "\u{85}", .preview = "\u{85}", .empty_slug = true }, + .{ .input = "\u{200b}", .preview = "Client Work", .empty_slug = true }, + .{ .input = "\u{200b}Café\u{200b}", .preview = "Café", .empty_slug = false }, + .{ .input = "\u{a0}\u{2000}Client Work\u{3000}\t", .preview = "Client Work", .empty_slug = false }, + .{ .input = "\u{2028}Café\u{2029}", .preview = "\u{2028}Café\u{2029}", .empty_slug = false }, + .{ .input = "\u{202f}東京\u{205f}", .preview = "東京", .empty_slug = false }, + .{ .input = "\u{180e}Café\u{feff}", .preview = "\u{180e}Café\u{feff}", .empty_slug = false }, + }; + try std.testing.expectEqual(@as(usize, 12), cases.len); + for (cases) |case| { + var state = TestValidation.state(); + defer state.deinit(); + try state.update(case.input); + try std.testing.expectEqualStrings(case.input, state.draft); + try std.testing.expectEqualStrings(case.preview, state.previewName()); + if (case.empty_slug) { + try std.testing.expect(!state.canCreate()); + try std.testing.expectEqualStrings("", state.path); + if (case.input.len == 0) { + try std.testing.expect(state.problem == null); + } else { + try std.testing.expect(state.problem != null); + try std.testing.expectEqualStrings("Give the workspace a name — letters and numbers.", state.problem.?); + } + try std.testing.expect((try state.submit()) == null); + } else { + try std.testing.expect(state.canCreate()); + try std.testing.expect(state.problem == null); + var result = (try state.submit()).?; + defer result.deinit(std.testing.allocator); + try std.testing.expectEqualStrings(case.input, result.name); + } + try std.testing.expectEqual(@as(usize, if (case.input.len == 0) 0 else 2), TestValidation.calls); + } +} + +test "new workspace naming whitespace parity trims all19Foundation whitespace scalars but not newlines" { + const spaces = [_]u21{ 0x09, 0x20, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x200b, 0x202f, 0x205f, 0x3000 }; + try std.testing.expectEqual(@as(usize, 19), spaces.len); + for (spaces) |scalar| { + var encoded: [4]u8 = undefined; + const count = try std.unicode.utf8Encode(scalar, &encoded); + var buffer: [64]u8 = undefined; + const input = try std.fmt.bufPrint(&buffer, "{s}Client Work{s}", .{ encoded[0..count], encoded[0..count] }); + var state = TestValidation.state(); + defer state.deinit(); + try state.update(input); + try std.testing.expectEqualStrings("Client Work", state.previewName()); + try std.testing.expect(state.canCreate()); + } + for ([_]u21{ 0x0a, 0x0b, 0x0c, 0x0d, 0x85, 0x2028, 0x2029, 0x180e, 0xfeff }) |scalar| { + var encoded: [4]u8 = undefined; + const count = try std.unicode.utf8Encode(scalar, &encoded); + var buffer: [64]u8 = undefined; + const input = try std.fmt.bufPrint(&buffer, "{s}Client Work{s}", .{ encoded[0..count], encoded[0..count] }); + var state = TestValidation.state(); + defer state.deinit(); + try state.update(input); + try std.testing.expectEqualStrings(input, state.previewName()); + try std.testing.expect(state.canCreate()); + } +} + +test "new workspace naming whitespace parity reports submit create races with actual normalized slug" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var state = State{ .allocator = allocator, .home = home, .current_name = "Default" }; + defer state.deinit(); + try state.update("Café Zürich"); + var result = (try state.submit()).?; + defer result.deinit(allocator); + var other = try Lifecycle.create(allocator, "CAFE ZURICH", home); + defer other.deinit(allocator); + try std.testing.expectError(error.NameTaken, Lifecycle.create(allocator, result.name, home)); + try state.update(result.name); + try std.testing.expectEqualStrings("A workspace named cafe-zurich already exists.", state.problem.?); + const race_message = try Lifecycle.nameProblemMessage(allocator, error.NameTaken, result.name); + defer allocator.free(race_message); + try std.testing.expectEqualStrings(state.problem.?, race_message); + try std.testing.expect(!state.canCreate()); + try std.testing.expectEqualStrings("", state.path); + try std.testing.expect((try state.submit()) == null); +} + +test "new workspace naming whitespace parity owns preview problem and collision lifetimes on allocation failure" { + const Probe = struct { + fn run(allocator: std.mem.Allocator) !void { + TestValidation.collision = false; + TestValidation.failure = false; + var state = State{ .allocator = allocator, .home = "C:\\OwnedHome", .current_name = "Default", .validator = &TestValidation.validate }; + defer state.deinit(); + try state.update("\u{200b}Client Work\u{200b}"); + try std.testing.expectEqualStrings("Client Work", state.previewName()); + try state.update(" \t"); + try std.testing.expect(state.problem != null); + TestValidation.collision = true; + try state.update("東京"); + try std.testing.expectEqualStrings("A workspace named dong-jing already exists.", state.problem.?); + try state.update(""); + try std.testing.expect(state.problem == null); + try std.testing.expectEqualStrings("Client Work", state.previewName()); + try std.testing.expect(!state.canCreate()); + } + }; + defer TestValidation.collision = false; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + +test "new workspace naming whitespace parity invalid drafts never create directories or rewrite valid state" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var state = State{ .allocator = allocator, .home = home, .current_name = "Default" }; + defer state.deinit(); + for ([_][]const u8{ "", " \t", "\r\n", "\u{200b}", "///" }) |input| { + try state.update(input); + try std.testing.expect((try state.submit()) == null); + try std.testing.expect(!state.canCreate()); + var directory = try std.fs.openDirAbsolute(home, .{ .iterate = true }); + defer directory.close(); + var iterator = directory.iterate(); + try std.testing.expect((try iterator.next()) == null); + } + try state.update("Valid"); + const path = try allocator.dupe(u8, state.path); + defer allocator.free(path); + try std.testing.expectError(error.InvalidUtf8, state.update("\xff")); + try std.testing.expectEqualStrings("Valid", state.draft); + try std.testing.expectEqualStrings("Valid", state.previewName()); + try std.testing.expectEqualStrings(path, state.path); + try std.testing.expect(state.canCreate()); +} + test "new workspace typed title and resolved directory remain distinct" { var state = TestValidation.state(); defer state.deinit(); @@ -565,7 +717,7 @@ test "new workspace collision at submit stays inline without a creation result" TestValidation.collision = true; try std.testing.expect((try state.submit()) == null); try std.testing.expect(state.problem != null); - try std.testing.expectEqualStrings("That workspace already exists", state.problem.?); + try std.testing.expectEqualStrings("A workspace named client-work already exists.", state.problem.?); try std.testing.expect(!state.canCreate()); TestValidation.collision = false; try state.update("Client Work 2"); @@ -580,15 +732,16 @@ test "new workspace validation operational failures propagate" { try std.testing.expectError(error.AccessDenied, state.update("Client Work")); } -test "new workspace name limit follows existing lifecycle without truncation" { +test "new workspace creation name truncates without rejecting the typed title" { var state = TestValidation.state(); defer state.deinit(); - try state.update(&(.{'a'} ** Lifecycle.max_name_length)); + try state.update(&(.{'a'} ** Lifecycle.max_creation_name_length)); try std.testing.expect(state.canCreate()); - try state.update(&(.{'a'} ** (Lifecycle.max_name_length + 1))); - try std.testing.expect(state.problem != null); - try std.testing.expectEqualStrings("Workspace name is too long", state.problem.?); - try std.testing.expect(!state.canCreate()); + try state.update(&(.{'a'} ** (Lifecycle.max_creation_name_length + 1))); + try std.testing.expect(state.problem == null); + try std.testing.expect(state.canCreate()); + try std.testing.expectEqualStrings("C:\\OwnedHome\\.graphcode-" ++ "a" ** 32, state.path); + try std.testing.expectEqual(@as(usize, 33), state.previewName().len); } test "new workspace layout keeps fields and diagram separate at supported DPIs" { @@ -626,17 +779,42 @@ test "new workspace create cancel and editing notifications route without accide try std.testing.expectEqual(CommandAction.ignore, commandAction(9999, 0)); } -test "new workspace presentation preserves Unicode and ampersands without changing directory rules" { +test "new workspace presentation preserves Unicode and ampersands with creation transliteration" { var state = TestValidation.state(); defer state.deinit(); try state.update("Caf\xc3\xa9 R&D"); try std.testing.expectEqualStrings("Caf\xc3\xa9 R&D", state.previewName()); - try std.testing.expectEqualStrings("C:\\OwnedHome\\.graphcode-caf-r-d", state.path); + try std.testing.expectEqualStrings("C:\\OwnedHome\\.graphcode-cafe-r-d", state.path); var result = (try state.submit()).?; defer result.deinit(std.testing.allocator); try std.testing.expectEqualStrings("Caf\xc3\xa9 R&D", result.name); } +test "new workspace production validator preview submit and create agree on Unicode and truncation" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var state = State{ .allocator = allocator, .home = home, .current_name = "Legacy-" ++ "x" ** 48 }; + defer state.deinit(); + for ([_][]const u8{ "Café Zürich", "東京", "a" ** 31 ++ " bb" }) |input| { + try state.update(input); + try std.testing.expectEqualStrings(input, state.previewName()); + try std.testing.expect(state.canCreate()); + var result = (try state.submit()).?; + defer result.deinit(allocator); + var workspace = try Lifecycle.create(allocator, result.name, home); + defer workspace.deinit(allocator); + try std.testing.expectEqualStrings(workspace.path, state.path); + try std.testing.expect((try state.submit()) == null); + const expected = try std.fmt.allocPrint(allocator, "A workspace named {s} already exists.", .{workspace.name}); + defer allocator.free(expected); + try std.testing.expectEqualStrings(expected, state.problem.?); + try std.testing.expectEqualStrings("", state.path); + } +} + test "new workspace state and handoff release every allocation on failure" { const Exercise = struct { fn run(allocator: std.mem.Allocator) !void { diff --git a/graphcode-windows/src/WorkspaceLifecycle.zig b/graphcode-windows/src/WorkspaceLifecycle.zig index 6bb01a79..541f11b0 100644 --- a/graphcode-windows/src/WorkspaceLifecycle.zig +++ b/graphcode-windows/src/WorkspaceLifecycle.zig @@ -3,6 +3,7 @@ const std = @import("std"); pub const directory_prefix = ".graphcode-"; pub const default_directory_name = ".graphcode"; pub const max_name_length: usize = 48; +pub const max_creation_name_length: usize = 32; pub const Workspace = struct { name: []const u8, @@ -131,6 +132,19 @@ pub const NameError = error{ NameTaken, }; +pub fn nameProblemMessage(allocator: std.mem.Allocator, problem: NameError, input: []const u8) ![]u8 { + return switch (problem) { + error.EmptyName => allocator.dupe(u8, "Give the workspace a name — letters and numbers."), + error.InvalidName => allocator.dupe(u8, "Use letters or numbers in the workspace name"), + error.NameTooLong => allocator.dupe(u8, "Workspace name is too long"), + error.NameTaken => message: { + const slug = try normalizeCreationName(allocator, input); + defer allocator.free(slug); + break :message std.fmt.allocPrint(allocator, "A workspace named {s} already exists.", .{slug}); + }, + }; +} + pub fn defaultPath(allocator: std.mem.Allocator) ![]u8 { const home = std.process.getEnvVarOwned(allocator, "USERPROFILE") catch |err| switch (err) { error.EnvironmentVariableNotFound => return error.UserProfileMissing, @@ -172,6 +186,134 @@ pub fn normalizeName(allocator: std.mem.Allocator, input: []const u8) ![]u8 { return result.toOwnedSlice(); } +const NamingIcu = struct { + library: std.DynLib, + open: *const fn ([*]const u16, i32, c_int, ?[*]const u16, i32, ?*anyopaque, *i32) callconv(.c) ?*anyopaque, + transform: *const fn (*const anyopaque, [*]u16, *i32, i32, i32, *i32, *i32) callconv(.c) void, + close: *const fn (*anyopaque) callconv(.c) void, + break_open: *const fn (c_int, [*:0]const u8, [*]const u16, i32, *i32) callconv(.c) ?*anyopaque, + break_next: *const fn (*anyopaque) callconv(.c) i32, + break_close: *const fn (*anyopaque) callconv(.c) void, + last_status: i32 = 0, + + fn init() !NamingIcu { + // ICU is an OS component on the supported Windows 11/Server 2022+ targets. + var library = std.DynLib{ .inner = .{ .dll = try std.os.windows.LoadLibraryExW( + std.unicode.utf8ToUtf16LeStringLiteral("icu.dll"), + .load_library_search_system32, + ) } }; + errdefer library.close(); + var result: NamingIcu = undefined; + inline for (.{ .{ "open", "utrans_openU" }, .{ "transform", "utrans_transUChars" }, .{ "close", "utrans_close" }, .{ "break_open", "ubrk_open" }, .{ "break_next", "ubrk_next" }, .{ "break_close", "ubrk_close" } }) |binding| { + @field(result, binding[0]) = library.lookup(@TypeOf(@field(result, binding[0])), binding[1]) orelse + return error.WorkspaceNamingIcuExportMissing; + } + result.library = library; + result.last_status = 0; + return result; + } + + fn latin(self: *NamingIcu, allocator: std.mem.Allocator, input: []const u16) ![]u16 { + const id = std.unicode.utf8ToUtf16LeStringLiteral("Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; Lower"); + var status: i32 = 0; + const transliterator = self.open(id, @intCast(id.len), 0, null, 0, null, &status); + self.last_status = status; + const handle = transliterator orelse return error.WorkspaceNamingTransformFailed; + defer self.close(handle); + if (status > 0) return error.WorkspaceNamingTransformFailed; + const max_length = std.math.maxInt(i32); + var capacity = std.math.add(usize, input.len, 1) catch return error.NameTooLong; + while (capacity <= max_length) { + const text = try allocator.alloc(u16, capacity); + defer allocator.free(text); + @memcpy(text[0..input.len], input); + var length: i32 = @intCast(input.len); + var limit = length; + status = 0; + self.transform(handle, text.ptr, &length, @intCast(capacity), 0, &limit, &status); + self.last_status = status; + if (status == 15) { // U_BUFFER_OVERFLOW_ERROR: retry the original input, not partial output. + capacity = @max(capacity * 2, @as(usize, @intCast(@max(length, 0))) + 1); + continue; + } + if (status > 0 or length < 0 or length > capacity) return error.WorkspaceNamingTransformFailed; + return allocator.dupe(u16, text[0..@intCast(length)]); + } + return error.NameTooLong; + } + + fn characters(self: *NamingIcu, plain: []const u16) !*anyopaque { + var status: i32 = 0; + const iterator = self.break_open(0, "", plain.ptr, @intCast(plain.len), &status); + self.last_status = status; + const handle = iterator orelse return error.WorkspaceNamingCharactersFailed; + if (status > 0) { + self.break_close(handle); + return error.WorkspaceNamingCharactersFailed; + } + return handle; + } +}; + +/// Creation follows Workspace.slug's transforms and Character mapping before prefix(32). +/// Legacy normalization remains separate for explicit rename; discovered paths never use either. +pub fn normalizeCreationName(allocator: std.mem.Allocator, input: []const u8) ![]u8 { + if (!std.unicode.utf8ValidateSlice(input)) return error.InvalidName; + var icu = NamingIcu.init() catch |err| { + std.log.err("Workspace Unicode naming ICU could not be loaded: {s}", .{@errorName(err)}); + return err; + }; + defer icu.library.close(); + const utf16 = try std.unicode.utf8ToUtf16LeAlloc(allocator, input); + defer allocator.free(utf16); + const plain = icu.latin(allocator, utf16) catch |err| { + if (err == error.WorkspaceNamingTransformFailed) + std.log.err("Workspace Unicode naming ICU transform failed: {d}", .{icu.last_status}); + return err; + }; + defer allocator.free(plain); + const iterator = icu.characters(plain) catch |err| { + std.log.err("Workspace Unicode naming ICU character iterator failed: {d}", .{icu.last_status}); + return err; + }; + defer icu.break_close(iterator); + var result: [max_creation_name_length]u8 = undefined; + var length: usize = 0; + var start: usize = 0; + var separator = false; + while (true) { + const end_signed = icu.break_next(iterator); + if (end_signed == -1) break; + const end: usize = @intCast(end_signed); + const character = plain[start..end]; + start = end; + if (character.len == 1 and character[0] < 128 and std.ascii.isAlphanumeric(@intCast(character[0]))) { + if (separator and length != 0) { + result[length] = '-'; + length += 1; + if (length == result.len) break; + } + result[length] = @intCast(character[0]); + length += 1; + if (length == result.len) break; + separator = false; + } else { + separator = true; + } + } + if (length == 0) return error.EmptyName; + return allocator.dupe(u8, result[0..length]); +} + +pub fn validateCreationName(allocator: std.mem.Allocator, input: []const u8, home: []const u8) ![]u8 { + const identity = try pathIdentity(allocator, home); + defer allocator.free(identity); + const name = try normalizeCreationName(allocator, input); + errdefer allocator.free(name); + try checkNameAvailable(allocator, name, home); + return name; +} + pub fn validateName( allocator: std.mem.Allocator, input: []const u8, @@ -179,10 +321,15 @@ pub fn validateName( ) ![]u8 { const name = try normalizeName(allocator, input); errdefer allocator.free(name); + try checkNameAvailable(allocator, name, home); + return name; +} + +fn checkNameAvailable(allocator: std.mem.Allocator, name: []const u8, home: []const u8) !void { const path = try workspacePath(allocator, name, home); defer allocator.free(path); std.fs.cwd().access(path, .{}) catch |err| switch (err) { - error.FileNotFound => return name, + error.FileNotFound => return, else => return err, }; return NameError.NameTaken; @@ -197,7 +344,7 @@ pub fn workspacePath( } pub fn create(allocator: std.mem.Allocator, input: []const u8, home: []const u8) !Workspace { - const name = try validateName(allocator, input, home); + const name = try validateCreationName(allocator, input, home); errdefer allocator.free(name); const path = try workspacePath(allocator, name, home); errdefer allocator.free(path); @@ -361,6 +508,212 @@ test "workspace names normalize to safe stable directory suffixes" { try std.testing.expectEqualStrings("caf-z-rich", name); } +test "workspace creation naming parity matches pinned Foundation oracle fixtures" { + const cases = [_]struct { input: []const u8, slug: []const u8 }{ + .{ .input = "", .slug = "" }, + .{ .input = "///", .slug = "" }, + .{ .input = "Client Work", .slug = "client-work" }, + .{ .input = " spaced out ", .slug = "spaced-out" }, + .{ .input = "OSS/side—projects!", .slug = "oss-side-projects" }, + .{ .input = "Café Zürich", .slug = "cafe-zurich" }, + .{ .input = "Cafe\u{301} Zu\u{308}rich", .slug = "cafe-zurich" }, + .{ .input = "Москва", .slug = "moskva" }, + .{ .input = "東京", .slug = "dong-jing" }, + .{ .input = "中文", .slug = "zhong-wen" }, + .{ .input = "Αθήνα", .slug = "athena" }, + .{ .input = "العربية", .slug = "al-rbyt" }, + .{ .input = "नमस्ते", .slug = "namaste" }, + .{ .input = "한국어", .slug = "hangug-eo" }, + .{ .input = "Łódź Straße Æsir Øresund", .slug = "odz-stra-e-sir-resund" }, + .{ .input = "İ I ı", .slug = "i-i" }, + .{ .input = "ABC 123", .slug = "" }, + .{ .input = "work👩‍💻space", .slug = "work-space" }, + .{ .input = "a\u{93e}b", .slug = "aab" }, + .{ .input = "a\u{200d}b", .slug = "b" }, + .{ .input = "1️⃣ team", .slug = "team" }, + .{ .input = "CON", .slug = "con" }, + .{ .input = "..\\..//Outside workspace", .slug = "outside-workspace" }, + .{ .input = "a" ** 31 ++ " bb", .slug = "a" ** 31 ++ "-" }, + .{ .input = "a" ** 32 ++ "bb", .slug = "a" ** 32 }, + .{ .input = "long " ** 40, .slug = "long-long-long-long-long-long-lo" }, + .{ .input = "🚀", .slug = "" }, + }; + try std.testing.expectEqual(@as(usize, 27), cases.len); + for (cases) |case| { + if (case.slug.len == 0) { + try std.testing.expectError(error.EmptyName, normalizeCreationName(std.testing.allocator, case.input)); + } else { + const slug = try normalizeCreationName(std.testing.allocator, case.input); + defer std.testing.allocator.free(slug); + try std.testing.expectEqualStrings(case.slug, slug); + } + } +} + +test "workspace creation naming parity detects transliteration truncation and file collisions" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + try temporary.dir.makeDir(".graphcode-cafe-zurich"); + try temporary.dir.makeDir(".graphcode-" ++ "a" ** 31 ++ "-"); + try temporary.dir.writeFile(.{ .sub_path = ".graphcode-moskva", .data = "untouched" }); + for ([_][]const u8{ "Café Zürich", "CAFÉ ZÜRICH", "a" ** 31 ++ " bb", "Москва" }) |input| { + if (create(allocator, input, home)) |workspace| { + var unexpected = workspace; + unexpected.deinit(allocator); + return error.ExpectedNameCollision; + } else |err| try std.testing.expectEqual(error.NameTaken, err); + } + const untouched = try temporary.dir.readFileAlloc(allocator, ".graphcode-moskva", 100); + defer allocator.free(untouched); + try std.testing.expectEqualStrings("untouched", untouched); +} + +test "workspace creation naming parity retains Windows path safety and rejects malformed UTF8" { + const allocator = std.testing.allocator; + for ([_][]const u8{ "\xff", "\xc3", "\xed\xa0\x80" }) |input| + try std.testing.expectError(error.InvalidName, normalizeCreationName(allocator, input)); + for ([_][]const u8{ "", "relative", "C:relative", "\\relative", "C:\\bad\x00home", "C:\\\xff" }) |home| + try std.testing.expectError(error.InvalidWorkspacePath, validateCreationName(allocator, "Valid", home)); + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + for ([_][]const u8{ "CON", "PRN", "AUX", "NUL", "COM1", "LPT1", "../outside", "valid\x00name" }) |input| { + var workspace = try create(allocator, input, home); + defer workspace.deinit(allocator); + try std.testing.expect(std.mem.startsWith(u8, workspace.path, home)); + try std.testing.expect(std.mem.startsWith(u8, std.fs.path.basenameWindows(workspace.path), directory_prefix)); + try std.testing.expect(std.mem.indexOfScalar(u8, workspace.name, 0) == null); + } +} + +test "workspace creation naming parity owns transform expansion and all allocation failures" { + const Probe = struct { + fn run(allocator: std.mem.Allocator) !void { + const name = try normalizeCreationName(allocator, "東京 Café " ** 40); + defer allocator.free(name); + try std.testing.expectEqual(@as(usize, 32), name.len); + try std.testing.expect(std.mem.startsWith(u8, name, "dong-jing-cafe-")); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + +test "workspace creation naming parity closes ICU failure handles and retains actual status" { + const Probe = struct { + var fail_open: bool = true; + var closed: usize = 0; + var break_closed: usize = 0; + + fn open(_: [*]const u16, _: i32, _: c_int, _: ?[*]const u16, _: i32, _: ?*anyopaque, status: *i32) callconv(.c) ?*anyopaque { + status.* = if (fail_open) 16 else 0; + return @ptrFromInt(1); + } + fn transform(_: *const anyopaque, _: [*]u16, _: *i32, _: i32, _: i32, _: *i32, status: *i32) callconv(.c) void { + status.* = 7; + } + fn close(handle: *anyopaque) callconv(.c) void { + std.debug.assert(@intFromPtr(handle) == 1); + closed += 1; + } + fn breakOpen(_: c_int, _: [*:0]const u8, _: [*]const u16, _: i32, status: *i32) callconv(.c) ?*anyopaque { + status.* = 2; + return @ptrFromInt(2); + } + fn breakNext(_: *anyopaque) callconv(.c) i32 { + unreachable; + } + fn breakClose(handle: *anyopaque) callconv(.c) void { + std.debug.assert(@intFromPtr(handle) == 2); + break_closed += 1; + } + }; + Probe.closed = 0; + Probe.break_closed = 0; + Probe.fail_open = true; + var icu = NamingIcu{ + .library = undefined, + .open = &Probe.open, + .transform = &Probe.transform, + .close = &Probe.close, + .break_open = &Probe.breakOpen, + .break_next = &Probe.breakNext, + .break_close = &Probe.breakClose, + }; + try std.testing.expectError(error.WorkspaceNamingTransformFailed, icu.latin(std.testing.allocator, &.{'a'})); + try std.testing.expectEqual(@as(usize, 1), Probe.closed); + try std.testing.expectEqual(@as(i32, 16), icu.last_status); + Probe.fail_open = false; + try std.testing.expectError(error.WorkspaceNamingTransformFailed, icu.latin(std.testing.allocator, &.{'a'})); + try std.testing.expectEqual(@as(usize, 2), Probe.closed); + try std.testing.expectEqual(@as(i32, 7), icu.last_status); + try std.testing.expectError(error.WorkspaceNamingCharactersFailed, icu.characters(&.{'a'})); + try std.testing.expectEqual(@as(usize, 1), Probe.break_closed); + try std.testing.expectEqual(@as(i32, 2), icu.last_status); +} + +test "workspace creation naming messages use macOS empty and actual creation collision slugs" { + const cases = [_]struct { problem: NameError, input: []const u8, message: []const u8 }{ + .{ .problem = error.EmptyName, .input = " \t", .message = "Give the workspace a name — letters and numbers." }, + .{ .problem = error.NameTaken, .input = "Café Zürich", .message = "A workspace named cafe-zurich already exists." }, + .{ .problem = error.NameTaken, .input = "a" ** 31 ++ " bb", .message = "A workspace named " ++ "a" ** 31 ++ "- already exists." }, + .{ .problem = error.InvalidName, .input = "\xff", .message = "Use letters or numbers in the workspace name" }, + .{ .problem = error.NameTooLong, .input = "long", .message = "Workspace name is too long" }, + }; + for (cases) |case| { + const message = try nameProblemMessage(std.testing.allocator, case.problem, case.input); + defer std.testing.allocator.free(message); + try std.testing.expectEqualStrings(case.message, message); + } +} + +test "workspace creation naming messages release every allocation and propagate failures" { + const Probe = struct { + fn run(allocator: std.mem.Allocator) !void { + for ([_]NameError{ error.EmptyName, error.InvalidName, error.NameTooLong, error.NameTaken }) |problem| { + const message = try nameProblemMessage(allocator, problem, "東京 Café"); + defer allocator.free(message); + try std.testing.expect(message.len != 0); + } + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Probe.run, .{}); +} + +test "workspace creation naming parity preserves existing raw directory identities" { + const allocator = std.testing.allocator; + var temporary = std.testing.tmpDir(.{}); + defer temporary.cleanup(); + const legacy_name = "Legacy-" ++ "x" ** 48; + for ([_][]const u8{ "caf-z-rich", "Café Zürich", legacy_name }) |name| { + const directory_name = try std.fmt.allocPrint(allocator, "{s}{s}", .{ directory_prefix, name }); + defer allocator.free(directory_name); + try temporary.dir.makeDir(directory_name); + } + const home = try temporary.dir.realpathAlloc(allocator, "."); + defer allocator.free(home); + var before = try listFromHome(allocator, home); + defer before.deinit(allocator); + var created = try create(allocator, "Café Zürich", home); + defer created.deinit(allocator); + try std.testing.expectEqualStrings("cafe-zurich", created.name); + var after = try managerListFromHome(allocator, home, before.items[0].path); + defer after.deinit(allocator); + try std.testing.expectEqual(@as(usize, 4), before.items.len); + try std.testing.expectEqual(@as(usize, 5), after.items.len); + for (before.items) |original| { + for (after.items) |workspace| { + if (!std.mem.eql(u8, original.identity, workspace.identity)) continue; + try std.testing.expectEqualStrings(original.name, workspace.name); + try std.testing.expectEqualStrings(original.path, workspace.path); + break; + } else return error.ExistingWorkspaceIdentityLost; + } +} + test "workspace validation rejects long names" { var long_name: [max_name_length + 2]u8 = undefined; @memset(&long_name, 'x');