Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# Clearance — Coding Conventions

## Project Overview

Clearance is a macOS markdown viewer/editor built with Swift 6, SwiftUI, and AppKit. It uses XcodeGen (`project.yml`) to generate the Xcode project. The minimum deployment target is macOS 14.

## Architecture

### Directory Structure

Organize source files into the MVVM+ folder structure under `Clearance/`:

- `Models/` — Data types (structs) and observable state holders (classes)
- `Services/` — Stateless business logic and I/O abstractions
- `ViewModels/` — View-driving logic
- `Views/` — SwiftUI views (with subdirectories: `Edit/`, `Render/`, `Sidebar/`)
- `Windows/` — AppKit window controllers
- `App/` — App entry point and delegate

Do not place source files flat in the `Clearance/` root.

### Model vs Class Distinction

Use plain structs for data-carrying model types (e.g. `RecentFileEntry`, `RemoteDocument`). Use `final class` conforming to `ObservableObject` with `@Published` properties for types that hold mutable state and drive UI updates (e.g. `RecentFilesStore`, `DocumentSession`, `AppSettings`). Mark `@Published` properties as `private(set)` when they should only be mutated internally.

```swift
// Good: plain struct for data
struct RemoteDocument: Equatable {
let requestedURL: URL
let renderURL: URL
}

// Good: final class for observable state
final class RecentFilesStore: ObservableObject {
@Published private(set) var entries: [RecentFileEntry]
}
```

### Stateless Services as Caseless Enums

Implement pure service types that have no stored state as caseless enums with static methods. This prevents accidental instantiation and makes statelessness explicit.

```swift
// Good
enum AddressBarFormatter {
static func displayText(for url: URL?) -> String { ... }
}

// Bad
struct AddressBarFormatter {
func displayText(for url: URL?) -> String { ... }
}
```

### Enums with User-Facing Choices

Enums representing user-facing choices must use `String` raw values and conform to `CaseIterable, Identifiable`. Add a computed `id` property (returning `rawValue` or `self`) and a computed `title` property with human-readable display text.

```swift
enum WorkspaceMode: String, CaseIterable, Identifiable {
case view
case edit

var id: String { rawValue }
var title: String {
switch self {
case .view: return "View"
case .edit: return "Edit"
}
}
}
```

## Dependency Injection & Testability

### Init Parameters with Production Defaults

Accept dependencies (UserDefaults, file I/O, network) as init parameters with default values pointing to production implementations. Do not hard-code dependencies inside initializers.

```swift
// Good
init(userDefaults: UserDefaults = .standard, storageKey: String = "recentFiles", maxEntries: Int = 200) {
self.userDefaults = userDefaults
}

// Bad
init() {
self.userDefaults = UserDefaults.standard
}
```

### FileIO Closure Struct Pattern

Abstract file system operations through a struct with closure properties and a static `.live` instance. Do not use protocol-based mocking for I/O.

```swift
struct FileIO: Sendable {
var read: @Sendable (URL) throws -> String
var write: @Sendable (String, URL) throws -> Void

static let live = FileIO(
read: { url in try String(contentsOf: url, encoding: .utf8) },
write: { text, url in try text.write(to: url, atomically: true, encoding: .utf8) }
)
}
```

### Async Dependencies via Closures

For async dependencies (e.g. network fetching), inject a closure typed as `@escaping @Sendable (URL) async throws -> T` with a default calling the production implementation. Do not create protocol + mock class pairs.

```swift
// Good
init(remoteDocumentLoader: @escaping @Sendable (URL) async throws -> RemoteDocument = { url in
try await RemoteDocumentFetcher.fetch(url)
}) { ... }

// Bad
protocol RemoteDocumentLoading {
func fetch(_ url: URL) async throws -> RemoteDocument
}
class MockRemoteDocumentLoader: RemoteDocumentLoading { ... }
```

## Swift Style

### Guard-Let Early Returns

Use `guard let` / `guard` for early returns rather than nesting logic inside `if let` blocks. Keep the main logic path at the top indentation level.

```swift
// Good
static func allows(_ url: URL?) -> Bool {
guard let url else { return true }
guard let scheme = url.scheme?.lowercased() else { return true }
// main logic at top level
}

// Bad
static func allows(_ url: URL?) -> Bool {
if let url = url {
if let scheme = url.scheme?.lowercased() {
// deeply nested
}
}
return true
}
```

## Testing

### Co-ship Tests with New Code

Every commit that introduces a new model or service must include corresponding unit tests in the same commit.

### Test File Organization

Test files live in `ClearanceTests/` mirroring the `Clearance/` source structure. Name each test file after the type it tests with a `Tests` suffix.

```
Clearance/Models/RecentFilesStore.swift → ClearanceTests/Models/RecentFilesStoreTests.swift
Clearance/Services/AddressBarFormatter.swift → ClearanceTests/Services/AddressBarFormatterTests.swift
```

### Test Method Naming

Name test methods as descriptive camelCase sentences starting with `test` that describe the expected behavior. Do not use underscores or given/when/then structure.

```swift
// Good
func testReopeningFileMovesItToTopWithoutDuplicates() { ... }
func testAutosaveWritesUpdatedContent() { ... }

// Bad
func test_adding_file_places_at_top() { ... }
func testAdd() { ... }
func test_given_existing_entry_when_reopened_then_moves_to_top() { ... }
```

### Isolated UserDefaults in Tests

Tests that exercise persistent storage must create isolated `UserDefaults` instances with unique suite names and call `removePersistentDomain` before use. Do not use `UserDefaults.standard` or protocol-based mocks in tests.

```swift
let suite = "RecentFilesStoreTests-1"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
let store = RecentFilesStore(userDefaults: defaults, storageKey: "recent")
```
1 change: 1 addition & 0 deletions CLAUDE.md