feat(projects): registry schema v2 — folder + archived fields

First of three v2.3 commits. Adds the data model + view-model plumbing
for folder grouping and soft-archive; no UI changes yet (sidebar still
renders a flat list).

ProjectEntry gains two optional fields:
- `folder: String?` — opaque single-level label for sidebar grouping;
  nil means top-level. Custom Codable decodeIfPresent so v2.2 registry
  files parse cleanly.
- `archived: Bool` — soft-delete flag; defaults to false via custom
  decoder. Archived projects stay on disk and in the registry; the
  v2.3 sidebar just hides them unless Show Archived is toggled on.

Custom encode(to:) omits both fields when they're at their default
values. Keeps registry files clean for the common (top-level,
unarchived) case and means v2.2 Scarf still loads a v2.3-written
registry of projects that never used the new features — forward +
backward compat by construction.

ProjectsViewModel grows four verbs:
- moveProject(_:toFolder:) — update the folder assignment
- renameProject(_:to:) — rename with duplicate-name + empty-name
  rejection; preserves selection across the rename so the user
  stays on the same project
- archiveProject(_:) — sets archived=true, clears selection if the
  archived project was selected (avoids lingering on a hidden view)
- unarchiveProject(_:) — sets archived=false; does NOT re-select
  (unhiding ≠ focusing)
- `folders: [String]` computed property — distinct folder labels,
  sorted, for the sidebar + move-to-folder sheet

Two new test suites:
- ProjectRegistryMigrationTests: round-trips v2.2 → v2.3 and back,
  asserts encoder cleanliness (defaults omitted), identity stability
  under folder / archive changes.
- ProjectsViewModelTests: verbs hit the real ~/.hermes/scarf/projects.json
  via TestRegistryLock for cross-suite serialization. Covers happy
  paths, duplicate / empty-name rename rejection, and folder dedup.

73/73 Swift tests pass (was 58; 15 new). No behavior change on v2.2
registry files yet — the sidebar UI lands in commit 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-23 22:57:02 +02:00
parent f366057cfd
commit f1e8f3070f
4 changed files with 453 additions and 0 deletions
@@ -11,7 +11,63 @@ struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
let name: String
let path: String
/// Folder path for sidebar grouping. `nil` means top-level (no
/// folder). Introduced in v2.3 v2.2 registry files have no
/// `folder` key, which decodes cleanly as `nil` via
/// `decodeIfPresent` below.
///
/// We leave shape flexible: today this is treated as an opaque
/// single-level label (e.g. "Clients"), and the sidebar renders
/// one DisclosureGroup per distinct value. If nesting becomes a
/// requirement later, we can interpret the string as a slash-
/// separated path without a migration (old single-label values
/// still mean a top-level folder with that name).
var folder: String?
/// Soft-archive flag. Archived projects are hidden from the
/// sidebar by default; a Show Archived toggle surfaces them.
/// Non-destructive nothing is deleted on disk. Introduced in
/// v2.3; v2.2 registry files default to `false` via the custom
/// decoder below.
var archived: Bool
var dashboardPath: String { path + "/.scarf/dashboard.json" }
init(name: String, path: String, folder: String? = nil, archived: Bool = false) {
self.name = name
self.path = path
self.folder = folder
self.archived = archived
}
// MARK: - Codable (custom for backward compat)
private enum CodingKeys: String, CodingKey {
case name, path, folder, archived
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.name = try c.decode(String.self, forKey: .name)
self.path = try c.decode(String.self, forKey: .path)
// Both new fields: tolerate absence for v2.2-era registries.
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(name, forKey: .name)
try c.encode(path, forKey: .path)
// Only emit optional fields when they carry meaning keeps
// registry files clean for the common (top-level, unarchived)
// case and means v2.2 Scarf can still load a v2.3-written
// registry of projects that never used the new features.
try c.encodeIfPresent(folder, forKey: .folder)
if archived {
try c.encode(archived, forKey: .archived)
}
}
}
// MARK: - Dashboard
@@ -73,6 +73,101 @@ final class ProjectsViewModel {
}
}
// MARK: - v2.3 registry verbs (folder / archive / rename)
/// Move a project into a folder. `nil` folder returns the project
/// to the top level. No-op when the target already matches.
func moveProject(_ project: ProjectEntry, toFolder folder: String?) {
mutateEntry(project) { $0.folder = folder }
}
/// Rename a project. `name` is the registry's unique key + the
/// Identifiable id; we reject renames that would collide with
/// another project's name. Returns true on success.
@discardableResult
func renameProject(_ project: ProjectEntry, to newName: String) -> Bool {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
guard trimmed != project.name else { return true }
var registry = service.loadRegistry()
// Reject collisions a second project already owns that name.
guard !registry.projects.contains(where: { $0.name == trimmed }) else { return false }
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return false }
let old = registry.projects[index]
registry.projects[index] = ProjectEntry(
name: trimmed,
path: old.path,
folder: old.folder,
archived: old.archived
)
do {
try service.saveRegistry(registry)
} catch {
logger.error("renameProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
return false
}
projects = registry.projects
// Preserve selection across the rename the selected project
// still exists, it just has a new id.
if selectedProject?.name == project.name {
selectedProject = registry.projects[index]
}
return true
}
/// Soft-archive a project. It stays on disk and in the registry;
/// the sidebar just hides it unless `showArchived` is on.
func archiveProject(_ project: ProjectEntry) {
mutateEntry(project) { $0.archived = true }
// If the archived project was selected, clear selection so
// the dashboard doesn't linger on a hidden project.
if selectedProject?.name == project.name {
selectedProject = nil
dashboard = nil
}
}
/// Restore an archived project to the default view.
func unarchiveProject(_ project: ProjectEntry) {
mutateEntry(project) { $0.archived = false }
}
/// Distinct folder labels across the current project set, sorted
/// alphabetically. Drives the sidebar's DisclosureGroups (commit
/// 2) and the Move-to-Folder sheet's existing-folder list. An
/// "empty" folder (folder with zero projects) can't exist under
/// this model folders are implicit in the data which is
/// intentional: v2.3 doesn't need first-class empty folders.
var folders: [String] {
let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty })
return set.sorted()
}
// MARK: - Helpers
/// Fetch the registry, apply `mutation` to the matched entry,
/// persist, update in-memory state. Centralises the save +
/// re-publish dance shared by `moveProject`, `archiveProject`,
/// and `unarchiveProject`. Callers that need different matching
/// semantics (rename, remove) handle their own registry mutation.
private func mutateEntry(_ project: ProjectEntry, _ mutation: (inout ProjectEntry) -> Void) {
var registry = service.loadRegistry()
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return }
var entry = registry.projects[index]
mutation(&entry)
registry.projects[index] = entry
do {
try service.saveRegistry(registry)
} catch {
logger.error("mutateEntry couldn't persist registry for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
return
}
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = entry
}
}
func refreshDashboard() {
guard let project = selectedProject else { return }
loadDashboard(for: project)
@@ -0,0 +1,129 @@
import Testing
import Foundation
@testable import scarf
/// v2.3 grew `ProjectEntry` with `folder` and `archived` fields.
/// Both are optional/defaulted at the decoder so v2.2-era
/// `~/.hermes/scarf/projects.json` files still parse cleanly, and
/// v2.3-written files are forward-compatible with v2.2 readers
/// (which ignore unknown keys). These tests lock in both ends of
/// that contract.
///
/// No disk or Hermes dependency we work entirely with in-memory
/// `Data`, so the `TestRegistryLock` from `ProjectTemplateTests` isn't
/// needed. Safe to run in parallel with every other test suite.
@Suite struct ProjectRegistryMigrationTests {
@Test func decodesV22RegistryWithoutNewFields() throws {
// v2.2-era file: just name + path. No folder, no archived.
let json = """
{
"projects": [
{ "name": "Legacy", "path": "/Users/x/legacy" },
{ "name": "Another", "path": "/Users/x/another" }
]
}
""".data(using: .utf8)!
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
#expect(registry.projects.count == 2)
#expect(registry.projects[0].name == "Legacy")
#expect(registry.projects[0].path == "/Users/x/legacy")
// Defaults hydrate for absent v2.3 fields.
#expect(registry.projects[0].folder == nil)
#expect(registry.projects[0].archived == false)
}
@Test func decodesV23RegistryWithFolderAndArchived() throws {
let json = """
{
"projects": [
{ "name": "Client A", "path": "/Users/x/a", "folder": "Clients" },
{ "name": "Client B", "path": "/Users/x/b", "folder": "Clients", "archived": true },
{ "name": "Personal", "path": "/Users/x/p" }
]
}
""".data(using: .utf8)!
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
#expect(registry.projects.count == 3)
#expect(registry.projects[0].folder == "Clients")
#expect(registry.projects[0].archived == false)
#expect(registry.projects[1].folder == "Clients")
#expect(registry.projects[1].archived == true)
#expect(registry.projects[2].folder == nil)
#expect(registry.projects[2].archived == false)
}
@Test func encodeOmitsDefaultedFields() throws {
// A top-level, non-archived project should encode with ONLY
// name + path keys. This keeps v2.3-written registries
// loadable by v2.2 Scarf (which ignores unknown keys), and
// keeps the file clean for the common case.
let entry = ProjectEntry(name: "Plain", path: "/Users/x/plain")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(entry)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s == #"{"name":"Plain","path":"\/Users\/x\/plain"}"#)
}
@Test func encodeIncludesFolderWhenPresent() throws {
let entry = ProjectEntry(name: "Acme", path: "/a", folder: "Clients")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(entry)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s.contains(#""folder":"Clients""#))
// archived still omitted when false cleanliness matters.
#expect(!s.contains(#""archived""#))
}
@Test func encodeIncludesArchivedOnlyWhenTrue() throws {
let archived = ProjectEntry(name: "Old", path: "/o", archived: true)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(archived)
let s = try #require(String(data: data, encoding: .utf8))
#expect(s.contains(#""archived":true"#))
let active = ProjectEntry(name: "New", path: "/n", archived: false)
let data2 = try encoder.encode(active)
let s2 = try #require(String(data: data2, encoding: .utf8))
#expect(!s2.contains(#""archived""#))
}
@Test func roundTripPreservesAllFields() throws {
let original = ProjectRegistry(projects: [
ProjectEntry(name: "Top", path: "/t"),
ProjectEntry(name: "InFolder", path: "/f", folder: "Work"),
ProjectEntry(name: "ArchivedTop", path: "/a", archived: true),
ProjectEntry(name: "ArchivedInFolder", path: "/af", folder: "Work", archived: true)
])
let encoded = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(ProjectRegistry.self, from: encoded)
#expect(decoded.projects.count == 4)
#expect(decoded.projects[0].folder == nil && decoded.projects[0].archived == false)
#expect(decoded.projects[1].folder == "Work" && decoded.projects[1].archived == false)
#expect(decoded.projects[2].folder == nil && decoded.projects[2].archived == true)
#expect(decoded.projects[3].folder == "Work" && decoded.projects[3].archived == true)
}
@Test func identityStaysKeyedOnName() throws {
// ProjectEntry.id should remain `name`, so selecting by id
// across a folder-move or archive-flip still works without
// a reselection step.
let a = ProjectEntry(name: "Foo", path: "/p")
let b = ProjectEntry(name: "Foo", path: "/p", folder: "Clients")
let c = ProjectEntry(name: "Foo", path: "/p", archived: true)
#expect(a.id == "Foo")
#expect(b.id == "Foo")
#expect(c.id == "Foo")
#expect(a.id == b.id)
#expect(a.id == c.id)
}
}
@@ -0,0 +1,173 @@
import Testing
import Foundation
@testable import scarf
/// Exercises the v2.3 registry verbs added to ProjectsViewModel:
/// moveProject, renameProject, archiveProject, unarchiveProject,
/// + the derived `folders` list. All verbs write through to
/// `~/.hermes/scarf/projects.json` via ProjectDashboardService, so
/// each test uses TestRegistryLock to snapshot + restore the real
/// file. Cross-suite serialization ensures we don't race with other
/// registry-touching tests.
@MainActor @Suite(.serialized) struct ProjectsViewModelTests {
@Test func moveProjectSetsFolder() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Alpha", path: "/a"),
ProjectEntry(name: "Beta", path: "/b")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.projects.count == 2)
vm.moveProject(vm.projects[0], toFolder: "Clients")
#expect(vm.projects.count == 2)
#expect(vm.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
#expect(vm.projects.first(where: { $0.name == "Beta" })?.folder == nil)
// Round-trip: reload from disk and confirm the move persisted.
let fresh = ProjectDashboardService(context: .local).loadRegistry()
#expect(fresh.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
}
@Test func moveProjectToNilReturnsToTopLevel() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Nested", path: "/n", folder: "Clients")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.moveProject(vm.projects[0], toFolder: nil)
#expect(vm.projects[0].folder == nil)
let fresh = ProjectDashboardService(context: .local).loadRegistry()
#expect(fresh.projects[0].folder == nil)
}
@Test func renameProjectUpdatesNameAndPreservesOtherFields() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "OldName", path: "/p", folder: "Work", archived: false)
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.selectProject(vm.projects[0])
let ok = vm.renameProject(vm.projects[0], to: "NewName")
#expect(ok == true)
#expect(vm.projects.count == 1)
#expect(vm.projects[0].name == "NewName")
#expect(vm.projects[0].folder == "Work")
#expect(vm.projects[0].archived == false)
// Selection follows the rename the user stays on the same
// project they were on.
#expect(vm.selectedProject?.name == "NewName")
}
@Test func renameProjectRejectsDuplicateName() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "A", path: "/a"),
ProjectEntry(name: "B", path: "/b")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
// Renaming A to B should be refused B already exists.
let ok = vm.renameProject(vm.projects[0], to: "B")
#expect(ok == false)
// Registry unchanged.
#expect(vm.projects.map(\.name) == ["A", "B"])
}
@Test func renameProjectRejectsEmptyName() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Foo", path: "/f")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.renameProject(vm.projects[0], to: "") == false)
#expect(vm.renameProject(vm.projects[0], to: " ") == false)
#expect(vm.projects[0].name == "Foo")
}
@Test func renameProjectToSameNameIsNoOpSuccess() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Foo", path: "/f")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.renameProject(vm.projects[0], to: "Foo") == true)
// Whitespace around matching name also no-ops.
#expect(vm.renameProject(vm.projects[0], to: " Foo ") == true)
#expect(vm.projects[0].name == "Foo")
}
@Test func archiveAndUnarchiveProject() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "Target", path: "/t")
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
vm.selectProject(vm.projects[0])
#expect(vm.projects[0].archived == false)
#expect(vm.selectedProject != nil)
vm.archiveProject(vm.projects[0])
#expect(vm.projects[0].archived == true)
// Archiving clears the selection so the dashboard doesn't
// linger on a project the sidebar will hide.
#expect(vm.selectedProject == nil)
vm.unarchiveProject(vm.projects[0])
#expect(vm.projects[0].archived == false)
// Unarchive doesn't re-select the user chose to hide it,
// surfacing it doesn't mean they want focus back.
#expect(vm.selectedProject == nil)
}
@Test func foldersListIsSortedAndDeduped() async throws {
let snapshot = TestRegistryLock.acquireAndSnapshot()
defer { TestRegistryLock.restore(snapshot) }
try seedRegistry(.init(projects: [
ProjectEntry(name: "A", path: "/a", folder: "Work"),
ProjectEntry(name: "B", path: "/b", folder: "Personal"),
ProjectEntry(name: "C", path: "/c", folder: "Work"),
ProjectEntry(name: "D", path: "/d"), // top-level
ProjectEntry(name: "E", path: "/e", folder: "") // empty string treated as nil
]))
let vm = ProjectsViewModel(context: .local)
vm.load()
#expect(vm.folders == ["Personal", "Work"])
}
// MARK: - Helpers
@MainActor
private func seedRegistry(_ registry: ProjectRegistry) throws {
try ProjectDashboardService(context: .local).saveRegistry(registry)
}
}