diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 12e74e9..cdad793 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,15 +2,84 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## 🚨 Read this first β€” every session + +**Before responding to the user's first message in any new session, you MUST:** + +1. **Read `AGENTS.md` at the project root** β€” `/Users/aarone/Documents/repos/mac-speech-to-text/AGENTS.md` on the host, `/workspace/AGENTS.md` in the devcontainer. It holds the Correctness Checklist (always/never rules), the Topic Router for `.claude/references/*.md`, and the tech-stack + commands reference. The `@../AGENTS.md` import below is defence-in-depth; if it resolves, great β€” **either way, read the file explicitly with the `Read` tool before any substantive work.** +2. **Read any `.claude/references/.md` files relevant to the task** β€” concurrency, testing conventions, PHI, Cliniko, MLX, menu-bar β€” per the Topic Router in AGENTS.md. Do not load all of them; load only what the task needs. +3. **Pick up the GitHub issue(s) you've been asked to work on** with `gh issue view -R CloudbrokerAz/mac-speech-to-text`. + +If the user asks "did you read AGENTS.md?" at any point in a session, the correct answer is "yes, I read it at session start" β€” not "let me check now." This checklist is the contract that makes that true. + +@../AGENTS.md + **Project Type**: macOS native application for local speech-to-text capture **Language**: Swift 6.x (compiler) with Swift 5.9 language mode (Package.swift) **Platform**: macOS 14+ (minimum), macOS 26+ (development) -## Primary Reference +## Current initiative (last updated 2026-04-24) + +**Clinical Notes Mode** β€” extend this app into a local-first clinical documentation assistant for chiropractors. Record consultation β†’ local LLM (MLX Swift + Gemma 3 4B-IT v1; Gemma 4 E4B migration gated on ml-explore/mlx-swift#389) β†’ structured SOAP notes β†’ doctor review β†’ Cliniko API export. 100% on-device. Session-only PHI. No cloud. + +**Work is tracked as GitHub issues** in `CloudbrokerAz/mac-speech-to-text`. Always start a session by reading the open EPICs + any assigned issues: + +```bash +gh issue list -R CloudbrokerAz/mac-speech-to-text --state open --label epic +gh issue view --comments # for any specific issue you pick up +``` -Please see the root `./AGENTS.md` in this same directory for the main project documentation and guidance. +**Two parallel EPICs**: +- **#19 β€” Testing + Workflow Framework** (children #20–#25). Must land first; unblocks feature work. Order: F1 β†’ (F2, F3 parallel) β†’ F4 β†’ F6 β†’ F5. +- **#1 β€” Clinical Notes Mode** (children #2–#18). Rides on top of #19 outputs. + +### Operating rules (binding) + +1. **Use Opus subagents liberally β€” and ALWAYS pass `model: "opus"` explicitly.** For any research/exploration spanning more than a couple of files, spawn parallel `Explore` / `general-purpose` agents with `model: "opus"` on every `Agent` tool call. Do not rely on the agent-definition default β€” several agents default to Sonnet and will silently downgrade if you omit the override. Review agents (`pr-review-toolkit:code-reviewer`, `pr-review-toolkit:comment-analyzer`, `pr-review-toolkit:silent-failure-hunter`, `pr-review-toolkit:type-design-analyzer`) run after substantive changes and **also** take `model: "opus"`. Keep the main thread for decisions and orchestration. +2. **Test everything.** Every new service gets a unit test; every new SwiftUI view gets a ViewInspector crash test; ReviewScreen + SafetyDisclaimer get snapshot tests. New pure-logic/async tests use **Swift Testing** (`@Test` / `#expect`) β€” see `Tests/SpeechToTextTests/Utilities/SwiftTestingExemplarTests.swift` for the canonical idiom. UI + ViewInspector stay on **XCTest**. Tag Swift Testing tests with `.fast` / `.slow` / `.requiresHardware` from `Tests/SpeechToTextTests/Utilities/TestTags.swift`; CI filters via `--skip-tag requiresHardware` where applicable. Acceptance criteria in every GH issue call out the test expectations. +3. **Talk to the tickets.** Three comment checkpoints per issue: (a) starting β€” plan + branch name, (b) PR opened β€” link + "awaiting CI", (c) **merged** β€” PR link + merge commit SHA + one-line summary. Link PRs with `Closes #N` so GitHub auto-closes. Also post an "unblocked by" comment on any downstream issue when its blocker merges. **Tick EPIC task-list checkboxes manually** when the child merges β€” GitHub only auto-ticks entries formatted as a bare `- [ ] #N`, which our EPICs usually aren't. **Always verify the post-merge main-branch workflow run** (`gh run list --branch main --limit 3`) before declaring a merge-batch done β€” the PR's CI and main's CI are separate runs. Keep discussion in GitHub, not in chat. +4. **Point to docs, don't duplicate.** In PRs, issue comments, and subagent prompts, reference `AGENTS.md` (and, once #25 lands, `.claude/references/*.md` β€” a topic-router split) instead of restating context inline. +5. **Security.** Never echo or reuse a GitHub PAT pasted in chat β€” `gh auth status` already has a valid token. Cliniko API keys live in Keychain only (#22 / #7); never logged, never in UserDefaults. PHI is in-memory only, plus the HTTPS body at the moment of POST to Cliniko β€” nowhere else (no logs, crash reports, audit files, or external tooling). +6. **Respect pre-commit.** `pre-commit run --all-files` must pass; SwiftLint is strict; gitleaks is on. The custom rules `observable_actor_existential_warning` and `nonisolated_unsafe_warning` stay honoured. +7. **Don't re-litigate locked decisions** (see below) without an explicit user ask. + +### Locked technical decisions (2026-04-24) + +| Area | Decision | +|---|---| +| LLM runtime | MLX Swift in-process (ml-explore/mlx-swift-examples) | +| LLM model v1 | Gemma 3 4B-IT (MLX 4-bit); swap to Gemma 4 E4B when mlx-swift#389 lands (#18) | +| Model delivery | Bundled in the .app (DMG distribution, not App Store) | +| Persistence | Session-only, cleared on export/quit β€” no on-disk PHI | +| Cliniko | API integration in v1, mirror patterns from [CloudbrokerAz/epc-letter-generation](https://github.com/CloudbrokerAz/epc-letter-generation/tree/main/Sources/Services) | +| Manipulations | Placeholder JSON v1 (#6); user supplies real Cliniko taxonomy later | +| UI entry | Settings toggle + "Generate Notes" action after recording | +| Review layout | Two-column (SOAP editor left, Manipulations + Excluded drawer right) β€” wireframe embedded in #13 | +| Safety | One-time "not a diagnostic tool" disclaimer, UserDefaults ack (#12) | +| Test frameworks | Mixed: Swift Testing (new) + XCTest (UI + ViewInspector) | +| HTTP mocking | Hand-rolled Sendable `URLProtocolStub` (#21) β€” zero deps | +| Keychain mocking | `SecureStore` protocol + `InMemorySecureStore` actor fake (#22) | +| LLM mocking | `MockLLMProvider` fast path; `RUN_MLX_GOLDEN=1` gated goldens nightly | +| Snapshot testing | `pointfreeco/swift-snapshot-testing` v1.17+ β€” scoped to ReviewScreen + Disclaimer only | +| Coverage | slather β†’ `codecov-action@v5` on PR (#20) | +| CI gains (#20) | `swift test --parallel -enableCodeCoverage` + pre-commit/action; UI + hardware-dependent tests skipped in CI, run pre-push on remote Mac | + +### Watch-list / blockers + +- **ml-explore/mlx-swift#389** β€” Gemma 4 E4B architecture support. Migration tracked in #18. +- **Real Cliniko manipulations taxonomy** β€” user-supplied; placeholder in #6 for now. +- **Disclaimer copy legal review** β€” draft in #12; must be reviewed before ship. + +### Reference projects + +- FluidAudio SDK: https://github.com/FluidInference/FluidAudio +- Cliniko API: https://docs.api.cliniko.com/ +- Cliniko integration reference: https://github.com/CloudbrokerAz/epc-letter-generation/tree/main/Sources/Services +- avdlee/swiftui-agent-skill (Topic Router pattern source): https://github.com/avdlee/swiftui-agent-skill -@/workspace/AGENTS.md +## Primary Reference + +The root `AGENTS.md` (at the project root, one level up from this file) is the primary project documentation. The session-start checklist at the top of this file requires you to read it before doing any work β€” don't rely solely on the `@` import, as imports can silently fail when paths don't resolve (which is exactly what bit us when this file previously imported `@/workspace/AGENTS.md` from the host). ## Additional Component-Specific Guidance @@ -18,11 +87,23 @@ For detailed module-specific implementation guides, also check for AGENTS.md fil These component-specific AGENTS.md files contain targeted guidance for working with those particular areas of the codebase. -## Important: Use Subagents Liberally +## Important: Use Subagents Liberally (and always Opus) When performing any research, concurrent subagents can be used for performance and isolation. Use parallel tool calls and tasks where possible. +**Mandatory:** every `Agent` tool call must pass `model: "opus"`. Several subagent definitions default to Sonnet and will silently downgrade if you omit the override. This applies to `Explore`, `general-purpose`, and every `pr-review-toolkit:*` reviewer. + +## Code review pipeline (three layers) + +For every non-trivial PR, exercise all three: + +1. **Pre-PR (local):** spawn a `pr-review-toolkit:code-reviewer` subagent with `model: "opus"` over the diff before pushing. For PHI / concurrency / HTTP / Keychain work, also spawn `pr-review-toolkit:silent-failure-hunter` in parallel (and `pr-review-toolkit:type-design-analyzer` if new types are introduced). Apply blockers before pushing. +2. **Automated (on PR open):** **Gemini Code Assist** runs automatically via the GitHub App, driven by `.gemini/config.yaml` + `.gemini/styleguide.md` at the repo root. Address its inline comments as peer review. Re-trigger with a `/gemini review` comment if the PR materially changed. Gemini Code Assist docs: https://developers.google.com/gemini-code-assist/docs/review-github-code +3. **On-demand deep dive:** invoke the `/code-review` slash command (the `code-review:code-review` skill) for large, multi-subsystem, or comment-heavy PRs. It operates on the PR surface (including review comments) rather than the local diff. + +"Non-trivial" = any diff touching PHI, concurrency, HTTP, Keychain, `@Observable`, actors, or >~30 lines across Sources/. Pure test additions and doc-only changes can skip layer 1. + ## Quick Reference: Project Structure ``` @@ -229,7 +310,7 @@ For CI or when developing on non-macOS: ### Concurrency Safety Patterns -**CRITICAL**: Review `docs/CONCURRENCY_PATTERNS.md` before writing concurrency code. +**CRITICAL**: Review [`.claude/references/concurrency.md`](references/concurrency.md) before writing concurrency code. The legacy `docs/CONCURRENCY_PATTERNS.md` now redirects there. #### 1. @Observable + Actor Existential (EXC_BAD_ACCESS) ```swift diff --git a/.claude/references/cliniko-api.md b/.claude/references/cliniko-api.md new file mode 100644 index 0000000..ab935d5 --- /dev/null +++ b/.claude/references/cliniko-api.md @@ -0,0 +1,136 @@ +# Cliniko API Reference + +> **Load this when:** writing or reviewing the Cliniko client layer +> (issue #8), the patient/appointment picker (#9), `treatment_note` +> export (#10), or the credential-management surface (#7). Also relevant +> when touching `AuditStore`. + +Design reference for the Cliniko integration, informed by the similar +work in +[`CloudbrokerAz/epc-letter-generation`](https://github.com/CloudbrokerAz/epc-letter-generation/tree/main/Sources/Services) +and the [official Cliniko API docs](https://docs.api.cliniko.com/). + +--- + +## Authentication + +- **API key** (secret): stored in Keychain via the `SecureStore` protocol. + - `SecureStore` service identifier: `"com.speechtotext.cliniko"`. + - Account: `"api_key"`. + - Accessibility: `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (no iCloud sync). +- **Shard / subdomain** (non-secret): stored in `UserDefaults`. + - Example values: `au1`, `au2`, `au3`, `au4`, `uk1`, `uk2`, `ca1`, `us1`. + - Used to build the base URL: `https://api.{shard}.cliniko.com/v1`. +- **Auth scheme**: HTTP Basic with `"{api_key}:"` (the empty password is + deliberate β€” Cliniko uses the API key as the username). +- **Required headers** (per Cliniko docs): + - `User-Agent: mac-speech-to-text/ (contact@example.test)` β€” + Cliniko requires a contact email; plumb through a setting if needed. + - `Accept: application/json`. + +--- + +## Error mapping + +`ClinikoClient.send(…)` must surface typed errors, not raw `URLError`: + +| Cliniko response | Swift error | +|---|---| +| `401` | `.unauthenticated` β€” API key invalid or revoked. Route the user to the Cliniko settings sheet. | +| `403` | `.forbidden` β€” key is valid but lacks the needed scope / the practitioner can't see that patient. | +| `404` | `.notFound` β€” typed `.notFound(resource: Resource)` where `Resource` identifies patient / appointment / treatment_note. | +| `422` | `.validation(fields: [String: [String]])` β€” parse the error body. Show field-level messages in the UI. | +| `429` | `.rateLimited(retryAfter: TimeInterval)` β€” parse `Retry-After` header. The client should auto-retry (see Retry). | +| `5xx` | `.server(status: Int)` β€” retry per policy below. | +| Network / DNS / TLS | `.transport(Error)` β€” wrap the underlying error, don't swallow it. | + +--- + +## Retry policy + +- **Idempotent reads** (GET): up to 2 retries on 5xx / transport, exponential backoff (1s, 2s). +- **Writes** (POST/PATCH on `treatment_notes`): **no auto-retry on 5xx** to avoid duplicate notes. Surface the error; let the user re-confirm. +- **429**: honour `Retry-After`, up to 2 retries. UI shows a countdown. +- Retries live inside the `ClinikoClient` actor so callers don't have to think about it. 2 retries max across the whole stack. + +--- + +## Redaction rules (PHI) + +Logging around Cliniko calls must follow the PHI rules from +[`phi-handling.md`](phi-handling.md). Summary for this client: + +- **OK to log**: HTTP method, path template (`/patients/:id`, not `/patients/12345`), status, latency, typed error case. +- **NEVER log**: request body, response body, patient first/last name, DOB, `treatment_note` content, API key (not even obfuscated), subdomain/shard (low-sensitivity but not needed). +- `OSLog` privacy annotation: default to `privacy: .private` for anything the code doesn't strictly own. `privacy: .public` is reserved for structural values (status, method, path template, error case name). + +--- + +## Endpoints in scope for v1 + +| Endpoint | Method | Purpose | Issue | +|---|---|---|---| +| `/users/me` | GET | "Test connection" in the Cliniko settings UI | #7 | +| `/patients?q={term}` | GET | Patient picker search (debounced 300 ms) | #9 | +| `/patients/{id}/appointments?from=…&to=…` | GET | List recent + today's appointments for the chosen patient | #9 | +| `/treatment_notes` | POST | Submit the generated SOAP note (+ optional `appointment_id`) | #10 | + +Schema details belong in fixture files under +`Tests/SpeechToTextTests/Fixtures/cliniko/`, not this doc. + +--- + +## Tenant template variability + +Cliniko `treatment_notes` are template-driven. Different clinics may have +different field layouts (custom fields for "Manipulations used", +different section names). Two decisions: + +1. **v1 approach**: post the SOAP note as a single markdown/HTML body + and let the clinic's template pull from it. This works for any + template without clinic-specific mapping code. +2. **Future**: a clinic-side configuration file maps our `StructuredNotes` + fields to a specific treatment-note template's custom fields. Deferred + until we have a real clinic to pilot with. + +--- + +## Audit + +Every successful export writes a metadata-only line to +`AuditStore` (Application Support, `audit.jsonl`): + +```json +{ + "timestamp": "2026-04-24T12:34:56Z", + "patient_id": "12345", + "appointment_id": "67890", + "note_id": "from-response", + "cliniko_status": 201, + "app_version": "0.x.y" +} +``` + +**No transcript, no SOAP body, no patient name.** The test matrix for +`AuditStore` must assert no such field ever leaks (see #10's acceptance). + +--- + +## Reference implementations + +- Networking layer mirrors patterns from + [`epc-letter-generation/Sources/Services/Networking/`](https://github.com/CloudbrokerAz/epc-letter-generation/tree/main/Sources/Services/Networking). +- Keychain credentials mirror + [`epc-letter-generation/Sources/Services/KeychainCredentialStore.swift`](https://github.com/CloudbrokerAz/epc-letter-generation/blob/main/Sources/Services/KeychainCredentialStore.swift). +- Audit log mirrors + [`epc-letter-generation/Sources/Services/AuditStore.swift`](https://github.com/CloudbrokerAz/epc-letter-generation/blob/main/Sources/Services/AuditStore.swift). + +--- + +## Related files (once implemented) + +- `Sources/Services/Cliniko/ClinikoClient.swift` β€” actor + URLSession. +- `Sources/Services/Cliniko/ClinikoEndpoint.swift` β€” endpoint enum. +- `Sources/Services/Cliniko/ClinikoError.swift` β€” typed errors. +- `Sources/Services/AuditStore.swift` β€” metadata-only audit log. +- `Tests/SpeechToTextTests/Fixtures/cliniko/` β€” request + response goldens. diff --git a/.claude/references/concurrency.md b/.claude/references/concurrency.md new file mode 100644 index 0000000..366d612 --- /dev/null +++ b/.claude/references/concurrency.md @@ -0,0 +1,232 @@ +# Concurrency Reference + +> **Load this when:** writing or reviewing code that uses `@Observable`, +> Swift actors, `@MainActor`, Core Audio callbacks, `Task { }`, or any +> nonisolated(unsafe) property. SwiftLint rules +> `observable_actor_existential_warning` and `nonisolated_unsafe_warning` +> point at the patterns here. + +This file supersedes the older `docs/CONCURRENCY_PATTERNS.md` (which now +redirects here). + +--- + +## 1. `@Observable` + actor existential (EXC_BAD_ACCESS) + +An `@Observable` class that stores an `any SomeActorProtocol` property +crashes on ARM64 with `KERN_INVALID_ADDRESS` (possible pointer +authentication failure) the first time the Observation macro scans it. + +```swift +// WRONG β€” crashes +@Observable +class MyViewModel { + private let service: any MyActorProtocol +} + +// CORRECT +@Observable +class MyViewModel { + @ObservationIgnored private let service: any MyActorProtocol +} +``` + +**Detection:** SwiftLint custom rule `observable_actor_existential_warning`. + +--- + +## 2. `nonisolated(unsafe)` β€” when it's the right call + +`nonisolated(unsafe)` opts out of compiler concurrency checking. That's +legitimately necessary in three scenarios: + +1. **`deinit` cleanup.** `deinit` runs nonisolated, so any resource held + by a `@MainActor`-isolated class that needs cleanup there must be + reachable from nonisolated context. +2. **Audio / system callbacks.** Core Audio taps execute on a real-time + thread with no actor context. +3. **Thread-safe types you own.** If the property is a type that handles + its own synchronisation (e.g. an `NSLock`-guarded counter, or a value + type used immutably), `nonisolated(unsafe)` is safe. + +```swift +@Observable @MainActor +class MyViewModel { + private var timer: Timer? + + // Reachable from deinit, which is nonisolated. + @ObservationIgnored + private nonisolated(unsafe) var deinitTimer: Timer? + + func startTimer() { + let newTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in /* … */ } + timer = newTimer + deinitTimer = newTimer + } + + deinit { + deinitTimer?.invalidate() + } +} +``` + +**Detection:** SwiftLint custom rule `nonisolated_unsafe_warning` flags +every usage so a reviewer inspects the synchronisation story. + +--- + +## 3. Audio callbacks + `@MainActor` (actor-isolation crash) + +`AVAudioEngine.inputNode.installTap` callbacks run on an audio thread. +Calling a `@MainActor` method directly from the callback crashes. + +```swift +// WRONG β€” crashes +@MainActor +class AudioCaptureService { + func processBuffer(_ buffer: AVAudioPCMBuffer) { /* … */ } + + func start() { + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in + self.processBuffer(buffer) // audio thread β†’ MainActor + } + } +} + +// CORRECT +@MainActor +class AudioCaptureService { + // Thread-safe helpers that the audio thread can touch directly. + private nonisolated(unsafe) let pendingWrites = PendingWritesCounter() + private nonisolated(unsafe) let throttler = AudioLevelThrottler() + + func start() { + inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.processBuffer(buffer) + } + } + + // Runs on the audio thread. No MainActor calls, no await. + private nonisolated func processBuffer(_ buffer: AVAudioPCMBuffer) { + let samples = convertToInt16(buffer) + pendingWrites.increment() + Task { @MainActor [weak self] in + defer { self?.pendingWrites.decrement() } + await self?.streamingBuffer?.append(samples) + } + } +} +``` + +**Rules:** +- Audio callback code must be `nonisolated` (can't `await`). +- Hop to `MainActor` via `Task { @MainActor … }` for state updates. +- Helpers the callback touches must be `Sendable` (or `@unchecked Sendable` with a synchronisation story). + +--- + +## 4. `AVAudioEngine` format compatibility + +Forcing a non-native format on `installTap` can make `audioEngine.start()` +throw on some hardware. + +```swift +// WRONG β€” may fail +let format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true) +inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { /* … */ } + +// CORRECT β€” native format, convert in callback +let nativeFormat = inputNode.outputFormat(forBus: 0) +inputNode.installTap(onBus: 0, bufferSize: 1024, format: nativeFormat) { buffer, _ in + if let floatData = buffer.floatChannelData { + let samples = floatData[0].map { Int16($0 * Float(Int16.max)) } + // forward samples… + } +} +``` + +--- + +## 5. SwiftUI task lifecycle + +`Task { }` launched from `onAppear` can outlive the view and touch a +deallocated view model. Use `.task(id:)` instead β€” SwiftUI cancels it on +view disappear. + +```swift +struct MyView: View { + @State private var taskId: UUID? + + var body: some View { + Text("Hello") + .task(id: taskId) { + guard taskId != nil else { return } + // Auto-cancelled on disappear. + } + .onAppear { taskId = UUID() } + } +} +``` + +--- + +## 6. Actor protocols for mockability + +Swift actors cannot be subclassed, so test-doubles can't inherit. Use a +protocol constrained to `Actor`. + +```swift +protocol FluidAudioServiceProtocol: Actor { + func transcribe(samples: [Int16]) async throws -> TranscriptionResult +} + +actor FluidAudioService: FluidAudioServiceProtocol { /* … */ } +actor MockFluidAudioService: FluidAudioServiceProtocol { /* … */ } +``` + +Stored on an `@Observable` class? Remember `@ObservationIgnored` (rule 1). + +--- + +## 7. What the tests can and can't catch + +Unit + ViewInspector tests catch most isolation bugs. These only surface +on real hardware: + +- ARM64 pointer-authentication failures. +- Race conditions under sustained load. +- Some SwiftUI rendering paths. + +Add a **render crash test** for every new `@Observable` view model: + +```swift +func test_myViewModel_instantiatesWithoutCrash() { + let viewModel = MyViewModel() + XCTAssertNotNil(viewModel) +} +``` + +`Tests/SpeechToTextTests/Views/RecordingModalRenderTests.swift` is the +reference pattern. + +--- + +## Checklist: adding an actor-backed service + +- [ ] Conform to an `Actor`-constrained protocol (not the concrete type). +- [ ] Store on any `@Observable` class with `@ObservationIgnored`. +- [ ] Provide an actor-typed mock for tests. +- [ ] Add a ViewInspector render crash test for any view that uses it. +- [ ] If a callback (audio, Carbon, `DispatchSource`) touches state, split + the nonisolated entry from the `@MainActor` side via `Task`. +- [ ] Run `swift test --parallel` plus a local smoke test on real hardware + before merging β€” `@Observable`+actor crashes don't always reproduce + in unit tests. + +--- + +## Related files + +- `.swiftlint.yml` β€” custom rules for these patterns. +- `Tests/SpeechToTextTests/Views/RecordingModalRenderTests.swift` β€” render crash pattern. +- `.github/workflows/ci.yml` β€” CI filters (hardware-dependent tests skipped in CI). diff --git a/.claude/references/menubar-integration.md b/.claude/references/menubar-integration.md new file mode 100644 index 0000000..22abc74 --- /dev/null +++ b/.claude/references/menubar-integration.md @@ -0,0 +1,175 @@ +# Menu-Bar Integration + +> **Load this when:** touching the menu-bar surface, the recording +> modal window, global hotkey registration (Carbon), or the +> Accessibility-API-based text insertion path. Relevant for issues #11, +> #12, #13 and anything that opens a new window. + +Reference for the hybrid SwiftUI + AppKit architecture the existing app +already uses. The Clinical Notes Mode additions ride on top of this +shape; they do not introduce a new window-management pattern. + +--- + +## App shell + +```swift +@main +struct SpeechToTextApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @State private var appState = AppState() + + var body: some Scene { + MenuBarExtra("Speech-to-Text", systemImage: "mic.fill") { + MenuBarView() + .environment(appState) + } + .menuBarExtraStyle(.window) + } +} +``` + +- `MenuBarExtra(style: .window)` gives us a popover-like SwiftUI panel + from the menu-bar icon. +- `AppDelegate` is where non-SwiftUI lifecycle lives: singleton guard, + hotkey registration, modal window presentation. + +--- + +## Singleton guard + +`AppDelegate.applicationDidFinishLaunching` terminates duplicate +instances: + +```swift +if NSRunningApplication.runningApplications( + withBundleIdentifier: Bundle.main.bundleIdentifier! +).count > 1 { + NSApp.terminate(nil) + return +} +``` + +Important for a menu-bar app β€” a second launched copy would double- +register the hotkey and double-present the modal. + +--- + +## Global hotkey (Carbon) + +`KeyboardShortcuts` (sindresorhus SPM) wraps Carbon under the hood. +Registration happens once, in `AppDelegate`. + +```swift +extension KeyboardShortcuts.Name { + static let startRecording = Self("startRecording", default: .init(.space, modifiers: [.command, .option])) +} + +KeyboardShortcuts.onKeyDown(for: .startRecording) { [weak self] in + Task { @MainActor in await self?.showRecordingModal() } +} +``` + +- Hotkey latency target: < 50 ms from keydown to modal visible. +- Carbon handlers run on the main thread β€” safe to call `@MainActor` + methods without a hop. +- `KeyboardShortcuts` stores the user's rebinding in `UserDefaults` + automatically; the settings UI uses `KeyboardShortcuts.Recorder`. + +For Clinical Notes Mode, `#11`'s toggle does not add a second hotkey +in v1 β€” one hotkey starts a recording, and the "Generate Notes" action +appears on the recording modal when the mode is on. + +--- + +## Recording modal window + +```swift +@MainActor +private func showRecordingModal() { + guard recordingWindow == nil else { return } + + let contentView = RecordingModal(viewModel: RecordingViewModel()) { [weak self] in + self?.recordingWindow?.close() + self?.recordingWindow = nil + } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.contentView = NSHostingView(rootView: contentView) + window.backgroundColor = .clear + window.isOpaque = false + window.level = .floating + window.center() + window.makeKeyAndOrderFront(nil) + + recordingWindow = window +} +``` + +Key points: +- `level: .floating` keeps the modal above other apps. +- `backgroundColor = .clear` + `isOpaque = false` + an + `.ultraThinMaterial` background on the SwiftUI root is how the + "frosted glass" look works. +- The `onDismiss` closure on `RecordingModal` is responsible for + nil'ing the stored window β€” otherwise the second hotkey press does + nothing (guard hits). +- The **`ReviewScreen`** (#13) uses the same shape: new floating window + presented from AppDelegate, SwiftUI root in `NSHostingView`, dismiss + through a closure that clears the stored reference. + +--- + +## Text insertion via Accessibility + +`TextInsertionService` has two paths: + +1. **Primary**: `NSPasteboard.general` + simulated `⌘V`. Fast, reliable. +2. **Fallback**: `AXUIElement` APIs to set the focused element's value + directly. Used when `⌘V` fails (e.g. some Electron apps). + +Permission requirements: +- Microphone β†’ TCC entry handled at first recording. +- Accessibility β†’ `AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true])`. + If `AXIsProcessTrusted()` is false, the service surfaces an error + rather than silently no-op'ing. + +For Clinical Notes Mode export, the flow goes Cliniko-direct β€” the +accessibility path is **not** used to paste the SOAP note into a +browser window. That would be a slow-and-fragile alternative; the +Cliniko API export (#14) is the only supported path. + +--- + +## Bundle-ID permission reset + +If the signed bundle ID changes between builds (dev β†’ release, team +ID swap, local unsigned builds), macOS invalidates previously-granted +TCC entries. The app detects this at launch: + +1. Compare current `Bundle.main.bundleIdentifier` to the last-known + value stored in UserDefaults. +2. If different, reset the "permissions already granted" + UserDefaults flag so the onboarding flow re-prompts. + +This matters for Clinical Notes Mode: if the doctor re-signs with a +different team ID, they'll be re-prompted for mic + accessibility, and +their Keychain-stored Cliniko key is accessible (Keychain entries are +scoped by bundle-id prefix, so they survive a simple re-sign with the +same team). + +--- + +## Related files + +- `Sources/SpeechToTextApp/AppDelegate.swift` β€” singleton guard, hotkey, + modal presentation. +- `Sources/SpeechToTextApp/AppState.swift` β€” `@Observable @MainActor` + root state injected into SwiftUI. +- `Sources/Services/TextInsertionService.swift` β€” paste / AX fallback. +- `Sources/Services/PermissionService.swift` β€” TCC checks + prompts. diff --git a/.claude/references/mlx-lifecycle.md b/.claude/references/mlx-lifecycle.md new file mode 100644 index 0000000..a6474ab --- /dev/null +++ b/.claude/references/mlx-lifecycle.md @@ -0,0 +1,146 @@ +# MLX Model Lifecycle + +> **Load this when:** implementing the local LLM provider (#3), the +> clinical-notes processor (#5), the model-download / first-run UX, or +> the eventual migration to Gemma 4 E4B (tracked in #18 and upstream +> [`ml-explore/mlx-swift#389`](https://github.com/ml-explore/mlx-swift/issues/389)). + +Design reference for running a local LLM in-process via +[`mlx-swift-examples`](https://github.com/ml-explore/mlx-swift-examples) +on Apple Silicon. + +--- + +## Model choice (locked) + +- **v1**: **Gemma 3 4B-IT** (MLX 4-bit). Quality/speed balance is good + enough for SOAP-note JSON extraction on 16 GB+ Apple Silicon. +- **v2 (blocked)**: **Gemma 4 E4B-IT**. MLX Swift lacks the `gemma4` + architecture registration as of 2026-04-24 + ([`mlx-swift#389`](https://github.com/ml-explore/mlx-swift/issues/389)). + Migration PR tracked in #18; one-config swap once upstream lands. +- Provider is abstracted behind `LLMProvider` (issue #3) so swapping + the model is a configuration change, not a refactor. + +--- + +## Deployment + +- **Bundled in the `.app`**: the 4-bit Gemma 3 4B-IT weights ship in + `Resources/Models/gemma-3-4b-it-4bit/` (~2.5 GB). DMG distribution, + not App Store. +- **Git LFS vs build-time download**: decide in #3's PR. LFS keeps the + repo self-contained but bloats clone size. Build-time download from + the `mlx-community` Hugging Face org is smaller at `git clone` time + but adds a network hop to the first build. + +--- + +## Load / warmup / unload + +`MLXGemmaProvider` is an actor. Two-phase init: + +1. **Lazy load** on first inference call. Reading 2.5 GB of weights off + disk into unified memory is the expensive step (~10–15 s cold on + M-series). +2. **`warmup()`** can be awaited proactively when the user enables + Clinical Notes Mode in Settings, so the first "Generate Notes" tap + doesn't pay the load cost. + +Unload policy for v1: keep the model loaded once it's in memory. Don't +evict on idle β€” warming it up is expensive. Revisit if memory pressure +surfaces on smaller Macs. + +--- + +## Inference defaults + +Deterministic by design: + +```swift +LLMOptions( + temperature: 0, // no sampling randomness + topP: 1.0, + maxTokens: 1024, + seed: 42, // fixed seed + stop: ["}"] // optional β€” trims after JSON close brace +) +``` + +The prompt builder (#4) assembles input; the processor (#5) handles +retry on schema failure. + +--- + +## Concurrency + +- `MLXGemmaProvider` is an `actor`. Only one inference runs at a time β€” + the GPU/ANE doesn't parallelise a single model's requests well, and + sequentialising at the actor layer keeps the memory pressure story + simple. +- Callers cross the actor boundary via `await`. If a UI callback is on + `@MainActor`, `await provider.generate(…)` is the right shape β€” don't + try to hop threads manually. +- Stored on an `@Observable` class (e.g. `AppState`): requires + `@ObservationIgnored`, per [`concurrency.md`](concurrency.md) rule 1. + +--- + +## Memory pressure + +Gemma 3 4B-IT @ 4-bit needs ~3 GB resident. On 8 GB Macs (unsupported +baseline officially β€” minimum is 16 GB), running the model alongside +Safari and an IDE will hit swap. + +- Document the recommended hardware (16 GB+). +- For 8 GB machines, consider surfacing a "low memory" setting that + swaps to a smaller model (or Apple Foundation Models on macOS 26+). +- Never hard-crash the app on memory pressure β€” catch the MLX load + error and surface it via the "LLM failed, showing raw transcript" + fallback (#5). + +--- + +## Failure fallback + +Per #5 acceptance: +- LLM load failure β†’ "Clinical Notes unavailable on this device" with a + link to the diagnostic / logs. +- LLM inference throws β†’ `.rawTranscriptFallback(reason:)`. The doctor + still gets the transcript and can edit manually. +- JSON schema failure β†’ retry once; failure on the retry β†’ + `.rawTranscriptFallback`. + +--- + +## Privacy + +- The LLM is in-process; no weights or prompts leave the device. +- Prompts contain PHI (the transcript). Apply + [`phi-handling.md`](phi-handling.md) rules to any logging around the + provider β€” log structural metadata only (token counts, latency, + retry counter). Never log the prompt or the response body. + +--- + +## Upstream tracking + +- **`ml-explore/mlx-swift#389`** β€” Gemma 4 architecture support. Until + this lands (or we port it ourselves / adopt a community fork), + Gemma 4 E4B cannot be loaded in Swift. Python MLX has day-0 support; + the Swift binding does not. +- **`unsloth/gemma-4-E4B-it-MLX-*bit`** and + `mlx-community/gemma-4-E4B-it-*bit` β€” pre-quantised weights waiting + for the Swift side. +- **`SharpAI/SwiftLM`** β€” a community Swift MLX fork that does + register `gemma4`. HTTP-server-shaped; considered and rejected for + bundled-in-app shipping (see #19 decision matrix). + +--- + +## Related issues + +- #3 β€” `LLMProvider` protocol + `MLXGemmaProvider`. +- #4 β€” prompt builder + JSON schema guard. +- #5 β€” `ClinicalNotesProcessor` orchestrator. +- #18 β€” Gemma 4 E4B migration tracker. diff --git a/.claude/references/phi-handling.md b/.claude/references/phi-handling.md new file mode 100644 index 0000000..d9b00c9 --- /dev/null +++ b/.claude/references/phi-handling.md @@ -0,0 +1,129 @@ +# PHI Handling Policy + +> **Load this when:** adding or reviewing any code path that touches a +> patient transcript, a generated SOAP note, a patient record, or +> anything that could be PHI. Load before writing logs, crash reports, +> telemetry, audit entries, test fixtures, or anything external (Slack, +> GitHub issues, etc.). + +"PHI" in this project means the narrow set of patient-related data the +app sees: consultation transcripts, generated SOAP notes, suggested +manipulations, excluded-content snippets, patient demographics (name, +DOB, contact), and `treatment_note` bodies. Chiropractic clinics in +AU/UK/US are subject to health-record-handling obligations regardless +of whether HIPAA applies directly β€” treat everything as sensitive. + +--- + +## Where PHI may live + +Exactly two places: + +1. **In-memory within the active `ClinicalSession`.** Cleared on export + success, app quit, or the inactivity-timeout threshold. Never + serialised to disk, UserDefaults, or any cache. See `SessionStore` + (issue #2). +2. **The HTTPS body at the moment of `POST /treatment_notes`.** Goes + directly from the doctor's Mac to the doctor's Cliniko tenant over + TLS. Nowhere else. + +--- + +## Where PHI MUST NOT appear + +- **Logs / `OSLog`.** Annotate anything remotely PHI-adjacent with + `privacy: .private`. `privacy: .public` is reserved for structural + values (error-case names, HTTP status, path templates, file paths that + don't contain patient IDs). +- **Crash reports.** Any `fatalError` / `preconditionFailure` message + must not interpolate PHI. Prefer inert messages (e.g. + `"SessionStore invariant violated"`) that the stack trace itself + explains. +- **Audit log (`audit.jsonl`).** Metadata only: timestamp, patient_id + (opaque string), appointment_id, note_id from response, HTTP status, + app version. Never note body, transcript, or patient name. See + `.claude/references/cliniko-api.md`. +- **Test fixtures.** Fixtures under `Tests/SpeechToTextTests/Fixtures/` + must use obviously-synthetic data (sample names, `@example.test` + domains, placeholder IDs). Never copy production data, even redacted. +- **Issue comments, PR descriptions, Slack messages, subagent prompts.** + Summarise in structural terms (e.g. "patient search returned zero + results in the UI test with a known-good query") instead of echoing + the patient data. +- **External tooling.** The `/ultrareview` command, Codecov, error + aggregators, GitHub Actions logs β€” nothing with PHI leaves the + device. If a tool needs a sample payload, use a fixture. +- **Disk outside Keychain.** Cliniko API keys + shard live in Keychain / + UserDefaults respectively (the subdomain is not PHI). No other + persistent store may contain patient-adjacent data in v1. + +--- + +## When a log line is ambiguous + +Rule of thumb: **if a reviewer's first reaction is "wait, can we log +that?", the answer is no.** + +```swift +// Fine β€” all structural. +logger.info("cliniko POST /treatment_notes status=\(status, privacy: .public) latency=\(ms, privacy: .public)ms") + +// NOT fine β€” response body can carry note_id AND the echoed payload. +logger.debug("cliniko response: \(responseString)") + +// NOT fine β€” subtle: even the count could be inferred as PHI if paired with appointment timing. +logger.info("patient search for \"\(query)\" returned \(results.count) hits") +``` + +When in doubt, drop the log. Tests should exercise the happy path β€” +logs are for ops, not debugging. + +--- + +## Crash-path hygiene + +- `fatalError` messages, `preconditionFailure` messages, and + `assertionFailure` messages are visible in crash reports and Console. +- Treat them as logs: no PHI interpolation. +- Prefer `guard … else { fatalError("SessionStore: active session lost") }` + over `fatalError("Session \(session.id) for \(session.patientName) lost")`. + +--- + +## Test guardrails + +Every service that touches PHI gets a test asserting the PHI-free +invariant: + +- `AuditStore` β€” assert exported keys are a fixed whitelist; any + transcript-looking string rejected. +- `ClinicalNotesProcessor` β€” assert no prompt or response data leaks + into `logger` at `.default` or above (verify with + `OSLogMessageReconstructor`-style test or by asserting the redaction + helper is called). + +These assertions are enumerated in each consumer's acceptance criteria. + +--- + +## Disclaimer interaction + +The one-time "not a diagnostic tool" disclaimer (#12) exists in part to +reinforce practitioner responsibility. Its acknowledgement flag is a +UserDefaults boolean β€” not PHI, no special handling needed. + +--- + +## Jurisdictions the app is likely used in + +- **Australia** β€” Privacy Act 1988, My Health Records Act. Clinic- + owned data; practitioner responsible. +- **UK** β€” UK GDPR, Data Protection Act 2018. Special-category health + data rules. +- **US** β€” HIPAA applies if the practitioner is a covered entity or + processes via a covered entity. Most solo chiropractic practices are + not strictly covered, but the bar is still "act as if you are". + +None of the above allow patient data to traverse an unrelated third +party. Keeping everything on-device + direct-to-Cliniko-tenant is the +compliance-safe default. diff --git a/.claude/references/testing-conventions.md b/.claude/references/testing-conventions.md new file mode 100644 index 0000000..eef8db9 --- /dev/null +++ b/.claude/references/testing-conventions.md @@ -0,0 +1,162 @@ +# Testing Conventions + +> **Load this when:** adding or modifying tests, setting up a new test +> target, designing a mock, or deciding whether a new piece of code needs +> a unit test vs a view-render test vs a snapshot vs a UI test. + +This reference consolidates the testing framework decisions made across +EPIC #19 (F1–F5). + +--- + +## Layered strategy + +| Layer | Where it runs | What it contains | +|---|---|---| +| `pre-commit` (L0) | Every commit, local | SwiftLint + gitleaks + whitespace/yaml/json/markdownlint. <5s. | +| `pre-push` (L1) | Remote Mac via SSH | Full `swift test` + UI suite. Hardware-dependent. | +| `GitHub Actions` (L2) | PR + `main` | `swift test --parallel --enable-code-coverage` skipping hardware-dependent classes; llvm-cov β†’ lcov β†’ Codecov; pre-commit scoped to PR diff. | +| Nightly (L3) | Scheduled, remote Mac | `RUN_MLX_GOLDEN=1` LLM goldens + full UI suite. | + +`.github/workflows/ci.yml` is the source of truth. + +--- + +## Which framework to reach for + +| Kind of test | Framework | +|---|---| +| New pure-logic or async test | **Swift Testing** (`@Test` / `@Suite` / `#expect`) | +| SwiftUI view render / crash detection | **XCTest + ViewInspector** | +| XCUITest E2E | **XCTest** (no equivalent in Swift Testing) | +| Visual regression for ReviewScreen / SafetyDisclaimer | **`pointfreeco/swift-snapshot-testing`** (scoped β€” see "Snapshots" below) | + +Existing XCTest files stay. New code voluntarily adopts Swift Testing. +`Tests/SpeechToTextTests/Utilities/SwiftTestingExemplarTests.swift` is the +canonical idiom reference. + +--- + +## Tags + +Every Swift Testing test carries at least one tag from +`Tests/SpeechToTextTests/Utilities/TestTags.swift`: + +| Tag | Meaning | +|---|---| +| `.fast` | Pure-logic, sub-millisecond. Default for new code. | +| `.slow` | Noticeably slower (real I/O, large fixtures). Promote a `.fast` test only when it becomes relevant for nightly-only runs. | +| `.requiresHardware` | Needs real mic / Accessibility TCC / display server / user keychain. Skipped on CI runners. | + +Apply with `@Test(.tags(.fast))` or propagate down from `@Suite(.tags(.fast))`. + +XCTest classes cannot be tagged. CI still filters them by name (see next +section). + +--- + +## Mocking patterns (already on main) + +| Dependency | Test-only replacement | File | +|---|---|---| +| HTTP (`URLSession`) | `URLProtocolStub.install(_:)` returning a `URLSessionConfiguration` | `Tests/SpeechToTextTests/Utilities/URLProtocolStub.swift` | +| HTTP response fixtures | `HTTPStubFixture.load(_:)` / `loadJSON(_:,_:)` loading from `Tests/SpeechToTextTests/Fixtures/` | `Tests/SpeechToTextTests/Utilities/HTTPStubFixture.swift` | +| Keychain (`SecureStore`) | `InMemorySecureStore` β€” actor-isolated `[String: Data]`, never imports `Security` | `Tests/SpeechToTextTests/Utilities/InMemorySecureStore.swift` | +| LLM (`LLMProvider` β€” once #3 lands) | `MockLLMProvider` with canned prompt-hash β†’ response lookup | pending #3 | + +Real Keychain / real LLM inference are **never** exercised in CI. They +run locally or pre-push on a real Mac. + +--- + +## Fixtures layout + +``` +Tests/SpeechToTextTests/Fixtures/ + cliniko/ + requests/.json # expected outgoing payloads + responses/.json # canned responses for URLProtocolStub + soap/ + valid/.json # valid SOAP JSON from the LLM + invalid/.json # edge cases the schema guard must reject + llm/ + prompts/.txt + expected/.json # goldens (only used when RUN_MLX_GOLDEN=1) +``` + +Fixtures are code. Changes ship in a PR that explains *why* the shape +changed. **Never auto-regenerate fixtures from production data** β€” they +must never contain PHI. + +--- + +## Snapshot tests (narrow) + +`pointfreeco/swift-snapshot-testing` v1.17+ is the chosen library once +#24 / F5 lands, and it's scoped deliberately to: + +- `ReviewScreen` (SOAP editor + manipulations + excluded drawer β€” #13) +- `SafetyDisclaimerView` (#12) + +Everything else gets ViewInspector crash tests, not image snapshots. +Rationale: macOS font rendering and Retina scaling make blanket snapshot +suites noisy; visual regression only matters on the doctor-facing +review surface. + +Record mode (`isRecording = true`) changes require a human reviewer +because they rewrite goldens unconditionally. + +--- + +## CI skip list (current) + +CI runs the XCTest suite minus these classes (hardware-dependent). When +an XCTest class migrates to Swift Testing and picks up +`.requiresHardware`, remove it from the list: + +- `AudioCaptureServiceTests` β€” `AVAudioEngine.start` (real mic) +- `PermissionServiceTests` β€” `AXIsProcessTrustedWithOptions` / TCC +- `TextInsertionServiceTests` β€” `CGEventPost` + display server +- `VoiceTriggerMonitoringServiceTests` β€” transitively needs real mic +- `WakeWordServiceTests` β€” reads real WAV fixtures +- `GeneralSectionPersistenceTests` β€” shared-UserDefaults race under `--parallel` (fix tracked in #32) + +The long-term target is to migrate these to Swift Testing + `.requiresHardware` +so the CI filter becomes `--skip-tag requiresHardware` (closes #31). + +--- + +## Writing a new test β€” minimum bar + +1. **Services**: a unit test. Hand-rolled protocol mocks in the test file. +2. **SwiftUI views**: a ViewInspector render / crash-detection test β€” + even a one-line `XCTAssertNotNil(MyView())` is valuable because it + catches `@Observable` + actor-existential regressions. +3. **New HTTP client**: tests via `URLProtocolStub` + fixtures under + `Fixtures/cliniko/`. +4. **New credential-store consumer**: tests using `InMemorySecureStore`. +5. **LLM-consuming code**: tests via `MockLLMProvider` (unit) + goldens + gated behind `RUN_MLX_GOLDEN=1` (nightly). + +Every GH issue's acceptance-criteria section must name the tests the PR +is expected to add. + +--- + +## Local workflow + +```bash +swift test --parallel # fast, most common +swift test --filter MyNewClassTests # while iterating +swift test --parallel --enable-code-coverage # match CI shape +SWIFT_TEST_EXTRA="--skip-tag requiresHardware" ./scripts/remote-test.sh +pre-commit run --files # mirror CI hooks +``` + +--- + +## Related files + +- `.github/workflows/ci.yml` β€” CI definition. +- `Tests/SpeechToTextTests/Utilities/` β€” test helpers + exemplars. +- `.pre-commit-config.yaml` β€” local hook definitions. diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 930da58..c99f3e3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -44,10 +44,6 @@ "Bash(env)", "Bash(git log:*)", "Bash(echo:*)", - "Skill(github-speckit-tester)", - "Skill(terraform-style-guide)", - "Skill(terraform-test)", - "Skill(terraform-stacks)", "Bash(find:*)", "Bash(sed:*)", "Bash(curl:*)", @@ -63,7 +59,8 @@ "Bash(git push:*)", "WebSearch", "Bash(gh run view:*)", - "Bash(npm run lint:*)" + "Bash(npm run lint:*)", + "Bash(gh issue *)" ], "deny": [ "Write(.gitignore)", @@ -75,6 +72,15 @@ "Write(.pre-commit-config.yaml)", "Update(.pre-commit-config.yaml)", "Edit(.pre-commit-config.yaml)", + "Write(.pre-commit-hooks.yaml)", + "Update(.pre-commit-hooks.yaml)", + "Edit(.pre-commit-hooks.yaml)", + "Write(.swiftlint.yml)", + "Update(.swiftlint.yml)", + "Edit(.swiftlint.yml)", + "Write(.swiftlint.yaml)", + "Update(.swiftlint.yaml)", + "Edit(.swiftlint.yaml)", "Write(.mcp.json)", "Update(.mcp.json)", "Edit(.mcp.json)", diff --git a/.claude/skills/github-speckit-tester/README.md b/.claude/skills/github-speckit-tester/README.md deleted file mode 100644 index 4f1f71c..0000000 --- a/.claude/skills/github-speckit-tester/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# github-speckit-tester - -Test harness for executing Speckit workflows non-interactively using subagents. Use when you need to test the complete Speckit pipeline (Phase 0 β†’ Phase 3) or individual phases, validate artifact generation across all commands, automate testing of specification-to-implementation workflows, or verify cross-phase consistency. This skill orchestrates the execution of all Speckit commands in order without user intervention. diff --git a/.claude/skills/github-speckit-tester/SKILL.md b/.claude/skills/github-speckit-tester/SKILL.md deleted file mode 100644 index 473a1bf..0000000 --- a/.claude/skills/github-speckit-tester/SKILL.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: github-speckit-tester -description: Test harness for executing Speckit workflows non-interactively using subagents. Use when you need to test the complete Speckit pipeline (Phase 0 β†’ Phase 3) or individual phases, validate artifact generation across all commands, automate testing of specification-to-implementation workflows, or verify cross-phase consistency. This skill orchestrates the execution of all Speckit commands in order without user intervention. ---- - -# GitHub Speckit Tester - -A comprehensive test harness for validating the Speckit workflow system by executing all phases non-interactively using subagents. - -## Overview - -This skill provides automated testing capabilities for the complete Speckit pipeline, executing all commands in sequence from specification to implementation without requiring user interaction. - -## Core Concepts - -### Non-Interactive Execution - -All Speckit commands must be executed without user intervention: - -- Automatic decision making for spec clarifications -- Default selections for ambiguous choices -- Automated validation and progression through phases -- Error handling and recovery without user input - -Document start time and end time, totals execution time, and tokens consumed inclusive of all subagents - -### Execution Workflow - -1 validate-env.sh β†’ env ok -2 /speckit.specify β†’ spec.md -3 /speckit.clarify β†’ spec.md updated -4 /speckit.plan β†’ plan.md, data-model.md -5 /review-tf-design β†’ approved -6 /speckit.tasks β†’ tasks.md -7 /speckit.analyze β†’ analysis -8 /speckit.implement β†’ tf code + sandbox test -9 deploy (cli) β†’ init/plan/apply -10 /report-tf-deployment β†’ report -11 commit and create a PR with details diff --git a/.claude/skills/github-speckit-tester/prompts/example_asg.md b/.claude/skills/github-speckit-tester/prompts/example_asg.md deleted file mode 100644 index aa9e3b3..0000000 --- a/.claude/skills/github-speckit-tester/prompts/example_asg.md +++ /dev/null @@ -1,31 +0,0 @@ -# Example: Auto-Scaling Group with ALB - -**IMPORTANT** - Do not prompt me, make best practice decisions independently (this is for prompt eval) - -Using the **github-speckit-tester** skill non-interactively. - -## Infrastructure Requirements - -Provision using Terraform: - -- Auto-scaling group with launch template -- Target tracking policies -- create basic static content for testing -- ALB with health checks across 2 AZs -- CloudWatch dashboards -- AWS Region: `ap-southeast-2` -- Environment: Development (minimal cost) -- Use existing default VPC always - -## HCP Terraform Configuration - -- **Organization**: `hashi-demos-apj` -- **Project**: `sandbox` -- **Workspace**: `sandbox_asg` - -## Workflow Instructions - -- Follow best practice -- Use subagents to make best practice decisions if you need clarity -- Don't prompt the user - make decisions yourself -- If you hit issues, resolve them without prompting diff --git a/.claude/skills/github-speckit-tester/prompts/example_cloudfront.md b/.claude/skills/github-speckit-tester/prompts/example_cloudfront.md deleted file mode 100644 index e73c8d6..0000000 --- a/.claude/skills/github-speckit-tester/prompts/example_cloudfront.md +++ /dev/null @@ -1,33 +0,0 @@ -# Example: CloudFront with Static Content - -**IMPORTANT** - Do not prompt me, make best practice decisions independently (this is for prompt eval) - -Using the **github-speckit-tester** skill non-interactively. - -## Infrastructure Requirements - -Provision using Terraform: - -- S3 bucket for static content storage -- create a basic static content page for testing only -- CloudFront distribution with OAI (Origin Access Identity) -- SSL/TLS certificate via ACM -- Route53 DNS records (optional) -- CloudWatch metrics and alarms -- AWS Region: `us-east-1` (CloudFront requires ACM certs in us-east-1) -- S3 bucket region: `ap-southeast-2` -- Environment: Development (minimal cost) -- Use existing default VPC always - -## HCP Terraform Configuration - -- **Organization**: `hashi-demos-apj` -- **Project**: `sandbox` -- **Workspace**: `sandbox_cloudfront` - -## Workflow Instructions - -- Follow best practice -- Use subagents to make best practice decisions if you need clarity -- Don't prompt the user - make decisions yourself -- If you hit issues, resolve them without prompting diff --git a/.claude/skills/github-speckit-tester/prompts/example_ec2.md b/.claude/skills/github-speckit-tester/prompts/example_ec2.md deleted file mode 100644 index 88b7709..0000000 --- a/.claude/skills/github-speckit-tester/prompts/example_ec2.md +++ /dev/null @@ -1,31 +0,0 @@ -# Example: EC2 Instance with ALB and Nginx - -**IMPORTANT**: Do not prompt me - make best practice decisions independently (this is for prompt eval) - -Using the **github-speckit-tester** skill non-interactively. - -## Infrastructure Requirements - -Provision using Terraform: - -- EC2 instances across 2 AZs -- create basic static content page for testing -- HTTPS and Nginx -- ALB (Application Load Balancer) -- AWS Region: `ap-southeast-2` -- Use existing default VPC -- enviromnment development minimal cost -- Use existing default VPC always - -## HCP Terraform Configuration - -- **Organization**: `hashi-demos-apj` -- **Project**: `sandbox` -- **Workspace**: `sandbox_ec2` - -## Workflow Instructions - -- Follow best practice -- Use subagents to make best practice decisions if you need clarity -- Don't prompt the user - make decisions yourself -- If you hit issues, resolve them without prompting diff --git a/.claude/skills/github-speckit-tester/prompts/example_elastic.md b/.claude/skills/github-speckit-tester/prompts/example_elastic.md deleted file mode 100644 index 5fafd22..0000000 --- a/.claude/skills/github-speckit-tester/prompts/example_elastic.md +++ /dev/null @@ -1,29 +0,0 @@ -# Example: ElastiCache Redis with Application Tier - -**IMPORTANT** - Do not prompt me, make best practice decisions independently (this is for prompt eval) - -Using the **github-speckit-tester** skill non-interactively. - -## Infrastructure Requirements - -Provision using Terraform: - -- ElastiCache Redis cluster in private subnets -- ECS across 2 AZs for application tier -- ALB with HTTPS -- AWS Region: `ap-southeast-2` -- Use existing default VPC -- Environment: Development (minimal cost) - -## HCP Terraform Configuration - -- **Organization**: `hashi-demos-apj` -- **Project**: `sandbox` -- **Workspace**: `sandbox_elastic` - -## Workflow Instructions - -- Follow best practice -- Use subagents to make best practice decisions if you need clarity -- Don't prompt the user - make decisions yourself -- If you hit issues, resolve them without prompting diff --git a/.claude/skills/github-speckit-tester/prompts/example_serverless.md b/.claude/skills/github-speckit-tester/prompts/example_serverless.md deleted file mode 100644 index 8f34c02..0000000 --- a/.claude/skills/github-speckit-tester/prompts/example_serverless.md +++ /dev/null @@ -1,30 +0,0 @@ -# Example: Serverless Application - -**IMPORTANT** - Do not prompt me, make best practice decisions independently (this is for prompt eval) - -Using the **github-speckit-tester** skill non-interactively. - -## Infrastructure Requirements - -Provision using Terraform: - -- Lambda functions with API Gateway -- DynamoDB tables -- S3 buckets for static assets -- CloudWatch Logs and alarms -- AWS Region: `ap-southeast-2` -- Environment: Development (minimal cost) -- Use existing default VPC always - -## HCP Terraform Configuration - -- **Organization**: `hashi-demos-apj` -- **Project**: `sandbox` -- **Workspace**: `sandbox_serverless` - -## Workflow Instructions - -- Follow best practice -- Use subagents to make best practice decisions if you need clarity -- Don't prompt the user - make decisions yourself -- If you hit issues, resolve them without prompting diff --git a/.claude/skills/github-speckit-tester/prompts/example_sqs.md b/.claude/skills/github-speckit-tester/prompts/example_sqs.md deleted file mode 100644 index e441d6c..0000000 --- a/.claude/skills/github-speckit-tester/prompts/example_sqs.md +++ /dev/null @@ -1,30 +0,0 @@ -# Example: SQS with Lambda and SNS - -**IMPORTANT** - Do not prompt me, make best practice decisions independently (this is for prompt eval) - -Using the **github-speckit-tester** skill non-interactively. - -## Infrastructure Requirements - -Provision using Terraform: - -- SQS queue with dead letter queue -- Lambda function triggered by SQS messages -- SNS topic for notifications -- CloudWatch alarms -- AWS Region: `ap-southeast-2` -- Environment: Development (minimal cost) -- Use existing default VPC always - -## HCP Terraform Configuration - -- **Organization**: `hashi-demos-apj` -- **Project**: `sandbox` -- **Workspace**: `sandbox_sqs` - -## Workflow Instructions - -- Follow best practice -- Use subagents to make best practice decisions if you need clarity -- Don't prompt the user - make decisions yourself -- If you hit issues, resolve them without prompting diff --git a/.claude/skills/terraform-mcp-as-code/README.md b/.claude/skills/terraform-mcp-as-code/README.md deleted file mode 100644 index 76349a4..0000000 --- a/.claude/skills/terraform-mcp-as-code/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# mcp-skill - -This is an auto-generated Claude Code skill from an MCP server. - -## Installation - -1. Copy this directory to your Claude Code skills location -2. The skill will be available for use in Claude Code - -## Contents - -- `SKILL.md` - Main skill documentation -- `scripts/` - TypeScript wrapper functions organized by category - - `scripts/variables/` - Variables (11 wrapper functions) - - `scripts/runs/` - Runs (3 wrapper functions) - - `scripts/workspaces/` - Workspaces (5 wrapper functions) - - `scripts/public-registry/` - Public Registry (9 wrapper functions) - - `scripts/private-registry/` - Private Registry (4 wrapper functions) - - `scripts/organization/` - Organization (2 wrapper functions) - -Each category contains: - -- Individual `.ts` files for each tool with input/output interfaces and wrapper functions -- `index.ts` - Barrel export for easy importing - -## Original MCP Server - -**Command:** `docker run -i --rm -e TFE_TOKEN=***REDACTED*** hashicorp/terraform-mcp-server` - -**Tools:** 34 available - -## Tool Categories - -- **Variables** (11 tools): Variable and variable set management -- **Runs** (3 tools): Terraform run creation and monitoring -- **Workspaces** (5 tools): Workspace creation, configuration, and management -- **Public Registry** (9 tools): Tools for accessing public Terraform registry (modules, providers, policies) -- **Private Registry** (4 tools): Tools for accessing private Terraform modules and providers -- **Organization** (2 tools): Organization and project listing - -## Usage - -Import wrapper functions from category modules: - -```typescript -import { CreateWorkspace, UpdateWorkspace } from './scripts/workspaces/index.js'; - -// Use type-safe wrapper functions -const result = await CreateWorkspace({ - workspace_name: 'my-workspace', - terraform_org_name: 'my-org', -}); -``` diff --git a/.claude/skills/terraform-mcp-as-code/SKILL.md b/.claude/skills/terraform-mcp-as-code/SKILL.md deleted file mode 100644 index 3616db9..0000000 --- a/.claude/skills/terraform-mcp-as-code/SKILL.md +++ /dev/null @@ -1,301 +0,0 @@ ---- -name: Terraform Infrastructure as Code -description: Automate Terraform Cloud/Enterprise operations: create workspaces, trigger runs, manage variables, and search registries for infrastructure-as-code projects. -version: 1.0.0 -dependencies: docker, node.js ---- - -# Terraform Infrastructure as Code - -Automate HashiCorp Cloud Platform (HCP) Terraform infrastructure management through type-safe TypeScript wrappers for Terraform Cloud and Terraform Enterprise. - -## When to Use This Skill - -Invoke this skill when you need to: - -- **Create, configure, or update** Terraform Cloud/Enterprise workspaces -- **Trigger and monitor** Terraform runs programmatically -- **Manage** workspace variables and variable sets -- **Search** for public Terraform modules, providers, or policies -- **Access** private registry modules and providers -- **List** organizations and projects in your Terraform Cloud/Enterprise account - -This skill is ideal for infrastructure-as-code automation and programmatic HCP Terraform management workflows. - -## Prerequisites - -**Required:** - -- Terraform Cloud/Enterprise account -- `TFE_TOKEN` environment variable with a valid Terraform API token -- Docker (for running the MCP server) - -**MCP Server Command:** - -```bash -docker run -i --rm -e TFE_TOKEN=your_token hashicorp/terraform-mcp-server -``` - -## Security Best Practices - -⚠️ **Important Security Guidelines:** - -- **Never hardcode credentials**: Always use environment variables for `TFE_TOKEN` -- **Token security**: Store tokens in secure credential managers or environment configuration -- **Least privilege**: Use workspace-specific or organization-specific tokens when possible -- **Review before execution**: Examine all generated code before running in production environments -- **No secrets in code**: Never commit tokens to version control - -**Example of secure token handling:** - -```typescript -// βœ… Correct: Use environment variables -const token = process.env.TFE_TOKEN; - -// ❌ Wrong: Hardcoded token -const token = 'abc123...'; // NEVER DO THIS -``` - -## Available Tools - -This skill provides 34 type-safe tools organized into 6 categories: - -- **Workspaces** (7 tools) - `scripts/workspaces/` - - Create, configure, update workspaces - - Manage workspace tags - - Create No Code module workspaces - -- **Runs** (3 tools) - `scripts/runs/` - - Create and trigger runs - - Get run details and status - - List runs with filtering - -- **Variables** (9 tools) - `scripts/variables/` - - Create/update/delete workspace variables - - Manage variable sets - - Attach/detach variable sets to workspaces - -- **Public Registry** (9 tools) - `scripts/public-registry/` - - Search modules, providers, and policies - - Get module/provider details and documentation - - Get provider capabilities - -- **Private Registry** (4 tools) - `scripts/private-registry/` - - Search private modules and providers - - Get private module/provider details - -- **Organization** (2 tools) - `scripts/organization/` - - List Terraform organizations - - List projects in an organization - -**For detailed parameters and types**, see the TypeScript files in each category directory. All functions include full type definitions and JSDoc comments for IDE autocomplete. - -## Quick Start - -```typescript -import { initializeMCPClient, closeMCPClient } from './scripts/client.js'; -import { CreateWorkspace } from './scripts/workspaces/index.js'; -import { CreateRun } from './scripts/runs/index.js'; - -// 1. Initialize connection -await initializeMCPClient({ - command: 'docker', - args: [ - 'run', - '-i', - '--rm', - '-e', - `TFE_TOKEN=${process.env.TFE_TOKEN}`, - 'hashicorp/terraform-mcp-server', - ], -}); - -try { - // 2. Create a workspace - const workspace = await CreateWorkspace({ - workspace_name: 'my-infrastructure', - terraform_org_name: 'my-org', - auto_apply: 'false', - }); - - // 3. Trigger a run - const run = await CreateRun({ - workspace_name: 'my-infrastructure', - terraform_org_name: 'my-org', - message: 'Initial deployment', - }); -} finally { - // 4. Clean up - await closeMCPClient(); -} -``` - -## Common Workflows - -### Workflow 1: Create Infrastructure Workspace - -```typescript -// Use case: Setting up a new production environment for an API service -import { CreateWorkspace } from './scripts/workspaces/index.js'; - -const workspace = await CreateWorkspace({ - workspace_name: 'production-api', - terraform_org_name: 'acme-corp', - description: 'Production API infrastructure', - auto_apply: 'false', // Require manual approval for production - execution_mode: 'remote', - terraform_version: '1.6.0', - tags: 'production,api,critical', -}); -``` - -### Workflow 2: Find and Use Registry Module - -```typescript -// Use case: Discovering the right VPC module for AWS infrastructure -import { SearchModules, GetModuleDetails } from './scripts/public-registry/index.js'; - -// 1. Search for VPC modules -const modules = await SearchModules({ - module_query: 'vpc aws terraform-aws-modules', -}); - -// 2. Get detailed documentation for the best match -const moduleDetails = await GetModuleDetails({ - module_id: 'terraform-aws-modules/vpc/aws/5.1.2', -}); - -console.log(moduleDetails.content[0].text); -``` - -### Workflow 3: Configure Workspace Variables - -```typescript -// Use case: Setting up environment-specific configuration -import { - CreateVariableSet, - CreateVariableInVariableSet, - AttachVariableSetToWorkspaces, -} from './scripts/variables/index.js'; - -// 1. Create a variable set for AWS credentials -const varSet = await CreateVariableSet({ - terraform_org_name: 'acme-corp', - name: 'aws-production-credentials', - description: 'AWS credentials for production workspaces', - global: false, -}); - -// 2. Add variables to the set -await CreateVariableInVariableSet({ - variable_set_id: varSet.id, - key: 'AWS_REGION', - value: 'us-east-1', - category: 'env', - sensitive: false, -}); - -// 3. Attach to workspaces -await AttachVariableSetToWorkspaces({ - variable_set_id: varSet.id, - workspace_ids: 'ws-123,ws-456,ws-789', -}); -``` - -### Workflow 4: Trigger and Monitor Runs - -```typescript -// Use case: Deploying infrastructure changes with monitoring -import { CreateRun, GetRunDetails } from './scripts/runs/index.js'; - -// 1. Trigger a run -const run = await CreateRun({ - workspace_name: 'production-api', - terraform_org_name: 'acme-corp', - message: 'Deploy v2.1.0 API changes', - run_type: 'plan-and-apply', -}); - -// 2. Monitor run status -const runDetails = await GetRunDetails({ - run_id: run.id, -}); - -console.log(`Run status: ${runDetails.status}`); -console.log(`Plan output: ${runDetails.content[0].text}`); -``` - -## Using the TypeScript Wrappers - -Import from category indexes or individual files: - -```typescript -// Import from category index -import { CreateWorkspace, UpdateWorkspace, ListWorkspaces } from './scripts/workspaces/index.js'; - -// Or import specific tool with types -import { - CreateWorkspace, - CreateWorkspaceInput, - CreateWorkspaceOutput, -} from './scripts/workspaces/createWorkspace.js'; -``` - -All wrapper functions are fully typed with Input/Output interfaces. Use your IDE's autocomplete to discover parameters and see JSDoc documentation. - -## Error Handling - -```typescript -try { - const result = await CreateWorkspace({ - workspace_name: 'my-workspace', - terraform_org_name: 'my-org', - }); - - if (result.isError) { - console.error('Workspace creation failed:', result.content); - } else { - console.log('Workspace created successfully'); - } -} catch (error) { - console.error('MCP call failed:', error); -} -``` - -## Testing This Skill - -**Before Using:** - -1. Verify `TFE_TOKEN` is set: `echo $TFE_TOKEN` -2. Confirm Docker is running: `docker --version` -3. Test MCP server connectivity: - ```bash - docker run -i --rm -e TFE_TOKEN=$TFE_TOKEN hashicorp/terraform-mcp-server - ``` - -**Troubleshooting:** - -- **Connection errors**: Verify Docker is running and token is valid -- **Authentication failures**: Check `TFE_TOKEN` has correct permissions for the operation -- **Type errors**: Ensure you're using the correct Input interface for each function - -## Architecture - -- **`scripts/client.ts`** - MCP connection manager (`initializeMCPClient`, `callMCPTool`, `closeMCPClient`) -- **`scripts/{category}/`** - Type-safe wrapper functions organized by category - - Each tool has its own `.ts` file with Input/Output interfaces - - `index.ts` provides barrel exports for convenient importing -- **Full type safety** - All interfaces generated from JSON Schema definitions - -## Limitations - -**This skill is NOT suitable for:** - -- Direct Terraform CLI operations (use Terraform CLI directly instead) -- Local Terraform state management (this is for Cloud/Enterprise only) -- Terraform configuration generation (use Terraform language skills instead) -- Non-Terraform infrastructure management - ---- - -_This skill was auto-generated by [mcp-to-claude-skill](https://github.com/hashi-demo-lab/mcp-to-claude-skill)_ diff --git a/.claude/skills/terraform-mcp-as-code/scripts/client.ts b/.claude/skills/terraform-mcp-as-code/scripts/client.ts deleted file mode 100644 index 3bc822d..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/client.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * MCP Client Helper - * Auto-generated by mcp-to-claude-skill - * - * This module provides a helper function to call MCP tools with type safety. - */ - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; - -interface MCPClientConfig { - command: string; - args: string[]; -} - -/** - * Global MCP client instance - */ -let mcpClient: Client | null = null; -let mcpTransport: StdioClientTransport | null = null; -let isConnected = false; - -/** - * Initialize the MCP client connection - * This should be called once before using any tools - */ -export async function initializeMCPClient(config: MCPClientConfig): Promise { - if (isConnected) { - console.warn('MCP client already initialized'); - return; - } - - mcpClient = new Client( - { - name: 'mcp-skill', - version: '1.0.0', - }, - { - capabilities: {}, - } - ); - - mcpTransport = new StdioClientTransport({ - command: config.command, - args: config.args, - }); - - await mcpClient.connect(mcpTransport); - isConnected = true; -} - -/** - * Close the MCP client connection - */ -export async function closeMCPClient(): Promise { - if (mcpClient && isConnected) { - await mcpClient.close(); - isConnected = false; - mcpClient = null; - mcpTransport = null; - } -} - -/** - * Call an MCP tool with type-safe inputs and outputs - * - * @param toolName - The name of the MCP tool to call - * @param input - The input parameters for the tool - * @returns The tool response - */ -export async function callMCPTool(toolName: string, input: any): Promise { - if (!mcpClient || !isConnected) { - throw new Error('MCP client not initialized. Call initializeMCPClient() first.'); - } - - try { - const response = await mcpClient.callTool({ - name: toolName, - arguments: input, - }); - - // MCP returns content array, extract the text content - if (response.content && Array.isArray(response.content)) { - // Try to parse JSON from text content - const textContent = response.content.find((c) => c.type === 'text'); - if (textContent && 'text' in textContent) { - try { - return JSON.parse(textContent.text) as TOutput; - } catch { - // If not JSON, return the raw text wrapped in the output structure - return { - content: response.content, - isError: response.isError, - } as TOutput; - } - } - } - - // Return raw response if no text content - return response as TOutput; - } catch (error) { - if (error instanceof Error) { - throw new Error(`MCP tool call failed: ${error.message}`); - } - throw error; - } -} - -/** - * Check if the MCP client is connected - */ -export function isClientConnected(): boolean { - return isConnected; -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/organization/index.ts b/.claude/skills/terraform-mcp-as-code/scripts/organization/index.ts deleted file mode 100644 index c974e29..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/organization/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Auto-generated barrel export - * Generated by mcp-to-claude-skill - */ - -export * from './listTerraformOrgs.js'; -export * from './listTerraformProjects.js'; diff --git a/.claude/skills/terraform-mcp-as-code/scripts/organization/listTerraformOrgs.ts b/.claude/skills/terraform-mcp-as-code/scripts/organization/listTerraformOrgs.ts deleted file mode 100644 index fb5c27e..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/organization/listTerraformOrgs.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * list_terraform_orgs - * Fetches a list of all Terraform organizations. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface ListTerraformOrgsInput { - /** - * Page number for pagination (min 1) - */ - page?: number; - /** - * Results per page for pagination (min 1, max 100) - */ - pageSize?: number; - [k: string]: unknown; -} - -export interface ListTerraformOrgsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches a list of all Terraform organizations. - */ -export async function ListTerraformOrgs( - input: ListTerraformOrgsInput -): Promise { - return await callMCPTool('list_terraform_orgs', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/organization/listTerraformProjects.ts b/.claude/skills/terraform-mcp-as-code/scripts/organization/listTerraformProjects.ts deleted file mode 100644 index 0c78415..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/organization/listTerraformProjects.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * list_terraform_projects - * Fetches a list of all Terraform projects. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface ListTerraformProjectsInput { - /** - * Page number for pagination (min 1) - */ - page?: number; - /** - * Results per page for pagination (min 1, max 100) - */ - pageSize?: number; - /** - * The name of the Terraform organization to list projects for. - */ - terraform_org_name: string; - [k: string]: unknown; -} - -export interface ListTerraformProjectsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches a list of all Terraform projects. - */ -export async function ListTerraformProjects( - input: ListTerraformProjectsInput -): Promise { - return await callMCPTool('list_terraform_projects', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/getPrivateModuleDetails.ts b/.claude/skills/terraform-mcp-as-code/scripts/private-registry/getPrivateModuleDetails.ts deleted file mode 100644 index 4c9e47b..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/getPrivateModuleDetails.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * get_private_module_details - * This tool retrieves detailed information about a specific private module in your Terraform Cloud/Enterprise organization. -It provides comprehensive details including inputs, outputs, dependencies, versions, and usage examples. The private_module_id format is 'module-namespace/module-name/module-provider-name'. -This can be obtained by calling 'search_private_modules' first to obtain the exact private_module_id required to use this tool. This tool requires a valid Terraform token to be configured. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetPrivateModuleDetailsInput { - /** - * The private module ID should be in the format 'module-namespace/module-name/module-provider-name' (for example, 'my-tfc-org/vpc/aws' or 'my-module-namespace/vm/azurerm'). - * The module-namespace is usually the name of the Terraform organization. Obtain this ID by calling 'search_private_modules'. - */ - private_module_id: string; - /** - * Specific version of the module to retrieve details for. If not provided, the latest version will be used - */ - private_module_version?: string; - /** - * The type of Terraform registry to search within Terraform Cloud/Enterprise (e.g., 'private', 'public') - */ - registry_name?: 'private' | 'public'; - /** - * The Terraform Cloud/Enterprise organization name - */ - terraform_org_name: string; - [k: string]: unknown; -} - -export interface GetPrivateModuleDetailsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * This tool retrieves detailed information about a specific private module in your Terraform Cloud/Enterprise organization. -It provides comprehensive details including inputs, outputs, dependencies, versions, and usage examples. The private_module_id format is 'module-namespace/module-name/module-provider-name'. -This can be obtained by calling 'search_private_modules' first to obtain the exact private_module_id required to use this tool. This tool requires a valid Terraform token to be configured. - */ -export async function GetPrivateModuleDetails( - input: GetPrivateModuleDetailsInput -): Promise { - return await callMCPTool('get_private_module_details', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/getPrivateProviderDetails.ts b/.claude/skills/terraform-mcp-as-code/scripts/private-registry/getPrivateProviderDetails.ts deleted file mode 100644 index dc5010b..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/getPrivateProviderDetails.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * get_private_provider_details - * This tool retrieves information about a specific private provider in your Terraform Cloud/Enterprise organization. -It provides details on how to use the provider, permissions, available versions, and more. This tool requires a valid Terraform token to be configured. - - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetPrivateProviderDetailsInput { - /** - * Whether to include detailed version information - */ - include_versions?: boolean; - /** - * The name of the private provider - */ - private_provider_name: string; - /** - * The namespace of the private provider in your Terraform Cloud/Enterprise organization. For public registry, use the namespace from the public Terraform registry. - */ - private_provider_namespace: string; - /** - * The type of Terraform registry to search within Terraform Cloud/Enterprise (e.g., 'private', 'public') - */ - registry_name?: 'private' | 'public'; - /** - * The Terraform Cloud/Enterprise organization name - */ - terraform_org_name: string; - [k: string]: unknown; -} - -export interface GetPrivateProviderDetailsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * This tool retrieves information about a specific private provider in your Terraform Cloud/Enterprise organization. -It provides details on how to use the provider, permissions, available versions, and more. This tool requires a valid Terraform token to be configured. - - */ -export async function GetPrivateProviderDetails( - input: GetPrivateProviderDetailsInput -): Promise { - return await callMCPTool('get_private_provider_details', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/index.ts b/.claude/skills/terraform-mcp-as-code/scripts/private-registry/index.ts deleted file mode 100644 index 3547ccf..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Auto-generated barrel export - * Generated by mcp-to-claude-skill - */ - -export * from './getPrivateModuleDetails.js'; -export * from './getPrivateProviderDetails.js'; -export * from './searchPrivateModules.js'; -export * from './searchPrivateProviders.js'; diff --git a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/searchPrivateModules.ts b/.claude/skills/terraform-mcp-as-code/scripts/private-registry/searchPrivateModules.ts deleted file mode 100644 index c8a9091..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/searchPrivateModules.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * search_private_modules - * This tool searches for private modules in your Terraform Cloud/Enterprise organization. -It retrieves a list of private modules that match the search criteria. This tool requires a valid Terraform token to be configured. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface SearchPrivateModulesInput { - /** - * Page number for pagination (starts at 1) - */ - page_number?: number; - /** - * Number of results to return per page (max 100) - */ - page_size?: number; - /** - * Optional search query to filter modules by name or namespace. If not provided, all modules will be returned - */ - search_query?: string; - /** - * The Terraform Cloud/Enterprise organization name to search within - */ - terraform_org_name: string; - [k: string]: unknown; -} - -export interface SearchPrivateModulesOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * This tool searches for private modules in your Terraform Cloud/Enterprise organization. -It retrieves a list of private modules that match the search criteria. This tool requires a valid Terraform token to be configured. - */ -export async function SearchPrivateModules( - input: SearchPrivateModulesInput -): Promise { - return await callMCPTool('search_private_modules', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/searchPrivateProviders.ts b/.claude/skills/terraform-mcp-as-code/scripts/private-registry/searchPrivateProviders.ts deleted file mode 100644 index 4e45df4..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/private-registry/searchPrivateProviders.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * search_private_providers - * This tool searches for private providers in your Terraform Cloud/Enterprise organization. -It retrieves a list of private providers that match the search criteria. This tool requires a valid Terraform token to be configured. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface SearchPrivateProvidersInput { - /** - * Page number for pagination (starts at 1) - */ - page_number?: number; - /** - * Number of results to return per page (max 100) - */ - page_size?: number; - /** - * The type of Terraform registry to search within Terraform Cloud/Enterprise (e.g., 'private', 'public') - */ - registry_name?: 'private' | 'public'; - /** - * Optional search query to filter providers by name or namespace. If not provided, all providers will be returned - */ - search_query?: string; - /** - * The Terraform Cloud/Enterprise organization name to search within - */ - terraform_org_name: string; - [k: string]: unknown; -} - -export interface SearchPrivateProvidersOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * This tool searches for private providers in your Terraform Cloud/Enterprise organization. -It retrieves a list of private providers that match the search criteria. This tool requires a valid Terraform token to be configured. - */ -export async function SearchPrivateProviders( - input: SearchPrivateProvidersInput -): Promise { - return await callMCPTool('search_private_providers', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getLatestModuleVersion.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getLatestModuleVersion.ts deleted file mode 100644 index d436d0e..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getLatestModuleVersion.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * get_latest_module_version - * Fetches the latest version of a Terraform module from the public registry - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetLatestModuleVersionInput { - /** - * The name of the module, this is usually the service or group of service the user is deploying e.g., 'security-group', 'secrets-manager' etc. - */ - module_name: string; - /** - * The name of the Terraform provider for the module, e.g., 'aws', 'google', 'azurerm' etc. - */ - module_provider: string; - /** - * The publisher of the module, e.g., 'hashicorp', 'aws-ia', 'terraform-google-modules', 'Azure' etc. - */ - module_publisher: string; - [k: string]: unknown; -} - -export interface GetLatestModuleVersionOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches the latest version of a Terraform module from the public registry - */ -export async function GetLatestModuleVersion( - input: GetLatestModuleVersionInput -): Promise { - return await callMCPTool('get_latest_module_version', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getLatestProviderVersion.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getLatestProviderVersion.ts deleted file mode 100644 index a8c1aba..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getLatestProviderVersion.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * get_latest_provider_version - * Fetches the latest version of a Terraform provider from the public registry - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetLatestProviderVersionInput { - /** - * The name of the Terraform provider, e.g., 'aws', 'azurerm', 'google', etc. - */ - name: string; - /** - * The namespace of the Terraform provider, typically the name of the company, or their GitHub organization name that created the provider e.g., 'hashicorp' - */ - namespace: string; - [k: string]: unknown; -} - -export interface GetLatestProviderVersionOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches the latest version of a Terraform provider from the public registry - */ -export async function GetLatestProviderVersion( - input: GetLatestProviderVersionInput -): Promise { - return await callMCPTool('get_latest_provider_version', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getModuleDetails.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getModuleDetails.ts deleted file mode 100644 index bf92463..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getModuleDetails.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * get_module_details - * Fetches up-to-date documentation on how to use a Terraform module. You must call 'search_modules' first to obtain the exact valid and compatible module_id required to use this tool. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetModuleDetailsInput { - /** - * Exact valid and compatible module_id retrieved from search_modules (e.g., 'squareops/terraform-kubernetes-mongodb/mongodb/2.1.1', 'GoogleCloudPlatform/vertex-ai/google/0.2.0') - */ - module_id: string; - [k: string]: unknown; -} - -export interface GetModuleDetailsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches up-to-date documentation on how to use a Terraform module. You must call 'search_modules' first to obtain the exact valid and compatible module_id required to use this tool. - */ -export async function GetModuleDetails( - input: GetModuleDetailsInput -): Promise { - return await callMCPTool('get_module_details', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getPolicyDetails.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getPolicyDetails.ts deleted file mode 100644 index 7a667c7..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getPolicyDetails.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * get_policy_details - * Fetches up-to-date documentation for a specific policy from the Terraform registry. You must call 'search_policies' first to obtain the exact terraform_policy_id required to use this tool. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetPolicyDetailsInput { - /** - * Matching terraform_policy_id retrieved from the 'search_policies' tool (e.g., 'policies/hashicorp/CIS-Policy-Set-for-AWS-Terraform/1.0.1') - */ - terraform_policy_id: string; - [k: string]: unknown; -} - -export interface GetPolicyDetailsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches up-to-date documentation for a specific policy from the Terraform registry. You must call 'search_policies' first to obtain the exact terraform_policy_id required to use this tool. - */ -export async function GetPolicyDetails( - input: GetPolicyDetailsInput -): Promise { - return await callMCPTool('get_policy_details', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getProviderCapabilities.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getProviderCapabilities.ts deleted file mode 100644 index 897ac47..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getProviderCapabilities.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * get_provider_capabilities - * Get the capabilities of a Terraform provider including the types of resources, data sources, functions, guides, and other features it supports. -This tool analyzes the provider documentation to determine what types of capabilities are available: -- resources: Infrastructure resources that can be created/managed -- data-sources: Read-only data sources for querying existing infrastructure -- functions: Provider-specific functions for data transformation -- guides: Documentation guides and tutorials for using the provider -- actions: Available provider actions (if any) -- ephemeral resources: Temporary resources for credentials and tokens -- list resources: Resources for listing multiple items of specific types - -Returns a summary with counts and examples for each capability type. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetProviderCapabilitiesInput { - /** - * The name of the Terraform provider, e.g., 'aws', 'azurerm', 'google', etc. - */ - name: string; - /** - * The namespace of the Terraform provider, typically the name of the company, or their GitHub organization name that created the provider e.g., 'hashicorp' - */ - namespace: string; - /** - * The version of the provider to analyze (defaults to 'latest') - */ - version?: string; - [k: string]: unknown; -} - -export interface GetProviderCapabilitiesOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Get the capabilities of a Terraform provider including the types of resources, data sources, functions, guides, and other features it supports. -This tool analyzes the provider documentation to determine what types of capabilities are available: -- resources: Infrastructure resources that can be created/managed -- data-sources: Read-only data sources for querying existing infrastructure -- functions: Provider-specific functions for data transformation -- guides: Documentation guides and tutorials for using the provider -- actions: Available provider actions (if any) -- ephemeral resources: Temporary resources for credentials and tokens -- list resources: Resources for listing multiple items of specific types - -Returns a summary with counts and examples for each capability type. - */ -export async function GetProviderCapabilities( - input: GetProviderCapabilitiesInput -): Promise { - return await callMCPTool('get_provider_capabilities', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getProviderDetails.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getProviderDetails.ts deleted file mode 100644 index 1564be9..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/getProviderDetails.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * get_provider_details - * Fetches up-to-date documentation for a specific service from a Terraform provider. -You must call 'search_providers' tool first to obtain the exact tfprovider-compatible provider_doc_id required to use this tool. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetProviderDetailsInput { - /** - * Exact tfprovider-compatible provider_doc_id, (e.g., '8894603', '8906901') retrieved from 'search_providers' - */ - provider_doc_id: string; - [k: string]: unknown; -} - -export interface GetProviderDetailsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches up-to-date documentation for a specific service from a Terraform provider. -You must call 'search_providers' tool first to obtain the exact tfprovider-compatible provider_doc_id required to use this tool. - */ -export async function GetProviderDetails( - input: GetProviderDetailsInput -): Promise { - return await callMCPTool('get_provider_details', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/index.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/index.ts deleted file mode 100644 index fa5553d..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Auto-generated barrel export - * Generated by mcp-to-claude-skill - */ - -export * from './getLatestModuleVersion.js'; -export * from './getLatestProviderVersion.js'; -export * from './getModuleDetails.js'; -export * from './getPolicyDetails.js'; -export * from './getProviderCapabilities.js'; -export * from './getProviderDetails.js'; -export * from './searchModules.js'; -export * from './searchPolicies.js'; -export * from './searchProviders.js'; diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchModules.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchModules.ts deleted file mode 100644 index 99b90bb..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchModules.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * search_modules - * Resolves a Terraform module name to obtain a compatible module_id for the get_module_details tool and returns a list of matching Terraform modules. -You MUST call this function before 'get_module_details' to obtain a valid and compatible module_id. -When selecting the best match, consider the following: - - Name similarity to the query - - Description relevance - - Verification status (verified) - - Download counts (popularity) -Return the selected module_id and explain your choice. If there are multiple good matches, mention this but proceed with the most relevant one. -If no modules were found, reattempt the search with a new moduleName query. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface SearchModulesInput { - /** - * Current offset for pagination - */ - current_offset?: number; - /** - * The query to search for Terraform modules. - */ - module_query: string; - [k: string]: unknown; -} - -export interface SearchModulesOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Resolves a Terraform module name to obtain a compatible module_id for the get_module_details tool and returns a list of matching Terraform modules. -You MUST call this function before 'get_module_details' to obtain a valid and compatible module_id. -When selecting the best match, consider the following: - - Name similarity to the query - - Description relevance - - Verification status (verified) - - Download counts (popularity) -Return the selected module_id and explain your choice. If there are multiple good matches, mention this but proceed with the most relevant one. -If no modules were found, reattempt the search with a new moduleName query. - */ -export async function SearchModules(input: SearchModulesInput): Promise { - return await callMCPTool('search_modules', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchPolicies.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchPolicies.ts deleted file mode 100644 index 4ab260a..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchPolicies.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * search_policies - * Searches for Terraform policies based on a query string. -This tool returns a list of matching policies, which can be used to retrieve detailed policy information using the 'get_policy_details' tool. -You MUST call this function before 'get_policy_details' to obtain a valid terraform_policy_id. -When selecting the best match, consider the following: - - Name similarity to the query - - Title relevance - - Verification status (verified) - - Download counts (popularity) -Return the selected policyID and explain your choice. If there are multiple good matches, mention this but proceed with the most relevant one. -If no policies were found, reattempt the search with a new policy_query. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface SearchPoliciesInput { - /** - * The query to search for Terraform modules. - */ - policy_query: string; - [k: string]: unknown; -} - -export interface SearchPoliciesOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Searches for Terraform policies based on a query string. -This tool returns a list of matching policies, which can be used to retrieve detailed policy information using the 'get_policy_details' tool. -You MUST call this function before 'get_policy_details' to obtain a valid terraform_policy_id. -When selecting the best match, consider the following: - - Name similarity to the query - - Title relevance - - Verification status (verified) - - Download counts (popularity) -Return the selected policyID and explain your choice. If there are multiple good matches, mention this but proceed with the most relevant one. -If no policies were found, reattempt the search with a new policy_query. - */ -export async function SearchPolicies(input: SearchPoliciesInput): Promise { - return await callMCPTool('search_policies', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchProviders.ts b/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchProviders.ts deleted file mode 100644 index e35e7c3..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/public-registry/searchProviders.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * search_providers - * This tool retrieves a list of potential documents based on the 'service_slug' and 'provider_document_type' provided. -You MUST call this function before 'get_provider_details' to obtain a valid tfprovider-compatible 'provider_doc_id'. -Use the most relevant single word as the search query for 'service_slug', if unsure about the 'service_slug', use the 'provider_name' for its value. -When selecting the best match, consider the following: - - Title similarity to the query - - Category relevance -Return the selected 'provider_doc_id' and explain your choice. -If there are multiple good matches, mention this but proceed with the most relevant one. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface SearchProvidersInput { - /** - * The type of the document to retrieve, - * for general overview of the provider use 'overview', - * for guidance on upgrading a provider or custom configuration information use 'guides', - * for deploying resources use 'resources', for reading pre-deployed resources use 'data-sources', - * for functions use 'functions', - * for Terraform actions use 'actions' - */ - provider_document_type: - | 'resources' - | 'data-sources' - | 'functions' - | 'guides' - | 'overview' - | 'actions'; - /** - * The name of the Terraform provider to perform the read or deployment operation - */ - provider_name: string; - /** - * The publisher of the Terraform provider, typically the name of the company, or their GitHub organization name that created the provider - */ - provider_namespace: string; - /** - * The version of the Terraform provider to retrieve in the format 'x.y.z', or 'latest' to get the latest version - */ - provider_version?: string; - /** - * The slug of the service you want to deploy or read using the Terraform provider, prefer using a single word, use underscores for multiple words and if unsure about the service_slug, use the provider_name for its value - */ - service_slug: string; - [k: string]: unknown; -} - -export interface SearchProvidersOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * This tool retrieves a list of potential documents based on the 'service_slug' and 'provider_document_type' provided. -You MUST call this function before 'get_provider_details' to obtain a valid tfprovider-compatible 'provider_doc_id'. -Use the most relevant single word as the search query for 'service_slug', if unsure about the 'service_slug', use the 'provider_name' for its value. -When selecting the best match, consider the following: - - Title similarity to the query - - Category relevance -Return the selected 'provider_doc_id' and explain your choice. -If there are multiple good matches, mention this but proceed with the most relevant one. - */ -export async function SearchProviders(input: SearchProvidersInput): Promise { - return await callMCPTool('search_providers', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/runs/createRun.ts b/.claude/skills/terraform-mcp-as-code/scripts/runs/createRun.ts deleted file mode 100644 index 86aeaee..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/runs/createRun.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * create_run - * Creates a new Terraform run in the specified workspace. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface CreateRunInput { - /** - * Optional message for the run - */ - message?: string; - /** - * A run type for the run - */ - run_type?: 'plan_and_apply' | 'refresh_state' | 'plan_only' | 'allow_empty_apply'; - /** - * The Terraform Cloud/Enterprise organization name - */ - terraform_org_name: string; - /** - * The name of the workspace to create a run in - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface CreateRunOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Creates a new Terraform run in the specified workspace. - */ -export async function CreateRun(input: CreateRunInput): Promise { - return await callMCPTool('create_run', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/runs/getRunDetails.ts b/.claude/skills/terraform-mcp-as-code/scripts/runs/getRunDetails.ts deleted file mode 100644 index aada583..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/runs/getRunDetails.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * get_run_details - * Fetches detailed information about a specific Terraform run. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetRunDetailsInput { - /** - * The ID of the run to get details for - */ - run_id: string; - [k: string]: unknown; -} - -export interface GetRunDetailsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches detailed information about a specific Terraform run. - */ -export async function GetRunDetails(input: GetRunDetailsInput): Promise { - return await callMCPTool('get_run_details', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/runs/index.ts b/.claude/skills/terraform-mcp-as-code/scripts/runs/index.ts deleted file mode 100644 index 1dde745..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/runs/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Auto-generated barrel export - * Generated by mcp-to-claude-skill - */ - -export * from './createRun.js'; -export * from './getRunDetails.js'; -export * from './listRuns.js'; diff --git a/.claude/skills/terraform-mcp-as-code/scripts/runs/listRuns.ts b/.claude/skills/terraform-mcp-as-code/scripts/runs/listRuns.ts deleted file mode 100644 index 9f96e90..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/runs/listRuns.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * list_runs - * List or search Terraform runs in a specific workspace with optional filtering. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface ListRunsInput { - /** - * Page number for pagination (min 1) - */ - page?: number; - /** - * Results per page for pagination (min 1, max 100) - */ - pageSize?: number; - /** - * Optional run status filter - */ - status?: ( - | 'pending' - | 'fetching' - | 'fetching_completed' - | 'pre_plan_running' - | 'pre_plan_completed' - | 'queuing' - | 'plan_queued' - | 'planning' - | 'planned' - | 'cost_estimating' - | 'cost_estimated' - | 'policy_checking' - | 'policy_override' - | 'policy_soft_failed' - | 'policy_checked' - | 'confirmed' - | 'post_plan_running' - | 'post_plan_completed' - | 'planned_and_finished' - | 'planned_and_saved' - | 'apply_queued' - | 'applying' - | 'applied' - | 'discarded' - | 'errored' - | 'canceled' - | 'force_canceled' - )[]; - /** - * Lists the runs in Terraform Cloud/Enterprise organization based on filters if no workspace is specified - */ - terraform_org_name: string; - /** - * Searches for runs that match the VCS username you supply - */ - vcs_username?: string; - /** - * If specified, lists the runs in the given workspace instead of the organization based on filters - */ - workspace_name?: string; - [k: string]: unknown; -} - -export interface ListRunsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * List or search Terraform runs in a specific workspace with optional filtering. - */ -export async function ListRuns(input: ListRunsInput): Promise { - return await callMCPTool('list_runs', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/attachVariableSetToWorkspaces.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/attachVariableSetToWorkspaces.ts deleted file mode 100644 index b376cde..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/attachVariableSetToWorkspaces.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * attach_variable_set_to_workspaces - * Attach a variable set to one or more workspaces. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface AttachVariableSetToWorkspacesInput { - /** - * Variable set ID - */ - variable_set_id: string; - /** - * Comma-separated list of workspace IDs - */ - workspace_ids: string; - [k: string]: unknown; -} - -export interface AttachVariableSetToWorkspacesOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Attach a variable set to one or more workspaces. - */ -export async function AttachVariableSetToWorkspaces( - input: AttachVariableSetToWorkspacesInput -): Promise { - return await callMCPTool( - 'attach_variable_set_to_workspaces', - input - ); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/createVariableInVariableSet.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/createVariableInVariableSet.ts deleted file mode 100644 index 4f0e32c..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/createVariableInVariableSet.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * create_variable_in_variable_set - * Create a new variable in a variable set. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface CreateVariableInVariableSetInput { - /** - * Variable category: terraform or env - */ - category?: 'terraform' | 'env'; - /** - * Variable description - */ - description?: string; - /** - * Whether variable is HCL: true or false - */ - hcl?: boolean; - /** - * Variable key/name - */ - key: string; - /** - * Whether variable is sensitive: true or false - */ - sensitive?: boolean; - /** - * Variable value - */ - value: string; - /** - * Variable set ID - */ - variable_set_id: string; - [k: string]: unknown; -} - -export interface CreateVariableInVariableSetOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Create a new variable in a variable set. - */ -export async function CreateVariableInVariableSet( - input: CreateVariableInVariableSetInput -): Promise { - return await callMCPTool( - 'create_variable_in_variable_set', - input - ); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/createVariableSet.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/createVariableSet.ts deleted file mode 100644 index 22f0daf..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/createVariableSet.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * create_variable_set - * Create a new variable set in an organization. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface CreateVariableSetInput { - /** - * Variable set description - */ - description?: string; - /** - * Whether variable set is global: true or false - */ - global?: boolean; - /** - * Variable set name - */ - name: string; - /** - * Organization name - */ - terraform_org_name: string; - [k: string]: unknown; -} - -export interface CreateVariableSetOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Create a new variable set in an organization. - */ -export async function CreateVariableSet( - input: CreateVariableSetInput -): Promise { - return await callMCPTool('create_variable_set', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/createWorkspaceVariable.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/createWorkspaceVariable.ts deleted file mode 100644 index 624e359..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/createWorkspaceVariable.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * create_workspace_variable - * Create a new variable in a Terraform workspace. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface CreateWorkspaceVariableInput { - /** - * Variable category: terraform or env - */ - category?: 'terraform' | 'env'; - /** - * Variable description - */ - description?: string; - /** - * Whether variable is HCL: true or false - */ - hcl?: boolean; - /** - * Variable key/name - */ - key: string; - /** - * Whether variable is sensitive: true or false - */ - sensitive?: boolean; - /** - * Organization name - */ - terraform_org_name: string; - /** - * Variable value - */ - value: string; - /** - * Workspace name - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface CreateWorkspaceVariableOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Create a new variable in a Terraform workspace. - */ -export async function CreateWorkspaceVariable( - input: CreateWorkspaceVariableInput -): Promise { - return await callMCPTool('create_workspace_variable', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/deleteVariableInVariableSet.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/deleteVariableInVariableSet.ts deleted file mode 100644 index 9805112..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/deleteVariableInVariableSet.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * delete_variable_in_variable_set - * Delete a variable in a variable set. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface DeleteVariableInVariableSetInput { - /** - * Variable ID to delete - */ - variable_id: string; - /** - * Variable set ID - */ - variable_set_id: string; - [k: string]: unknown; -} - -export interface DeleteVariableInVariableSetOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Delete a variable in a variable set. - */ -export async function DeleteVariableInVariableSet( - input: DeleteVariableInVariableSetInput -): Promise { - return await callMCPTool( - 'delete_variable_in_variable_set', - input - ); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/detachVariableSetFromWorkspaces.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/detachVariableSetFromWorkspaces.ts deleted file mode 100644 index 49cddc9..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/detachVariableSetFromWorkspaces.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * detach_variable_set_from_workspaces - * Detach a variable set from one or more workspaces. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface DetachVariableSetFromWorkspacesInput { - /** - * Variable set ID - */ - variable_set_id: string; - /** - * Comma-separated list of workspace IDs - */ - workspace_ids: string; - [k: string]: unknown; -} - -export interface DetachVariableSetFromWorkspacesOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Detach a variable set from one or more workspaces. - */ -export async function DetachVariableSetFromWorkspaces( - input: DetachVariableSetFromWorkspacesInput -): Promise { - return await callMCPTool( - 'detach_variable_set_from_workspaces', - input - ); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/index.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/index.ts deleted file mode 100644 index 3397890..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Auto-generated barrel export - * Generated by mcp-to-claude-skill - */ - -export * from './attachVariableSetToWorkspaces.js'; -export * from './createVariableInVariableSet.js'; -export * from './createVariableSet.js'; -export * from './createWorkspaceVariable.js'; -export * from './deleteVariableInVariableSet.js'; -export * from './detachVariableSetFromWorkspaces.js'; -export * from './listVariableSets.js'; -export * from './listWorkspaceVariables.js'; -export * from './updateWorkspaceVariable.js'; diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/listVariableSets.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/listVariableSets.ts deleted file mode 100644 index 561c536..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/listVariableSets.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * list_variable_sets - * List all variable sets in an organization. Returns all if query is empty. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface ListVariableSetsInput { - /** - * Page number for pagination (min 1) - */ - page?: number; - /** - * Results per page for pagination (min 1, max 100) - */ - pageSize?: number; - /** - * Optional filter query for variable set names - */ - query?: string; - /** - * Organization name - */ - terraform_org_name: string; - [k: string]: unknown; -} - -export interface ListVariableSetsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * List all variable sets in an organization. Returns all if query is empty. - */ -export async function ListVariableSets( - input: ListVariableSetsInput -): Promise { - return await callMCPTool('list_variable_sets', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/listWorkspaceVariables.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/listWorkspaceVariables.ts deleted file mode 100644 index 8317548..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/listWorkspaceVariables.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * list_workspace_variables - * List all variables in a Terraform workspace. Returns all variables if query is empty. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface ListWorkspaceVariablesInput { - /** - * Page number for pagination (min 1) - */ - page?: number; - /** - * Results per page for pagination (min 1, max 100) - */ - pageSize?: number; - /** - * Organization name - */ - terraform_org_name: string; - /** - * Workspace name - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface ListWorkspaceVariablesOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * List all variables in a Terraform workspace. Returns all variables if query is empty. - */ -export async function ListWorkspaceVariables( - input: ListWorkspaceVariablesInput -): Promise { - return await callMCPTool('list_workspace_variables', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/variables/updateWorkspaceVariable.ts b/.claude/skills/terraform-mcp-as-code/scripts/variables/updateWorkspaceVariable.ts deleted file mode 100644 index 6a463f1..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/variables/updateWorkspaceVariable.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * update_workspace_variable - * Update an existing variable in a Terraform workspace. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface UpdateWorkspaceVariableInput { - /** - * Variable description - */ - description?: string; - /** - * Whether variable is HCL: true or false - */ - hcl?: boolean; - /** - * Variable key/name - */ - key: string; - /** - * Whether variable is sensitive: true or false - */ - sensitive?: boolean; - /** - * Organization name - */ - terraform_org_name: string; - /** - * Variable value - */ - value: string; - /** - * Variable ID to update - */ - variable_id: string; - /** - * Workspace name - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface UpdateWorkspaceVariableOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Update an existing variable in a Terraform workspace. - */ -export async function UpdateWorkspaceVariable( - input: UpdateWorkspaceVariableInput -): Promise { - return await callMCPTool('update_workspace_variable', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createNoCodeWorkspace.ts b/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createNoCodeWorkspace.ts deleted file mode 100644 index 88e26df..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createNoCodeWorkspace.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * create_no_code_workspace - * Creates a new Terraform No Code module workspace. The tool uses the MCP elicitation feature to automatically discover and collect required variables from the user. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface CreateNoCodeWorkspaceInput { - /** - * Whether to automatically apply changes in the workspace: 'true' or 'false' - */ - auto_apply?: boolean; - /** - * The ID of the No Code module to create a workspace for - */ - no_code_module_id: string; - /** - * The ID of the project to use - */ - project_id: string; - /** - * The name of the workspace to create - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface CreateNoCodeWorkspaceOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Creates a new Terraform No Code module workspace. The tool uses the MCP elicitation feature to automatically discover and collect required variables from the user. - */ -export async function CreateNoCodeWorkspace( - input: CreateNoCodeWorkspaceInput -): Promise { - return await callMCPTool('create_no_code_workspace', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createWorkspace.ts b/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createWorkspace.ts deleted file mode 100644 index ff78160..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createWorkspace.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * create_workspace - * Creates a new Terraform workspace in the specified organization. This is a destructive operation that will create new infrastructure resources. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface CreateWorkspaceInput { - /** - * Whether to automatically apply successful plans: 'true' or 'false' (default: 'false') - */ - auto_apply?: string; - /** - * Optional description for the workspace - */ - description?: string; - /** - * Execution mode: 'remote', 'local', or 'agent' (default: 'remote') - */ - execution_mode?: string; - /** - * Optional project ID to associate the workspace with - */ - project_id?: string; - /** - * Optional comma-separated list of tags to apply to the workspace - */ - tags?: string; - /** - * The Terraform Cloud/Enterprise organization name - */ - terraform_org_name: string; - /** - * Optional Terraform version to use (e.g., '1.5.0') - */ - terraform_version?: string; - /** - * Optional VCS repository branch (default: main/master) - */ - vcs_repo_branch?: string; - /** - * Optional VCS repository identifier (e.g., 'org/repo') - */ - vcs_repo_identifier?: string; - /** - * OAuth token ID for VCS integration - */ - vcs_repo_oauth_token_id?: string; - /** - * Optional working directory for Terraform operations - */ - working_directory?: string; - /** - * The name of the workspace to create - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface CreateWorkspaceOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Creates a new Terraform workspace in the specified organization. This is a destructive operation that will create new infrastructure resources. - */ -export async function CreateWorkspace(input: CreateWorkspaceInput): Promise { - return await callMCPTool('create_workspace', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createWorkspaceTags.ts b/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createWorkspaceTags.ts deleted file mode 100644 index 869fbae..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/createWorkspaceTags.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * create_workspace_tags - * Add tags to a Terraform workspace. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface CreateWorkspaceTagsInput { - /** - * Comma-separated list of tag names to add, for key-value tags use key:value - */ - tags: string; - /** - * Organization name - */ - terraform_org_name: string; - /** - * Workspace name - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface CreateWorkspaceTagsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Add tags to a Terraform workspace. - */ -export async function CreateWorkspaceTags( - input: CreateWorkspaceTagsInput -): Promise { - return await callMCPTool('create_workspace_tags', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/getWorkspaceDetails.ts b/.claude/skills/terraform-mcp-as-code/scripts/workspaces/getWorkspaceDetails.ts deleted file mode 100644 index 029d388..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/getWorkspaceDetails.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * get_workspace_details - * Fetches detailed information about a specific Terraform workspace, including configuration, variables, and current state information. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface GetWorkspaceDetailsInput { - /** - * The Terraform Cloud/Enterprise organization name - */ - terraform_org_name: string; - /** - * The name of the workspace to get details for - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface GetWorkspaceDetailsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Fetches detailed information about a specific Terraform workspace, including configuration, variables, and current state information. - */ -export async function GetWorkspaceDetails( - input: GetWorkspaceDetailsInput -): Promise { - return await callMCPTool('get_workspace_details', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/index.ts b/.claude/skills/terraform-mcp-as-code/scripts/workspaces/index.ts deleted file mode 100644 index 2a2a467..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Auto-generated barrel export - * Generated by mcp-to-claude-skill - */ - -export * from './createNoCodeWorkspace.js'; -export * from './createWorkspace.js'; -export * from './createWorkspaceTags.js'; -export * from './getWorkspaceDetails.js'; -export * from './listWorkspaces.js'; -export * from './readWorkspaceTags.js'; -export * from './updateWorkspace.js'; diff --git a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/listWorkspaces.ts b/.claude/skills/terraform-mcp-as-code/scripts/workspaces/listWorkspaces.ts deleted file mode 100644 index 3e47ced..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/listWorkspaces.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * list_workspaces - * Search and list Terraform workspaces within a specified organization. Returns all workspaces when no filters are applied, or filters results based on name patterns, tags, or search queries. Supports pagination for large result sets. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface ListWorkspacesInput { - /** - * Optional comma-separated list of tags to exclude from results - */ - exclude_tags?: string; - /** - * Page number for pagination (min 1) - */ - page?: number; - /** - * Results per page for pagination (min 1, max 100) - */ - pageSize?: number; - /** - * Optional project ID to filter workspaces - */ - project_id?: string; - /** - * Optional search query to filter workspaces by name - */ - search_query?: string; - /** - * Optional comma-separated list of tags to filter workspaces - */ - tags?: string; - /** - * The Terraform organization name - */ - terraform_org_name: string; - /** - * Optional wildcard pattern to match workspace names - */ - wildcard_name?: string; - [k: string]: unknown; -} - -export interface ListWorkspacesOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Search and list Terraform workspaces within a specified organization. Returns all workspaces when no filters are applied, or filters results based on name patterns, tags, or search queries. Supports pagination for large result sets. - */ -export async function ListWorkspaces(input: ListWorkspacesInput): Promise { - return await callMCPTool('list_workspaces', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/readWorkspaceTags.ts b/.claude/skills/terraform-mcp-as-code/scripts/workspaces/readWorkspaceTags.ts deleted file mode 100644 index 83da8a6..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/readWorkspaceTags.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * read_workspace_tags - * Read all tags from a Terraform workspace. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface ReadWorkspaceTagsInput { - /** - * Organization name - */ - terraform_org_name: string; - /** - * Workspace name - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface ReadWorkspaceTagsOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Read all tags from a Terraform workspace. - */ -export async function ReadWorkspaceTags( - input: ReadWorkspaceTagsInput -): Promise { - return await callMCPTool('read_workspace_tags', input); -} diff --git a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/updateWorkspace.ts b/.claude/skills/terraform-mcp-as-code/scripts/workspaces/updateWorkspace.ts deleted file mode 100644 index 8f21467..0000000 --- a/.claude/skills/terraform-mcp-as-code/scripts/workspaces/updateWorkspace.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * update_workspace - * Updates an existing Terraform workspace configuration. This is a potentially destructive operation that may affect infrastructure resources. - * Auto-generated by mcp-to-claude-skill - */ - -import { callMCPTool } from '../client.js'; - -export interface UpdateWorkspaceInput { - /** - * Whether to automatically apply successful plans: 'true' or 'false' - */ - auto_apply?: string; - /** - * Optional new description for the workspace - */ - description?: string; - /** - * Execution mode: 'remote', 'local', or 'agent' - */ - execution_mode?: string; - /** - * Whether file triggers are enabled: 'true' or 'false' - */ - file_triggers_enabled?: string; - /** - * Optional new name for the workspace - */ - new_name?: string; - /** - * Whether to queue all runs: 'true' or 'false' - */ - queue_all_runs?: string; - /** - * Whether speculative plans are enabled: 'true' or 'false' - */ - speculative_enabled?: string; - /** - * Optional comma-separated list of tags to replace existing tags - */ - tags?: string; - /** - * The Terraform Cloud/Enterprise organization name - */ - terraform_org_name: string; - /** - * Optional new Terraform version to use (e.g., '1.5.0') - */ - terraform_version?: string; - /** - * Optional comma-separated list of trigger prefixes - */ - trigger_prefixes?: string; - /** - * Optional new working directory for Terraform operations - */ - working_directory?: string; - /** - * The name of the workspace to update - */ - workspace_name: string; - [k: string]: unknown; -} - -export interface UpdateWorkspaceOutput { - content?: Array<{ - type: string; - text?: string; - [key: string]: any; - }>; - isError?: boolean; - [key: string]: any; -} - -/** - * Updates an existing Terraform workspace configuration. This is a potentially destructive operation that may affect infrastructure resources. - */ -export async function UpdateWorkspace(input: UpdateWorkspaceInput): Promise { - return await callMCPTool('update_workspace', input); -} diff --git a/.claude/skills/terraform-stacks/CLAUDE.md b/.claude/skills/terraform-stacks/CLAUDE.md deleted file mode 100644 index 600ac49..0000000 --- a/.claude/skills/terraform-stacks/CLAUDE.md +++ /dev/null @@ -1,278 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is a **Claude Skill repository** for HashiCorp Terraform Stacks - a specialized knowledge base that provides comprehensive documentation and guidance for working with Terraform Stacks configurations. This is not a traditional software project with executable code, but rather a documentation repository structured as a skill module for Claude AI assistants. - -**Purpose**: Enable Claude to help users create, modify, validate, and troubleshoot Terraform Stack configurations (`.tfcomponent.hcl` and `.tfdeploy.hcl` files), manage multi-region/multi-environment infrastructure, and understand Terraform Stacks syntax and best practices. - -## Repository Structure - -``` -claude-skill-terraform-stacks/ -β”œβ”€β”€ README.md # Brief project description (2 lines) -β”œβ”€β”€ SKILL.md # Main comprehensive guide (580 lines) -└── references/ # Detailed reference documentation - β”œβ”€β”€ component-blocks.md # Component block specification (649 lines) - β”œβ”€β”€ deployment-blocks.md # Deployment block specification (1009 lines) - └── examples.md # Complete working examples (1529 lines) -``` - -Total: 3,767 lines of documentation organized into focused modules. - -## Documentation Architecture - -### Core Documentation Flow - -1. **[SKILL.md](SKILL.md)** - Start here for high-level concepts, syntax overview, CLI commands, common patterns, and troubleshooting -2. **[references/component-blocks.md](references/component-blocks.md)** - Deep dive into component configuration syntax for `.tfcomponent.hcl` files -3. **[references/deployment-blocks.md](references/deployment-blocks.md)** - Deep dive into deployment configuration syntax for `.tfdeploy.hcl` files -4. **[references/examples.md](references/examples.md)** - Complete working examples from simple to complex scenarios - -### Content Organization - -**SKILL.md covers**: - -- Core concepts (Stack, Component, Deployment, Stack Language) -- File structure and organization -- Configuration blocks: variables, providers, components, outputs, locals, removed blocks -- Deployment configuration syntax -- CLI commands (`terraform stacks validate`, `plan`, `apply`) -- Common patterns (multi-region, component dependencies) -- Best practices and troubleshooting - -**references/component-blocks.md covers**: - -- Complete syntax reference for all component configuration blocks -- Detailed argument specifications with types and constraints -- Code examples for each block type -- Key differences from traditional Terraform syntax - -**references/deployment-blocks.md covers**: - -- Complete syntax reference for all deployment configuration blocks -- Identity token configurations (OIDC) -- Deployment groups and auto-approval rules -- Linked Stacks (publish outputs and upstream inputs) -- Cloud provider-specific configurations (AWS, Azure, GCP) - -**references/examples.md covers**: - -- Simple single-region Stack (with deployment group) -- Stack with private registry modules -- Multi-environment Stack (dev/staging/prod with deployment groups) -- Multi-region Stack with regional provider configurations -- Linked Stacks with cross-stack dependencies -- Multi-cloud Stack (AWS + Azure) -- Complete AWS production Stack with all features -- Destroying deployments safely - -## Key Terraform Stacks Concepts - -### Stack Language vs Traditional Terraform - -Terraform Stacks use a **separate HCL-based language** distinct from traditional Terraform: - -- Different file extensions: `.tfcomponent.hcl` (components), `.tfdeploy.hcl` (deployments) -- Different block syntax for providers (use `for_each`, aliases in headers, `config` blocks) -- Components wrap modules (modules cannot contain provider blocks) -- Outputs and variables require `type` argument -- All files must be at root level (processed in dependency order) - -### Component Module Sources - -Components can reference modules from multiple source types: - -- **Local paths**: `./modules/vpc` or `../shared-modules/networking` -- **Public registry**: `terraform-aws-modules/vpc/aws` (format: `//`) -- **Private registry**: `app.terraform.io/my-org/vpc/aws` (format: `///`) - - HCP Terraform SaaS: Use `app.terraform.io` - - Terraform Enterprise: Use your instance hostname - - Generic hostname: Use `localterraform.com` for multi-instance deployments -- **Git repositories**: `git::https://github.com/org/repo.git//modules/vpc?ref=v1.0.0` -- **HTTP/HTTPS archives**: `https://example.com/modules/vpc.tar.gz` - -The `version` argument is supported only for registry sources (public and private). See [references/component-blocks.md](references/component-blocks.md) for complete details. - -### Critical Architecture Points - -1. **Components are abstractions around modules** - Each component specifies a source module, inputs, and providers -2. **Deployments are instances of the entire Stack** - Used for different environments, regions, or accounts -3. **Each deployment has isolated state** - No shared state between deployments -4. **Dependencies are auto-inferred** - When components reference other component outputs -5. **Provider configurations support `for_each`** - Enable multi-region patterns with single configuration -6. **Deployment groups are essential** - Always organize deployments into deployment groups, even single deployments. This enables auto-approval rules, maintains consistency, and provides a foundation for scaling - -## Common Scenarios and Patterns - -### When Users Ask About Multi-Region Infrastructure - -Guide them to use: - -- `for_each` on provider blocks to create regional providers -- `for_each` on component blocks to deploy per region -- Each region gets its own provider instance and component instance - -See SKILL.md lines 442-476 for the complete pattern. - -### When Users Ask About Multi-Environment Deployments - -Guide them to create: - -- Multiple deployment blocks (one per environment) -- Each deployment gets its own inputs and isolated state -- **Always create deployment groups** to organize deployments (even for single deployments) -- Deployment groups enable auto-approval rules and provide consistent configuration patterns - -**Best Practice**: Every deployment should be organized into a deployment group, even if it's the only deployment in the Stack. This establishes a consistent pattern and enables future scaling. - -See references/examples.md for multi-environment example. - -### When Users Ask About Cross-Stack Dependencies - -Guide them to use: - -- `publish_output` blocks in the source Stack (exports values) -- `upstream_input` blocks in the dependent Stack (imports values) -- Reference upstream inputs in deployment inputs - -See SKILL.md lines 375-407 for syntax. - -### When Users Ask About OIDC Authentication - -Guide them to use: - -- `identity_token` blocks in `.tfdeploy.hcl` with appropriate audience -- Reference token via `identity_token..jwt` -- Pass token to provider configuration in deployment inputs -- Use `assume_role_with_web_identity` in AWS provider config - -### When Users Ask About Destroying/Removing Deployments - -Guide them to: - -1. Set `destroy = true` in the deployment block -2. Apply the plan through HCP Terraform (this destroys all resources) -3. After successful destruction, remove the deployment block from configuration - -**Important**: Using `destroy = true` ensures provider authentication is retained during resource destruction. See references/deployment-blocks.md lines 173-196 and references/examples.md "Destroying Deployments" section. - -## Common Errors and Solutions - -### Provider Configuration Errors - -**Issue**: Providers defined inside modules -**Solution**: All provider configurations must be at Stack level in `.tfcomponent.hcl` files - -### Circular Dependencies - -**Issue**: Component A references Component B, and B references A -**Solution**: Refactor to break circular reference or introduce intermediate component - -### Maximum Deployments Limit - -HCP Terraform supports maximum 20 deployments per Stack. For more instances, use multiple Stacks or `for_each` within components. - -## File Naming Conventions - -Follow these naming patterns for clarity: - -``` -variables.tfcomponent.hcl # Variable declarations -providers.tfcomponent.hcl # Provider configurations -components.tfcomponent.hcl # Component definitions -outputs.tfcomponent.hcl # Stack outputs -deployments.tfdeploy.hcl # Deployment definitions -``` - -All files are processed together by HCP Terraform, so the naming is for human organization only. - -## How to Work with This Repository - -### No Build System - -This is a documentation-only repository: - -- No compilation or build commands -- No package manager or dependencies -- No automated tests -- No Docker or containerization - -### Making Changes - -When updating documentation: - -1. **Maintain consistency across files** - Changes to syntax should be reflected in SKILL.md, appropriate references/ file, and examples.md -2. **Update examples when syntax changes** - All code examples must remain valid and working -3. **Keep the architecture accurate** - The "big picture" concepts in SKILL.md must align with detailed specs in references/ -4. **Test HCL syntax accuracy** - Ensure all code blocks use correct Terraform Stacks HCL syntax (not regular Terraform) -5. **Include deployment groups in all examples** - Every example with deployments must include corresponding deployment_group blocks, even for single deployments - -### Version Control - -Use Git for all changes: - -```bash -git status # Check current changes -git add # Stage changes -git commit -m "message" # Commit changes -git push # Push to remote -``` - -## Documentation Style Guide - -### Code Blocks - -All Terraform Stacks code examples use HCL syntax: - -```hcl -# Correct block structure -component "example" { - source = "./modules/example" - - inputs = { - key = value - } - - providers = { - aws = provider.aws.this - } -} -``` - -### File References - -When referencing syntax details, point to specific files: - -- Detailed component syntax β†’ references/component-blocks.md -- Detailed deployment syntax β†’ references/deployment-blocks.md -- Working examples β†’ references/examples.md - -### Terminology Consistency - -Use these exact terms consistently: - -- **Stack** (not "stack configuration" or "terraform stack") -- **Component** (not "module" - modules are what components wrap) -- **Deployment** (not "environment" - deployments can represent environments) -- **Stack Language** (not "HCL" - it's a separate language based on HCL) - -## CLI Commands (Terraform Stacks) - -```bash -# Generate provider lock file -terraform stacks providers lock - -# Validate Stack configuration -terraform stacks validate - -# Plan specific deployment -terraform stacks plan --deployment= - -# Apply specific deployment -terraform stacks apply --deployment= -``` - -Note: These are **not** regular `terraform` commands - they are `terraform stacks` subcommands specific to Terraform Stacks. diff --git a/.claude/skills/terraform-stacks/README.md b/.claude/skills/terraform-stacks/README.md deleted file mode 100644 index 372b143..0000000 --- a/.claude/skills/terraform-stacks/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# claude-skill-terraform-stacks - -Comprehensive guide for working with HashiCorp Terraform Stacks. Use when creating, modifying, or validating Terraform Stack configurations (.tfcomponent.hcl, .tfdeploy.hcl files), working with stack components and deployments from local modules, public registry, or private registry sources, managing multi-region or multi-environment infrastructure, or troubleshooting Terraform Stacks syntax and structure. diff --git a/.claude/skills/terraform-stacks/SKILL.md b/.claude/skills/terraform-stacks/SKILL.md deleted file mode 100644 index 69687d9..0000000 --- a/.claude/skills/terraform-stacks/SKILL.md +++ /dev/null @@ -1,570 +0,0 @@ ---- -name: terraform-stacks -description: Comprehensive guide for working with HashiCorp Terraform Stacks. Use when creating, modifying, or validating Terraform Stack configurations (.tfcomponent.hcl, .tfdeploy.hcl files), working with stack components and deployments from local modules, or private registry, or private registry sources, managing multi-region or multi-environment infrastructure, or troubleshooting Terraform Stacks syntax and structure. ---- - -# Terraform Stacks - -Terraform Stacks simplify infrastructure provisioning and management at scale by providing a configuration layer above traditional Terraform modules. Stacks enable declarative orchestration of multiple components across environments, regions, and cloud accounts. - -## Core Concepts - -**Stack**: A complete unit of infrastructure composed of components and deployments that can be managed together. - -**Component**: An abstraction around a Terraform module that defines infrastructure pieces. Each component specifies a source module, inputs, and providers. - -**Deployment**: An instance of all components in a stack with specific input values. Use deployments for different environments (dev/staging/prod), regions, or cloud accounts. - -**Stack Language**: A separate HCL-based language (not regular Terraform HCL) with distinct blocks and file extensions. - -## File Structure - -Terraform Stacks use specific file extensions: - -- **Component configuration**: `.tfcomponent.hcl` (newer, recommended) -- **Deployment configuration**: `.tfdeploy.hcl` -- **Provider lock file**: `.terraform.lock.hcl` (generated by CLI) - -All configuration files must be at the root level of the Stack repository. HCP Terraform processes all files in dependency order. - -### Recommended File Organization - -``` -my-stack/ -β”œβ”€β”€ variables.tfcomponent.hcl # Variable declarations -β”œβ”€β”€ providers.tfcomponent.hcl # Provider configurations -β”œβ”€β”€ components.tfcomponent.hcl # Component definitions -β”œβ”€β”€ outputs.tfcomponent.hcl # Stack outputs -β”œβ”€β”€ deployments.tfdeploy.hcl # Deployment definitions -β”œβ”€β”€ .terraform.lock.hcl # Provider lock file (generated) -└── modules/ # Local modules - β”œβ”€β”€ vpc/ - └── compute/ -``` - -## Component Configuration (.tfcomponent.hcl) - -### Variable Block - -Declare input variables for the Stack configuration. Variables must define a `type` field and do not support the `validation` argument. - -```hcl -variable "aws_region" { - type = string - description = "AWS region for deployments" - default = "us-west-1" -} - -variable "identity_token" { - type = string - description = "OIDC identity token" - ephemeral = true # Does not persist to state file -} - -variable "instance_count" { - type = number - nullable = false -} -``` - -### Required Providers Block - -Works the same as traditional Terraform configurations: - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } - random = { - source = "hashicorp/random" - version = "~> 3.5.0" - } -} -``` - -### Provider Block - -Provider blocks differ from traditional Terraform: - -1. Support `for_each` meta-argument -2. Define aliases in the block header (not as an argument) -3. Accept configuration through a `config` block - -**Single Provider Configuration:** - -```hcl -provider "aws" "this" { - config { - region = var.aws_region - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - } -} -``` - -**Multiple Provider Configurations with for_each:** - -```hcl -provider "aws" "configurations" { - for_each = var.regions - - config { - region = each.value - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - } -} -``` - -### Component Block - -Each Stack requires at least one component block. Add a component for each module to include in the Stack. - -**Component Source Types:** - -- Local file paths: `./modules/vpc` -- Public registry: `terraform-aws-modules/vpc/aws` -- Private registry: `app.terraform.io/my-org/vpc/aws` -- Git repositories: `git::https://github.com/org/repo.git//modules/vpc?ref=v1.0.0` - -```hcl -component "vpc" { - source = "./modules/vpc" - - inputs = { - cidr_block = var.vpc_cidr - name_prefix = var.name_prefix - } - - providers = { - aws = provider.aws.this - } -} - -component "networking" { - source = "app.terraform.io/my-org/vpc/aws" - version = "2.1.0" - - inputs = { - cidr_block = var.vpc_cidr - environment = var.environment - } - - providers = { - aws = provider.aws.this - } -} - -component "compute" { - source = "./modules/compute" - - inputs = { - vpc_id = component.vpc.vpc_id - subnet_ids = component.vpc.private_subnet_ids - instance_type = var.instance_type - } - - providers = { - aws = provider.aws.this - } -} -``` - -**Component with for_each for Multi-Region:** - -```hcl -component "s3" { - for_each = var.regions - - source = "./modules/s3" - - inputs = { - region = each.value - tags = var.common_tags - } - - providers = { - aws = provider.aws.configurations[each.value] - } -} -``` - -**Key Points:** - -- Reference component outputs using `component..` -- All inputs are provided as a single `inputs` object -- Provider references are normal values: `provider..` -- Dependencies are automatically inferred from component references - -### Output Block - -Outputs require a `type` argument and do not support `preconditions`: - -```hcl -output "vpc_id" { - type = string - description = "VPC ID" - value = component.vpc.vpc_id -} - -output "endpoint_urls" { - type = map(string) - value = { - for region, comp in component.api : region => comp.endpoint_url - } - sensitive = false -} -``` - -### Locals Block - -Works exactly as in traditional Terraform: - -```hcl -locals { - common_tags = { - Environment = var.environment - ManagedBy = "Terraform Stacks" - Project = var.project_name - } - - region_config = { - for region in var.regions : region => { - name_suffix = "${var.environment}-${region}" - } - } -} -``` - -### Removed Block - -Use to safely remove components from a Stack. HCP Terraform requires the component's providers to remove it. - -```hcl -removed { - from = component.old_component - source = "./modules/old-module" - - providers = { - aws = provider.aws.this - } -} -``` - -## Deployment Configuration (.tfdeploy.hcl) - -### Identity Token Block - -Generate JWT tokens for OIDC authentication with cloud providers: - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -identity_token "azure" { - audience = ["api://AzureADTokenExchange"] -} -``` - -Reference tokens in deployments using `identity_token..jwt` - -### Locals Block - -Define local values for deployment configuration: - -```hcl -locals { - aws_regions = ["us-west-1", "us-east-1", "eu-west-1"] - role_arn = "arn:aws:iam::123456789012:role/hcp-terraform-stacks" -} -``` - -### Deployment Block - -Define deployment instances. Each Stack requires at least one deployment (maximum 20 per Stack). - -**Single Environment Deployment:** - -```hcl -deployment "production" { - inputs = { - aws_region = "us-west-1" - instance_count = 3 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} -``` - -**Multiple Environment Deployments:** - -```hcl -deployment "development" { - inputs = { - aws_region = "us-east-1" - instance_count = 1 - name_suffix = "dev" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "staging" { - inputs = { - aws_region = "us-east-1" - instance_count = 2 - name_suffix = "staging" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "production" { - inputs = { - aws_region = "us-west-1" - instance_count = 5 - name_suffix = "prod" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} -``` - -**Destroying a Deployment:** - -To safely remove a deployment: - -```hcl -deployment "old_environment" { - inputs = { - aws_region = "us-west-1" - instance_count = 2 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } - destroy = true # Mark for destruction -} -``` - -After applying the plan and the deployment is destroyed, remove the deployment block from your configuration. - -### Deployment Group Block - -Group deployments together to configure shared settings (Premium feature). **Best Practice**: Always create deployment groups for all deployments, even single deployments, to enable future auto-approval rules and maintain consistent configuration patterns. - -```hcl -deployment_group "canary" { - deployments = [ - deployment.dev, - deployment.staging - ] -} - -deployment_group "production" { - deployments = [ - deployment.prod_us_east, - deployment.prod_us_west - ] -} -``` - -### Deployment Auto-Approve Block - -Define rules that automatically approve deployment plans based on specific conditions (Premium feature): - -```hcl -deployment_auto_approve "safe_changes" { - deployment_group = deployment_group.canary - - check { - condition = context.plan.changes.remove == 0 - reason = "Cannot auto-approve plans with resource deletions" - } - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} - -deployment_auto_approve "applyable_only" { - deployment_group = deployment_group.production - - check { - condition = context.plan.applyable - reason = "Plan must be successful" - } -} -``` - -**Available Context Variables:** - -- `context.plan.applyable` - Plan succeeded without errors -- `context.plan.changes.add` - Number of resources to add -- `context.plan.changes.change` - Number of resources to change -- `context.plan.changes.remove` - Number of resources to remove - -**Note:** `orchestrate` blocks are deprecated. Use `deployment_group` and `deployment_auto_approve` instead. - -### Publish Output Block - -Export outputs from a Stack for use in other Stacks (linked Stacks): - -```hcl -publish_output "vpc_id_network" { - type = string - value = deployment.network.vpc_id -} - -publish_output "subnet_ids" { - type = list(string) - value = deployment.network.private_subnet_ids -} -``` - -### Upstream Input Block - -Reference published outputs from another Stack: - -```hcl -upstream_input "network_stack" { - type = "stack" - source = "app.terraform.io/my-org/my-project/networking-stack" -} - -deployment "application" { - inputs = { - vpc_id = upstream_input.network_stack.vpc_id_network - subnet_ids = upstream_input.network_stack.subnet_ids - } -} -``` - -## Terraform Stacks CLI - -### Initialize and Validate - -Generate provider lock file: - -```bash -terraform stacks providers-lock -``` - -Validate Stack configuration: - -```bash -terraform stacks validate -``` - -### Plan and Apply - -Plan a specific deployment: - -```bash -terraform stacks plan --deployment=production -``` - -Apply a deployment: - -```bash -terraform stacks apply --deployment=production -``` - -## Common Patterns - -### Multi-Region Deployment - -```hcl -# variables.tfcomponent.hcl -variable "regions" { - type = set(string) - default = ["us-west-1", "us-east-1", "eu-west-1"] -} - -# providers.tfcomponent.hcl -provider "aws" "regional" { - for_each = var.regions - - config { - region = each.value - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - } -} - -# components.tfcomponent.hcl -component "regional_infra" { - for_each = var.regions - source = "./modules/regional" - - inputs = { - region = each.value - } - - providers = { - aws = provider.aws.regional[each.value] - } -} -``` - -### Component Dependencies - -Dependencies are automatically inferred when one component references another's output: - -```hcl -component "database" { - source = "./modules/rds" - - inputs = { - subnet_ids = component.vpc.private_subnet_ids # Creates dependency - } - - providers = { - aws = provider.aws.this - } -} -``` - -## Best Practices - -1. **Component Granularity**: Create components for logical infrastructure units that share a lifecycle -2. **Module Compatibility**: Modules used with Stacks cannot include provider blocks (configure providers in Stack configuration) -3. **State Isolation**: Each deployment has its own isolated state -4. **Input Variables**: Use variables for values that differ across deployments; use locals for shared values -5. **Provider Lock Files**: Always generate and commit `.terraform.lock.hcl` to version control -6. **Naming Conventions**: Use descriptive names for components and deployments -7. **Deployment Groups**: Always organize deployments into deployment groups, even if you only have one deployment. Deployment groups enable auto-approval rules, logical organization, and provide a foundation for scaling. While deployment groups are a Premium feature, organizing your configurations to use them is a best practice for all Stacks -8. **Testing**: Test Stack configurations in dev/staging deployments before production - -## Troubleshooting - -### Provider Configuration Errors - -**Issue**: Providers defined inside modules -**Solution**: Move all provider configurations to Stack-level `.tfcomponent.hcl` files - -### Circular Dependencies - -**Issue**: Component A references Component B, and Component B references Component A -**Solution**: Refactor to break the circular reference or use intermediate components - -### Deployment Limit - -HCP Terraform supports maximum 20 deployments per Stack. For more instances, use multiple Stacks or `for_each` within components. - -## References - -For detailed block specifications and advanced features, see: - -- `references/component-blocks.md` - Complete component block reference -- `references/deployment-blocks.md` - Complete deployment block reference -- `references/examples.md` - Complete working examples for common scenarios diff --git a/.claude/skills/terraform-stacks/references/component-blocks.md b/.claude/skills/terraform-stacks/references/component-blocks.md deleted file mode 100644 index 3e47ea0..0000000 --- a/.claude/skills/terraform-stacks/references/component-blocks.md +++ /dev/null @@ -1,656 +0,0 @@ -# Component Configuration Block Reference - -Complete reference for all blocks available in Terraform Stack component configuration files (`.tfcomponent.hcl`). - -## Table of Contents - -1. [Variable Block](#variable-block) -2. [Required Providers Block](#required-providers-block) -3. [Provider Block](#provider-block) -4. [Component Block](#component-block) -5. [Output Block](#output-block) -6. [Locals Block](#locals-block) -7. [Removed Block](#removed-block) - -## Variable Block - -Declares input variables for Stack configuration. - -### Syntax - -```hcl -variable "variable_name" { - type = - description = "" - default = - sensitive = - nullable = - ephemeral = -} -``` - -### Arguments - -- **type** (required): Data type (string, number, bool, list, map, object, set, tuple, any) -- **description** (optional): Variable description -- **default** (optional): Default value -- **sensitive** (optional, default false): Mark as sensitive to redact from logs -- **nullable** (optional, default true): Whether null is allowed -- **ephemeral** (optional, default false): Do not persist to state file - -### Differences from Traditional Terraform - -- **type** is required (not optional) -- **validation** argument is not supported - -### Examples - -```hcl -variable "aws_region" { - type = string - description = "AWS region for infrastructure" - default = "us-west-1" -} - -variable "instance_count" { - type = number - description = "Number of instances" - nullable = false -} - -variable "identity_token" { - type = string - description = "OIDC identity token" - ephemeral = true -} - -variable "tags" { - type = map(string) - default = { - Environment = "dev" - ManagedBy = "Terraform" - } -} - -variable "subnet_config" { - type = object({ - cidr_block = string - availability_zone = string - map_public_ip = bool - }) -} -``` - -## Required Providers Block - -Declares provider dependencies. - -### Syntax - -```hcl -required_providers { - = { - source = "" - version = "" - } -} -``` - -### Arguments - -- **source** (required): Provider source address (e.g., "hashicorp/aws") -- **version** (optional): Version constraint (e.g., "~> 5.0") - -### Examples - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } - - random = { - source = "hashicorp/random" - version = "~> 3.5.0" - } - - azurerm = { - source = "hashicorp/azurerm" - version = ">= 3.0" - } -} -``` - -## Provider Block - -Configures provider instances. - -### Syntax - -```hcl -provider "" "" { - for_each = # Optional - - config { - - } -} -``` - -### Arguments - -- **provider_type** (label 1, required): Provider type (e.g., "aws", "azurerm") -- **alias** (label 2, required): Unique identifier for this provider configuration -- **for_each** (optional): Create multiple provider instances from a map or set -- **config** (required): Nested block containing provider-specific configuration - -### Key Differences from Traditional Terraform - -1. Alias is defined in block header, not as an argument -2. Configuration goes in a nested `config` block -3. Supports `for_each` meta-argument -4. Provider configurations are treated as first-class values - -### Examples - -**Single Provider:** - -```hcl -provider "aws" "main" { - config { - region = var.aws_region - - default_tags { - tags = var.common_tags - } - } -} -``` - -**Provider with OIDC Authentication:** - -```hcl -provider "aws" "authenticated" { - config { - region = var.aws_region - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - } -} -``` - -**Multiple Providers with for_each:** - -```hcl -provider "aws" "regional" { - for_each = toset(var.regions) - - config { - region = each.value - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - } -} -``` - -**Multiple Cloud Accounts:** - -```hcl -provider "aws" "accounts" { - for_each = var.aws_accounts - - config { - region = var.default_region - - assume_role { - role_arn = "arn:aws:iam::${each.value.account_id}:role/${var.role_name}" - } - } -} -``` - -## Component Block - -Defines infrastructure components to include in the Stack. - -### Syntax - -```hcl -component "" { - for_each = # Optional - - source = "" - - inputs = { - = - } - - providers = { - = provider..[] - } -} -``` - -### Arguments - -- **component_name** (label, required): Unique identifier for this component -- **for_each** (optional): Create multiple component instances -- **source** (required): Module source (see [Source Argument](#source-argument) below) -- **version** (optional): Version constraint for registry-based sources only -- **inputs** (required): Map of input variables for the module -- **providers** (required): Map of provider configurations - -### Source Argument - -The `source` argument accepts the same module sources as traditional Terraform configurations. - -**Local File Path:** - -```hcl -source = "./modules/vpc" -source = "../shared-modules/networking" -``` - -**Public Terraform Registry:** - -```hcl -source = "terraform-aws-modules/vpc/aws" -source = "hashicorp/consul/aws" -``` - -Format: `//` - -**Private HCP Terraform Registry:** - -```hcl -source = "app.terraform.io/my-org/vpc/aws" -source = "app.terraform.io/example-corp/networking/azurerm" -``` - -Format: `///` - -- **HCP Terraform (SaaS)**: Use hostname `app.terraform.io` -- **Terraform Enterprise**: Use your instance hostname (e.g., `terraform.mycompany.com`) -- **Generic hostname**: Use `localterraform.com` for deployments spanning multiple Terraform Enterprise instances - -**Git Repository:** - -```hcl -source = "git::https://github.com/org/repo.git//modules/vpc?ref=v1.0.0" -source = "git::ssh://git@github.com/org/repo.git//modules/vpc?ref=main" -``` - -**HTTP/HTTPS Archive:** - -```hcl -source = "https://example.com/modules/vpc-module.tar.gz" -``` - -### Version Argument - -The `version` argument is supported only for registry-based sources (public and private registries). Local file paths and Git sources do not support the `version` argument. - -```hcl -component "vpc" { - source = "app.terraform.io/my-org/vpc/aws" - version = "~> 2.0" # Semantic versioning constraint - - inputs = { - cidr_block = var.vpc_cidr - } - - providers = { - aws = provider.aws.main - } -} -``` - -**Note**: Modules sourced from local file paths always share the same version as their caller and cannot have independent version constraints. - -### Component References - -Access component outputs using: `component..` - -For components with `for_each`: `component.[].` - -### Examples - -**Basic Component (Local Module):** - -```hcl -component "vpc" { - source = "./modules/vpc" - - inputs = { - cidr_block = var.vpc_cidr - name_prefix = var.name_prefix - } - - providers = { - aws = provider.aws.main - } -} -``` - -**Component from Public Registry:** - -```hcl -component "vpc" { - source = "terraform-aws-modules/vpc/aws" - version = "~> 5.0" - - inputs = { - cidr = var.vpc_cidr - azs = var.availability_zones - private_subnets = var.private_subnet_cidrs - public_subnets = var.public_subnet_cidrs - } - - providers = { - aws = provider.aws.main - } -} -``` - -**Component from Private Registry:** - -```hcl -component "vpc" { - source = "app.terraform.io/my-org/vpc/aws" - version = "2.1.0" - - inputs = { - cidr_block = var.vpc_cidr - name_prefix = var.name_prefix - environment = var.environment - } - - providers = { - aws = provider.aws.main - } -} -``` - -**Component with Dependencies:** - -```hcl -component "database" { - source = "./modules/rds" - - inputs = { - vpc_id = component.vpc.vpc_id - subnet_ids = component.vpc.private_subnet_ids - security_group_ids = [component.security.database_sg_id] - engine_version = var.db_engine_version - } - - providers = { - aws = provider.aws.main - } -} -``` - -**Component with for_each (Multi-Region):** - -```hcl -component "regional_s3" { - for_each = toset(var.regions) - - source = "./modules/s3" - - inputs = { - region = each.value - bucket_name = "${var.app_name}-${each.value}" - tags = local.common_tags - } - - providers = { - aws = provider.aws.regional[each.value] - } -} -``` - -**Component with Multiple Providers:** - -```hcl -component "cross_region_replication" { - source = "./modules/s3-replication" - - inputs = { - source_bucket = var.source_bucket - dest_bucket = var.dest_bucket - } - - providers = { - aws.source = provider.aws.us_east - aws.dest = provider.aws.eu_west - } -} -``` - -**Component with for_each over Map:** - -```hcl -component "applications" { - for_each = var.applications - - source = "./modules/application" - - inputs = { - app_name = each.key - instance_type = each.value.instance_type - instance_count = each.value.count - vpc_id = component.vpc.vpc_id - } - - providers = { - aws = provider.aws.main - } -} -``` - -## Output Block - -Exposes values from Stack configuration. - -### Syntax - -```hcl -output "" { - type = - description = "" - value = - sensitive = - ephemeral = -} -``` - -### Arguments - -- **output_name** (label, required): Unique identifier for this output -- **type** (required): Data type of the output -- **description** (optional): Output description -- **value** (required): Expression to output -- **sensitive** (optional, default false): Mark as sensitive -- **ephemeral** (optional, default false): Ephemeral value - -### Differences from Traditional Terraform - -- **type** is required -- **precondition** block is not supported - -### Examples - -```hcl -output "vpc_id" { - type = string - description = "VPC ID" - value = component.vpc.vpc_id -} - -output "database_endpoint" { - type = string - description = "Database endpoint" - value = component.database.endpoint - sensitive = true -} - -output "regional_endpoints" { - type = map(string) - description = "API endpoints by region" - value = { - for region, comp in component.api_gateway : region => comp.endpoint_url - } -} - -output "instance_details" { - type = object({ - id = string - public_ip = string - private_ip = string - }) - description = "EC2 instance details" - value = { - id = component.compute.instance_id - public_ip = component.compute.public_ip - private_ip = component.compute.private_ip - } -} -``` - -## Locals Block - -Defines local values for reuse within the Stack configuration. - -### Syntax - -```hcl -locals { - = -} -``` - -### Examples - -```hcl -locals { - common_tags = { - Environment = var.environment - ManagedBy = "Terraform Stacks" - Project = var.project_name - CostCenter = var.cost_center - } - - name_prefix = "${var.project_name}-${var.environment}" - - region_config = { - for region in var.regions : region => { - name_suffix = region - instance_count = var.environment == "prod" ? 3 : 1 - } - } - - availability_zones = [ - for az in var.availability_zones : az - if can(regex("^${var.aws_region}", az)) - ] -} -``` - -## Removed Block - -Declares components to be removed from the Stack. - -### Syntax - -```hcl -removed { - from = component. - source = "" - - providers = { - = provider.. - } -} -``` - -### Arguments - -- **from** (required): Reference to the component being removed -- **source** (required): Original module source -- **providers** (required): Provider configurations needed for removal - -### Important Notes - -- Required for safe component removal -- Must include all providers the component used -- Do not remove providers before removing components that use them - -### Examples - -```hcl -removed { - from = component.old_component - source = "./modules/deprecated-module" - - providers = { - aws = provider.aws.main - } -} - -removed { - from = component.legacy_regional - source = "registry.terraform.io/example/legacy/aws" - - providers = { - aws = provider.aws.main - random = provider.random.main - } -} -``` - -## Provider References in Component Blocks - -### Single Provider - -```hcl -providers = { - aws = provider.aws.main -} -``` - -### Multiple Providers - -```hcl -providers = { - aws = provider.aws.main - random = provider.random.main - tls = provider.tls.main -} -``` - -### Provider from for_each - -```hcl -providers = { - aws = provider.aws.regional[each.value] -} -``` - -### Aliased Providers in Module - -If module requires specific provider aliases: - -```hcl -providers = { - aws.source = provider.aws.us_east - aws.dest = provider.aws.eu_west -} -``` diff --git a/.claude/skills/terraform-stacks/references/deployment-blocks.md b/.claude/skills/terraform-stacks/references/deployment-blocks.md deleted file mode 100644 index d96873e..0000000 --- a/.claude/skills/terraform-stacks/references/deployment-blocks.md +++ /dev/null @@ -1,1016 +0,0 @@ -# Deployment Configuration Block Reference - -Complete reference for all blocks available in Terraform Stack deployment configuration files (`.tfdeploy.hcl`). - -## Table of Contents - -1. [Identity Token Block](#identity-token-block) -2. [Locals Block](#locals-block) -3. [Deployment Block](#deployment-block) -4. [Deployment Group Block](#deployment-group-block) -5. [Deployment Auto-Approve Block](#deployment-auto-approve-block) -6. [Publish Output Block](#publish-output-block) -7. [Upstream Input Block](#upstream-input-block) - -## Identity Token Block - -Generates JWT tokens for OIDC authentication with cloud providers. - -### Syntax - -```hcl -identity_token "" { - audience = [] -} -``` - -### Arguments - -- **token_name** (label, required): Unique identifier for this token -- **audience** (required): List of audience strings for the JWT - -### Accessing Token - -Reference the JWT using: `identity_token..jwt` - -### Cloud Provider Audiences - -**AWS:** - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} -``` - -**Azure:** - -```hcl -identity_token "azure" { - audience = ["api://AzureADTokenExchange"] -} -``` - -**Google Cloud:** - -```hcl -identity_token "gcp" { - audience = ["//iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/"] -} -``` - -### Examples - -**Single Token:** - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -deployment "production" { - inputs = { - identity_token = identity_token.aws.jwt - role_arn = var.role_arn - } -} -``` - -**Multiple Tokens for Different Regions:** - -```hcl -identity_token "aws_east" { - audience = ["aws.workload.identity.east"] -} - -identity_token "aws_west" { - audience = ["aws.workload.identity.west"] -} - -deployment "east_deployment" { - inputs = { - identity_token = identity_token.aws_east.jwt - role_arn = var.east_role_arn - } -} - -deployment "west_deployment" { - inputs = { - identity_token = identity_token.aws_west.jwt - role_arn = var.west_role_arn - } -} -``` - -## Locals Block - -Defines local values for reuse within deployment configuration. - -### Syntax - -```hcl -locals { - = -} -``` - -### Examples - -```hcl -locals { - aws_regions = ["us-west-1", "us-east-1", "eu-west-1"] - - role_arn = "arn:aws:iam::123456789012:role/hcp-terraform-stacks" - - common_inputs = { - project_name = "my-app" - environment = "production" - } - - environments = { - dev = { - region = "us-east-1" - instance_count = 1 - instance_type = "t3.micro" - } - staging = { - region = "us-west-1" - instance_count = 2 - instance_type = "t3.small" - } - prod = { - region = "us-west-1" - instance_count = 5 - instance_type = "t3.large" - } - } -} -``` - -## Deployment Block - -Defines deployment instances of the Stack. - -### Syntax - -```hcl -deployment "" { - inputs = { - = - } -} -``` - -### Arguments - -- **deployment_name** (label, required): Unique identifier for this deployment -- **inputs** (required): Map of input variable values -- **destroy** (optional, default: false): Boolean flag to destroy this deployment - -### Constraints - -- Minimum 1 deployment per Stack -- Maximum 20 deployments per Stack -- No meta-arguments supported (no `for_each`, `count`) - -### Destroying a Deployment - -To safely remove a deployment from your Stack: - -1. Set `destroy = true` in the deployment block -2. Apply the plan through HCP Terraform -3. After successful destruction, remove the deployment block from your configuration - -**Important**: Using the `destroy` argument ensures your configuration has the provider authentication necessary to properly destroy the deployment's resources. - -**Example:** - -```hcl -deployment "old_environment" { - inputs = { - aws_region = "us-west-1" - instance_count = 2 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } - destroy = true # Mark for destruction -} -``` - -After applying this plan and the deployment is destroyed, remove the entire `deployment "old_environment"` block from your configuration. - -### Examples - -**Single Deployment:** - -```hcl -deployment "production" { - inputs = { - aws_region = "us-west-1" - instance_count = 5 - instance_type = "t3.large" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} -``` - -**Multiple Environment Deployments:** - -```hcl -deployment "development" { - inputs = { - aws_region = "us-east-1" - instance_count = 1 - instance_type = "t3.micro" - name_suffix = "dev" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "staging" { - inputs = { - aws_region = "us-west-1" - instance_count = 2 - instance_type = "t3.small" - name_suffix = "staging" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "production" { - inputs = { - aws_region = "us-west-1" - instance_count = 5 - instance_type = "t3.large" - name_suffix = "prod" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} -``` - -**Multi-Region Deployments:** - -```hcl -deployment "us_prod_east" { - inputs = { - aws_region = "us-east-1" - instance_count = 3 - name_suffix = "prod-east" - role_arn = local.role_arn - identity_token = identity_token.aws_east.jwt - } -} - -deployment "us_prod_west" { - inputs = { - aws_region = "us-west-1" - instance_count = 3 - name_suffix = "prod-west" - role_arn = local.role_arn - identity_token = identity_token.aws_west.jwt - } -} - -deployment "eu_prod" { - inputs = { - aws_region = "eu-west-1" - instance_count = 3 - name_suffix = "prod-eu" - role_arn = local.role_arn - identity_token = identity_token.aws_eu.jwt - } -} -``` - -**Using Locals for DRY Configuration:** - -```hcl -locals { - common_inputs = { - role_arn = "arn:aws:iam::123456789012:role/terraform" - identity_token = identity_token.aws.jwt - project_name = "my-app" - } -} - -deployment "dev" { - inputs = merge(local.common_inputs, { - aws_region = "us-east-1" - instance_count = 1 - environment = "dev" - }) -} - -deployment "prod" { - inputs = merge(local.common_inputs, { - aws_region = "us-west-1" - instance_count = 5 - environment = "prod" - }) -} -``` - -## Deployment Group Block - -Groups deployments together to configure shared settings and auto-approval rules (HCP Terraform Premium feature). - -**Best Practice**: Always create deployment groups for all deployments, even when you have only a single deployment. This establishes a consistent configuration pattern, enables future auto-approval rules, and provides a foundation for scaling your Stack. - -### Syntax - -```hcl -deployment_group "" { - deployments = [] -} -``` - -### Arguments - -- **group_name** (label, required): Unique identifier for this deployment group -- **deployments** (required): List of deployment references to include in this group - -### Purpose - -Deployment groups allow you to: - -- Organize deployments logically (by environment, team, region, etc.) -- Configure shared auto-approval rules for multiple deployments -- Manage deployments more effectively at scale -- Establish consistent configuration patterns across all Stacks - -### Examples - -**Single Deployment Group (Best Practice):** - -```hcl -deployment "production" { - inputs = { - aws_region = "us-west-1" - instance_count = 5 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment_group "production" { - deployments = [deployment.production] -} -``` - -**Multiple Deployment Groups:** - -```hcl -deployment_group "non_production" { - deployments = [ - deployment.development, - deployment.staging - ] -} - -deployment_group "production" { - deployments = [ - deployment.prod_us_east, - deployment.prod_us_west, - deployment.prod_eu_west - ] -} -``` - -**Environment-Based Groups:** - -```hcl -deployment_group "development_environments" { - deployments = [ - deployment.dev_feature_a, - deployment.dev_feature_b, - deployment.dev_integration - ] -} - -deployment_group "production_environments" { - deployments = [ - deployment.prod_primary, - deployment.prod_dr - ] -} -``` - -**Regional Groups:** - -```hcl -deployment_group "americas" { - deployments = [ - deployment.us_east, - deployment.us_west, - deployment.brazil - ] -} - -deployment_group "europe" { - deployments = [ - deployment.eu_west, - deployment.eu_central - ] -} -``` - -## Deployment Auto-Approve Block - -Defines rules that automatically approve deployment plans based on specific conditions (HCP Terraform Premium feature). - -### Syntax - -```hcl -deployment_auto_approve "" { - deployment_group = deployment_group. - - check { - condition = - reason = "" - } -} -``` - -### Arguments - -- **rule_name** (label, required): Unique identifier for this auto-approve rule -- **deployment_group** (required): Reference to the deployment group this rule applies to -- **check** (required, one or more): Condition that must be met for auto-approval - -### Context Variables - -Access plan information through `context` object: - -- `context.plan.applyable` - Boolean: plan succeeded without errors -- `context.plan.changes.add` - Number: resources to add -- `context.plan.changes.change` - Number: resources to change -- `context.plan.changes.remove` - Number: resources to remove -- `context.plan.changes.import` - Number: resources to import - -### Important Notes - -- All checks must pass for auto-approval to occur -- If any check fails, manual approval is required -- HCP Terraform displays the failure reason from failed checks -- Auto-approve rules only apply to deployments in the specified deployment group - -### Examples - -**Auto-approve Successful Plans:** - -```hcl -deployment_group "canary" { - deployments = [ - deployment.dev, - deployment.staging - ] -} - -deployment_auto_approve "applyable_plans" { - deployment_group = deployment_group.canary - - check { - condition = context.plan.applyable - reason = "Plan must be applyable without errors" - } -} -``` - -**Auto-approve Only Additions (No Changes or Deletions):** - -```hcl -deployment_group "non_prod" { - deployments = [ - deployment.development, - deployment.qa - ] -} - -deployment_auto_approve "additions_only" { - deployment_group = deployment_group.non_prod - - check { - condition = context.plan.changes.change == 0 - reason = "Cannot auto-approve changes to existing resources" - } - - check { - condition = context.plan.changes.remove == 0 - reason = "Cannot auto-approve resource deletions" - } - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} -``` - -**Auto-approve Small Changes:** - -```hcl -deployment_group "staging" { - deployments = [deployment.staging] -} - -deployment_auto_approve "small_changes" { - deployment_group = deployment_group.staging - - check { - condition = ( - context.plan.changes.add + - context.plan.changes.change + - context.plan.changes.remove - ) <= 10 - reason = "Cannot auto-approve changes affecting more than 10 resources" - } - - check { - condition = context.plan.changes.remove == 0 - reason = "Cannot auto-approve plans with deletions" - } - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} -``` - -**Auto-approve Non-Destructive Changes:** - -```hcl -deployment_group "production" { - deployments = [ - deployment.prod_primary, - deployment.prod_secondary - ] -} - -deployment_auto_approve "safe_production_changes" { - deployment_group = deployment_group.production - - check { - condition = context.plan.changes.remove == 0 - reason = "Production deletions require manual approval" - } - - check { - condition = context.plan.applyable - reason = "Plan must be successful" - } -} -``` - -**Multiple Auto-Approve Rules for Different Groups:** - -```hcl -deployment_group "development" { - deployments = [deployment.dev] -} - -deployment_group "staging" { - deployments = [deployment.staging] -} - -deployment_group "production" { - deployments = [deployment.production] -} - -# Auto-approve all successful dev plans -deployment_auto_approve "dev_auto" { - deployment_group = deployment_group.development - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} - -# Auto-approve staging plans with no deletions -deployment_auto_approve "staging_safe" { - deployment_group = deployment_group.staging - - check { - condition = context.plan.changes.remove == 0 - reason = "No deletions allowed in staging auto-approve" - } - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} - -# Production requires manual approval (no auto-approve rule defined) -``` - -**Graduated Rollout Pattern:** - -```hcl -deployment_group "canary" { - deployments = [deployment.canary] -} - -deployment_group "production" { - deployments = [ - deployment.prod_us, - deployment.prod_eu, - deployment.prod_asia - ] -} - -# Canary auto-approves with strict checks -deployment_auto_approve "canary_strict" { - deployment_group = deployment_group.canary - - check { - condition = context.plan.changes.remove == 0 - reason = "Canary cannot delete resources" - } - - check { - condition = context.plan.changes.change <= 5 - reason = "Canary limited to 5 resource changes" - } - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} - -# Production requires manual approval after canary validation -``` - -## Deprecated: Orchestrate Block - -**Note:** The `orchestrate` block is deprecated. Use `deployment_group` and `deployment_auto_approve` blocks instead. - -The `orchestrate` block was used in public beta but has been replaced by deployment groups for better scalability and flexibility: - -```hcl -# ❌ DEPRECATED - Do not use -orchestrate "auto_approve" "rule_name" { - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} - -# βœ… Use deployment_group and deployment_auto_approve instead -deployment_group "my_group" { - deployments = [deployment.my_deployment] -} - -deployment_auto_approve "my_rule" { - deployment_group = deployment_group.my_group - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} -``` - -## Publish Output Block - -Exports outputs from a Stack for consumption by other Stacks (linked Stacks). - -### Syntax - -```hcl -publish_output "" { - type = - value = -} -``` - -### Arguments - -- **output_name** (label, required): Unique identifier for this published output -- **type** (required): Data type of the output -- **value** (required): Expression to export - -### Accessing Deployment Outputs - -Reference deployment outputs using: `deployment..` - -### Important Notes - -- Must apply the Stack's deployment configuration before downstream Stacks can reference outputs -- Published outputs create a snapshot that other Stacks can read -- Changes to published outputs automatically trigger runs in downstream Stacks - -### Examples - -**Basic Published Output:** - -```hcl -publish_output "vpc_id" { - type = string - value = deployment.network.vpc_id -} - -publish_output "subnet_ids" { - type = list(string) - value = deployment.network.private_subnet_ids -} -``` - -**Multiple Deployment Outputs:** - -```hcl -publish_output "regional_vpc_ids" { - type = map(string) - value = { - us_east = deployment.us_east.vpc_id - us_west = deployment.us_west.vpc_id - eu_west = deployment.eu_west.vpc_id - } -} -``` - -**Complex Output:** - -```hcl -publish_output "database_config" { - type = object({ - endpoint = string - port = number - name = string - }) - value = { - endpoint = deployment.production.db_endpoint - port = deployment.production.db_port - name = deployment.production.db_name - } -} -``` - -**Regional Endpoints:** - -```hcl -publish_output "api_endpoints" { - type = map(object({ - url = string - region = string - })) - value = { - for env in ["dev", "staging", "prod"] : env => { - url = deployment[env].api_url - region = deployment[env].region - } - } -} -``` - -## Upstream Input Block - -References published outputs from another Stack (linked Stacks). - -### Syntax - -```hcl -upstream_input "" { - type = "stack" - source = "" -} -``` - -### Arguments - -- **input_name** (label, required): Local name for this upstream input -- **type** (required): Must be "stack" -- **source** (required): Full Stack address in format: `app.terraform.io///` - -### Accessing Upstream Outputs - -Reference upstream outputs using: `upstream_input..` - -### Important Notes - -- Creates a dependency on the upstream Stack -- Upstream Stack must have applied its deployment configuration -- Changes in upstream Stack automatically trigger downstream Stack runs -- Only works with Stacks in the same HCP Terraform project - -### Examples - -**Basic Upstream Reference:** - -```hcl -upstream_input "network" { - type = "stack" - source = "app.terraform.io/my-org/my-project/networking-stack" -} - -deployment "application" { - inputs = { - vpc_id = upstream_input.network.vpc_id - subnet_ids = upstream_input.network.subnet_ids - } -} -``` - -**Multiple Upstream Stacks:** - -```hcl -upstream_input "network" { - type = "stack" - source = "app.terraform.io/my-org/my-project/network-stack" -} - -upstream_input "database" { - type = "stack" - source = "app.terraform.io/my-org/my-project/database-stack" -} - -deployment "application" { - inputs = { - vpc_id = upstream_input.network.vpc_id - subnet_ids = upstream_input.network.private_subnet_ids - database_endpoint = upstream_input.database.endpoint - database_credentials = upstream_input.database.credentials - } -} -``` - -**Regional Upstream Dependencies:** - -```hcl -upstream_input "regional_network" { - type = "stack" - source = "app.terraform.io/my-org/my-project/regional-networks" -} - -deployment "us_east_app" { - inputs = { - region = "us-east-1" - vpc_id = upstream_input.regional_network.regional_vpc_ids["us_east"] - subnet_ids = upstream_input.regional_network.regional_subnet_ids["us_east"] - } -} - -deployment "eu_west_app" { - inputs = { - region = "eu-west-1" - vpc_id = upstream_input.regional_network.regional_vpc_ids["eu_west"] - subnet_ids = upstream_input.regional_network.regional_subnet_ids["eu_west"] - } -} -``` - -**Complete Linked Stack Example:** - -Upstream Stack (network-stack): - -```hcl -# deployments.tfdeploy.hcl -deployment "network" { - inputs = { - vpc_cidr = "10.0.0.0/16" - } -} - -publish_output "vpc_id_network" { - type = string - value = deployment.network.vpc_id -} - -publish_output "private_subnet_ids" { - type = list(string) - value = deployment.network.private_subnet_ids -} - -publish_output "security_group_id" { - type = string - value = deployment.network.default_sg_id -} -``` - -Downstream Stack (application-stack): - -```hcl -# deployments.tfdeploy.hcl -upstream_input "networking" { - type = "stack" - source = "app.terraform.io/my-org/my-project/network-stack" -} - -deployment "application" { - inputs = { - vpc_id = upstream_input.networking.vpc_id_network - subnet_ids = upstream_input.networking.private_subnet_ids - security_group_id = upstream_input.networking.security_group_id - instance_type = "t3.large" - } -} -``` - -## Complete Deployment Configuration Example - -```hcl -# Identity tokens for cloud authentication -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -# Local values -locals { - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" - project = "my-application" - cost_center = "engineering" -} - -# Upstream dependencies -upstream_input "shared_services" { - type = "stack" - source = "app.terraform.io/my-org/my-project/shared-services" -} - -# Deployments -deployment "development" { - inputs = { - aws_region = "us-east-1" - environment = "dev" - instance_count = 1 - instance_type = "t3.micro" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - vpc_id = upstream_input.shared_services.dev_vpc_id - } -} - -deployment "staging" { - inputs = { - aws_region = "us-west-1" - environment = "staging" - instance_count = 2 - instance_type = "t3.small" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - vpc_id = upstream_input.shared_services.staging_vpc_id - } -} - -deployment "production" { - inputs = { - aws_region = "us-west-1" - environment = "prod" - instance_count = 5 - instance_type = "t3.large" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - vpc_id = upstream_input.shared_services.prod_vpc_id - } -} - -# Deployment groups -deployment_group "non_production" { - deployments = [ - deployment.development, - deployment.staging - ] -} - -deployment_group "production" { - deployments = [ - deployment.production - ] -} - -# Auto-approval rules -deployment_auto_approve "non_prod_auto" { - deployment_group = deployment_group.non_production - - check { - condition = context.plan.applyable - reason = "Non-production plans must be applyable" - } -} - -deployment_auto_approve "prod_safe" { - deployment_group = deployment_group.production - - check { - condition = context.plan.changes.remove == 0 - reason = "Production cannot auto-approve deletions" - } - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} - -# Published outputs -publish_output "application_url" { - type = string - value = deployment.production.load_balancer_url -} -``` diff --git a/.claude/skills/terraform-stacks/references/examples.md b/.claude/skills/terraform-stacks/references/examples.md deleted file mode 100644 index 3f051e8..0000000 --- a/.claude/skills/terraform-stacks/references/examples.md +++ /dev/null @@ -1,1571 +0,0 @@ -# Terraform Stacks Complete Examples - -Complete, working examples for common Terraform Stacks scenarios. - -## Table of Contents - -1. [Simple Single-Region Stack](#simple-single-region-stack) -2. [Stack with Private Registry Modules](#stack-with-private-registry-modules) -3. [Multi-Environment Stack](#multi-environment-stack) -4. [Multi-Region Stack](#multi-region-stack) -5. [Linked Stacks (Cross-Stack Dependencies)](#linked-stacks-cross-stack-dependencies) -6. [Multi-Cloud Stack](#multi-cloud-stack) -7. [Complete AWS Production Stack](#complete-aws-production-stack) -8. [Destroying Deployments](#destroying-deployments) - -## Simple Single-Region Stack - -Basic Stack with a single environment deployment. - -### File Structure - -``` -simple-stack/ -β”œβ”€β”€ variables.tfcomponent.hcl -β”œβ”€β”€ providers.tfcomponent.hcl -β”œβ”€β”€ components.tfcomponent.hcl -β”œβ”€β”€ deployments.tfdeploy.hcl -└── modules/ - └── webapp/ - β”œβ”€β”€ main.tf - β”œβ”€β”€ variables.tf - └── outputs.tf -``` - -### variables.tfcomponent.hcl - -```hcl -variable "aws_region" { - type = string - default = "us-west-1" -} - -variable "identity_token" { - type = string - ephemeral = true -} - -variable "role_arn" { - type = string -} - -variable "app_name" { - type = string -} -``` - -### providers.tfcomponent.hcl - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } -} - -provider "aws" "main" { - config { - region = var.aws_region - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - } -} -``` - -### components.tfcomponent.hcl - -```hcl -component "webapp" { - source = "./modules/webapp" - - inputs = { - app_name = var.app_name - region = var.aws_region - } - - providers = { - aws = provider.aws.main - } -} -``` - -### deployments.tfdeploy.hcl - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -deployment "production" { - inputs = { - aws_region = "us-west-1" - app_name = "my-webapp" - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" - identity_token = identity_token.aws.jwt - } -} - -# Deployment groups -deployment_group "production" { - deployments = [deployment.production] -} -``` - -## Stack with Private Registry Modules - -Example Stack using modules from a private HCP Terraform registry, combining both private and public registry sources. - -### File Structure - -``` -private-registry-stack/ -β”œβ”€β”€ variables.tfcomponent.hcl -β”œβ”€β”€ providers.tfcomponent.hcl -β”œβ”€β”€ components.tfcomponent.hcl -β”œβ”€β”€ outputs.tfcomponent.hcl -└── deployments.tfdeploy.hcl -``` - -### variables.tfcomponent.hcl - -```hcl -variable "aws_region" { - type = string - default = "us-west-2" -} - -variable "environment" { - type = string -} - -variable "identity_token" { - type = string - ephemeral = true -} - -variable "role_arn" { - type = string -} - -variable "vpc_cidr" { - type = string - default = "10.0.0.0/16" -} - -variable "app_name" { - type = string -} - -variable "db_password" { - type = string - sensitive = true -} -``` - -### providers.tfcomponent.hcl - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } - random = { - source = "hashicorp/random" - version = "~> 3.5.0" - } -} - -provider "aws" "main" { - config { - region = var.aws_region - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - - default_tags { - tags = { - Environment = var.environment - ManagedBy = "Terraform Stacks" - Application = var.app_name - } - } - } -} - -provider "random" "main" { - config {} -} -``` - -### components.tfcomponent.hcl - -```hcl -locals { - name_prefix = "${var.app_name}-${var.environment}" - common_tags = { - Project = var.app_name - Environment = var.environment - } -} - -# Using a private registry module for VPC -component "vpc" { - source = "app.terraform.io/my-org/vpc/aws" - version = "2.1.0" - - inputs = { - name_prefix = local.name_prefix - cidr_block = var.vpc_cidr - availability_zones = ["${var.aws_region}a", "${var.aws_region}b", "${var.aws_region}c"] - enable_nat_gateway = true - single_nat_gateway = var.environment != "prod" - tags = local.common_tags - } - - providers = { - aws = provider.aws.main - } -} - -# Using a private registry module for security groups -component "security_groups" { - source = "app.terraform.io/my-org/security-groups/aws" - version = "1.5.2" - - inputs = { - vpc_id = component.vpc.vpc_id - name_prefix = local.name_prefix - environment = var.environment - } - - providers = { - aws = provider.aws.main - } -} - -# Using a public registry module for RDS -component "database" { - source = "terraform-aws-modules/rds/aws" - version = "~> 6.0" - - inputs = { - identifier = "${local.name_prefix}-db" - engine = "postgres" - engine_version = "15.3" - family = "postgres15" - major_engine_version = "15" - instance_class = var.environment == "prod" ? "db.t3.large" : "db.t3.micro" - - allocated_storage = var.environment == "prod" ? 100 : 20 - db_name = replace(var.app_name, "-", "_") - username = "dbadmin" - password = var.db_password - port = 5432 - - db_subnet_group_name = component.vpc.database_subnet_group_name - vpc_security_group_ids = [component.security_groups.database_sg_id] - - backup_retention_period = var.environment == "prod" ? 30 : 7 - skip_final_snapshot = var.environment != "prod" - deletion_protection = var.environment == "prod" - - tags = local.common_tags - } - - providers = { - aws = provider.aws.main - } -} - -# Using a private registry module for application infrastructure -component "application" { - source = "app.terraform.io/my-org/ecs-application/aws" - version = "3.2.1" - - inputs = { - name_prefix = local.name_prefix - vpc_id = component.vpc.vpc_id - private_subnet_ids = component.vpc.private_subnet_ids - public_subnet_ids = component.vpc.public_subnet_ids - app_security_group_id = component.security_groups.app_sg_id - - container_image = "my-org/my-app:latest" - container_port = 8080 - desired_count = var.environment == "prod" ? 3 : 1 - - environment_variables = { - ENVIRONMENT = var.environment - DATABASE_HOST = component.database.db_instance_endpoint - DATABASE_NAME = component.database.db_instance_name - } - - tags = local.common_tags - } - - providers = { - aws = provider.aws.main - } -} -``` - -### outputs.tfcomponent.hcl - -```hcl -output "vpc_id" { - type = string - description = "VPC ID" - value = component.vpc.vpc_id -} - -output "application_url" { - type = string - description = "Application load balancer URL" - value = component.application.load_balancer_dns -} - -output "database_endpoint" { - type = string - description = "Database endpoint" - value = component.database.db_instance_endpoint - sensitive = true -} -``` - -### deployments.tfdeploy.hcl - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -locals { - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" -} - -deployment "development" { - inputs = { - aws_region = "us-west-2" - environment = "dev" - app_name = "myapp" - vpc_cidr = "10.0.0.0/16" - db_password = "dev-password-change-me" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "production" { - inputs = { - aws_region = "us-east-1" - environment = "prod" - app_name = "myapp" - vpc_cidr = "10.1.0.0/16" - db_password = "prod-password-use-secrets-manager" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -# Deployment groups -deployment_group "development" { - deployments = [deployment.development] -} - -deployment_group "production" { - deployments = [deployment.production] -} -``` - -### Key Points - -- **Private registry modules** use the format `app.terraform.io///` -- **Version constraints** ensure consistent module versions across environments -- **Mixed sources**: Combining private registry modules (VPC, security groups, application) with public registry modules (RDS) -- **Authentication**: HCP Terraform workspaces automatically authenticate to private registries; CLI users need credentials configured -- **Terraform Enterprise**: Replace `app.terraform.io` with your instance hostname - -## Multi-Environment Stack - -Stack with development, staging, and production deployments. - -### variables.tfcomponent.hcl - -```hcl -variable "aws_region" { - type = string -} - -variable "environment" { - type = string -} - -variable "instance_count" { - type = number -} - -variable "instance_type" { - type = string -} - -variable "identity_token" { - type = string - ephemeral = true -} - -variable "role_arn" { - type = string -} -``` - -### providers.tfcomponent.hcl - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } -} - -provider "aws" "this" { - config { - region = var.aws_region - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - - default_tags { - tags = { - Environment = var.environment - ManagedBy = "Terraform Stacks" - } - } - } -} -``` - -### components.tfcomponent.hcl - -```hcl -locals { - name_prefix = "myapp-${var.environment}" -} - -component "vpc" { - source = "./modules/vpc" - - inputs = { - name_prefix = local.name_prefix - cidr_block = "10.0.0.0/16" - } - - providers = { - aws = provider.aws.this - } -} - -component "compute" { - source = "./modules/compute" - - inputs = { - name_prefix = local.name_prefix - vpc_id = component.vpc.vpc_id - subnet_ids = component.vpc.private_subnet_ids - instance_count = var.instance_count - instance_type = var.instance_type - } - - providers = { - aws = provider.aws.this - } -} -``` - -### outputs.tfcomponent.hcl - -```hcl -output "vpc_id" { - type = string - value = component.vpc.vpc_id -} - -output "load_balancer_url" { - type = string - value = component.compute.load_balancer_url -} -``` - -### deployments.tfdeploy.hcl - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -locals { - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" - - environments = { - dev = { - region = "us-east-1" - instance_count = 1 - instance_type = "t3.micro" - } - staging = { - region = "us-west-1" - instance_count = 2 - instance_type = "t3.small" - } - prod = { - region = "us-west-1" - instance_count = 5 - instance_type = "t3.large" - } - } -} - -deployment "development" { - inputs = { - aws_region = local.environments.dev.region - environment = "dev" - instance_count = local.environments.dev.instance_count - instance_type = local.environments.dev.instance_type - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "staging" { - inputs = { - aws_region = local.environments.staging.region - environment = "staging" - instance_count = local.environments.staging.instance_count - instance_type = local.environments.staging.instance_type - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "production" { - inputs = { - aws_region = local.environments.prod.region - environment = "prod" - instance_count = local.environments.prod.instance_count - instance_type = local.environments.prod.instance_type - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -# Deployment groups -deployment_group "development" { - deployments = [deployment.development] -} - -deployment_group "non_production" { - deployments = [deployment.staging] -} - -deployment_group "production" { - deployments = [deployment.production] -} - -# Auto-approve dev deployments -deployment_auto_approve "dev_auto" { - deployment_group = deployment_group.development - - check { - condition = context.plan.applyable - reason = "Development plans must be applyable" - } -} -``` - -## Multi-Region Stack - -Stack that deploys identical infrastructure across multiple AWS regions. - -### variables.tfcomponent.hcl - -```hcl -variable "regions" { - type = set(string) -} - -variable "identity_token" { - type = string - ephemeral = true -} - -variable "role_arn" { - type = string -} - -variable "app_name" { - type = string -} -``` - -### providers.tfcomponent.hcl - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } -} - -provider "aws" "regional" { - for_each = var.regions - - config { - region = each.value - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - - default_tags { - tags = { - Region = each.value - ManagedBy = "Terraform Stacks" - AppName = var.app_name - } - } - } -} -``` - -### components.tfcomponent.hcl - -```hcl -component "regional_infrastructure" { - for_each = var.regions - - source = "./modules/regional-infra" - - inputs = { - region = each.value - app_name = var.app_name - name_suffix = each.value - } - - providers = { - aws = provider.aws.regional[each.value] - } -} - -component "global_route53" { - source = "./modules/route53" - - inputs = { - app_name = var.app_name - domain_name = "example.com" - regional_lbs = { - for region, comp in component.regional_infrastructure : - region => comp.load_balancer_dns - } - } - - # Use one region's provider for global resources - providers = { - aws = provider.aws.regional["us-west-1"] - } -} -``` - -### outputs.tfcomponent.hcl - -```hcl -output "regional_endpoints" { - type = map(string) - value = { - for region, comp in component.regional_infrastructure : - region => comp.load_balancer_url - } -} - -output "global_domain" { - type = string - value = component.global_route53.domain_name -} -``` - -### deployments.tfdeploy.hcl - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -locals { - regions = ["us-west-1", "us-east-1", "eu-west-1"] -} - -deployment "multi_region_prod" { - inputs = { - regions = toset(local.regions) - app_name = "my-global-app" - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" - identity_token = identity_token.aws.jwt - } -} - -# Deployment groups -deployment_group "production" { - deployments = [deployment.multi_region_prod] -} -``` - -## Linked Stacks (Cross-Stack Dependencies) - -Two Stacks where the application Stack depends on the network Stack. - -### Network Stack - -#### network-stack/variables.tfcomponent.hcl - -```hcl -variable "vpc_cidr" { - type = string -} - -variable "environment" { - type = string -} - -variable "aws_region" { - type = string -} - -variable "identity_token" { - type = string - ephemeral = true -} - -variable "role_arn" { - type = string -} -``` - -#### network-stack/providers.tfcomponent.hcl - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } -} - -provider "aws" "this" { - config { - region = var.aws_region - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - } -} -``` - -#### network-stack/components.tfcomponent.hcl - -```hcl -component "vpc" { - source = "./modules/vpc" - - inputs = { - cidr_block = var.vpc_cidr - environment = var.environment - } - - providers = { - aws = provider.aws.this - } -} - -component "security_groups" { - source = "./modules/security-groups" - - inputs = { - vpc_id = component.vpc.vpc_id - environment = var.environment - } - - providers = { - aws = provider.aws.this - } -} -``` - -#### network-stack/outputs.tfcomponent.hcl - -```hcl -output "vpc_id" { - type = string - value = component.vpc.vpc_id -} - -output "private_subnet_ids" { - type = list(string) - value = component.vpc.private_subnet_ids -} - -output "public_subnet_ids" { - type = list(string) - value = component.vpc.public_subnet_ids -} - -output "app_security_group_id" { - type = string - value = component.security_groups.app_sg_id -} -``` - -#### network-stack/deployments.tfdeploy.hcl - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -locals { - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" -} - -deployment "network" { - inputs = { - aws_region = "us-west-1" - environment = "production" - vpc_cidr = "10.0.0.0/16" - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -# Publish outputs for other stacks -publish_output "vpc_id_network" { - type = string - value = deployment.network.vpc_id -} - -publish_output "private_subnet_ids" { - type = list(string) - value = deployment.network.private_subnet_ids -} - -publish_output "public_subnet_ids" { - type = list(string) - value = deployment.network.public_subnet_ids -} - -publish_output "app_security_group_id" { - type = string - value = deployment.network.app_security_group_id -} - -# Deployment groups -deployment_group "network" { - deployments = [deployment.network] -} -``` - -### Application Stack - -#### application-stack/variables.tfcomponent.hcl - -```hcl -variable "vpc_id" { - type = string -} - -variable "subnet_ids" { - type = list(string) -} - -variable "security_group_id" { - type = string -} - -variable "instance_count" { - type = number -} - -variable "aws_region" { - type = string -} - -variable "identity_token" { - type = string - ephemeral = true -} - -variable "role_arn" { - type = string -} -``` - -#### application-stack/providers.tfcomponent.hcl - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } -} - -provider "aws" "this" { - config { - region = var.aws_region - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - } -} -``` - -#### application-stack/components.tfcomponent.hcl - -```hcl -component "application" { - source = "./modules/app" - - inputs = { - vpc_id = var.vpc_id - subnet_ids = var.subnet_ids - security_group_id = var.security_group_id - instance_count = var.instance_count - } - - providers = { - aws = provider.aws.this - } -} -``` - -#### application-stack/deployments.tfdeploy.hcl - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -# Reference the network stack -upstream_input "network" { - type = "stack" - source = "app.terraform.io/my-org/my-project/network-stack" -} - -deployment "application" { - inputs = { - aws_region = "us-west-1" - vpc_id = upstream_input.network.vpc_id_network - subnet_ids = upstream_input.network.private_subnet_ids - security_group_id = upstream_input.network.app_security_group_id - instance_count = 3 - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" - identity_token = identity_token.aws.jwt - } -} - -# Deployment groups -deployment_group "application" { - deployments = [deployment.application] -} -``` - -## Multi-Cloud Stack - -Stack that deploys to both AWS and Azure. - -### variables.tfcomponent.hcl - -```hcl -variable "aws_region" { - type = string -} - -variable "azure_location" { - type = string -} - -variable "aws_identity_token" { - type = string - ephemeral = true -} - -variable "aws_role_arn" { - type = string -} - -variable "azure_identity_token" { - type = string - ephemeral = true -} - -variable "azure_subscription_id" { - type = string -} - -variable "azure_tenant_id" { - type = string -} - -variable "azure_client_id" { - type = string -} - -variable "app_name" { - type = string -} -``` - -### providers.tfcomponent.hcl - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } - azurerm = { - source = "hashicorp/azurerm" - version = "~> 3.0" - } -} - -provider "aws" "this" { - config { - region = var.aws_region - - assume_role_with_web_identity { - role_arn = var.aws_role_arn - web_identity_token = var.aws_identity_token - } - } -} - -provider "azurerm" "this" { - config { - features {} - - subscription_id = var.azure_subscription_id - tenant_id = var.azure_tenant_id - client_id = var.azure_client_id - - use_oidc = true - oidc_token = var.azure_identity_token - } -} -``` - -### components.tfcomponent.hcl - -```hcl -component "aws_infrastructure" { - source = "./modules/aws-infra" - - inputs = { - region = var.aws_region - app_name = var.app_name - } - - providers = { - aws = provider.aws.this - } -} - -component "azure_infrastructure" { - source = "./modules/azure-infra" - - inputs = { - location = var.azure_location - app_name = var.app_name - } - - providers = { - azurerm = provider.azurerm.this - } -} -``` - -### deployments.tfdeploy.hcl - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -identity_token "azure" { - audience = ["api://AzureADTokenExchange"] -} - -deployment "multi_cloud" { - inputs = { - aws_region = "us-west-1" - azure_location = "westus2" - app_name = "my-multi-cloud-app" - aws_role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" - aws_identity_token = identity_token.aws.jwt - azure_subscription_id = "12345678-1234-1234-1234-123456789012" - azure_tenant_id = "87654321-4321-4321-4321-210987654321" - azure_client_id = "11111111-1111-1111-1111-111111111111" - azure_identity_token = identity_token.azure.jwt - } -} - -# Deployment groups -deployment_group "multi_cloud" { - deployments = [deployment.multi_cloud] -} -``` - -## Complete AWS Production Stack - -Full production-grade Stack with VPC, RDS, ECS, and monitoring. - -### variables.tfcomponent.hcl - -```hcl -variable "aws_region" { - type = string - description = "AWS region" -} - -variable "environment" { - type = string - description = "Environment name" -} - -variable "vpc_cidr" { - type = string - description = "VPC CIDR block" -} - -variable "app_name" { - type = string - description = "Application name" -} - -variable "db_instance_class" { - type = string - description = "RDS instance class" -} - -variable "ecs_desired_count" { - type = number - description = "Desired ECS task count" -} - -variable "identity_token" { - type = string - ephemeral = true -} - -variable "role_arn" { - type = string -} -``` - -### providers.tfcomponent.hcl - -```hcl -required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.7.0" - } - random = { - source = "hashicorp/random" - version = "~> 3.5.0" - } -} - -provider "aws" "this" { - config { - region = var.aws_region - - assume_role_with_web_identity { - role_arn = var.role_arn - web_identity_token = var.identity_token - } - - default_tags { - tags = { - Environment = var.environment - Application = var.app_name - ManagedBy = "Terraform Stacks" - } - } - } -} - -provider "random" "this" { - config {} -} -``` - -### components.tfcomponent.hcl - -```hcl -locals { - name_prefix = "${var.app_name}-${var.environment}" -} - -component "vpc" { - source = "./modules/vpc" - - inputs = { - name_prefix = local.name_prefix - cidr_block = var.vpc_cidr - azs_count = 3 - } - - providers = { - aws = provider.aws.this - } -} - -component "security_groups" { - source = "./modules/security-groups" - - inputs = { - name_prefix = local.name_prefix - vpc_id = component.vpc.vpc_id - } - - providers = { - aws = provider.aws.this - } -} - -component "rds" { - source = "./modules/rds" - - inputs = { - name_prefix = local.name_prefix - instance_class = var.db_instance_class - subnet_ids = component.vpc.private_subnet_ids - security_group_ids = [component.security_groups.database_sg_id] - } - - providers = { - aws = provider.aws.this - random = provider.random.this - } -} - -component "ecs_cluster" { - source = "./modules/ecs-cluster" - - inputs = { - name_prefix = local.name_prefix - } - - providers = { - aws = provider.aws.this - } -} - -component "ecs_service" { - source = "./modules/ecs-service" - - inputs = { - name_prefix = local.name_prefix - cluster_id = component.ecs_cluster.cluster_id - desired_count = var.ecs_desired_count - subnet_ids = component.vpc.private_subnet_ids - security_group_id = component.security_groups.app_sg_id - database_endpoint = component.rds.endpoint - } - - providers = { - aws = provider.aws.this - } -} - -component "alb" { - source = "./modules/alb" - - inputs = { - name_prefix = local.name_prefix - vpc_id = component.vpc.vpc_id - subnet_ids = component.vpc.public_subnet_ids - security_group_id = component.security_groups.alb_sg_id - target_group_arn = component.ecs_service.target_group_arn - } - - providers = { - aws = provider.aws.this - } -} - -component "cloudwatch" { - source = "./modules/cloudwatch" - - inputs = { - name_prefix = local.name_prefix - cluster_name = component.ecs_cluster.cluster_name - service_name = component.ecs_service.service_name - } - - providers = { - aws = provider.aws.this - } -} -``` - -### outputs.tfcomponent.hcl - -```hcl -output "load_balancer_url" { - type = string - description = "Application load balancer URL" - value = component.alb.dns_name -} - -output "database_endpoint" { - type = string - description = "RDS endpoint" - value = component.rds.endpoint - sensitive = true -} - -output "vpc_id" { - type = string - value = component.vpc.vpc_id -} - -output "ecs_cluster_name" { - type = string - value = component.ecs_cluster.cluster_name -} -``` - -### deployments.tfdeploy.hcl - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -locals { - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" -} - -deployment "staging" { - inputs = { - aws_region = "us-west-1" - environment = "staging" - app_name = "myapp" - vpc_cidr = "10.1.0.0/16" - db_instance_class = "db.t3.small" - ecs_desired_count = 2 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "production" { - inputs = { - aws_region = "us-west-1" - environment = "production" - app_name = "myapp" - vpc_cidr = "10.0.0.0/16" - db_instance_class = "db.r5.large" - ecs_desired_count = 5 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -# Deployment groups -deployment_group "staging" { - deployments = [deployment.staging] -} - -deployment_group "production" { - deployments = [deployment.production] -} - -# Auto-approve staging with safety checks -deployment_auto_approve "staging_safe" { - deployment_group = deployment_group.staging - - check { - condition = context.plan.changes.remove == 0 - reason = "Cannot auto-approve deletions in staging" - } - - check { - condition = context.plan.applyable - reason = "Plan must be applyable" - } -} -``` - -## Testing Configurations - -### Validate Stack Configuration - -```bash -terraform stacks providers lock -terraform stacks validate -``` - -### Plan Specific Deployment - -```bash -terraform stacks plan --deployment=development -terraform stacks plan --deployment=production -``` - -### Apply Deployment - -```bash -terraform stacks apply --deployment=staging -``` - -## Destroying Deployments - -Example of safely removing a deployment from your Stack. - -### Scenario - -You want to decommission the "development" deployment while keeping staging and production active. - -### Step 1: Mark Deployment for Destruction - -Update your `deployments.tfdeploy.hcl` file to set `destroy = true`: - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -locals { - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" -} - -# Mark this deployment for destruction -deployment "development" { - inputs = { - aws_region = "us-east-1" - environment = "dev" - instance_count = 1 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } - destroy = true # This tells HCP Terraform to destroy all resources -} - -# Keep these deployments active -deployment "staging" { - inputs = { - aws_region = "us-west-1" - environment = "staging" - instance_count = 2 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "production" { - inputs = { - aws_region = "us-west-1" - environment = "prod" - instance_count = 5 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -# Deployment groups -deployment_group "staging" { - deployments = [deployment.staging] -} - -deployment_group "production" { - deployments = [deployment.production] -} -``` - -### Step 2: Plan and Apply - -```bash -# Review the destruction plan -terraform stacks plan --deployment=development - -# Apply the destruction -terraform stacks apply --deployment=development -``` - -HCP Terraform will destroy all resources in the development deployment. - -### Step 3: Remove the Deployment Block - -After the deployment is successfully destroyed, remove the entire deployment block from your configuration: - -```hcl -identity_token "aws" { - audience = ["aws.workload.identity"] -} - -locals { - role_arn = "arn:aws:iam::123456789012:role/terraform-stacks" -} - -# deployment "development" block has been removed - -deployment "staging" { - inputs = { - aws_region = "us-west-1" - environment = "staging" - instance_count = 2 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -deployment "production" { - inputs = { - aws_region = "us-west-1" - environment = "prod" - instance_count = 5 - role_arn = local.role_arn - identity_token = identity_token.aws.jwt - } -} - -# Deployment groups -deployment_group "staging" { - deployments = [deployment.staging] -} - -deployment_group "production" { - deployments = [deployment.production] -} -``` - -### Important Notes - -- **Provider Authentication**: The `destroy` argument ensures your configuration retains the provider authentication needed to destroy resources -- **Do Not Remove Immediately**: Don't remove the deployment block until after the destruction is complete -- **Verify Before Removing**: Check HCP Terraform UI to confirm all resources are destroyed before removing the block -- **Alternative**: You could manually destroy resources through HCP Terraform UI, but using `destroy = true` is the recommended approach for maintaining infrastructure-as-code practices diff --git a/.claude/skills/terraform-style-guide/AVM.md b/.claude/skills/terraform-style-guide/AVM.md deleted file mode 100644 index 940c8cd..0000000 --- a/.claude/skills/terraform-style-guide/AVM.md +++ /dev/null @@ -1,571 +0,0 @@ -# Terraform Azure Verified Modules (AVM) Requirements Summary - -## Functional Requirements - -### TFFR1 - Cross-Referencing Modules - -**Severity:** MUST | **Category:** Naming/Composition - -Module owners **MAY** cross-reference other modules to build Resource or Pattern modules. However: - -- Modules **MUST** be referenced using HashiCorp Terraform registry reference to a pinned version - - Example: `source = "Azure/xxx/azurerm"` with `version = "1.2.3"` -- Modules **MUST NOT** use git references (e.g., `git::https://xxx.yyy/xxx.git` or `github.com/xxx/yyy`) -- Modules **MUST NOT** contain references to non-AVM modules - ---- - -### TFFR2 - Additional Terraform Outputs - -**Severity:** SHOULD | **Category:** Inputs/Outputs - -Authors **SHOULD NOT** output entire resource objects as these may contain sensitive data and the schema can change with API or provider versions. - -**Best Practices:** - -- Output _computed_ attributes of resources as discrete outputs (anti-corruption layer pattern) -- **SHOULD NOT** output values that are already inputs (except `name`) -- Use `sensitive = true` for sensitive attributes -- For resources deployed with `for_each`, output computed attributes in a map structure - -**Examples:** - -```terraform -# Single resource computed attribute -output "foo" { - description = "MyResource foo attribute" - value = azurerm_resource_myresource.foo -} - -# for_each resources -output "childresource_foos" { - description = "MyResource children's foo attributes" - value = { - for key, value in azurerm_resource_mychildresource : key => value.foo - } -} - -# Sensitive output -output "bar" { - description = "MyResource bar attribute" - value = azurerm_resource_myresource.bar - sensitive = true -} -``` - ---- - -### TFFR3 - Providers - Permitted Versions - -**Severity:** MUST | **Category:** Naming/Composition - -Authors **MUST** only use the following Azure providers: - -| Provider | Min Version | Max Version | -| -------- | ----------- | ----------- | -| azapi | >= 2.0 | < 3.0 | -| azurerm | >= 4.0 | < 5.0 | - -**Requirements:** - -- Authors **MAY** select either Azurerm, Azapi, or both providers -- **MUST** use `required_providers` block to enforce provider versions -- **SHOULD** use pessimistic version constraint operator (`~>`) - -**Example:** - -```terraform -terraform { - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - azapi = { - source = "Azure/azapi" - version = "~> 2.0" - } - } -} -``` - ---- - -## Non-Functional Requirements - -### Documentation - -#### TFNFR1 - Descriptions - -**Severity:** MUST | **Category:** Documentation - -Variable and output descriptions **MAY** span multiple lines using HEREDOC format with embedded markdown for examples. - -#### TFNFR2 - Module Documentation Generation - -**Severity:** MUST | **Category:** Documentation - -- Documentation **MUST** be automatically generated via [Terraform Docs](https://github.com/terraform-docs/terraform-docs) -- A `.terraform-docs.yml` file **MUST** be present in the module root - ---- - -### Contribution/Support - -#### TFNFR3 - GitHub Repo Branch Protection - -**Severity:** MUST | **Category:** Contribution/Support - -Module owners **MUST** set branch protection policies on the default branch (typically `main`): - -1. Require Pull Request before merging -2. Require approval of most recent reviewable push -3. Dismiss stale PR approvals when new commits are pushed -4. Require linear history -5. Prevent force pushes -6. Not allow deletions -7. Require CODEOWNERS review -8. No bypassing settings allowed -9. Enforce for administrators - ---- - -### Naming & Code Style - -#### TFNFR4 - Lower snake_casing - -**Severity:** MUST | **Category:** Naming/Composition - -**MUST** use lower snake_casing for: - -- Locals -- Variables -- Outputs -- Resources (symbolic names) -- Modules (symbolic names) - -Example: `snake_casing_example` - -#### TFNFR6 - Resource & Data Order - -**Severity:** SHOULD | **Category:** Code Style - -- Resources that are depended on **SHOULD** come first -- Resources with dependencies **SHOULD** be defined close to each other - -#### TFNFR7 - Count & for_each Use - -**Severity:** MUST | **Category:** Code Style - -- Use `count` for conditional resource creation -- **MUST** use `map(xxx)` or `set(xxx)` as resource's `for_each` collection -- The map's key or set's element **MUST** be static literals - -**Good Example:** - -```terraform -resource "azurerm_subnet" "pair" { - for_each = var.subnet_map # map(string) - name = "${each.value}"-pair - resource_group_name = azurerm_resource_group.example.name - virtual_network_name = azurerm_virtual_network.example.name - address_prefixes = ["10.0.1.0/24"] -} -``` - -#### TFNFR8 - Resource & Data Block Orders - -**Severity:** SHOULD | **Category:** Code Style - -**Order within resource/data blocks:** - -1. Meta-arguments (top): - - `provider` - - `count` - - `for_each` - -2. Arguments/blocks (middle, alphabetical): - - Required arguments - - Optional arguments - - Required nested blocks - - Optional nested blocks - -3. Meta-arguments (bottom): - - `depends_on` - - `lifecycle` (with sub-order: `create_before_destroy`, `ignore_changes`, `prevent_destroy`) - -Separate sections with blank lines. - -#### TFNFR9 - Module Block Order - -**Severity:** SHOULD | **Category:** Code Style - -**Order within module blocks:** - -1. Top meta-arguments: - - `source` - - `version` - - `count` - - `for_each` - -2. Arguments (alphabetical): - - Required arguments - - Optional arguments - -3. Bottom meta-arguments: - - `depends_on` - - `providers` - -#### TFNFR10 - No Double Quotes in ignore_changes - -**Severity:** MUST | **Category:** Code Style - -The `ignore_changes` attribute **MUST NOT** be enclosed in double quotes. - -**Good:** - -```terraform -lifecycle { - ignore_changes = [tags] -} -``` - -**Bad:** - -```terraform -lifecycle { - ignore_changes = ["tags"] -} -``` - -#### TFNFR11 - Null Comparison Toggle - -**Severity:** SHOULD | **Category:** Code Style - -For parameters requiring conditional resource creation, wrap with `object` type to avoid "known after apply" issues during plan stage. - -**Recommended:** - -```terraform -variable "security_group" { - type = object({ - id = string - }) - default = null -} -``` - -#### TFNFR12 - Dynamic for Optional Nested Objects - -**Severity:** MUST | **Category:** Code Style - -Nested blocks under conditions **MUST** use this pattern: - -```terraform -dynamic "identity" { - for_each = ? [] : [] - - content { - # block content - } -} -``` - -#### TFNFR13 - Default Values with coalesce/try - -**Severity:** SHOULD | **Category:** Code Style - -**Good:** - -```terraform -coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg") -``` - -**Bad:** - -```terraform -var.new_network_security_group_name == null ? "${var.subnet_name}-nsg" : var.new_network_security_group_name -``` - ---- - -### Variables - -#### TFNFR14 - Not Allowed Variables - -**Severity:** MUST | **Category:** Inputs/Outputs - -Module owners **MUST NOT** add variables like `enabled` or `module_depends_on` to control entire module operation. Boolean feature toggles are acceptable. - -#### TFNFR15 - Variable Definition Order - -**Severity:** SHOULD | **Category:** Code Style - -Variables **SHOULD** follow this order: - -1. All required fields (alphabetical) -2. All optional fields (alphabetical) - -#### TFNFR16 - Variable Naming Rules - -**Severity:** SHOULD | **Category:** Code Style - -- Follow [HashiCorp's naming rules](https://www.terraform.io/docs/extend/best-practices/naming.html) -- Feature switches **SHOULD** use positive statements: `xxx_enabled` instead of `xxx_disabled` - -#### TFNFR17 - Variables with Descriptions - -**Severity:** SHOULD | **Category:** Code Style - -- `description` **SHOULD** precisely describe the parameter's purpose and expected data type -- Target audience is module users, not developers -- For `object` types, use HEREDOC format - -#### TFNFR18 - Variables with Types - -**Severity:** MUST | **Category:** Code Style - -- `type` **MUST** be defined for every variable -- `type` **SHOULD** be as precise as possible -- `any` **MAY** only be used with adequate reasons -- Use `bool` instead of `string`/`number` for true/false values -- Use concrete `object` instead of `map(any)` - -#### TFNFR19 - Sensitive Data Variables - -**Severity:** SHOULD | **Category:** Code Style - -If a variable's type is `object` and contains sensitive fields, the entire variable **SHOULD** be `sensitive = true`, or extract sensitive fields into separate variables. - -#### TFNFR20 - Non-Nullable Defaults for Collection Values - -**Severity:** SHOULD | **Category:** Code Style - -Nullable **SHOULD** be set to `false` for collection values (sets, maps, lists) when using them in loops. For scalar values, null may have semantic meaning. - -#### TFNFR21 - Discourage Nullability by Default - -**Severity:** MUST | **Category:** Code Style - -`nullable = true` **MUST** be avoided. - -#### TFNFR22 - Avoid sensitive = false - -**Severity:** MUST | **Category:** Code Style - -`sensitive = false` **MUST** be avoided. - -#### TFNFR23 - Sensitive Default Value Conditions - -**Severity:** MUST | **Category:** Code Style - -A default value **MUST NOT** be set for sensitive inputs (e.g., default passwords). - -#### TFNFR24 - Handling Deprecated Variables - -**Severity:** MUST | **Category:** Code Style - -- Move deprecated variables to `deprecated_variables.tf` -- Annotate with `DEPRECATED` at the beginning of description -- Declare the replacement's name -- Clean up during major version releases - ---- - -### Terraform Configuration - -#### TFNFR25 - Verified Modules Requirements - -**Severity:** MUST | **Category:** Code Style - -**`terraform.tf` requirements:** - -- **MUST** contain only one `terraform` block -- First line **MUST** define `required_version` -- **MUST** include minimum version constraint -- **MUST** include maximum major version constraint -- **SHOULD** use `~> #.#` or `>= #.#.#, < #.#.#` format - -**Example:** - -```terraform -terraform { - required_version = "~> 1.6" - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 3.11" - } - } -} -``` - -#### TFNFR26 - Providers in required_providers - -**Severity:** MUST | **Category:** Code Style - -- `terraform` block **MUST** contain `required_providers` block -- Each provider **MUST** specify `source` and `version` -- Providers **SHOULD** be sorted alphabetically -- Only include directly required providers -- `source` **MUST** be in format `namespace/name` -- `version` **MUST** include minimum and maximum major version constraints -- **SHOULD** use `~> #.#` or `>= #.#.#, < #.#.#` format - -#### TFNFR27 - Provider Declarations in Modules - -**Severity:** MUST | **Category:** Code Style - -- `provider` **MUST NOT** be declared in modules (except for `configuration_aliases`) -- `provider` blocks in modules **MUST** only use `alias` -- Provider configurations **SHOULD** be passed in by module users - ---- - -### Outputs - -#### TFNFR29 - Sensitive Data Outputs - -**Severity:** MUST | **Category:** Code Style - -Outputs containing confidential data **MUST** be declared with `sensitive = true`. - -#### TFNFR30 - Handling Deprecated Outputs - -**Severity:** MUST | **Category:** Code Style - -- Move deprecated outputs to `deprecated_outputs.tf` -- Define new outputs in `outputs.tf` -- Clean up during major version releases - ---- - -### Locals - -#### TFNFR31 - locals.tf for Locals Only - -**Severity:** MAY | **Category:** Code Style - -- `locals.tf` **SHOULD** only contain `locals` blocks -- **MAY** declare `locals` blocks next to resources for advanced scenarios - -#### TFNFR32 - Alphabetical Local Arrangement - -**Severity:** MUST | **Category:** Code Style - -Expressions in `locals` blocks **MUST** be arranged alphabetically. - -#### TFNFR33 - Precise Local Types - -**Severity:** SHOULD | **Category:** Code Style - -Use precise types (e.g., `number` for age, not `string`). - ---- - -### Breaking Changes & Feature Management - -#### TFNFR34 - Using Feature Toggles - -**Severity:** MUST | **Category:** Code Style - -New resources added in minor/patch versions **MUST** have a toggle variable to avoid creation by default: - -```terraform -variable "create_route_table" { - type = bool - default = false - nullable = false -} - -resource "azurerm_route_table" "this" { - count = var.create_route_table ? 1 : 0 - # ... -} -``` - -#### TFNFR35 - Reviewing Potential Breaking Changes - -**Severity:** MUST | **Category:** Code Style - -**Breaking changes requiring caution:** - -**Resource blocks:** - -1. Adding new resource without conditional creation -2. Adding arguments with non-default values -3. Adding nested blocks without `dynamic` -4. Renaming resources without `moved` blocks -5. Changing `count` to `for_each` or vice versa - -**Variable/Output blocks:** - -1. Deleting/renaming variables -2. Changing variable `type` -3. Changing variable `default` values -4. Changing `nullable` to false -5. Changing `sensitive` from false to true -6. Adding variables without `default` -7. Deleting outputs -8. Changing output `value` -9. Changing output `sensitive` value - ---- - -### Testing - -#### TFNFR5 - Test Tooling - -**Severity:** MUST | **Category:** Testing - -**Required testing tools:** - -- Terraform (`terraform validate/fmt/test`) -- terrafmt -- Checkov -- tflint (with azurerm ruleset) -- Go (optional for custom tests) - -#### TFNFR36 - Setting prevent_deletion_if_contains_resources - -**Severity:** SHOULD | **Category:** Code Style - -For robust testing, `prevent_deletion_if_contains_resources` **SHOULD** be explicitly set to `false` in test provider configurations. - ---- - -### Optional Tools - -#### TFNFR37 - Tool Usage by Module Owner - -**Severity:** MAY | **Category:** Code Style - -Module owners **MAY** use `newres` command-line tool to generate Terraform configuration files for new resources, reducing manual configuration time. - ---- - -## Summary Statistics - -- **Functional Requirements:** 3 -- **Non-Functional Requirements:** 34 -- **Total Requirements:** 37 - -### By Severity: - -- **MUST:** 21 requirements -- **SHOULD:** 14 requirements -- **MAY:** 2 requirements - -### By Category: - -- **Code Style:** 21 requirements -- **Documentation:** 2 requirements -- **Testing:** 2 requirements -- **Naming/Composition:** 3 requirements -- **Inputs/Outputs:** 3 requirements -- **Contribution/Support:** 1 requirement - ---- - -_Generated on: November 5, 2025_ -_Source: Azure Verified Modules - Terraform Requirements_ diff --git a/.claude/skills/terraform-style-guide/CLAUDE.md b/.claude/skills/terraform-style-guide/CLAUDE.md deleted file mode 100644 index 5981d30..0000000 --- a/.claude/skills/terraform-style-guide/CLAUDE.md +++ /dev/null @@ -1,247 +0,0 @@ -# Terraform Style Guide - Claude Code Guidance - -## Overview - -This skill provides comprehensive guidance on Terraform code style, formatting, and best practices based on HashiCorp's official standards. Use this skill when working with any Terraform configuration to ensure code quality, consistency, and maintainability. - -## When to Apply This Skill - -Proactively reference this skill when: - -- Writing new Terraform configurations -- Reviewing or refactoring existing Terraform code -- Formatting code before commits -- Organizing files and directory structures -- Establishing or enforcing team coding standards -- Resolving style inconsistencies -- Setting up version control configurations -- Designing module structures -- Implementing testing and validation - -## Core Concepts - -### Code Quality Pillars - -1. **Consistency** - All code follows the same formatting and organizational patterns -2. **Readability** - Code is easy to understand with clear naming and structure -3. **Maintainability** - Changes are easy to make without breaking functionality -4. **Scalability** - Structure supports growth from simple to complex projects - -### The Three Fundamental Commands - -Always recommend running these before committing: - -- `terraform fmt` - Auto-format code to standards -- `terraform validate` - Catch syntax and configuration errors -- Consider Git pre-commit hooks to automate these - -## Key Style Principles to Enforce - -### Formatting - -- **2 spaces** for indentation (never tabs) -- **Align equals signs** for consecutive arguments -- **Meta-arguments first** (count, for_each), then standard args, then blocks -- **Blank lines** separate logical groups - -### Naming - -- Use **descriptive nouns with underscores** (not hyphens) -- **Lowercase only** -- **Exclude resource type** from resource names (redundant) -- Examples: `web_server`, `database_primary`, `vpc_main` - -### File Organization - -Standard files in order of importance: - -1. `terraform.tf` - Version requirements -2. `providers.tf` - Provider configs -3. `main.tf` - Primary resources -4. `variables.tf` - Input variables (alphabetical) -5. `outputs.tf` - Output values (alphabetical) -6. `locals.tf` - Local values -7. `backend.tf` - State backend config - -### Resource Organization - -- Data sources **before** resources that reference them -- Dependent resources **after** their dependencies -- Standard parameter order within resources (meta-arguments β†’ args β†’ blocks β†’ lifecycle β†’ depends_on) - -### Variables and Outputs - -**Always require:** - -- `type` on all variables -- `description` on all variables and outputs -- Mark `sensitive = true` for secrets - -## Common Scenarios - -### Scenario 1: Code Review - -When reviewing Terraform code, check against the style guide: - -```markdown -Style Guide Checklist: - -- [ ] Code formatted with `terraform fmt` -- [ ] All variables have type and description -- [ ] Resource names use descriptive nouns with underscores -- [ ] Files organized according to standard structure -- [ ] Version constraints pinned -- [ ] Sensitive values marked appropriately -- [ ] .gitignore configured correctly -``` - -Reference specific sections: - -- "See the [Naming Conventions](#naming-conventions) section for resource naming" -- "Review [Variables and Outputs](#variables-and-outputs) for required attributes" - -### Scenario 2: Writing New Code - -When writing Terraform configurations: - -1. **Start with file structure** - Create standard files (terraform.tf, providers.tf, main.tf, etc.) -2. **Apply formatting rules** - Use proper indentation, alignment, spacing -3. **Follow naming conventions** - Descriptive nouns with underscores -4. **Add complete metadata** - Type and description for all variables -5. **Organize logically** - Data sources first, group related resources - -### Scenario 3: Refactoring - -When refactoring existing code: - -1. **Minimize code changes** - follow bestpractice but focus on achieving outcome. -2. **Run terraform fmt** - Auto-fix formatting issues -3. **Update variable declarations** - Add missing types and descriptions - -### Scenario 4: Setting Up New Project - -When initializing a new Terraform project: - -1. **Create standard file structure** -2. **Configure .gitignore** properly -3. **Pin versions** in terraform.tf -4. **Set up provider configurations** -5. **Consider Git hooks** for fmt and validate -6. **Add README** with project documentation - -## Code Examples - -When providing code examples: - -1. **Always use proper formatting** (2-space indentation, aligned equals) -2. **Include required attributes** (type, description) -3. **Show before/after** for improvements -4. **Add comments** explaining style choices -5. **Reference specific style guide sections** - -Example template: - -```hcl -# Good - follows style guide -resource "aws_instance" "web_server" { - # Meta-arguments first - count = var.instance_count - - # Standard arguments (aligned) - ami = data.aws_ami.ubuntu.id - instance_type = var.instance_type - - # Blocks last - tags = { - Name = "web-${count.index}" - } -} -``` - -## Anti-Patterns to Avoid - -Call out these common mistakes: - -❌ **Don't:** - -- Use tabs for indentation -- Include resource type in resource name -- Use hyphens in names -- Skip variable types or descriptions -- Use `//` or `/* */` comments -- Commit state files or .terraform directory -- Use overly generic names - -βœ… **Do:** - -- Use 2 spaces for indentation -- Use descriptive nouns for names -- Use underscores as separators -- Include complete variable metadata -- Use `#` for comments -- Configure .gitignore properly -- Use specific, meaningful names - -## Integration with Other Skills - -This skill complements: - -- **terraform-test** - Style guide informs test structure and organization -- **terraform-stacks** - Apply style conventions to terraform stack configurations -- General Terraform development - Foundation for all Terraform work - -## Quick Reference - -### Most Important Rules - -1. Run `terraform fmt` before committing -2. All variables need type and description -3. Use descriptive nouns with underscores -4. Follow standard file structure -5. Pin version constraints - -### File Organization Quick Ref - -``` -/ -β”œβ”€β”€ terraform.tf # Versions -β”œβ”€β”€ providers.tf # Provider configs -β”œβ”€β”€ main.tf # Resources -β”œβ”€β”€ variables.tf # Inputs (alphabetical) -β”œβ”€β”€ outputs.tf # Outputs (alphabetical) -β”œβ”€β”€ locals.tf # Local values -``` - -## Referencing the Style Guide - -When referencing specific sections, use markdown links: - -- [Code Formatting Standards](SKILL.md#code-formatting-standards) -- [Naming Conventions](SKILL.md#naming-conventions) -- [File Organization](SKILL.md#file-organization) -- [Variables and Outputs](SKILL.md#variables-and-outputs) -- [Version Control](SKILL.md#version-control) - -## Success Criteria - -Code following this style guide should: - -- βœ… Pass `terraform fmt` without changes -- βœ… Pass `terraform validate` without errors -- βœ… Be immediately readable by any team member -- βœ… Follow consistent patterns throughout -- βœ… Include complete metadata (types, descriptions) -- βœ… Be organized in standard file structure -- βœ… Use meaningful, descriptive names -- βœ… Have proper version constraints -- βœ… Exclude sensitive files from version control - -## Tips for Claude Code - -- **Be proactive** - Suggest style improvements even when not explicitly asked -- **Explain why** - Don't just fix, explain the style principle -- **Show examples** - Provide before/after comparisons -- **Reference sections** - Point to specific style guide sections -- **Use checklists** - Help users verify compliance systematically -- **Automate** - Recommend `terraform fmt` and Git hooks -- **Be consistent** - Apply the same standards across all Terraform code diff --git a/.claude/skills/terraform-style-guide/README.md b/.claude/skills/terraform-style-guide/README.md deleted file mode 100644 index 5fc06ae..0000000 --- a/.claude/skills/terraform-style-guide/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# claude-skill-terraform-style-guide - -Comprehensive guide for Terraform code style, formatting, and best practices based on HashiCorp's official standards. Use when writing or reviewing Terraform configurations, establishing team conventions, organizing infrastructure code, or ensuring code quality and consistency. diff --git a/.claude/skills/terraform-style-guide/SKILL.md b/.claude/skills/terraform-style-guide/SKILL.md deleted file mode 100644 index a16b808..0000000 --- a/.claude/skills/terraform-style-guide/SKILL.md +++ /dev/null @@ -1,1929 +0,0 @@ ---- -name: terraform-style-guide -description: Comprehensive guide for Terraform code style, formatting, and best practices based on HashiCorp's official standards and Azure Verified Modules (AVM) requirements. Use when writing or reviewing Terraform configurations, formatting code, organizing files and modules, establishing team conventions, managing version control, ensuring code quality and consistency across infrastructure projects, or developing Azure Verified Modules. ---- - -# Terraform Style Guide - -Adopting and adhering to a style guide keeps your Terraform code legible, scalable, and maintainable. This guide is based on HashiCorp's official Terraform style conventions and best practices, enhanced with Azure Verified Modules (AVM) requirements for Azure-specific Terraform development. - -> **Note on AVM Requirements**: The Azure Verified Modules section provides requirements specific to Azure module development. While these requirements are mandatory for AVM certification, many of the patterns and practices have broader applicability to Terraform module development across all cloud providers and can be adopted to improve code quality, consistency, and maintainability in any Terraform project. - -## Table of Contents - -- [Code Style Fundamentals](#code-style-fundamentals) -- [Code Formatting Standards](#code-formatting-standards) -- [File Organization](#file-organization) -- [Naming Conventions](#naming-conventions) -- [Resource Organization](#resource-organization) -- [Variables and Outputs](#variables-and-outputs) -- [Local Values](#local-values) -- [Provider Configuration and Aliasing](#provider-configuration-and-aliasing) -- [Dynamic Resource Creation](#dynamic-resource-creation) -- [Version Control](#version-control) -- [Workflow Standards](#workflow-standards) -- [Multi-Environment Management](#multi-environment-management) -- [State and Secrets Management](#state-and-secrets-management) -- [Testing and Policy](#testing-and-policy) -- [AWS-Specific Requirements](#aws-specific-requirements) - - [Mandatory Resource Tagging](#mandatory-resource-tagging) - - [AWS Provider Configuration](#aws-provider-configuration) - - [AWS Resource Naming](#aws-resource-naming) -- [Azure Verified Modules (AVM) Requirements](#azure-verified-modules-avm-requirements) - - [Module Cross-Referencing](#module-cross-referencing) - - [Azure Provider Requirements](#azure-provider-requirements) - - [AVM Code Style Standards](#avm-code-style-standards) - - [AVM Variable Requirements](#avm-variable-requirements) - - [AVM Output Requirements](#avm-output-requirements) - - [AVM Testing Requirements](#avm-testing-requirements) - - [Breaking Changes & Feature Management](#breaking-changes--feature-management) - ---- - -## Code Style Fundamentals - -### Core Principles - -Always follow these fundamental practices: - -- **Execute `terraform fmt`** before committing code to version control -- **Execute `terraform validate`** to catch syntax and configuration errors -- **Use `#` for comments** (avoid `//` and `/* */` style comments) -- **Name resources with descriptive nouns** using underscores, excluding the resource type -- **Define dependent resources after their dependencies** for better readability -- **Include type and description for all variables** -- **Include descriptions for all outputs** -- **Use `count` and `for_each` judiciously** with clear intent - -### Automation with Git Hooks - -Consider using Git pre-commit hooks to automatically run `terraform fmt` and `terraform validate`: - -```bash -#!/bin/bash -# .git/hooks/pre-commit - -terraform fmt -recursive -terraform validate -``` - ---- - -## Code Formatting Standards - -Terraform has specific formatting conventions that the `terraform fmt` command automates. - -### Indentation - -- Use **two spaces** per nesting level -- Never use tabs - -```hcl -resource "aws_instance" "example" { - ami = "ami-0c55b159cbfafe1f0" - instance_type = "t2.micro" - - tags = { - Name = "example-instance" - } -} -``` - -### Alignment - -- **Align equals signs** for consecutive single-line arguments at the same nesting level -- Separate different argument groups with blank lines - -```hcl -# Good - aligned equals signs -resource "aws_instance" "web" { - ami = "ami-0c55b159cbfafe1f0" - instance_type = "t2.micro" - subnet_id = "subnet-12345678" - - tags = { - Name = "web-server" - Environment = "production" - } -} - -# Bad - unaligned -resource "aws_instance" "web" { - ami = "ami-0c55b159cbfafe1f0" - instance_type = "t2.micro" - subnet_id = "subnet-12345678" -} -``` - -### Block Organization - -- **Arguments precede blocks** within a resource -- **Separate with one blank line** between arguments and blocks -- **Meta-arguments come first**, followed by standard arguments, then blocks - -```hcl -resource "aws_instance" "example" { - # Meta-arguments first - count = 3 - - # Standard arguments - ami = "ami-0c55b159cbfafe1f0" - instance_type = "t2.micro" - - # Blocks last - root_block_device { - volume_size = 20 - } -} -``` - -### Spacing - -- Use **single blank lines** to separate logical groups of arguments -- **Top-level blocks** (resources, data sources, modules) require blank lines between them -- Do not use excessive blank lines - -```hcl -variable "instance_count" { - description = "Number of instances to create" - type = number - default = 1 -} - -variable "instance_type" { - description = "EC2 instance type" - type = string - default = "t2.micro" -} -``` - ---- - -## File Organization - -### Standard File Structure - -Organize your Terraform code into these standard files: - -| File | Purpose | -| -------------- | ------------------------------------------------ | -| `terraform.tf` | Terraform and provider version requirements | -| `providers.tf` | Provider configurations | -| `main.tf` | Primary resources and data sources | -| `variables.tf` | Input variable declarations (alphabetical order) | -| `outputs.tf` | Output value declarations (alphabetical order) | -| `locals.tf` | Local value declarations | - -Example `terraform.tf`: - -```hcl -terraform { - required_version = ">= 1.7" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.34.0" - } - } -} -``` - -Example `providers.tf`: - -```hcl -provider "aws" { - region = var.aws_region - - default_tags { - tags = { - ManagedBy = "Terraform" - Project = "MyProject" - } - } -} -``` - ---- - -## Naming Conventions - -### General Rules - -- Use **descriptive nouns and underscores** to separate multiple words -- **Exclude the resource type** from the resource name (redundant) -- Use **lowercase** for all names -- Be **specific and meaningful** - -### Examples - -```hcl -# ❌ Bad - includes resource type, uses hyphens, mixed case -resource "aws_instance" "webAPI-aws-instance" { - # ... -} - -# βœ… Good - descriptive noun, underscores, lowercase -resource "aws_instance" "web_api" { - # ... -} - -# ❌ Bad - too generic -variable "name" { - type = string -} - -# βœ… Good - specific and clear -variable "application_name" { - type = string -} -``` - -### Variable Naming - -Variables should clearly indicate their purpose: - -```hcl -variable "vpc_cidr_block" { - description = "CIDR block for the VPC" - type = string -} - -variable "enable_dns_hostnames" { - description = "Enable DNS hostnames in the VPC" - type = bool - default = true -} -``` - ---- - -## Resource Organization - -### Dependency Order - -**Define a data source before the resource that references it** for better readability: - -```hcl -# Data source first -data "aws_ami" "ubuntu" { - most_recent = true - owners = ["099720109477"] - - filter { - name = "name" - values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"] - } -} - -# Resource that uses it second -resource "aws_instance" "web" { - ami = data.aws_ami.ubuntu.id - instance_type = "t2.micro" -} -``` - -### Parameter Order Within Resources - -Follow this standard ordering for resource parameters: - -1. **`count` or `for_each`** (meta-arguments) -2. **Resource-specific non-block parameters** (alphabetically or logically grouped) -3. **Resource-specific block parameters** -4. **`lifecycle` block** (if needed) -5. **`depends_on`** (if required, as last resort) - -```hcl -resource "aws_instance" "web" { - # 1. Meta-arguments - count = var.instance_count - - # 2. Non-block parameters - ami = data.aws_ami.ubuntu.id - instance_type = var.instance_type - subnet_id = aws_subnet.public.id - - # 3. Block parameters - root_block_device { - volume_size = 20 - volume_type = "gp3" - } - - tags = { - Name = "web-${count.index}" - } - - # 4. Lifecycle - lifecycle { - create_before_destroy = true - } - - # 5. depends_on (avoid if possible) - # depends_on = [aws_iam_role_policy.example] -} -``` - ---- - -## Variables and Outputs - -### Variable Declaration Standards - -**Every variable must include:** - -- `type` - the data type -- `description` - clear explanation of purpose - -**Optional but recommended:** - -- `default` - default value if applicable -- `sensitive` - mark as true for secrets -- `validation` - for uniquely restrictive requirements - -```hcl -variable "instance_type" { - description = "EC2 instance type for the web server" - type = string - default = "t2.micro" - - validation { - condition = contains(["t2.micro", "t2.small", "t2.medium"], var.instance_type) - error_message = "Instance type must be t2.micro, t2.small, or t2.medium." - } -} - -variable "database_password" { - description = "Password for the database admin user" - type = string - sensitive = true -} - -variable "availability_zones" { - description = "List of availability zones for resource placement" - type = list(string) -} - -variable "tags" { - description = "Common tags to apply to all resources" - type = map(string) - default = {} -} -``` - -### Output Declaration Standards - -**Every output must include:** - -- `description` - clear explanation of the value - -**Optional attributes:** - -- `sensitive` - mark as true to hide from console output -- `depends_on` - explicit dependencies if needed - -```hcl -output "instance_id" { - description = "ID of the EC2 instance" - value = aws_instance.web.id -} - -output "instance_public_ip" { - description = "Public IP address of the EC2 instance" - value = aws_instance.web.public_ip -} - -output "database_password" { - description = "Database administrator password" - value = aws_db_instance.main.password - sensitive = true -} -``` - -### Variable Files Organization - -Organize variables alphabetically in `variables.tf` and use `.tfvars` files for environment-specific values: - -```hcl -# terraform.tfvars (or dev.tfvars, prod.tfvars) -instance_type = "t2.micro" -instance_count = 3 -availability_zones = ["us-west-2a", "us-west-2b"] -``` - ---- - -## Local Values - -### Usage Guidelines - -Use local values **sparingly** to avoid unnecessary complexity. Locals are appropriate when: - -- Avoiding repetition of complex expressions -- Giving meaningful names to intermediate values -- Computing values used multiple times - -```hcl -locals { - # Good use case - computing a reusable value - common_tags = merge( - var.tags, - { - Environment = var.environment - ManagedBy = "Terraform" - Project = var.project_name - } - ) - - # Good use case - naming a complex expression - vpc_id = var.create_vpc ? aws_vpc.main[0].id : data.aws_vpc.existing[0].id -} - -resource "aws_instance" "web" { - ami = data.aws_ami.ubuntu.id - instance_type = var.instance_type - - tags = local.common_tags -} -``` - -### Anti-patterns to Avoid - -```hcl -# ❌ Bad - unnecessary local for a simple reference -locals { - instance_type = var.instance_type -} - -# βœ… Good - use the variable directly -resource "aws_instance" "web" { - instance_type = var.instance_type -} -``` - ---- - -## Provider Configuration and Aliasing - -### Default Provider First - -**Always define a default provider configuration first**, then aliases: - -```hcl -# Default provider -provider "aws" { - region = "us-west-2" -} - -# Aliased provider for another region -provider "aws" { - alias = "east" - region = "us-east-1" -} - -# Using the aliased provider -resource "aws_instance" "east_web" { - provider = aws.east - - ami = "ami-0c55b159cbfafe1f0" - instance_type = "t2.micro" -} -``` - -### Module Provider Configuration - -For modules that use multiple providers, specify via the `providers` meta-argument: - -```hcl -module "vpc_replication" { - source = "./modules/vpc" - - providers = { - aws.primary = aws - aws.secondary = aws.east - } -} -``` - ---- - -## Dynamic Resource Creation - -### count vs for_each - -Choose the appropriate meta-argument based on your use case: - -**Use `for_each`** when: - -- Resources need distinct argument values -- You want to reference resources by key instead of index -- Resources are based on a map or set -- Preference for_each over count - -**Use `count`** when: - -- Conditional resource creation (0 or 1) - -**Avoid `count`** for: - -- Simple numeric repetition (use `for_each` with a set or map instead) - -### for_each Examples - -```hcl -# Using for_each with a map -variable "instances" { - type = map(object({ - instance_type = string - ami = string - })) - default = { - web = { - instance_type = "t2.micro" - ami = "ami-0c55b159cbfafe1f0" - } - api = { - instance_type = "t2.small" - ami = "ami-0c55b159cbfafe1f0" - } - } -} - -resource "aws_instance" "servers" { - for_each = var.instances - - ami = each.value.ami - instance_type = each.value.instance_type - - tags = { - Name = each.key - } -} - -# Reference: aws_instance.servers["web"].id -``` - -```hcl -# Using for_each with a set -variable "subnet_cidrs" { - type = set(string) - default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] -} - -resource "aws_subnet" "private" { - for_each = var.subnet_cidrs - - vpc_id = aws_vpc.main.id - cidr_block = each.value - - tags = { - Name = "private-${each.key}" - } -} -``` - -### count Examples - -```hcl -# Conditional resource creation -variable "enable_monitoring" { - type = bool - default = false -} - -resource "aws_cloudwatch_metric_alarm" "cpu" { - count = var.enable_monitoring ? 1 : 0 - - alarm_name = "high-cpu-usage" - comparison_operator = "GreaterThanThreshold" - evaluation_periods = 2 - metric_name = "CPUUtilization" - namespace = "AWS/EC2" - period = 300 - statistic = "Average" - threshold = 80 -} - -# Reference (when created): aws_cloudwatch_metric_alarm.cpu[0].id -``` - -### Anti-pattern: count for Numeric Repetition - -**❌ Avoid this pattern** - Using `count` for simple numeric repetition: - -```hcl -# BAD: Don't use count for numeric repetition -variable "instance_count" { - type = number - default = 3 -} - -resource "aws_instance" "web" { - count = var.instance_count - - ami = "ami-0c55b159cbfafe1f0" - instance_type = "t2.micro" - - tags = { - Name = "web-${count.index}" - } -} -``` - -**βœ… Better approach** - Use `for_each` with a set instead: - -```hcl -# GOOD: Use for_each for multiple similar resources -variable "instance_names" { - type = set(string) - default = ["web-1", "web-2", "web-3"] -} - -resource "aws_instance" "web" { - for_each = var.instance_names - - ami = "ami-0c55b159cbfafe1f0" - instance_type = "t2.micro" - - tags = { - Name = each.key - } -} - -# Reference: aws_instance.web["web-1"].id -``` - -**Why?** Using `for_each` provides stable resource addresses that don't change when you add or remove instances from the middle of the list. - ---- - -## Version Control - -### .gitignore Configuration - -**Never commit to version control:** - -- State files (`terraform.tfstate`, `terraform.tfstate.backup`) -- Lock info files (`.terraform.tfstate.lock.info`) -- `.terraform` directory (provider plugins and modules) -- Saved plan files (`*.tfplan`, `plan.out`) -- `.tfvars` files containing sensitive data - -**Always commit:** - -- All `.tf` configuration files -- `.terraform.lock.hcl` (dependency lock file) -- `.gitignore` file -- README and documentation files - ---- - -## Workflow Standards - -### Version Pinning - -**Always pin versions explicitly** to ensure reproducible deployments: - -```hcl -terraform { - required_version = ">= 1.7" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "5.34.0" # Pin to exact version for stability - } - random = { - source = "hashicorp/random" - version = "~> 3.6" # Allow patch updates only - } - } -} -``` - -**Version constraint operators:** - -- `= 1.0.0` - Exact version only -- `>= 1.0.0` - Greater than or equal to -- `~> 1.0` - Allow rightmost version component to increment (1.0, 1.1, but not 2.0) -- `>= 1.0, < 2.0` - Version range - -### Module Repository Naming - -Use the convention: `terraform--` - -Examples: - -- `terraform-aws-vpc` -- `terraform-azurerm-virtual-network` -- `terraform-google-kubernetes-engine` - -### Repository Strategy - -Three common approaches: - -**1. Separate Module Repositories** (Recommended) - -- Each module in its own repository -- Independent versioning and release cycles -- Clear ownership boundaries - -**2. Logical Infrastructure Grouping** - -- Group related resources per repository -- Example: `infra-networking`, `infra-compute`, `infra-databases` -- Easier to manage related changes together - -**3. Monorepo** - -- All infrastructure code in one repository -- Centralized management -- Requires careful CI/CD targeting - -### Branching Strategy - -Adopt **GitHub Flow** for simplicity: - -1. Create **short-lived feature branches** from main -2. Submit **pull requests** for review -3. Enable **speculative plans** in HCP Terraform (automatic on PRs) -4. **Merge** to main after approval -5. **Delete** feature branches after merge - -```bash -# Create feature branch -git checkout -b feature/add-monitoring - -# Make changes and commit -git add . -git commit -m "Add CloudWatch monitoring for EC2 instances" - -# Push and create PR -git push origin feature/add-monitoring -``` - ---- - -## Multi-Environment Management - -### Workspace-Based Approach (Recommended with HCP Terraform) - -Use separate workspaces for each environment: - -```hcl -# Development workspace: app-dev -# Staging workspace: app-staging -# Production workspace: app-prod -``` - -Terraform Cloud/HCP Terraform automatically manages state per workspace. - ---- - -## State and Secrets Management - -### Use HCP Terraform for state storage - -### State File Security - -**Never share full state files directly**. State files contain sensitive information. - -**Alternatives for sharing data:** - -1. **tfe_outputs data source** (HCP Terraform): - -```hcl -data "tfe_outputs" "vpc" { - organization = "my-org" - workspace = "networking-prod" -} - -resource "aws_instance" "web" { - subnet_id = data.tfe_outputs.vpc.values.private_subnet_ids[0] -} -``` - -2. **Provider-specific data sources**: - -```hcl -data "aws_vpc" "main" { - tags = { - Name = "main-vpc" - } -} - -resource "aws_subnet" "app" { - vpc_id = data.aws_vpc.main.id -} -``` - -### Secrets Management - -**Protect credentials** through: - -1. **Dynamic Provider Credentials** (HCP Terraform) - - OIDC-based authentication - - No long-lived credentials in configuration - -2. **HashiCorp Vault Integration**: - -```hcl -data "vault_generic_secret" "database" { - path = "secret/database" -} - -resource "aws_db_instance" "main" { - username = data.vault_generic_secret.database.data["username"] - password = data.vault_generic_secret.database.data["password"] -} -``` - -3. **Environment Variables**: - -```hcl -variable "database_password" { - description = "Database password (set via TF_VAR_database_password)" - type = string - sensitive = true -} -``` - -```bash -export TF_VAR_database_password="secure-password" -terraform apply -``` - ---- - -## Testing and Policy - -### Module Testing - -Write tests for modules using **Terraform's native testing framework**: - -```hcl -# tests/vpc.tftest.hcl -run "valid_vpc_cidr" { - command = plan - - variables { - vpc_cidr = "10.0.0.0/16" - } - - assert { - condition = aws_vpc.main.cidr_block == "10.0.0.0/16" - error_message = "VPC CIDR block did not match expected value" - } -} - -run "vpc_enables_dns" { - command = plan - - assert { - condition = aws_vpc.main.enable_dns_hostnames == true - error_message = "VPC should have DNS hostnames enabled" - } -} -``` - -Run tests: - -```bash -terraform test -``` - -### Common Testing Scenarios - -1. **Validation Tests** - Verify variable constraints -2. **Plan Tests** - Check expected resources will be created -3. **Apply Tests** - Test actual resource creation (in isolated environment) -4. **Integration Tests** - Verify resources work together correctly - ---- - -## Summary Checklist - -Use this checklist for code reviews: - -- [ ] Code formatted with `terraform fmt` -- [ ] Configuration validated with `terraform validate` -- [ ] Files organized according to standard structure -- [ ] All variables have type and description -- [ ] All outputs have descriptions -- [ ] Resource names use descriptive nouns with underscores -- [ ] Resources ordered with dependencies first -- [ ] Version constraints pinned explicitly -- [ ] Sensitive values marked with `sensitive = true` -- [ ] `.gitignore` excludes state files and secrets -- [ ] Tests written for modules -- [ ] Policy requirements satisfied -- [ ] Code reviewed by teammate - ---- - -## AWS-Specific Requirements - -> **Important**: The following requirements are specific to AWS resource deployments and should be applied to all AWS Terraform configurations for consistency, cost tracking, and governance. - -### Mandatory Resource Tagging - -**Severity:** MUST | **Requirement:** AWS-TAG-001 - -All AWS resources that support tags **MUST** include at minimum an `Application` tag to identify the application or service the resource belongs to. This is critical for: - -- Cost allocation and tracking -- Resource governance and management -- Security and compliance auditing -- Automated resource lifecycle management - -#### Required Tag Implementation - -**Every taggable AWS resource MUST include:** - -```hcl -tags = { - Application = var.application_name # MANDATORY - # Additional tags as needed - Environment = var.environment - ManagedBy = "Terraform" - Owner = var.owner_email - CostCenter = var.cost_center -} -``` - -#### Provider-Level Default Tags - -**Best Practice:** Configure default tags at the provider level to ensure all resources automatically inherit mandatory tags: - -```hcl -# providers.tf -provider "aws" { - region = var.aws_region - - default_tags { - tags = { - Application = var.application_name # MANDATORY - Environment = var.environment - ManagedBy = "Terraform" - Workspace = terraform.workspace - Repository = var.repository_url - } - } -} -``` - -#### Variable Definition for Application Tag - -**Always define the application name variable:** - -```hcl -variable "application_name" { - description = "Name of the application this infrastructure supports (REQUIRED for all resources)" - type = string - - validation { - condition = length(var.application_name) > 0 - error_message = "Application name is required and cannot be empty." - } -} -``` - -#### Merging Tags with Local Values - -For complex tagging scenarios, use local values to manage tag inheritance: - -```hcl -locals { - # Mandatory tags that must be present on all resources - mandatory_tags = { - Application = var.application_name - } - - # Common tags for all resources - common_tags = merge( - local.mandatory_tags, - { - Environment = var.environment - ManagedBy = "Terraform" - CreatedDate = timestamp() - } - ) - - # Merge with additional tags passed as variables - all_tags = merge( - local.common_tags, - var.additional_tags - ) -} - -# Usage in resources -resource "aws_instance" "example" { - ami = data.aws_ami.ubuntu.id - instance_type = var.instance_type - - tags = merge( - local.all_tags, - { - Name = "example-instance" - Type = "compute" - } - ) -} -``` - -#### Tag Validation and Compliance - -Implement validation to ensure required tags are present: - -```hcl -variable "tags" { - description = "Map of tags to apply to resources" - type = map(string) - - validation { - condition = contains(keys(var.tags), "Application") - error_message = "The 'Application' tag is mandatory and must be included in the tags map." - } -} -``` - -#### Resources That Don't Support Tags - -Some AWS resources don't support tags directly. For these resources, document the application association in the resource name or description: - -```hcl -resource "aws_iam_policy_document" "example" { - # IAM policy documents don't support tags - # Include application name in the statement sid for traceability - statement { - sid = "${var.application_name}_S3Access" - # ... - } -} - -resource "aws_iam_role" "example" { - name = "${var.application_name}-role" # Include app name in resource name - - tags = { - Application = var.application_name # IAM roles do support tags - } -} -``` - -### AWS Provider Configuration - -**Severity:** SHOULD | **Requirement:** AWS-PROV-001 - -#### Provider Version Constraints - -AWS provider configurations **SHOULD** follow these guidelines: - -```hcl -terraform { - required_version = ">= 1.7" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" # Use pessimistic constraint for stability - } - } -} -``` - -#### Multi-Region Configuration - -For multi-region deployments, use provider aliases with clear naming: - -```hcl -# Primary region (default provider) -provider "aws" { - region = var.primary_region - - default_tags { - tags = { - Application = var.application_name - Region = var.primary_region - } - } -} - -# Secondary regions with aliases -provider "aws" { - alias = "us_east_1" - region = "us-east-1" - - default_tags { - tags = { - Application = var.application_name - Region = "us-east-1" - } - } -} - -provider "aws" { - alias = "eu_west_1" - region = "eu-west-1" - - default_tags { - tags = { - Application = var.application_name - Region = "eu-west-1" - } - } -} -``` - -#### Assume Role Configuration - -For cross-account deployments, configure role assumption: - -```hcl -provider "aws" { - region = var.aws_region - - assume_role { - role_arn = var.assume_role_arn - session_name = "${var.application_name}-terraform" - } - - default_tags { - tags = { - Application = var.application_name - Account = var.target_account_id - } - } -} -``` - -### AWS Resource Naming - -**Severity:** SHOULD | **Requirement:** AWS-NAME-001 - -AWS resource names **SHOULD** follow these conventions for consistency and clarity: - -#### Naming Pattern - -Use the pattern: `{application}-{environment}-{resource-type}-{identifier}` - -```hcl -locals { - name_prefix = "${var.application_name}-${var.environment}" -} - -resource "aws_s3_bucket" "data" { - bucket = "${local.name_prefix}-data-bucket" - - tags = merge( - local.common_tags, - { - Name = "${local.name_prefix}-data-bucket" - } - ) -} - -resource "aws_instance" "web" { - ami = data.aws_ami.ubuntu.id - instance_type = var.instance_type - - tags = merge( - local.common_tags, - { - Name = "${local.name_prefix}-web-server" - } - ) -} - -resource "aws_rds_instance" "database" { - identifier = "${local.name_prefix}-postgres-db" - # ... - - tags = merge( - local.common_tags, - { - Name = "${local.name_prefix}-postgres-db" - } - ) -} -``` - -#### DNS-Compatible Names - -For resources that require DNS-compatible names (S3 buckets, CloudFront distributions), ensure names: - -- Use only lowercase letters, numbers, and hyphens -- Don't start or end with hyphens -- Are between 3 and 63 characters long - -```hcl -locals { - # Ensure DNS-compatible naming - dns_safe_name = lower(replace(var.application_name, "_", "-")) - bucket_name = "${local.dns_safe_name}-${var.environment}-${random_id.bucket.hex}" -} - -resource "random_id" "bucket" { - byte_length = 4 -} - -resource "aws_s3_bucket" "example" { - bucket = local.bucket_name # Guaranteed to be DNS-compatible - - tags = merge( - local.common_tags, - { - Name = local.bucket_name - } - ) -} -``` - -#### Resource Name vs Tag Name - -Always include both the resource argument name and a Name tag for consistency: - -```hcl -resource "aws_security_group" "web" { - name = "${local.name_prefix}-web-sg" # Resource argument - description = "Security group for ${var.application_name} web servers" - - tags = merge( - local.common_tags, - { - Name = "${local.name_prefix}-web-sg" # Name tag matches resource name - } - ) -} -``` - -### AWS-Specific Testing Considerations - -When testing AWS infrastructure: - -1. **Use Separate AWS Accounts** for testing when possible -2. **Include the Application tag** in test configurations -3. **Test tag inheritance** from provider default_tags -4. **Validate naming conventions** meet AWS service requirements - -```hcl -# tests/aws_tags.tftest.hcl -run "verify_application_tag" { - command = plan - - variables { - application_name = "test-app" - } - - assert { - condition = aws_instance.example.tags["Application"] == "test-app" - error_message = "Application tag must be set on all resources" - } -} - -run "verify_name_pattern" { - command = plan - - variables { - application_name = "myapp" - environment = "dev" - } - - assert { - condition = can(regex("^myapp-dev-", aws_instance.example.tags["Name"])) - error_message = "Resource names must follow the {app}-{env}-{type} pattern" - } -} -``` - -### AWS Compliance Checklist - -Add these items to your review checklist for AWS deployments: - -- [ ] All taggable resources include the mandatory `Application` tag -- [ ] Provider configuration includes `default_tags` with `Application` tag -- [ ] Application name variable is defined with validation -- [ ] Resource names follow the `{application}-{environment}-{resource-type}` pattern -- [ ] DNS-required resource names are validated for compliance -- [ ] Multi-region deployments use clear provider aliases -- [ ] Cross-account access uses assume_role with proper session naming -- [ ] Both resource names and Name tags are set consistently -- [ ] Tag inheritance from provider default_tags is working correctly -- [ ] Test configurations include Application tag validation - ---- - -## Azure Verified Modules (AVM) Requirements - -> **Important**: The following requirements are **mandatory for Azure Verified Modules** but represent best practices that can enhance Terraform module development across any cloud provider. - -### Module Cross-Referencing - -**Severity:** MUST | **Requirement:** TFFR1 - -When building Resource or Pattern modules, module owners **MAY** cross-reference other modules. However: - -- Modules **MUST** be referenced using HashiCorp Terraform registry reference to a pinned version - - Example: `source = "Azure/xxx/azurerm"` with `version = "1.2.3"` -- Modules **MUST NOT** use git references (e.g., `git::https://xxx.yyy/xxx.git` or `github.com/xxx/yyy`) -- Modules **MUST NOT** contain references to non-AVM modules - -**Broader Applicability**: Always use registry references with pinned versions for any module to ensure reproducibility and version control. - ---- - -### Azure Provider Requirements - -**Severity:** MUST | **Requirement:** TFFR3 - -For Azure Verified Modules, authors **MUST** only use the following Azure providers: - -| Provider | Min Version | Max Version | -| -------- | ----------- | ----------- | -| azapi | >= 2.0 | < 3.0 | -| azurerm | >= 4.0 | < 5.0 | - -**Requirements:** - -- Authors **MAY** select either Azurerm, Azapi, or both providers -- **MUST** use `required_providers` block to enforce provider versions -- **SHOULD** use pessimistic version constraint operator (`~>`) - -**Example:** - -```hcl -terraform { - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - azapi = { - source = "Azure/azapi" - version = "~> 2.0" - } - } -} -``` - -**Broader Applicability**: Always specify provider versions with appropriate constraints for any cloud provider to ensure compatibility. - ---- - -### AVM Code Style Standards - -#### Lower snake_casing - -**Severity:** MUST | **Requirement:** TFNFR4 - -**MUST** use lower snake_casing for: - -- Locals -- Variables -- Outputs -- Resources (symbolic names) -- Modules (symbolic names) - -Example: `snake_casing_example` - -#### Resource & Data Source Ordering - -**Severity:** SHOULD | **Requirement:** TFNFR6 - -- Resources that are depended on **SHOULD** come first -- Resources with dependencies **SHOULD** be defined close to each other - -#### Count & for_each Usage - -**Severity:** MUST | **Requirement:** TFNFR7 - -- Use `count` for conditional resource creation -- **MUST** use `map(xxx)` or `set(xxx)` as resource's `for_each` collection -- The map's key or set's element **MUST** be static literals - -**Good Example:** - -```hcl -resource "azurerm_subnet" "pair" { - for_each = var.subnet_map # map(string) - name = "${each.value}-pair" - resource_group_name = azurerm_resource_group.example.name - virtual_network_name = azurerm_virtual_network.example.name - address_prefixes = ["10.0.1.0/24"] -} -``` - -**Broader Applicability**: Using typed collections with `for_each` ensures predictable behavior across all providers. - -#### Resource & Data Block Internal Ordering - -**Severity:** SHOULD | **Requirement:** TFNFR8 - -**Order within resource/data blocks:** - -1. **Meta-arguments (top)**: - - `provider` - - `count` - - `for_each` - -2. **Arguments/blocks (middle, alphabetical)**: - - Required arguments - - Optional arguments - - Required nested blocks - - Optional nested blocks - -3. **Meta-arguments (bottom)**: - - `depends_on` - - `lifecycle` (with sub-order: `create_before_destroy`, `ignore_changes`, `prevent_destroy`) - -Separate sections with blank lines. - -#### Module Block Ordering - -**Severity:** SHOULD | **Requirement:** TFNFR9 - -**Order within module blocks:** - -1. **Top meta-arguments**: - - `source` - - `version` - - `count` - - `for_each` - -2. **Arguments (alphabetical)**: - - Required arguments - - Optional arguments - -3. **Bottom meta-arguments**: - - `depends_on` - - `providers` - -#### Lifecycle ignore_changes Syntax - -**Severity:** MUST | **Requirement:** TFNFR10 - -The `ignore_changes` attribute **MUST NOT** be enclosed in double quotes. - -**Good:** - -```hcl -lifecycle { - ignore_changes = [tags] -} -``` - -**Bad:** - -```hcl -lifecycle { - ignore_changes = ["tags"] -} -``` - -#### Null Comparison for Conditional Creation - -**Severity:** SHOULD | **Requirement:** TFNFR11 - -For parameters requiring conditional resource creation, wrap with `object` type to avoid "known after apply" issues during plan stage. - -**Recommended:** - -```hcl -variable "security_group" { - type = object({ - id = string - }) - default = null -} -``` - -**Broader Applicability**: This pattern prevents plan-time issues across all providers when using conditional resources. - -#### Dynamic Blocks for Optional Nested Objects - -**Severity:** MUST | **Requirement:** TFNFR12 - -Nested blocks under conditions **MUST** use this pattern: - -```hcl -dynamic "identity" { - for_each = ? [] : [] - - content { - # block content - } -} -``` - -**Broader Applicability**: This is the standard Terraform pattern for conditional nested blocks. - -#### Default Values with coalesce/try - -**Severity:** SHOULD | **Requirement:** TFNFR13 - -**Good:** - -```hcl -coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg") -``` - -**Bad:** - -```hcl -var.new_network_security_group_name == null ? "${var.subnet_name}-nsg" : var.new_network_security_group_name -``` - -**Broader Applicability**: `coalesce()` and `try()` functions provide cleaner, more readable default value handling. - -#### Provider Declarations in Modules - -**Severity:** MUST | **Requirement:** TFNFR27 - -- `provider` **MUST NOT** be declared in modules (except for `configuration_aliases`) -- `provider` blocks in modules **MUST** only use `alias` -- Provider configurations **SHOULD** be passed in by module users - -**Broader Applicability**: This is a universal best practice for reusable Terraform modules. - ---- - -### AVM Variable Requirements - -#### Not Allowed Variables - -**Severity:** MUST | **Requirement:** TFNFR14 - -Module owners **MUST NOT** add variables like `enabled` or `module_depends_on` to control entire module operation. Boolean feature toggles for specific resources are acceptable. - -#### Variable Definition Order - -**Severity:** SHOULD | **Requirement:** TFNFR15 - -Variables **SHOULD** follow this order: - -1. All required fields (alphabetical) -2. All optional fields (alphabetical) - -#### Variable Naming Rules - -**Severity:** SHOULD | **Requirement:** TFNFR16 - -- Follow [HashiCorp's naming rules](https://www.terraform.io/docs/extend/best-practices/naming.html) -- Feature switches **SHOULD** use positive statements: `xxx_enabled` instead of `xxx_disabled` - -#### Variables with Descriptions - -**Severity:** SHOULD | **Requirement:** TFNFR17 - -- `description` **SHOULD** precisely describe the parameter's purpose and expected data type -- Target audience is module users, not developers -- For `object` types, use HEREDOC format - -Variable and output descriptions **MAY** span multiple lines using HEREDOC format with embedded markdown for examples. - -#### Variables with Types - -**Severity:** MUST | **Requirement:** TFNFR18 - -- `type` **MUST** be defined for every variable -- `type` **SHOULD** be as precise as possible -- `any` **MAY** only be used with adequate reasons -- Use `bool` instead of `string`/`number` for true/false values -- Use concrete `object` instead of `map(any)` - -**Broader Applicability**: Precise typing prevents errors and improves documentation across all Terraform code. - -#### Sensitive Data Variables - -**Severity:** SHOULD | **Requirement:** TFNFR19 - -If a variable's type is `object` and contains sensitive fields, the entire variable **SHOULD** be `sensitive = true`, or extract sensitive fields into separate variables. - -#### Non-Nullable Defaults for Collections - -**Severity:** SHOULD | **Requirement:** TFNFR20 - -Nullable **SHOULD** be set to `false` for collection values (sets, maps, lists) when using them in loops. For scalar values, null may have semantic meaning. - -#### Discourage Nullability by Default - -**Severity:** MUST | **Requirement:** TFNFR21 - -`nullable = true` **MUST** be avoided unless there's a specific semantic need for null values. - -#### Avoid sensitive = false - -**Severity:** MUST | **Requirement:** TFNFR22 - -`sensitive = false` **MUST** be avoided (this is the default). - -#### Sensitive Default Value Conditions - -**Severity:** MUST | **Requirement:** TFNFR23 - -A default value **MUST NOT** be set for sensitive inputs (e.g., default passwords). - -#### Handling Deprecated Variables - -**Severity:** MUST | **Requirement:** TFNFR24 - -- Move deprecated variables to `deprecated_variables.tf` -- Annotate with `DEPRECATED` at the beginning of description -- Declare the replacement's name -- Clean up during major version releases - -**Broader Applicability**: Clear deprecation management improves user experience for any module. - ---- - -### AVM Output Requirements - -#### Additional Terraform Outputs - -**Severity:** SHOULD | **Requirement:** TFFR2 - -Authors **SHOULD NOT** output entire resource objects as these may contain sensitive data and the schema can change with API or provider versions. - -**Best Practices:** - -- Output _computed_ attributes of resources as discrete outputs (anti-corruption layer pattern) -- **SHOULD NOT** output values that are already inputs (except `name`) -- Use `sensitive = true` for sensitive attributes -- For resources deployed with `for_each`, output computed attributes in a map structure - -**Examples:** - -```hcl -# Single resource computed attribute -output "foo" { - description = "MyResource foo attribute" - value = azurerm_resource_myresource.foo -} - -# for_each resources -output "childresource_foos" { - description = "MyResource children's foo attributes" - value = { - for key, value in azurerm_resource_mychildresource : key => value.foo - } -} - -# Sensitive output -output "bar" { - description = "MyResource bar attribute" - value = azurerm_resource_myresource.bar - sensitive = true -} -``` - -**Broader Applicability**: The anti-corruption layer pattern protects consumers from provider API changes. - -#### Sensitive Data Outputs - -**Severity:** MUST | **Requirement:** TFNFR29 - -Outputs containing confidential data **MUST** be declared with `sensitive = true`. - -#### Handling Deprecated Outputs - -**Severity:** MUST | **Requirement:** TFNFR30 - -- Move deprecated outputs to `deprecated_outputs.tf` -- Define new outputs in `outputs.tf` -- Clean up during major version releases - ---- - -### AVM Local Values Standards - -#### locals.tf Organization - -**Severity:** MAY | **Requirement:** TFNFR31 - -- `locals.tf` **SHOULD** only contain `locals` blocks -- **MAY** declare `locals` blocks next to resources for advanced scenarios - -#### Alphabetical Local Arrangement - -**Severity:** MUST | **Requirement:** TFNFR32 - -Expressions in `locals` blocks **MUST** be arranged alphabetically. - -#### Precise Local Types - -**Severity:** SHOULD | **Requirement:** TFNFR33 - -Use precise types (e.g., `number` for age, not `string`). - -**Broader Applicability**: Type precision improves code clarity and catches errors early. - ---- - -### AVM Terraform Configuration Requirements - -#### Terraform Version Requirements - -**Severity:** MUST | **Requirement:** TFNFR25 - -**`terraform.tf` requirements:** - -- **MUST** contain only one `terraform` block -- First line **MUST** define `required_version` -- **MUST** include minimum version constraint -- **MUST** include maximum major version constraint -- **SHOULD** use `~> #.#` or `>= #.#.#, < #.#.#` format - -**Example:** - -```hcl -terraform { - required_version = "~> 1.6" - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } - } -} -``` - -**Broader Applicability**: Version constraints prevent compatibility issues across all Terraform projects. - -#### Providers in required_providers - -**Severity:** MUST | **Requirement:** TFNFR26 - -- `terraform` block **MUST** contain `required_providers` block -- Each provider **MUST** specify `source` and `version` -- Providers **SHOULD** be sorted alphabetically -- Only include directly required providers -- `source` **MUST** be in format `namespace/name` -- `version` **MUST** include minimum and maximum major version constraints -- **SHOULD** use `~> #.#` or `>= #.#.#, < #.#.#` format - ---- - -### AVM Testing Requirements - -#### Test Tooling - -**Severity:** MUST | **Requirement:** TFNFR5 - -**Required testing tools for AVM:** - -- Terraform (`terraform validate/fmt/test`) -- terrafmt -- trivy -- tflint (with azurerm ruleset) -- Go (optional for custom tests) - -**Broader Applicability**: These tools provide comprehensive quality checks for any Terraform code. - -#### Test Provider Configuration - -**Severity:** SHOULD | **Requirement:** TFNFR36 - -For robust testing, `prevent_deletion_if_contains_resources` **SHOULD** be explicitly set to `false` in test provider configurations. - ---- - -### AVM Documentation Requirements - -#### Module Documentation Generation - -**Severity:** MUST | **Requirement:** TFNFR2 - -- Documentation **MUST** be automatically generated via [Terraform Docs](https://github.com/terraform-docs/terraform-docs) -- A `.terraform-docs.yml` file **MUST** be present in the module root - -**Broader Applicability**: Automated documentation ensures consistency and reduces maintenance burden. - ---- - -### Breaking Changes & Feature Management - -#### Using Feature Toggles - -**Severity:** MUST | **Requirement:** TFNFR34 - -New resources added in minor/patch versions **MUST** have a toggle variable to avoid creation by default: - -```hcl -variable "create_route_table" { - type = bool - default = false - nullable = false -} - -resource "azurerm_route_table" "this" { - count = var.create_route_table ? 1 : 0 - # ... -} -``` - -**Broader Applicability**: Feature toggles allow backward-compatible module evolution. - -#### Reviewing Potential Breaking Changes - -**Severity:** MUST | **Requirement:** TFNFR35 - -**Breaking changes requiring caution:** - -**Resource blocks:** - -1. Adding new resource without conditional creation -2. Adding arguments with non-default values -3. Adding nested blocks without `dynamic` -4. Renaming resources without `moved` blocks -5. Changing `count` to `for_each` or vice versa - -**Variable/Output blocks:** - -1. Deleting/renaming variables -2. Changing variable `type` -3. Changing variable `default` values -4. Changing `nullable` to false -5. Changing `sensitive` from false to true -6. Adding variables without `default` -7. Deleting outputs -8. Changing output `value` -9. Changing output `sensitive` value - -**Broader Applicability**: Understanding breaking changes is crucial for maintaining any public Terraform module. - ---- - -### AVM Contribution Standards - -#### GitHub Repository Branch Protection - -**Severity:** MUST | **Requirement:** TFNFR3 - -Module owners **MUST** set branch protection policies on the default branch (typically `main`): - -1. Require Pull Request before merging -2. Require approval of most recent reviewable push -3. Dismiss stale PR approvals when new commits are pushed -4. Require linear history -5. Prevent force pushes -6. Not allow deletions -7. Require CODEOWNERS review -8. No bypassing settings allowed -9. Enforce for administrators - -**Broader Applicability**: These protections ensure code quality for any collaborative project. - ---- - -## AVM Compliance Checklist - -For Azure Verified Modules, add these items to your review checklist: - -- [ ] Module cross-references use registry sources with pinned versions -- [ ] Azure providers (azurerm/azapi) versions meet AVM requirements -- [ ] All names use lower snake_casing -- [ ] Resources ordered with dependencies first -- [ ] `for_each` uses `map()` or `set()` with static keys -- [ ] Resource/data/module blocks follow proper internal ordering -- [ ] `ignore_changes` not quoted -- [ ] Dynamic blocks used for conditional nested objects -- [ ] No `enabled` or `module_depends_on` variables -- [ ] Variables ordered: required (alphabetical) then optional (alphabetical) -- [ ] All variables have precise types (avoid `any`) -- [ ] Collections have `nullable = false` -- [ ] No `sensitive = false` declarations -- [ ] No default values for sensitive inputs -- [ ] Deprecated variables moved to `deprecated_variables.tf` -- [ ] Outputs use anti-corruption layer pattern (discrete attributes) -- [ ] Sensitive outputs marked `sensitive = true` -- [ ] Deprecated outputs moved to `deprecated_outputs.tf` -- [ ] Locals arranged alphabetically -- [ ] `terraform.tf` has version constraints (`~>` format) -- [ ] `required_providers` block present with all providers -- [ ] No `provider` declarations in module (except aliases) -- [ ] `.terraform-docs.yml` present -- [ ] New resources have feature toggles -- [ ] CODEOWNERS file present - ---- - -## Summary - -This style guide combines HashiCorp's official Terraform conventions with cloud-specific requirements to provide comprehensive guidance for: - -- **General Terraform Development**: Core formatting, naming, and organizational standards -- **Module Development**: Best practices for creating reusable, maintainable modules -- **AWS-Specific Requirements**: Mandatory tagging and naming conventions for AWS resources -- **Azure-Specific Modules**: Mandatory requirements for AVM certification -- **Cross-Cloud Applicability**: Patterns and practices beneficial for all cloud providers - -By following these guidelines, you'll create Terraform code that is: - -- **Consistent**: Predictable structure and formatting -- **Maintainable**: Easy to update and extend -- **Scalable**: Supports growth in complexity -- **Reliable**: Tested and validated -- **Collaborative**: Easy for teams to work with - ---- - -_Last Updated: November 15, 2024_ -_Based on: HashiCorp Terraform Style Conventions, AWS Best Practices & Azure Verified Modules Requirements_ diff --git a/.claude/skills/terraform-test/CLAUDE.md b/.claude/skills/terraform-test/CLAUDE.md deleted file mode 100644 index 5f65e8e..0000000 --- a/.claude/skills/terraform-test/CLAUDE.md +++ /dev/null @@ -1,468 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is a **Claude Skill repository** for HashiCorp Terraform Test - a specialized knowledge base that provides comprehensive documentation and guidance for writing and running automated tests for Terraform configurations. This is not a traditional software project with executable code, but rather a documentation repository structured as a skill module for Claude AI assistants. - -**Purpose**: Enable Claude to help users create test files (`.tftest.hcl`), write test scenarios with run blocks, validate infrastructure behavior with assertions, mock providers and data sources, test module outputs and resource configurations, and troubleshoot Terraform test syntax and execution. - -## Repository Structure - -``` -claude-skill-terraform-test/ -β”œβ”€β”€ README.md # Brief project description -β”œβ”€β”€ SKILL.md # Main comprehensive guide -└── CLAUDE.md # This file - guidance for Claude Code -``` - -## Documentation Architecture - -### Core Documentation - -**[SKILL.md](SKILL.md)** - Comprehensive guide covering: - -- Core concepts (test files, run blocks, assert blocks, mock providers) -- Test file structure and components -- Test configuration syntax (run, assert, variables, expect_failures, etc.) -- Mock providers for unit testing -- Test execution commands and options -- Common test patterns -- Integration testing -- Cleanup and destruction -- Best practices -- Advanced features (parallel execution, state management) -- Troubleshooting -- Complete example test suite -- CI/CD integration - -## Key Terraform Test Concepts - -### Test Modes: Apply vs Plan - -**Critical Understanding**: Terraform tests use two modes: - -1. **Integration Testing (Default)**: `command = apply` - - Creates **real infrastructure** in your cloud provider - - Tests actual resource creation and behavior - - Slower and incurs cloud costs - - Resources are automatically destroyed after test completion - - Best for validating end-to-end infrastructure behavior - -2. **Unit Testing**: `command = plan` - - Does **NOT** create real infrastructure - - Validates Terraform logic, conditionals, and outputs - - Fast and free (no cloud resources created) - - Best for testing module logic, variable handling, resource counts - -**Important**: When helping users write tests, always clarify which mode is appropriate for their use case. Default is `apply`, not `plan`. - -### Test File Structure - -Test files (`.tftest.hcl` or `.tftest.json`) contain: - -- **Zero to one** `test` block (configuration settings) -- **One to many** `run` blocks (test scenarios) -- **Zero to one** `variables` block (input values) -- **Zero to many** `provider` blocks (provider configuration) -- **Zero to many** `mock_provider` blocks (mock provider data, since v1.7.0) - -**Important**: The order of `variables` and `provider` blocks doesn't matter - they're all processed at the beginning. - -### Run Block Execution - -Run blocks execute **sequentially by default**: - -- Each run block can reference outputs from previous run blocks via `run..` -- Use `parallel = true` for independent tests (requires different state files) -- Sequential execution is critical for tests with dependencies - -### Variable Precedence - -**Critical**: Variables defined in test files have the **highest precedence**, overriding: - -- Environment variables -- Variables files (`.tfvars`) -- Command-line input (`-var`) - -This ensures test scenarios are reproducible and not affected by external configuration. - -### Module Support Limitation - -**Important**: Terraform test files only support: - -- **Local modules**: `./modules/vpc` or `../shared-modules/networking` -- **Registry modules**: `terraform-aws-modules/vpc/aws` or `app.terraform.io/my-org/vpc/aws` - -**NOT supported**: - -- Git sources -- HTTP/HTTPS archives -- Other remote sources - -If users have Git-based modules, guide them to either clone locally or publish to a registry. - -### Cleanup and Destruction - -**Critical behavior**: Resources created with `command = apply` are destroyed in **reverse run block order** after test completion. - -**Why this matters**: For resources with dependencies (e.g., S3 bucket with objects), destruction order is critical: - -1. Objects must be deleted first (later run block) -2. Bucket can then be deleted (earlier run block) - -The reverse order ensures dependencies are respected during cleanup. - -**Debugging**: Use `terraform test -no-cleanup` to leave resources in place for inspection. - -## Common Scenarios and Patterns - -### When Users Ask "How do I test my module?" - -Guide them to: - -1. Create a `tests/` directory in their module -2. Start with a simple `defaults.tftest.hcl` testing default configuration -3. Use `command = plan` for fast unit tests -4. Add `command = apply` integration tests for critical paths -5. Organize tests by scenario (defaults, edge cases, integration) - -### When Users Ask "My tests are too slow" - -Suggest: - -1. Use `command = plan` instead of `apply` where possible -2. Use mock providers (requires Terraform 1.7.0+) for isolated unit tests -3. Use `parallel = true` for independent tests with different state files -4. Separate slow integration tests from fast unit tests -5. Run integration tests only in CI, not locally - -### When Users Ask About Testing Multiple Scenarios - -Guide them to: - -1. Create multiple run blocks in the same test file -2. Override variables in each run block for different scenarios -3. Use descriptive names: `run "test_small_deployment"`, `run "test_large_deployment"` -4. Keep related scenarios in the same file for context - -### When Users Ask About Mocking - -Guide them to: - -1. Requires Terraform 1.7.0 or later -2. Use `mock_provider` blocks to simulate provider behavior -3. Define `mock_resource` and `mock_data` with default values -4. Enables fast unit testing without cloud resources -5. Best for testing logic in isolation - -### When Users Ask About Testing Failures - -Guide them to use `expect_failures`: - -1. Test that validation rules work correctly -2. Test that invalid inputs are rejected -3. Specify checkable objects: variables, outputs, check blocks, resources -4. Test **passes** when the specified objects fail as expected - -### When Users Ask About CI/CD Integration - -Recommend: - -1. Run `terraform test` in CI pipeline -2. Include `terraform fmt -check`, `terraform validate`, and `terraform test` -3. Set up cloud credentials as secrets/environment variables -4. Use `-verbose` flag for detailed CI output -5. **Separate unit tests from integration tests** for optimal CI performance: - - Run unit tests (plan mode) on every PR: `terraform test tests/*_unit_test.tftest.hcl` - - Run integration tests (apply mode) only on merge to main or scheduled: `terraform test tests/*_integration_test.tftest.hcl` - - Unit tests are fast (seconds) and free; integration tests are slow (minutes) and cost money - - Example: Run unit tests in ~10 seconds for quick feedback, integration tests in ~5 minutes nightly - -## Common Errors and Solutions - -### "Test failed: assertion failed" - -**Issue**: Assertion condition evaluated to false -**Solution**: Review the error message, check actual vs expected values, verify variable inputs. Use `-verbose` for detailed output. - -### "Provider authentication failed" - -**Issue**: Missing credentials for integration tests -**Solution**: Either configure provider credentials (for integration tests) or use mock providers (for unit tests, requires v1.7.0+). - -### "Cannot reference run block output" - -**Issue**: Trying to reference outputs from a parallel run or invalid run block name -**Solution**: Ensure run blocks are sequential (not parallel) and use correct syntax: `run..` - -### "Module source not supported" - -**Issue**: Test references Git or HTTP module source -**Solution**: Terraform test only supports local and registry modules. Clone Git modules locally or use registry sources. - -### "Tests interfere with each other" - -**Issue**: State conflicts between tests -**Solution**: Use different modules (automatic separate state), `state_key` attribute, or mock providers for isolation. - -## Test Organization Best Practices - -### File Naming - -**Organize by test type using clear naming conventions:** - -``` -tests/ -β”œβ”€β”€ defaults_unit_test.tftest.hcl # Unit test (plan mode - fast, no resources) -β”œβ”€β”€ edge_cases_unit_test.tftest.hcl # Unit test (plan mode) -β”œβ”€β”€ validation_unit_test.tftest.hcl # Unit test (plan mode) -β”œβ”€β”€ full_stack_integration_test.tftest.hcl # Integration test (apply mode - creates real resources) -└── multi_region_integration_test.tftest.hcl # Integration test (apply mode) -``` - -**Benefits:** - -- Clearly distinguishes unit tests (plan mode) from integration tests (apply mode) -- Makes it easy to run tests selectively: `terraform test tests/*_unit_test.tftest.hcl` -- Self-documenting file names indicate test type and resource implications - -### Run Block Naming - -Use descriptive names that explain the test scenario: - -- Good: `run "test_with_3_availability_zones"` -- Good: `run "test_invalid_cidr_rejected"` -- Bad: `run "test1"` -- Bad: `run "test"` - -### Assertion Error Messages - -Write clear error messages that help diagnose failures: - -- Good: `"Should create exactly 3 subnets across availability zones"` -- Good: `"VPC CIDR must be within 10.0.0.0/8 range for private networks"` -- Bad: `"Test failed"` -- Bad: `"Wrong value"` - -## Test Writing Strategy - -### Start with Unit Tests (Plan Mode) - -1. Test default configuration works -2. Test variable overrides -3. Test conditional logic -4. Test resource counts and attributes -5. Test outputs are defined correctly - -All with `command = plan` - fast, free, no real resources. - -### Add Integration Tests (Apply Mode) Selectively - -Only for: - -- Critical infrastructure paths -- Actual resource behavior validation -- Provider-specific features -- Real cloud service interactions - -Use `command = apply` sparingly - slower, costs money, creates real resources. - -### Use Mocks for Isolated Unit Testing - -When available (Terraform 1.7.0+): - -- Mock external data sources -- Mock dependencies between modules -- Test logic without cloud provider calls -- Fastest option for pure logic testing - -## How to Work with This Repository - -### No Build System - -This is a documentation-only repository: - -- No compilation or build commands -- No package manager or dependencies -- No automated tests -- No Docker or containerization - -### Making Changes - -When updating documentation: - -1. **Keep examples accurate** - All code examples must use valid Terraform test syntax -2. **Update version-specific features** - Note when features require specific Terraform versions -3. **Test command accuracy** - Ensure all CLI commands are correct -4. **Maintain consistency** - Terminology should be consistent throughout - -### Version Control - -Use Git for all changes: - -```bash -git status # Check current changes -git add # Stage changes -git commit -m "message" # Commit changes -git push # Push to remote -``` - -## Documentation Style Guide - -### Code Blocks - -All Terraform test examples use HCL syntax: - -```hcl -# Correct test structure -run "test_example" { - command = plan - - variables { - key = "value" - } - - assert { - condition = resource.example.attribute == "expected" - error_message = "Clear, helpful error message" - } -} -``` - -### Terminology Consistency - -Use these exact terms consistently: - -- **Test file** (not "test configuration" or "test script") -- **Run block** (not "test block" or "test case") -- **Assert block** (not "assertion" or "check") -- **Integration test** for `command = apply` (creates real resources) -- **Unit test** for `command = plan` (no real resources) -- **Mock provider** (not "fake provider" or "stub provider") - -### Command Format - -Always use the full command format: - -```bash -# Correct -terraform test -terraform test tests/defaults.tftest.hcl -terraform test -verbose -terraform test -filter=test_vpc_configuration - -# Not abbreviated -tf test # Don't use -``` - -## CLI Commands (Terraform Test) - -```bash -# Run all tests -terraform test - -# Run specific test file -terraform test tests/defaults.tftest.hcl - -# Run with verbose output -terraform test -verbose - -# Run tests in specific directory -terraform test -test-directory=integration-tests - -# Filter tests by name -terraform test -filter=test_vpc_configuration - -# Run tests without cleanup (for debugging) -terraform test -no-cleanup -``` - -Note: These are subcommands of the regular `terraform` CLI, not a separate tool. - -## Key Differences from Other Testing Frameworks - -Users familiar with other testing frameworks may have incorrect assumptions: - -### vs. Terratest (Go-based) - -- **Terraform Test**: Native, no additional languages, HCL syntax -- **Terratest**: Requires Go, programmatic testing, more flexibility - -### vs. Kitchen-Terraform (Ruby-based) - -- **Terraform Test**: Built-in, no external dependencies -- **Kitchen-Terraform**: Requires Ruby and Test Kitchen setup - -### vs. Terraform Compliance (Policy) - -- **Terraform Test**: Tests actual infrastructure behavior and logic -- **Terraform Compliance**: Policy-as-code, compliance checks only - -## Terraform Version Requirements - -Key version milestones: - -- **Terraform 1.6.0**: Terraform test introduced -- **Terraform 1.7.0**: Mock providers added -- **Terraform 1.9.0**: `state_key` and `parallel` attributes added - -Always check user's Terraform version if they request features from newer versions. - -## Integration with HCP Terraform / Terraform Cloud - -Terraform tests work with: - -- Local Terraform CLI (most common) -- HCP Terraform / Terraform Cloud (run tests in workspace context) -- CI/CD pipelines (GitHub Actions, GitLab CI, etc.) - -Tests access the same state and variables as regular Terraform operations. - -## Common User Questions - -### "Should I use Terraform test or Terratest?" - -**Terraform Test**: Start here for most projects - -- Built-in, no additional setup -- Perfect for module testing -- Native HCL syntax - -**Terratest**: Consider when: - -- Need complex test logic -- Testing across multiple tools (Packer, Docker, etc.) -- Already invested in Go ecosystem - -### "How do I test without creating real resources?" - -Two options: - -1. Use `command = plan` (validates logic without creating resources) -2. Use mock providers with `command = plan` (requires Terraform 1.7.0+) - -### "Can I test Terraform Cloud/Enterprise features?" - -Yes, but with limitations: - -- Test runs in context of your workspace -- Remote state access works normally -- Sentinel policies and run triggers are not tested -- Cost estimation is not included in test runs - -### "How do I test modules in a monorepo?" - -Use the `module` block in run blocks: - -```hcl -run "test_vpc_module" { - module { - source = "./modules/vpc" - } - # ... -} -``` - -Each module can have its own `tests/` directory with test files. diff --git a/.claude/skills/terraform-test/README.md b/.claude/skills/terraform-test/README.md deleted file mode 100644 index 19094f0..0000000 --- a/.claude/skills/terraform-test/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# claude-skill-terraform-test - -Comprehensive guide for writing and running Terraform tests. Use when creating test files (.tftest.hcl), writing test scenarios with run blocks, validating infrastructure behavior with assertions, mocking providers and data sources, testing module outputs and resource configurations, or troubleshooting Terraform test syntax and execution. diff --git a/.claude/skills/terraform-test/SKILL.md b/.claude/skills/terraform-test/SKILL.md deleted file mode 100644 index 656ec98..0000000 --- a/.claude/skills/terraform-test/SKILL.md +++ /dev/null @@ -1,1677 +0,0 @@ ---- -name: terraform-test -description: Comprehensive guide for writing and running Terraform tests. Use when creating test files (.tftest.hcl), writing test scenarios with run blocks, validating infrastructure behavior with assertions, mocking providers and data sources, testing module outputs and resource configurations, or troubleshooting Terraform test syntax and execution. Terraform test is typically used when validating Terraform modules. ---- - -# Terraform Test - -Terraform's built-in testing framework enables module authors to validate that configuration updates don't introduce breaking changes. Tests execute against temporary resources, protecting existing infrastructure and state files. - -## Core Concepts - -**Test File**: A `.tftest.hcl` or `.tftest.json` file containing test configuration and run blocks that validate your Terraform configuration. - -**Test Block**: Optional configuration block that defines test-wide settings (available since Terraform 1.6.0). - -**Run Block**: Defines a single test scenario with optional variables, provider configurations, and assertions. Each test file requires at least one run block. - -**Assert Block**: Contains conditions that must evaluate to true for the test to pass. Failed assertions cause the test to fail. - -**Mock Provider**: Simulates provider behavior without creating real infrastructure (available since Terraform 1.7.0). - -**Test Modes**: Tests run in apply mode (default, creates real infrastructure) or plan mode (validates logic without creating resources). - -## File Structure - -Terraform test files use the `.tftest.hcl` or `.tftest.json` extension and are typically organized in a `tests/` directory. Use clear naming conventions to distinguish between unit tests (plan mode) and integration tests (apply mode): - -``` -my-module/ -β”œβ”€β”€ main.tf -β”œβ”€β”€ variables.tf -β”œβ”€β”€ outputs.tf -└── tests/ - β”œβ”€β”€ validation_unit_test.tftest.hcl # Unit test (plan mode) - β”œβ”€β”€ edge_cases_unit_test.tftest.hcl # Unit test (plan mode) - └── full_stack_integration_test.tftest.hcl # Integration test (apply mode - creates real resources) -``` - -### Test File Components - -A test file contains: - -- **Zero to one** `test` block (configuration settings) -- **One to many** `run` blocks (test executions) -- **Zero to one** `variables` block (input values) -- **Zero to many** `provider` blocks (provider configuration) -- **Zero to many** `mock_provider` blocks (mock provider data, since v1.7.0) - -**Important**: The order of `variables` and `provider` blocks doesn't matter. Terraform processes all values within these blocks at the beginning of the test operation. - -## Test Configuration (.tftest.hcl) - -### Test Block - -The optional `test` block configures test-wide settings: - -```hcl -test { - parallel = true # Enable parallel execution for all run blocks (default: false) -} -``` - -**Test Block Attributes:** - -- `parallel` - Boolean, when set to `true`, enables parallel execution for all run blocks by default (default: `false`). Individual run blocks can override this setting. - -### Run Block - -Each `run` block executes a command against your configuration. Run blocks execute **sequentially by default**. - -**Basic Integration Test (Apply Mode - Default):** - -```hcl -run "test_instance_creation" { - command = apply - - assert { - condition = aws_instance.example.id != "" - error_message = "Instance should be created with a valid ID" - } - - assert { - condition = output.instance_public_ip != "" - error_message = "Instance should have a public IP" - } -} -``` - -**Unit Test (Plan Mode):** - -```hcl -run "test_default_configuration" { - command = plan - - assert { - condition = aws_instance.example.instance_type == "t2.micro" - error_message = "Instance type should be t2.micro by default" - } - - assert { - condition = aws_instance.example.tags["Environment"] == "test" - error_message = "Environment tag should be 'test'" - } -} -``` - -**Run Block Attributes:** - -- `command` - Either `apply` (default) or `plan` -- `plan_options` - Configure plan behavior (see below) -- `variables` - Override test-level variable values -- `module` - Reference alternate modules for testing -- `providers` - Customize provider availability -- `assert` - Validation conditions (multiple allowed) -- `expect_failures` - Specify expected validation failures -- `state_key` - Manage state file isolation (since v1.9.0) -- `parallel` - Enable parallel execution when set to `true` (since v1.9.0) - -### Plan Options - -The `plan_options` block configures plan command behavior: - -```hcl -run "test_refresh_only" { - command = plan - - plan_options { - mode = refresh-only # "normal" (default) or "refresh-only" - refresh = true # boolean, defaults to true - replace = [ - aws_instance.example - ] - target = [ - aws_instance.example - ] - } - - assert { - condition = aws_instance.example.instance_type == "t2.micro" - error_message = "Instance type should be t2.micro" - } -} -``` - -**Plan Options Attributes:** - -- `mode` - `normal` (default) or `refresh-only` -- `refresh` - Boolean, defaults to `true` -- `replace` - List of resource addresses to replace -- `target` - List of resource addresses to target - -### Variables Block - -Define variables at the test file level (applied to all run blocks) or within individual run blocks. - -**Important**: Variables defined in test files take the **highest precedence**, overriding environment variables, variables files, or command-line input. - -**File-Level Variables:** - -```hcl -# Applied to all run blocks -variables { - aws_region = "us-west-2" - instance_type = "t2.micro" - environment = "test" -} - -run "test_with_file_variables" { - command = plan - - assert { - condition = var.aws_region == "us-west-2" - error_message = "Region should be us-west-2" - } -} -``` - -**Run Block Variables (Override File-Level):** - -```hcl -variables { - instance_type = "t2.small" - environment = "test" -} - -run "test_with_override_variables" { - command = plan - - # Override file-level variables - variables { - instance_type = "t3.large" - } - - assert { - condition = var.instance_type == "t3.large" - error_message = "Instance type should be overridden to t3.large" - } -} -``` - -**Variables Referencing Prior Run Blocks:** - -```hcl -run "setup_vpc" { - command = apply -} - -run "test_with_vpc_output" { - command = plan - - variables { - vpc_id = run.setup_vpc.vpc_id - } - - assert { - condition = var.vpc_id == run.setup_vpc.vpc_id - error_message = "VPC ID should match setup_vpc output" - } -} -``` - -### Assert Block - -Assert blocks validate conditions within run blocks. All assertions must pass for the test to succeed. - -**Syntax:** - -```hcl -assert { - condition = - error_message = "failure description" -} -``` - -**Resource Attribute Assertions:** - -```hcl -run "test_resource_configuration" { - command = plan - - assert { - condition = aws_s3_bucket.example.bucket == "my-test-bucket" - error_message = "Bucket name should match expected value" - } - - assert { - condition = aws_s3_bucket.example.versioning[0].enabled == true - error_message = "Bucket versioning should be enabled" - } - - assert { - condition = length(aws_s3_bucket.example.tags) > 0 - error_message = "Bucket should have at least one tag" - } -} -``` - -**Output Validation:** - -```hcl -run "test_outputs" { - command = plan - - assert { - condition = output.vpc_id != "" - error_message = "VPC ID output should not be empty" - } - - assert { - condition = length(output.subnet_ids) == 3 - error_message = "Should create exactly 3 subnets" - } -} -``` - -**Referencing Prior Run Block Outputs:** - -```hcl -run "create_vpc" { - command = apply -} - -run "validate_vpc_output" { - command = plan - - assert { - condition = run.create_vpc.vpc_id != "" - error_message = "VPC from previous run should have an ID" - } -} -``` - -**Complex Conditions:** - -```hcl -run "test_complex_validation" { - command = plan - - assert { - condition = alltrue([ - for subnet in aws_subnet.private : - can(regex("^10\\.0\\.", subnet.cidr_block)) - ]) - error_message = "All private subnets should use 10.0.0.0/8 CIDR range" - } - - assert { - condition = alltrue([ - for instance in aws_instance.workers : - contains(["t2.micro", "t2.small", "t3.micro"], instance.instance_type) - ]) - error_message = "Worker instances should use approved instance types" - } -} -``` - -### Expect Failures Block - -Test that certain conditions intentionally fail. The test **passes** if the specified checkable objects report an issue, and **fails** if they do not. - -**Checkable objects include**: Input variables, output values, check blocks, and managed resources or data sources. - -```hcl -run "test_invalid_input_rejected" { - command = plan - - variables { - instance_count = -1 - } - - expect_failures = [ - var.instance_count - ] -} -``` - -**Testing Custom Conditions:** - -```hcl -run "test_custom_condition_failure" { - command = plan - - variables { - instance_type = "t2.nano" # Invalid type - } - - expect_failures = [ - var.instance_type - ] -} -``` - -### Module Block - -Test a specific module rather than the root configuration. - -**Supported Module Sources:** - -- βœ… **Local modules**: `./modules/vpc`, `../shared/networking` -- βœ… **Public Terraform Registry**: `terraform-aws-modules/vpc/aws` -- βœ… **Private Registry (HCP Terraform)**: `app.terraform.io/org/module/provider` - -**Unsupported Module Sources:** - -- ❌ Git repositories: `git::https://github.com/...` -- ❌ HTTP URLs: `https://example.com/module.zip` -- ❌ Other remote sources (S3, GCS, etc.) - -**Module Block Attributes:** - -- `source` - Module source (local path or registry address) -- `version` - Version constraint (only for registry modules) - -**Testing Local Modules:** - -```hcl -run "test_vpc_module" { - command = plan - - module { - source = "./modules/vpc" - } - - variables { - cidr_block = "10.0.0.0/16" - name = "test-vpc" - } - - assert { - condition = aws_vpc.main.cidr_block == "10.0.0.0/16" - error_message = "VPC CIDR should match input variable" - } -} -``` - -**Testing Public Registry Modules:** - -```hcl -run "test_registry_module" { - command = plan - - module { - source = "terraform-aws-modules/vpc/aws" - version = "5.0.0" - } - - variables { - name = "test-vpc" - cidr = "10.0.0.0/16" - } - - assert { - condition = output.vpc_id != "" - error_message = "VPC should be created" - } -} -``` - -### Provider Configuration - -Override or configure providers for tests. Since Terraform 1.7.0, provider blocks can reference test variables and prior run block outputs. - -**Basic Provider Configuration:** - -```hcl -provider "aws" { - region = "us-west-2" -} - -run "test_with_provider" { - command = plan - - assert { - condition = aws_instance.example.availability_zone == "us-west-2a" - error_message = "Instance should be in us-west-2 region" - } -} -``` - -**Multiple Provider Configurations:** - -```hcl -provider "aws" { - alias = "primary" - region = "us-west-2" -} - -provider "aws" { - alias = "secondary" - region = "us-east-1" -} - -run "test_with_specific_provider" { - command = plan - - providers = { - aws = provider.aws.secondary - } - - assert { - condition = aws_instance.example.availability_zone == "us-east-1a" - error_message = "Instance should be in us-east-1 region" - } -} -``` - -**Provider with Test Variables:** - -```hcl -variables { - aws_region = "eu-west-1" -} - -provider "aws" { - region = var.aws_region -} -``` - -### State Key Management - -The `state_key` attribute controls which state file a run block uses. By default: - -- The main configuration shares a state file across all run blocks -- Each alternate module (referenced via `module` block) gets its own state file - -**Force Run Blocks to Share State:** - -```hcl -run "create_vpc" { - command = apply - - module { - source = "./modules/vpc" - } - - state_key = "shared_state" -} - -run "create_subnet" { - command = apply - - module { - source = "./modules/subnet" - } - - state_key = "shared_state" # Shares state with create_vpc -} -``` - -### Parallel Execution - -Run blocks execute **sequentially by default**. Enable parallel execution with `parallel = true`. - -**Requirements for Parallel Execution:** - -- No inter-run output references (run blocks cannot reference outputs from parallel runs) -- Different state files (via different modules or state keys) -- Explicit `parallel = true` attribute - -```hcl -run "test_module_a" { - command = plan - parallel = true - - module { - source = "./modules/module-a" - } - - assert { - condition = output.result != "" - error_message = "Module A should produce output" - } -} - -run "test_module_b" { - command = plan - parallel = true - - module { - source = "./modules/module-b" - } - - assert { - condition = output.result != "" - error_message = "Module B should produce output" - } -} - -# This creates a synchronization point -run "test_integration" { - command = plan - - # Waits for parallel runs above to complete - assert { - condition = output.combined != "" - error_message = "Integration should work" - } -} -``` - -## Mock Providers - -Mock providers simulate provider behavior without creating real infrastructure (available since Terraform 1.7.0). - -**Basic Mock Provider:** - -```hcl -mock_provider "aws" { - mock_resource "aws_instance" { - defaults = { - id = "i-1234567890abcdef0" - instance_type = "t2.micro" - ami = "ami-12345678" - } - } - - mock_data "aws_ami" { - defaults = { - id = "ami-12345678" - } - } -} - -run "test_with_mocks" { - command = plan - - assert { - condition = aws_instance.example.id == "i-1234567890abcdef0" - error_message = "Mock instance ID should match" - } -} -``` - -**Advanced Mock with Custom Values:** - -```hcl -mock_provider "aws" { - alias = "mocked" - - mock_resource "aws_s3_bucket" { - defaults = { - id = "test-bucket-12345" - bucket = "test-bucket" - arn = "arn:aws:s3:::test-bucket" - } - } - - mock_data "aws_availability_zones" { - defaults = { - names = ["us-west-2a", "us-west-2b", "us-west-2c"] - } - } -} - -run "test_with_mock_provider" { - command = plan - - providers = { - aws = provider.aws.mocked - } - - assert { - condition = length(data.aws_availability_zones.available.names) == 3 - error_message = "Should return 3 availability zones" - } -} -``` - -## Test Execution - -### Running Tests - -**Run all tests:** - -```bash -terraform test -``` - -**Run specific test file:** - -```bash -terraform test tests/defaults.tftest.hcl -``` - -**Run with verbose output:** - -```bash -terraform test -verbose -``` - -**Run tests in a specific directory:** - -```bash -terraform test -test-directory=integration-tests -``` - -**Filter tests by name:** - -```bash -terraform test -filter=test_vpc_configuration -``` - -**Run tests without cleanup (for debugging):** - -```bash -terraform test -no-cleanup -``` - -### Test Output - -**Successful test output:** - -``` -tests/defaults.tftest.hcl... in progress - run "test_default_configuration"... pass - run "test_outputs"... pass -tests/defaults.tftest.hcl... tearing down -tests/defaults.tftest.hcl... pass - -Success! 2 passed, 0 failed. -``` - -**Failed test output:** - -``` -tests/defaults.tftest.hcl... in progress - run "test_default_configuration"... fail - Error: Test assertion failed - Instance type should be t2.micro by default - -Success! 0 passed, 1 failed. -``` - -## Common Test Patterns (Unit Tests - Plan Mode) - -The following examples demonstrate common unit test patterns using `command = plan`. These tests validate Terraform logic without creating real infrastructure, making them fast and cost-free. - -### Testing Module Outputs - -```hcl -run "test_module_outputs" { - command = plan - - assert { - condition = output.vpc_id != null - error_message = "VPC ID output must be defined" - } - - assert { - condition = can(regex("^vpc-", output.vpc_id)) - error_message = "VPC ID should start with 'vpc-'" - } - - assert { - condition = length(output.subnet_ids) >= 2 - error_message = "Should output at least 2 subnet IDs" - } -} -``` - -### Testing Resource Counts - -```hcl -run "test_resource_count" { - command = plan - - variables { - instance_count = 3 - } - - assert { - condition = length(aws_instance.workers) == 3 - error_message = "Should create exactly 3 worker instances" - } -} -``` - -### Testing Conditional Resources - -```hcl -run "test_conditional_resource_created" { - command = plan - - variables { - create_nat_gateway = true - } - - assert { - condition = length(aws_nat_gateway.main) == 1 - error_message = "NAT gateway should be created when enabled" - } -} - -run "test_conditional_resource_not_created" { - command = plan - - variables { - create_nat_gateway = false - } - - assert { - condition = length(aws_nat_gateway.main) == 0 - error_message = "NAT gateway should not be created when disabled" - } -} -``` - -### Testing Tags - -```hcl -run "test_resource_tags" { - command = plan - - variables { - common_tags = { - Environment = "production" - ManagedBy = "Terraform" - } - } - - assert { - condition = aws_instance.example.tags["Environment"] == "production" - error_message = "Environment tag should be set correctly" - } - - assert { - condition = aws_instance.example.tags["ManagedBy"] == "Terraform" - error_message = "ManagedBy tag should be set correctly" - } -} -``` - -### Sequential Tests with Dependencies - -```hcl -run "setup_vpc" { - # command defaults to apply - - variables { - vpc_cidr = "10.0.0.0/16" - } - - assert { - condition = output.vpc_id != "" - error_message = "VPC should be created" - } -} - -run "test_subnet_in_vpc" { - command = plan - - variables { - vpc_id = run.setup_vpc.vpc_id - } - - assert { - condition = aws_subnet.example.vpc_id == run.setup_vpc.vpc_id - error_message = "Subnet should be created in the VPC from setup_vpc" - } -} -``` - -### Testing Data Sources - -```hcl -run "test_data_source_lookup" { - command = plan - - assert { - condition = data.aws_ami.ubuntu.id != "" - error_message = "Should find a valid Ubuntu AMI" - } - - assert { - condition = can(regex("^ami-", data.aws_ami.ubuntu.id)) - error_message = "AMI ID should be in correct format" - } -} -``` - -### Testing Validation Rules - -```hcl -# In variables.tf -variable "environment" { - type = string - - validation { - condition = contains(["dev", "staging", "prod"], var.environment) - error_message = "Environment must be dev, staging, or prod" - } -} - -# In test file -run "test_valid_environment" { - command = plan - - variables { - environment = "staging" - } - - assert { - condition = var.environment == "staging" - error_message = "Valid environment should be accepted" - } -} - -run "test_invalid_environment" { - command = plan - - variables { - environment = "invalid" - } - - expect_failures = [ - var.environment - ] -} -``` - -## Integration Testing - -For tests that create real infrastructure (default behavior with `command = apply`): - -```hcl -run "integration_test_full_stack" { - # command defaults to apply - - variables { - environment = "integration-test" - vpc_cidr = "10.100.0.0/16" - } - - assert { - condition = aws_vpc.main.id != "" - error_message = "VPC should be created" - } - - assert { - condition = length(aws_subnet.private) == 2 - error_message = "Should create 2 private subnets" - } - - assert { - condition = aws_instance.bastion.public_ip != "" - error_message = "Bastion instance should have a public IP" - } -} - -# Cleanup happens automatically after test completes -``` - -## Cleanup and Destruction - -**Important**: Resources are destroyed in **reverse run block order** after test completion. This is critical for configurations with dependencies. - -**Example**: For S3 buckets containing objects, the bucket must be emptied before deletion: - -```hcl -run "create_bucket_with_objects" { - command = apply - - assert { - condition = aws_s3_bucket.example.id != "" - error_message = "Bucket should be created" - } -} - -run "add_objects_to_bucket" { - command = apply - - assert { - condition = length(aws_s3_object.files) > 0 - error_message = "Objects should be added" - } -} - -# Cleanup occurs in reverse order: -# 1. Destroys objects (run "add_objects_to_bucket") -# 2. Destroys bucket (run "create_bucket_with_objects") -``` - -**Disable Cleanup for Debugging:** - -```bash -terraform test -no-cleanup -``` - -## Best Practices - -1. **Test Organization**: Organize tests by type using clear naming conventions: - - Unit tests (plan mode): `*_unit_test.tftest.hcl` - fast, no resources created - - Integration tests (apply mode): `*_integration_test.tftest.hcl` - creates real resources - - Example: `defaults_unit_test.tftest.hcl`, `validation_unit_test.tftest.hcl`, `full_stack_integration_test.tftest.hcl` - - This makes it easy to run unit tests separately from integration tests in CI/CD - -2. **Apply vs Plan**: - - Default is `command = apply` (integration testing with real resources) - - Use `command = plan` for unit tests (fast, no real resources) - - Use mocks for isolated unit testing - -3. **Meaningful Assertions**: Write clear, specific assertion error messages that help diagnose failures - -4. **Test Isolation**: Each run block should be independent when possible. Use sequential runs only when testing dependencies - -5. **Variable Coverage**: Test different variable combinations to validate all code paths. Remember that test variables have the highest precedence - -6. **Mock Providers**: Use mocks for external dependencies to speed up tests and reduce costs (requires Terraform 1.7.0+) - -7. **Cleanup**: Integration tests automatically destroy resources in reverse order after completion. Use `-no-cleanup` flag for debugging - -8. **CI Integration**: Run `terraform test` in CI/CD pipelines to catch issues early - -9. **Test Naming**: Use descriptive names for run blocks that explain what scenario is being tested - -10. **Negative Testing**: Test invalid inputs and expected failures using `expect_failures` - -11. **Module Support**: Remember that test files only support **local** and **registry** modules, not Git or other sources - -12. **Parallel Execution**: Use `parallel = true` for independent tests with different state files to speed up test execution - -## Advanced Features - -### Testing with Refresh-Only Mode - -```hcl -run "test_refresh_only" { - command = plan - - plan_options { - mode = refresh-only - } - - assert { - condition = aws_instance.example.tags["Environment"] == "production" - error_message = "Tags should be refreshed correctly" - } -} -``` - -### Testing with Targeted Resources - -```hcl -run "test_specific_resource" { - command = plan - - plan_options { - target = [ - aws_instance.example - ] - } - - assert { - condition = aws_instance.example.instance_type == "t2.micro" - error_message = "Targeted resource should be planned" - } -} -``` - -### Testing Multiple Modules in Parallel - -```hcl -run "test_networking_module" { - command = plan - parallel = true - - module { - source = "./modules/networking" - } - - variables { - cidr_block = "10.0.0.0/16" - } - - assert { - condition = output.vpc_id != "" - error_message = "VPC should be created" - } -} - -run "test_compute_module" { - command = plan - parallel = true - - module { - source = "./modules/compute" - } - - variables { - instance_type = "t2.micro" - } - - assert { - condition = output.instance_id != "" - error_message = "Instance should be created" - } -} -``` - -### Custom State Management - -```hcl -run "create_foundation" { - command = apply - state_key = "foundation" - - assert { - condition = aws_vpc.main.id != "" - error_message = "Foundation VPC should be created" - } -} - -run "create_application" { - command = apply - state_key = "foundation" # Share state with foundation - - variables { - vpc_id = run.create_foundation.vpc_id - } - - assert { - condition = aws_instance.app.vpc_id == run.create_foundation.vpc_id - error_message = "Application should use foundation VPC" - } -} -``` - -## Troubleshooting - -### Test Failures - -**Issue**: Assertion failures - -**Solution**: Review error messages, check actual vs expected values, verify variable inputs. Use `-verbose` flag for detailed output - -### Provider Authentication - -**Issue**: Tests fail due to missing credentials - -**Solution**: Configure provider credentials for testing, or use mock providers for unit tests (available since v1.7.0) - -### Resource Dependencies - -**Issue**: Tests fail due to missing dependencies - -**Solution**: Use sequential run blocks or create setup runs to establish required resources. Remember cleanup happens in reverse order - -### Long Test Execution - -**Issue**: Tests take too long to run - -**Solution**: - -- Use `command = plan` instead of `apply` where possible -- Leverage mock providers -- Use `parallel = true` for independent tests -- Organize slow integration tests separately - -### State Conflicts - -**Issue**: Multiple tests interfere with each other - -**Solution**: - -- Use different modules (automatic separate state) -- Use `state_key` attribute to control state file sharing -- Use mock providers for isolated testing - -### Module Source Errors - -**Issue**: Test fails with unsupported module source - -**Solution**: Terraform test files only support **local** and **registry** modules. Convert Git or HTTP sources to local modules or use registry modules - -## Example Test Suite - -Complete example testing a VPC module, demonstrating both unit tests (plan mode) and integration tests (apply mode): - -```hcl -# tests/vpc_module_unit_test.tftest.hcl -# This file contains unit tests using command = plan (fast, no resources created) - -variables { - environment = "test" - aws_region = "us-west-2" -} - -# ============================================================================ -# UNIT TESTS (Plan Mode) - Validate logic without creating resources -# ============================================================================ - -# Test default configuration -run "test_defaults" { - command = plan - - variables { - vpc_cidr = "10.0.0.0/16" - vpc_name = "test-vpc" - } - - assert { - condition = aws_vpc.main.cidr_block == "10.0.0.0/16" - error_message = "VPC CIDR should match input" - } - - assert { - condition = aws_vpc.main.enable_dns_hostnames == true - error_message = "DNS hostnames should be enabled by default" - } - - assert { - condition = aws_vpc.main.tags["Name"] == "test-vpc" - error_message = "VPC name tag should match input" - } -} - -# Test subnet creation -run "test_subnets" { - command = plan - - variables { - vpc_cidr = "10.0.0.0/16" - vpc_name = "test-vpc" - public_subnets = ["10.0.1.0/24", "10.0.2.0/24"] - private_subnets = ["10.0.10.0/24", "10.0.11.0/24"] - } - - assert { - condition = length(aws_subnet.public) == 2 - error_message = "Should create 2 public subnets" - } - - assert { - condition = length(aws_subnet.private) == 2 - error_message = "Should create 2 private subnets" - } - - assert { - condition = alltrue([ - for subnet in aws_subnet.private : - subnet.map_public_ip_on_launch == false - ]) - error_message = "Private subnets should not assign public IPs" - } -} - -# Test outputs -run "test_outputs" { - command = plan - - variables { - vpc_cidr = "10.0.0.0/16" - vpc_name = "test-vpc" - } - - assert { - condition = output.vpc_id != "" - error_message = "VPC ID output should not be empty" - } - - assert { - condition = can(regex("^vpc-", output.vpc_id)) - error_message = "VPC ID should have correct format" - } - - assert { - condition = output.vpc_cidr == "10.0.0.0/16" - error_message = "VPC CIDR output should match input" - } -} - -# Test invalid CIDR block -run "test_invalid_cidr" { - command = plan - - variables { - vpc_cidr = "invalid" - vpc_name = "test-vpc" - } - - expect_failures = [ - var.vpc_cidr - ] -} -``` - -```hcl -# tests/vpc_module_integration_test.tftest.hcl -# This file contains integration tests using command = apply (creates real resources) - -variables { - environment = "integration-test" - aws_region = "us-west-2" -} - -# ============================================================================ -# INTEGRATION TESTS (Apply Mode) - Creates and validates real infrastructure -# ============================================================================ - -# Integration test creating real VPC -run "integration_test_vpc_creation" { - # command defaults to apply - creates real AWS resources! - - variables { - vpc_cidr = "10.100.0.0/16" - vpc_name = "integration-test-vpc" - } - - assert { - condition = aws_vpc.main.id != "" - error_message = "VPC should be created with valid ID" - } - - assert { - condition = aws_vpc.main.state == "available" - error_message = "VPC should be in available state" - } -} -``` - -```hcl -# tests/vpc_module_mock_test.tftest.hcl -# This file demonstrates mock provider testing - fastest option, no credentials needed - -# ============================================================================ -# MOCK TESTS (Plan Mode with Mocks) - No real infrastructure or API calls -# ============================================================================ -# Mock tests are ideal for: -# - Testing complex logic without cloud costs -# - Running tests without provider credentials -# - Fast feedback in local development -# - CI/CD pipelines without cloud access -# - Testing with predictable data source results - -# Define mock provider to simulate AWS behavior -mock_provider "aws" { - # Mock EC2 instances - returns these values instead of creating real resources - mock_resource "aws_instance" { - defaults = { - id = "i-1234567890abcdef0" - arn = "arn:aws:ec2:us-west-2:123456789012:instance/i-1234567890abcdef0" - instance_type = "t2.micro" - ami = "ami-12345678" - availability_zone = "us-west-2a" - subnet_id = "subnet-12345678" - vpc_security_group_ids = ["sg-12345678"] - associate_public_ip_address = true - public_ip = "203.0.113.1" - private_ip = "10.0.1.100" - tags = {} - } - } - - # Mock VPC resources - mock_resource "aws_vpc" { - defaults = { - id = "vpc-12345678" - arn = "arn:aws:ec2:us-west-2:123456789012:vpc/vpc-12345678" - cidr_block = "10.0.0.0/16" - enable_dns_hostnames = true - enable_dns_support = true - instance_tenancy = "default" - tags = {} - } - } - - # Mock subnet resources - mock_resource "aws_subnet" { - defaults = { - id = "subnet-12345678" - arn = "arn:aws:ec2:us-west-2:123456789012:subnet/subnet-12345678" - vpc_id = "vpc-12345678" - cidr_block = "10.0.1.0/24" - availability_zone = "us-west-2a" - map_public_ip_on_launch = false - tags = {} - } - } - - # Mock S3 bucket resources - mock_resource "aws_s3_bucket" { - defaults = { - id = "test-bucket-12345" - arn = "arn:aws:s3:::test-bucket-12345" - bucket = "test-bucket-12345" - bucket_domain_name = "test-bucket-12345.s3.amazonaws.com" - region = "us-west-2" - tags = {} - } - } - - # Mock data sources - critical for testing modules that query existing infrastructure - mock_data "aws_ami" { - defaults = { - id = "ami-0c55b159cbfafe1f0" - name = "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-20210430" - architecture = "x86_64" - root_device_type = "ebs" - virtualization_type = "hvm" - owners = ["099720109477"] - } - } - - mock_data "aws_availability_zones" { - defaults = { - names = ["us-west-2a", "us-west-2b", "us-west-2c"] - zone_ids = ["usw2-az1", "usw2-az2", "usw2-az3"] - } - } - - mock_data "aws_vpc" { - defaults = { - id = "vpc-12345678" - cidr_block = "10.0.0.0/16" - enable_dns_hostnames = true - enable_dns_support = true - } - } -} - -# Test 1: Validate resource configuration with mocked values -run "test_instance_with_mocks" { - command = plan # Mocks only work with plan mode - - variables { - instance_type = "t2.micro" - ami_id = "ami-12345678" - } - - assert { - condition = aws_instance.example.instance_type == "t2.micro" - error_message = "Instance type should match input variable" - } - - assert { - condition = aws_instance.example.id == "i-1234567890abcdef0" - error_message = "Mock should return consistent instance ID" - } - - assert { - condition = can(regex("^203\\.0\\.113\\.", aws_instance.example.public_ip)) - error_message = "Mock public IP should be in TEST-NET-3 range" - } -} - -# Test 2: Validate data source behavior with mocked results -run "test_data_source_with_mocks" { - command = plan - - assert { - condition = data.aws_ami.ubuntu.id == "ami-0c55b159cbfafe1f0" - error_message = "Mock data source should return predictable AMI ID" - } - - assert { - condition = length(data.aws_availability_zones.available.names) == 3 - error_message = "Should return 3 mocked availability zones" - } - - assert { - condition = contains( - data.aws_availability_zones.available.names, - "us-west-2a" - ) - error_message = "Should include us-west-2a in mocked zones" - } -} - -# Test 3: Validate complex logic with for_each and mocks -run "test_multiple_subnets_with_mocks" { - command = plan - - variables { - subnet_cidrs = { - "public-a" = "10.0.1.0/24" - "public-b" = "10.0.2.0/24" - "private-a" = "10.0.10.0/24" - "private-b" = "10.0.11.0/24" - } - } - - # Test that all subnets are created - assert { - condition = length(keys(aws_subnet.subnets)) == 4 - error_message = "Should create 4 subnets from for_each map" - } - - # Test that public subnets have correct naming - assert { - condition = alltrue([ - for name, subnet in aws_subnet.subnets : - can(regex("^public-", name)) ? subnet.map_public_ip_on_launch == true : true - ]) - error_message = "Public subnets should map public IPs on launch" - } - - # Test that all subnets belong to mocked VPC - assert { - condition = alltrue([ - for subnet in aws_subnet.subnets : - subnet.vpc_id == "vpc-12345678" - ]) - error_message = "All subnets should belong to mocked VPC" - } -} - -# Test 4: Validate output values with mocks -run "test_outputs_with_mocks" { - command = plan - - assert { - condition = output.vpc_id == "vpc-12345678" - error_message = "VPC ID output should match mocked value" - } - - assert { - condition = can(regex("^vpc-", output.vpc_id)) - error_message = "VPC ID output should have correct format" - } - - assert { - condition = output.instance_public_ip == "203.0.113.1" - error_message = "Instance public IP should match mock" - } -} - -# Test 5: Test conditional logic with mocks -run "test_conditional_resources_with_mocks" { - command = plan - - variables { - create_bastion = true - create_nat_gateway = false - } - - assert { - condition = length(aws_instance.bastion) == 1 - error_message = "Bastion should be created when enabled" - } - - assert { - condition = length(aws_nat_gateway.nat) == 0 - error_message = "NAT gateway should not be created when disabled" - } -} - -# Test 6: Test tag propagation with mocks -run "test_tag_inheritance_with_mocks" { - command = plan - - variables { - common_tags = { - Environment = "test" - ManagedBy = "Terraform" - Project = "MockTesting" - } - } - - # Verify tags are properly merged with defaults - assert { - condition = alltrue([ - for key in keys(var.common_tags) : - contains(keys(aws_instance.example.tags), key) - ]) - error_message = "All common tags should be present on instance" - } - - assert { - condition = aws_instance.example.tags["Environment"] == "test" - error_message = "Environment tag should be set correctly" - } -} - -# Test 7: Test validation rules with mocks (expect_failures) -run "test_invalid_cidr_with_mocks" { - command = plan - - variables { - vpc_cidr = "192.168.0.0/8" # Invalid - should be /16 or /24 - } - - # Expect custom validation to fail - expect_failures = [ - var.vpc_cidr - ] -} - -# Test 8: Sequential mock tests with state sharing -run "setup_vpc_with_mocks" { - command = plan - - variables { - vpc_cidr = "10.0.0.0/16" - vpc_name = "test-vpc" - } - - assert { - condition = aws_vpc.main.cidr_block == "10.0.0.0/16" - error_message = "VPC CIDR should match input" - } -} - -run "test_subnet_references_vpc_with_mocks" { - command = plan - - variables { - vpc_id = run.setup_vpc_with_mocks.vpc_id - subnet_cidr = "10.0.1.0/24" - } - - assert { - condition = aws_subnet.example.vpc_id == run.setup_vpc_with_mocks.vpc_id - error_message = "Subnet should reference VPC from previous run" - } - - assert { - condition = aws_subnet.example.vpc_id == "vpc-12345678" - error_message = "VPC ID should match mocked value" - } -} -``` - -**Key Benefits of Mock Testing:** - -1. **No Cloud Costs**: Runs entirely locally without creating infrastructure -2. **No Credentials Needed**: Perfect for CI/CD environments without cloud access -3. **Fast Execution**: Tests complete in seconds, not minutes -4. **Predictable Results**: Data sources return consistent values -5. **Isolated Testing**: No dependencies on existing cloud resources -6. **Safe Experimentation**: Test destructive operations without risk - -**Limitations of Mock Testing:** - -1. **Plan Mode Only**: Mocks don't work with `command = apply` -2. **Not Real Behavior**: Mocks may not reflect actual provider API behavior -3. **Computed Values**: Mock defaults may not match real computed attributes -4. **Provider Updates**: Mocks need manual updates when provider schemas change -5. **Resource Interactions**: Can't test real resource dependencies or timing issues - -**When to Use Mock Tests:** - -- βœ… Testing Terraform logic and conditionals -- βœ… Validating variable transformations -- βœ… Testing for_each and count expressions -- βœ… Checking output calculations -- βœ… Local development without cloud access -- βœ… Fast CI/CD feedback loops -- ❌ Validating actual provider behavior -- ❌ Testing real resource creation side effects -- ❌ Verifying API-level interactions -- ❌ End-to-end integration testing - -## CI/CD Integration - -### GitHub Actions Example - -```yaml -name: Terraform Tests - -on: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - terraform-test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: 1.9.0 - - - name: Terraform Format Check - run: terraform fmt -check -recursive - - - name: Terraform Init - run: terraform init - - - name: Terraform Validate - run: terraform validate - - - name: Run Terraform Tests - run: terraform test -verbose - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} -``` - -### GitLab CI Example - -```yaml -terraform-test: - image: hashicorp/terraform:1.9 - stage: test - before_script: - - terraform init - script: - - terraform fmt -check -recursive - - terraform validate - - terraform test -verbose - only: - - merge_requests - - main -``` - -## References - -For more information: - -- [Terraform Testing Documentation](https://developer.hashicorp.com/terraform/language/tests) -- [Terraform Test Command Reference](https://developer.hashicorp.com/terraform/cli/commands/test) -- [Testing Best Practices](https://developer.hashicorp.com/terraform/language/tests/best-practices) diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 0000000..c905d4e --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,58 @@ +# Gemini Code Assist β€” repo-level configuration +# +# Docs: https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github +# Style guide companion: see ./styleguide.md +# +# This config targets a single-repo macOS-Swift project that carries PHI +# obligations. We keep the signal-to-noise ratio high (MEDIUM threshold, +# capped comment count) and exclude machine-generated / vendored / binary +# paths from analysis. + +have_fun: false + +# Paths excluded from analysis. Use glob syntax. +ignore_patterns: + # Build outputs + IDE caches + - ".build/**" + - "build/**" + - "DerivedData/**" + - "xcode-project/**" + - "*.xcodeproj/**" + # Vendored dependencies + - "Vendor/**" + # Large on-disk ML / audio artifacts β€” not source code + - "Sources/Resources/Models/**" + - "**/*.onnx" + - "**/*.mlpackage/**" + - "**/*.wav" + # Test fixtures are curated by humans and must not contain PHI; the + # review signal here is low and false-positive noise is high. + - "Tests/SpeechToTextTests/Fixtures/**" + # Generated docs + checked-in lockfiles + - "Package.resolved" + # Claude tooling + skills (these are agent config, not product code) + - ".claude/**" + +memory_config: + # Enable cross-repo memory if the org turns it on β€” at single-repo scale + # this is a no-op. + disabled: false + +code_review: + disable: false + # Only surface MEDIUM+ findings by default β€” drops stylistic nits that + # SwiftLint --strict already enforces pre-push. + comment_severity_threshold: MEDIUM + # Keep reviews focused. If a PR genuinely needs more than 25 comments + # it probably needs a human reviewer first. + max_review_comments: 25 + pull_request_opened: + # "Help" intro is noisy for a small team. + help: false + # Summary is genuinely useful β€” keep. + summary: true + # Full review on every PR open. + code_review: true + # Don't review drafts β€” they're still in flight and the author + # doesn't want early-stage feedback. + include_drafts: false diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md new file mode 100644 index 0000000..1c934ce --- /dev/null +++ b/.gemini/styleguide.md @@ -0,0 +1,157 @@ +# Gemini Code Assist β€” styleguide + +> Read `AGENTS.md` at the project root (and `.claude/references/*.md`) +> before reviewing a PR in this repo. Those documents are the authoritative +> rules; this file is a reviewer-oriented summary that calibrates +> Gemini's output to match the project's conventions. + +## Project at a glance + +A privacy-focused local-first macOS speech-to-text menu-bar app, being +extended into a clinical documentation assistant for chiropractors. +Swift 5.9 language mode / Swift 6.2 compiler. macOS 14+ baseline, macOS +26 for development. Swift Package Manager. SwiftLint `--strict` + two +custom concurrency rules. FluidAudio (Parakeet v3) for ASR; MLX Swift + +Gemma 3 4B-IT (bundled) for the clinical-notes LLM; URLSession actor for +Cliniko API. **No cloud services** β€” only egress is the doctor-initiated +`POST /treatment_notes` to their own Cliniko tenant over TLS. + +--- + +## What to prioritise in every review + +### Security & PHI (highest priority β€” block on violations) + +- **PHI must never appear in logs, crash-report messages, UserDefaults, + on-disk caches, or external tooling.** PHI in this project means: + consultation transcripts, generated SOAP notes, suggested + manipulations, excluded-content snippets, patient demographics, and + `treatment_note` bodies. +- `OSLog` / `Logger` calls: any non-structural interpolation of PHI is a + blocker. `privacy: .public` is reserved for structural values only + (HTTP status, error-case name, path template, method). Everything + else must be `privacy: .private`. +- `fatalError` / `preconditionFailure` / `assertionFailure` messages: + **never** interpolate PHI. These show up in crash reports. +- Secrets, API keys, `.env` files, entitlements with hardcoded team + IDs, GitHub PATs β€” never. Cliniko API keys live in Keychain via the + `SecureStore` protocol with + `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. +- `AuditStore` entries must carry metadata only: timestamp, patient_id + (opaque string), appointment_id, note_id (from Cliniko response), + HTTP status, app version. **Never** request/response body, + transcript, or patient name. +- Test fixtures must use obviously-synthetic data (`@example.test` + domains, placeholder IDs). Flag any fixture that looks like copied + production data. + +### Concurrency correctness (block on violations) + +- Any `any SomeActorProtocol` / any actor-existential field on an + `@Observable` class **must** be marked `@ObservationIgnored`. + Omitting this causes an ARM64 pointer-authentication crash on first + Observation scan. SwiftLint rule + `observable_actor_existential_warning` catches most cases β€” flag any + usage the rule may have missed. +- Core Audio / Carbon / `DispatchSource` callbacks must run through a + `nonisolated` entry and hop to `@MainActor` via + `Task { @MainActor … }` for state mutation. Never call a `@MainActor` + method directly from an audio-thread callback. +- `nonisolated(unsafe)` is tolerated only for: (a) `deinit` cleanup, + (b) audio/system callbacks with a self-synchronised data type, + (c) internally thread-safe value types. Flag every other usage for + justification β€” the custom SwiftLint rule + `nonisolated_unsafe_warning` surfaces them. +- Actors cannot be subclassed, so mocks must go through an + `Actor`-constrained protocol. Flag any mock-by-subclass on an actor. +- Every new `@Observable` view model requires a ViewInspector render / + crash-detection test. Flag PRs that add a view model without one. + +### Code quality + +- **No** force-unwraps (`!`) without a justifying comment and a `guard` + above them. Flag every `!`. +- **No** `try!` outside test code. `try?` is only legitimate where + `nil` is a genuine "absent" signal, not to swallow errors. +- **No** `@ObservedObject` / `@StateObject` / `ObservableObject` β€” this + project is on the `@Observable` macro. Flag any legacy + ObservableObject usage. +- Prefer `let` unless mutation is required. +- No emoji in source or test fixtures unless the user has asked for + them. +- No "nice-to-have" comments that just restate the code. Comments + should explain *why*, not *what*. Flag large docstring blocks that + paraphrase the implementation. + +### Testing + +- New pure-logic and async tests go in **Swift Testing** + (`@Test` / `@Suite` / `#expect`), tagged with `.fast` / `.slow` / + `.requiresHardware` from + `Tests/SpeechToTextTests/Utilities/TestTags.swift`. The canonical + idiom lives in `SwiftTestingExemplarTests.swift`. +- UI / ViewInspector / XCUITest stay on **XCTest**. +- Snapshot tests (`pointfreeco/swift-snapshot-testing`) are scoped to + `ReviewScreen` + `SafetyDisclaimerView` only. Don't encourage broader + snapshot coverage. +- **Never** encourage a test that hits real Keychain / real Cliniko / + real LLM inference in the default CI path. Use `InMemorySecureStore` + / `URLProtocolStub` + fixtures / `MockLLMProvider`. Golden-file LLM + tests are gated behind `RUN_MLX_GOLDEN=1` (nightly only). +- Every service that touches PHI needs an invariant test asserting it + doesn't leak (e.g. snapshot `UserDefaults.standard.dictionaryRepresentation()` + before/after a lifecycle). + +### Workflow hygiene + +- `pre-commit run --all-files` is assumed to be green before push. + SwiftLint strict + gitleaks are mandatory hooks. Flag any PR that + looks like it was pushed without them. +- Every GH issue closes via `Closes #N` in the PR body. +- PRs should carry three issue-level checkpoint comments (start / PR + opened / merged) β€” absence is a process smell but not a code smell; + flag only in the PR summary, not as inline comments. + +### Locked technical decisions β€” do not re-litigate + +The following are intentional and live in the EPIC + `.claude/CLAUDE.md`: + +| Area | Decision | +|---|---| +| LLM runtime | MLX Swift in-process (no XPC / daemon) | +| LLM model v1 | Gemma 3 4B-IT (MLX 4-bit); Gemma 4 E4B migration gated on `ml-explore/mlx-swift#389` | +| Model delivery | Bundled in the `.app` (DMG distribution, not App Store) | +| Persistence | Session-only, cleared on export/quit β€” no on-disk PHI | +| Cliniko | API integration in v1, direct from doctor's Mac to doctor's tenant | +| UI entry | Settings toggle + "Generate Notes" action after recording | +| Review layout | Two-column (SOAP editor / Manipulations + Excluded drawer) β€” wireframe in issue #13 | +| Safety | One-time "not a diagnostic tool" disclaimer, UserDefaults ack (#12) | +| Test frameworks | Swift Testing (new) + XCTest (UI / ViewInspector) | +| HTTP mocking | Hand-rolled `URLProtocolStub` (zero deps) | +| Keychain mocking | `SecureStore` protocol + `InMemorySecureStore` actor fake | + +**Do not suggest:** switching to a different LLM runtime, adding cloud +telemetry, shipping via the Mac App Store, persisting PHI, introducing a +third-party HTTP mocking library, or replacing SwiftLint with a +different linter. These have been evaluated and rejected. + +--- + +## PR summary style + +When summarising a PR, lead with the behavioural change in one sentence, +then call out PHI / concurrency risks (if any) and the test coverage +delta (what was added, what's intentionally deferred). Keep it to ~150 +words. Reviewers here are time-pressed. + +## When to stay quiet + +- Style nits already enforced by SwiftLint `--strict` or by pre-commit + hooks (trailing whitespace, import ordering, closure spacing). + Comment only if the hook is somehow bypassed. +- Test-coverage "should also test X" comments when the PR explicitly + defers X to a tracked issue. Cross-check the PR body and linked + issues before suggesting new tests. +- Refactors / abstractions that aren't justified by the PR's scope. + The project prefers "three similar lines over a premature + abstraction." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1169eb0..0c3b95d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,10 @@ concurrency: jobs: lint: name: SwiftLint - runs-on: macos-14 + runs-on: macos-15 steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install SwiftLint run: brew install swiftlint @@ -25,13 +25,68 @@ jobs: - name: Run SwiftLint run: swiftlint lint --strict + pre-commit: + name: pre-commit hooks + runs-on: macos-15 + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + # Need history back to the PR base so pre-commit can diff against it + # and only check files changed in this PR. + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install hook dependencies + run: | + brew install swiftlint gitleaks + npm install -g markdownlint-cli@0.43.0 + + - name: Determine diff range + id: range + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + base_ref="origin/${{ github.base_ref }}" + else + # push / workflow_dispatch: diff the pushed range if available. + raw="${{ github.event.before }}" + if [[ -z "$raw" || "$raw" == "0000000000000000000000000000000000000000" ]]; then + # Initial push of a new branch, or force-push from empty: + # HEAD~1 does not exist. Fall back to the repository's first + # commit so pre-commit sees every file in the push as "new". + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + base_ref="HEAD~1" + else + base_ref="$(git rev-list --max-parents=0 HEAD | tail -1)" + fi + else + base_ref="$raw" + fi + fi + echo "base_ref=${base_ref}" >> "$GITHUB_OUTPUT" + echo "using diff range: ${base_ref}...HEAD" + + # Only check files changed in this PR (or push range) so we're not + # enforcing hook compliance retroactively on the whole repository. CI + # pre-commit exists to catch developers who bypass the local hook with + # `--no-verify`, not to block PRs on pre-existing legacy files. + - name: Run pre-commit on changed files + uses: pre-commit/action@v3.0.1 + with: + extra_args: --from-ref ${{ steps.range.outputs.base_ref }} --to-ref HEAD + test: name: Build and Test - runs-on: macos-14 - needs: lint + runs-on: macos-15 + needs: [lint, pre-commit] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Select Xcode version run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer @@ -42,7 +97,7 @@ jobs: swift --version - name: Cache Swift Package Manager dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .build @@ -52,7 +107,7 @@ jobs: spm-${{ runner.os }}- - name: Cache sherpa-onnx xcframework - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: Frameworks/sherpa-onnx.xcframework key: sherpa-onnx-v1.12.20-${{ runner.os }} @@ -63,28 +118,155 @@ jobs: - name: Resolve dependencies run: swift package resolve + # Sanity-check the CI summary parser before we commit to parsing real + # build/test logs with it. Cheap (<1s) and guarantees a broken + # regex change can't ship a silently-empty summary. + - name: ci-summary self-test + run: python3 scripts/ci-summary.py --self-test + + # We `tee` build + test output to log files so the `CI summary` step + # below can parse them into `$GITHUB_STEP_SUMMARY`. `set -eo pipefail` + # preserves the underlying exit status across the pipe β€” without it, + # `tee` would mask a `swift build` failure. `touch` up-front so the + # file always exists even if the underlying command exits before + # emitting a single line, which lets `_log_status` distinguish + # "empty" from "missing" cleanly. - name: Build - run: swift build -c release + shell: bash + run: | + set -euo pipefail + : > build.log + swift build -c release 2>&1 | tee build.log - # Note: Tests require real macOS hardware with audio/accessibility permissions - # Tests run via pre-push hook on macdev remote Mac instead of GH Actions - # See docs/UI_TESTING.md for the testing workflow - # - name: Run tests - # run: swift test --parallel + # Unit tests β€” the GitHub Actions runner has no microphone, no + # Accessibility (TCC) grant, no display server, and no user keychain. + # Classes that exercise those APIs are skipped here and run on the + # remote Mac via the pre-push hook instead. The by-name skip list is + # temporary; it migrates to a tag filter once the Swift Testing tag + # scheme lands (see the testing framework EPIC). + # + # Skipped classes and why: + # AudioCaptureServiceTests AVAudioEngine.start (real mic) + # PermissionServiceTests AXIsProcessTrustedWithOptions / TCC + # TextInsertionServiceTests CGEventPost + display server + # VoiceTriggerMonitoringServiceTests transitively needs real mic + # WakeWordServiceTests reads real WAV fixtures from models + # GeneralSectionPersistenceTests shared UserDefaults race under --parallel; + # tracked for fix, runs pre-push serially. + - name: Run unit tests (with coverage) + shell: bash + run: | + set -euo pipefail + : > test.log + swift test --parallel --enable-code-coverage \ + --skip AudioCaptureServiceTests \ + --skip PermissionServiceTests \ + --skip TextInsertionServiceTests \ + --skip VoiceTriggerMonitoringServiceTests \ + --skip WakeWordServiceTests \ + --skip GeneralSectionPersistenceTests 2>&1 | tee test.log + + # Coverage export is a *required* part of this job β€” the whole point is + # PR-visible coverage. A missing profdata / xctest bundle / binary + # means something is wrong with the test-run layout, not a soft edge + # case to warn past. Fail the step so we don't silently ship a PR + # with no coverage report. + - name: Export coverage (lcov) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + BIN_PATH=$(swift build --show-bin-path) + PROF="${BIN_PATH}/codecov/default.profdata" + if [[ ! -f "${PROF}" ]]; then + echo "::error::profdata not found at ${PROF} β€” test run produced no coverage" + exit 1 + fi + TEST_BUNDLE=$(find "${BIN_PATH}" -maxdepth 1 -name '*.xctest' -type d | head -1) + if [[ -z "${TEST_BUNDLE}" ]]; then + echo "::error::no .xctest bundle found in ${BIN_PATH}" + exit 1 + fi + TEST_BIN=$(find "${TEST_BUNDLE}/Contents/MacOS" -type f -perm -u+x | head -1) + if [[ -z "${TEST_BIN}" ]]; then + echo "::error::no test binary found in ${TEST_BUNDLE}" + exit 1 + fi + xcrun llvm-cov export \ + --format=lcov \ + --instr-profile="${PROF}" \ + --ignore-filename-regex='(\.build|Tests|UITests|Frameworks|checkouts)/' \ + "${TEST_BIN}" > coverage.lcov + echo "--- coverage summary ---" + xcrun llvm-cov report \ + --instr-profile="${PROF}" \ + --ignore-filename-regex='(\.build|Tests|UITests|Frameworks|checkouts)/' \ + "${TEST_BIN}" | tail -3 + + # Codecov upload is a hard gate on PRs: if the upload fails (token + # revoked, Codecov outage, malformed lcov), the job fails so we + # don't silently ship a PR with missing coverage visibility. + # Coverage is also attached as the `coverage-report` workflow + # artifact below so a reviewer can inspect it directly. + - name: Upload coverage to Codecov + if: ${{ !cancelled() && hashFiles('coverage.lcov') != '' }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.lcov + fail_ci_if_error: true + flags: unit + disable_search: true + + - name: Upload coverage artifact + if: ${{ !cancelled() && hashFiles('coverage.lcov') != '' }} + uses: actions/upload-artifact@v5 + with: + name: coverage-report + path: coverage.lcov + retention-days: 7 + + # Parse the teed logs into a structured PR-visible summary. Runs even + # when earlier steps fail β€” the whole point is to make failures easy + # to triage without scrolling raw logs. See `scripts/ci-summary.py`. + # + # `|| true` at the end ensures the summary step itself never flips a + # green job red: a diagnostic tool that hides its own failures (or + # masks a real build failure behind a summary crash) is worse than + # useless. The script's internal try/except prints a visible + # fallback to the summary on crash. + # + # We deliberately do NOT upload `build.log` / `test.log` as + # workflow artifacts. They contain verbatim `swift test` output, + # and any future test fixture that accidentally echoes PHI (SOAP + # body, transcript, Cliniko request body) would egress for 7+ days + # to GitHub artifacts β€” exactly what `.claude/references/phi-handling.md` + # forbids. The rendered summary + the raw workflow log are enough + # for triage. + - name: CI summary + if: ${{ always() }} + shell: bash + run: | + python3 scripts/ci-summary.py \ + --job "Build and Test" \ + --build-log build.log \ + --test-log test.log \ + --job-status "${{ job.status }}" \ + --out "$GITHUB_STEP_SUMMARY" \ + || echo "::warning::ci-summary.py exited non-zero; see raw log" build: name: Build Release - runs-on: macos-14 + runs-on: macos-15 needs: test steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Select Xcode version run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer - name: Cache Swift Package Manager dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | .build @@ -94,7 +276,7 @@ jobs: spm-${{ runner.os }}- - name: Cache sherpa-onnx xcframework - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: Frameworks/sherpa-onnx.xcframework key: sherpa-onnx-v1.12.20-${{ runner.os }} @@ -103,14 +285,34 @@ jobs: run: ./scripts/setup-sherpa-onnx.sh - name: Build release - run: swift build -c release + shell: bash + run: | + set -euo pipefail + : > build.log + swift build -c release 2>&1 | tee build.log - name: Upload build artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: release-build path: .build/release/ retention-days: 7 + # Parse the teed build log into a PR-visible summary (warnings / + # errors by diagnostic category). Runs even on failure. See notes on + # the equivalent step in the `test` job β€” no log-artifact upload + # (PHI policy), `|| true` to isolate summary-script failures from + # the job's own exit status. + - name: CI summary + if: ${{ always() }} + shell: bash + run: | + python3 scripts/ci-summary.py \ + --job "Build Release" \ + --build-log build.log \ + --job-status "${{ job.status }}" \ + --out "$GITHUB_STEP_SUMMARY" \ + || echo "::warning::ci-summary.py exited non-zero; see raw log" + # Note: Smoke tests require real macOS hardware with audio/accessibility # Use scripts/smoke-test.sh locally before merging diff --git a/AGENTS.md b/AGENTS.md index 5af7a8e..0267d10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,960 +1,238 @@ -# Swift macOS Application Development Guidelines - -## Project Overview - -**Speech-to-Text macOS Application**: A privacy-focused, local-first menu bar application for speech-to-text capture using the FluidAudio SDK. - -- **Language**: Swift 5.9+ (Swift 6 concurrency enabled) -- **Platform**: macOS 14+ -- **Architecture**: Pure Swift (SwiftUI + AppKit hybrid) -- **Build System**: Swift Package Manager -- **Testing**: XCTest framework - ---- - -## AI Assistant Rules - -> **Read this section first.** These are hard constraints for code generation. - -### MUST - -- Use Swift 5.9+ with async/await for all asynchronous operations -- Use Swift actors for thread-safe concurrent access to shared mutable state -- Use @Observable macro (not deprecated @StateObject/@ObservableObject) -- Use @MainActor for UI-bound classes and methods -- Write tests FIRST before implementation (TDD with XCTest) -- Use SwiftLint for code quality enforcement -- Use explicit types for public APIs (no implicit type inference at boundaries) -- Use `let` by default, `var` only when mutation is required -- Implement proper error handling with custom Error enums conforming to LocalizedError -- Follow the service layer pattern for business logic -- Use protocol-based design for testability (dependency injection) -- Use Sendable conformance for types shared across concurrency boundaries -- Follow the "Warm Minimalism" design aesthetic for UI components - -### NEVER - -- Use force unwrapping (`!`) without explicit justification and guard clauses -- Use `try!` or `try?` for error handling (use proper do-catch blocks) -- Store secrets or API keys in code or version control -- Use synchronous blocking operations on the main thread -- Skip error handling for async/throwing operations -- Use mutable global state (use @Observable or actors instead) -- Commit sensitive files (entitlements with hardcoded teams, credentials) -- Write implementation code without corresponding tests -- Use deprecated APIs (@ObservedObject, @StateObject, etc.) -- Use `print()` for production logging (use proper logging framework) -- Ignore SwiftLint warnings without justification - -### PREFER - -- Protocol-oriented programming over class inheritance -- Value types (struct) over reference types (class) when possible -- Composition over inheritance -- Small, focused functions (< 30 lines) -- Early returns with guard clauses -- Explicit error types (enum Error) over generic Error -- Named parameters for clarity -- Descriptive variable names over comments -- Property wrappers (@Observable, @MainActor) for clean code -- Red-Green-Refactor TDD cycle - ---- - -## Tech Stack - -| Layer | Technology | Notes | -|-------|-----------|-------| -| Language | Swift 5.9+ | Strict type safety, modern concurrency | -| UI Framework | SwiftUI | Declarative UI with @Observable state | -| System Integration | AppKit | Menu bar, hotkeys, accessibility APIs | -| Audio Processing | AVFoundation | AVAudioEngine for 16kHz mono capture | -| Speech Recognition | FluidAudio SDK | Local ML models, 25 languages | -| Testing | XCTest | Native Swift testing framework | -| Code Quality | SwiftLint | Static analysis and style enforcement | -| Build System | Swift Package Manager | Dependency management | -| Git Hooks | pre-commit | Automated quality checks | -| CI/CD | GitHub Actions | Automated testing pipeline | - ---- - -## Test-Driven Development (TDD) - -### The TDD Cycle - -1. **RED**: Write a failing test that defines the expected behavior -2. **GREEN**: Write minimal code to make the test pass -3. **REFACTOR**: Improve the code while keeping tests green - -### TDD Workflow Example - -```swift -// Step 1: RED - Write the failing test first -import XCTest -@testable import SpeechToText - -final class RecordingSessionTests: XCTestCase { - func testSessionStartsInIdleState() { - // Arrange - let session = RecordingSession() - - // Act & Assert - XCTAssertEqual(session.state, .idle) - XCTAssertNil(session.startTime) - } - - func testSessionTransitionsToRecording() { - // Arrange - var session = RecordingSession() - - // Act - session.start() - - // Assert - XCTAssertEqual(session.state, .recording) - XCTAssertNotNil(session.startTime) - } -} - -// Step 2: GREEN - Implement minimal code to pass -struct RecordingSession { - enum State { - case idle, recording, transcribing, completed - } - - var state: State = .idle - var startTime: Date? - - mutating func start() { - state = .recording - startTime = Date() - } -} - -// Step 3: REFACTOR - Add validation, error handling -struct RecordingSession { - // ... existing code ... - - mutating func start() throws { - guard state == .idle else { - throw RecordingError.invalidStateTransition(from: state, to: .recording) - } - state = .recording - startTime = Date() - } -} -``` - -### Test Structure - -```swift -import XCTest -@testable import SpeechToText - -final class FluidAudioServiceTests: XCTestCase { - // MARK: - Properties - var sut: FluidAudioService! - var mockPermissionService: MockPermissionService! - - // MARK: - Setup & Teardown - override func setUp() async throws { - try await super.setUp() - mockPermissionService = MockPermissionService() - sut = FluidAudioService(permissionService: mockPermissionService) - } - - override func tearDown() async throws { - sut = nil - mockPermissionService = nil - try await super.tearDown() - } - - // MARK: - Tests - func testInitializeCreatesASRManager() async throws { - // Arrange - let modelPath = "/path/to/model" - - // Act - try await sut.initialize(modelPath: modelPath) - - // Assert - let isInitialized = await sut.isInitialized - XCTAssertTrue(isInitialized) - } - - func testTranscribeThrowsWhenNotInitialized() async { - // Arrange - let audioData = Data() - - // Act & Assert - await XCTAssertThrowsError( - try await sut.transcribe(audioData: audioData) - ) { error in - XCTAssertEqual(error as? FluidAudioError, .notInitialized) - } - } -} -``` - ---- - -## Architecture - -### Project Structure - -``` -SpeechToText/ -β”œβ”€β”€ Sources/ -β”‚ β”œβ”€β”€ SpeechToTextApp/ # App entry point -β”‚ β”‚ β”œβ”€β”€ SpeechToTextApp.swift # @main App struct -β”‚ β”‚ β”œβ”€β”€ AppDelegate.swift # macOS lifecycle & menu bar -β”‚ β”‚ └── AppState.swift # @Observable app state -β”‚ β”‚ -β”‚ β”œβ”€β”€ Services/ # Business logic layer -β”‚ β”‚ β”œβ”€β”€ FluidAudioService.swift # actor for ML model -β”‚ β”‚ β”œβ”€β”€ AudioCaptureService.swift # AVAudioEngine wrapper -β”‚ β”‚ β”œβ”€β”€ PermissionService.swift # macOS permissions -β”‚ β”‚ β”œβ”€β”€ HotkeyService.swift # Global hotkey registration -β”‚ β”‚ β”œβ”€β”€ TextInsertionService.swift # Accessibility text insertion -β”‚ β”‚ β”œβ”€β”€ SettingsService.swift # UserDefaults persistence -β”‚ β”‚ └── StatisticsService.swift # Usage tracking -β”‚ β”‚ -β”‚ β”œβ”€β”€ Models/ # Data structures -β”‚ β”‚ β”œβ”€β”€ RecordingSession.swift -β”‚ β”‚ β”œβ”€β”€ UserSettings.swift -β”‚ β”‚ β”œβ”€β”€ LanguageModel.swift -β”‚ β”‚ β”œβ”€β”€ UsageStatistics.swift -β”‚ β”‚ └── AudioBuffer.swift -β”‚ β”‚ -β”‚ β”œβ”€β”€ Views/ # SwiftUI views -β”‚ β”‚ β”œβ”€β”€ MenuBarView.swift -β”‚ β”‚ β”œβ”€β”€ RecordingModal.swift -β”‚ β”‚ β”œβ”€β”€ RecordingViewModel.swift -β”‚ β”‚ β”œβ”€β”€ OnboardingView.swift -β”‚ β”‚ β”œβ”€β”€ OnboardingViewModel.swift -β”‚ β”‚ └── Components/ -β”‚ β”‚ β”œβ”€β”€ WaveformView.swift -β”‚ β”‚ └── PermissionCard.swift -β”‚ β”‚ -β”‚ └── Utilities/ -β”‚ β”œβ”€β”€ Constants.swift -β”‚ └── Extensions/ -β”‚ └── Color+Theme.swift -β”‚ -β”œβ”€β”€ Tests/ -β”‚ └── SpeechToTextTests/ -β”‚ β”œβ”€β”€ Models/ # Model tests (5 files) -β”‚ β”œβ”€β”€ Services/ # Service tests (7 files) -β”‚ β”œβ”€β”€ App/ # App state tests -β”‚ └── Utilities/ # Utility tests -β”‚ -β”œβ”€β”€ Resources/ # Assets, models -β”œβ”€β”€ Package.swift # SPM manifest -β”œβ”€β”€ .swiftlint.yml # Linting config -└── SpeechToText.entitlements # macOS permissions - -``` - -### Service Layer Pattern - -```swift -// Services/AudioCaptureService.swift -import AVFoundation - -enum AudioCaptureError: LocalizedError { - case engineStartFailed - case noInputNode - case invalidFormat - - var errorDescription: String? { - switch self { - case .engineStartFailed: return "Failed to start audio engine" - case .noInputNode: return "No audio input available" - case .invalidFormat: return "Invalid audio format" - } - } -} - -@MainActor -class AudioCaptureService { - private let audioEngine = AVAudioEngine() - private var streamingBuffer: StreamingAudioBuffer? - - func startCapture(levelCallback: @escaping (Double) -> Void) async throws { - guard let inputNode = audioEngine.inputNode else { - throw AudioCaptureError.noInputNode - } - - let format = AVAudioFormat( - commonFormat: .pcmFormatInt16, - sampleRate: 16000, - channels: 1, - interleaved: true - ) - - guard let format else { - throw AudioCaptureError.invalidFormat - } - - streamingBuffer = StreamingAudioBuffer() - - inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in - Task { [weak self] in - await self?.processBuffer(buffer, levelCallback: levelCallback) - } - } - - do { - try audioEngine.start() - } catch { - throw AudioCaptureError.engineStartFailed - } - } - - func stopCapture() async throws -> Data { - audioEngine.stop() - guard let buffer = streamingBuffer else { - return Data() - } - return await buffer.getData() - } - - private func processBuffer(_ buffer: AVAudioPCMBuffer, levelCallback: @escaping (Double) -> Void) async { - // Calculate audio level - let level = calculateLevel(from: buffer) - await MainActor.run { - levelCallback(level) - } - - // Store in buffer - await streamingBuffer?.append(buffer) - } -} -``` - -### Protocol-Based Design for Testing - -```swift -// Services/PermissionService.swift -protocol PermissionChecker { - func checkMicrophonePermission() async -> Bool - func requestMicrophonePermission() async throws - func checkAccessibilityPermission() -> Bool - func openAccessibilitySettings() -} - -class PermissionService: PermissionChecker { - func checkMicrophonePermission() async -> Bool { - let status = AVCaptureDevice.authorizationStatus(for: .audio) - return status == .authorized - } - - func requestMicrophonePermission() async throws { - let granted = await AVCaptureDevice.requestAccess(for: .audio) - if !granted { - throw PermissionError.microphoneDenied - } - } - - func checkAccessibilityPermission() -> Bool { - return AXIsProcessTrusted() - } - - func openAccessibilitySettings() { - let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! - NSWorkspace.shared.open(url) - } -} - -// Tests/Services/PermissionServiceTests.swift -class MockPermissionService: PermissionChecker { - var microphoneGranted = false - var accessibilityGranted = false - - func checkMicrophonePermission() async -> Bool { - return microphoneGranted - } - - func requestMicrophonePermission() async throws { - if !microphoneGranted { - throw PermissionError.microphoneDenied - } - } - - func checkAccessibilityPermission() -> Bool { - return accessibilityGranted - } - - func openAccessibilitySettings() { - // No-op in tests - } -} -``` - ---- - -## Actor-Based Concurrency - -Use Swift actors for thread-safe access to shared mutable state: - -```swift -// Services/FluidAudioService.swift -import FluidAudio - -actor FluidAudioService { - private var asrManager: AsrManager? - private var isInitialized = false - - func initialize(modelPath: String) async throws { - let config = ASRConfig(modelPath: modelPath) - asrManager = AsrManager(config: config) - isInitialized = true - } - - func transcribe(audioData: Data) async throws -> String { - guard isInitialized, let manager = asrManager else { - throw FluidAudioError.notInitialized - } - - // Thread-safe access to asrManager - let result = manager.process(audioData) - return result.text - } -} - -// Usage from @MainActor context -@MainActor -class RecordingViewModel: Observable { - private let fluidAudioService: FluidAudioService - - func processAudio(_ data: Data) async { - do { - // await needed to cross actor boundary - let text = try await fluidAudioService.transcribe(audioData: data) - self.transcribedText = text - } catch { - self.error = error - } - } -} -``` - ---- - -## SwiftUI + AppKit Integration - -### Menu Bar Application Pattern - -```swift -// SpeechToTextApp.swift -import SwiftUI - -@main -struct SpeechToTextApp: App { - @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - @State private var appState = AppState() - - var body: some Scene { - MenuBarExtra("Speech-to-Text", systemImage: "mic.fill") { - MenuBarView() - .environment(appState) - } - .menuBarExtraStyle(.window) - } -} - -// AppDelegate.swift -import AppKit -import SwiftUI - -class AppDelegate: NSObject, NSApplicationDelegate { - private var hotkeyService: HotkeyService? - private var recordingWindow: NSWindow? - - func applicationDidFinishLaunching(_ notification: Notification) { - // Prevent multiple instances - if NSRunningApplication.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier!).count > 1 { - NSApp.terminate(nil) - return - } - - // Setup global hotkey - hotkeyService = HotkeyService() - try? hotkeyService?.register { [weak self] in - await self?.showRecordingModal() - } - } - - @MainActor - private func showRecordingModal() { - guard recordingWindow == nil else { return } - - let contentView = RecordingModal( - viewModel: RecordingViewModel(), - onDismiss: { [weak self] in - self?.recordingWindow?.close() - self?.recordingWindow = nil - } - ) - - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), - styleMask: [.borderless, .fullSizeContentView], - backing: .buffered, - defer: false - ) - - window.contentView = NSHostingView(rootView: contentView) - window.backgroundColor = .clear - window.isOpaque = false - window.level = .floating - window.center() - window.makeKeyAndOrderFront(nil) - - recordingWindow = window - } -} -``` - -### SwiftUI View with @Observable - -```swift -// Views/RecordingViewModel.swift -import Observation - -@Observable @MainActor -class RecordingViewModel { - // MARK: - State - enum State { - case idle - case recording - case transcribing - case completed - case error(Error) - } - - var state: State = .idle - var audioLevel: Double = 0.0 - var transcribedText: String = "" - var confidenceScore: Double = 0.0 - - // MARK: - Services (injected) - private let audioCaptureService: AudioCaptureService - private let fluidAudioService: FluidAudioService - private let textInsertionService: TextInsertionService - - init( - audioCaptureService: AudioCaptureService = AudioCaptureService(), - fluidAudioService: FluidAudioService = FluidAudioService(), - textInsertionService: TextInsertionService = TextInsertionService() - ) { - self.audioCaptureService = audioCaptureService - self.fluidAudioService = fluidAudioService - self.textInsertionService = textInsertionService - } - - // MARK: - Actions - func startRecording() async { - state = .recording - - do { - try await audioCaptureService.startCapture { [weak self] level in - self?.audioLevel = level - } - } catch { - state = .error(error) - } - } - - func stopRecording() async { - state = .transcribing - - do { - let audioData = try await audioCaptureService.stopCapture() - let text = try await fluidAudioService.transcribe(audioData: audioData) - transcribedText = text - - try await textInsertionService.insertText(text) - state = .completed - } catch { - state = .error(error) - } - } -} - -// Views/RecordingModal.swift -import SwiftUI - -struct RecordingModal: View { - @State var viewModel: RecordingViewModel - let onDismiss: () -> Void - - var body: some View { - VStack(spacing: 24) { - // Waveform visualization - WaveformView(audioLevel: viewModel.audioLevel) - .frame(height: 100) - - // Status text - Text(statusText) - .font(.headline) - - // Transcribed text (if available) - if !viewModel.transcribedText.isEmpty { - Text(viewModel.transcribedText) - .font(.body) - .foregroundStyle(.secondary) - } - - // Action button - Button(action: handleAction) { - Text(buttonTitle) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - } - .padding(32) - .frame(width: 480, height: 320) - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .onAppear { - Task { await viewModel.startRecording() } - } - } - - private var statusText: String { - switch viewModel.state { - case .idle: return "Ready" - case .recording: return "Recording..." - case .transcribing: return "Transcribing..." - case .completed: return "Done!" - case .error(let error): return "Error: \(error.localizedDescription)" - } - } - - private var buttonTitle: String { - switch viewModel.state { - case .recording: return "Stop Recording" - case .completed: return "Close" - default: return "Cancel" - } - } - - private func handleAction() { - Task { - switch viewModel.state { - case .recording: - await viewModel.stopRecording() - default: - onDismiss() - } - } - } -} -``` - ---- - -## Error Handling - -```swift -// Models/Errors.swift -enum RecordingError: LocalizedError { - case permissionDenied - case audioCaptureFailed - case transcriptionFailed - case invalidStateTransition(from: RecordingSession.State, to: RecordingSession.State) - - var errorDescription: String? { - switch self { - case .permissionDenied: - return "Microphone permission is required" - case .audioCaptureFailed: - return "Failed to capture audio" - case .transcriptionFailed: - return "Failed to transcribe audio" - case .invalidStateTransition(let from, let to): - return "Cannot transition from \(from) to \(to)" - } - } - - var recoverySuggestion: String? { - switch self { - case .permissionDenied: - return "Please grant microphone access in System Settings" - case .audioCaptureFailed: - return "Check your microphone connection and try again" - case .transcriptionFailed: - return "Try recording again with clearer audio" - case .invalidStateTransition: - return "Please restart the recording session" - } - } -} - -// Usage -do { - try await session.start() -} catch let error as RecordingError { - print("Recording error: \(error.localizedDescription)") - if let suggestion = error.recoverySuggestion { - print("Suggestion: \(suggestion)") - } -} catch { - print("Unexpected error: \(error)") -} -``` - ---- - -## Design Aesthetic: "Warm Minimalism" - -### Color Palette - -```swift -// Utilities/Extensions/Color+Theme.swift -import SwiftUI - -extension Color { - // Amber palette - static let amberLight = Color(red: 1.0, green: 0.9, blue: 0.7) - static let amberPrimary = Color(red: 1.0, green: 0.75, blue: 0.3) - static let amberBright = Color(red: 1.0, green: 0.6, blue: 0.0) - - // Semantic colors - static let recordingActive = amberBright - static let transcribing = amberPrimary - static let completed = Color.green -} -``` - -### Animation Style - -```swift -// Use spring animations for natural feel -.animation(.spring(response: 0.5, dampingFraction: 0.7), value: state) - -// Example: Recording button -Button("Record") { - startRecording() -} -.scaleEffect(isRecording ? 1.1 : 1.0) -.animation(.spring(response: 0.5, dampingFraction: 0.7), value: isRecording) -``` - -### Material & Effects - -```swift -// Frosted glass modal -VStack { - // Content -} -.background(.ultraThinMaterial) -.clipShape(RoundedRectangle(cornerRadius: 16)) - -// Glow effect for waveform -Circle() - .fill(Color.amberBright) - .shadow(color: Color.amberBright.opacity(0.6), radius: 10) -``` - ---- - -## Code Quality Tools - -### SwiftLint Configuration - -The project uses SwiftLint with the following key rules: - -```yaml -# .swiftlint.yml (summary) -opt_in_rules: - - empty_count - - empty_string - - explicit_init - - first_where - - sorted_imports - - closure_spacing - -line_length: 120 -function_body_length: 50 -type_body_length: 300 -file_length: 500 -``` - -### Pre-commit Hooks - -Git hooks run automatically on commit: -- SwiftLint validation -- Secret detection (Gitleaks) -- YAML/JSON validation -- Large file detection -- Trailing whitespace removal - ---- - -## Testing with XCTest - -### Async Testing - -```swift -func testAsyncOperation() async throws { - // Arrange - let service = MyAsyncService() - - // Act - let result = try await service.performOperation() - - // Assert - XCTAssertEqual(result, expectedValue) -} -``` - -### Actor Testing - -```swift -func testActorIsolatedState() async { - // Arrange - let actorService = MyActorService() - - // Act - await actorService.updateState(newValue: 42) - - // Assert - let state = await actorService.getState() - XCTAssertEqual(state, 42) -} -``` - -### Mock Services - -```swift -class MockFluidAudioService: FluidAudioService { - var transcribeCallCount = 0 - var stubbedTranscription = "Test transcription" - - override func transcribe(audioData: Data) async throws -> String { - transcribeCallCount += 1 - return stubbedTranscription - } -} -``` - ---- - -## Naming Conventions - -| Type | Convention | Example | -|------|-----------|---------| -| Files | PascalCase | `RecordingSession.swift` | -| Classes/Structs | PascalCase | `AudioCaptureService` | -| Protocols | PascalCase | `PermissionChecker` | -| Functions | camelCase | `startRecording()` | -| Variables | camelCase | `audioLevel` | -| Constants | camelCase | `maxRecordingDuration` | -| Enums | PascalCase | `RecordingState` | -| Enum Cases | camelCase | `.recording`, `.completed` | - ---- - -## Common Commands +# AGENTS.md β€” Swift macOS App + Clinical Notes Mode + +A privacy-focused local-first menu-bar speech-to-text app for macOS, in +the middle of being extended into a clinical documentation assistant +for chiropractors. Swift 5.9 language mode, Swift 6.2 compiler, macOS +14+ baseline (development on macOS 26). FluidAudio SDK wrapping +Parakeet v3 for transcription; MLX Swift + Gemma 3 4B-IT (in-process, +bundled) for the clinical-notes LLM layer. No cloud services at any +point β€” all processing is on-device, and the only egress is the +doctor-initiated Cliniko API POST from their Mac to their Cliniko +tenant. + +Work is tracked as GitHub issues in this repo. Two parallel EPICs: + +- **[#19 β€” Testing + Workflow Framework](https://github.com/CloudbrokerAz/mac-speech-to-text/issues/19)** (children #20–#25). Lands first. +- **[#1 β€” Clinical Notes Mode](https://github.com/CloudbrokerAz/mac-speech-to-text/issues/1)** (children #2–#18). Rides on top of #19. + +--- + +## Topic Router + +Load the reference file that matches the task. **Do not load all of +them at once** β€” the whole point of the router is context efficiency. + +| If you are… | Load | +|---|---| +| Writing a new `@Observable` class, actor, or touching `@MainActor` / `nonisolated(unsafe)` | [`.claude/references/concurrency.md`](.claude/references/concurrency.md) | +| Adding or modifying tests (unit, ViewInspector, snapshot, fixtures, tags) | [`.claude/references/testing-conventions.md`](.claude/references/testing-conventions.md) | +| Building or debugging the Cliniko HTTP client, endpoints, retries, errors | [`.claude/references/cliniko-api.md`](.claude/references/cliniko-api.md) | +| Touching anything that sees transcripts, notes, patient data, or writes logs | [`.claude/references/phi-handling.md`](.claude/references/phi-handling.md) | +| Working on the LLM provider, model loading / warmup / fallback | [`.claude/references/mlx-lifecycle.md`](.claude/references/mlx-lifecycle.md) | +| Touching the menu-bar icon, hotkey, recording modal window, or Accessibility text insertion | [`.claude/references/menubar-integration.md`](.claude/references/menubar-integration.md) | + +Component-scoped `AGENTS.md` files will live alongside new +subdirectories as they're created (`Sources/Services/ClinicalNotes/`, +`Sources/Services/Cliniko/`, `Sources/Views/ClinicalNotes/` β€” tracked +in #17). + +--- + +## Correctness Checklist + +Every item here is **always / never**. No "consider" bullets β€” those +belong in PR review, not in the hard-rules file. + +### Concurrency + +- **Always** mark `any SomeActorProtocol` / any actor existential on an + `@Observable` class with `@ObservationIgnored`. +- **Always** run Core Audio / Carbon / `DispatchSource` callbacks from + a `nonisolated` entry, hopping to `@MainActor` via `Task` for any + state mutation. +- **Never** call a `@MainActor` method directly from an audio-thread + callback. +- **Always** use `Actor`-constrained protocols for mockability; actors + cannot be subclassed. +- **Always** pair every new `@Observable` view model with a + ViewInspector render-crash test. + +### Security / PHI + +- **Never** log transcript content, SOAP-note body, patient name / + DOB / contact, or raw Cliniko responses. `OSLog` `privacy: .public` + is reserved for structural values only (status, method, path + template, error-case name). +- **Never** interpolate PHI into `fatalError` / `assertionFailure` / + `preconditionFailure` messages. +- **Never** commit secrets, API keys, `.env` files, or entitlements + with hardcoded team IDs. +- **Never** paste a GitHub PAT, API key, or password into chat β€” the + existing `gh auth status` token is the only one we use. If a user + pastes one anyway, warn and refuse. +- **Always** store Cliniko API keys in Keychain (`SecureStore` protocol) + with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. +- **Always** verify `AuditStore` entries carry metadata only (timestamp, + patient_id, appointment_id, note_id, status, app_version) β€” never + body. + +### Code quality + +- **Never** force-unwrap (`!`) without a justifying comment and a + guard above it. +- **Never** use `try!` outside test code. `try?` only where a `nil` is + a legitimate "missing" signal, not to swallow errors. +- **Never** use `@ObservedObject` / `@StateObject` / `ObservableObject` + β€” this project is on the `@Observable` macro. +- **Always** use `let` unless mutation is genuinely needed. +- **Always** respect SwiftLint `--strict`. The custom rules + `observable_actor_existential_warning` and + `nonisolated_unsafe_warning` are surface the patterns in + [`concurrency.md`](.claude/references/concurrency.md). +- **Always** run `pre-commit run --all-files` before a PR (or let CI + do it β€” it runs diff-scoped hooks on every PR). + +### Testing + +- **Always** add tests with new code β€” see + [`testing-conventions.md`](.claude/references/testing-conventions.md) + for which framework fits which situation. +- **Always** tag new Swift Testing tests with `.fast` / `.slow` / + `.requiresHardware` (from `Tests/SpeechToTextTests/Utilities/TestTags.swift`). +- **Never** add a test that hits the real Keychain, real Cliniko, or + runs a real LLM inference in the default CI path β€” use the + `InMemorySecureStore` / `URLProtocolStub` / `MockLLMProvider` fakes. + Gated-by-env-var golden tests are allowed and run in nightly only. + +### Workflow + +- **Always** comment on the GitHub issue at three checkpoints: + (a) starting β€” plan + branch name, (b) PR opened β€” link + "awaiting CI", + (c) merged β€” PR link + merge commit SHA + one-line summary. +- **Always** post an "unblocked by" comment on downstream issues when + their blocker merges. +- **Always** manually tick EPIC task-list checkboxes when a child + merges β€” GitHub does not auto-tick our format. +- **Always** verify the post-merge `main` CI run + (`gh run list --branch main --limit 3`) before declaring a merge-batch + done. +- **Never** re-litigate locked technical decisions (LLM runtime, model + version v1, persistence strategy, UI entry point, Cliniko scope, + testing stack) without an explicit user ask β€” they live in the + EPIC body and `.claude/CLAUDE.md`. + +### Subagents & code review + +- **Always** pass `model: "opus"` explicitly when calling the `Agent` + tool. The parent session is Opus; several subagent definitions default + to Sonnet and will silently downgrade if you omit the override. This + applies to every `Agent` invocation β€” `Explore`, `general-purpose`, + and every `pr-review-toolkit:*` reviewer. +- **Always** run the three-layer review pipeline on a non-trivial PR: + 1. **Pre-PR (local):** spawn a `pr-review-toolkit:code-reviewer` + subagent with `model: "opus"` over the diff before you push, and + apply/fold in its blockers. For PHI-sensitive or concurrency-heavy + changes also spawn `pr-review-toolkit:silent-failure-hunter` and + (for new types) `pr-review-toolkit:type-design-analyzer` in + parallel. + 2. **Automated (on PR open):** Gemini Code Assist runs automatically + via the GitHub App (see `.gemini/config.yaml` + + `.gemini/styleguide.md`). Its summary + inline comments are + treated as peer review; address them before merging. Re-trigger + with a `/gemini review` comment if the PR materially changed. + 3. **On-demand deep dive:** invoke the `/code-review` skill + (`code-review:code-review`) when the PR is large, touches + multiple subsystems, or has review comments that need triage. + Prefer it over re-running the subagent because it operates on the + PR surface (including comments) rather than the local diff. +- **Never** skip the pre-PR subagent pass on substantive changes. A + `wc -l` >~30 on the diff, or anything touching PHI / concurrency / + HTTP / Keychain, is substantive. + +--- + +## Tech stack quick reference + +| Layer | Technology | +|---|---| +| Language | Swift 5.9 (tools) / 6.2 (compiler) | +| UI | SwiftUI with `@Observable` | +| System | AppKit (menu bar, hotkey, Accessibility) | +| Audio | AVFoundation, 16 kHz mono | +| ASR | FluidAudio β†’ Parakeet v3 | +| LLM (clinical notes) | MLX Swift + Gemma 3 4B-IT (4-bit, bundled) | +| HTTP | URLSession in an actor (Cliniko) | +| Credentials | Keychain via `SecureStore` protocol | +| Testing | XCTest + ViewInspector + Swift Testing + `pointfreeco/swift-snapshot-testing` (scoped) | +| Lint | SwiftLint strict + 2 custom concurrency rules | +| Build | Swift Package Manager | +| CI | GitHub Actions (macOS-14 runner) | + +--- + +## Common commands ```bash -# Development -swift package resolve # Resolve dependencies -swift build # Build project -swift build -c release # Release build -open Package.swift # Open in Xcode - -# Testing -swift test # Run all tests -swift test --parallel # Parallel test execution -swift test --filter TestName # Run specific test - -# Code Quality -swiftlint # Run linter -swiftlint lint --strict # Strict mode (zero tolerance) -swiftlint autocorrect # Auto-fix violations -swiftlint analyze # Deep analysis - -# Git Hooks -pre-commit install # Install hooks -pre-commit run --all-files # Run all hooks manually - -# CI/CD -# GitHub Actions runs automatically -# See .github/workflows/ci.yml -``` - ---- - -## Key Performance Considerations +# Build +swift package resolve +swift build # debug +swift build -c release -- **Hotkey latency**: < 50ms (Carbon APIs) -- **Modal appearance**: Optimize spring animations for 60fps -- **Transcription**: FluidAudio handles ~25ms latency -- **Waveform rendering**: Canvas API for 60fps updates -- **Text insertion**: Accessibility API dependent +# Test +swift test --parallel # fast, most common +swift test --filter SomeTests # iterate +swift test --parallel --enable-code-coverage # match CI shape +./scripts/remote-test.sh # remote Mac via SSH +SWIFT_TEST_EXTRA="--skip-tag requiresHardware" ./scripts/remote-test.sh ---- +# Quality +swiftlint lint --strict +pre-commit run --all-files +pre-commit run --files # scoped -## Privacy & Security +# App bundle (needed for UI tests) +./scripts/build-app.sh +./scripts/build-app.sh --sync # rsync to remote Mac +./scripts/run-ui-tests.sh -- **Local-first**: All transcription happens on-device -- **No network calls**: After model download, fully offline -- **Permission-based**: Requires microphone & accessibility permissions -- **Anonymous statistics**: No PII in usage tracking -- **Sandboxed**: macOS entitlements for security +# GitHub +gh issue list --state open --label epic +gh issue view --comments +gh pr checks --watch --fail-fast +gh run list --branch main --limit 3 # verify post-merge CI +``` --- -## Troubleshooting Common Issues - -### Actor Isolation Errors -**Problem**: "Expression is 'async' but is not marked with 'await'" -**Solution**: Add `await` when crossing actor boundaries - -```swift -// ❌ Wrong -let result = actorService.getValue() - -// βœ… Correct -let result = await actorService.getValue() -``` - -### SwiftUI State Updates -**Problem**: "Publishing changes from background threads" -**Solution**: Use `@MainActor` for UI-bound classes +## Project structure (abbreviated) -```swift -@Observable @MainActor -class ViewModel { - var state: String = "" -} ``` +Sources/ + SpeechToTextApp/ # @main, AppDelegate, AppState + Services/ # Business logic (actors + @MainActor classes) + Models/ # Data structures + Views/ # SwiftUI views + ViewModels + Utilities/ # Extensions, Constants -### Memory Leaks with Closures -**Problem**: Retain cycles with self in closures -**Solution**: Use `[weak self]` or `[unowned self]` +Tests/SpeechToTextTests/ + Utilities/ # URLProtocolStub, InMemorySecureStore, TestTags, exemplars + Fixtures/ # cliniko/, soap/, llm/ β€” test bundle resources + Services/ # service unit tests + Views/ # ViewInspector + crash-detection -```swift -// ❌ Wrong -Task { - self.updateState() -} +UITests/ # XCUITest (pre-push / remote Mac only) -// βœ… Correct -Task { [weak self] in - self?.updateState() -} +.claude/references/ # Topic router loads these on demand +docs/ # Long-form docs (also linked from references/) +scripts/ # build, test, deploy automation ``` --- -## Additional Resources +## Warm Minimalism (design aesthetic) -- **Swift Documentation**: https://docs.swift.org -- **SwiftUI Tutorials**: https://developer.apple.com/tutorials/swiftui -- **Swift Concurrency**: https://docs.swift.org/swift-book/LanguageGuide/Concurrency.html -- **FluidAudio SDK**: (SDK documentation) -- **macOS Human Interface Guidelines**: https://developer.apple.com/design/human-interface-guidelines/macos +Frosted glass modals (`.ultraThinMaterial`), amber palette +(`AmberLight` / `AmberPrimary` / `AmberBright` in +`Utilities/Extensions/Color+Theme.swift`), spring animations +(`response: 0.5, dampingFraction: 0.7`), floating window level for +modals, minimal chrome, content-focused. All new UI adheres. diff --git a/Package.swift b/Package.swift index e9827dd..e0e8d33 100644 --- a/Package.swift +++ b/Package.swift @@ -36,7 +36,9 @@ let package = Package( exclude: [], resources: [ .process("Resources/app_logov2.png"), - .copy("Resources/Models") + .copy("Resources/Models"), + .copy("Resources/Manipulations"), + .copy("Resources/Prompts") ], swiftSettings: [ .enableUpcomingFeature("BareSlashRegexLiterals"), @@ -93,7 +95,10 @@ let package = Package( "SherpaOnnxSwift", .product(name: "ViewInspector", package: "ViewInspector") ], - path: "Tests/SpeechToTextTests" + path: "Tests/SpeechToTextTests", + resources: [ + .copy("Fixtures") + ] ) ] ) diff --git a/Sources/Models/Appointment.swift b/Sources/Models/Appointment.swift new file mode 100644 index 0000000..3639115 --- /dev/null +++ b/Sources/Models/Appointment.swift @@ -0,0 +1,29 @@ +import Foundation + +/// A Cliniko appointment as the picker UI needs to display them. +/// +/// Wire shape: Cliniko's `GET /patients/{id}/appointments` returns numeric +/// `id` and ISO8601 `starts_at` / `ends_at` (datetime with timezone), which +/// `ClinikoClient.defaultDecoder` decodes natively via `.iso8601`. +/// +/// PHI: appointment timing combined with a known patient is PHI. Held in +/// memory only β€” no logging beyond structural fields, no on-disk cache. +public struct Appointment: Decodable, Identifiable, Sendable, Equatable, Hashable { + /// Cliniko numeric appointment ID. Stored as `Int` to match the wire + /// shape; the picker converts it to `String` at the `SessionStore` + /// boundary (`ClinicalSession.selectedAppointmentID`). + public let id: Int + + /// Scheduled start time. Required by Cliniko's schema. + public let startsAt: Date + + /// Scheduled end time. Optional in case Cliniko ever returns an open- + /// ended appointment; today it's always present. + public let endsAt: Date? + + public init(id: Int, startsAt: Date, endsAt: Date? = nil) { + self.id = id + self.startsAt = startsAt + self.endsAt = endsAt + } +} diff --git a/Sources/Models/ClinicalSession.swift b/Sources/Models/ClinicalSession.swift new file mode 100644 index 0000000..099033a --- /dev/null +++ b/Sources/Models/ClinicalSession.swift @@ -0,0 +1,54 @@ +import Foundation + +/// A single clinical consultation session held in memory for the duration +/// of the doctor's review + export cycle. +/// +/// Lifecycle is owned by `SessionStore` (issue #2): +/// - Created from a completed `RecordingSession`. +/// - Mutated as the LLM produces `draftNotes`, the practitioner edits, +/// and a patient + appointment are selected. +/// - Discarded on successful export, quit, or inactivity timeout. +/// +/// **PHI.** Never persisted to disk, UserDefaults, logs, or crash reports. +/// See `.claude/references/phi-handling.md`. +struct ClinicalSession: Sendable, Identifiable { + let id: UUID + + /// The underlying transcript + audio metadata produced by the + /// recording pipeline. + var recordingSession: RecordingSession + + /// SOAP note generated by `ClinicalNotesProcessor` (#5). `nil` while + /// generation is pending or if the LLM fell back to the raw + /// transcript. + var draftNotes: StructuredNotes? + + /// Snippets from `draftNotes.excluded` that the practitioner has + /// re-added to a SOAP section. Tracked here (not in `StructuredNotes`) + /// so the original LLM output stays immutable for audit-trail + /// purposes and the ReviewScreen drawer can hide re-added entries. + var excludedReAdded: [String] + + /// Cliniko patient ID selected for export. Opaque string; not + /// user-visible demographics. + var selectedPatientID: String? + + /// Cliniko appointment ID selected for export. + var selectedAppointmentID: String? + + init( + id: UUID = UUID(), + recordingSession: RecordingSession, + draftNotes: StructuredNotes? = nil, + excludedReAdded: [String] = [], + selectedPatientID: String? = nil, + selectedAppointmentID: String? = nil + ) { + self.id = id + self.recordingSession = recordingSession + self.draftNotes = draftNotes + self.excludedReAdded = excludedReAdded + self.selectedPatientID = selectedPatientID + self.selectedAppointmentID = selectedAppointmentID + } +} diff --git a/Sources/Models/ClinikoCredentials.swift b/Sources/Models/ClinikoCredentials.swift new file mode 100644 index 0000000..b1ef8fc --- /dev/null +++ b/Sources/Models/ClinikoCredentials.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Cliniko API credentials in memory: an opaque API key plus the regional +/// shard. Conforms to `Sendable` so it can cross the actor boundary into the +/// HTTP client; conforms to `Equatable` for tests but the comparison is on +/// the *whole struct*, never on the raw key alone (no equality side channel +/// exposed). +/// +/// PHI: The `apiKey` is a secret. It is **not** publicly readable β€” the only +/// supported accessor is `basicAuthHeaderValue`, which already encodes the +/// secret for HTTP Basic auth. `description` is overridden to redact it. +/// `Equatable` and `init(apiKey:shard:)` are the only places that touch the +/// raw key value, and `init` rejects whitespace-only / empty input so that +/// "valid credentials exist" is guaranteed by the type, not by every caller. +public struct ClinikoCredentials: Sendable, Equatable, CustomStringConvertible { + public enum CredentialsError: Error, Sendable, Equatable, CustomStringConvertible { + case emptyAPIKey + + public var description: String { + switch self { + case .emptyAPIKey: return "ClinikoCredentials: API key is empty" + } + } + } + + /// The raw API key. `internal` so this file's tests can verify trim + /// behaviour, but **not** publicly readable β€” `basicAuthHeaderValue` is + /// the only sanctioned accessor for outside callers. Keeping it + /// `internal let` prevents accidental log interpolation + /// (`"\(creds.apiKey)"`) without burning a `private` scope that this + /// file's tests would have to fight. + let apiKey: String + public let shard: ClinikoShard + + /// Failable initializer that enforces the "non-empty key" invariant at + /// the type boundary. Trims whitespace before storing. Empty / whitespace- + /// only keys throw `.emptyAPIKey`. Callers that already validated input + /// can wrap with `try!` only in tests, never in production. + public init(apiKey: String, shard: ClinikoShard) throws { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw CredentialsError.emptyAPIKey } + self.apiKey = trimmed + self.shard = shard + } + + /// Base URL for `ClinikoClient` requests. Built from `URLComponents` with + /// enum-constrained host segments so the construction cannot fail at + /// runtime; the `preconditionFailure` guard is defence-in-depth that + /// would only fire if a future refactor broke the host-name invariant β€” + /// a programmer bug, not a user error. `ClinikoCredentialsTests` + /// iterates every shard to keep that invariant under test. + public var baseURL: URL { + var components = URLComponents() + components.scheme = "https" + components.host = shard.apiHost + components.path = "/v1/" + guard let url = components.url else { + preconditionFailure("ClinikoCredentials: enum-constrained shard host produced nil URL") + } + return url + } + + /// Value for the HTTP `Authorization` header. Cliniko's auth scheme is + /// HTTP Basic with the API key as the username and an empty password. + /// See: https://docs.api.cliniko.com/#authentication + public var basicAuthHeaderValue: String { + let token = "\(apiKey):" + let data = Data(token.utf8) + return "Basic \(data.base64EncodedString())" + } + + /// Custom description that never echoes the API key β€” protects logs and + /// any accidental string interpolation. + public var description: String { + "ClinikoCredentials(shard: \(shard.rawValue), apiKey: )" + } +} diff --git a/Sources/Models/ClinikoPagination.swift b/Sources/Models/ClinikoPagination.swift new file mode 100644 index 0000000..b5d1556 --- /dev/null +++ b/Sources/Models/ClinikoPagination.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Cliniko paginates list endpoints with a `{ , total_entries, links }` +/// envelope. The picker UI for #9 only needs the first page, so the wrappers +/// surface the items directly and expose `links` / `totalEntries` for any +/// future paginate-helper. No follow-up `links.next` fetch is wired today β€” +/// adding one is a one-line change at the service layer. +public struct PatientSearchResponse: Decodable, Sendable, Equatable { + public let patients: [Patient] + public let totalEntries: Int? + public let links: ClinikoPaginationLinks? + + public init( + patients: [Patient], + totalEntries: Int? = nil, + links: ClinikoPaginationLinks? = nil + ) { + self.patients = patients + self.totalEntries = totalEntries + self.links = links + } +} + +public struct AppointmentListResponse: Decodable, Sendable, Equatable { + public let appointments: [Appointment] + public let totalEntries: Int? + public let links: ClinikoPaginationLinks? + + public init( + appointments: [Appointment], + totalEntries: Int? = nil, + links: ClinikoPaginationLinks? = nil + ) { + self.appointments = appointments + self.totalEntries = totalEntries + self.links = links + } +} + +/// `links` envelope shared by every Cliniko list endpoint. We don't follow +/// `next` today β€” keep the type loose (`URL?`) so a future paginate helper +/// can opt in without a model change. Cliniko also returns a `self` link; +/// it's intentionally not modelled here because including it would require +/// either a backtick-quoted keyword property (Swift 6 makes this a syntax +/// pain in `init`) or a renamed key strategy. Add it when we actually need +/// it. +public struct ClinikoPaginationLinks: Decodable, Sendable, Equatable { + public let next: URL? + + public init(next: URL? = nil) { + self.next = next + } +} diff --git a/Sources/Models/Manipulation.swift b/Sources/Models/Manipulation.swift new file mode 100644 index 0000000..49c7ead --- /dev/null +++ b/Sources/Models/Manipulation.swift @@ -0,0 +1,36 @@ +import Foundation + +/// A single chiropractic manipulation technique in the app's taxonomy. +/// +/// Loaded from a bundled JSON resource via `ManipulationsRepository` +/// (issue #6). v1 ships with a seven-entry placeholder list; the real +/// Cliniko manipulation-codes taxonomy will replace the JSON file once +/// the practitioner supplies it β€” no code changes required. +/// +/// Fields: +/// - `id` β€” stable string identifier referenced from +/// `StructuredNotes.selectedManipulationIDs` and the Cliniko export +/// mapping (#10). Matching is by `id`, not by `displayName`. +/// - `displayName` β€” practitioner-facing label rendered in the +/// ReviewScreen manipulation checklist (#13). +/// - `clinikoCode` β€” optional code emitted into the Cliniko +/// `treatment_note` payload once the real taxonomy lands. `nil` for +/// every entry in the v1 placeholder list. +/// +/// JSON shape (snake_case to match the file shipped from Cliniko): +/// ```json +/// { "id": "diversified_hvla", "display_name": "Diversified HVLA", "cliniko_code": null } +/// ``` +/// +/// Not PHI β€” this is a static taxonomy, never patient data. +struct Manipulation: Codable, Sendable, Identifiable, Equatable, Hashable { + let id: String + let displayName: String + let clinikoCode: String? + + private enum CodingKeys: String, CodingKey { + case id + case displayName = "display_name" + case clinikoCode = "cliniko_code" + } +} diff --git a/Sources/Models/Patient.swift b/Sources/Models/Patient.swift new file mode 100644 index 0000000..c3667cd --- /dev/null +++ b/Sources/Models/Patient.swift @@ -0,0 +1,55 @@ +import Foundation + +/// A Cliniko patient as the picker UI needs to display them. +/// +/// Wire shape: Cliniko's `GET /patients` returns numeric `id`, snake-cased +/// fields, and `date_of_birth` in `YYYY-MM-DD` (calendar date β€” *not* the +/// `iso8601` datetime that `ClinikoClient.defaultDecoder` is configured for). +/// We therefore decode `dateOfBirth` as a `String?` and let the view format +/// it; the alternative (a per-endpoint custom date strategy) buys nothing +/// for the picker, which only renders the field, never reasons about it. +/// +/// PHI: every field on this struct is patient data. The picker holds it in +/// memory only β€” no logging, no `UserDefaults`, no on-disk cache. See +/// `.claude/references/phi-handling.md`. +public struct Patient: Decodable, Identifiable, Sendable, Equatable, Hashable { + /// Cliniko numeric patient ID. Stored as `Int` to match the wire shape; + /// the picker converts it to `String` at the `SessionStore` boundary + /// (`ClinicalSession.selectedPatientID` is opaque-string-typed). + public let id: Int + + /// Patient's first / given name. Required by Cliniko's schema; we still + /// guard against `null` by decoding via the optional initializer, but a + /// missing first-name is exceedingly rare in practice. + public let firstName: String + + /// Patient's last / family name. + public let lastName: String + + /// Date of birth in `YYYY-MM-DD` form (Cliniko's documented shape) or + /// `nil` if the practitioner hasn't recorded one. Kept as a `String` β€” + /// see the type-level note above. + public let dateOfBirth: String? + + /// Best-effort primary contact for the picker row. Cliniko exposes a + /// patient's primary email at the top level (`email`); we display this + /// rather than digging into `patient_phone_numbers` which is a separate, + /// nested array per the API. A future iteration can extend this to a + /// computed `primaryContact: String?` once the wire shape is pinned by + /// real fixtures. + public let email: String? + + public init( + id: Int, + firstName: String, + lastName: String, + dateOfBirth: String? = nil, + email: String? = nil + ) { + self.id = id + self.firstName = firstName + self.lastName = lastName + self.dateOfBirth = dateOfBirth + self.email = email + } +} diff --git a/Sources/Models/RawLLMDraft.swift b/Sources/Models/RawLLMDraft.swift new file mode 100644 index 0000000..59a25d0 --- /dev/null +++ b/Sources/Models/RawLLMDraft.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Byte-for-byte mirror of the JSON the local LLM returns for a clinical +/// note, per the contract locked in EPIC #1. +/// +/// **Not the UI model.** `ReviewScreen` (#13) binds to `StructuredNotes`, +/// which uses `selectedManipulationIDs: [String]` β€” stable ids from +/// `ManipulationsRepository`. The `RawLLMDraft β†’ StructuredNotes` +/// mapping resolves the LLM's free-text `manipulations[].name` back to +/// an `id` via the taxonomy; that mapping lives in +/// `ClinicalNotesProcessor` (#5), not here. +/// +/// **All PHI.** SOAP strings, excluded snippets, and manipulation names +/// can all contain transcript content. Never log, persist, or serialise +/// instances of this type outside the live session. See +/// `.claude/references/phi-handling.md`. +struct RawLLMDraft: Codable, Sendable, Equatable { + let subjective: String + let objective: String + let assessment: String + let plan: String + let manipulations: [SuggestedManipulation] + let excludedContent: [String] + + /// A single manipulation the LLM believes was performed, matched + /// back to the taxonomy by `name` in `ClinicalNotesProcessor` (#5). + struct SuggestedManipulation: Codable, Sendable, Equatable { + let name: String + /// Model-reported confidence. Contract: `[0.0, 1.0]`. + /// `ClinicalNotesPromptBuilder.validate(json:)` rejects values + /// outside that range. + let confidence: Double + } + + private enum CodingKeys: String, CodingKey { + case subjective, objective, assessment, plan, manipulations + case excludedContent = "excluded_content" + } +} diff --git a/Sources/Models/StructuredNotes.swift b/Sources/Models/StructuredNotes.swift new file mode 100644 index 0000000..eb58893 --- /dev/null +++ b/Sources/Models/StructuredNotes.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Structured clinical note produced by the local LLM from a consultation +/// transcript. +/// +/// **Status:** minimal scaffold to unblock `SessionStore` (issue #2). +/// The authoritative prompt + parser + JSON schema are owned by: +/// - #4 `ClinicalNotesPromptBuilder` β€” SOAP prompt + JSON schema guard. +/// - #5 `ClinicalNotesProcessor` β€” transcript β†’ `StructuredNotes`. +/// +/// Fields here match the locked ReviewScreen wireframe (#13): four SOAP +/// sections, a list of selected manipulation IDs, and the "excluded" +/// content the LLM deliberately dropped from the final note (small talk, +/// unrelated tangents) so the practitioner can re-add anything worth +/// keeping. Extend here β€” do not rename β€” so #2's `ClinicalSession.draftNotes` +/// field survives #4/#5 landing without a downstream refactor. +/// +/// All PHI. Never serialised to disk, never logged. See +/// `.claude/references/phi-handling.md`. +struct StructuredNotes: Sendable, Equatable { + var subjective: String + var objective: String + var assessment: String + var plan: String + + /// Manipulation IDs selected for the treatment_note (refers to the + /// placeholder taxonomy in #6 / `ManipulationsRepository`). + var selectedManipulationIDs: [String] + + /// Snippets the LLM excluded from the SOAP note. Displayed in the + /// ReviewScreen excluded drawer; each entry can be re-added to a SOAP + /// section by the practitioner. + var excluded: [String] + + init( + subjective: String = "", + objective: String = "", + assessment: String = "", + plan: String = "", + selectedManipulationIDs: [String] = [], + excluded: [String] = [] + ) { + self.subjective = subjective + self.objective = objective + self.assessment = assessment + self.plan = plan + self.selectedManipulationIDs = selectedManipulationIDs + self.excluded = excluded + } +} diff --git a/Sources/Resources/Manipulations/placeholder.json b/Sources/Resources/Manipulations/placeholder.json new file mode 100644 index 0000000..bbc0bfd --- /dev/null +++ b/Sources/Resources/Manipulations/placeholder.json @@ -0,0 +1,9 @@ +[ + { "id": "diversified_hvla", "display_name": "Diversified HVLA", "cliniko_code": null }, + { "id": "gonstead", "display_name": "Gonstead", "cliniko_code": null }, + { "id": "activator", "display_name": "Activator", "cliniko_code": null }, + { "id": "thompson_drop", "display_name": "Thompson Drop", "cliniko_code": null }, + { "id": "sacro_occipital_technique", "display_name": "Sacro-Occipital Technique (SOT)", "cliniko_code": null }, + { "id": "toggle_recoil", "display_name": "Toggle Recoil", "cliniko_code": null }, + { "id": "mobilisation_non_hvla", "display_name": "Mobilisation (non-HVLA)", "cliniko_code": null } +] diff --git a/Sources/Resources/Prompts/soap_v1.txt b/Sources/Resources/Prompts/soap_v1.txt new file mode 100644 index 0000000..b9f5fc6 --- /dev/null +++ b/Sources/Resources/Prompts/soap_v1.txt @@ -0,0 +1,29 @@ +You are a clinical documentation drafting assistant for a chiropractor. +You are a drafting assistant, not a diagnostic tool; defer all clinical judgement to the reviewing practitioner. + +Task: Convert the CONSULTATION TRANSCRIPT below into a structured SOAP note for a chiropractic appointment, and identify which manipulation techniques (if any) from the AVAILABLE MANIPULATIONS list were performed. + +Output rules: +- Return ONLY a single valid JSON object matching the schema below. No prose outside the JSON object. No markdown code fences. +- Strip small talk, social pleasantries, unrelated tangents, and any content that does not belong in a clinical note. Return those excluded snippets verbatim in "excluded_content" so the reviewing practitioner can re-add anything worth keeping. +- "manipulations" must reference techniques from AVAILABLE MANIPULATIONS by their "name" exactly as listed. Include a confidence score in the closed interval [0.0, 1.0]. Do not invent techniques that are not on the list. +- If the transcript does not describe any manipulation, return "manipulations": []. +- Empty SOAP sections are acceptable when the transcript genuinely does not contain that information β€” return "" (not null) for the section. + +JSON schema: +{ + "subjective": "string", + "objective": "string", + "assessment": "string", + "plan": "string", + "manipulations": [{ "name": "string", "confidence": 0.0 }], + "excluded_content": ["string"] +} + +AVAILABLE MANIPULATIONS: +{{manipulations_list}} + +CONSULTATION TRANSCRIPT: +{{transcript}} + +JSON output: diff --git a/Sources/Services/AudioCaptureService.swift b/Sources/Services/AudioCaptureService.swift index 9996e53..518342a 100644 --- a/Sources/Services/AudioCaptureService.swift +++ b/Sources/Services/AudioCaptureService.swift @@ -233,34 +233,46 @@ class AudioCaptureService { return } - // Get the Core Audio device ID from the AVCaptureDevice - // AVCaptureDevice's uniqueID for audio devices corresponds to the Core Audio device UID + // Get the Core Audio device ID from the AVCaptureDevice. + // AVCaptureDevice's uniqueID for audio devices corresponds to the + // Core Audio device UID, and `kAudioHardwarePropertyDeviceForUID` + // translates a `CFStringRef` UID into an `AudioDeviceID`. + // + // We explicitly scope every pointer we hand to `AudioValueTranslation` + // with nested `withUnsafeMutablePointer(to:)`. `AudioValueTranslation` + // holds raw pointers, which under the old `&deviceUID` / `&deviceId` + // inout syntax outlived their guaranteed validity window β€” the + // Swift 6 compiler warned this was "likely incorrect because + // 'CFString' may contain an object reference" (see issue #40). The + // closure form pins the storage across the Core Audio call site. var deviceId: AudioDeviceID = 0 - var deviceIdSize = UInt32(MemoryLayout.size) + let deviceIdSize = UInt32(MemoryLayout.size) var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDeviceForUID, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain ) - - // Create a CFString from the device UID var deviceUID: CFString = selectedDevice.uniqueID as CFString - var translation = AudioValueTranslation( - mInputData: &deviceUID, - mInputDataSize: UInt32(MemoryLayout.size), - mOutputData: &deviceId, - mOutputDataSize: UInt32(MemoryLayout.size) - ) - var translationSize = UInt32(MemoryLayout.size) - let status = AudioObjectGetPropertyData( - AudioObjectID(kAudioObjectSystemObject), - &address, - 0, - nil, - &translationSize, - &translation - ) + let status = withUnsafeMutablePointer(to: &deviceUID) { uidPtr in + withUnsafeMutablePointer(to: &deviceId) { idPtr in + var translation = AudioValueTranslation( + mInputData: UnsafeMutableRawPointer(uidPtr), + mInputDataSize: UInt32(MemoryLayout.size), + mOutputData: UnsafeMutableRawPointer(idPtr), + mOutputDataSize: UInt32(MemoryLayout.size) + ) + var translationSize = UInt32(MemoryLayout.size) + return AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &translationSize, + &translation + ) + } + } guard status == noErr, deviceId != 0 else { AppLogger.warning( diff --git a/Sources/Services/ClinicalNotesProcessor.swift b/Sources/Services/ClinicalNotesProcessor.swift new file mode 100644 index 0000000..c5663a5 --- /dev/null +++ b/Sources/Services/ClinicalNotesProcessor.swift @@ -0,0 +1,244 @@ +import Foundation + +/// Orchestrates the transcript β†’ `StructuredNotes` pipeline. +/// +/// **Issue #5.** Wires three previously-landed pieces: +/// - `ClinicalNotesPromptBuilder` (#4) for prompt assembly + JSON +/// schema validation, +/// - `LLMProvider` (#3 protocol slice) for local inference, +/// - `ManipulationsRepository` (#6) for the taxonomy the prompt +/// enumerates and the response maps back to. +/// +/// The concrete `LLMProvider` will be `MLXGemmaProvider` once the +/// MLX-Swift concrete implementation lands (same ticket, follow-up +/// PR). Tests exercise this actor against `MockLLMProvider`. +/// +/// ### Retry-once contract +/// On a `SchemaError` from the first response, the processor issues a +/// second `generate` call with a short "fix the JSON" prompt that +/// quotes the invalid output and restates the schema requirement. The +/// quote is load-bearing: deterministic defaults (`temperature: 0`, +/// fixed `seed`) would otherwise reproduce the same invalid output, +/// making naive retries pointless. Including the prior bad response in +/// the prompt perturbs the context enough for the model to attempt a +/// correction. +/// +/// ### Fallback contract +/// Any path that doesn't yield a valid `StructuredNotes` resolves to +/// `.rawTranscriptFallback(reason:)` so the ReviewScreen (#13) can +/// surface the raw transcript and the practitioner's work is never +/// lost. The `reason` is a structural sentinel β€” **never** a PHI- +/// bearing error message. See `.claude/references/phi-handling.md`. +/// +/// ### PHI +/// `transcript`, the prompt derived from it, and the LLM response all +/// contain patient data. They flow through this actor in-memory only +/// and are never logged or persisted. Error messages from the LLM +/// provider can contain input fragments; the processor therefore +/// refuses to interpolate a caught error's text into the fallback +/// reason β€” only the structural tag `"llm_error"` is returned. +actor ClinicalNotesProcessor { + /// Result of a single `process(transcript:)` call. + enum Outcome: Sendable, Equatable { + /// A schema-valid SOAP note was produced (possibly after one + /// retry). The payload is ready for `SessionStore.setDraftNotes`. + case success(StructuredNotes) + /// The pipeline could not produce a valid note. The caller + /// should render the raw transcript and let the practitioner + /// compose the note manually. `reason` is a structural sentinel + /// for telemetry / UI branching β€” never PHI. + case rawTranscriptFallback(reason: String) + } + + /// Structural sentinel emitted when any `LLMProvider.generate` call + /// throws. Deliberately opaque β€” provider error descriptions can + /// quote input tokens, so we discard the caught error's text. + static let reasonLLMError = "llm_error" + + /// Structural sentinel emitted when both the first and the retry + /// responses fail schema validation. + static let reasonInvalidJSONAfterRetry = "invalid_json_after_retry" + + // MARK: - Dependencies + + private let provider: any LLMProvider + private let promptBuilder: ClinicalNotesPromptBuilder + private let manipulations: ManipulationsRepository + private let options: LLMOptions + + init( + provider: any LLMProvider, + promptBuilder: ClinicalNotesPromptBuilder, + manipulations: ManipulationsRepository, + options: LLMOptions = LLMOptions() + ) { + self.provider = provider + self.promptBuilder = promptBuilder + self.manipulations = manipulations + self.options = options + } + + // MARK: - Pipeline + + /// Drive a transcript through the LLM and parse the response. + /// + /// Never throws. All failure modes resolve to + /// `.rawTranscriptFallback(reason:)`. + func process(transcript: String) async -> Outcome { + let initialPrompt = promptBuilder.buildPrompt(transcript: transcript) + + let firstResponse: String + do { + firstResponse = try await provider.generate( + prompt: initialPrompt, + options: options + ) + } catch { + logLLMError(error, attempt: 1) + return .rawTranscriptFallback(reason: Self.reasonLLMError) + } + + switch promptBuilder.validate(json: firstResponse) { + case .success(let draft): + return .success(map(draft)) + case .failure(let schemaError): + // Record the case tag only β€” structural, not PHI. See + // `.claude/references/phi-handling.md`; SchemaError + // payloads are keyPaths + case names + numeric scores only. + logSchemaError(schemaError, attempt: 1) + } + + let retryPrompt = buildRetryPrompt( + originalPrompt: initialPrompt, + badResponse: firstResponse + ) + + let secondResponse: String + do { + secondResponse = try await provider.generate( + prompt: retryPrompt, + options: options + ) + } catch { + logLLMError(error, attempt: 2) + return .rawTranscriptFallback(reason: Self.reasonLLMError) + } + + switch promptBuilder.validate(json: secondResponse) { + case .success(let draft): + return .success(map(draft)) + case .failure(let schemaError): + logSchemaError(schemaError, attempt: 2) + return .rawTranscriptFallback( + reason: Self.reasonInvalidJSONAfterRetry + ) + } + } + + // MARK: - Logging (structural only β€” no PHI) + + /// Record the Swift type name of a caught LLM error. `type(of:)` + /// is the *class* of error (e.g. `URLError`, `MLXError`), never the + /// `localizedDescription`, which can quote input tokens. + private nonisolated func logLLMError(_ error: any Error, attempt: Int) { + let kind = String(describing: type(of: error)) + AppLogger.service.error( + "ClinicalNotesProcessor: llm_error attempt=\(attempt, privacy: .public) kind=\(kind, privacy: .public)" + ) + } + + /// Record the SchemaError case tag. `String(describing:)` on a + /// `SchemaError` prints the case and its structural payload + /// (keyPaths, numeric scores) β€” deliberately designed to be + /// PHI-safe per #4. + private nonisolated func logSchemaError(_ error: SchemaError, attempt: Int) { + AppLogger.service.warning( + "ClinicalNotesProcessor: schema_invalid attempt=\(attempt, privacy: .public) kind=\(String(describing: error), privacy: .public)" + ) + } + + // MARK: - Retry prompt + + /// Build the second-attempt prompt. Re-sending the original prompt + /// against a deterministic provider would reproduce the same + /// invalid output; quoting the bad response and restating the + /// requirement perturbs the model enough to attempt a correction. + private nonisolated func buildRetryPrompt( + originalPrompt: String, + badResponse: String + ) -> String { + """ + \(originalPrompt) + + Your previous response could not be parsed as a JSON object + matching the schema above: + + --- + \(badResponse) + --- + + Return ONLY the JSON object. No Markdown code fences, no + commentary before or after the object, no prose. Keep the same + content; correct the structural error. + """ + } + + // MARK: - Draft β†’ StructuredNotes mapping + + /// Resolve a `RawLLMDraft` to a `StructuredNotes`. SOAP strings + /// pass through verbatim; `manipulations[].name` is matched back + /// to a taxonomy id (see `resolveManipulationID`); unmatchable + /// names are silently dropped so the practitioner re-adds them + /// from the ReviewScreen checklist. Order is preserved; duplicate + /// ids are removed. + private nonisolated func map(_ draft: RawLLMDraft) -> StructuredNotes { + var seen: Set = [] + var ids: [String] = [] + for suggested in draft.manipulations { + guard let resolved = resolveManipulationID(for: suggested.name) else { + continue + } + guard seen.insert(resolved).inserted else { continue } + ids.append(resolved) + } + + return StructuredNotes( + subjective: draft.subjective, + objective: draft.objective, + assessment: draft.assessment, + plan: draft.plan, + selectedManipulationIDs: ids, + excluded: draft.excludedContent + ) + } + + /// Match an LLM-returned manipulation `name` back to a taxonomy id. + /// + /// Permissive: the prompt enumerates each manipulation as + /// `"- id: , name: "`, so the model can legitimately + /// emit either form. Matching is case-insensitive after trimming + /// surrounding whitespace. Id match wins if both happen to apply. + /// Returns `nil` if neither form matches β€” the unmatchable entry + /// is then dropped by `map`. + private nonisolated func resolveManipulationID( + for name: String + ) -> String? { + let key = name + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !key.isEmpty else { return nil } + + // Single pass; id match wins if it appears anywhere in the + // list. Remember the first displayName match as a fallback. + var nameMatch: String? + for manipulation in manipulations.all { + if manipulation.id.lowercased() == key { + return manipulation.id + } + if nameMatch == nil, manipulation.displayName.lowercased() == key { + nameMatch = manipulation.id + } + } + return nameMatch + } +} diff --git a/Sources/Services/ClinicalNotesPromptBuilder.swift b/Sources/Services/ClinicalNotesPromptBuilder.swift new file mode 100644 index 0000000..464d4c2 --- /dev/null +++ b/Sources/Services/ClinicalNotesPromptBuilder.swift @@ -0,0 +1,292 @@ +import Foundation + +/// Assembles the prompt sent to the local LLM, and validates the JSON +/// returned against the contract locked in EPIC #1. +/// +/// **Issue #4.** Pure-logic service: no PHI persistence, no HTTP, no +/// actor isolation. Transcript content passes through `buildPrompt` and +/// LLM response text passes through `validate` in-memory only. +/// +/// **Template.** `Resources/Prompts/soap_v1.txt`, loadable so the prompt +/// can be tuned without recompiling the app. Two placeholders: +/// `{{manipulations_list}}` (rendered from `ManipulationsRepository`) and +/// `{{transcript}}`. +/// +/// **Validation.** `validate(json:)` is forgiving about cosmetic wrapping +/// (whitespace, code fences, trailing commentary) and strict about the +/// schema β€” a response that does not decode to `RawLLMDraft` or whose +/// `manipulations[].confidence` falls outside `[0, 1]` is rejected with a +/// typed `SchemaError`. +struct ClinicalNotesPromptBuilder: Sendable { + let template: String + let manipulations: ManipulationsRepository + + /// Load the bundled prompt template. + /// + /// - Throws: `ClinicalNotesPromptBuilderError.templateNotFound` if + /// the named template is missing from the bundle. + static func loadFromBundle( + _ bundle: Bundle = .module, + templateResource: String = "soap_v1", + templateSubdirectory: String = "Prompts", + manipulations: ManipulationsRepository + ) throws -> ClinicalNotesPromptBuilder { + guard let url = bundle.url( + forResource: templateResource, + withExtension: "txt", + subdirectory: templateSubdirectory + ) else { + throw ClinicalNotesPromptBuilderError.templateNotFound( + resource: templateResource, + subdirectory: templateSubdirectory + ) + } + let template = try String(contentsOf: url, encoding: .utf8) + return ClinicalNotesPromptBuilder( + template: template, + manipulations: manipulations + ) + } + + // MARK: - Prompt assembly + + /// Substitute the template placeholders with the live taxonomy and + /// the supplied transcript. The manipulations list is rendered as + /// `- id: , name: ` lines so the LLM can map its + /// free-text `name` output back to a stable id downstream. + /// + /// Substitution order is load-bearing: `{{manipulations_list}}` is + /// replaced first, then `{{transcript}}`. Doing manipulations first + /// means a transcript that literally contains the string + /// `{{manipulations_list}}` (or `{{transcript}}`) is inserted + /// verbatim without re-triggering substitution. + func buildPrompt(transcript: String) -> String { + let list = manipulations.all + .map { "- id: \($0.id), name: \($0.displayName)" } + .joined(separator: "\n") + return template + .replacingOccurrences(of: "{{manipulations_list}}", with: list) + .replacingOccurrences(of: "{{transcript}}", with: transcript) + } + + // MARK: - Response validation + + /// Parse and validate the LLM's JSON response. + /// + /// Tolerates: leading / trailing whitespace, ```` ```json … ``` ```` + /// and bare ```` ``` … ``` ```` code fences, and trailing commentary + /// after the final closing brace. + /// + /// Rejects: + /// - empty or whitespace-only input β†’ `.emptyInput` + /// - input with no recognisable JSON object β†’ `.noJSONFound` + /// - JSON that doesn't decode to `RawLLMDraft` β†’ `.decodingFailed` + /// - any `manipulations[].confidence` outside `[0, 1]` + /// β†’ `.confidenceOutOfRange(name:value:)` + func validate(json: String) -> Result { + let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return .failure(.emptyInput) + } + + let unfenced = Self.stripCodeFence(trimmed) + + guard let object = Self.firstJSONObject(in: unfenced) else { + return .failure(.noJSONFound) + } + + do { + let draft = try JSONDecoder().decode( + RawLLMDraft.self, + from: Data(object.utf8) + ) + if let offender = draft.manipulations.enumerated().first(where: { _, manipulation in + manipulation.confidence < 0 || manipulation.confidence > 1 + }) { + return .failure(.confidenceOutOfRange( + keyPath: "manipulations.\(offender.offset).confidence", + value: offender.element.confidence + )) + } + return .success(draft) + } catch { + return .failure(.decodingFailed(Self.redact(error))) + } + } + + /// Map `DecodingError` into a PHI-safe structural description. Only + /// schema-level context is preserved β€” never the offending value. + /// + /// `DecodingError`'s default `debugDescription` can quote the value + /// that failed to decode (e.g. "Expected Double but found a String + /// instead: \"high\""). If that value ever came from a transcript- + /// derived SOAP field, interpolating the raw error description into a + /// log or telemetry line would leak PHI. By capturing only the + /// `codingPath` we keep the diagnostic useful while staying within + /// the rules in `.claude/references/phi-handling.md`. + private static func redact(_ error: any Error) -> DecodingFailureKind { + guard let decodingError = error as? DecodingError else { + return .other + } + switch decodingError { + case .keyNotFound(let key, let context): + return .missingKey(keyPath: keyPath(context.codingPath + [key])) + case .valueNotFound(_, let context): + return .missingKey(keyPath: keyPath(context.codingPath)) + case .typeMismatch(_, let context): + return .typeMismatch(keyPath: keyPath(context.codingPath)) + case .dataCorrupted(let context): + return .dataCorrupted(keyPath: keyPath(context.codingPath)) + @unknown default: + return .other + } + } + + /// Render a `DecodingError.Context.codingPath` as a dotted string + /// using schema keys + integer array indices only. Both are static + /// structural values β€” never PHI. + private static func keyPath(_ path: [any CodingKey]) -> String { + path.map { key in + if let intValue = key.intValue { + return String(intValue) + } + return key.stringValue + }.joined(separator: ".") + } + + // MARK: - Private helpers + + /// Strip a single outer ``` or ```json fence, if present. Idempotent + /// on unfenced input. + private static func stripCodeFence(_ input: String) -> String { + var body = input + if body.hasPrefix("```json") { + body = String(body.dropFirst("```json".count)) + } else if body.hasPrefix("```") { + body = String(body.dropFirst("```".count)) + } + body = body.trimmingCharacters(in: .whitespacesAndNewlines) + if body.hasSuffix("```") { + body = String(body.dropLast("```".count)) + } + return body.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Return the substring of the first balanced top-level JSON object + /// in `input`, or `nil` if none is found. Walks the string + /// brace-by-brace, tracking string literals + escapes so that braces + /// inside JSON strings are not counted. + private static func firstJSONObject(in input: String) -> String? { + guard let start = input.firstIndex(of: "{") else { return nil } + var state = BraceScanner() + var idx = start + while idx < input.endIndex { + if state.step(input[idx]) { + return String(input[start...idx]) + } + idx = input.index(after: idx) + } + return nil + } +} + +/// Private state machine for `ClinicalNotesPromptBuilder.firstJSONObject`. +/// Tracks brace depth + in-string / escape context so a `{` or `}` +/// inside a JSON string literal does not confuse the top-level scan. +/// Extracted so each step fits under SwiftLint's cyclomatic-complexity +/// limit. +private struct BraceScanner { + private var depth = 0 + private var inString = false + private var escaped = false + + /// Consume one character. Returns `true` iff this character closes + /// the outer JSON object (i.e. depth returned to zero on a `}`). + mutating func step(_ char: Character) -> Bool { + if escaped { + escaped = false + return false + } + if inString { + stepInString(char) + return false + } + return stepOutsideString(char) + } + + private mutating func stepInString(_ char: Character) { + if char == "\\" { + escaped = true + } else if char == "\"" { + inString = false + } + } + + private mutating func stepOutsideString(_ char: Character) -> Bool { + switch char { + case "\"": + inString = true + case "{": + depth += 1 + case "}": + depth -= 1 + return depth == 0 + default: + break + } + return false + } +} + +/// Errors surfaced by `ClinicalNotesPromptBuilder.validate(json:)`. +/// +/// Every case is structural only. No PHI (transcript content, patient +/// data, SOAP body text, LLM-returned names, or decoded values derived +/// from patient input) is carried in any payload β€” payloads are schema +/// key names, integer array indices, and numeric scores. +enum SchemaError: Error, Equatable { + /// Input was empty or whitespace-only. + case emptyInput + /// No `{ … }` JSON object could be located in the input. + case noJSONFound + /// JSON parsed, but did not match the `RawLLMDraft` shape. The + /// associated kind carries only the schema key path that failed β€” + /// never the offending value. See `DecodingFailureKind` for detail. + case decodingFailed(DecodingFailureKind) + /// A `manipulations[]` entry carried a `confidence` outside the + /// locked `[0, 1]` range. `keyPath` is the structural JSON path + /// (e.g. `"manipulations.0.confidence"`); `value` is the numeric + /// score. Neither is PHI β€” deliberately we do *not* carry the + /// model-returned `name`, which could contain hallucinated or + /// prompt-injected transcript content. + case confidenceOutOfRange(keyPath: String, value: Double) +} + +/// PHI-safe structural description of a `DecodingError`. +/// +/// The raw `DecodingError.debugDescription` can quote the offending +/// value, which in this pipeline may have been derived from transcript +/// text. This type preserves only the schema `codingPath` (dotted +/// schema keys + integer array indices), which is never PHI. +enum DecodingFailureKind: Error, Equatable, Sendable { + /// A required schema key was missing (or its value was `null` on a + /// non-optional field). `keyPath` is the dotted schema path β€” e.g. + /// `"plan"` or `"manipulations.0.confidence"`. + case missingKey(keyPath: String) + /// A value at `keyPath` was present but of the wrong type. The + /// offending value is deliberately not captured. + case typeMismatch(keyPath: String) + /// JSON parsed to the expected outer shape but failed a deeper + /// data-corruption check (e.g. malformed nested structure). + case dataCorrupted(keyPath: String) + /// Any other decoding failure (future `DecodingError` variants). + case other +} + +/// Failures surfaced by `ClinicalNotesPromptBuilder.loadFromBundle(_:)`. +enum ClinicalNotesPromptBuilderError: Error, Equatable { + /// The named template resource is missing from the bundle. Usually a + /// build-system misconfiguration (e.g. a missing `.copy(...)` entry + /// in `Package.swift`). + case templateNotFound(resource: String, subdirectory: String) +} diff --git a/Sources/Services/Cliniko/ClinikoAppointmentService.swift b/Sources/Services/Cliniko/ClinikoAppointmentService.swift new file mode 100644 index 0000000..c0abf13 --- /dev/null +++ b/Sources/Services/Cliniko/ClinikoAppointmentService.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Actor-constrained protocol for the patient picker's appointment-loading +/// dependency. See `ClinikoPatientSearching` for the rationale on +/// actor-constrained protocols. +public protocol ClinikoAppointmentLoading: Actor { + /// Load the patient's recent (past 7 days) + today's appointments. The + /// picker's "No appointment / general note" option is rendered by the + /// view, not represented in the response. + /// + /// - Parameters: + /// - patientID: Cliniko patient ID, opaque string. Pass through as-is; + /// the underlying endpoint percent-encodes it. + /// - reference: The "today" anchor β€” defaults to `Date()`. Tests pass + /// a fixed date so the from / to query params are deterministic. + /// - Throws: `ClinikoError`. Same shape as `searchPatients`. + /// - Returns: zero or more appointments, Cliniko's natural order. + func recentAndTodayAppointments( + forPatientID patientID: String, + reference: Date + ) async throws -> [Appointment] +} + +/// Default `ClinikoAppointmentLoading` implementation. Computes a 7-day-back +/// window in UTC against the supplied `reference` date and delegates to +/// `ClinikoClient.send(.patientAppointments(...))`. +/// +/// **Window definition**: the picker shows "recent + today" β€” concretely, +/// `[reference βˆ’ 7 days, reference + 1 day)` so today's evening +/// appointments still match. Both bounds are emitted as ISO8601 in UTC, +/// matching the encoding `ClinikoEndpoint.patientAppointments` uses. +/// +/// PHI: as with `ClinikoPatientService`, this actor never logs query +/// arguments, never logs the response, and never persists. Logging stays +/// inside `ClinikoClient`. +public actor ClinikoAppointmentService: ClinikoAppointmentLoading { + private let client: ClinikoClient + + public init(client: ClinikoClient) { + self.client = client + } + + /// Conformance to `ClinikoAppointmentLoading`. Note: no default for + /// `reference` here even though Swift would accept one β€” defaults on + /// protocol witnesses don't surface through the existential, so + /// providing one would silently mislead callers who think they can + /// omit the argument when they hold an `any ClinikoAppointmentLoading`. + public func recentAndTodayAppointments( + forPatientID patientID: String, + reference: Date + ) async throws -> [Appointment] { + let from = reference.addingTimeInterval(-7 * 24 * 60 * 60) + let to = reference.addingTimeInterval(24 * 60 * 60) + let response: AppointmentListResponse = try await client.send( + .patientAppointments(patientID: patientID, from: from, to: to) + ) + return response.appointments + } +} diff --git a/Sources/Services/Cliniko/ClinikoAuthProbe.swift b/Sources/Services/Cliniko/ClinikoAuthProbe.swift new file mode 100644 index 0000000..73f2456 --- /dev/null +++ b/Sources/Services/Cliniko/ClinikoAuthProbe.swift @@ -0,0 +1,118 @@ +import Foundation +import os.log + +/// Errors surfaced by `ClinikoAuthProbe`. Structural cases only β€” error +/// strings never embed the API key, the URL, or any PHI. The `.transport` +/// payload preserves a `URLError.Code` so callers can distinguish offline +/// from DNS / TLS, but `.cancelled` lives in its own case so a user-cancelled +/// probe never renders as "could not reach Cliniko" in the UI. +public enum ClinikoAuthProbeError: Error, Sendable, Equatable, CustomStringConvertible { + case unauthorized + case http(status: Int) + case transport(URLError.Code) + case cancelled + case nonHTTPResponse + case unknown(typeName: String) + + public var description: String { + switch self { + case .unauthorized: return "Cliniko rejected the API key (HTTP 401/403)" + case .http(let status): return "Cliniko responded with HTTP \(status)" + case .transport(let code): return "Network error (URLError code \(code.rawValue))" + case .cancelled: return "Request was cancelled" + case .nonHTTPResponse: return "Cliniko returned a non-HTTP response" + case .unknown(let typeName): return "Unexpected error of type \(typeName)" + } + } +} + +/// Minimal `GET /users/me` probe used by the Cliniko settings UI to verify +/// that a freshly entered API key works. This is intentionally narrower than +/// the full `ClinikoClient` planned for #8 β€” once that lands, the probe will +/// be expressed as `clinikoClient.send(.usersMe)`. Until then, a small +/// dependency-free actor here keeps #7 self-contained without pre-empting #8's +/// design. +/// +/// PHI: `/users/me` returns the practitioner's account, **not** patient data, +/// so the response body is non-PHI. Logs still avoid the response body and +/// any URL details β€” only the structural status / error case is emitted. +public actor ClinikoAuthProbe { + /// Default `User-Agent` exposed for tests. Cliniko requires the header to + /// be non-empty and to embed a contact reference per their docs (see + /// `.claude/references/cliniko-api.md`); we publish the app version plus + /// the public repository URL so Cliniko ops can reach the project on + /// abuse / incident. + public static var defaultUserAgent: String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + return "mac-speech-to-text/\(version) (https://github.com/CloudbrokerAz/mac-speech-to-text)" + } + + private let session: URLSession + private let userAgent: String + + public init(session: URLSession = .shared, userAgent: String? = nil) { + self.session = session + self.userAgent = userAgent ?? Self.defaultUserAgent + } + + /// Issue `GET /users/me` against Cliniko using `credentials`. Returns + /// successfully on any 2xx response, throws a typed `ClinikoAuthProbeError` + /// otherwise. The response body is discarded β€” we only care that the key + /// authenticates. + public func ping(credentials: ClinikoCredentials) async throws { + var request = URLRequest(url: credentials.baseURL.appendingPathComponent("users/me")) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(credentials.basicAuthHeaderValue, forHTTPHeaderField: "Authorization") + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + let response: URLResponse + do { + (_, response) = try await session.data(for: request) + } catch is CancellationError { + // Swift Concurrency cancellation β€” user navigated away. Don't + // misreport as a network failure. + throw ClinikoAuthProbeError.cancelled + } catch let urlError as URLError where urlError.code == .cancelled { + // URLSession-level cancellation (session invalidation) β€” treat + // the same as user cancellation. + throw ClinikoAuthProbeError.cancelled + } catch let urlError as URLError { + AppLogger.service.error( + "ClinikoAuthProbe.ping: transport error code=\(urlError.code.rawValue, privacy: .public)" + ) + throw ClinikoAuthProbeError.transport(urlError.code) + } catch { + // Type-name-only logging β€” never `String(describing: error)` because + // a future error type could embed PHI (e.g. URL paths after #8). + let typeName = String(describing: type(of: error)) + AppLogger.service.error( + "ClinikoAuthProbe.ping: unexpected error type=\(typeName, privacy: .public)" + ) + throw ClinikoAuthProbeError.unknown(typeName: typeName) + } + // `URLProtocolStub` always returns `HTTPURLResponse`, so this branch + // is defensive against future custom URLProtocols (e.g. file://). + guard let http = response as? HTTPURLResponse else { + throw ClinikoAuthProbeError.nonHTTPResponse + } + switch http.statusCode { + case 200..<300: + AppLogger.service.info( + "ClinikoAuthProbe.ping: OK status=\(http.statusCode, privacy: .public)" + ) + return + case 401, 403: + AppLogger.service.info( + "ClinikoAuthProbe.ping: unauthorized status=\(http.statusCode, privacy: .public)" + ) + throw ClinikoAuthProbeError.unauthorized + default: + AppLogger.service.info( + "ClinikoAuthProbe.ping: http status=\(http.statusCode, privacy: .public)" + ) + throw ClinikoAuthProbeError.http(status: http.statusCode) + } + } +} diff --git a/Sources/Services/Cliniko/ClinikoClient.swift b/Sources/Services/Cliniko/ClinikoClient.swift new file mode 100644 index 0000000..a538bd4 --- /dev/null +++ b/Sources/Services/Cliniko/ClinikoClient.swift @@ -0,0 +1,442 @@ +import Foundation +import os.log + +/// Thin Cliniko HTTP client. The single public method `send(_:)` builds an +/// authenticated request from a `ClinikoEndpoint`, retries on 429 + +/// (idempotent verbs only) 5xx / transport failures per the policy in +/// `.claude/references/cliniko-api.md`, and surfaces a typed `ClinikoError`. +/// `Decodable` responses are decoded with `convertFromSnakeCase` + ISO8601 +/// dates β€” Cliniko's documented shape. +/// +/// PHI: +/// - The API key is held only via `ClinikoCredentials` (which exposes the +/// secret only through `basicAuthHeaderValue`); the credentials struct +/// itself is `Sendable` and crosses the actor boundary fine. +/// - Logs at any privacy posture carry only structural values: HTTP method, +/// path **template** (`/patients/:id/appointments`, never the bound URL), +/// status code, latency, error case name, decoding-target type name, and +/// the URLError raw integer code. The request URL with bound IDs, the +/// request body, the response body, and the underlying `Error` value +/// (anything from another framework that might embed PHI) are **never** +/// logged. +public actor ClinikoClient { + /// Default `User-Agent`. Mirrors `ClinikoAuthProbe.defaultUserAgent` + /// β€” Cliniko requires the header to be non-empty and include a contact + /// reference. We use the public repo URL so Cliniko ops can reach the + /// project for abuse / incident. + public static var defaultUserAgent: String { + let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + return "mac-speech-to-text/\(version) (https://github.com/CloudbrokerAz/mac-speech-to-text)" + } + + /// Default JSON decoder: snake_case β†’ camelCase + ISO8601 dates. Suitable + /// for the documented Cliniko response shapes; consumers can supply a + /// custom decoder for endpoints that need different strategies. + public static var defaultDecoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + /// Tunable retry behaviour. The default schedules `[1s, 2s]` between + /// attempts and uses `Task.sleep`; tests inject `.immediate` to avoid + /// real waits. The sleep closure is `@Sendable async throws` so + /// cancellation propagates and tests can replace the implementation. + public struct RetryPolicy: Sendable { + public let delays: [TimeInterval] + public let sleep: @Sendable (TimeInterval) async throws -> Void + + public init( + delays: [TimeInterval], + sleep: @escaping @Sendable (TimeInterval) async throws -> Void + ) { + self.delays = delays + self.sleep = sleep + } + + public static let `default` = RetryPolicy( + delays: [1.0, 2.0], + sleep: { interval in + // `Task.sleep(for:)` is the modern replacement for the + // nanosecond-based variant; available on macOS 13+ which + // is below our `macOS 14` deployment target. + try await Task.sleep(for: .seconds(max(0, interval))) + } + ) + + /// No-wait variant for tests. Keeps the retry *count* identical to + /// `.default` so retry-budget assertions match production shape. + public static let immediate = RetryPolicy( + delays: [0.0, 0.0], + sleep: { _ in /* no-op */ } + ) + + /// Maximum number of retry attempts (after the initial request). + var maxRetries: Int { delays.count } + } + + // MARK: - State + + private let credentials: ClinikoCredentials + private let session: URLSession + private let userAgent: String + private let retryPolicy: RetryPolicy + private let decoder: JSONDecoder + private let logger = Logger(subsystem: "com.speechtotext", category: "ClinikoClient") + + /// RFC 7231 IMF-fixdate parser. Used for `Retry-After: `. + /// Held as an *instance* property (not `static`) because `DateFormatter` + /// is documented as thread-unsafe under mutation; even though we never + /// mutate this one after construction, sharing a single static across + /// multiple `ClinikoClient` actor instances would still trip Swift 6 + /// strict-concurrency checking (`DateFormatter` is not `Sendable`). + /// Actor isolation makes the per-instance copy provably safe. + private let httpDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return formatter + }() + + // MARK: - Init + + public init( + credentials: ClinikoCredentials, + session: URLSession = .shared, + userAgent: String = ClinikoClient.defaultUserAgent, + retryPolicy: RetryPolicy = .default, + decoder: JSONDecoder = ClinikoClient.defaultDecoder + ) { + self.credentials = credentials + self.session = session + self.userAgent = userAgent + self.retryPolicy = retryPolicy + self.decoder = decoder + } + + // MARK: - Public API + + /// Issue `endpoint`, retry per policy, decode the 2xx body into `T`. + /// Throws `ClinikoError` for every non-success path. The retry loop + /// honours the endpoint's `allowsRetryOn5xx` flag for 5xx + transport; + /// 429 always retries (up to the policy's budget) regardless of method. + public func send(_ endpoint: ClinikoEndpoint) async throws -> T { + guard let url = endpoint.buildURL(against: credentials.baseURL) else { + // Closed-set inputs (enum-constrained shard host + endpoint cases) + // make this unreachable; tests pin every-shard Γ— every-endpoint + // URL build. Treat reaching this branch as a programmer bug, not + // a transport error β€” the user shouldn't see "transport error + // (URLError code -1000)" for a code-side regression. + // PHI: `pathTemplate` is documented log-safe (no bound IDs). + preconditionFailure("ClinikoEndpoint.buildURL returned nil for closed-set input \(endpoint.pathTemplate)") + } + let request = buildRequest(url: url, endpoint: endpoint) + + var attempt = 0 + while true { + let outcome: AttemptOutcome = await runAttempt( + request: request, + endpoint: endpoint, + attempt: attempt + ) + switch outcome { + case .success(let value): + return value + case .terminal(let error): + throw error + case .retry(let delay): + try await retryPolicy.sleep(delay) + attempt += 1 + } + } + } + + // MARK: - Attempt execution + + /// Outcome of a single network attempt. `success` returns the decoded + /// body; `terminal` is an error the caller will rethrow; `retry` + /// re-enters the loop after the named delay. + private enum AttemptOutcome { + case success(T) + case terminal(ClinikoError) + case retry(TimeInterval) + } + + private func runAttempt( + request: URLRequest, + endpoint: ClinikoEndpoint, + attempt: Int + ) async -> AttemptOutcome { + let methodLog = endpoint.method.rawValue + let pathTemplate = endpoint.pathTemplate + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + logger.error( + "ClinikoClient: \(methodLog, privacy: .public) \(pathTemplate, privacy: .public) non-HTTP response" + ) + return .terminal(.nonHTTPResponse) + } + logger.info( + "ClinikoClient: \(methodLog, privacy: .public) \(pathTemplate, privacy: .public) status=\(http.statusCode, privacy: .public) attempt=\(attempt, privacy: .public)" + ) + return classifyResponse(T.self, data: data, http: http, endpoint: endpoint, attempt: attempt) + } catch is CancellationError { + return .terminal(.cancelled) + } catch let urlError as URLError where urlError.code == .cancelled { + return .terminal(.cancelled) + } catch let urlError as URLError { + return classifyTransportError(urlError, endpoint: endpoint, attempt: attempt) + } catch { + // Catch-all for non-URLError, non-Cancellation throws. Capture + // the bridged `NSError` domain + code so a CFNetwork-layer + // failure (captive-portal proxy, NSPOSIXErrorDomain, etc.) + // leaves a triage trail. `domain` is a constant string; `code` + // is an Int β€” both structural / non-PHI. We deliberately do + // **not** log `localizedDescription`, which can carry localised + // PHI-adjacent text. + let typeName = String(reflecting: Swift.type(of: error)) + let nsError = error as NSError + logger.error( + "ClinikoClient: \(methodLog, privacy: .public) \(pathTemplate, privacy: .public) unexpected error type=\(typeName, privacy: .public) domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public)" + ) + return .terminal(.transport(.unknown)) + } + } + + // MARK: - Response classification + + /// Map a successful URLSession round-trip (we got an `HTTPURLResponse`) + /// into a success / terminal / retry outcome based on status. + private func classifyResponse( + _ type: T.Type, + data: Data, + http: HTTPURLResponse, + endpoint: ClinikoEndpoint, + attempt: Int + ) -> AttemptOutcome { + let status = http.statusCode + switch status { + case 200..<300: + return decodeOutcome(T.self, from: data) + case 401: return .terminal(.unauthenticated) + case 403: return .terminal(.forbidden) + case 404: return .terminal(.notFound(resource: endpoint.resource)) + case 422: return .terminal(.validation(fields: parseValidationErrors(from: data))) + case 429: return classifyRateLimit(http: http, attempt: attempt) + case 500..<600: return classifyServerError(status: status, endpoint: endpoint, attempt: attempt) + default: + // 1xx / 3xx / unclassified 4xx β€” surface as `.server(status:)` + // but flag 3xx specifically because URLSession follows + // same-origin redirects by default; a 3xx that survives to here + // usually means a captive portal injecting a login redirect or + // a Cliniko shard migration the user hasn't picked up yet. + // Worth a structural log so the triage path doesn't read this + // as "Cliniko is broken." + if (300..<400).contains(status) { + logger.error( + "ClinikoClient: \(endpoint.method.rawValue, privacy: .public) \(endpoint.pathTemplate, privacy: .public) unexpected redirect status=\(status, privacy: .public) β€” possible captive portal or shard migration" + ) + } else { + logger.error( + "ClinikoClient: \(endpoint.method.rawValue, privacy: .public) \(endpoint.pathTemplate, privacy: .public) unclassified status=\(status, privacy: .public)" + ) + } + return .terminal(.server(status: status)) + } + } + + private func decodeOutcome( + _ type: T.Type, + from data: Data + ) -> AttemptOutcome { + do { + let value = try decodeBody(T.self, from: data) + return .success(value) + } catch let error as ClinikoError { + return .terminal(error) + } catch { + // Swift's catch-exhaustiveness rule forces a fallback even + // though `decodeBody` only throws `ClinikoError`. If a future + // refactor makes it throw something else, we surface it as a + // structural decoding failure (not a transport failure) so the + // UI doesn't lie about the failure layer. + let typeName = String(describing: T.self) + logger.error( + "ClinikoClient: decodeOutcome unexpected non-ClinikoError type=\(String(reflecting: Swift.type(of: error)), privacy: .public)" + ) + return .terminal(.decoding(typeName: typeName)) + } + } + + private func classifyRateLimit(http: HTTPURLResponse, attempt: Int) -> AttemptOutcome { + let retryAfter = retryAfterSeconds(from: http) + guard attempt < retryPolicy.maxRetries else { + return .terminal(.rateLimited(retryAfter: retryAfter)) + } + // Floor any honoured `Retry-After` at the policy's own delay so a + // misbehaving server emitting `Retry-After: 0` (or negative) can't + // make us hammer back-to-back. Still honour values *above* the + // policy floor β€” that's the server saying "wait longer than you + // planned to." + let policyDelay = retryPolicy.delays[attempt] + let delay = max(retryAfter ?? policyDelay, policyDelay) + return .retry(delay) + } + + private func classifyServerError( + status: Int, + endpoint: ClinikoEndpoint, + attempt: Int + ) -> AttemptOutcome { + guard endpoint.isIdempotent, attempt < retryPolicy.maxRetries else { + return .terminal(.server(status: status)) + } + return .retry(retryPolicy.delays[attempt]) + } + + private func classifyTransportError( + _ urlError: URLError, + endpoint: ClinikoEndpoint, + attempt: Int + ) -> AttemptOutcome { + let methodLog = endpoint.method.rawValue + let pathTemplate = endpoint.pathTemplate + if endpoint.isIdempotent, attempt < retryPolicy.maxRetries { + return .retry(retryPolicy.delays[attempt]) + } + logger.error( + "ClinikoClient: \(methodLog, privacy: .public) \(pathTemplate, privacy: .public) transport code=\(urlError.code.rawValue, privacy: .public) attempt=\(attempt, privacy: .public)" + ) + return .terminal(.transport(urlError.code)) + } + + // MARK: - Private + + private func buildRequest(url: URL, endpoint: ClinikoEndpoint) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = endpoint.method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(credentials.basicAuthHeaderValue, forHTTPHeaderField: "Authorization") + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + if let body = endpoint.body { + request.httpBody = body + request.setValue(endpoint.contentType ?? "application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + + private func decodeBody(_ type: T.Type, from data: Data) throws -> T { + // `EmptyResponse` is the marker the caller passes when the response + // body should be ignored β€” both for 204 (no body) and for 200/201 + // responses where the caller doesn't care about the payload. We + // short-circuit so neither case fails decode. + if T.self == EmptyResponse.self, let empty = EmptyResponse() as? T { + return empty + } + let typeName = String(describing: T.self) + do { + return try decoder.decode(T.self, from: data) + } catch let decodingError as DecodingError { + // `DecodingError.localizedDescription` and the associated + // `CodingKey` paths can echo JSON keys, which are PHI-adjacent + // for some Cliniko payloads. The case **tag** alone + // (`keyNotFound`, `typeMismatch`, etc.) is structural, not + // PHI β€” log the tag so triage knows the failure shape without + // ever interpolating the underlying value. + let kind = decodingErrorKind(decodingError) + logger.error( + "ClinikoClient: decode failed type=\(typeName, privacy: .public) kind=\(kind, privacy: .public)" + ) + throw ClinikoError.decoding(typeName: typeName) + } catch { + // Non-DecodingError on a Decodable.decode call should be + // unreachable, but never silently relabel β€” log the type and + // surface as `.decoding` not `.transport`. + let errorTypeName = String(reflecting: Swift.type(of: error)) + logger.error( + "ClinikoClient: decode failed (non-DecodingError) type=\(typeName, privacy: .public) error=\(errorTypeName, privacy: .public)" + ) + throw ClinikoError.decoding(typeName: typeName) + } + } + + private func decodingErrorKind(_ error: DecodingError) -> String { + switch error { + case .keyNotFound: return "keyNotFound" + case .typeMismatch: return "typeMismatch" + case .valueNotFound: return "valueNotFound" + case .dataCorrupted: return "dataCorrupted" + @unknown default: return "unknownKind" + } + } + + private func retryAfterSeconds(from response: HTTPURLResponse) -> TimeInterval? { + guard let raw = response.value(forHTTPHeaderField: "Retry-After") else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespaces) + if let seconds = TimeInterval(trimmed) { + return seconds + } + // RFC 7231 Β§7.1.3 also allows the HTTP-date form (e.g. + // "Wed, 21 Oct 2026 07:28:00 GMT"), which Cliniko emits during + // scheduled-maintenance windows. Parse it and convert to a forward- + // looking interval; floor at zero in case the server-clock drifted. + if let date = httpDateFormatter.date(from: trimmed) { + return max(0, date.timeIntervalSinceNow) + } + logger.error( + "ClinikoClient: 429 with unparseable Retry-After (length=\(trimmed.count, privacy: .public))" + ) + return nil + } + + private func parseValidationErrors(from data: Data) -> [String: [String]] { + // Best-effort decode of the two response shapes Cliniko documents + // for 422. A fresh `JSONDecoder()` (rather than `self.decoder`) is + // intentional: the validation envelope keys are already lowercase + // and we don't want `convertFromSnakeCase` rewriting `errors` β†’ + // `errors` (no-op today, but a footgun if a future endpoint case + // adds `error_code` etc.). + struct DictShape: Decodable { let errors: [String: [String]]? } + if let dict = try? JSONDecoder().decode(DictShape.self, from: data), + let errors = dict.errors { + return errors + } + struct ListShape: Decodable { + let errors: [Item]? + struct Item: Decodable { + let field: String? + let message: String? + } + } + if let list = try? JSONDecoder().decode(ListShape.self, from: data), + let items = list.errors { + var result: [String: [String]] = [:] + for item in items { + let key = item.field ?? "_" + let message = item.message ?? "" + result[key, default: []].append(message) + } + return result + } + // Both documented shapes failed. Log a structural marker so we can + // detect a third undocumented shape in production without ever + // logging the body. `data.count` and the first byte are non-PHI + // and tell triage "is it JSON-shaped at all? object or array?". + let firstByte: String = data.first.map { String(format: "0x%02x", $0) } ?? "empty" + logger.error( + "ClinikoClient: 422 body parse failed; bytes=\(data.count, privacy: .public) firstByte=\(firstByte, privacy: .public)" + ) + return [:] + } +} + +/// Marker type the caller can pass to `ClinikoClient.send(_:) as Empty…` when +/// the response body is irrelevant (e.g. probing connectivity, or a 204 +/// response). Decoding short-circuits so empty bodies don't fail. +public struct EmptyResponse: Decodable, Sendable, Equatable { + public init() {} +} diff --git a/Sources/Services/Cliniko/ClinikoCredentialStore.swift b/Sources/Services/Cliniko/ClinikoCredentialStore.swift new file mode 100644 index 0000000..40eebd3 --- /dev/null +++ b/Sources/Services/Cliniko/ClinikoCredentialStore.swift @@ -0,0 +1,176 @@ +import Foundation +import os.log + +/// Persists Cliniko credentials. The API key goes to Keychain via the +/// `SecureStore` abstraction (#22 / F3); the shard goes to `UserDefaults` +/// because it is structural metadata, not a secret. Account / service / +/// UserDefaults key names are pinned in `.claude/references/cliniko-api.md`. +/// +/// PHI / security: +/// - The API key value is **never** logged. Error-path logs reference the +/// service + account name + the *type* of the underlying error only β€” +/// never the error's stringified value (`String(describing: error)`), +/// because a future SecureStore failure type could carry user content. +/// - This store is the only path that reads or writes the Keychain item; the +/// raw key never round-trips back to the SwiftUI layer once stored. +/// Callers that need to make a request fetch a `ClinikoCredentials` value +/// directly and pass it to the HTTP client. +public actor ClinikoCredentialStore { + /// Keychain `kSecAttrService` namespace shared by every Cliniko-related + /// secret (currently only the API key, but reserved for future bearer + /// tokens etc.). Matches `.claude/references/cliniko-api.md`. + public static let serviceName = "com.speechtotext.cliniko" + + /// Keychain `kSecAttrAccount` for the API key. + public static let apiKeyAccount = "api_key" + + /// `UserDefaults` key for the shard rawValue. Non-PHI structural value. + public static let shardUserDefaultsKey = "cliniko.shard" + + /// Errors surfaced from this store. Cases are semantic β€” callers can + /// pattern-match on the operation that failed and inspect the wrapped + /// `SecureStore` failure when they need the underlying `OSStatus`. This + /// mirrors the direction of issue #29 ("split osStatus into semantic + /// cases") at the next layer up. + public enum Failure: Error, Sendable, CustomStringConvertible { + case missingAPIKey + case readFailed(underlying: any Error) + case writeFailed(underlying: any Error) + case deleteFailed(underlying: any Error) + + public var description: String { + switch self { + case .missingAPIKey: + return "ClinikoCredentialStore: API key is empty" + case .readFailed(let underlying): + return "ClinikoCredentialStore: read failed (\(type(of: underlying)))" + case .writeFailed(let underlying): + return "ClinikoCredentialStore: write failed (\(type(of: underlying)))" + case .deleteFailed(let underlying): + return "ClinikoCredentialStore: delete failed (\(type(of: underlying)))" + } + } + } + + private let secureStore: any SecureStore + /// `UserDefaults` is documented thread-safe β€” every read/write is atomic + /// from the caller's perspective. We mark it `nonisolated(unsafe)` so + /// `loadShard` / `updateShard` can stay non-async; the picker binding in + /// the settings UI then doesn't need an actor hop. + nonisolated(unsafe) private let userDefaults: UserDefaults + + public init( + secureStore: any SecureStore = KeychainSecureStore(service: ClinikoCredentialStore.serviceName), + userDefaults: UserDefaults = .standard + ) { + self.secureStore = secureStore + self.userDefaults = userDefaults + } + + /// Returns the currently configured credentials, or `nil` if no API key + /// is stored. The shard falls back to `ClinikoShard.default` when the + /// stored value is missing or unrecognised (e.g. old install). + public func loadCredentials() async throws -> ClinikoCredentials? { + let key: String? + do { + key = try await secureStore.getString(forKey: Self.apiKeyAccount) + } catch { + // PHI rule: log the *type* of error, never `String(describing:)` + // of the value itself (privacy: .public on a stringly-typed + // payload would be a footgun the moment a SecureStore wraps a + // body). Type names are structural and safe. + AppLogger.service.error( + "ClinikoCredentialStore.loadCredentials: SecureStore read failed type=\(String(describing: type(of: error)), privacy: .public)" + ) + throw Failure.readFailed(underlying: error) + } + guard let trimmed = key.map({ $0.trimmingCharacters(in: .whitespacesAndNewlines) }), + !trimmed.isEmpty + else { + return nil + } + do { + return try ClinikoCredentials(apiKey: trimmed, shard: loadShard()) + } catch { + // Should be unreachable: `trimmed` is non-empty by the guard above. + // Surface as a read failure so the caller can recover. + throw Failure.readFailed(underlying: error) + } + } + + /// Lightweight presence check that avoids materialising the API key in + /// the caller's memory. Used by the settings UI to render the connected / + /// disconnected state without copying the secret. + public func hasAPIKey() async throws -> Bool { + do { + let key = try await secureStore.getString(forKey: Self.apiKeyAccount) + let trimmed = key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !trimmed.isEmpty + } catch { + AppLogger.service.error( + "ClinikoCredentialStore.hasAPIKey: SecureStore read failed type=\(String(describing: type(of: error)), privacy: .public)" + ) + throw Failure.readFailed(underlying: error) + } + } + + /// Returns the persisted shard, or the default for new installs. + /// Marked `nonisolated` because it only touches `UserDefaults` (thread-safe + /// + immutable `let` reference) β€” keeps the SwiftUI picker binding fast. + public nonisolated func loadShard() -> ClinikoShard { + let raw = userDefaults.string(forKey: Self.shardUserDefaultsKey) + return raw.flatMap(ClinikoShard.init(rawValue:)) ?? .default + } + + /// Save (or replace) the API key + shard pair atomically from the + /// caller's point of view. Trims whitespace and rejects empty input. + /// The Keychain write happens before the UserDefaults write; on Keychain + /// failure the shard is left untouched. + public func saveCredentials(apiKey: String, shard: ClinikoShard) async throws { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw Failure.missingAPIKey } + do { + try await secureStore.setString(trimmed, forKey: Self.apiKeyAccount) + } catch { + AppLogger.service.error( + "ClinikoCredentialStore.saveCredentials: SecureStore write failed type=\(String(describing: type(of: error)), privacy: .public)" + ) + throw Failure.writeFailed(underlying: error) + } + userDefaults.set(shard.rawValue, forKey: Self.shardUserDefaultsKey) + } + + /// Update only the shard. Useful when the user changes the picker without + /// re-pasting the API key. + /// Marked `nonisolated` for the same reason as `loadShard`: shard changes + /// from the picker should not require an actor hop / `Task`. + public nonisolated func updateShard(_ shard: ClinikoShard) { + userDefaults.set(shard.rawValue, forKey: Self.shardUserDefaultsKey) + } + + /// Delete every Cliniko credential and forget the shard. The Keychain + /// delete runs first; only on success do we clear the shard. This keeps + /// the on-disk pair consistent across failure modes: + /// + /// - Keychain delete **fails** β†’ key + shard both remain. `loadCredentials` + /// still returns a usable pair pointed at the correct tenant, so a user + /// who retries (or who chooses to keep using Cliniko while we sort out + /// the Keychain error) hits the right shard. + /// - Keychain delete **succeeds** β†’ shard cleared. No stale tenant + /// reference outlives the secret it authenticated against. + /// + /// The reverse asymmetry (Keychain succeeds + UserDefaults remove fails) + /// is a non-failure mode in practice β€” `UserDefaults.removeObject` is a + /// documented thread-safe call with no failure path on a writable suite. + public func deleteCredentials() async throws { + do { + try await secureStore.delete(forKey: Self.apiKeyAccount) + } catch { + AppLogger.service.error( + "ClinikoCredentialStore.deleteCredentials: SecureStore delete failed type=\(String(describing: type(of: error)), privacy: .public)" + ) + throw Failure.deleteFailed(underlying: error) + } + userDefaults.removeObject(forKey: Self.shardUserDefaultsKey) + } +} diff --git a/Sources/Services/Cliniko/ClinikoEndpoint.swift b/Sources/Services/Cliniko/ClinikoEndpoint.swift new file mode 100644 index 0000000..3687e5e --- /dev/null +++ b/Sources/Services/Cliniko/ClinikoEndpoint.swift @@ -0,0 +1,171 @@ +import Foundation + +/// Closed-set of Cliniko endpoints in scope for v1. Each case carries the +/// minimum information the client needs to build a request and surface a +/// semantic 404 (`resource`). Adding an endpoint is a one-line enum addition +/// plus arms in the four computed properties below. +/// +/// The endpoint enum stays free of response-decoding concerns: callers pass +/// `T: Decodable` to `ClinikoClient.send(_:)` and the client decodes against +/// the raw 2xx body. Pagination wrappers, request envelopes, and patient / +/// appointment / treatment_note model types live in their consuming PRs +/// (#9 / #10) β€” `ClinikoEndpoint` is intentionally untyped on the response +/// shape so #8 doesn't pre-empt those design decisions. +public enum ClinikoEndpoint: Sendable, Equatable { + /// `GET /users/me` β€” used by the Cliniko settings UI's Test Connection + /// button (originally via `ClinikoAuthProbe` in #7; the VM may switch + /// to `client.send(.usersMe)` in a follow-up). + case usersMe + + /// `GET /patients?q={query}` β€” debounced patient search for #9. + case patientSearch(query: String) + + /// `GET /patients/{id}/appointments?from={ISO8601}&to={ISO8601}` β€” + /// recent + today's appointments for the chosen patient (#9). + case patientAppointments(patientID: String, from: Date, to: Date) + + /// `POST /treatment_notes` with a JSON body β€” #10 will own the codable + /// payload; this PR keeps the body opaque so #8 doesn't pre-empt the + /// payload shape. **Not** auto-retried on 5xx (see `allowsRetryOn5xx`). + case createTreatmentNote(body: Data) + + /// HTTP method strings published as a typed sub-enum so callers can't + /// accidentally type `"get"` and skip the retry classification. + public enum Method: String, Sendable { + case get = "GET" + case post = "POST" + case patch = "PATCH" + case delete = "DELETE" + } + + public var method: Method { + switch self { + case .usersMe, .patientSearch, .patientAppointments: return .get + case .createTreatmentNote: return .post + } + } + + /// Path **template** for logging. Bound IDs MUST NOT appear in logs per + /// `.claude/references/phi-handling.md`; the client logs this string + /// while building the resolved URL separately. + public var pathTemplate: String { + switch self { + case .usersMe: return "/users/me" + case .patientSearch: return "/patients?q={query}" + case .patientAppointments: return "/patients/:id/appointments" + case .createTreatmentNote: return "/treatment_notes" + } + } + + /// Discriminator the client passes to `ClinikoError.notFound(resource:)` + /// when the response is 404. Always non-optional β€” every endpoint maps + /// onto a single Cliniko resource type. + public var resource: ClinikoError.Resource { + switch self { + case .usersMe: return .user + case .patientSearch, .patientAppointments: return .patient + case .createTreatmentNote: return .treatmentNote + } + } + + public var body: Data? { + switch self { + case .createTreatmentNote(let body): return body + case .usersMe, .patientSearch, .patientAppointments: return nil + } + } + + public var contentType: String? { + switch self { + case .createTreatmentNote: return "application/json" + case .usersMe, .patientSearch, .patientAppointments: return nil + } + } + + /// Whether the endpoint is safe to retry on 5xx **or** transport + /// failures. Per `cliniko-api.md` retry policy, `treatment_notes` POST + /// is **not** auto-retried in either case β€” both 5xx (server may have + /// applied the change) and transport (we don't know if the request + /// landed) carry duplicate-write risk, so the same `isIdempotent` + /// flag governs both. + /// 429 retries with `Retry-After` are governed separately and apply + /// even when this flag is `false`. + public var isIdempotent: Bool { + switch self { + case .createTreatmentNote: return false + case .usersMe, .patientSearch, .patientAppointments: return true + } + } + + /// Build the resolved request URL against a `baseURL` that includes the + /// `/v1/` prefix (i.e. `ClinikoCredentials.baseURL`). Returns `nil` only + /// for malformed components β€” in practice unreachable given the + /// closed-set inputs and the enum-constrained shard host. Callers + /// should treat `nil` as a programmer error and propagate up; tests + /// pin the every-shard non-nil invariant. + public func buildURL(against baseURL: URL) -> URL? { + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + return nil + } + // `pathSuffix` returns an already-percent-encoded fragment so that + // bound IDs containing slashes / spaces don't split the path. + // `URLComponents.percentEncodedPath` accepts encoded input verbatim; + // using `.path` would re-encode our `%XX` escapes into `%25XX`. + components.percentEncodedPath = components.percentEncodedPath.appending(pathSuffix) + if let items = queryItems { + components.queryItems = items + } + return components.url + } + + /// Path segment relative to `/v1/`, percent-encoded if it embeds a + /// dynamic id. Bound IDs are encoded here, not in `pathTemplate` + /// (which stays log-safe). + private var pathSuffix: String { + switch self { + case .usersMe: + return "users/me" + case .patientSearch: + return "patients" + case .patientAppointments(let patientID, _, _): + // Encode weirdness inside the bound id (spaces, slashes, etc.). + // We start from `urlPathAllowed` and *remove* "/" so an embedded + // slash in the id can't accidentally introduce a new path + // segment. Cliniko ids are numeric in practice; this is + // defence-in-depth. + let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + let encoded = patientID.addingPercentEncoding(withAllowedCharacters: allowed) ?? patientID + return "patients/\(encoded)/appointments" + case .createTreatmentNote: + return "treatment_notes" + } + } + + private var queryItems: [URLQueryItem]? { + switch self { + case .usersMe, .createTreatmentNote: + return nil + case .patientSearch(let query): + return [URLQueryItem(name: "q", value: query)] + case .patientAppointments(_, let from, let to): + return [ + URLQueryItem(name: "from", value: ClinikoEndpoint.iso8601(from)), + URLQueryItem(name: "to", value: ClinikoEndpoint.iso8601(to)) + ] + } + } + + /// ISO8601 with seconds + UTC. Cliniko accepts this canonical shape for + /// query-string date filters. + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() + + /// Internal-by-design but exposed for tests that pin the encoding. + static func iso8601(_ date: Date) -> String { + iso8601Formatter.string(from: date) + } +} diff --git a/Sources/Services/Cliniko/ClinikoError.swift b/Sources/Services/Cliniko/ClinikoError.swift new file mode 100644 index 0000000..f838f4d --- /dev/null +++ b/Sources/Services/Cliniko/ClinikoError.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Typed errors surfaced by `ClinikoClient`. Cases mirror the response-code +/// β†’ semantic-error table in `.claude/references/cliniko-api.md`. The +/// payloads are deliberately structural β€” none of them carry PHI, so the +/// whole enum is safe to interpolate into a log line at `OSLog`'s default +/// privacy posture (which is `.private` in release builds), and the +/// `description` property is safe at `.public`. +public enum ClinikoError: Error, Sendable, Equatable, CustomStringConvertible { + /// 401 β€” API key missing, revoked, or malformed. + case unauthenticated + + /// 403 β€” key valid but lacks scope, or the practitioner can't see this + /// resource. Distinguished from `.unauthenticated` so the UI can route + /// the user differently (re-paste key vs. ask Cliniko admin for access). + case forbidden + + /// 404 β€” the requested resource doesn't exist or isn't visible to this + /// practitioner. The `Resource` discriminator is **structural**: we + /// never embed the patient id / name / etc. β€” only the type of thing + /// that was missing. + case notFound(resource: Resource) + + /// 422 β€” Cliniko returned field-level validation errors. The body shape + /// is best-effort decoded into `[field: messages]` so the UI can render + /// them; an empty dictionary means we couldn't parse the response. + case validation(fields: [String: [String]]) + + /// 429 β€” rate limit hit. `retryAfter` is the parsed `Retry-After` + /// header value when present. + case rateLimited(retryAfter: TimeInterval?) + + /// 5xx β€” Cliniko returned a server error after any allowed retries + /// have been exhausted. + case server(status: Int) + + /// URLSession-level transport error (no HTTP response). Carries the + /// `URLError.Code` so the UI can distinguish offline / DNS / TLS, but + /// never the underlying URL or message. + case transport(URLError.Code) + + /// User cancelled the request (Swift Concurrency cancellation or + /// URLSession-level `.cancelled`). Distinguished from `.transport` + /// so the UI doesn't mis-render a user-cancellation as a network bug. + case cancelled + + /// 2xx response, but the body did not decode into the requested + /// `T: Decodable`. Carries the type name for log triage; the + /// underlying `DecodingError` stays inside the client's logger and + /// is **not** re-thrown (it can include JSON path fragments that may + /// be PHI-adjacent in some payloads). + case decoding(typeName: String) + + /// `URLSession` returned a `URLResponse` that wasn't an + /// `HTTPURLResponse`. Defensive against custom URLProtocols (e.g. + /// `file://`) and never expected against real Cliniko traffic. + case nonHTTPResponse + + /// Discriminator for `.notFound`. Filled in from `ClinikoEndpoint.resource` + /// at the call site so the UI gets a meaningful "patient not found" / + /// "appointment not found" / etc. without us echoing identifiers. Every + /// endpoint provides a non-optional resource β€” there's no `.unknown` + /// fallback because the client never has to guess. + public enum Resource: String, Sendable, Equatable, CustomStringConvertible { + case user + case patient + case appointment + case treatmentNote + + public var description: String { rawValue } + } + + public var description: String { + switch self { + case .unauthenticated: return "Cliniko: API key was rejected (401)" + case .forbidden: return "Cliniko: API key is valid but lacks scope (403)" + case .notFound(let resource): return "Cliniko: \(resource.rawValue) not found (404)" + case .validation(let fields): return "Cliniko: validation failed (\(fields.count) field(s))" + case .rateLimited(let retryAfter): + if let retryAfter { + return "Cliniko: rate limited; retry after \(Int(retryAfter))s (429)" + } + return "Cliniko: rate limited (429)" + case .server(let status): return "Cliniko: server error (HTTP \(status))" + case .transport(let code): return "Cliniko: transport error (URLError code \(code.rawValue))" + case .cancelled: return "Cliniko: request cancelled" + case .decoding(let typeName): return "Cliniko: failed to decode \(typeName)" + case .nonHTTPResponse: return "Cliniko: non-HTTP response" + } + } +} diff --git a/Sources/Services/Cliniko/ClinikoPatientService.swift b/Sources/Services/Cliniko/ClinikoPatientService.swift new file mode 100644 index 0000000..9d59628 --- /dev/null +++ b/Sources/Services/Cliniko/ClinikoPatientService.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Actor-constrained protocol for the patient picker's search dependency. +/// +/// The picker view-model holds `any ClinikoPatientSearching` so unit tests +/// can substitute an in-test actor without spinning up a real HTTP stack. +/// Per `.claude/references/concurrency.md`, mockable services in this +/// project use actor-constrained protocols (actors cannot be subclassed, +/// so a `class`-style protocol won't do). +public protocol ClinikoPatientSearching: Actor { + /// Issue a debounced patient search. The caller is responsible for + /// debouncing (the VM does this with `Task.sleep`); this protocol stays + /// thin so the picker can express "I want results for *this* query" and + /// the service layer doesn't need its own timer. + /// + /// - Throws: `ClinikoError`. `.cancelled` for user-cancelled requests + /// (so the picker can swallow them silently); `.unauthenticated` to + /// route the user to settings; `.transport` for connectivity. + /// - Returns: zero or more `Patient` records, in Cliniko's response + /// order (Cliniko sorts by relevance / recency β€” the picker does not + /// re-sort). + func searchPatients(query: String) async throws -> [Patient] +} + +/// Default `ClinikoPatientSearching` implementation: a thin wrapper around +/// `ClinikoClient.send(.patientSearch(query:))`. +/// +/// PHI: this actor never logs the query, never logs the response, and never +/// stores anything across calls β€” every `searchPatients` is a pure function +/// of its arguments. Logging belongs to `ClinikoClient`, which redacts the +/// URL bound IDs and the response body per +/// `.claude/references/cliniko-api.md`. +public actor ClinikoPatientService: ClinikoPatientSearching { + private let client: ClinikoClient + + public init(client: ClinikoClient) { + self.client = client + } + + public func searchPatients(query: String) async throws -> [Patient] { + let response: PatientSearchResponse = try await client.send(.patientSearch(query: query)) + return response.patients + } +} diff --git a/Sources/Services/Cliniko/ClinikoShard.swift b/Sources/Services/Cliniko/ClinikoShard.swift new file mode 100644 index 0000000..8683bb8 --- /dev/null +++ b/Sources/Services/Cliniko/ClinikoShard.swift @@ -0,0 +1,43 @@ +import Foundation + +/// A Cliniko regional shard. The base URL of a tenant is +/// `https://api.{shard}.cliniko.com/v1/`; the shard is also encoded as a +/// suffix on the API key (e.g. `MS-XXXXX-au1`), but #7 takes the explicit +/// picker route per `.claude/references/cliniko-api.md` so the user is in +/// control. Auto-detection from the API-key suffix is a possible follow-up. +public enum ClinikoShard: String, CaseIterable, Codable, Sendable, Identifiable { + case au1, au2, au3, au4 + case uk1, uk2 + case ca1 + case us1 + case eu1 + + public var id: String { rawValue } + + /// Default for new installs. Most early users are in AU; the picker lets + /// anyone change it before saving credentials. + public static let `default`: ClinikoShard = .au1 + + /// Hostname for the shard's Cliniko API endpoint. Composed only from the + /// enum's lowercase ASCII raw value, so it is always a valid URL host. + public var apiHost: String { + "api.\(rawValue).cliniko.com" + } + + /// User-facing label shown in the picker. Pairs the region name with the + /// raw shard identifier so an experienced Cliniko admin can spot the right + /// one quickly. + public var displayName: String { + switch self { + case .au1: return "Australia 1 (au1)" + case .au2: return "Australia 2 (au2)" + case .au3: return "Australia 3 (au3)" + case .au4: return "Australia 4 (au4)" + case .uk1: return "United Kingdom 1 (uk1)" + case .uk2: return "United Kingdom 2 (uk2)" + case .ca1: return "Canada 1 (ca1)" + case .us1: return "United States 1 (us1)" + case .eu1: return "Europe 1 (eu1)" + } + } +} diff --git a/Sources/Services/KeychainSecureStore.swift b/Sources/Services/KeychainSecureStore.swift new file mode 100644 index 0000000..c1ae91d --- /dev/null +++ b/Sources/Services/KeychainSecureStore.swift @@ -0,0 +1,163 @@ +import Foundation +import os.log +import Security + +/// Real Keychain-backed implementation of `SecureStore`. Uses +/// `kSecClassGenericPassword` scoped by a service identifier that namespaces +/// every item belonging to this store (e.g. `"com.speechtotext.cliniko"`). +/// +/// Accessibility is pinned to +/// `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` to prevent secrets from +/// syncing via iCloud Keychain β€” important for credentials tied to a specific +/// practitioner's workstation. +/// +/// Item values are never logged. Error-path logs include the service and key +/// name but never the value. +public actor KeychainSecureStore: SecureStore { + public enum Failure: Swift.Error, CustomStringConvertible, Equatable, Sendable { + /// The keychain returned an unexpected OSStatus for an operation. + case osStatus(OSStatus, Operation) + /// `SecItemCopyMatching` succeeded but returned a value that was not + /// `Data`. Indicates keychain corruption or a cross-class match and + /// MUST NOT be silently mapped to "missing". + case unexpectedItemType(Operation) + + public enum Operation: String, Sendable { + case set, get, delete, deleteAll + } + + public var description: String { + switch self { + case let .osStatus(status, op): + return "KeychainSecureStore: \(op.rawValue) failed (OSStatus \(status))" + case let .unexpectedItemType(op): + return "KeychainSecureStore: \(op.rawValue) returned a non-Data item" + } + } + } + + private let service: String + private let logger = Logger(subsystem: "com.speechtotext", category: "KeychainSecureStore") + + public init(service: String) { + self.service = service + } + + public func set(_ data: Data, forKey key: String) async throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + // Include accessibility in the update payload too. If an item already + // exists with a looser policy (e.g. created by an older build before + // the ThisDeviceOnly guard was introduced), `SecItemUpdate` otherwise + // leaves `kSecAttrAccessible` unchanged and silently preserves the + // looser policy β€” defeating the iCloud-sync guard. + let attributesToUpdate: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] + let updateStatus = SecItemUpdate(query as CFDictionary, attributesToUpdate as CFDictionary) + + switch updateStatus { + case errSecSuccess: + return + case errSecItemNotFound: + try addItem(key: key, data: data, query: query, retryOnDuplicate: true) + default: + logger.error("set failed (update) service=\(self.service, privacy: .public) key=\(key, privacy: .public) status=\(updateStatus)") + throw Failure.osStatus(updateStatus, .set) + } + } + + /// `SecItemAdd` with a one-shot retry when another process races us + /// between our `SecItemUpdate` miss and `SecItemAdd`. The alternative + /// (`errSecDuplicateItem` bubbling to the caller) is a flake that would + /// rarely fire but ship to users. + private func addItem( + key: String, + data: Data, + query: [String: Any], + retryOnDuplicate: Bool + ) throws { + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + + switch addStatus { + case errSecSuccess: + return + case errSecDuplicateItem where retryOnDuplicate: + let updateStatus = SecItemUpdate( + query as CFDictionary, + [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ] as CFDictionary + ) + if updateStatus != errSecSuccess { + logger.error("set failed (retry-update) service=\(self.service, privacy: .public) key=\(key, privacy: .public) status=\(updateStatus)") + throw Failure.osStatus(updateStatus, .set) + } + default: + logger.error("set failed (add) service=\(self.service, privacy: .public) key=\(key, privacy: .public) status=\(addStatus)") + throw Failure.osStatus(addStatus, .set) + } + } + + public func get(forKey key: String) async throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + // Keychain said "success" but returned something other than Data + // (class mismatch, corruption, etc.). The SecureStore contract is + // "missing returns nil, anything else throws" β€” silently returning + // nil here would hide a real problem. + guard let data = item as? Data else { + logger.error("get returned non-Data item service=\(self.service, privacy: .public) key=\(key, privacy: .public)") + throw Failure.unexpectedItemType(.get) + } + return data + case errSecItemNotFound: + return nil + default: + logger.error("get failed service=\(self.service, privacy: .public) key=\(key, privacy: .public) status=\(status)") + throw Failure.osStatus(status, .get) + } + } + + public func delete(forKey key: String) async throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + logger.error("delete failed service=\(self.service, privacy: .public) key=\(key, privacy: .public) status=\(status)") + throw Failure.osStatus(status, .delete) + } + } + + public func deleteAll() async throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + logger.error("deleteAll failed service=\(self.service, privacy: .public) status=\(status)") + throw Failure.osStatus(status, .deleteAll) + } + } +} diff --git a/Sources/Services/LLMProvider.swift b/Sources/Services/LLMProvider.swift new file mode 100644 index 0000000..1aa18d2 --- /dev/null +++ b/Sources/Services/LLMProvider.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Local LLM abstraction. +/// +/// **Issue #3.** Defines the contract `ClinicalNotesProcessor` (#5) binds +/// to. The first concrete implementation β€” `MLXGemmaProvider` loading +/// Gemma 3 4B-IT in-process via `mlx-swift-examples` β€” ships in a +/// separate PR against this same ticket. Tests use `MockLLMProvider` +/// (`Tests/SpeechToTextTests/Utilities/MockLLMProvider.swift`). +/// +/// The protocol is `Actor`-constrained β€” both `MLXGemmaProvider` and +/// `MockLLMProvider` are actors, and AGENTS.md / `.claude/references/concurrency.md` +/// Β§6 require mockable services to go through an `Actor`-constrained +/// protocol (Swift actors cannot be subclassed, so test-doubles can't +/// inherit from the concrete type). Callers that store `any LLMProvider` +/// on an `@Observable` class MUST annotate that property +/// `@ObservationIgnored` to avoid the actor-existential crash β€” see +/// `.claude/references/concurrency.md` Β§1. +/// +/// ### PHI +/// Implementations MUST NOT log `prompt` content or the generated +/// response. `OSLog` with `privacy: .public` is reserved for structural +/// values only β€” token counts, latency, truncation reasons, error-case +/// names. Thrown errors MUST NOT carry `prompt` or generated-response +/// text in their `localizedDescription` (or any other field that callers +/// might log) β€” `DecodingError`-style value-quoting is the classic leak +/// vector. See `.claude/references/phi-handling.md`. +public protocol LLMProvider: Actor { + /// Generate a full completion for `prompt` using `options`. + /// + /// Implementations surface inference, tokenisation, and + /// resource-exhaustion failures as throws. A "model returned no + /// tokens" condition is a valid empty `String`, not a throw β€” + /// callers distinguish "empty completion" from "generation failed" + /// by inspecting the returned value vs. catching. + func generate( + prompt: String, + options: LLMOptions + ) async throws -> String + + /// Stream text fragments as they are produced. + /// + /// Each element is one or more UTF-8 text fragments; callers + /// reassemble by appending in order. Cancelling the awaiting task + /// cancels the underlying generation if the implementation supports + /// it. Terminal errors are delivered via the stream, not thrown + /// from this factory. + /// + /// Declared `nonisolated` so callers can build the stream + /// synchronously from any context; implementations hop back into + /// the actor via `await` to drive generation. + nonisolated func generateStream( + prompt: String, + options: LLMOptions + ) -> AsyncThrowingStream +} + +/// Sampling configuration for a single `LLMProvider` call. +/// +/// Defaults are **deterministic** β€” temperature `0` and a fixed `seed` +/// β€” so clinical-notes generation is reproducible across runs for the +/// same transcript, per the EPIC #1 contract. Callers that want +/// nondeterminism (e.g. exploratory UIs) opt in explicitly by raising +/// `temperature` and/or setting `seed` to `nil`. +public struct LLMOptions: Sendable, Equatable { + /// Sampling temperature. `0` yields greedy decoding. + public var temperature: Float + /// Top-p nucleus sampling cutoff in `[0, 1]`. Ignored at temperature 0. + public var topP: Float + /// Hard upper bound on generated tokens. Implementations may stop + /// earlier on stop-sequence hit or EOS. + public var maxTokens: Int + /// Deterministic seed. `nil` asks the implementation to pick (which + /// makes the call non-reproducible). + public var seed: UInt64? + /// Strings that terminate generation when produced. Matched + /// greedy-left; the matched sequence is not included in the + /// returned string. + public var stop: [String] + + public init( + temperature: Float = 0, + topP: Float = 1.0, + maxTokens: Int = 1024, + seed: UInt64? = 42, + stop: [String] = [] + ) { + self.temperature = temperature + self.topP = topP + self.maxTokens = maxTokens + self.seed = seed + self.stop = stop + } +} diff --git a/Sources/Services/ManipulationsRepository.swift b/Sources/Services/ManipulationsRepository.swift new file mode 100644 index 0000000..1c2b3e3 --- /dev/null +++ b/Sources/Services/ManipulationsRepository.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Read-only snapshot of the chiropractic manipulations taxonomy loaded +/// from a bundled JSON resource. +/// +/// **Issue #6.** v1 ships with a seven-entry placeholder list at +/// `Resources/Manipulations/placeholder.json`. The real Cliniko taxonomy +/// replaces that file β€” the repository itself never changes, so the swap +/// is one file and zero code changes (EPIC acceptance criterion). +/// +/// **Call sites:** +/// - `ClinicalNotesPromptBuilder` (#4) enumerates `.all` into the LLM +/// prompt so the model knows which manipulation IDs it may select. +/// - `ReviewScreen` (#13) renders `.all` as the practitioner checklist. +/// - The Cliniko export mapping (#10) reads `clinikoCode` for each +/// selected ID. +/// +/// **Thread safety.** Immutable value type; `Sendable` by construction. +/// +/// **Not PHI.** Static taxonomy; nothing patient-specific reaches this +/// type. +struct ManipulationsRepository: Sendable, Equatable { + /// Every manipulation in the taxonomy, in the order declared by the + /// source JSON. Stable order is a UI contract β€” the ReviewScreen + /// checklist renders entries in this order. + let all: [Manipulation] + + // MARK: - Initialisation + + /// Seam used by tests and by callers assembling a repository from an + /// already-decoded list. Duplicate IDs are a programmer error β€” the + /// production `init(data:decoder:)` path enforces uniqueness at + /// runtime; this debug-only assertion flags fixtures that mis-declare. + init(all: [Manipulation]) { + assert( + Set(all.map(\.id)).count == all.count, + "ManipulationsRepository: manipulation IDs must be unique" + ) + self.all = all + } + + /// Decode a taxonomy from raw JSON bytes. + /// + /// - Throws: `DecodingError` if `data` cannot be parsed as the + /// expected `[Manipulation]` shape, or + /// `ManipulationsRepositoryError.duplicateIDs(_:)` if the parsed + /// list contains duplicate `id` values. Uniqueness matters because + /// `id` is the join key for `StructuredNotes.selectedManipulationIDs` + /// and the Cliniko export mapping (#10); a dup would silently + /// corrupt selection state. + init(data: Data, decoder: JSONDecoder = JSONDecoder()) throws { + let decoded = try decoder.decode([Manipulation].self, from: data) + let duplicates = Dictionary(grouping: decoded, by: \.id) + .filter { $0.value.count > 1 } + .keys + .sorted() + guard duplicates.isEmpty else { + throw ManipulationsRepositoryError.duplicateIDs(duplicates) + } + self.all = decoded + } + + // MARK: - Bundle loader + + /// Load the bundled taxonomy JSON. + /// + /// Defaults resolve to `Bundle.module` of the `SpeechToText` target + /// and `Resources/Manipulations/placeholder.json`. Production callers + /// should use the defaults; tests may pass a custom `bundle` to + /// point at test fixtures. + /// + /// - Throws: `ManipulationsRepositoryError.resourceNotFound` if the + /// named file is missing from the bundle, or a `DecodingError` if + /// the file is present but malformed. + static func loadFromBundle( + _ bundle: Bundle = .module, + resource: String = "placeholder", + subdirectory: String = "Manipulations" + ) throws -> ManipulationsRepository { + guard let url = bundle.url( + forResource: resource, + withExtension: "json", + subdirectory: subdirectory + ) else { + throw ManipulationsRepositoryError.resourceNotFound( + resource: resource, + subdirectory: subdirectory + ) + } + let data = try Data(contentsOf: url) + return try ManipulationsRepository(data: data) + } +} + +/// Failures surfaced by `ManipulationsRepository` initialisers. +enum ManipulationsRepositoryError: Error, Equatable { + /// The named resource is missing from the bundle. Usually a + /// build-system misconfiguration (e.g. a missing `.copy(...)` entry + /// in `Package.swift`). + case resourceNotFound(resource: String, subdirectory: String) + + /// The parsed taxonomy contains duplicate `id` values. Associated + /// value lists the offending IDs (sorted, deduplicated) so callers + /// and test assertions have a concrete diagnostic without logging + /// anything PHI-adjacent β€” the taxonomy itself is static, not + /// patient data. + case duplicateIDs([String]) +} diff --git a/Sources/Services/PermissionService.swift b/Sources/Services/PermissionService.swift index c28debb..c8b9b22 100644 --- a/Sources/Services/PermissionService.swift +++ b/Sources/Services/PermissionService.swift @@ -142,8 +142,15 @@ class PermissionService: PermissionChecker { /// Flag to signal polling should stop private var shouldStopPolling: Bool = false - /// App activation observer for immediate permission detection + /// App activation observer for immediate permission detection. + /// The nonisolated copy is the one `deinit` reads β€” `deinit` runs + /// nonisolated and Swift 6 strict-concurrency warns when it accesses + /// a main-actor-isolated `NSObjectProtocol` property (which is not + /// `Sendable`). Keep `activationObserver` as the main-actor source + /// of truth and mirror every write into `deinitActivationObserver` + /// for cleanup. Pattern matches `VoiceTriggerMonitoringService.swift`. private var activationObserver: NSObjectProtocol? + private nonisolated(unsafe) var deinitActivationObserver: NSObjectProtocol? /// Callback for when permission is granted during polling with activation observer private var onPermissionGrantedCallback: (@MainActor @Sendable () -> Void)? @@ -164,10 +171,13 @@ class PermissionService: PermissionChecker { } deinit { - // Clean up NotificationCenter observer to prevent memory leak - // Note: NotificationCenter.removeObserver is thread-safe, so we can call it from deinit - // even though the class is @MainActor isolated - if let observer = activationObserver { + // Clean up NotificationCenter observer to prevent memory leak. + // `NotificationCenter.removeObserver` is thread-safe, so calling + // it from a nonisolated `deinit` is fine β€” but reading the + // `@MainActor`-isolated `activationObserver` property from here + // isn't (NSObjectProtocol is not Sendable). Read from the + // nonisolated mirror instead. + if let observer = deinitActivationObserver { NotificationCenter.default.removeObserver(observer) } } @@ -183,6 +193,7 @@ class PermissionService: PermissionChecker { if let observer = activationObserver { NotificationCenter.default.removeObserver(observer) activationObserver = nil + deinitActivationObserver = nil } onPermissionGrantedCallback = nil } @@ -551,10 +562,11 @@ class PermissionService: PermissionChecker { if let existing = activationObserver { NotificationCenter.default.removeObserver(existing) activationObserver = nil + deinitActivationObserver = nil } // Create new observer - activationObserver = NotificationCenter.default.addObserver( + let observer = NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main @@ -583,6 +595,8 @@ class PermissionService: PermissionChecker { } } } + activationObserver = observer + deinitActivationObserver = observer } // MARK: - Enhanced Permission Request with Validation diff --git a/Sources/Services/SecureStore.swift b/Sources/Services/SecureStore.swift new file mode 100644 index 0000000..9d841ec --- /dev/null +++ b/Sources/Services/SecureStore.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Abstraction over the system Keychain so services can be unit-tested with an +/// in-memory fake without touching the real login keychain. +/// +/// Implementations must be thread-safe and `Sendable`. Reads of a missing key +/// return `nil` rather than throwing; `throws` is reserved for underlying OS +/// errors (e.g. access denied) that should propagate. +public protocol SecureStore: Sendable { + /// Store `data` under `key`, overwriting any existing value. + func set(_ data: Data, forKey key: String) async throws + + /// Retrieve the value for `key`, or `nil` if not present. + func get(forKey key: String) async throws -> Data? + + /// Remove the value for `key`. No-op if missing. + func delete(forKey key: String) async throws + + /// Remove every item owned by this store's namespace. Intended for "log + /// out" / "clear credentials" flows and for tests. + func deleteAll() async throws +} + +public extension SecureStore { + /// Convenience: store a UTF-8 string. + func setString(_ string: String, forKey key: String) async throws { + try await set(Data(string.utf8), forKey: key) + } + + /// Convenience: retrieve a UTF-8 string, or `nil` if missing. + func getString(forKey key: String) async throws -> String? { + guard let data = try await get(forKey: key) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/Sources/Services/SessionStore.swift b/Sources/Services/SessionStore.swift new file mode 100644 index 0000000..478bac6 --- /dev/null +++ b/Sources/Services/SessionStore.swift @@ -0,0 +1,147 @@ +import Foundation +import Observation + +/// Owns the in-memory lifecycle of a single `ClinicalSession`. +/// +/// There is exactly one active session at a time β€” from the completion of +/// a `RecordingSession` through LLM generation, practitioner review, and +/// Cliniko export. `clear()` drops the active session; callers are +/// expected to invoke it on: +/// - successful `treatment_note` export (#10), +/// - app termination / `NSApplication.willTerminateNotification`, +/// - user-initiated cancel, +/// - `checkIdleTimeout()` crossing the inactivity threshold. +/// +/// **PHI.** Everything on the active session is patient data (transcript, +/// draft SOAP note, patient/appointment IDs). This type therefore writes +/// nothing to disk, `UserDefaults`, or logs. See +/// `.claude/references/phi-handling.md`. +/// +/// Thread safety: +/// - `@MainActor`-isolated so SwiftUI views can observe `active` without +/// actor hops. +/// - No `@ObservationIgnored` dependencies today; if this store ever +/// gains an `any SomeActor`-typed collaborator, it must be marked +/// `@ObservationIgnored` (see `.claude/references/concurrency.md` Β§1). +@Observable +@MainActor +final class SessionStore { + // MARK: - Observed state + + /// The currently-active session, or `nil` if nothing is in flight. + private(set) var active: ClinicalSession? + + /// Timestamp of the most recent mutation to `active`. Used by + /// `checkIdleTimeout()`. + private(set) var lastActivity: Date + + // MARK: - Dependencies + + /// Injectable clock. Defaults to `Date.init`. Tests pass a + /// fake to drive `checkIdleTimeout` deterministically. + @ObservationIgnored private let now: @Sendable () -> Date + + /// How long `active` may sit unmodified before `checkIdleTimeout()` + /// discards it. Default 30 minutes. + @ObservationIgnored private let idleTimeout: TimeInterval + + // MARK: - Initialisation + + init( + idleTimeout: TimeInterval = 30 * 60, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.idleTimeout = idleTimeout + self.now = now + self.lastActivity = now() + } + + // MARK: - Lifecycle + + /// Start a new session from a completed `RecordingSession`. + /// Replaces any currently-active session. + func start(from recording: RecordingSession) { + let session = ClinicalSession(recordingSession: recording) + active = session + touch() + AppLogger.service.info("SessionStore: started session") + } + + /// Start from a pre-built `ClinicalSession`. Primarily used by tests + /// so they can set up a populated session without driving it through + /// the `setDraftNotes` / `setSelectedPatient` mutators one at a time. + /// Not currently used in production. + func start(_ session: ClinicalSession) { + active = session + touch() + AppLogger.service.info("SessionStore: started session") + } + + /// Discard the active session. Idempotent. + /// + /// Call sites: successful export, app quit, cancel, idle timeout. + func clear() { + guard active != nil else { return } + active = nil + touch() + AppLogger.service.info("SessionStore: cleared session") + } + + // MARK: - Mutations + + /// Attach or replace the LLM-generated SOAP draft. + func setDraftNotes(_ notes: StructuredNotes?) { + guard active != nil else { return } + active?.draftNotes = notes + touch() + } + + /// Record that the practitioner re-added a previously-excluded + /// snippet. Duplicates are ignored. + func markExcludedReAdded(_ snippet: String) { + guard let current = active?.excludedReAdded, !current.contains(snippet) else { return } + active?.excludedReAdded.append(snippet) + touch() + } + + /// Set the Cliniko patient selection. + func setSelectedPatient(id: String?) { + guard active != nil else { return } + active?.selectedPatientID = id + touch() + } + + /// Set the Cliniko appointment selection. + func setSelectedAppointment(id: String?) { + guard active != nil else { return } + active?.selectedAppointmentID = id + touch() + } + + // MARK: - Idle management + + /// Bumps `lastActivity` to "now". Called from every mutation above. + /// Exposed so the UI layer can surface activity (e.g. focus change in + /// the ReviewScreen) without mutating the session itself. + func touch() { + lastActivity = now() + } + + /// Host-callable inactivity check. If `now() - lastActivity` exceeds + /// `idleTimeout`, discards the active session. + /// + /// This store deliberately does not own a timer β€” the app lifecycle + /// (e.g. `NSApplication.willResignActive`) drives invocation so tests + /// stay deterministic and we avoid coupling the PHI layer to a + /// run-loop. + @discardableResult + func checkIdleTimeout() -> Bool { + guard active != nil else { return false } + let elapsed = now().timeIntervalSince(lastActivity) + guard elapsed > idleTimeout else { return false } + AppLogger.service.info("SessionStore: idle timeout exceeded, clearing session") + active = nil + lastActivity = now() + return true + } +} diff --git a/Sources/SpeechToTextApp/AppDelegate.swift b/Sources/SpeechToTextApp/AppDelegate.swift index f1824d4..176b377 100644 --- a/Sources/SpeechToTextApp/AppDelegate.swift +++ b/Sources/SpeechToTextApp/AppDelegate.swift @@ -282,16 +282,38 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // Observer for theme changes - applies NSAppearance app-wide - // Note: No Task wrapper - runs synchronously on main queue to prevent race conditions + // Observer for theme changes - applies NSAppearance app-wide. + // + // Intentionally *not* wrapped in `Task { @MainActor ... }`: + // - We pass `queue: .main`, so the callback runs on the main + // queue synchronously. Task dispatch would reorder rapid + // theme toggles. + // - `MainActor.assumeIsolated` is the Swift 6 tool for + // "compiler, trust me, I know this runs on MainActor". It's + // a zero-cost runtime-checked assertion, not a dispatch, + // so the synchronous semantics survive. If our precondition + // is ever wrong (callback invoked off-main), it traps + // immediately β€” exactly the failure mode we want. themeChangeObserver = NotificationCenter.default.addObserver( forName: .themeDidChange, object: nil, queue: .main ) { [weak self] _ in - guard let self else { return } - let settings = self.settingsService.load() - NSApp.appearance = settings.ui.theme.nsAppearance + MainActor.assumeIsolated { + // `guard let self` stays inside the isolated region so + // all `self.*` access happens in MainActor context β€” + // otherwise a future `self.foo` call added before the + // isolated block would regress the concurrency check. + guard let self else { return } + // `NSApp` is non-nil by construction: this observer is + // registered from `applicationDidFinishLaunching` (line 69), + // after the system has vended `NSApp`, and torn down in + // `applicationWillTerminate` before `NSApp` is released. If + // this observer setup is ever moved earlier in the lifecycle, + // the `NSApp.appearance` access below needs revisiting. + let settings = self.settingsService.load() + NSApp.appearance = settings.ui.theme.nsAppearance + } } } diff --git a/Sources/Views/ClinicalNotes/PatientPickerView.swift b/Sources/Views/ClinicalNotes/PatientPickerView.swift new file mode 100644 index 0000000..a5fe823 --- /dev/null +++ b/Sources/Views/ClinicalNotes/PatientPickerView.swift @@ -0,0 +1,277 @@ +import SwiftUI + +/// Two-pane patient picker: live-search field on the left, appointment +/// list (post-selection) on the right. +/// +/// Adheres to Warm Minimalism: frosted `.ultraThinMaterial` background, +/// amber accents from `Color+Theme.swift`, spring `(0.5, 0.7)` animations, +/// minimal chrome. +/// +/// PHI: every visible row contains patient data. The view is purely +/// presentational β€” no logging, no `print`, no analytics. State lives in +/// `PatientPickerViewModel` which lives only in memory. +struct PatientPickerView: View { + + /// VM is held as a `@Bindable` so SwiftUI tracks `@Observable` + /// property reads. Created by the host view (per the + /// `RecordingViewModel` pattern in this codebase) β€” never instantiated + /// inline with `@State` on the view, which can trigger the + /// actor-existential crash documented in + /// `.claude/references/concurrency.md` Β§1. + @Bindable var viewModel: PatientPickerViewModel + + /// Mirror of the search field's editable text. Decoupled from the VM + /// so SwiftUI's two-way binding can write here while the VM owns the + /// debounce β†’ search lifecycle through `updateQuery(_:)`. + @State private var searchText: String = "" + + var body: some View { + HStack(alignment: .top, spacing: 16) { + searchPane + .frame(minWidth: 280) + appointmentPane + .frame(minWidth: 280) + } + .padding(20) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: viewModel.searchPhase) + .animation(.spring(response: 0.5, dampingFraction: 0.7), value: viewModel.appointmentPhase) + } + + // MARK: - Search pane + + private var searchPane: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Patient") + .font(.headline) + .foregroundStyle(.primary) + + TextField("Search by name", text: $searchText) + .textFieldStyle(.roundedBorder) + .onChange(of: searchText) { _, newValue in + viewModel.updateQuery(newValue) + } + .accessibilityIdentifier("patient-picker-search-field") + + phaseContent + } + } + + @ViewBuilder + private var phaseContent: some View { + switch viewModel.searchPhase { + case .idle: + placeholderRow("Type to search for a patient") + case .searching: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Searching…").foregroundStyle(.secondary) + } + case .results(let patients): + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(patients) { patient in + patientRow(patient) + } + } + } + case .empty: + placeholderRow("No matches") + case .error(let error): + errorRow(error) + } + } + + private func patientRow(_ patient: Patient) -> some View { + Button { + viewModel.selectPatient(patient) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("\(patient.firstName) \(patient.lastName)") + .font(.body) + .foregroundStyle(.primary) + HStack(spacing: 8) { + if let dob = patient.dateOfBirth { + Label(dob, systemImage: "calendar") + .font(.caption) + .foregroundStyle(.secondary) + } + if let email = patient.email { + Label(email, systemImage: "envelope") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground(selected: viewModel.selectedPatient == patient)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("patient-row-\(patient.id)") + } + + // MARK: - Appointment pane + + private var appointmentPane: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Appointment") + .font(.headline) + .foregroundStyle(.primary) + + switch viewModel.appointmentPhase { + case .idle: + placeholderRow("Select a patient to see appointments") + case .loading: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading appointments…").foregroundStyle(.secondary) + } + case .loaded(let appointments): + appointmentList(appointments) + case .error(let error): + errorRow(error) + } + } + } + + @ViewBuilder + private func appointmentList(_ appointments: [Appointment]) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + noAppointmentRow + ForEach(appointments) { appointment in + appointmentRow(appointment) + } + } + } + } + + private var noAppointmentRow: some View { + Button { + viewModel.selectAppointment(id: nil) + } label: { + HStack { + Image(systemName: "circle.dashed") + Text("No appointment / general note") + .foregroundStyle(.primary) + Spacer() + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground(selected: viewModel.selectedAppointmentID == nil + && viewModel.selectedPatient != nil)) + } + .buttonStyle(.plain) + .accessibilityIdentifier("appointment-row-none") + } + + private func appointmentRow(_ appointment: Appointment) -> some View { + Button { + viewModel.selectAppointment(id: appointment.id) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(formatAppointmentTime(appointment)) + .font(.body) + .foregroundStyle(.primary) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground( + selected: viewModel.selectedAppointmentID == appointment.id + )) + } + .buttonStyle(.plain) + .accessibilityIdentifier("appointment-row-\(appointment.id)") + } + + /// Static formatter β€” `DateFormatter` allocation is non-trivial and + /// we'd otherwise rebuild one per row on every body re-evaluation. + /// Both styles are locale-aware so this displays correctly in any + /// of the AU/UK/US jurisdictions the picker ships into. + private static let appointmentTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + private func formatAppointmentTime(_ appointment: Appointment) -> String { + Self.appointmentTimeFormatter.string(from: appointment.startsAt) + } + + // MARK: - Shared row pieces + + private func placeholderRow(_ text: String) -> some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.tertiary) + Text(text).foregroundStyle(.secondary) + Spacer() + } + .padding(8) + } + + private func errorRow(_ error: ClinikoError) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.amberBright) + Text(humanReadable(error)) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + } + .padding(8) + } + + private func humanReadable(_ error: ClinikoError) -> String { + switch error { + case .unauthenticated: + return "Cliniko didn't accept the API key. Open Settings to update it." + case .forbidden: + // `.forbidden` for a list endpoint is most often a key-scope + // issue rather than a per-resource ACL β€” call that out so + // users don't go in circles asking their Cliniko admin when + // the fix is a regenerated key with the right scope. + return "Your Cliniko API key is valid but lacks permission. " + + "Check the key's scopes in Cliniko or contact your admin." + case .notFound(let resource): + return notFoundMessage(for: resource) + case .validation: + return "Cliniko rejected the request β€” please check the input." + case .rateLimited: + return "Cliniko is throttling requests. Try again shortly." + case .server: + return "Cliniko had a server error. Try again." + case .transport: + return "Couldn't reach Cliniko. Check your connection." + case .cancelled: + return "Request cancelled." + case .decoding: + // `.decoding` always indicates either a Cliniko-side schema + // change or a bug on our side β€” the user can't fix it, but + // they can report it so we know to ship a fix. + return "Cliniko returned an unexpected response shape. " + + "If this persists, please report it." + case .nonHTTPResponse: + return "Cliniko returned an unexpected response." + } + } + + /// Resource-specific copy for `.notFound` so the picker tells the user + /// what's missing rather than a generic "no match" β€” more actionable + /// across the patient / appointment panes. Extracted from + /// `humanReadable(_:)` to keep its cyclomatic complexity in check. + private func notFoundMessage(for resource: ClinikoError.Resource) -> String { + switch resource { + case .patient: return "No matching patient in Cliniko." + case .appointment: return "No matching appointment in Cliniko." + case .user: return "Cliniko couldn't find your user record." + case .treatmentNote: return "Cliniko couldn't find that treatment note." + } + } + + private func rowBackground(selected: Bool) -> some View { + RoundedRectangle(cornerRadius: 8) + .fill(selected ? Color.amberLight.opacity(0.4) : Color.clear) + } +} diff --git a/Sources/Views/ClinicalNotes/PatientPickerViewModel.swift b/Sources/Views/ClinicalNotes/PatientPickerViewModel.swift new file mode 100644 index 0000000..49029fc --- /dev/null +++ b/Sources/Views/ClinicalNotes/PatientPickerViewModel.swift @@ -0,0 +1,250 @@ +import Foundation +import Observation +import os.log + +/// Picker view-model for selecting a Cliniko patient + (optionally) one of +/// their recent appointments. Owns the debounced search lifecycle, swaps +/// the active patient + appointment-list phase, and writes selections +/// through to the supplied `SessionStore`. +/// +/// PHI: query strings, patient names, DOBs, and appointment timing all +/// flow through this VM. None of them are logged. Service references are +/// `@ObservationIgnored` so the existential-actor + `@Observable` +/// crash-pattern from `.claude/references/concurrency.md` Β§1 stays +/// avoided. +@Observable +@MainActor +final class PatientPickerViewModel { + + /// Phase machine for the patient-search panel. + enum SearchPhase: Sendable, Equatable { + case idle + case searching + case results([Patient]) + case empty + case error(ClinikoError) + } + + /// Phase machine for the per-patient appointments panel. + enum AppointmentPhase: Sendable, Equatable { + case idle + case loading + case loaded([Appointment]) + case error(ClinikoError) + } + + // MARK: - Observed state + + /// The current debounced query value. Bound from the search field via + /// `updateQuery(_:)` rather than a writable property, so the VM owns + /// the cancellation + debounce semantics. + private(set) var query: String = "" + + private(set) var searchPhase: SearchPhase = .idle + + private(set) var selectedPatient: Patient? + + private(set) var appointmentPhase: AppointmentPhase = .idle + + /// Local mirror of `ClinicalSession.selectedAppointmentID`, kept as an + /// `Int?` for the picker UI. `nil` means "No appointment / general + /// note" (the post-recording note doesn't tie to an appointment). + private(set) var selectedAppointmentID: Int? + + // MARK: - Dependencies + + @ObservationIgnored private let patientService: any ClinikoPatientSearching + @ObservationIgnored private let appointmentService: any ClinikoAppointmentLoading + @ObservationIgnored private let sessionStore: SessionStore + @ObservationIgnored private let debounceMillis: UInt64 + @ObservationIgnored private let now: @Sendable () -> Date + + // MARK: - Mutable internal state + + @ObservationIgnored private var searchTask: Task? + @ObservationIgnored private var appointmentTask: Task? + @ObservationIgnored private let logger = Logger( + subsystem: "com.speechtotext", + category: "PatientPickerViewModel" + ) + + // MARK: - Init + + /// - Parameters: + /// - patientService / appointmentService: the actor-constrained + /// services. Tests pass in-test actor fakes. + /// - sessionStore: where patient / appointment selections are + /// persisted within the active `ClinicalSession`. + /// - debounceMillis: how long to wait after the last keystroke + /// before issuing a search. Tests pass `0` to bypass. + /// - now: clock for the `recentAndTodayAppointments(reference:)` + /// anchor β€” `Date()` in production, fixed in tests. + init( + patientService: any ClinikoPatientSearching, + appointmentService: any ClinikoAppointmentLoading, + sessionStore: SessionStore, + debounceMillis: UInt64 = 300, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.patientService = patientService + self.appointmentService = appointmentService + self.sessionStore = sessionStore + self.debounceMillis = debounceMillis + self.now = now + } + + // MARK: - Search + + /// Update the search query. Cancels any in-flight search task and + /// schedules a new debounced one. Empty / whitespace-only queries + /// reset the panel to `.idle` without firing a network call β€” + /// satisfying #9's "first keystroke β†’ no network call" acceptance. + func updateQuery(_ newQuery: String) { + query = newQuery + searchTask?.cancel() + let trimmed = newQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + searchPhase = .idle + return + } + let debounce = debounceMillis + searchTask = Task { [weak self] in + if debounce > 0 { + do { + try await Task.sleep(nanoseconds: debounce * 1_000_000) + } catch { + return // cancelled β€” caller already replaced the task. + } + } + // After the debounce window, re-check cancellation before + // issuing the network request. Without this, a fast typist + // can race the cancel with the sleep return. + guard !Task.isCancelled else { return } + await self?.performSearch(trimmed) + } + } + + private func performSearch(_ trimmed: String) async { + searchPhase = .searching + do { + let patients = try await patientService.searchPatients(query: trimmed) + // Stale-result guard: if the query was further mutated while + // we were awaiting, a new searchTask is already in flight; + // reset to `.idle` rather than leave the UI stuck on + // `.searching` if no follow-up search task fires (e.g. host + // view dismissed mid-flight). + guard !Task.isCancelled else { + if searchPhase == .searching { searchPhase = .idle } + return + } + searchPhase = patients.isEmpty ? .empty : .results(patients) + } catch let error as ClinikoError { + // `.cancelled` is the user-typing-faster-than-the-network + // case: swallow silently so the UI doesn't flash an error, + // but reset stale `.searching` so the UI doesn't get stuck. + if case .cancelled = error { + if searchPhase == .searching { searchPhase = .idle } + return + } + searchPhase = .error(error) + } catch is CancellationError { + if searchPhase == .searching { searchPhase = .idle } + return + } catch { + // The service layer is contractually `throws ClinikoError` + // only β€” anything else is a programmer bug we want to know + // about. Crash in DEBUG so it gets caught in test/dev; in + // RELEASE, log structurally and degrade to a transport- + // shaped error so the UI has something concrete to render. + // PHI: only the Swift type name is logged (structural). + let typeName = String(reflecting: Swift.type(of: error)) + logger.error( + "PatientPickerViewModel: non-ClinikoError from patientService type=\(typeName, privacy: .public)" + ) + assertionFailure("PatientPickerViewModel: non-ClinikoError from patientService: \(typeName)") + searchPhase = .error(.transport(.unknown)) + } + } + + // MARK: - Selection + + /// Select a patient. Clears any prior appointment selection, kicks + /// off the appointment-list load, and writes through to the session + /// store immediately so downstream UI (export panel) sees the + /// selection without waiting on the appointment fetch. + func selectPatient(_ patient: Patient) { + selectedPatient = patient + sessionStore.setSelectedPatient(id: String(patient.id)) + sessionStore.setSelectedAppointment(id: nil) + selectedAppointmentID = nil + appointmentPhase = .loading + appointmentTask?.cancel() + appointmentTask = Task { [weak self] in + await self?.loadAppointments(for: patient) + } + } + + private func loadAppointments(for patient: Patient) async { + let reference = now() + do { + let appointments = try await appointmentService.recentAndTodayAppointments( + forPatientID: String(patient.id), + reference: reference + ) + // See `performSearch` for the cancel-guard rationale. + guard !Task.isCancelled else { + if appointmentPhase == .loading { appointmentPhase = .idle } + return + } + appointmentPhase = .loaded(appointments) + } catch let error as ClinikoError { + // `.cancelled` from the service is only safe to swallow + // when our local task was actually cancelled (the user + // picked a different patient or dismissed the picker). + // A URLSession-level cancel that arrives without our task + // being cancelled is a real failure the user should see. + if case .cancelled = error { + if Task.isCancelled { + if appointmentPhase == .loading { appointmentPhase = .idle } + return + } + appointmentPhase = .error(error) + return + } + appointmentPhase = .error(error) + } catch is CancellationError { + if appointmentPhase == .loading { appointmentPhase = .idle } + return + } catch { + // Same contract as performSearch β€” service layer is + // `throws ClinikoError` only. + let typeName = String(reflecting: Swift.type(of: error)) + logger.error( + "PatientPickerViewModel: non-ClinikoError from appointmentService type=\(typeName, privacy: .public)" + ) + assertionFailure("PatientPickerViewModel: non-ClinikoError from appointmentService: \(typeName)") + appointmentPhase = .error(.transport(.unknown)) + } + } + + /// Select an appointment, or `nil` for "No appointment / general + /// note". Writes through to the session store. + func selectAppointment(id: Int?) { + selectedAppointmentID = id + sessionStore.setSelectedAppointment(id: id.map(String.init)) + } + + /// Clear the entire selection state. Used by the host view when the + /// picker is dismissed without confirmation. + func clearSelection() { + searchTask?.cancel() + appointmentTask?.cancel() + selectedPatient = nil + selectedAppointmentID = nil + appointmentPhase = .idle + searchPhase = .idle + query = "" + sessionStore.setSelectedPatient(id: nil) + sessionStore.setSelectedAppointment(id: nil) + } +} diff --git a/Sources/Views/Components/ParticleVortexWaveform.swift b/Sources/Views/Components/ParticleVortexWaveform.swift index 66944cb..dc869c9 100644 --- a/Sources/Views/Components/ParticleVortexWaveform.swift +++ b/Sources/Views/Components/ParticleVortexWaveform.swift @@ -145,9 +145,31 @@ struct ParticleVortexWaveform: View { // MARK: - Animation Loop private func startAnimation() { - displayLink = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { _ in - updateParticles() + // Schedule the animation timer explicitly on `RunLoop.main` with + // `.common` mode: + // + // - `RunLoop.main` (not `.scheduledTimer(...)`'s implicit + // `RunLoop.current`) guarantees `MainActor.assumeIsolated` + // holds even if a caller β€” SwiftUI previews in live mode, + // or a future refactor that wraps `onAppear` in a detached + // Task β€” isn't on the main run loop. `RunLoop.current` is + // a footgun: the compiler can't check it, and a trap at + // 60 Hz is a bad outage. + // - `.common` keeps the animation running during modal tracking + // (menu bar, sheet). Default mode pauses β€” a pre-existing + // quality-of-life bug picked up during the concurrency + // audit. + // + // `assumeIsolated` is preferred over `Task { @MainActor in … }` + // because this timer fires 60Γ— per second; Task dispatch would + // allocate + queue-hop every frame and drop animation frames. + let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { _ in + MainActor.assumeIsolated { + updateParticles() + } } + RunLoop.main.add(timer, forMode: .common) + displayLink = timer } private func stopAnimation() { diff --git a/Sources/Views/MainView/MainView.swift b/Sources/Views/MainView/MainView.swift index be4fb08..3e8b747 100644 --- a/Sources/Views/MainView/MainView.swift +++ b/Sources/Views/MainView/MainView.swift @@ -34,6 +34,7 @@ struct MainView: View { @State private var languageViewModel: LanguageSectionViewModel? @State private var privacyViewModel: PrivacySectionViewModel? + @State private var clinicalNotesViewModel: ClinicalNotesSectionViewModel? @State private var aboutViewModel: AboutSectionViewModel? // MARK: - Initialization @@ -261,6 +262,9 @@ struct MainView: View { if privacyViewModel == nil { privacyViewModel = PrivacySectionViewModel() } + if clinicalNotesViewModel == nil { + clinicalNotesViewModel = ClinicalNotesSectionViewModel() + } if aboutViewModel == nil { aboutViewModel = AboutSectionViewModel() } @@ -296,6 +300,12 @@ struct MainView: View { } else { PrivacySectionPlaceholder() } + case .clinicalNotes: + if let clinicalNotesVM = clinicalNotesViewModel { + ClinicalNotesSection(viewModel: clinicalNotesVM) + } else { + ClinicalNotesSectionPlaceholder() + } case .about: if let aboutVM = aboutViewModel { AboutSection(viewModel: aboutVM) @@ -320,6 +330,12 @@ private struct PrivacySectionPlaceholder: View { } } +private struct ClinicalNotesSectionPlaceholder: View { + var body: some View { + GlassPlaceholder(icon: "stethoscope", title: "Clinical Notes Section") + } +} + private struct AboutSectionPlaceholder: View { var body: some View { GlassPlaceholder(icon: "info.circle", title: "About Section") diff --git a/Sources/Views/MainView/MainViewModel.swift b/Sources/Views/MainView/MainViewModel.swift index 6caf197..ce7469a 100644 --- a/Sources/Views/MainView/MainViewModel.swift +++ b/Sources/Views/MainView/MainViewModel.swift @@ -20,6 +20,7 @@ enum SidebarSection: String, CaseIterable, Identifiable, Codable { case language case theme case privacy + case clinicalNotes case about var id: String { rawValue } @@ -34,6 +35,7 @@ enum SidebarSection: String, CaseIterable, Identifiable, Codable { case .language: return "Language" case .theme: return "Theme" case .privacy: return "Privacy" + case .clinicalNotes: return "Clinical Notes" case .about: return "About" } } @@ -48,6 +50,7 @@ enum SidebarSection: String, CaseIterable, Identifiable, Codable { case .language: return "globe" case .theme: return "paintbrush" case .privacy: return "lock.shield" + case .clinicalNotes: return "stethoscope" case .about: return "info.circle" } } diff --git a/Sources/Views/MainView/Sections/ClinicalNotesSection.swift b/Sources/Views/MainView/Sections/ClinicalNotesSection.swift new file mode 100644 index 0000000..9b108c1 --- /dev/null +++ b/Sources/Views/MainView/Sections/ClinicalNotesSection.swift @@ -0,0 +1,582 @@ +// ClinicalNotesSection.swift +// macOS Local Speech-to-Text Application +// +// Cliniko credentials + Clinical Notes Mode settings (issue #7). +// The Clinical Notes Mode toggle itself ships in #11; this section currently +// owns only the Cliniko-export credentials surface. + +import SwiftUI + +/// Settings section for Cliniko credentials. The doctor pastes their Cliniko +/// API key, picks the regional shard, optionally tests the connection, and +/// can clear credentials. Per `.claude/references/cliniko-api.md` the key is +/// stored in Keychain via `ClinikoCredentialStore`; the shard goes to +/// `UserDefaults`. +struct ClinicalNotesSection: View { + @Bindable var viewModel: ClinicalNotesSectionViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + sectionHeader + + connectionStatusCard + + Divider().padding(.vertical, 4) + + apiKeyEntrySection + + shardPickerSection + + actionButtons + + if let message = viewModel.statusMessage { + statusBanner(message: message, kind: viewModel.connectionStatus) + } + + Spacer(minLength: 20) + + privacyFooter + } + .padding(20) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("clinicalNotesSection") + .task { + await viewModel.refreshState() + } + } + + // MARK: - Section Header + + private var sectionHeader: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Clinical Notes") + .font(.title2) + .fontWeight(.semibold) + .accessibilityAddTraits(.isHeader) + + Text("Cliniko credentials and clinical-notes export") + .font(.callout) + .foregroundStyle(.secondary) + } + .accessibilityIdentifier("clinicalNotesSection.header") + } + + // MARK: - Connection Status Card + + private var connectionStatusCard: some View { + let display = viewModel.statusCardDisplay + return HStack(spacing: 16) { + Image(systemName: display.icon) + .font(.system(size: 28)) + .foregroundStyle(display.tint) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(display.title) + .font(.headline) + .foregroundStyle(.primary) + + Text(display.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(display.tint.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("clinicalNotesSection.statusCard") + } + + // MARK: - API Key Entry + + private var apiKeyEntrySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("API key") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.secondary) + + SecureField( + viewModel.hasStoredCredentials ? "β€’β€’β€’β€’β€’β€’β€’β€’ (paste a new key to replace)" : "Paste your Cliniko API key", + text: $viewModel.apiKeyDraft + ) + .textFieldStyle(.roundedBorder) + .disableAutocorrection(true) + .accessibilityIdentifier("clinicalNotesSection.apiKeyField") + + Text("Find this in Cliniko under My Info β†’ Manage API keys. The key is stored in macOS Keychain on this Mac only.") + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + + // MARK: - Shard Picker + + private var shardPickerSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Region (shard)") + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(.secondary) + + Picker("Shard", selection: $viewModel.selectedShard) { + ForEach(ClinikoShard.allCases) { shard in + Text(shard.displayName).tag(shard) + } + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityIdentifier("clinicalNotesSection.shardPicker") + + Text("Pick the region your Cliniko tenant is hosted in. The shard is part of your account URL (e.g. au1 in `cliniko.com.au/...`).") + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + + // MARK: - Action Buttons + + private var actionButtons: some View { + HStack(spacing: 12) { + Button { + Task { await viewModel.saveAndTest() } + } label: { + Label( + viewModel.hasStoredCredentials ? "Update & test" : "Save & test connection", + systemImage: "checkmark.seal" + ) + .frame(minWidth: 0) + } + .buttonStyle(.borderedProminent) + .tint(Color.warmAmber) + .disabled(viewModel.isBusy || !viewModel.isApiKeyDraftValid) + .accessibilityIdentifier("clinicalNotesSection.saveButton") + + Button { + Task { await viewModel.testConnection() } + } label: { + Label("Test connection", systemImage: "antenna.radiowaves.left.and.right") + } + .buttonStyle(.bordered) + .disabled(viewModel.isBusy || !viewModel.hasStoredCredentials) + .accessibilityIdentifier("clinicalNotesSection.testButton") + + Spacer() + + Button(role: .destructive) { + Task { await viewModel.removeCredentials() } + } label: { + Label("Remove", systemImage: "trash") + } + .buttonStyle(.bordered) + .disabled(viewModel.isBusy || !viewModel.hasStoredCredentials) + .accessibilityIdentifier("clinicalNotesSection.removeButton") + } + } + + // MARK: - Status Banner + + @ViewBuilder + private func statusBanner(message: String, kind: ClinicalNotesSectionViewModel.ConnectionStatus) -> some View { + let (icon, tint): (String, Color) = { + switch kind { + case .testing: return ("hourglass", Color.secondary) + case .success: return ("checkmark.circle.fill", Color.successGreen) + case .failure: return ("exclamationmark.triangle.fill", Color.red) + case .idle: return ("info.circle", Color.secondary) + } + }() + + HStack(alignment: .top, spacing: 8) { + Image(systemName: icon) + .foregroundStyle(tint) + Text(message) + .font(.callout) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + .padding(12) + .background(tint.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("clinicalNotesSection.statusBanner") + } + + // MARK: - Privacy Footer + + private var privacyFooter: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + + HStack(spacing: 4) { + Image(systemName: "checkmark.shield") + .font(.caption) + .foregroundStyle(Color.successGreen) + + Text("Your patient data stays on this Mac") + .font(.caption) + .foregroundStyle(.secondary) + } + + Text("Transcripts and notes are kept in memory only and cleared on export or quit. The only network call goes from this Mac directly to your Cliniko tenant.") + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("clinicalNotesSection.privacyFooter") + } +} + +// MARK: - ViewModel + +/// State + side-effects for `ClinicalNotesSection`. Owns a +/// `ClinikoCredentialStore` and `ClinikoAuthProbe`; never reads the API key +/// back from Keychain into VM-visible state. Both dependencies are actor +/// existentials and live behind `@ObservationIgnored` per the project's +/// concurrency rule (`@Observable` + actor existential without +/// `@ObservationIgnored` crashes; see `.claude/references/concurrency.md`). +@Observable +@MainActor +final class ClinicalNotesSectionViewModel { + /// Outcome of the most recent network probe + transient testing state. + /// Drives the status banner only β€” the *card* uses `verificationStatus` + /// so a save without a successful probe doesn't render as "verified". + enum ConnectionStatus: Equatable { + case idle + case testing + case success + case failure + } + + /// What `hasAPIKey()` last reported. Distinguishes "Keychain says absent" + /// from "Keychain read errored" so #11's Clinical Notes Mode toggle + /// won't silently disable itself when the Keychain is transiently locked. + enum CredentialLoadState: Equatable { + case unknown + case present + case absent + case readFailed + } + + /// Whether the credentials currently in the store have been verified + /// against Cliniko by a successful probe in this app session. The status + /// card derives its colour + message from this β€” saving alone doesn't + /// flip the card to "verified". + enum VerificationStatus: Equatable { + case absent + case unverified + case verified + case readError + } + + /// The user's in-flight API-key input. Cleared after a successful save so + /// the secret only lives in VM memory for the duration of the entry. + var apiKeyDraft: String = "" + + /// Whether the current draft is non-empty after trimming. Centralises the + /// "is the save button enabled" check so both the view's button and the + /// VM's `saveAndTest` guard apply the same rule. + var isApiKeyDraftValid: Bool { + !apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Picker selection. Changes are persisted to UserDefaults via the store + /// when the user hits Save; merely changing the picker without saving is + /// reverted on next refresh. + var selectedShard: ClinikoShard = .default { + didSet { + guard oldValue != selectedShard, hasStoredCredentials, !isApplyingExternalUpdate else { return } + // Persist shard changes immediately when credentials already exist β€” + // no need to re-enter the API key for a region change. The store's + // `updateShard` is `nonisolated` so this stays a synchronous call. + credentialStore.updateShard(selectedShard) + // Re-pointing at a different tenant invalidates any prior probe. + verificationStatus = .unverified + } + } + + private(set) var credentialState: CredentialLoadState = .unknown + private(set) var verificationStatus: VerificationStatus = .absent + private(set) var connectionStatus: ConnectionStatus = .idle + private(set) var statusMessage: String? + private(set) var isBusy: Bool = false + + /// Convenience flag for #11 to gate the Clinical Notes Mode toggle on, + /// and for the view to gate the Remove / Test buttons. Returns `true` + /// when we have either positively confirmed credentials OR a Keychain + /// read error has prevented us from telling β€” both of those mean + /// "credentials may exist on this device", so the user must be allowed + /// to click Remove (otherwise the banner's own "Try removing and + /// re-adding your API key" advice becomes a UX deadlock when the + /// Keychain is transiently locked). + var hasStoredCredentials: Bool { + credentialState == .present || credentialState == .readFailed + } + + @ObservationIgnored private let credentialStore: ClinikoCredentialStore + @ObservationIgnored private let authProbe: ClinikoAuthProbe + @ObservationIgnored private var isApplyingExternalUpdate: Bool = false + + init( + credentialStore: ClinikoCredentialStore = ClinikoCredentialStore(), + authProbe: ClinikoAuthProbe = ClinikoAuthProbe() + ) { + self.credentialStore = credentialStore + self.authProbe = authProbe + } + + /// Refresh `credentialState` + `selectedShard` from the persisted store. + /// Called from `.task { … }` on the section view. + func refreshState() async { + let shard = credentialStore.loadShard() + applyExternalUpdate { self.selectedShard = shard } + + do { + let present = try await credentialStore.hasAPIKey() + credentialState = present ? .present : .absent + // After a refresh we don't yet know if the stored key still + // works β€” a verification probe runs only on user action. + if present { + if verificationStatus == .absent || verificationStatus == .readError { + verificationStatus = .unverified + } + } else { + verificationStatus = .absent + } + } catch { + // Keychain read failed (locked session, signing regression, etc.). + // We deliberately do NOT flip `credentialState` to `.absent`: that + // would silently disable Clinical Notes Mode for any consumer + // gating on `hasStoredCredentials`. + credentialState = .readFailed + verificationStatus = .readError + connectionStatus = .failure + statusMessage = "Could not read stored credentials. Try removing and re-adding your API key." + } + } + + /// Persist the API key + shard, then probe `/users/me` to confirm the key + /// works. The probe failure does not roll back the save β€” operators + /// frequently rotate keys while offline. + func saveAndTest() async { + let trimmed = apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + connectionStatus = .failure + statusMessage = "Paste an API key before saving." + return + } + + isBusy = true + connectionStatus = .testing + statusMessage = "Saving credentials and contacting Cliniko…" + defer { isBusy = false } + + do { + try await credentialStore.saveCredentials(apiKey: trimmed, shard: selectedShard) + } catch ClinikoCredentialStore.Failure.missingAPIKey { + connectionStatus = .failure + statusMessage = "Paste an API key before saving." + return + } catch { + connectionStatus = .failure + statusMessage = "Could not save the API key to Keychain." + return + } + + // Save succeeded β€” clear the draft regardless of probe outcome so the + // secret stops living in VM memory. + apiKeyDraft = "" + credentialState = .present + // The card stays "unverified" until the probe succeeds; runProbe + // promotes it to `.verified` on 2xx. + verificationStatus = .unverified + + await runProbe() + } + + /// Run `/users/me` against the currently stored credentials. Used by the + /// "Test connection" button when credentials are already saved. + func testConnection() async { + guard hasStoredCredentials else { + connectionStatus = .failure + statusMessage = "No credentials saved yet." + return + } + isBusy = true + connectionStatus = .testing + statusMessage = "Contacting Cliniko…" + defer { isBusy = false } + + await runProbe() + } + + /// Delete the API key + shard from disk and reset the UI back to a clean + /// empty state. AC item 4 ("removing credentials disables Clinical Notes + /// Mode toggle") will be enforced by #11 once that toggle ships β€” it can + /// gate on `hasStoredCredentials` exposed here. + func removeCredentials() async { + isBusy = true + defer { isBusy = false } + do { + try await credentialStore.deleteCredentials() + } catch { + // Keychain delete failed but `deleteCredentials` clears the + // shard `defer`-style; the API key is likely still on this Mac. + connectionStatus = .failure + statusMessage = "Could not remove credentials. Your API key may still be stored β€” open Keychain Access to clear it manually." + return + } + applyExternalUpdate { + self.selectedShard = .default + self.apiKeyDraft = "" + } + credentialState = .absent + verificationStatus = .absent + connectionStatus = .idle + statusMessage = "Cliniko credentials removed." + } + + // MARK: - Status card derivation + + /// Display info for the connection status card. Pure function of state β€” + /// makes the card test-friendly without exposing colours / icons in the + /// VM API. + struct StatusCardDisplay: Equatable { + let icon: String + let tint: Color + let title: String + let subtitle: String + } + + var statusCardDisplay: StatusCardDisplay { + switch verificationStatus { + case .absent: + return StatusCardDisplay( + icon: "lock.shield", + tint: Color.warmAmber, + title: "No Cliniko credentials", + subtitle: "Add your Cliniko API key below to enable clinical-notes export." + ) + case .unverified: + return StatusCardDisplay( + icon: "exclamationmark.shield", + tint: Color.warmAmber, + title: "Saved but not yet verified", + subtitle: "Your API key is stored on this Mac. Use Test connection to verify it against \(selectedShard.displayName)." + ) + case .verified: + return StatusCardDisplay( + icon: "checkmark.shield.fill", + tint: Color.successGreen, + title: "Connected to Cliniko", + subtitle: "Verified against \(selectedShard.displayName). Your API key is stored on this Mac only." + ) + case .readError: + return StatusCardDisplay( + icon: "exclamationmark.triangle.fill", + tint: Color.red, + title: "Could not read stored credentials", + subtitle: "macOS Keychain returned an error. Removing and re-adding your API key usually resolves this." + ) + } + } + + // MARK: - Private + + private func runProbe() async { + let credentials: ClinikoCredentials? + do { + credentials = try await credentialStore.loadCredentials() + } catch { + credentialState = .readFailed + verificationStatus = .readError + connectionStatus = .failure + statusMessage = "Could not read the saved API key." + return + } + guard let credentials else { + credentialState = .absent + verificationStatus = .absent + connectionStatus = .failure + statusMessage = "No credentials saved." + return + } + + do { + try await authProbe.ping(credentials: credentials) + verificationStatus = .verified + connectionStatus = .success + statusMessage = "Connected to Cliniko (\(credentials.shard.displayName))." + } catch ClinikoAuthProbeError.unauthorized { + verificationStatus = .unverified + connectionStatus = .failure + statusMessage = "Cliniko rejected the API key. Double-check the key and the selected region." + } catch ClinikoAuthProbeError.http(let status) { + verificationStatus = .unverified + connectionStatus = .failure + statusMessage = "Cliniko responded with HTTP \(status). Try again, or contact Cliniko support." + } catch ClinikoAuthProbeError.transport(let code) { + verificationStatus = .unverified + connectionStatus = .failure + statusMessage = transportFailureMessage(for: code) + } catch ClinikoAuthProbeError.cancelled { + // User navigated away or session was invalidated. Don't render + // a misleading "could not reach Cliniko" message. + verificationStatus = .unverified + connectionStatus = .idle + statusMessage = nil + } catch ClinikoAuthProbeError.nonHTTPResponse { + verificationStatus = .unverified + connectionStatus = .failure + statusMessage = "Cliniko returned an unexpected response. Try again." + } catch { + // Catches `.unknown(typeName:)` and any unexpected throw type + // with the same message β€” splitting them adds no UX value while + // duplicating the branch arm. + verificationStatus = .unverified + connectionStatus = .failure + statusMessage = "Unexpected error contacting Cliniko." + } + } + + private func transportFailureMessage(for code: URLError.Code) -> String { + switch code { + case .cannotFindHost, .dnsLookupFailed: + return "Could not reach api.\(selectedShard.rawValue).cliniko.com β€” is the region correct?" + case .notConnectedToInternet, .networkConnectionLost: + return "You appear to be offline. Reconnect and try again." + case .timedOut: + return "The request to Cliniko timed out. Try again." + case .secureConnectionFailed, .serverCertificateUntrusted, .serverCertificateHasBadDate, .serverCertificateNotYetValid: + return "Cliniko's TLS certificate could not be verified." + default: + return "Could not reach Cliniko β€” check your internet connection and try again." + } + } + + /// Wrap a property update so the `selectedShard.didSet` knows not to + /// echo the change back to the credential store β€” used during refresh + /// and remove flows. + private func applyExternalUpdate(_ apply: () -> Void) { + isApplyingExternalUpdate = true + apply() + isApplyingExternalUpdate = false + } +} + +// MARK: - Previews + +#Preview("Clinical Notes Section") { + ClinicalNotesSection(viewModel: ClinicalNotesSectionViewModel()) + .frame(width: 640, height: 700) +} diff --git a/Tests/SpeechToTextTests/Fixtures/README.md b/Tests/SpeechToTextTests/Fixtures/README.md new file mode 100644 index 0000000..ea31e5e --- /dev/null +++ b/Tests/SpeechToTextTests/Fixtures/README.md @@ -0,0 +1,37 @@ +# Test Fixtures + +JSON + text fixtures shipped with the test bundle via `Package.swift` +(`resources: [.copy("Fixtures")]`) and loaded via `HTTPStubFixture.load(_:)`. + +## Layout + +``` +Fixtures/ + cliniko/ + requests/.json # expected outgoing payloads (golden checks) + responses/.json # stub responses returned by URLProtocolStub + soap/ + valid/.json # valid SOAP JSON the LLM should emit + invalid/.json # edge cases the schema guard must reject + llm/ + prompts/.txt + expected/.json # golden output (used only when RUN_MLX_GOLDEN=1) +``` + +## Naming + +Use `snake_case` that matches the endpoint, e.g. `users_me.json`, +`patients_search.json`. For variants of the same endpoint, add a suffix: +`patients_search_empty.json`, `patients_search_paginated.json`. + +## When to add a fixture vs inline the data + +- **Add a fixture** when the payload is more than ~10 lines or the same shape + is reused across tests. +- **Inline** small one-off payloads so the test narrative stays readable. + +## Updating fixtures + +Fixtures are code. Commit changes with a PR that explains **why** the shape +changed (e.g. Cliniko API version bump, new field added, schema tightened). +Do not auto-regenerate from production data β€” fixtures must never contain PHI. diff --git a/Tests/SpeechToTextTests/Fixtures/cliniko/requests/.gitkeep b/Tests/SpeechToTextTests/Fixtures/cliniko/requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patient_appointments.json b/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patient_appointments.json new file mode 100644 index 0000000..dadafd7 --- /dev/null +++ b/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patient_appointments.json @@ -0,0 +1,23 @@ +{ + "appointments": [ + { + "id": 5001, + "starts_at": "2026-04-25T09:00:00Z", + "ends_at": "2026-04-25T09:30:00Z" + }, + { + "id": 5002, + "starts_at": "2026-04-22T14:15:00Z", + "ends_at": "2026-04-22T14:45:00Z" + }, + { + "id": 5003, + "starts_at": "2026-04-19T08:00:00Z", + "ends_at": "2026-04-19T08:30:00Z" + } + ], + "total_entries": 3, + "links": { + "self": "https://api.au1.cliniko.com/v1/patients/1001/appointments?from=2026-04-18T00:00:00Z&to=2026-04-26T00:00:00Z" + } +} diff --git a/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patients_search.json b/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patients_search.json new file mode 100644 index 0000000..b8dfb1c --- /dev/null +++ b/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patients_search.json @@ -0,0 +1,29 @@ +{ + "patients": [ + { + "id": 1001, + "first_name": "Sample", + "last_name": "Patient", + "date_of_birth": "1980-01-15", + "email": "sample.patient@example.test" + }, + { + "id": 1002, + "first_name": "Test", + "last_name": "Person", + "date_of_birth": "1992-07-04", + "email": "test.person@example.test" + }, + { + "id": 1003, + "first_name": "Fixture", + "last_name": "Subject", + "date_of_birth": null, + "email": null + } + ], + "total_entries": 3, + "links": { + "self": "https://api.au1.cliniko.com/v1/patients?q=sample" + } +} diff --git a/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patients_search_empty.json b/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patients_search_empty.json new file mode 100644 index 0000000..8bf249d --- /dev/null +++ b/Tests/SpeechToTextTests/Fixtures/cliniko/responses/patients_search_empty.json @@ -0,0 +1,7 @@ +{ + "patients": [], + "total_entries": 0, + "links": { + "self": "https://api.au1.cliniko.com/v1/patients?q=zzznomatchzzz" + } +} diff --git a/Tests/SpeechToTextTests/Fixtures/cliniko/responses/users_me.json b/Tests/SpeechToTextTests/Fixtures/cliniko/responses/users_me.json new file mode 100644 index 0000000..e52a5d3 --- /dev/null +++ b/Tests/SpeechToTextTests/Fixtures/cliniko/responses/users_me.json @@ -0,0 +1,6 @@ +{ + "id": 12345, + "first_name": "Sample", + "last_name": "User", + "email": "sample.user@example.test" +} diff --git a/Tests/SpeechToTextTests/Fixtures/llm/expected/.gitkeep b/Tests/SpeechToTextTests/Fixtures/llm/expected/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/SpeechToTextTests/Fixtures/llm/prompts/.gitkeep b/Tests/SpeechToTextTests/Fixtures/llm/prompts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/SpeechToTextTests/Fixtures/soap/invalid/.gitkeep b/Tests/SpeechToTextTests/Fixtures/soap/invalid/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/SpeechToTextTests/Fixtures/soap/valid/.gitkeep b/Tests/SpeechToTextTests/Fixtures/soap/valid/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/SpeechToTextTests/Models/ClinicalSessionTests.swift b/Tests/SpeechToTextTests/Models/ClinicalSessionTests.swift new file mode 100644 index 0000000..4a4c3b4 --- /dev/null +++ b/Tests/SpeechToTextTests/Models/ClinicalSessionTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing +@testable import SpeechToText + +// MARK: - ClinicalSession model tests +// +// Pure-logic invariants only. `SessionStore` (see SessionStoreTests) covers +// the lifecycle + PHI-free-disk assertions. + +@Suite("ClinicalSession", .tags(.fast)) +struct ClinicalSessionTests { + + @Test("Default init populates defaults and generates an id") + func init_defaults() { + let recording = RecordingSession() + let session = ClinicalSession(recordingSession: recording) + + #expect(session.recordingSession.id == recording.id) + #expect(session.draftNotes == nil) + #expect(session.excludedReAdded.isEmpty) + #expect(session.selectedPatientID == nil) + #expect(session.selectedAppointmentID == nil) + } + + @Test("IDs are distinct across sessions built from the same recording") + func init_idsAreDistinct() { + let recording = RecordingSession() + let a = ClinicalSession(recordingSession: recording) + let b = ClinicalSession(recordingSession: recording) + #expect(a.id != b.id) + } + + @Test("Full-init round-trips every field") + func init_full() { + let recording = RecordingSession() + let notes = StructuredNotes( + subjective: "s", + objective: "o", + assessment: "a", + plan: "p", + selectedManipulationIDs: ["diversified"], + excluded: ["smalltalk"] + ) + let id = UUID() + let session = ClinicalSession( + id: id, + recordingSession: recording, + draftNotes: notes, + excludedReAdded: ["weather"], + selectedPatientID: "patient-1", + selectedAppointmentID: "appt-1" + ) + + #expect(session.id == id) + #expect(session.draftNotes == notes) + #expect(session.excludedReAdded == ["weather"]) + #expect(session.selectedPatientID == "patient-1") + #expect(session.selectedAppointmentID == "appt-1") + } + + @Test("Mutating draftNotes leaves other fields untouched") + func mutate_draftNotesIsIsolated() { + var session = ClinicalSession( + recordingSession: RecordingSession(), + selectedPatientID: "p" + ) + session.draftNotes = StructuredNotes(subjective: "x") + + #expect(session.draftNotes?.subjective == "x") + #expect(session.selectedPatientID == "p") + } +} + +@Suite("StructuredNotes", .tags(.fast)) +struct StructuredNotesTests { + @Test("Default init is empty") + func init_defaults() { + let notes = StructuredNotes() + #expect(notes.subjective.isEmpty) + #expect(notes.objective.isEmpty) + #expect(notes.assessment.isEmpty) + #expect(notes.plan.isEmpty) + #expect(notes.selectedManipulationIDs.isEmpty) + #expect(notes.excluded.isEmpty) + } + + @Test("Equatable distinguishes field changes") + func equatable_detectsChanges() { + let base = StructuredNotes(subjective: "a") + var mutated = base + mutated.subjective = "b" + #expect(base != mutated) + } + + @Test("Equatable discriminates on manipulations and excluded arrays") + func equatable_discriminatesOnArrayFields() { + let base = StructuredNotes(selectedManipulationIDs: ["a"], excluded: ["x"]) + + var differentManipulations = base + differentManipulations.selectedManipulationIDs = ["a", "b"] + #expect(base != differentManipulations) + + var differentExcluded = base + differentExcluded.excluded = ["y"] + #expect(base != differentExcluded) + + var sameShape = base + sameShape.selectedManipulationIDs = ["a"] + sameShape.excluded = ["x"] + #expect(base == sameShape) + } +} diff --git a/Tests/SpeechToTextTests/Services/AudioCaptureMemoryLayoutTests.swift b/Tests/SpeechToTextTests/Services/AudioCaptureMemoryLayoutTests.swift new file mode 100644 index 0000000..17abb7b --- /dev/null +++ b/Tests/SpeechToTextTests/Services/AudioCaptureMemoryLayoutTests.swift @@ -0,0 +1,51 @@ +import CoreAudio +import Foundation +import Testing + +// MARK: - Core Audio MemoryLayout sanity tests +// +// `AudioCaptureService.setInputDevice(...)` hands Core Audio raw pointers +// plus explicit byte sizes via `AudioValueTranslation`. A silent platform +// drift in any of those sizes would turn the device-UID β†’ `AudioDeviceID` +// lookup into a memory-safety bug without touching a single line of our +// code, and the call site is hardware-gated from CI (real mic required). +// +// This file exists so CI *can* catch that drift without hardware. Pure +// logic, no device access, `.fast`-tagged so it runs on every PR. + +@Suite("AudioCapture MemoryLayout", .tags(.fast)) +struct AudioCaptureMemoryLayoutTests { + + @Test("AudioDeviceID is a 4-byte UInt32") + func audioDeviceID_size() { + #expect(MemoryLayout.size == 4) + } + + @Test("CFString reference is 8 bytes on 64-bit macOS") + func cfString_size() { + // Core Audio's `kAudioHardwarePropertyDeviceForUID` reads the + // CFStringRef through `AudioValueTranslation.mInputData`. The + // byte size we pass as `mInputDataSize` must match the reference + // size, which is 8 on all macOS targets we support. + #expect(MemoryLayout.size == 8) + } + + @Test("AudioValueTranslation is 32 bytes") + func audioValueTranslation_size() { + // Two `UnsafeMutableRawPointer`s (8 bytes each) + two `UInt32`s + // (4 bytes each, 4 bytes trailing pad) = 32 bytes. + // + // If this ever drifts, the `&translation` pointer in + // `AudioObjectGetPropertyData` reads/writes the wrong stride and + // the Core Audio call silently misbehaves. + #expect(MemoryLayout.size == 32) + } + + @Test("AudioObjectPropertyAddress is 12 bytes") + func audioObjectPropertyAddress_size() { + // Three `UInt32`s β€” used by `AudioObjectGetPropertyData`'s + // `inAddress` parameter. Guarded here so a future struct change + // doesn't break the property-lookup call site. + #expect(MemoryLayout.size == 12) + } +} diff --git a/Tests/SpeechToTextTests/Services/AudioCaptureServiceTests.swift b/Tests/SpeechToTextTests/Services/AudioCaptureServiceTests.swift index c63fcac..d34a37a 100644 --- a/Tests/SpeechToTextTests/Services/AudioCaptureServiceTests.swift +++ b/Tests/SpeechToTextTests/Services/AudioCaptureServiceTests.swift @@ -406,9 +406,12 @@ final class PendingWritesCounterTests: XCTestCase { let elapsed = CFAbsoluteTimeGetCurrent() - startTime // Then - // Should wait at least 50ms but less than 200ms + // Should wait at least 50ms (proves the method actually blocks + // on the outstanding write) but comfortably under 500ms (catches + // a pathological "wait forever" regression). The upper bound was + // 200ms, which flaked on busy CI runners β€” see issue #38. XCTAssertGreaterThan(elapsed, 0.04) - XCTAssertLessThan(elapsed, 0.2) + XCTAssertLessThan(elapsed, 0.5) XCTAssertTrue(counter.isEmpty) } diff --git a/Tests/SpeechToTextTests/Services/ClinicalNotesProcessorTests.swift b/Tests/SpeechToTextTests/Services/ClinicalNotesProcessorTests.swift new file mode 100644 index 0000000..2cb458a --- /dev/null +++ b/Tests/SpeechToTextTests/Services/ClinicalNotesProcessorTests.swift @@ -0,0 +1,320 @@ +import Foundation +import Testing +@testable import SpeechToText + +// Covers issue #5 acceptance criteria: +// - Happy path returns `.success`. +// - Invalid-then-valid JSON triggers retry, returns `.success`. +// - Invalid-twice triggers `.rawTranscriptFallback(reason: ...)`. +// - LLM throws at either attempt β†’ `.rawTranscriptFallback(reason: "llm_error")`. +// - No retries if the first response is valid. +// - No network, no on-disk writes. +// - `RawLLMDraft β†’ StructuredNotes` mapping: id match, displayName +// match, case-insensitive, unmatchable dropped, duplicates removed, +// order preserved. + +@Suite("ClinicalNotesProcessor", .tags(.fast)) +struct ClinicalNotesProcessorTests { + + // MARK: - Shared fixtures + + private static let simpleTemplate = """ + MANIPULATIONS: + {{manipulations_list}} + + TRANSCRIPT: + {{transcript}} + """ + + private static let sampleRepo = ManipulationsRepository(all: [ + Manipulation(id: "diversified_hvla", displayName: "Diversified HVLA", clinikoCode: nil), + Manipulation(id: "activator", displayName: "Activator", clinikoCode: nil), + Manipulation(id: "drop_table", displayName: "Drop Table", clinikoCode: nil) + ]) + + private static func promptBuilder( + repo: ManipulationsRepository = sampleRepo + ) -> ClinicalNotesPromptBuilder { + ClinicalNotesPromptBuilder(template: simpleTemplate, manipulations: repo) + } + + private static func processor( + provider: any LLMProvider, + repo: ManipulationsRepository = sampleRepo + ) -> ClinicalNotesProcessor { + ClinicalNotesProcessor( + provider: provider, + promptBuilder: promptBuilder(repo: repo), + manipulations: repo + ) + } + + private static let validJSON = #""" + { + "subjective": "neck pain for 3 days", + "objective": "reduced cervical rotation R", + "assessment": "cervical facet restriction", + "plan": "follow up in 1 week", + "manipulations": [ + { "name": "Diversified HVLA", "confidence": 0.91 } + ], + "excluded_content": ["chatter about weekend"] + } + """# + + // A response that will fail validate(json:) β€” no JSON object at all. + private static let invalidJSON = "sorry, I can't produce JSON right now." + + // MARK: - Happy path + + @Test("Valid JSON on first attempt returns .success with mapped fields") + func happyPath_returnsSuccess() async { + let provider = MockLLMProvider(response: Self.validJSON) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + let expected = StructuredNotes( + subjective: "neck pain for 3 days", + objective: "reduced cervical rotation R", + assessment: "cervical facet restriction", + plan: "follow up in 1 week", + selectedManipulationIDs: ["diversified_hvla"], + excluded: ["chatter about weekend"] + ) + #expect(outcome == .success(expected)) + } + + @Test("No retry when the first response is valid") + func happyPath_noRetry() async { + let provider = MockLLMProvider(response: Self.validJSON) + let proc = Self.processor(provider: provider) + + _ = await proc.process(transcript: "t") + + #expect(await provider.callCount() == 1) + } + + // MARK: - Retry flow + + @Test("Invalid-then-valid JSON retries once and returns .success") + func retry_invalidThenValid_returnsSuccess() async { + let provider = MockLLMProvider( + responses: [Self.invalidJSON, Self.validJSON] + ) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + guard case .success = outcome else { + Issue.record("Expected .success, got \(outcome)") + return + } + #expect(await provider.callCount() == 2) + } + + @Test("Retry prompt quotes the invalid first response so the model can correct it") + func retry_promptQuotesBadOutput() async { + let provider = MockLLMProvider( + responses: [Self.invalidJSON, Self.validJSON] + ) + let proc = Self.processor(provider: provider) + + _ = await proc.process(transcript: "t") + + let calls = await provider.calls() + #expect(calls.count == 2) + let retryPrompt = calls.last?.prompt ?? "" + #expect(retryPrompt.contains(Self.invalidJSON)) + // The retry prompt must also include the schema/instruction so + // the model has something structural to anchor against. + #expect(retryPrompt.contains("JSON")) + } + + @Test("Invalid JSON on both attempts returns .rawTranscriptFallback(invalid_json_after_retry)") + func retry_invalidTwice_fallback() async { + let provider = MockLLMProvider( + responses: [Self.invalidJSON, Self.invalidJSON] + ) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + #expect(outcome == .rawTranscriptFallback( + reason: ClinicalNotesProcessor.reasonInvalidJSONAfterRetry + )) + #expect(await provider.callCount() == 2) + } + + // MARK: - LLM failure + + @Test("LLM throws on first attempt β†’ .rawTranscriptFallback(llm_error), no retry") + func llmThrows_firstAttempt_fallbackWithoutRetry() async { + let provider = MockLLMProvider(error: SampleError.boom) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + #expect(outcome == .rawTranscriptFallback( + reason: ClinicalNotesProcessor.reasonLLMError + )) + // One call: the throw aborts the pipeline; no retry on LLM + // throws per the acceptance criteria. + #expect(await provider.callCount() == 1) + } + + @Test("LLM throws on retry β†’ .rawTranscriptFallback(llm_error)") + func llmThrows_secondAttempt_fallback() async { + // Single-element queue: first call pops invalidJSON (forces + // retry); second call exhausts the queue, mock throws + // responseQueueExhausted. That throw is indistinguishable from + // a real provider error from the processor's perspective. + let provider = MockLLMProvider(responses: [Self.invalidJSON]) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + #expect(outcome == .rawTranscriptFallback( + reason: ClinicalNotesProcessor.reasonLLMError + )) + #expect(await provider.callCount() == 2) + } + + // MARK: - Manipulation mapping + + @Test("Mapping matches by Manipulation.id") + func mapping_matchesByID() async { + let json = Self.soapJSON(manipulations: [("activator", 0.5)]) + let provider = MockLLMProvider(response: json) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + guard case .success(let notes) = outcome else { + Issue.record("Expected .success, got \(outcome)") + return + } + #expect(notes.selectedManipulationIDs == ["activator"]) + } + + @Test("Mapping matches by displayName, case-insensitively") + func mapping_matchesByDisplayName_caseInsensitive() async { + let json = Self.soapJSON(manipulations: [("dIvErSiFiEd HvLa", 0.4)]) + let provider = MockLLMProvider(response: json) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + guard case .success(let notes) = outcome else { + Issue.record("Expected .success, got \(outcome)") + return + } + #expect(notes.selectedManipulationIDs == ["diversified_hvla"]) + } + + @Test("Unmatchable manipulation name is silently dropped") + func mapping_unmatchableDropped() async { + let json = Self.soapJSON(manipulations: [ + ("Diversified HVLA", 0.8), + ("totally made up technique", 0.4) + ]) + let provider = MockLLMProvider(response: json) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + guard case .success(let notes) = outcome else { + Issue.record("Expected .success, got \(outcome)") + return + } + #expect(notes.selectedManipulationIDs == ["diversified_hvla"]) + } + + @Test("Duplicate manipulation matches are de-duped; first occurrence wins order") + func mapping_deDupesPreservingOrder() async { + let json = Self.soapJSON(manipulations: [ + ("Activator", 0.7), + ("Diversified HVLA", 0.9), + ("activator", 0.3), // same as first after lowercasing + ("Drop Table", 0.5) + ]) + let provider = MockLLMProvider(response: json) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + guard case .success(let notes) = outcome else { + Issue.record("Expected .success, got \(outcome)") + return + } + #expect(notes.selectedManipulationIDs == [ + "activator", "diversified_hvla", "drop_table" + ]) + } + + @Test("Empty manipulations list round-trips to empty selectedManipulationIDs") + func mapping_empty() async { + let json = Self.soapJSON(manipulations: []) + let provider = MockLLMProvider(response: json) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + guard case .success(let notes) = outcome else { + Issue.record("Expected .success, got \(outcome)") + return + } + #expect(notes.selectedManipulationIDs.isEmpty) + } + + @Test("excluded_content passes through verbatim") + func mapping_excludedPassthrough() async { + let json = Self.soapJSON( + manipulations: [("Activator", 0.5)], + excluded: ["weekend small talk", "coffee banter"] + ) + let provider = MockLLMProvider(response: json) + let proc = Self.processor(provider: provider) + + let outcome = await proc.process(transcript: "t") + + guard case .success(let notes) = outcome else { + Issue.record("Expected .success, got \(outcome)") + return + } + #expect(notes.excluded == ["weekend small talk", "coffee banter"]) + } + + // MARK: - Helpers + + /// Render a minimal SOAP JSON response with the given manipulations + /// and excluded-content list. Uses static SOAP strings so mapping + /// tests can focus on the manipulations array. + private static func soapJSON( + manipulations: [(name: String, confidence: Double)], + excluded: [String] = [] + ) -> String { + let manipulationsJSON = manipulations.map { entry in + #"{ "name": "\#(entry.name)", "confidence": \#(entry.confidence) }"# + }.joined(separator: ",\n ") + let excludedJSON = excluded.map { #""\#($0)""# }.joined(separator: ", ") + return #""" + { + "subjective": "s", + "objective": "o", + "assessment": "a", + "plan": "p", + "manipulations": [ + \#(manipulationsJSON) + ], + "excluded_content": [\#(excludedJSON)] + } + """# + } +} + +// MARK: - Test fixtures + +private enum SampleError: Error, Equatable, Sendable { + case boom +} diff --git a/Tests/SpeechToTextTests/Services/ClinicalNotesPromptBuilderTests.swift b/Tests/SpeechToTextTests/Services/ClinicalNotesPromptBuilderTests.swift new file mode 100644 index 0000000..c05cafd --- /dev/null +++ b/Tests/SpeechToTextTests/Services/ClinicalNotesPromptBuilderTests.swift @@ -0,0 +1,436 @@ +import Foundation +import Testing +@testable import SpeechToText + +// Covers issue #4 acceptance criteria: +// - Prompt loadable from a Resources text file (checks `loadFromBundle` +// + safety line from soap_v1.txt). +// - `validate(json:)` returns `Result<..., SchemaError>`; we return +// `RawLLMDraft` not `StructuredNotes` β€” see the design +// reconciliation note on #4. The downstream `RawLLMDraft β†’ +// StructuredNotes` mapping is tested in #5. +// - Handles extra whitespace, code-fence-wrapped JSON, and trailing +// commentary gracefully. +// - Unit coverage: valid JSON, missing key, wrong type, empty +// manipulations, confidence outside [0, 1]. +// +// Style: Swift Testing only, `.fast` via the suite. See +// `.claude/references/testing-conventions.md`. + +@Suite("ClinicalNotesPromptBuilder", .tags(.fast)) +struct ClinicalNotesPromptBuilderTests { + + // MARK: - Shared fixtures + + private static let simpleTemplate = """ + MANIPULATIONS: + {{manipulations_list}} + + TRANSCRIPT: + {{transcript}} + """ + + private static let sampleRepo = ManipulationsRepository(all: [ + Manipulation(id: "diversified_hvla", displayName: "Diversified HVLA", clinikoCode: nil), + Manipulation(id: "activator", displayName: "Activator", clinikoCode: nil) + ]) + + private static func builder( + template: String = simpleTemplate, + repo: ManipulationsRepository = sampleRepo + ) -> ClinicalNotesPromptBuilder { + ClinicalNotesPromptBuilder(template: template, manipulations: repo) + } + + private static let validJSON = #""" + { + "subjective": "neck pain for 3 days", + "objective": "reduced cervical rotation R", + "assessment": "cervical facet restriction", + "plan": "follow up in 1 week", + "manipulations": [ + { "name": "Diversified HVLA", "confidence": 0.91 } + ], + "excluded_content": ["chatter about weekend"] + } + """# + + // MARK: - Prompt assembly + + @Test("buildPrompt renders every manipulation id + display_name and embeds the transcript") + func buildPrompt_rendersTaxonomyAndTranscript() { + let prompt = Self.builder().buildPrompt(transcript: "Patient says hello.") + + #expect(prompt.contains("- id: diversified_hvla, name: Diversified HVLA")) + #expect(prompt.contains("- id: activator, name: Activator")) + #expect(prompt.contains("Patient says hello.")) + #expect(!prompt.contains("{{manipulations_list}}")) + #expect(!prompt.contains("{{transcript}}")) + } + + @Test("buildPrompt with empty taxonomy renders an empty manipulations block") + func buildPrompt_emptyTaxonomy() { + let emptyRepo = ManipulationsRepository(all: []) + let prompt = Self.builder(repo: emptyRepo).buildPrompt(transcript: "x") + #expect(prompt.contains("MANIPULATIONS:\n\n")) + } + + @Test("bundled soap_v1 template includes the locked safety line") + func bundledTemplate_includesSafetyLine() throws { + let builder = try ClinicalNotesPromptBuilder.loadFromBundle( + manipulations: Self.sampleRepo + ) + let prompt = builder.buildPrompt(transcript: "x") + #expect(prompt.contains("drafting assistant")) + #expect(prompt.contains("not a diagnostic tool")) + } + + @Test("loadFromBundle surfaces typed templateNotFound for a missing resource") + func loadFromBundle_missingResource_throws() { + #expect(throws: ClinicalNotesPromptBuilderError.self) { + _ = try ClinicalNotesPromptBuilder.loadFromBundle( + templateResource: "does-not-exist", + manipulations: Self.sampleRepo + ) + } + } + + // MARK: - validate() happy path + + @Test("validate returns RawLLMDraft for well-formed JSON") + func validate_happyPath() { + let result = Self.builder().validate(json: Self.validJSON) + guard case let .success(draft) = result else { + Issue.record("expected success, got \(result)") + return + } + #expect(draft.subjective == "neck pain for 3 days") + #expect(draft.objective == "reduced cervical rotation R") + #expect(draft.assessment == "cervical facet restriction") + #expect(draft.plan == "follow up in 1 week") + #expect(draft.manipulations.count == 1) + #expect(draft.manipulations[0].name == "Diversified HVLA") + #expect(draft.manipulations[0].confidence == 0.91) + #expect(draft.excludedContent == ["chatter about weekend"]) + } + + @Test("validate accepts an empty manipulations array") + func validate_emptyManipulations() { + let json = #""" + { + "subjective": "", + "objective": "", + "assessment": "", + "plan": "", + "manipulations": [], + "excluded_content": [] + } + """# + guard case let .success(draft) = Self.builder().validate(json: json) else { + Issue.record("expected success") + return + } + #expect(draft.manipulations.isEmpty) + #expect(draft.excludedContent.isEmpty) + } + + // MARK: - Tolerant parsing + + @Test("validate strips a ```json ... ``` code fence") + func validate_stripsJsonCodeFence() { + let wrapped = "```json\n" + Self.validJSON + "\n```" + guard case .success = Self.builder().validate(json: wrapped) else { + Issue.record("expected success for fenced JSON") + return + } + } + + @Test("validate strips a bare ``` ... ``` code fence") + func validate_stripsBareCodeFence() { + let wrapped = "```\n" + Self.validJSON + "\n```" + guard case .success = Self.builder().validate(json: wrapped) else { + Issue.record("expected success for bare-fenced JSON") + return + } + } + + @Test("validate tolerates trailing commentary after the JSON object") + func validate_toleratesTrailingCommentary() { + let noisy = Self.validJSON + "\n\nLet me know if you'd like me to adjust this!" + guard case .success = Self.builder().validate(json: noisy) else { + Issue.record("expected success when trailing commentary follows the JSON") + return + } + } + + @Test("validate tolerates leading whitespace + a leading newline") + func validate_toleratesLeadingWhitespace() { + let padded = " \n\n\t" + Self.validJSON + guard case .success = Self.builder().validate(json: padded) else { + Issue.record("expected success with leading whitespace") + return + } + } + + @Test("validate is not fooled by braces inside string literals") + func validate_bracesInStringsDoNotBreakExtraction() { + let withBraceInString = #""" + { + "subjective": "patient quoted: \"{not json}\" earlier", + "objective": "", + "assessment": "", + "plan": "", + "manipulations": [], + "excluded_content": [] + } + """# + guard case let .success(draft) = Self.builder().validate(json: withBraceInString) else { + Issue.record("expected success β€” inner braces are in a string literal") + return + } + #expect(draft.subjective.contains("{not json}")) + } + + // MARK: - Error cases + + @Test("validate returns emptyInput for whitespace-only input") + func validate_emptyInput() { + #expect(Self.builder().validate(json: " \n\t") == .failure(.emptyInput)) + #expect(Self.builder().validate(json: "") == .failure(.emptyInput)) + } + + @Test("validate returns noJSONFound when the input has no JSON object") + func validate_noJSONFound() { + let notJSON = "I don't know what to say about this consultation." + #expect(Self.builder().validate(json: notJSON) == .failure(.noJSONFound)) + } + + @Test("validate returns decodingFailed(.missingKey) for a missing required key β€” with a PHI-safe keyPath") + func validate_decodingFailed_missingKey() { + let missingPlan = #""" + { + "subjective": "x", + "objective": "x", + "assessment": "x", + "manipulations": [], + "excluded_content": [] + } + """# + let result = Self.builder().validate(json: missingPlan) + #expect(result == .failure(.decodingFailed(.missingKey(keyPath: "plan")))) + } + + @Test("validate returns decodingFailed(.typeMismatch) when confidence is the wrong type, without leaking the offending value") + func validate_decodingFailed_wrongType() { + // The word "suspicious" is used as the bogus confidence value so + // that we can assert it does *not* appear anywhere in the typed + // error β€” proving the PHI-safe redaction path. + let wrongType = #""" + { + "subjective": "x", + "objective": "x", + "assessment": "x", + "plan": "x", + "manipulations": [{ "name": "Activator", "confidence": "suspicious" }], + "excluded_content": [] + } + """# + let result = Self.builder().validate(json: wrongType) + guard case let .failure(.decodingFailed(kind)) = result else { + Issue.record("expected decodingFailed") + return + } + guard case let .typeMismatch(keyPath) = kind else { + Issue.record("expected .typeMismatch, got \(kind)") + return + } + #expect(keyPath.hasPrefix("manipulations")) + #expect(keyPath.contains("confidence")) + // B1 PHI-safety invariant: the offending value must not surface + // in the rendered error description. + let rendered = String(describing: result) + #expect(!rendered.contains("suspicious"), "PHI-safe error must not quote the offending value") + } + + @Test("validate returns decodingFailed(.typeMismatch) when a SOAP section is the wrong type, without leaking the offending value") + func validate_decodingFailed_soapSectionTypeMismatch_redacted() { + // Integer in a String field β€” same PHI-safety invariant as above. + let wrongType = #""" + { + "subjective": 424242, + "objective": "x", + "assessment": "x", + "plan": "x", + "manipulations": [], + "excluded_content": [] + } + """# + let result = Self.builder().validate(json: wrongType) + guard case let .failure(.decodingFailed(kind)) = result else { + Issue.record("expected decodingFailed") + return + } + guard case .typeMismatch(keyPath: "subjective") = kind else { + Issue.record("expected .typeMismatch(keyPath: \"subjective\"), got \(kind)") + return + } + let rendered = String(describing: result) + #expect(!rendered.contains("424242"), "PHI-safe error must not quote the offending value") + } + + @Test("validate returns confidenceOutOfRange with a structural keyPath when confidence > 1") + func validate_confidenceAboveOne() { + let json = #""" + { + "subjective": "", + "objective": "", + "assessment": "", + "plan": "", + "manipulations": [{ "name": "Activator", "confidence": 1.25 }], + "excluded_content": [] + } + """# + #expect( + Self.builder().validate(json: json) + == .failure(.confidenceOutOfRange(keyPath: "manipulations.0.confidence", value: 1.25)) + ) + } + + @Test("validate returns confidenceOutOfRange with a structural keyPath when confidence < 0") + func validate_confidenceBelowZero() { + let json = #""" + { + "subjective": "", + "objective": "", + "assessment": "", + "plan": "", + "manipulations": [{ "name": "Gonstead", "confidence": -0.1 }], + "excluded_content": [] + } + """# + #expect( + Self.builder().validate(json: json) + == .failure(.confidenceOutOfRange(keyPath: "manipulations.0.confidence", value: -0.1)) + ) + } + + @Test("confidenceOutOfRange does not carry the LLM-returned name (prompt-injection / hallucination PHI guard)") + func validate_confidenceOutOfRange_doesNotCarryName() { + // If the LLM hallucinates a "name" that contains transcript- + // derived text (patient name, DOB, quoted symptom, …), the + // resulting error must not carry it. This test embeds a + // PHI-looking token in the manipulation name and asserts it + // never appears in the rendered error. + let phiLooking = "Alice Smith DOB 1983-04-12" + let json = """ + { + "subjective": "", + "objective": "", + "assessment": "", + "plan": "", + "manipulations": [{ "name": "\(phiLooking)", "confidence": 1.9 }], + "excluded_content": [] + } + """ + let result = Self.builder().validate(json: json) + #expect( + result == .failure(.confidenceOutOfRange(keyPath: "manipulations.0.confidence", value: 1.9)) + ) + let rendered = String(describing: result) + #expect(!rendered.contains("Alice"), "PHI-safe error must not quote the LLM-returned name") + #expect(!rendered.contains("1983"), "PHI-safe error must not quote the LLM-returned name") + } + + @Test("confidenceOutOfRange keyPath reports the first offender index for a later manipulation entry") + func validate_confidenceOutOfRange_reportsCorrectIndex() { + // Pins that `offender.offset` is the array index of the first + // offending entry, so downstream diagnostics can map back to + // exactly which manipulation misbehaved. + let json = #""" + { + "subjective": "", + "objective": "", + "assessment": "", + "plan": "", + "manipulations": [ + { "name": "Activator", "confidence": 0.5 }, + { "name": "Gonstead", "confidence": 0.9 }, + { "name": "Toggle Recoil", "confidence": 42.0 } + ], + "excluded_content": [] + } + """# + #expect( + Self.builder().validate(json: json) + == .failure(.confidenceOutOfRange(keyPath: "manipulations.2.confidence", value: 42.0)) + ) + } + + @Test("validate accepts confidence at the inclusive bounds 0.0 and 1.0") + func validate_confidenceBounds_inclusive() { + let json = #""" + { + "subjective": "", + "objective": "", + "assessment": "", + "plan": "", + "manipulations": [ + { "name": "Activator", "confidence": 0.0 }, + { "name": "Gonstead", "confidence": 1.0 } + ], + "excluded_content": [] + } + """# + guard case .success = Self.builder().validate(json: json) else { + Issue.record("expected success for bounds 0.0 and 1.0") + return + } + } + + // MARK: - Edge cases pinned by pre-PR review + + @Test("validate returns noJSONFound for an unbalanced JSON object (open brace, no close)") + func validate_unbalancedJSON_returnsNoJSONFound() { + // Pins N1: the brace walker returns nil for an unbalanced input, + // which surfaces as `.noJSONFound` rather than `.decodingFailed`. + // Behaviour is load-bearing for the retry-once orchestration in + // #5 β€” swapping this to a different failure kind would change + // which retries fire. + let unbalanced = #"{ "subjective": "x", "objective": "y""# + #expect(Self.builder().validate(json: unbalanced) == .failure(.noJSONFound)) + } + + @Test("validate accepts an uppercase ```JSON fence (dual-path via firstJSONObject fallback)") + func validate_stripsUppercaseJsonFence() { + // Pins N2: `stripCodeFence` only strips lowercase `json`, but the + // overall validate path recovers via `firstJSONObject` on any + // leftover preamble. + let wrapped = "```JSON\n" + Self.validJSON + "\n```" + guard case .success = Self.builder().validate(json: wrapped) else { + Issue.record("expected success for uppercase JSON fence") + return + } + } + + @Test("buildPrompt embeds a transcript containing triple-backticks verbatim without breaking template substitution") + func buildPrompt_tripleBackticksInTranscript_roundTrip() { + // Pins N3: a transcript that contains ``` must flow straight + // through `{{transcript}}` substitution. Nothing in the builder + // should try to re-interpret or strip fences on the input side. + let transcript = "Patient said: ```hello``` and went home." + let prompt = Self.builder().buildPrompt(transcript: transcript) + #expect(prompt.contains(transcript)) + #expect(!prompt.contains("{{transcript}}")) + } + + @Test("buildPrompt inserts a transcript containing {{manipulations_list}} literally, without re-substitution") + func buildPrompt_placeholderInTranscript_notResubstituted() { + // Pins the substitution-order invariant documented on + // `buildPrompt`: manipulations_list is substituted before + // transcript, so a transcript that literally contains + // `{{manipulations_list}}` stays verbatim. + let transcript = "Literal placeholder: {{manipulations_list}} β€” should stay." + let prompt = Self.builder().buildPrompt(transcript: transcript) + #expect(prompt.contains("Literal placeholder: {{manipulations_list}} β€” should stay.")) + } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoAppointmentServiceTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoAppointmentServiceTests.swift new file mode 100644 index 0000000..80e4d0a --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoAppointmentServiceTests.swift @@ -0,0 +1,168 @@ +import Foundation +import XCTest +@testable import SpeechToText + +/// End-to-end behaviour tests for `ClinikoAppointmentService`. Exercises +/// payload decoding, the 7-day-back / 1-day-forward window definition, and +/// error pass-through from `ClinikoClient`. +/// +/// Why XCTest (not Swift Testing): see `ClinikoPatientServiceTests` β€” +/// `URLProtocolStub` is a process-wide singleton; XCTest serialises within +/// a class while Swift Testing parallelises. Refactor tracked in #30. +final class ClinikoAppointmentServiceTests: XCTestCase { + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + // MARK: - Helpers + + private func credentials() throws -> ClinikoCredentials { + try ClinikoCredentials(apiKey: "MS-test-au1", shard: .au1) + } + + private func makeService( + responder: @escaping URLProtocolStub.Responder + ) throws -> ClinikoAppointmentService { + let config = URLProtocolStub.install(responder) + let session = URLSession(configuration: config) + let client = ClinikoClient( + credentials: try credentials(), + session: session, + userAgent: "appointment-service-tests/1.0", + retryPolicy: .immediate + ) + return ClinikoAppointmentService(client: client) + } + + // MARK: - Happy path + decoding + + func test_appointments_decodesPayload() async throws { + let service = try makeService { request in + let body = try HTTPStubFixture.load("cliniko/responses/patient_appointments.json") + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, body) + } + + let reference = ISO8601DateFormatter().date(from: "2026-04-25T12:00:00Z") ?? Date() + let appointments = try await service.recentAndTodayAppointments( + forPatientID: "1001", + reference: reference + ) + + XCTAssertEqual(appointments.count, 3) + XCTAssertEqual(appointments.first?.id, 5001) + let firstStart = ISO8601DateFormatter().date(from: "2026-04-25T09:00:00Z") + XCTAssertEqual(appointments.first?.startsAt, firstStart) + XCTAssertNotNil(appointments.first?.endsAt) + } + + // MARK: - Window definition + + func test_appointments_emitsCorrectFromAndToWindowEdges() async throws { + let captured = CapturedRequestBox() + let service = try makeService { request in + captured.set(request) + let body = try HTTPStubFixture.load("cliniko/responses/patient_appointments.json") + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, body) + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(identifier: "UTC") + let reference = try XCTUnwrap(formatter.date(from: "2026-04-25T12:00:00Z")) + + _ = try await service.recentAndTodayAppointments( + forPatientID: "1001", + reference: reference + ) + + let url = try XCTUnwrap(captured.value?.url) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let queryItems = components.queryItems ?? [] + let from = queryItems.first { $0.name == "from" }?.value + let to = queryItems.first { $0.name == "to" }?.value + XCTAssertEqual(from, "2026-04-18T12:00:00Z") + XCTAssertEqual(to, "2026-04-26T12:00:00Z") + } + + func test_appointments_pathPercentEncodesPatientID() async throws { + let captured = CapturedRequestBox() + let service = try makeService { request in + captured.set(request) + let body = try HTTPStubFixture.load("cliniko/responses/patient_appointments.json") + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (response, body) + } + + // ID containing characters that would otherwise split the path β€” + // not realistic for Cliniko (numeric IDs) but defence-in-depth. + _ = try await service.recentAndTodayAppointments( + forPatientID: "weird id/with/slashes", + reference: Date() + ) + + let url = try XCTUnwrap(captured.value?.url) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let template = "/v1/patients/weird%20id%2Fwith%2Fslashes/appointments" + XCTAssertEqual(components.percentEncodedPath, template) + } + + // MARK: - Error mapping + + func test_appointments_401_mapsToUnauthenticated() async throws { + try await assertAppointmentsError(status: 401, expected: .unauthenticated) + } + + func test_appointments_404_mapsToNotFoundPatient() async throws { + try await assertAppointmentsError( + status: 404, + expected: .notFound(resource: .patient) + ) + } + + private func assertAppointmentsError( + status: Int, + expected: ClinikoError, + file: StaticString = #file, + line: UInt = #line + ) async throws { + let service = try makeService { request in + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (response, Data()) + } + do { + _ = try await service.recentAndTodayAppointments( + forPatientID: "1001", + reference: Date() + ) + XCTFail("expected \(expected), got success", file: file, line: line) + } catch let error as ClinikoError { + XCTAssertEqual(error, expected, file: file, line: line) + } catch { + XCTFail("expected ClinikoError, got \(error)", file: file, line: line) + } + } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoAuthProbeTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoAuthProbeTests.swift new file mode 100644 index 0000000..44f6e43 --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoAuthProbeTests.swift @@ -0,0 +1,204 @@ +import Foundation +import XCTest +@testable import SpeechToText + +/// Tests `ClinikoAuthProbe` against a stubbed `URLSession` so no real network +/// call ever fires. Header assertions verify AC item 2 of issue #7 +/// ("Test connection request includes User-Agent: mac-speech-to-text/ +/// and Basic auth per Cliniko docs"). +final class ClinikoAuthProbeTests: XCTestCase { + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + // MARK: - Helpers + + private func makeSession( + responder: @escaping URLProtocolStub.Responder + ) -> URLSession { + let config = URLProtocolStub.install(responder) + return URLSession(configuration: config) + } + + private var credentials: ClinikoCredentials { + // swiftlint:disable:next force_try + try! ClinikoCredentials(apiKey: "MS-test-au1", shard: .au1) + } + + // MARK: - Happy path + headers + + func test_ping_sends_basicAuth_userAgent_and_acceptHeaders() async throws { + let captured = CapturedRequest() + let session = makeSession { request in + captured.set(request) + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + let body = try HTTPStubFixture.load("cliniko/responses/users_me.json") + return (response, body) + } + let probe = ClinikoAuthProbe(session: session, userAgent: "mac-speech-to-text/9.9.9 (test)") + + try await probe.ping(credentials: credentials) + + let unwrapped = try XCTUnwrap(captured.value) + XCTAssertEqual(unwrapped.httpMethod, "GET") + XCTAssertEqual(unwrapped.url?.absoluteString, "https://api.au1.cliniko.com/v1/users/me") + XCTAssertEqual(unwrapped.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertEqual(unwrapped.value(forHTTPHeaderField: "User-Agent"), "mac-speech-to-text/9.9.9 (test)") + + let auth = try XCTUnwrap(unwrapped.value(forHTTPHeaderField: "Authorization")) + XCTAssertTrue(auth.hasPrefix("Basic "), "expected HTTP Basic auth header, got \(auth)") + let encoded = String(auth.dropFirst("Basic ".count)) + let decoded = try XCTUnwrap(Data(base64Encoded: encoded).flatMap { String(data: $0, encoding: .utf8) }) + XCTAssertEqual(decoded, "MS-test-au1:", "Cliniko Basic auth: API key as username + empty password") + } + + func test_defaultUserAgent_includesAppNameAndContactReference() { + // Pins the User-Agent shape required by `.claude/references/cliniko-api.md`: + // app name + version + a contact reference (we use the public repo URL). + let ua = ClinikoAuthProbe.defaultUserAgent + XCTAssertTrue(ua.hasPrefix("mac-speech-to-text/"), "got \(ua)") + XCTAssertTrue(ua.contains("github.com/CloudbrokerAz/mac-speech-to-text"), + "User-Agent must embed a contact reference; got \(ua)") + } + + func test_ping_succeedsOn200() async throws { + let session = makeSession { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + let probe = ClinikoAuthProbe(session: session) + try await probe.ping(credentials: credentials) + } + + // MARK: - Status mapping + + func test_ping_throwsUnauthorized_on401() async { + await assertProbe(returnsStatus: 401, throws: .unauthorized) + } + + func test_ping_throwsUnauthorized_on403() async { + await assertProbe(returnsStatus: 403, throws: .unauthorized) + } + + func test_ping_throwsHTTPStatus_on500() async { + await assertProbe(returnsStatus: 500, throws: .http(status: 500)) + } + + func test_ping_throwsHTTPStatus_on404() async { + await assertProbe(returnsStatus: 404, throws: .http(status: 404)) + } + + func test_ping_succeedsOnEdgeOfSuccessRange() async throws { + // 204 (No Content) is the boundary case the `200..<300` arm must + // accept; pin it explicitly so a future refactor doesn't narrow + // the range to 200..<201 by accident. + let session = makeSession { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 204, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + let probe = ClinikoAuthProbe(session: session) + try await probe.ping(credentials: credentials) + } + + private func assertProbe( + returnsStatus statusCode: Int, + throws expected: ClinikoAuthProbeError, + file: StaticString = #file, + line: UInt = #line + ) async { + let session = makeSession { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return (response, Data("{}".utf8)) + } + let probe = ClinikoAuthProbe(session: session) + do { + try await probe.ping(credentials: credentials) + XCTFail("expected \(expected) for HTTP \(statusCode)", file: file, line: line) + } catch let error as ClinikoAuthProbeError { + XCTAssertEqual(error, expected, file: file, line: line) + } catch { + XCTFail("unexpected error type \(type(of: error)): \(error)", file: file, line: line) + } + } + + // MARK: - Transport errors + + func test_ping_throwsTransport_onURLError() async { + let session = makeSession { _ in + throw URLError(.notConnectedToInternet) + } + let probe = ClinikoAuthProbe(session: session) + do { + try await probe.ping(credentials: credentials) + XCTFail("expected transport error") + } catch let error as ClinikoAuthProbeError { + switch error { + case .transport: + break + default: + XCTFail("expected .transport, got \(error)") + } + } catch { + XCTFail("unexpected error \(error)") + } + } + + func test_ping_throwsCancelled_onURLErrorCancelled() async { + let session = makeSession { _ in + throw URLError(.cancelled) + } + let probe = ClinikoAuthProbe(session: session) + do { + try await probe.ping(credentials: credentials) + XCTFail("expected cancelled error") + } catch ClinikoAuthProbeError.cancelled { + // Expected β€” URLSession-level cancellation must surface as + // `.cancelled`, never as `.transport(.cancelled)`, so the VM + // can render it as a no-op rather than a network failure. + } catch { + XCTFail("expected .cancelled, got \(error)") + } + } +} + +// MARK: - Test helpers + +/// Captures the request seen by the URLProtocolStub responder. The responder +/// is synchronous, so we use an `NSLock`-protected class β€” same pattern as +/// `URLProtocolStub` itself. +private final class CapturedRequest: @unchecked Sendable { + private let lock = NSLock() + private var stored: URLRequest? + + func set(_ request: URLRequest) { + lock.lock(); defer { lock.unlock() } + stored = request + } + + var value: URLRequest? { + lock.lock(); defer { lock.unlock() } + return stored + } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoClientTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoClientTests.swift new file mode 100644 index 0000000..cd71bde --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoClientTests.swift @@ -0,0 +1,623 @@ +import Foundation +import XCTest +@testable import SpeechToText + +/// End-to-end tests for `ClinikoClient` against `URLProtocolStub`. Exercises +/// header construction, retry policy, status mapping, body redaction, and +/// the createTreatmentNote no-retry contract from `.claude/references/cliniko-api.md`. +final class ClinikoClientTests: XCTestCase { + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + // MARK: - Helpers + + private var credentials: ClinikoCredentials { + // swiftlint:disable:next force_try + try! ClinikoCredentials(apiKey: "MS-test-au1", shard: .au1) + } + + private func makeSession(responder: @escaping URLProtocolStub.Responder) -> URLSession { + let config = URLProtocolStub.install(responder) + return URLSession(configuration: config) + } + + private func makeClient( + session: URLSession, + retryPolicy: ClinikoClient.RetryPolicy = .immediate + ) -> ClinikoClient { + ClinikoClient( + credentials: credentials, + session: session, + userAgent: "client-tests/1.0", + retryPolicy: retryPolicy + ) + } + + private struct UsersMeResponse: Decodable, Sendable, Equatable { + let id: Int + let firstName: String + let lastName: String + let email: String + } + + // MARK: - Headers + happy path + + func test_send_usersMe_sendsAuthUserAgentAcceptHeaders_andDecodesBody() async throws { + let captured = CapturedRequest() + let session = makeSession { request in + captured.set(request) + let body = try HTTPStubFixture.load("cliniko/responses/users_me.json") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, body) + } + let client = makeClient(session: session) + + let user: UsersMeResponse = try await client.send(.usersMe) + + XCTAssertEqual(user, UsersMeResponse( + id: 12345, + firstName: "Sample", + lastName: "User", + email: "sample.user@example.test" + )) + let request = try XCTUnwrap(captured.value) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.absoluteString, "https://api.au1.cliniko.com/v1/users/me") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "client-tests/1.0") + let auth = try XCTUnwrap(request.value(forHTTPHeaderField: "Authorization")) + XCTAssertTrue(auth.hasPrefix("Basic ")) + let decoded = try XCTUnwrap(Data(base64Encoded: String(auth.dropFirst("Basic ".count))) + .flatMap { String(data: $0, encoding: .utf8) }) + XCTAssertEqual(decoded, "MS-test-au1:") + } + + func test_send_emptyResponseMarker_succeedsOn204() async throws { + let session = makeSession { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 204, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + let client = makeClient(session: session) + let _: EmptyResponse = try await client.send(.usersMe) + } + + // MARK: - Status mapping + + func test_send_401_mapsToUnauthenticated() async { + await assertSend(returnsStatus: 401, throws: .unauthenticated) + } + + func test_send_403_mapsToForbidden() async { + await assertSend(returnsStatus: 403, throws: .forbidden) + } + + func test_send_404_mapsToNotFound_withEndpointResource() async { + await assertSend( + endpoint: .patientSearch(query: "x"), + returnsStatus: 404, + throws: .notFound(resource: .patient) + ) + } + + func test_send_422_parsesValidationFields_dictShape() async throws { + let body = Data(#"{"errors":{"name":["must be present"],"age":["must be a number"]}}"#.utf8) + let session = makeSession { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 422, httpVersion: nil, headerFields: nil)! + return (response, body) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(.usersMe) + XCTFail("expected validation error") + } catch let ClinikoError.validation(fields) { + XCTAssertEqual(fields["name"], ["must be present"]) + XCTAssertEqual(fields["age"], ["must be a number"]) + } catch { + XCTFail("expected .validation; got \(error)") + } + } + + func test_send_422_parsesValidationFields_listShape() async throws { + let body = Data(#""" +{"errors":[{"field":"email","message":"is invalid"},{"field":"email","message":"is too short"}]} +"""#.utf8) + let session = makeSession { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 422, httpVersion: nil, headerFields: nil)! + return (response, body) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(.usersMe) + XCTFail("expected validation error") + } catch let ClinikoError.validation(fields) { + XCTAssertEqual(fields["email"]?.sorted(), ["is invalid", "is too short"]) + } catch { + XCTFail("expected .validation; got \(error)") + } + } + + func test_send_500_mapsToServer_afterRetries() async { + await assertSend(returnsStatus: 500, throws: .server(status: 500)) + } + + func test_send_unclassified_3xx_mapsToServer() async { + // 301 / 302 / etc. are unclassified β€” surface as `.server(status:)` + // rather than swallowing. + await assertSend(returnsStatus: 301, throws: .server(status: 301)) + } + + private func assertSend( + endpoint: ClinikoEndpoint = .usersMe, + returnsStatus statusCode: Int, + throws expected: ClinikoError, + file: StaticString = #file, + line: UInt = #line + ) async { + let session = makeSession { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return (response, Data("{}".utf8)) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(endpoint) + XCTFail("expected \(expected) for status \(statusCode)", file: file, line: line) + } catch let error as ClinikoError { + XCTAssertEqual(error, expected, file: file, line: line) + } catch { + XCTFail("unexpected error type \(type(of: error)): \(error)", file: file, line: line) + } + } + + // MARK: - Retry policy + + func test_send_5xx_retries_onIdempotentEndpoint_thenRecovers() async throws { + let counter = CallCounter() + let session = makeSession { request in + let attempt = counter.increment() + if attempt < 3 { + let response = HTTPURLResponse(url: request.url!, statusCode: 503, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + let body = try HTTPStubFixture.load("cliniko/responses/users_me.json") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, body) + } + let client = makeClient(session: session) + let _: UsersMeResponse = try await client.send(.usersMe) + XCTAssertEqual(counter.value, 3, "expected initial attempt + 2 retries before success") + } + + func test_send_5xx_exhausts_retryBudget() async { + let counter = CallCounter() + let session = makeSession { request in + _ = counter.increment() + let response = HTTPURLResponse(url: request.url!, statusCode: 502, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(.usersMe) + XCTFail("expected .server") + } catch ClinikoError.server(let status) { + XCTAssertEqual(status, 502) + XCTAssertEqual(counter.value, 3, "expected initial attempt + 2 retries (max budget)") + } catch { + XCTFail("expected .server, got \(error)") + } + } + + func test_send_5xx_doesNotRetry_onCreateTreatmentNote() async { + let counter = CallCounter() + let session = makeSession { request in + _ = counter.increment() + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + let client = makeClient(session: session) + do { + let _: EmptyResponse = try await client.send(.createTreatmentNote(body: Data("{}".utf8))) + XCTFail("expected .server") + } catch ClinikoError.server(let status) { + XCTAssertEqual(status, 500) + XCTAssertEqual(counter.value, 1, + "POST treatment_notes must not retry on 5xx β€” duplicate-write guard") + } catch { + XCTFail("expected .server, got \(error)") + } + } + + func test_send_429_honoursRetryAfter_aboveFloor_thenSucceeds() async throws { + // Retry-After (5s) is *above* the policy floor (0.1s), so it wins β€” + // the server is asking us to wait longer than we'd planned to and + // we honour that. (The reverse β€” server asking for *less* time than + // policy floor β€” is covered by `test_send_429_zeroRetryAfter_isClampedToPolicyFloor`.) + let counter = CallCounter() + let captured = CapturedRetryAfter() + let session = makeSession { request in + let attempt = counter.increment() + if attempt == 1 { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 429, + httpVersion: nil, + headerFields: ["Retry-After": "5"] + )! + return (response, Data()) + } + let body = try HTTPStubFixture.load("cliniko/responses/users_me.json") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, body) + } + let client = ClinikoClient( + credentials: credentials, + session: session, + userAgent: "client-tests/1.0", + retryPolicy: ClinikoClient.RetryPolicy( + delays: [0.1, 0.1], + sleep: { interval in captured.record(interval) } + ) + ) + let _: UsersMeResponse = try await client.send(.usersMe) + XCTAssertEqual(counter.value, 2, "1 retry on 429") + XCTAssertEqual(captured.values, [5.0], + "Retry-After header value (above policy floor) must override the policy delay") + } + + func test_send_429_retriesEvenWhenAllowsRetryOn5xxIsFalse() async throws { + // POST treatment_notes does not auto-retry on 5xx but DOES retry on + // 429 per cliniko-api.md ("UI shows a countdown"). + let counter = CallCounter() + let session = makeSession { request in + let attempt = counter.increment() + if attempt == 1 { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 429, + httpVersion: nil, + headerFields: ["Retry-After": "1"] + )! + return (response, Data()) + } + let response = HTTPURLResponse(url: request.url!, statusCode: 201, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + let client = makeClient(session: session) + let _: EmptyResponse = try await client.send(.createTreatmentNote(body: Data("{}".utf8))) + XCTAssertEqual(counter.value, 2) + } + + func test_send_429_exhaustsBudget_throwsRateLimited() async { + let counter = CallCounter() + let session = makeSession { request in + _ = counter.increment() + let response = HTTPURLResponse( + url: request.url!, + statusCode: 429, + httpVersion: nil, + headerFields: ["Retry-After": "0"] + )! + return (response, Data()) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(.usersMe) + XCTFail("expected .rateLimited") + } catch ClinikoError.rateLimited(let retryAfter) { + XCTAssertEqual(retryAfter, 0) + XCTAssertEqual(counter.value, 3, "1 + 2 retries = 3 total attempts") + } catch { + XCTFail("expected .rateLimited, got \(error)") + } + } + + // MARK: - Transport + cancellation + + func test_send_transportError_retriesOnIdempotent_thenSurfacesAfterBudget() async { + let counter = CallCounter() + let session = makeSession { _ in + _ = counter.increment() + throw URLError(.notConnectedToInternet) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(.usersMe) + XCTFail("expected .transport") + } catch ClinikoError.transport(let code) { + XCTAssertEqual(code, .notConnectedToInternet) + XCTAssertEqual(counter.value, 3) + } catch { + XCTFail("got \(error)") + } + } + + func test_send_transportError_doesNotRetryOnCreateTreatmentNote() async { + let counter = CallCounter() + let session = makeSession { _ in + _ = counter.increment() + throw URLError(.notConnectedToInternet) + } + let client = makeClient(session: session) + do { + let _: EmptyResponse = try await client.send(.createTreatmentNote(body: Data("{}".utf8))) + XCTFail("expected .transport") + } catch ClinikoError.transport { + XCTAssertEqual(counter.value, 1, "POST treatment_notes must not retry on transport errors") + } catch { + XCTFail("got \(error)") + } + } + + func test_send_urlErrorCancelled_mapsToCancelled() async { + let session = makeSession { _ in + throw URLError(.cancelled) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(.usersMe) + XCTFail("expected .cancelled") + } catch ClinikoError.cancelled { + // expected + } catch { + XCTFail("got \(error)") + } + } + + // MARK: - Retry-After (HTTP-date form) + + func test_send_429_honoursHTTPDateRetryAfter_thenSucceeds() async throws { + let counter = CallCounter() + let captured = CapturedRetryAfter() + let session = makeSession { request in + let attempt = counter.increment() + if attempt == 1 { + // 30 seconds in the future from "now". + let future = Date().addingTimeInterval(30) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + let dateString = formatter.string(from: future) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 429, + httpVersion: nil, + headerFields: ["Retry-After": dateString] + )! + return (response, Data()) + } + let body = try HTTPStubFixture.load("cliniko/responses/users_me.json") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, body) + } + let client = ClinikoClient( + credentials: credentials, + session: session, + userAgent: "client-tests/1.0", + retryPolicy: ClinikoClient.RetryPolicy( + delays: [0.1, 0.1], + sleep: { interval in captured.record(interval) } + ) + ) + let _: UsersMeResponse = try await client.send(.usersMe) + XCTAssertEqual(counter.value, 2) + let firstDelay = try XCTUnwrap(captured.values.first) + // Date-form parses; the floor is the policy delay (0.1) so the + // honoured value is whichever is larger. Should be ~30s, not 0.1. + XCTAssertGreaterThan(firstDelay, 5.0, + "HTTP-date Retry-After must produce a forward-looking interval") + } + + func test_send_429_zeroRetryAfter_isClampedToPolicyFloor() async throws { + // `Retry-After: 0` from a misbehaving server must NOT cause + // back-to-back hammering β€” clamp to the policy delay. + let counter = CallCounter() + let captured = CapturedRetryAfter() + let session = makeSession { request in + let attempt = counter.increment() + if attempt == 1 { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 429, + httpVersion: nil, + headerFields: ["Retry-After": "0"] + )! + return (response, Data()) + } + let body = try HTTPStubFixture.load("cliniko/responses/users_me.json") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, body) + } + let client = ClinikoClient( + credentials: credentials, + session: session, + userAgent: "client-tests/1.0", + retryPolicy: ClinikoClient.RetryPolicy( + delays: [2.0, 2.0], + sleep: { interval in captured.record(interval) } + ) + ) + let _: UsersMeResponse = try await client.send(.usersMe) + XCTAssertEqual(captured.values, [2.0], + "Retry-After: 0 must be clamped up to the policy floor (2.0)") + } + + // MARK: - Empty retry policy + + func test_send_emptyRetryDelays_terminatesOnFirstFailure() async { + let counter = CallCounter() + let session = makeSession { request in + _ = counter.increment() + let response = HTTPURLResponse(url: request.url!, statusCode: 503, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + let client = ClinikoClient( + credentials: credentials, + session: session, + userAgent: "client-tests/1.0", + retryPolicy: ClinikoClient.RetryPolicy(delays: [], sleep: { _ in }) + ) + do { + let _: EmptyResponse = try await client.send(.usersMe) + XCTFail("expected .server") + } catch ClinikoError.server(let status) { + XCTAssertEqual(status, 503) + XCTAssertEqual(counter.value, 1, "empty delays β†’ no retries") + } catch { + XCTFail("got \(error)") + } + } + + func test_send_unparseable422Body_returnsEmptyValidationFields() async { + // Pin the documented behaviour: when both response shapes fail to + // decode, surface an empty validation dict rather than crashing. + // (The implementation also logs a structural marker so we'd notice + // a third undocumented shape in production.) + let session = makeSession { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 422, httpVersion: nil, headerFields: nil)! + return (response, Data("plain text".utf8)) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(.usersMe) + XCTFail("expected .validation") + } catch ClinikoError.validation(let fields) { + XCTAssertTrue(fields.isEmpty) + } catch { + XCTFail("got \(error)") + } + } + + // MARK: - Decoding + + func test_send_2xxButMalformedBody_throwsDecoding() async { + let session = makeSession { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, Data("not json".utf8)) + } + let client = makeClient(session: session) + do { + let _: UsersMeResponse = try await client.send(.usersMe) + XCTFail("expected .decoding") + } catch ClinikoError.decoding(let typeName) { + XCTAssertTrue(typeName.contains("UsersMeResponse"), "got \(typeName)") + } catch { + XCTFail("got \(error)") + } + } + + // MARK: - Body wiring (POST) + + func test_send_createTreatmentNote_setsBodyAndContentType() async throws { + let captured = CapturedRequest() + let session = makeSession { request in + captured.set(request) + let response = HTTPURLResponse(url: request.url!, statusCode: 201, httpVersion: nil, headerFields: nil)! + return (response, Data()) + } + let client = makeClient(session: session) + let payload = Data(#"{"notes":"hello"}"#.utf8) + let _: EmptyResponse = try await client.send(.createTreatmentNote(body: payload)) + + let request = try XCTUnwrap(captured.value) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.absoluteString, "https://api.au1.cliniko.com/v1/treatment_notes") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + // URLProtocolStub strips httpBody (URLSession buffers it into + // httpBodyStream); read either path. + let bodyData: Data? + if let direct = request.httpBody { + bodyData = direct + } else if let stream = request.httpBodyStream { + bodyData = ClinikoClientTests.readAll(from: stream) + } else { + bodyData = nil + } + XCTAssertEqual(bodyData, payload) + } + + private static func readAll(from stream: InputStream) -> Data { + var data = Data() + stream.open() + defer { stream.close() } + let bufferSize = 1024 + var buffer = [UInt8](repeating: 0, count: bufferSize) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } +} + +// MARK: - Test helpers + +/// Counts how many times a URLProtocolStub responder is invoked. +private final class CallCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + @discardableResult + func increment() -> Int { + lock.lock(); defer { lock.unlock() } + count += 1 + return count + } + + var value: Int { + lock.lock(); defer { lock.unlock() } + return count + } +} + +/// Captures the most recent request seen by the URLProtocolStub responder. +private final class CapturedRequest: @unchecked Sendable { + private let lock = NSLock() + private var stored: URLRequest? + + func set(_ request: URLRequest) { + lock.lock(); defer { lock.unlock() } + stored = request + } + + var value: URLRequest? { + lock.lock(); defer { lock.unlock() } + return stored + } +} + +/// Captures the sequence of `TimeInterval` values passed to a `RetryPolicy`'s +/// sleep closure β€” pins that we honour `Retry-After` instead of the policy +/// default. +private final class CapturedRetryAfter: @unchecked Sendable { + private let lock = NSLock() + private var stored: [TimeInterval] = [] + + func record(_ value: TimeInterval) { + lock.lock(); defer { lock.unlock() } + stored.append(value) + } + + var values: [TimeInterval] { + lock.lock(); defer { lock.unlock() } + return stored + } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoCredentialStoreTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoCredentialStoreTests.swift new file mode 100644 index 0000000..e2cb74c --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoCredentialStoreTests.swift @@ -0,0 +1,287 @@ +import Foundation +import Testing +@testable import SpeechToText + +@Suite("ClinikoCredentialStore", .tags(.fast)) +struct ClinikoCredentialStoreTests { + + /// A fresh in-memory `UserDefaults` suite per test, so concurrent tests + /// never collide on the shared standard suite. Mirrors the pattern called + /// out by issue #32. + private func makeUserDefaults() -> UserDefaults { + let suiteName = "ClinikoCredentialStoreTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + preconditionFailure("UserDefaults(suiteName:) unexpectedly nil") + } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func makeStore( + secureStore: any SecureStore = InMemorySecureStore(), + userDefaults: UserDefaults? = nil + ) -> ClinikoCredentialStore { + ClinikoCredentialStore( + secureStore: secureStore, + userDefaults: userDefaults ?? makeUserDefaults() + ) + } + + // MARK: - Empty state + + @Test("loadCredentials returns nil when nothing is stored") + func loadCredentialsEmpty() async throws { + let store = makeStore() + let creds = try await store.loadCredentials() + #expect(creds == nil) + } + + @Test("hasAPIKey returns false when nothing is stored") + func hasAPIKeyEmpty() async throws { + let store = makeStore() + let present = try await store.hasAPIKey() + #expect(present == false) + } + + @Test("loadShard defaults to ClinikoShard.default") + func loadShardDefault() { + // `loadShard` is `nonisolated` β€” no `await` needed. + let store = makeStore() + let shard = store.loadShard() + #expect(shard == .default) + } + + // MARK: - Save / load round-trip + + @Test("saveCredentials stores key in SecureStore + shard in UserDefaults") + func saveRoundTrip() async throws { + let secureStore = InMemorySecureStore() + let userDefaults = makeUserDefaults() + let store = makeStore(secureStore: secureStore, userDefaults: userDefaults) + + try await store.saveCredentials(apiKey: "MS-secret-uk2", shard: .uk2) + + let storedRaw = try await secureStore.getString(forKey: ClinikoCredentialStore.apiKeyAccount) + #expect(storedRaw == "MS-secret-uk2") + + let storedShard = userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey) + #expect(storedShard == "uk2") + + let creds = try await store.loadCredentials() + let expected = try ClinikoCredentials(apiKey: "MS-secret-uk2", shard: .uk2) + #expect(creds == expected) + } + + @Test("hasAPIKey reflects current state") + func hasAPIKeyReflectsState() async throws { + let store = makeStore() + try await store.saveCredentials(apiKey: "k", shard: .au1) + let present = try await store.hasAPIKey() + #expect(present == true) + } + + // MARK: - Validation + + @Test("saveCredentials rejects empty key with .missingAPIKey") + func rejectsEmptyKey() async { + let store = makeStore() + await #expect(throws: ClinikoCredentialStore.Failure.self) { + try await store.saveCredentials(apiKey: "", shard: .au1) + } + } + + @Test("saveCredentials rejects whitespace-only key") + func rejectsWhitespace() async { + let store = makeStore() + await #expect(throws: ClinikoCredentialStore.Failure.self) { + try await store.saveCredentials(apiKey: " \n\t ", shard: .au1) + } + } + + @Test("saveCredentials trims whitespace before storing") + func trimsWhitespace() async throws { + let secureStore = InMemorySecureStore() + let store = makeStore(secureStore: secureStore) + try await store.saveCredentials(apiKey: " MS-key-au1 \n", shard: .au1) + let storedRaw = try await secureStore.getString(forKey: ClinikoCredentialStore.apiKeyAccount) + #expect(storedRaw == "MS-key-au1") + } + + @Test("hasAPIKey treats empty / whitespace-only stored value as absent") + func emptyStoredValueTreatedAsAbsent() async throws { + let secureStore = InMemorySecureStore() + try await secureStore.setString(" ", forKey: ClinikoCredentialStore.apiKeyAccount) + let store = makeStore(secureStore: secureStore) + #expect(try await store.hasAPIKey() == false) + #expect(try await store.loadCredentials() == nil) + } + + // MARK: - Update / delete + + @Test("updateShard persists without touching the API key") + func updateShardOnly() async throws { + let secureStore = InMemorySecureStore() + let userDefaults = makeUserDefaults() + let store = makeStore(secureStore: secureStore, userDefaults: userDefaults) + try await store.saveCredentials(apiKey: "k", shard: .au1) + // `updateShard` is `nonisolated` β€” no `await` needed. + store.updateShard(.uk2) + + let stored = userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey) + #expect(stored == "uk2") + let key = try await secureStore.getString(forKey: ClinikoCredentialStore.apiKeyAccount) + #expect(key == "k") + } + + @Test("deleteCredentials removes both the key and the shard") + func deleteCredentials() async throws { + let secureStore = InMemorySecureStore() + let userDefaults = makeUserDefaults() + let store = makeStore(secureStore: secureStore, userDefaults: userDefaults) + try await store.saveCredentials(apiKey: "k", shard: .uk1) + + try await store.deleteCredentials() + + #expect(try await store.hasAPIKey() == false) + let storedShard = userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey) + #expect(storedShard == nil) + let creds = try await store.loadCredentials() + #expect(creds == nil) + } + + @Test("deleteCredentials on empty store is a no-op") + func deleteEmptyIsNoOp() async { + let store = makeStore() + do { + try await store.deleteCredentials() + } catch { + Issue.record("deleteCredentials should not throw on an empty store: \(error)") + } + } + + @Test("deleteCredentials retains the shard when SecureStore.delete throws") + func deleteCredentialsRetainsShardOnSecureStoreFailure() async throws { + // Pre-seed the shard via a working store, then swap in a throwing + // SecureStore that shares the same UserDefaults instance. + let userDefaults = makeUserDefaults() + userDefaults.set("uk2", forKey: ClinikoCredentialStore.shardUserDefaultsKey) + + let store = ClinikoCredentialStore( + secureStore: ThrowingSecureStore(mode: .alwaysThrow), + userDefaults: userDefaults + ) + + await #expect(throws: ClinikoCredentialStore.Failure.self) { + try await store.deleteCredentials() + } + // On Keychain failure the shard MUST remain β€” the API key is still + // there too, and clearing the shard alone would amputate the pair so + // that `loadCredentials` returns au1 (the default) against the user's + // real uk2 key, which would 401. Retaining both halves preserves + // user intent until a retry succeeds. + let storedShard = userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey) + #expect(storedShard == "uk2", "shard must be retained when Keychain delete fails") + } + + // MARK: - Service / account constants are pinned + + @Test("service + account constants match the Cliniko reference doc") + func pinConstants() { + #expect(ClinikoCredentialStore.serviceName == "com.speechtotext.cliniko") + #expect(ClinikoCredentialStore.apiKeyAccount == "api_key") + #expect(ClinikoCredentialStore.shardUserDefaultsKey == "cliniko.shard") + } + + // MARK: - SecureStore failure surfacing + + @Test("SecureStore read failures surface as .readFailed with underlying error") + func secureStoreReadFailureWrapped() async { + let store = makeStore(secureStore: ThrowingSecureStore(mode: .alwaysThrow)) + do { + _ = try await store.loadCredentials() + Issue.record("expected loadCredentials to throw") + } catch let failure as ClinikoCredentialStore.Failure { + guard case .readFailed = failure else { + Issue.record("expected .readFailed, got \(failure)") + return + } + } catch { + Issue.record("unexpected error type \(type(of: error))") + } + + do { + _ = try await store.hasAPIKey() + Issue.record("expected hasAPIKey to throw") + } catch let failure as ClinikoCredentialStore.Failure { + guard case .readFailed = failure else { + Issue.record("expected .readFailed for hasAPIKey, got \(failure)") + return + } + } catch { + Issue.record("unexpected error type \(type(of: error))") + } + } + + @Test("SecureStore write failures surface as .writeFailed") + func secureStoreWriteFailureWrapped() async { + let store = makeStore(secureStore: ThrowingSecureStore(mode: .alwaysThrow)) + do { + try await store.saveCredentials(apiKey: "k", shard: .au1) + Issue.record("expected saveCredentials to throw") + } catch let failure as ClinikoCredentialStore.Failure { + guard case .writeFailed = failure else { + Issue.record("expected .writeFailed, got \(failure)") + return + } + } catch { + Issue.record("unexpected error type \(type(of: error))") + } + } + + @Test("SecureStore delete failures surface as .deleteFailed") + func secureStoreDeleteFailureWrapped() async { + let store = makeStore(secureStore: ThrowingSecureStore(mode: .alwaysThrow)) + do { + try await store.deleteCredentials() + Issue.record("expected deleteCredentials to throw") + } catch let failure as ClinikoCredentialStore.Failure { + guard case .deleteFailed = failure else { + Issue.record("expected .deleteFailed, got \(failure)") + return + } + } catch { + Issue.record("unexpected error type \(type(of: error))") + } + } + + @Test("missing API key surfaces as .missingAPIKey") + func missingAPIKeyCase() async { + let store = makeStore() + do { + try await store.saveCredentials(apiKey: "", shard: .au1) + Issue.record("expected saveCredentials to throw") + } catch ClinikoCredentialStore.Failure.missingAPIKey { + // expected + } catch { + Issue.record("expected .missingAPIKey, got \(type(of: error))") + } + } +} + +// MARK: - Test fakes + +/// `SecureStore` fake that throws on every call. Used to verify failure +/// surfacing without depending on a real Keychain error path. +private actor ThrowingSecureStore: SecureStore { + enum Mode { case alwaysThrow } + + struct Boom: Error, Equatable {} + + private let mode: Mode + init(mode: Mode) { self.mode = mode } + + func set(_ data: Data, forKey key: String) async throws { throw Boom() } + func get(forKey key: String) async throws -> Data? { throw Boom() } + func delete(forKey key: String) async throws { throw Boom() } + func deleteAll() async throws { throw Boom() } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoCredentialsTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoCredentialsTests.swift new file mode 100644 index 0000000..439d00c --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoCredentialsTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +@testable import SpeechToText + +@Suite("ClinikoCredentials", .tags(.fast)) +struct ClinikoCredentialsTests { + + @Test("baseURL matches `https://api.{shard}.cliniko.com/v1/`") + func baseURLPerShard() throws { + for shard in ClinikoShard.allCases { + let creds = try ClinikoCredentials(apiKey: "MS-fake-\(shard.rawValue)", shard: shard) + #expect(creds.baseURL.absoluteString == "https://api.\(shard.rawValue).cliniko.com/v1/") + } + } + + @Test("baseURL is HTTPS only") + func baseURLIsHTTPS() throws { + let creds = try ClinikoCredentials(apiKey: "k", shard: .au1) + #expect(creds.baseURL.scheme == "https") + } + + @Test("basicAuthHeaderValue base64-encodes `apiKey:`") + func basicAuthHeaderShape() throws { + let creds = try ClinikoCredentials(apiKey: "MS-secret-au1", shard: .au1) + let header = creds.basicAuthHeaderValue + #expect(header.hasPrefix("Basic ")) + let encoded = String(header.dropFirst("Basic ".count)) + let data = Data(base64Encoded: encoded) + #expect(data != nil) + if let data { + let decoded = String(data: data, encoding: .utf8) + #expect(decoded == "MS-secret-au1:", "Cliniko Basic auth uses key as username + empty password") + } + } + + @Test("description redacts the API key") + func descriptionDoesNotEchoKey() throws { + let creds = try ClinikoCredentials(apiKey: "MS-super-secret-VALUE-123", shard: .uk1) + let text = "\(creds)" + #expect(!text.contains("MS-super-secret")) + #expect(text.contains("")) + #expect(text.contains("uk1")) + } + + @Test("Equatable distinguishes by key + shard") + func equatable() throws { + let a = try ClinikoCredentials(apiKey: "k1", shard: .au1) + let b = try ClinikoCredentials(apiKey: "k1", shard: .au1) + let c = try ClinikoCredentials(apiKey: "k1", shard: .au2) + let d = try ClinikoCredentials(apiKey: "k2", shard: .au1) + #expect(a == b) + #expect(a != c) + #expect(a != d) + } + + @Test("baseURL composes against `users/me` endpoint") + func appendingUsersMe() throws { + let creds = try ClinikoCredentials(apiKey: "k", shard: .au1) + let url = creds.baseURL.appendingPathComponent("users/me") + #expect(url.absoluteString == "https://api.au1.cliniko.com/v1/users/me") + } + + @Test("base64 of the auth value uses standard alphabet") + func base64StandardAlphabet() throws { + // A handful of edge-case keys (multiples of 3 bytes, padding cases). + for raw in ["a", "ab", "abc", "abcd", "abcde", "abcdef"] { + let creds = try ClinikoCredentials(apiKey: raw, shard: .au1) + let value = creds.basicAuthHeaderValue + let encoded = String(value.dropFirst("Basic ".count)) + // Standard base64 alphabet: [A-Za-z0-9+/=] + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") + for scalar in encoded.unicodeScalars { + #expect(allowed.contains(scalar), "non-standard base64 char in \(encoded)") + } + } + } + + @Test("init rejects empty API key") + func initRejectsEmptyKey() { + #expect(throws: ClinikoCredentials.CredentialsError.emptyAPIKey) { + _ = try ClinikoCredentials(apiKey: "", shard: .au1) + } + } + + @Test("init rejects whitespace-only API key") + func initRejectsWhitespaceKey() { + #expect(throws: ClinikoCredentials.CredentialsError.emptyAPIKey) { + _ = try ClinikoCredentials(apiKey: " \n\t ", shard: .au1) + } + } + + @Test("init trims surrounding whitespace") + func initTrimsWhitespace() throws { + let creds = try ClinikoCredentials(apiKey: " MS-trim-au1 \n", shard: .au1) + // Verify via the basic-auth path β€” the `apiKey` field itself is internal. + let encoded = String(creds.basicAuthHeaderValue.dropFirst("Basic ".count)) + let decoded = String(data: Data(base64Encoded: encoded) ?? Data(), encoding: .utf8) ?? "" + #expect(decoded == "MS-trim-au1:") + } + + @Test("baseURL is non-nil for every shard") + func baseURLPerShardNonNil() throws { + // Pins the `preconditionFailure` defence in `baseURL` β€” if a future + // shard rawValue ever produces a malformed URL, this test fires + // before runtime. + for shard in ClinikoShard.allCases { + let creds = try ClinikoCredentials(apiKey: "k", shard: shard) + #expect(!creds.baseURL.absoluteString.isEmpty) + } + } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoEndpointTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoEndpointTests.swift new file mode 100644 index 0000000..34a48b6 --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoEndpointTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing +@testable import SpeechToText + +@Suite("ClinikoEndpoint", .tags(.fast)) +struct ClinikoEndpointTests { + + private let baseURL = URL(string: "https://api.au1.cliniko.com/v1/")! + + // MARK: - Method + path template + + @Test("usersMe is a GET with the right template") + func usersMeShape() { + let endpoint = ClinikoEndpoint.usersMe + #expect(endpoint.method == .get) + #expect(endpoint.pathTemplate == "/users/me") + #expect(endpoint.body == nil) + #expect(endpoint.contentType == nil) + #expect(endpoint.isIdempotent) + #expect(endpoint.resource == .user) + } + + @Test("patientSearch is a GET with structural template") + func patientSearchShape() { + let endpoint = ClinikoEndpoint.patientSearch(query: "smith") + #expect(endpoint.method == .get) + #expect(endpoint.pathTemplate == "/patients?q={query}") + #expect(endpoint.body == nil) + #expect(endpoint.isIdempotent) + #expect(endpoint.resource == .patient) + } + + @Test("patientAppointments is a GET with id-template") + func patientAppointmentsShape() { + let endpoint = ClinikoEndpoint.patientAppointments( + patientID: "12345", + from: Date(timeIntervalSince1970: 0), + to: Date(timeIntervalSince1970: 86_400) + ) + #expect(endpoint.method == .get) + #expect(endpoint.pathTemplate == "/patients/:id/appointments") + // The bound id MUST NOT be in the template (PHI logging rule). + #expect(!endpoint.pathTemplate.contains("12345")) + #expect(endpoint.isIdempotent) + #expect(endpoint.resource == .patient) + } + + @Test("createTreatmentNote is a POST that is not idempotent") + func createTreatmentNoteShape() { + let body = Data("{}".utf8) + let endpoint = ClinikoEndpoint.createTreatmentNote(body: body) + #expect(endpoint.method == .post) + #expect(endpoint.pathTemplate == "/treatment_notes") + #expect(endpoint.body == body) + #expect(endpoint.contentType == "application/json") + #expect(!endpoint.isIdempotent, + "POST treatment_notes must not auto-retry on 5xx OR transport (duplicate-write guard)") + #expect(endpoint.resource == .treatmentNote) + } + + // MARK: - URL building + + @Test("usersMe URL is /v1/users/me") + func usersMeURL() { + let url = ClinikoEndpoint.usersMe.buildURL(against: baseURL) + #expect(url?.absoluteString == "https://api.au1.cliniko.com/v1/users/me") + } + + @Test("patientSearch URL encodes the query") + func patientSearchURL() { + let url = ClinikoEndpoint.patientSearch(query: "John & Jane").buildURL(against: baseURL) + // The query must be percent-encoded; `&` becomes `%26`, space becomes `%20`. + #expect(url != nil) + let absolute = url?.absoluteString ?? "" + #expect(absolute.contains("/v1/patients")) + #expect(absolute.contains("q=")) + // URLComponents standard encoding turns space into "%20" + #expect(absolute.contains("John%20%26%20Jane") || absolute.contains("John+%26+Jane")) + } + + @Test("patientAppointments URL embeds id + ISO8601 dates") + func patientAppointmentsURL() { + let from = Date(timeIntervalSince1970: 0) + let to = Date(timeIntervalSince1970: 86_400) + let url = ClinikoEndpoint.patientAppointments(patientID: "12345", from: from, to: to) + .buildURL(against: baseURL) + let absolute = url?.absoluteString ?? "" + #expect(absolute.contains("/v1/patients/12345/appointments")) + #expect(absolute.contains("from=")) + #expect(absolute.contains("to=")) + // ISO8601 1970-01-01 / 1970-01-02 β€” exact format may include a "Z". + #expect(absolute.contains("1970-01-01T00%3A00%3A00Z") || absolute.contains("1970-01-01T00:00:00Z")) + } + + @Test("patientAppointments percent-encodes weird patient IDs") + func patientAppointmentsPercentEncodesID() { + let url = ClinikoEndpoint.patientAppointments( + patientID: "12 345/abc", + from: Date(timeIntervalSince1970: 0), + to: Date(timeIntervalSince1970: 1) + ).buildURL(against: baseURL) + let absolute = url?.absoluteString ?? "" + // Space β†’ %20 and slash β†’ %2F, so the id stays a single path + // segment between `/patients/` and `/appointments`. + #expect(absolute.contains("/v1/patients/12%20345%2Fabc/appointments"), + "got \(absolute)") + } + + @Test("createTreatmentNote URL is /v1/treatment_notes") + func createTreatmentNoteURL() { + let url = ClinikoEndpoint.createTreatmentNote(body: Data()) + .buildURL(against: baseURL) + #expect(url?.absoluteString == "https://api.au1.cliniko.com/v1/treatment_notes") + } + + // MARK: - Cross-shard URL building + + @Test("buildURL returns non-nil for every shard Γ— endpoint combo") + func buildURLSucceedsForEveryShardAndEndpoint() throws { + let endpoints: [ClinikoEndpoint] = [ + .usersMe, + .patientSearch(query: "x"), + .patientAppointments(patientID: "1", from: Date(), to: Date()), + .createTreatmentNote(body: Data()) + ] + for shard in ClinikoShard.allCases { + let creds = try ClinikoCredentials(apiKey: "k", shard: shard) + for endpoint in endpoints { + let url = endpoint.buildURL(against: creds.baseURL) + #expect(url != nil, "\(shard) Γ— \(endpoint.pathTemplate) produced nil URL") + } + } + } + + // MARK: - ISO8601 helper + + @Test("iso8601 formatter emits UTC") + func iso8601IsUTC() { + let formatted = ClinikoEndpoint.iso8601(Date(timeIntervalSince1970: 0)) + #expect(formatted == "1970-01-01T00:00:00Z") + } + + // MARK: - Equatable + + @Test("Equatable separates same-case values") + func equatableShape() { + #expect(ClinikoEndpoint.usersMe == ClinikoEndpoint.usersMe) + #expect(ClinikoEndpoint.patientSearch(query: "a") != .patientSearch(query: "b")) + #expect(ClinikoEndpoint.patientAppointments(patientID: "1", from: Date(timeIntervalSince1970: 0), to: Date(timeIntervalSince1970: 1)) + != .patientAppointments(patientID: "2", from: Date(timeIntervalSince1970: 0), to: Date(timeIntervalSince1970: 1))) + } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoErrorTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoErrorTests.swift new file mode 100644 index 0000000..8db6de7 --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoErrorTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import SpeechToText + +@Suite("ClinikoError", .tags(.fast)) +struct ClinikoErrorTests { + + @Test("Equatable distinguishes every case") + func equatableDistinguishesCases() { + let cases: [ClinikoError] = [ + .unauthenticated, + .forbidden, + .notFound(resource: .patient), + .notFound(resource: .appointment), + .validation(fields: [:]), + .validation(fields: ["name": ["must be present"]]), + .rateLimited(retryAfter: nil), + .rateLimited(retryAfter: 30), + .server(status: 500), + .server(status: 502), + .transport(.notConnectedToInternet), + .transport(.timedOut), + .cancelled, + .decoding(typeName: "Foo"), + .nonHTTPResponse + ] + for (index, lhs) in cases.enumerated() { + for (other, rhs) in cases.enumerated() where index != other { + #expect(lhs != rhs, "\(lhs) and \(rhs) should not be equal") + } + } + } + + @Test("description never echoes PHI-shaped fields") + func descriptionsAreStructural() { + // The description is the only side-effect surface that any caller + // can interpolate into a log line. Pin that nothing it contains + // could be patient-identifying β€” only error-case names + status + // codes + resource enum tags. + let phiBait = ClinikoError.validation(fields: [ + "patient_first_name": ["Marcus"], + "patient_last_name": ["Aurelius"] + ]) + let text = phiBait.description + // Field count is OK β€” that's structural. But neither key nor value + // should appear. + #expect(!text.contains("Marcus")) + #expect(!text.contains("Aurelius")) + #expect(!text.contains("patient_first_name")) + } + + @Test("Resource enum tags are stable strings") + func resourceRawValues() { + #expect(ClinikoError.Resource.user.rawValue == "user") + #expect(ClinikoError.Resource.patient.rawValue == "patient") + #expect(ClinikoError.Resource.appointment.rawValue == "appointment") + #expect(ClinikoError.Resource.treatmentNote.rawValue == "treatmentNote") + } + + @Test("Resource enum has exactly four cases") + func resourceCaseCount() { + // No `.unknown` fallback β€” every endpoint provides a real resource. + // If a new endpoint is added that needs a new resource, this test + // forces the addition to be deliberate. + let allCases: [ClinikoError.Resource] = [.user, .patient, .appointment, .treatmentNote] + for case_ in allCases { + #expect(!case_.rawValue.isEmpty) + } + } + + @Test("rateLimited description handles nil retryAfter") + func rateLimitedNilRetryAfter() { + let error = ClinikoError.rateLimited(retryAfter: nil) + let text = error.description + #expect(text.contains("429")) + #expect(!text.contains("retry after")) + } + + @Test("rateLimited description includes integer retryAfter") + func rateLimitedFloorsRetryAfter() { + let error = ClinikoError.rateLimited(retryAfter: 12.7) + let text = error.description + #expect(text.contains("12")) + } + + @Test("notFound description names the resource") + func notFoundIncludesResource() { + let error = ClinikoError.notFound(resource: .appointment) + #expect(error.description.contains("appointment")) + #expect(error.description.contains("404")) + } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoPatientServiceTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoPatientServiceTests.swift new file mode 100644 index 0000000..74adfc5 --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoPatientServiceTests.swift @@ -0,0 +1,185 @@ +import Foundation +import XCTest +@testable import SpeechToText + +/// End-to-end behaviour tests for `ClinikoPatientService` against +/// `URLProtocolStub`. Scoped to what the picker UI cares about: +/// query-item shape, decoded payload, error pass-through, cancellation. +/// +/// Why XCTest (and not Swift Testing): `URLProtocolStub` keeps a single +/// process-wide responder. XCTest serialises test methods within a class +/// by default; Swift Testing parallelises them, so two `@Test`s would +/// race the responder. Refactor tracked in #30. +final class ClinikoPatientServiceTests: XCTestCase { + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + // MARK: - Helpers + + private func credentials() throws -> ClinikoCredentials { + try ClinikoCredentials(apiKey: "MS-test-au1", shard: .au1) + } + + private func makeService( + responder: @escaping URLProtocolStub.Responder + ) throws -> ClinikoPatientService { + let config = URLProtocolStub.install(responder) + let session = URLSession(configuration: config) + let client = ClinikoClient( + credentials: try credentials(), + session: session, + userAgent: "patient-service-tests/1.0", + retryPolicy: .immediate + ) + return ClinikoPatientService(client: client) + } + + // MARK: - Happy path + + func test_searchPatients_decodesPayload() async throws { + let service = try makeService { request in + let body = try HTTPStubFixture.load("cliniko/responses/patients_search.json") + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, body) + } + + let patients = try await service.searchPatients(query: "sample") + + XCTAssertEqual(patients.count, 3) + XCTAssertEqual(patients.first?.id, 1001) + XCTAssertEqual(patients.first?.firstName, "Sample") + XCTAssertEqual(patients.first?.lastName, "Patient") + XCTAssertEqual(patients.first?.dateOfBirth, "1980-01-15") + XCTAssertEqual(patients.first?.email, "sample.patient@example.test") + XCTAssertNil(patients.last?.dateOfBirth) + XCTAssertNil(patients.last?.email) + } + + func test_searchPatients_emitsQueryItem() async throws { + let captured = CapturedRequestBox() + let service = try makeService { request in + captured.set(request) + let body = try HTTPStubFixture.load("cliniko/responses/patients_search_empty.json") + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, body) + } + + _ = try await service.searchPatients(query: "doe smith") + + let url = try XCTUnwrap(captured.value?.url) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let queryValue = components.queryItems?.first { $0.name == "q" }?.value + XCTAssertEqual(queryValue, "doe smith") + XCTAssertEqual(components.path, "/v1/patients") + } + + func test_searchPatients_emptyResponse_returnsEmptyArray() async throws { + let service = try makeService { request in + let body = try HTTPStubFixture.load("cliniko/responses/patients_search_empty.json") + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, body) + } + + let patients = try await service.searchPatients(query: "zzznomatchzzz") + XCTAssertTrue(patients.isEmpty) + } + + // MARK: - Error mapping (pass-through from ClinikoClient) + + func test_searchPatients_401_mapsToUnauthenticated() async throws { + try await assertSearchError(status: 401, expected: .unauthenticated) + } + + func test_searchPatients_403_mapsToForbidden() async throws { + try await assertSearchError(status: 403, expected: .forbidden) + } + + func test_searchPatients_404_mapsToNotFoundPatient() async throws { + try await assertSearchError( + status: 404, + expected: .notFound(resource: .patient) + ) + } + + func test_searchPatients_503_afterRetriesExhausted_mapsToServer() async throws { + try await assertSearchError(status: 503, expected: .server(status: 503)) + } + + // MARK: - Cancellation + + func test_searchPatients_urlSessionCancelled_mapsToClinikoCancelled() async throws { + let service = try makeService { _ in + throw URLError(.cancelled) + } + do { + _ = try await service.searchPatients(query: "anything") + XCTFail("expected ClinikoError.cancelled") + } catch let error as ClinikoError { + XCTAssertEqual(error, .cancelled) + } catch { + XCTFail("expected ClinikoError, got \(error)") + } + } + + // MARK: - Helpers + + private func assertSearchError( + status: Int, + expected: ClinikoError, + file: StaticString = #file, + line: UInt = #line + ) async throws { + let service = try makeService { request in + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.test")!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + return (response, Data()) + } + do { + _ = try await service.searchPatients(query: "anything") + XCTFail("expected \(expected), got success", file: file, line: line) + } catch let error as ClinikoError { + XCTAssertEqual(error, expected, file: file, line: line) + } catch { + XCTFail("expected ClinikoError, got \(error)", file: file, line: line) + } + } +} + +/// Captures the most recent request seen by the URLProtocolStub responder. +/// Lockless `@unchecked Sendable` because the lock guards every access. +final class CapturedRequestBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: URLRequest? + + func set(_ request: URLRequest) { + lock.lock(); defer { lock.unlock() } + stored = request + } + + var value: URLRequest? { + lock.lock(); defer { lock.unlock() } + return stored + } +} diff --git a/Tests/SpeechToTextTests/Services/Cliniko/ClinikoShardTests.swift b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoShardTests.swift new file mode 100644 index 0000000..a94cad8 --- /dev/null +++ b/Tests/SpeechToTextTests/Services/Cliniko/ClinikoShardTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import SpeechToText + +@Suite("ClinikoShard", .tags(.fast)) +struct ClinikoShardTests { + + @Test("apiHost composes from rawValue") + func apiHostMatchesRawValue() { + for shard in ClinikoShard.allCases { + #expect(shard.apiHost == "api.\(shard.rawValue).cliniko.com") + } + } + + @Test("apiHost contains only ASCII lowercase hostname characters") + func apiHostIsURLSafe() { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789.-") + for shard in ClinikoShard.allCases { + let scalars = shard.apiHost.unicodeScalars + for scalar in scalars { + #expect(allowed.contains(scalar), "shard \(shard.rawValue) host has unexpected character \(scalar)") + } + } + } + + @Test("default is au1") + func defaultIsAU1() { + #expect(ClinikoShard.default == .au1) + } + + @Test("displayName is non-empty for every case") + func displayNamesPresent() { + for shard in ClinikoShard.allCases { + #expect(!shard.displayName.isEmpty) + #expect(shard.displayName.contains(shard.rawValue)) + } + } + + @Test("rawValue round-trips via Codable") + func codableRoundTrip() throws { + for shard in ClinikoShard.allCases { + let encoded = try JSONEncoder().encode(shard) + let decoded = try JSONDecoder().decode(ClinikoShard.self, from: encoded) + #expect(decoded == shard) + } + } + + @Test("Identifiable id matches rawValue") + func identifiableIdMatchesRawValue() { + for shard in ClinikoShard.allCases { + #expect(shard.id == shard.rawValue) + } + } + + @Test("all expected regions covered") + func expectedRegionsCovered() { + let raw = Set(ClinikoShard.allCases.map(\.rawValue)) + for expected in ["au1", "au2", "au3", "au4", "uk1", "uk2", "ca1", "us1", "eu1"] { + #expect(raw.contains(expected), "missing shard \(expected)") + } + } +} diff --git a/Tests/SpeechToTextTests/Services/LLMOptionsTests.swift b/Tests/SpeechToTextTests/Services/LLMOptionsTests.swift new file mode 100644 index 0000000..819fcd1 --- /dev/null +++ b/Tests/SpeechToTextTests/Services/LLMOptionsTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import SpeechToText + +/// Covers the `LLMOptions` defaults contract. These defaults are +/// load-bearing: EPIC #1 locks "deterministic generation (temperature +/// 0, fixed seed)" for clinical notes, so a silent drift would allow +/// nondeterministic output to slip through. +@Suite("LLMOptions", .tags(.fast)) +struct LLMOptionsTests { + @Test("Default-initialised options are deterministic") + func defaults_areDeterministic() { + let options = LLMOptions() + + // Temperature 0 + non-nil seed == reproducible output for the + // same prompt under the same model weights. + #expect(options.temperature == 0) + #expect(options.seed != nil) + } + + @Test("Default options match the EPIC #1 contract") + func defaults_matchContract() { + let options = LLMOptions() + + #expect(options.temperature == 0) + #expect(options.topP == 1.0) + #expect(options.maxTokens == 1024) + #expect(options.seed == 42) + #expect(options.stop.isEmpty) + } + + @Test("Explicit parameters override the defaults") + func explicitParameters_override() { + let options = LLMOptions( + temperature: 0.5, + topP: 0.9, + maxTokens: 256, + seed: nil, + stop: [""] + ) + + #expect(options.temperature == 0.5) + #expect(options.topP == 0.9) + #expect(options.maxTokens == 256) + #expect(options.seed == nil) + #expect(options.stop == [""]) + } + + @Test("Equatable conformance ignores nothing β€” all fields compared") + func equatable_comparesAllFields() { + let a = LLMOptions() + let b = LLMOptions() + #expect(a == b) + + var c = LLMOptions() + c.temperature = 0.1 + #expect(a != c) + + var d = LLMOptions() + d.seed = nil + #expect(a != d) + } +} diff --git a/Tests/SpeechToTextTests/Services/ManipulationsRepositoryTests.swift b/Tests/SpeechToTextTests/Services/ManipulationsRepositoryTests.swift new file mode 100644 index 0000000..8047400 --- /dev/null +++ b/Tests/SpeechToTextTests/Services/ManipulationsRepositoryTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import SpeechToText + +// Covers issue #6 acceptance criteria: +// - Repo loads bundled JSON at startup and exposes [Manipulation] to +// downstream call sites (#4 prompt builder, #13 ReviewScreen, #10 +// Cliniko export). +// - One-file swap: populating `cliniko_code` in the source JSON flows +// through to the repository without any code change. +// - Malformed / missing resource paths surface typed errors so a bad +// swap is loud, not silent. +// +// Style: Swift Testing only; `.fast` via the suite. See +// `.claude/references/testing-conventions.md`. + +@Suite("ManipulationsRepository", .tags(.fast)) +struct ManipulationsRepositoryTests { + + // MARK: - Bundled placeholder (production path) + + @Test("Bundled placeholder decodes to the v1 seven-entry taxonomy in declared order") + func bundledPlaceholder_decodesToSevenKnownEntries() throws { + let repo = try ManipulationsRepository.loadFromBundle() + + let expectedIDs = [ + "diversified_hvla", + "gonstead", + "activator", + "thompson_drop", + "sacro_occipital_technique", + "toggle_recoil", + "mobilisation_non_hvla" + ] + #expect(repo.all.map(\.id) == expectedIDs) + #expect(repo.all.count == 7) + } + + @Test("Every placeholder entry has a non-empty display name and nil cliniko_code") + func bundledPlaceholder_displayNameAndClinikoCodeInvariants() throws { + let repo = try ManipulationsRepository.loadFromBundle() + + for manipulation in repo.all { + #expect( + !manipulation.displayName.isEmpty, + "display_name must be populated for \(manipulation.id)" + ) + #expect( + manipulation.clinikoCode == nil, + "v1 placeholder must leave cliniko_code nil for \(manipulation.id)" + ) + } + } + + @Test("Bundled placeholder IDs are unique") + func bundledPlaceholder_idsAreUnique() throws { + // `id` is the join key for `StructuredNotes.selectedManipulationIDs` + // and the future Cliniko export mapping (#10). A duplicate would + // silently corrupt selection state, so the taxonomy file must keep + // unique ids even as it grows. + let repo = try ManipulationsRepository.loadFromBundle() + #expect(Set(repo.all.map(\.id)).count == repo.all.count) + } + + @Test("Empty JSON array decodes to an empty repository") + func emptyArray_decodesToEmptyRepository() throws { + // Pins current behaviour: an empty taxonomy file is not an error at + // this layer. Call sites are free to add their own guard if they + // require a non-empty list. + let repo = try ManipulationsRepository(data: Data("[]".utf8)) + #expect(repo.all.isEmpty) + } + + @Test("Duplicate id in JSON throws duplicateIDs with the offending ids") + func duplicateIDs_throwTyped() { + // A broken taxonomy swap must surface loudly β€” dup ids would + // silently corrupt StructuredNotes.selectedManipulationIDs + // matching and the #10 Cliniko export mapping. + let dupJSON = Data(""" + [ + { "id": "activator", "display_name": "Activator", "cliniko_code": null }, + { "id": "activator", "display_name": "Activator (copy)", "cliniko_code": null }, + { "id": "gonstead", "display_name": "Gonstead", "cliniko_code": null }, + { "id": "gonstead", "display_name": "Gonstead (copy)", "cliniko_code": null } + ] + """.utf8) + + #expect { + _ = try ManipulationsRepository(data: dupJSON) + } throws: { error in + guard case let .duplicateIDs(ids) = error as? ManipulationsRepositoryError else { + return false + } + return ids == ["activator", "gonstead"] + } + } + + // MARK: - One-file-swap contract (future real taxonomy) + + @Test("Populated cliniko_code round-trips through the repository") + func realTaxonomyShape_preservesClinikoCode() throws { + // Shape mirrors the real Cliniko taxonomy that will one day replace + // `placeholder.json`. The values here are illustrative only. + let realTaxonomyJSON = Data(""" + [ + { "id": "diversified_hvla", "display_name": "Diversified HVLA", "cliniko_code": "CH-DHVLA-001" }, + { "id": "activator", "display_name": "Activator", "cliniko_code": "CH-ACT-014" } + ] + """.utf8) + + let repo = try ManipulationsRepository(data: realTaxonomyJSON) + + #expect(repo.all.count == 2) + #expect(repo.all[0].clinikoCode == "CH-DHVLA-001") + #expect(repo.all[1].clinikoCode == "CH-ACT-014") + } + + // MARK: - Negative paths + + @Test("Malformed JSON throws a DecodingError") + func malformedJSON_throwsDecodingError() { + let garbage = Data("not json".utf8) + #expect(throws: DecodingError.self) { + _ = try ManipulationsRepository(data: garbage) + } + } + + @Test("Missing required key surfaces a decoding failure") + func missingRequiredKey_throws() { + let invalid = Data(""" + [ { "id": "x", "cliniko_code": null } ] + """.utf8) + #expect(throws: (any Error).self) { + _ = try ManipulationsRepository(data: invalid) + } + } + + @Test("Missing resource surfaces the typed resourceNotFound error") + func missingResource_throwsTypedError() { + #expect(throws: ManipulationsRepositoryError.self) { + _ = try ManipulationsRepository.loadFromBundle( + resource: "does-not-exist", + subdirectory: "Manipulations" + ) + } + } +} diff --git a/Tests/SpeechToTextTests/Services/SessionStoreTests.swift b/Tests/SpeechToTextTests/Services/SessionStoreTests.swift new file mode 100644 index 0000000..e17cd73 --- /dev/null +++ b/Tests/SpeechToTextTests/Services/SessionStoreTests.swift @@ -0,0 +1,338 @@ +import Foundation +import Testing +@testable import SpeechToText + +// MARK: - SessionStore lifecycle tests +// +// Covers issue #2 acceptance criteria: +// - start/replace/clear lifecycle +// - mutator no-op semantics when active == nil +// - idle-timeout behaviour driven by an injected clock +// - PHI-free-disk invariant (no writes to UserDefaults) +// +// Style: Swift Testing only; every test tagged `.fast` via the suite. The +// suite is `@MainActor` because `SessionStore` is MainActor-isolated. +// See `.claude/references/phi-handling.md` for the PHI policy and +// `Tests/SpeechToTextTests/Utilities/SwiftTestingExemplarTests.swift` +// for the canonical idiom. + +@Suite("SessionStore", .tags(.fast)) +@MainActor +struct SessionStoreTests { + + // MARK: - Helpers + + /// Build a mutable clock whose value can be advanced between calls. + /// Returned closure is `@Sendable` to satisfy `SessionStore.init`. + private final class MutableClock: @unchecked Sendable { + var current: Date + init(_ start: Date) { self.current = start } + } + + private func makeClock(_ start: Date = Date(timeIntervalSince1970: 1_000_000)) + -> (MutableClock, @Sendable () -> Date) { + let box = MutableClock(start) + let closure: @Sendable () -> Date = { box.current } + return (box, closure) + } + + // MARK: - start(from:) + + @Test("start(from:) activates a session and stamps lastActivity") + func start_fromRecording_activates() { + let (clock, now) = makeClock() + let store = SessionStore(now: now) + let recording = RecordingSession() + + store.start(from: recording) + + #expect(store.active != nil) + #expect(store.active?.recordingSession.id == recording.id) + #expect(store.lastActivity == clock.current) + } + + // MARK: - start(_:) + + @Test("start(_:) with a pre-built session round-trips every field") + func start_preBuilt_roundTrips() throws { + let store = SessionStore() + let notes = StructuredNotes(subjective: "subjective-placeholder") + let id = UUID() + let session = ClinicalSession( + id: id, + recordingSession: RecordingSession(), + draftNotes: notes, + excludedReAdded: ["snippet-placeholder"], + selectedPatientID: "patient-1", + selectedAppointmentID: "appt-1" + ) + + store.start(session) + + let active = try #require(store.active) + #expect(active.id == id) + #expect(active.draftNotes == notes) + #expect(active.excludedReAdded == ["snippet-placeholder"]) + #expect(active.selectedPatientID == "patient-1") + #expect(active.selectedAppointmentID == "appt-1") + } + + // MARK: - replace + + @Test("start replaces the previously active session") + func start_replacesPreviousSession() throws { + let store = SessionStore() + store.start(from: RecordingSession()) + let firstID = try #require(store.active?.id) + + store.start(from: RecordingSession()) + let secondID = try #require(store.active?.id) + + #expect(firstID != secondID) + } + + // MARK: - clear + + @Test("clear() drops the active session") + func clear_dropsActive() { + let store = SessionStore() + store.start(from: RecordingSession()) + + store.clear() + + #expect(store.active == nil) + } + + @Test("clear() is idempotent when nothing is active") + func clear_idempotent() { + let store = SessionStore() + store.clear() + store.clear() + #expect(store.active == nil) + } + + // MARK: - Mutators when inactive + + @Test("setDraftNotes is a no-op when active is nil") + func setDraftNotes_noopWhenInactive() { + let store = SessionStore() + store.setDraftNotes(StructuredNotes(subjective: "subjective-placeholder")) + #expect(store.active == nil) + } + + @Test("markExcludedReAdded is a no-op when active is nil") + func markExcludedReAdded_noopWhenInactive() { + let store = SessionStore() + store.markExcludedReAdded("snippet-placeholder") + #expect(store.active == nil) + } + + @Test("setSelectedPatient is a no-op when active is nil") + func setSelectedPatient_noopWhenInactive() { + let store = SessionStore() + store.setSelectedPatient(id: "patient-1") + #expect(store.active == nil) + } + + @Test("setSelectedAppointment is a no-op when active is nil") + func setSelectedAppointment_noopWhenInactive() { + let store = SessionStore() + store.setSelectedAppointment(id: "appt-1") + #expect(store.active == nil) + } + + // MARK: - setDraftNotes + + @Test("setDraftNotes updates the active session") + func setDraftNotes_updatesActive() { + let store = SessionStore() + store.start(from: RecordingSession()) + let notes = StructuredNotes(subjective: "subjective-placeholder") + + store.setDraftNotes(notes) + + #expect(store.active?.draftNotes == notes) + } + + // MARK: - markExcludedReAdded + + @Test("markExcludedReAdded appends on first call") + func markExcludedReAdded_appends() { + let store = SessionStore() + store.start(from: RecordingSession()) + + store.markExcludedReAdded("snippet-1") + + #expect(store.active?.excludedReAdded == ["snippet-1"]) + } + + @Test("markExcludedReAdded dedups identical entries") + func markExcludedReAdded_dedups() { + let store = SessionStore() + store.start(from: RecordingSession()) + + store.markExcludedReAdded("snippet-1") + store.markExcludedReAdded("snippet-1") + + #expect(store.active?.excludedReAdded == ["snippet-1"]) + } + + @Test("markExcludedReAdded preserves order across distinct entries") + func markExcludedReAdded_preservesOrder() { + let store = SessionStore() + store.start(from: RecordingSession()) + + store.markExcludedReAdded("snippet-a") + store.markExcludedReAdded("snippet-b") + store.markExcludedReAdded("snippet-c") + + #expect(store.active?.excludedReAdded == ["snippet-a", "snippet-b", "snippet-c"]) + } + + // MARK: - setSelectedPatient / setSelectedAppointment + + @Test("setSelectedPatient round-trips and clears on nil") + func setSelectedPatient_roundTrips() { + let store = SessionStore() + store.start(from: RecordingSession()) + + store.setSelectedPatient(id: "patient-1") + #expect(store.active?.selectedPatientID == "patient-1") + + store.setSelectedPatient(id: nil) + #expect(store.active?.selectedPatientID == nil) + } + + @Test("setSelectedAppointment round-trips and clears on nil") + func setSelectedAppointment_roundTrips() { + let store = SessionStore() + store.start(from: RecordingSession()) + + store.setSelectedAppointment(id: "appt-1") + #expect(store.active?.selectedAppointmentID == "appt-1") + + store.setSelectedAppointment(id: nil) + #expect(store.active?.selectedAppointmentID == nil) + } + + // MARK: - touch() + + @Test("touch() bumps lastActivity to the injected clock's latest value") + func touch_bumpsLastActivity() { + let (clock, now) = makeClock() + let store = SessionStore(now: now) + let initialStamp = store.lastActivity + + clock.current = clock.current.addingTimeInterval(42) + store.touch() + + #expect(store.lastActivity != initialStamp) + #expect(store.lastActivity == clock.current) + } + + // MARK: - checkIdleTimeout() + + @Test("checkIdleTimeout returns false when no session is active") + func checkIdleTimeout_falseWhenInactive() { + let store = SessionStore() + #expect(store.checkIdleTimeout() == false) + } + + @Test("checkIdleTimeout returns false when elapsed < idleTimeout") + func checkIdleTimeout_falseWhenBelowThreshold() { + let (clock, now) = makeClock() + let store = SessionStore(idleTimeout: 60, now: now) + store.start(from: RecordingSession()) + + clock.current = clock.current.addingTimeInterval(30) + + #expect(store.checkIdleTimeout() == false) + #expect(store.active != nil) + } + + @Test("checkIdleTimeout returns false at exactly elapsed == idleTimeout (strict >)") + func checkIdleTimeout_falseAtBoundary() { + let (clock, now) = makeClock() + let store = SessionStore(idleTimeout: 60, now: now) + store.start(from: RecordingSession()) + + clock.current = clock.current.addingTimeInterval(60) + + #expect(store.checkIdleTimeout() == false) + #expect(store.active != nil) + } + + @Test("checkIdleTimeout clears and returns true when elapsed > idleTimeout") + func checkIdleTimeout_clearsWhenExceeded() { + let (clock, now) = makeClock() + let store = SessionStore(idleTimeout: 60, now: now) + store.start(from: RecordingSession()) + + clock.current = clock.current.addingTimeInterval(61) + + #expect(store.checkIdleTimeout() == true) + #expect(store.active == nil) + } + + @Test("checkIdleTimeout returns false on a second call after a successful clear") + func checkIdleTimeout_secondCallFalseAfterClear() { + let (clock, now) = makeClock() + let store = SessionStore(idleTimeout: 60, now: now) + store.start(from: RecordingSession()) + + clock.current = clock.current.addingTimeInterval(61) + _ = store.checkIdleTimeout() + + #expect(store.checkIdleTimeout() == false) + } + + // MARK: - PHI-free disk invariant + + @Test("SessionStore lifecycle writes nothing to UserDefaults") + func lifecycle_doesNotTouchUserDefaults() { + let before = UserDefaults.standard.dictionaryRepresentation() + let beforeKeys = Set(before.keys) + + // Exercise the full public surface. + let store = SessionStore() + store.start(from: RecordingSession()) + store.setDraftNotes(StructuredNotes(subjective: "subjective-placeholder")) + store.markExcludedReAdded("snippet-placeholder") + store.setSelectedPatient(id: "patient-1") + store.setSelectedAppointment(id: "appt-1") + store.touch() + _ = store.checkIdleTimeout() + store.clear() + + let after = UserDefaults.standard.dictionaryRepresentation() + let afterKeys = Set(after.keys) + + #expect(beforeKeys == afterKeys) + for key in beforeKeys { + #expect( + String(describing: before[key]) == String(describing: after[key]), + "UserDefaults value for \(key) changed during SessionStore lifecycle" + ) + } + } + + // MARK: - Export-success contract + + @Test("Export-success path: populated session clears to nil") + func exportSuccess_clearsActive() { + let store = SessionStore() + store.start(from: RecordingSession()) + store.setDraftNotes(StructuredNotes( + subjective: "subjective-placeholder", + objective: "objective-placeholder", + assessment: "assessment-placeholder", + plan: "plan-placeholder" + )) + store.setSelectedPatient(id: "patient-1") + store.setSelectedAppointment(id: "appt-1") + + store.clear() + + #expect(store.active == nil) + } +} diff --git a/Tests/SpeechToTextTests/Utilities/HTTPStubFixture.swift b/Tests/SpeechToTextTests/Utilities/HTTPStubFixture.swift new file mode 100644 index 0000000..87f085e --- /dev/null +++ b/Tests/SpeechToTextTests/Utilities/HTTPStubFixture.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Loads JSON + text fixture files shipped with the test bundle. +/// +/// Fixtures live under `Tests/SpeechToTextTests/Fixtures/` and are copied into the test +/// bundle via `resources: [.copy("Fixtures")]` in `Package.swift`. Access is via +/// `Bundle.module` which SPM synthesises automatically once any resources are declared +/// on the target. +/// +/// Path convention: use forward-slash segments relative to `Fixtures/`, +/// e.g. `cliniko/responses/users_me.json`. +enum HTTPStubFixture { + enum FixtureError: Swift.Error, CustomStringConvertible, Equatable, Sendable { + case notFound(path: String) + case readFailed(path: String, underlying: Swift.Error) + + var description: String { + switch self { + case .notFound(let path): + return "HTTPStubFixture: fixture not found at 'Fixtures/\(path)'" + case .readFailed(let path, let underlying): + return "HTTPStubFixture: read failed for 'Fixtures/\(path)': \(underlying)" + } + } + + // Ignore `underlying` in equality so `XCTAssertEqual`-style + // assertions work across Foundation's various `NSError` instances + // that represent the same failure. + static func == (lhs: FixtureError, rhs: FixtureError) -> Bool { + switch (lhs, rhs) { + case let (.notFound(l), .notFound(r)): + return l == r + case let (.readFailed(l, _), .readFailed(r, _)): + return l == r + default: + return false + } + } + } + + /// Load a fixture's raw bytes. + static func load(_ path: String) throws -> Data { + // Reject ambiguous paths up-front. Trailing "/" otherwise resolves + // to the directory itself, which Bundle happily returns a URL for + // and `Data(contentsOf:)` then fails on with a confusing + // "Is a directory" error. Empty paths have no filename to match. + guard !path.isEmpty, !path.hasSuffix("/") else { + throw FixtureError.notFound(path: path) + } + let components = path.split(separator: "/").map(String.init) + guard let filename = components.last, !filename.isEmpty else { + throw FixtureError.notFound(path: path) + } + let name = (filename as NSString).deletingPathExtension + let ext = (filename as NSString).pathExtension + let subdirectory: String? + if components.count > 1 { + subdirectory = "Fixtures/" + components.dropLast().joined(separator: "/") + } else { + subdirectory = "Fixtures" + } + + guard let url = Bundle.module.url( + forResource: name, + withExtension: ext.isEmpty ? nil : ext, + subdirectory: subdirectory + ) else { + throw FixtureError.notFound(path: path) + } + + do { + return try Data(contentsOf: url) + } catch { + throw FixtureError.readFailed(path: path, underlying: error) + } + } + + /// Load a fixture and decode it as `T`. + static func loadJSON( + _ type: T.Type, + _ path: String, + decoder: JSONDecoder = JSONDecoder() + ) throws -> T { + let data = try load(path) + return try decoder.decode(type, from: data) + } +} diff --git a/Tests/SpeechToTextTests/Utilities/InMemorySecureStore.swift b/Tests/SpeechToTextTests/Utilities/InMemorySecureStore.swift new file mode 100644 index 0000000..6642cde --- /dev/null +++ b/Tests/SpeechToTextTests/Utilities/InMemorySecureStore.swift @@ -0,0 +1,39 @@ +import Foundation +@testable import SpeechToText + +/// In-memory fake for `SecureStore` used in unit tests. Never touches the +/// real Keychain. Thread-safe via actor isolation. +/// +/// Use this anywhere a test needs a credentials store β€” the real +/// `KeychainSecureStore` is only exercised via manual smoke tests, not in CI. +public actor InMemorySecureStore: SecureStore { + private var storage: [String: Data] + + public init(initial: [String: Data] = [:]) { + self.storage = initial + } + + public func set(_ data: Data, forKey key: String) async throws { + storage[key] = data + } + + public func get(forKey key: String) async throws -> Data? { + storage[key] + } + + public func delete(forKey key: String) async throws { + storage.removeValue(forKey: key) + } + + public func deleteAll() async throws { + storage.removeAll(keepingCapacity: false) + } + + // MARK: - Test helpers + + /// Number of items currently held. Test-only. + public func count() async -> Int { storage.count } + + /// Sorted list of keys currently held. Test-only. + public func keys() async -> [String] { storage.keys.sorted() } +} diff --git a/Tests/SpeechToTextTests/Utilities/InMemorySecureStoreTests.swift b/Tests/SpeechToTextTests/Utilities/InMemorySecureStoreTests.swift new file mode 100644 index 0000000..5087fdf --- /dev/null +++ b/Tests/SpeechToTextTests/Utilities/InMemorySecureStoreTests.swift @@ -0,0 +1,156 @@ +import XCTest +@testable import SpeechToText + +/// Tests for `InMemorySecureStore`, the test-only `SecureStore` fake. +/// +/// These tests also exercise the default protocol extension (`setString` / +/// `getString`), so they act as a contract-test for any future +/// `SecureStore` implementation. +final class InMemorySecureStoreTests: XCTestCase { + + // MARK: - Data API + + func test_getMissing_returnsNil() async throws { + let store = InMemorySecureStore() + let value = try await store.get(forKey: "missing") + XCTAssertNil(value) + } + + func test_setThenGet_returnsSameData() async throws { + let store = InMemorySecureStore() + let expected = Data("secret".utf8) + try await store.set(expected, forKey: "api-key") + let actual = try await store.get(forKey: "api-key") + XCTAssertEqual(actual, expected) + } + + func test_overwrite_replacesValue() async throws { + let store = InMemorySecureStore() + try await store.set(Data("old".utf8), forKey: "k") + try await store.set(Data("new".utf8), forKey: "k") + let actual = try await store.get(forKey: "k") + XCTAssertEqual(actual, Data("new".utf8)) + } + + func test_delete_removesValue() async throws { + let store = InMemorySecureStore() + try await store.set(Data("x".utf8), forKey: "k") + try await store.delete(forKey: "k") + let actual = try await store.get(forKey: "k") + XCTAssertNil(actual) + } + + func test_deleteMissing_isNoOp() async throws { + let store = InMemorySecureStore() + try await store.delete(forKey: "never-existed") + let count = await store.count() + XCTAssertEqual(count, 0) + } + + func test_deleteAll_clearsEveryItem() async throws { + let store = InMemorySecureStore(initial: [ + "a": Data("1".utf8), + "b": Data("2".utf8) + ]) + + let countBefore = await store.count() + XCTAssertEqual(countBefore, 2) + + try await store.deleteAll() + + let countAfter = await store.count() + XCTAssertEqual(countAfter, 0) + } + + // MARK: - String convenience (default protocol extension) + + func test_setString_getString_roundTrip() async throws { + let store = InMemorySecureStore() + try await store.setString("hello", forKey: "greeting") + let value = try await store.getString(forKey: "greeting") + XCTAssertEqual(value, "hello") + } + + func test_getString_missing_returnsNil() async throws { + let store = InMemorySecureStore() + let value = try await store.getString(forKey: "missing") + XCTAssertNil(value) + } + + // MARK: - Initial state + + func test_initial_state_isReflected() async throws { + let initial: [String: Data] = [ + "alpha": Data("A".utf8), + "bravo": Data("B".utf8) + ] + let store = InMemorySecureStore(initial: initial) + + // Assert via the public `get` API (not the `keys()` helper) so a + // future divergence between internal state and the public contract + // is caught. + let alpha = try await store.get(forKey: "alpha") + XCTAssertEqual(alpha, Data("A".utf8)) + + let bravo = try await store.get(forKey: "bravo") + XCTAssertEqual(bravo, Data("B".utf8)) + + let missing = try await store.get(forKey: "charlie") + XCTAssertNil(missing) + } + + // MARK: - Byte-transparency + + func test_roundTrip_preservesBinaryContent() async throws { + // Null bytes + non-UTF-8 bytes. Catches any future refactor that + // silently routes through `String` and corrupts arbitrary-byte secrets. + let store = InMemorySecureStore() + let bytes: [UInt8] = [0x00, 0xFF, 0xFE, 0x00, 0xC3, 0x28, 0x00] + let expected = Data(bytes) + + try await store.set(expected, forKey: "binary-blob") + let actual = try await store.get(forKey: "binary-blob") + + XCTAssertEqual(actual, expected) + XCTAssertEqual(actual?.count, expected.count) + } + + // MARK: - Bulk delete edge + + func test_deleteAll_onEmptyStore_isNoOp() async throws { + let store = InMemorySecureStore() + try await store.deleteAll() + let count = await store.count() + XCTAssertEqual(count, 0) + } + + // MARK: - Concurrency contract + + func test_concurrent_setsAndGets_doNotCorruptState() async throws { + // Fires N concurrent writes and reads through the actor to lock in + // the serialisation contract the type advertises. If the actor is + // ever refactored to a class with a data-race bug, this test should + // start failing under ThreadSanitizer. + let store = InMemorySecureStore() + let count = 128 + + await withTaskGroup(of: Void.self) { group in + for i in 0.. String { + callLog.append(Call(prompt: prompt, options: options)) + switch behavior { + case .fixedResponse(let response): + return response + case .queuedResponses(let queue): + guard let next = queue.first else { + throw MockLLMProviderError.responseQueueExhausted + } + behavior = .queuedResponses(Array(queue.dropFirst())) + return next + case .error(let err): + throw err + } + } + + public nonisolated func generateStream( + prompt: String, + options: LLMOptions + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { [weak self] in + guard let self else { + continuation.finish() + return + } + do { + let full = try await self.generate( + prompt: prompt, + options: options + ) + continuation.yield(full) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Test helpers + + /// Snapshot of every call observed so far. + public func calls() -> [Call] { callLog } + + /// Number of calls observed so far. + public func callCount() -> Int { callLog.count } + + /// Most recent call, or `nil` if none has been made. + public func lastCall() -> Call? { callLog.last } + + /// Swap the response mode mid-test. + public func setBehavior(_ newBehavior: Behavior) { + behavior = newBehavior + } + + /// Clear the recorded call log. Behaviour is unchanged. + public func reset() { + callLog.removeAll(keepingCapacity: false) + } +} + +/// Failures surfaced by `MockLLMProvider` itself (not by the system +/// under test). +public enum MockLLMProviderError: Error, Equatable, Sendable { + /// A queued-response mock was called more times than responses were + /// supplied. Indicates the test is under-specified β€” enqueue + /// another response or switch to `.fixedResponse`. + case responseQueueExhausted +} diff --git a/Tests/SpeechToTextTests/Utilities/MockLLMProviderTests.swift b/Tests/SpeechToTextTests/Utilities/MockLLMProviderTests.swift new file mode 100644 index 0000000..0146da3 --- /dev/null +++ b/Tests/SpeechToTextTests/Utilities/MockLLMProviderTests.swift @@ -0,0 +1,193 @@ +import Foundation +import Testing +@testable import SpeechToText + +/// Tests for the `MockLLMProvider` test fake. These exist so the fake +/// itself is covered β€” its behaviour is load-bearing for every future +/// consumer test (`ClinicalNotesProcessor` #5 being the first). +@Suite("MockLLMProvider", .tags(.fast)) +struct MockLLMProviderTests { + // MARK: - Fixed response + + @Test("Fixed-response mode returns the same string for every call") + func fixedResponse_isStableAcrossCalls() async throws { + let provider = MockLLMProvider(response: "hello") + + let first = try await provider.generate( + prompt: "a", + options: LLMOptions() + ) + let second = try await provider.generate( + prompt: "b", + options: LLMOptions() + ) + + #expect(first == "hello") + #expect(second == "hello") + #expect(await provider.callCount() == 2) + } + + @Test("Default init returns the empty string") + func defaultInit_returnsEmptyString() async throws { + let provider = MockLLMProvider() + + let result = try await provider.generate( + prompt: "anything", + options: LLMOptions() + ) + + #expect(result.isEmpty) + } + + // MARK: - Queued responses + + @Test("Queued responses pop front-first") + func queuedResponses_popInOrder() async throws { + let provider = MockLLMProvider(responses: ["first", "second", "third"]) + + let one = try await provider.generate(prompt: "p", options: LLMOptions()) + let two = try await provider.generate(prompt: "p", options: LLMOptions()) + let three = try await provider.generate(prompt: "p", options: LLMOptions()) + + #expect(one == "first") + #expect(two == "second") + #expect(three == "third") + } + + @Test("Queued responses throw responseQueueExhausted after drain") + func queuedResponses_exhaustionThrows() async throws { + let provider = MockLLMProvider(responses: ["only"]) + + _ = try await provider.generate(prompt: "p", options: LLMOptions()) + + await #expect(throws: MockLLMProviderError.responseQueueExhausted) { + _ = try await provider.generate(prompt: "p", options: LLMOptions()) + } + } + + @Test("Empty queue throws on the first call") + func queuedResponses_emptyFromStart_throws() async throws { + let provider = MockLLMProvider(responses: []) + + await #expect(throws: MockLLMProviderError.responseQueueExhausted) { + _ = try await provider.generate(prompt: "p", options: LLMOptions()) + } + } + + // MARK: - Error injection + + @Test("Error mode throws the injected error on every call") + func errorMode_throws() async throws { + let provider = MockLLMProvider(error: SampleError.boom) + + await #expect(throws: SampleError.boom) { + _ = try await provider.generate(prompt: "p", options: LLMOptions()) + } + await #expect(throws: SampleError.boom) { + _ = try await provider.generate(prompt: "p", options: LLMOptions()) + } + + #expect(await provider.callCount() == 2) + } + + // MARK: - Call log + + @Test("Call log captures prompt and options verbatim") + func callLog_capturesPromptAndOptions() async throws { + let provider = MockLLMProvider(response: "ok") + let options = LLMOptions(temperature: 0.3, maxTokens: 512, seed: 7) + + _ = try await provider.generate(prompt: "transcript", options: options) + + let last = await provider.lastCall() + #expect(last?.prompt == "transcript") + #expect(last?.options == options) + } + + @Test("reset() clears the call log but preserves behaviour") + func reset_clearsCallLog() async throws { + let provider = MockLLMProvider(response: "x") + + _ = try await provider.generate(prompt: "a", options: LLMOptions()) + #expect(await provider.callCount() == 1) + + await provider.reset() + + #expect(await provider.callCount() == 0) + // Behaviour still fires after reset. + let after = try await provider.generate(prompt: "b", options: LLMOptions()) + #expect(after == "x") + } + + @Test("setBehavior swaps mode mid-test") + func setBehavior_swapsMode() async throws { + let provider = MockLLMProvider(response: "first-mode") + + let one = try await provider.generate(prompt: "p", options: LLMOptions()) + #expect(one == "first-mode") + + await provider.setBehavior(.queuedResponses(["after-swap"])) + let two = try await provider.generate(prompt: "p", options: LLMOptions()) + #expect(two == "after-swap") + } + + // MARK: - Streaming + + @Test("generateStream yields the fixed response then finishes") + func stream_fixedResponse() async throws { + let provider = MockLLMProvider(response: "streamed") + + var collected: [String] = [] + for try await chunk in provider.generateStream( + prompt: "p", + options: LLMOptions() + ) { + collected.append(chunk) + } + + #expect(collected == ["streamed"]) + } + + @Test("generateStream finishes with the injected error") + func stream_errorModePropagates() async throws { + let provider = MockLLMProvider(error: SampleError.boom) + + await #expect(throws: SampleError.boom) { + for try await _ in provider.generateStream( + prompt: "p", + options: LLMOptions() + ) { + // Drain until the stream errors. + } + } + } + + @Test("generateStream cancellation terminates the underlying task") + func stream_cancellation_terminates() async throws { + // The protocol contract says cancelling the awaiting task cancels + // the underlying generation. We can't observe "cancel mid-token" + // on this fast fake, but we can prove the stream terminates + // cleanly when a consumer drops out after the first chunk + // (exercising the `onTermination` β†’ `task.cancel()` wiring). + let provider = MockLLMProvider(response: "done") + + var seen: [String] = [] + for try await chunk in provider.generateStream( + prompt: "p", + options: LLMOptions() + ) { + seen.append(chunk) + break + } + + #expect(seen == ["done"]) + // One generate call, regardless of whether we drained the stream. + #expect(await provider.callCount() == 1) + } +} + +// MARK: - Test fixtures + +private enum SampleError: Error, Equatable, Sendable { + case boom +} diff --git a/Tests/SpeechToTextTests/Utilities/SwiftTestingExemplarTests.swift b/Tests/SpeechToTextTests/Utilities/SwiftTestingExemplarTests.swift new file mode 100644 index 0000000..ddb4cff --- /dev/null +++ b/Tests/SpeechToTextTests/Utilities/SwiftTestingExemplarTests.swift @@ -0,0 +1,75 @@ +import Testing +import Foundation + +// MARK: - Reference: Swift Testing patterns for this project +// +// This file exists as a reference implementation for contributors adding +// new tests using Swift Testing (`@Test` / `@Suite` / `#expect`). It +// exercises trivially-simple Foundation behaviour; the point is the +// *shape* of the tests, not the coverage. +// +// Guidance (see `.claude/CLAUDE.md` β†’ "Operating rules" #2): +// - NEW pure-logic and async tests β†’ use Swift Testing (this file's style). +// - UI / ViewInspector / XCUITest β†’ keep using XCTest. +// - Tag every Swift Testing test with at least one of .fast / .slow / +// .requiresHardware (see `TestTags.swift`). CI filters on these. + +// --------------------------------------------------------------------------- +// Pattern 1: a simple tagged test. +// --------------------------------------------------------------------------- + +@Test("UUIDs produced in quick succession are distinct", .tags(.fast)) +func uuids_areUnique_acrossCalls() { + let a = UUID() + let b = UUID() + #expect(a != b) +} + +// --------------------------------------------------------------------------- +// Pattern 2: parameterized test. +// +// Swift Testing's `arguments:` avoids the XCTest convention of per-case +// helper methods. One `@Test`, many inputs, clean reporting. +// --------------------------------------------------------------------------- + +@Test( + "Trimming known-whitespace strings yields the expected result", + .tags(.fast), + arguments: [ + (" hello ", "hello"), + ("\thello\n", "hello"), + ("hello", "hello"), + (" ", "") + ] +) +func whitespaceTrim(input: String, expected: String) { + #expect(input.trimmingCharacters(in: .whitespacesAndNewlines) == expected) +} + +// --------------------------------------------------------------------------- +// Pattern 3: a `@Suite` that propagates a tag to every test it contains. +// +// Use for groups of related assertions. Tags on the suite apply to every +// member `@Test` unless the test carries its own explicit tag. +// --------------------------------------------------------------------------- + +@Suite("Calendar date arithmetic", .tags(.fast)) +struct DateArithmeticTests { + @Test("Adding zero days preserves the date") + func addingZeroDays_isIdentity() throws { + let now = Date() + let later = try #require( + Calendar(identifier: .gregorian).date(byAdding: .day, value: 0, to: now) + ) + #expect(now == later) + } + + @Test("Adding 1 day advances the date") + func addingOneDay_advances() throws { + let now = Date() + let later = try #require( + Calendar(identifier: .gregorian).date(byAdding: .day, value: 1, to: now) + ) + #expect(later > now) + } +} diff --git a/Tests/SpeechToTextTests/Utilities/TestTags.swift b/Tests/SpeechToTextTests/Utilities/TestTags.swift new file mode 100644 index 0000000..d0a95c8 --- /dev/null +++ b/Tests/SpeechToTextTests/Utilities/TestTags.swift @@ -0,0 +1,22 @@ +import Testing + +/// Shared tags used across Swift Testing test files in this target. +/// +/// Use these to route tests into the right CI layer (see `.claude/CLAUDE.md` +/// "Testing conventions"): +/// +/// - `.fast` β€” pure-logic, sub-millisecond; the default CI PR run. +/// - `.slow` β€” anything noticeably slower (real I/O, large fixtures, etc.). +/// Consider tagging as `.fast` first and promoting to `.slow` only when +/// the test actually matters for nightly runs. +/// - `.requiresHardware` β€” needs real microphone, accessibility TCC grant, +/// display server, or user keychain. Skipped on GitHub Actions runners; +/// runs via pre-push on the remote Mac. +/// +/// Apply with `@Test(.tags(.fast))` on individual tests, or pass down via +/// `@Suite(.tags(.fast))` to cover a whole file. +extension Tag { + @Tag public static var fast: Self + @Tag public static var slow: Self + @Tag public static var requiresHardware: Self +} diff --git a/Tests/SpeechToTextTests/Utilities/URLProtocolStub.swift b/Tests/SpeechToTextTests/Utilities/URLProtocolStub.swift new file mode 100644 index 0000000..efc9440 --- /dev/null +++ b/Tests/SpeechToTextTests/Utilities/URLProtocolStub.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Thread-safe `URLProtocol` stub for testing network code without hitting the network. +/// +/// Install once per test, route all URL requests through the supplied closure, then call +/// `reset()` in tearDown. +/// +/// ```swift +/// let config = URLProtocolStub.install { request in +/// guard let url = request.url else { throw URLError(.badURL) } +/// let response = HTTPURLResponse( +/// url: url, +/// statusCode: 200, +/// httpVersion: "HTTP/1.1", +/// headerFields: ["Content-Type": "application/json"] +/// )! +/// let body = try HTTPStubFixture.load("cliniko/responses/users_me.json") +/// return (response, body) +/// } +/// let session = URLSession(configuration: config) +/// // ...use session... +/// URLProtocolStub.reset() +/// ``` +/// +/// `@unchecked Sendable` is safe because the only mutable static (`currentResponder`) is +/// always accessed under `lock`. `nonisolated(unsafe)` is required for Swift 6 concurrency +/// checking since `URLProtocol` callbacks do not come with actor isolation β€” SwiftLint's +/// `nonisolated_unsafe_warning` custom rule calls these usages out for review. +final class URLProtocolStub: URLProtocol, @unchecked Sendable { + typealias Responder = @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) + + private static let lock = NSLock() + nonisolated(unsafe) private static var currentResponder: Responder? + + /// Install the stub as the first protocol class in a new `URLSessionConfiguration`. + /// Callers create a `URLSession` from the returned config; every request through + /// that session will be intercepted until `reset()` is called. + static func install(_ responder: @escaping Responder) -> URLSessionConfiguration { + lock.lock() + defer { lock.unlock() } + currentResponder = responder + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [URLProtocolStub.self] + (config.protocolClasses ?? []) + return config + } + + /// Clear the current responder. Call from `tearDown` so tests don't leak state. + static func reset() { + lock.lock() + defer { lock.unlock() } + currentResponder = nil + } + + // MARK: - URLProtocol overrides + + override class func canInit(with request: URLRequest) -> Bool { + lock.lock(); defer { lock.unlock() } + return currentResponder != nil + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.lock.lock() + let responder = Self.currentResponder + Self.lock.unlock() + guard let responder else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotLoadFromNetwork)) + return + } + do { + let (response, data) = try responder(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() { + // No-op; work completes synchronously in startLoading. + } +} diff --git a/Tests/SpeechToTextTests/Utilities/URLProtocolStubTests.swift b/Tests/SpeechToTextTests/Utilities/URLProtocolStubTests.swift new file mode 100644 index 0000000..456a85f --- /dev/null +++ b/Tests/SpeechToTextTests/Utilities/URLProtocolStubTests.swift @@ -0,0 +1,179 @@ +import XCTest + +/// Exemplar tests for the `URLProtocolStub` + `HTTPStubFixture` helpers. +/// Patterns shown here are the reference implementation for future +/// network-client tests. +final class URLProtocolStubTests: XCTestCase { + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + // MARK: - Happy path + + func test_installedStub_returnsFixtureResponse() async throws { + let config = URLProtocolStub.install { request in + guard let url = request.url else { throw URLError(.badURL) } + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + let body = try HTTPStubFixture.load("cliniko/responses/users_me.json") + return (response, body) + } + let session = URLSession(configuration: config) + + let url = URL(string: "https://api.au1.cliniko.com/v1/users/me")! + let (data, response) = try await session.data(from: url) + + let http = try XCTUnwrap(response as? HTTPURLResponse) + XCTAssertEqual(http.statusCode, 200) + XCTAssertEqual(http.value(forHTTPHeaderField: "Content-Type"), "application/json") + + struct UserMe: Decodable { + let id: Int + let email: String + } + let decoded = try JSONDecoder().decode(UserMe.self, from: data) + XCTAssertEqual(decoded.id, 12345) + XCTAssertEqual(decoded.email, "sample.user@example.test") + } + + // MARK: - Error surfacing + + func test_responderError_surfacesToCaller_asNetworkLayerFailure() async { + // URLProtocol wraps a thrown non-URLError as an NSError whose domain + // is the Swift type name of the error. URLSession re-throws that. + // Assert the error originated at the URLSession boundary β€” i.e. we + // did NOT slip past with successful data that then fails to decode + // downstream. + struct BoomError: Error {} + + let config = URLProtocolStub.install { _ in throw BoomError() } + let session = URLSession(configuration: config) + + do { + _ = try await session.data(from: URL(string: "https://example.test/")!) + XCTFail("expected the responder's error to surface") + } catch { + // A regression that lets data past silently would throw a + // DecodingError later β€” assert we didn't get there. + XCTAssertFalse(error is DecodingError, "URLSession should fail, not succeed with junk data") + + // NSError-bridged form carries the thrown type's name in the + // domain. That's implementation detail but gives us a signal + // that the failure carried info from the responder rather than + // being a spurious cancellation / timeout. + let nsError = error as NSError + XCTAssertTrue( + nsError.domain.contains("BoomError") || (error is URLError), + "expected failure to reference the responder's error; got domain=\(nsError.domain) err=\(error)" + ) + } + } + + func test_reset_clearsInterception() { + _ = URLProtocolStub.install { request in + guard let url = request.url else { throw URLError(.badURL) } + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + URLProtocolStub.reset() + + let request = URLRequest(url: URL(string: "https://example.test/")!) + XCTAssertFalse(URLProtocolStub.canInit(with: request), + "canInit must return false after reset()") + } + + // MARK: - Fixture loading (not-found + edge cases) + + func test_fixtureNotFound_throwsNotFoundError() { + XCTAssertThrowsError(try HTTPStubFixture.load("cliniko/responses/does_not_exist.json")) { error in + guard let fixtureError = error as? HTTPStubFixture.FixtureError, + case .notFound = fixtureError else { + XCTFail("expected .notFound error, got \(error)") + return + } + } + } + + func test_fixtureEmptyPath_throwsNotFoundError() { + XCTAssertThrowsError(try HTTPStubFixture.load("")) { error in + guard let fixtureError = error as? HTTPStubFixture.FixtureError, + case .notFound = fixtureError else { + XCTFail("expected .notFound for empty path, got \(error)") + return + } + } + } + + func test_fixtureTrailingSlashPath_throwsNotFoundError() { + // A path that resolves to no filename component should fail loudly + // rather than silently look for an empty filename. + XCTAssertThrowsError(try HTTPStubFixture.load("cliniko/responses/")) { error in + guard let fixtureError = error as? HTTPStubFixture.FixtureError, + case .notFound = fixtureError else { + XCTFail("expected .notFound for trailing-slash path, got \(error)") + return + } + } + } + + // MARK: - Typed JSON decode + + func test_loadJSON_decodesTypedModel() throws { + struct UserMe: Decodable, Equatable { + let id: Int + let firstName: String + let lastName: String + let email: String + + enum CodingKeys: String, CodingKey { + case id + case firstName = "first_name" + case lastName = "last_name" + case email + } + } + + let decoded = try HTTPStubFixture.loadJSON(UserMe.self, "cliniko/responses/users_me.json") + XCTAssertEqual(decoded, UserMe( + id: 12345, + firstName: "Sample", + lastName: "User", + email: "sample.user@example.test" + )) + } + + func test_loadJSON_wrongCodableShape_throwsDecodingError() { + // Mismatch between fixture shape and the decoded type should surface + // as DecodingError, not a silent zero-value or crash. + struct WrongShape: Decodable { + let not_a_real_field: [String] + } + + XCTAssertThrowsError( + try HTTPStubFixture.loadJSON(WrongShape.self, "cliniko/responses/users_me.json") + ) { error in + XCTAssertTrue(error is DecodingError, + "expected DecodingError, got \(type(of: error)): \(error)") + } + } + + // MARK: - FixtureError Equatable (quick sanity) + + func test_fixtureError_equatable_onPath() { + let a = HTTPStubFixture.FixtureError.notFound(path: "x") + let b = HTTPStubFixture.FixtureError.notFound(path: "x") + let c = HTTPStubFixture.FixtureError.notFound(path: "y") + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } +} diff --git a/Tests/SpeechToTextTests/Views/ClinicalNotesSectionRenderTests.swift b/Tests/SpeechToTextTests/Views/ClinicalNotesSectionRenderTests.swift new file mode 100644 index 0000000..ad9c1bc --- /dev/null +++ b/Tests/SpeechToTextTests/Views/ClinicalNotesSectionRenderTests.swift @@ -0,0 +1,70 @@ +import SwiftUI +import ViewInspector +import XCTest +@testable import SpeechToText + +/// Render-crash tests for `ClinicalNotesSection`. We don't assert on layout β€” +/// only that the view + its `@Observable @MainActor` view model can be +/// instantiated and inspected without an `EXC_BAD_ACCESS` (the failure mode +/// when an actor existential is held without `@ObservationIgnored`; see +/// `.claude/references/concurrency.md`). +@MainActor +final class ClinicalNotesSectionRenderTests: XCTestCase { + + private func makeUserDefaults() -> UserDefaults { + let suiteName = "ClinicalNotesSectionRenderTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + preconditionFailure("UserDefaults(suiteName:) returned nil") + } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func makeViewModel() -> ClinicalNotesSectionViewModel { + let store = ClinikoCredentialStore( + secureStore: InMemorySecureStore(), + userDefaults: makeUserDefaults() + ) + // Probe will never fire in render-only tests β€” we don't call save/test. + let probe = ClinikoAuthProbe(session: .shared) + return ClinicalNotesSectionViewModel(credentialStore: store, authProbe: probe) + } + + func test_clinicalNotesSection_instantiatesWithoutCrash() { + let viewModel = makeViewModel() + let view = ClinicalNotesSection(viewModel: viewModel) + XCTAssertNotNil(view) + } + + func test_clinicalNotesSection_canBeInspected() throws { + let viewModel = makeViewModel() + let view = ClinicalNotesSection(viewModel: viewModel) + // ViewInspector forces SwiftUI's body to be evaluated; if any + // @Observable + actor existential issue exists in the VM, this is + // where it surfaces (EXC_BAD_ACCESS). Just touching the body counts. + XCTAssertNoThrow(try view.inspect().findAll(ViewType.ScrollView.self)) + } + + func test_clinicalNotesSection_disconnectedState_rendersWithoutCrash() { + let viewModel = makeViewModel() + // Default state β€” no credentials saved. + XCTAssertFalse(viewModel.hasStoredCredentials) + let view = ClinicalNotesSection(viewModel: viewModel) + XCTAssertNoThrow(try view.inspect().findAll(ViewType.SecureField.self)) + } + + func test_clinicalNotesSection_connectedState_rendersWithoutCrash() async throws { + let secureStore = InMemorySecureStore() + let store = ClinikoCredentialStore( + secureStore: secureStore, + userDefaults: makeUserDefaults() + ) + try await store.saveCredentials(apiKey: "MS-test-au1", shard: .au1) + let probe = ClinikoAuthProbe(session: .shared) + let viewModel = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: probe) + await viewModel.refreshState() + + let view = ClinicalNotesSection(viewModel: viewModel) + XCTAssertNoThrow(try view.inspect().findAll(ViewType.SecureField.self)) + } +} diff --git a/Tests/SpeechToTextTests/Views/ClinicalNotesSectionViewModelTests.swift b/Tests/SpeechToTextTests/Views/ClinicalNotesSectionViewModelTests.swift new file mode 100644 index 0000000..7bbebf5 --- /dev/null +++ b/Tests/SpeechToTextTests/Views/ClinicalNotesSectionViewModelTests.swift @@ -0,0 +1,401 @@ +import Foundation +import XCTest +@testable import SpeechToText + +/// VM-level tests for `ClinicalNotesSectionViewModel`. Use the in-memory +/// SecureStore fake + the URLProtocolStub-backed session to exercise +/// save/test/remove without touching the real Keychain or the network. +@MainActor +final class ClinicalNotesSectionViewModelTests: XCTestCase { + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + // MARK: - Helpers + + private func makeUserDefaults() -> UserDefaults { + let suiteName = "ClinicalNotesSectionVMTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("UserDefaults(suiteName:) returned nil") + preconditionFailure("UserDefaults(suiteName:) returned nil") + } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + private func makeStore( + secureStore: any SecureStore = InMemorySecureStore(), + userDefaults: UserDefaults? = nil + ) -> (ClinikoCredentialStore, any SecureStore, UserDefaults) { + let userDefaults = userDefaults ?? makeUserDefaults() + let store = ClinikoCredentialStore(secureStore: secureStore, userDefaults: userDefaults) + return (store, secureStore, userDefaults) + } + + private func makeProbe( + responder: @escaping URLProtocolStub.Responder + ) -> ClinikoAuthProbe { + let config = URLProtocolStub.install(responder) + let session = URLSession(configuration: config) + return ClinikoAuthProbe(session: session, userAgent: "vm-tests/1.0") + } + + private func neverInvokedProbe(file: StaticString = #file, line: UInt = #line) -> ClinikoAuthProbe { + makeProbe { _ in + XCTFail("probe must not be invoked", file: file, line: line) + throw URLError(.cannotConnectToHost) + } + } + + private func httpResponseProbe(status: Int) -> ClinikoAuthProbe { + makeProbe { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + } + + // MARK: - Initial state + + func test_initial_state_isClean() { + let (store, _, _) = makeStore() + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + XCTAssertEqual(vm.apiKeyDraft, "") + XCTAssertEqual(vm.selectedShard, .default) + XCTAssertFalse(vm.hasStoredCredentials) + XCTAssertFalse(vm.isApiKeyDraftValid) + XCTAssertEqual(vm.credentialState, .unknown) + XCTAssertEqual(vm.verificationStatus, .absent) + XCTAssertEqual(vm.connectionStatus, .idle) + XCTAssertNil(vm.statusMessage) + XCTAssertFalse(vm.isBusy) + } + + func test_isApiKeyDraftValid_reflectsTrimmedDraft() { + let (store, _, _) = makeStore() + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + + XCTAssertFalse(vm.isApiKeyDraftValid, "empty draft is invalid") + + vm.apiKeyDraft = " " + XCTAssertFalse(vm.isApiKeyDraftValid, "whitespace-only draft is invalid") + + vm.apiKeyDraft = "MS-test-au1" + XCTAssertTrue(vm.isApiKeyDraftValid) + + vm.apiKeyDraft = " MS-trim " + XCTAssertTrue(vm.isApiKeyDraftValid, "trimmable non-empty draft is valid") + } + + // MARK: - Refresh + + func test_refresh_loadsExistingCredentialsState() async throws { + let secureStore = InMemorySecureStore() + let userDefaults = makeUserDefaults() + let (store, _, _) = makeStore(secureStore: secureStore, userDefaults: userDefaults) + try await store.saveCredentials(apiKey: "MS-key-uk2", shard: .uk2) + + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + await vm.refreshState() + + XCTAssertTrue(vm.hasStoredCredentials) + XCTAssertEqual(vm.credentialState, .present) + XCTAssertEqual(vm.verificationStatus, .unverified, "freshly loaded credentials are 'unverified' until probe succeeds") + XCTAssertEqual(vm.selectedShard, .uk2) + XCTAssertEqual(vm.apiKeyDraft, "", "draft must never be hydrated from the keychain") + } + + func test_refresh_keychainReadFailure_doesNotCollapseToAbsent() async { + let (store, _, _) = makeStore(secureStore: ThrowingSecureStore()) + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + + await vm.refreshState() + + // Critical: a Keychain read error must NOT silently flip + // `hasStoredCredentials` to `false` β€” that would silently disable + // Clinical Notes Mode (#11) AND deadlock the UI (Remove button gates + // on `hasStoredCredentials`, but the banner tells the user to use + // Remove to recover). The credential state is exposed separately as + // `.readFailed` so consumers that need the positive-only signal can + // pattern-match on it directly. + XCTAssertEqual(vm.credentialState, .readFailed) + XCTAssertTrue(vm.hasStoredCredentials, + "hasStoredCredentials must remain true during read failures so the user can click Remove to recover") + XCTAssertEqual(vm.verificationStatus, .readError) + XCTAssertEqual(vm.connectionStatus, .failure) + XCTAssertNotNil(vm.statusMessage) + XCTAssertEqual(vm.statusCardDisplay.title, "Could not read stored credentials") + } + + func test_refresh_emptyStore_marksAbsent() async { + let (store, _, _) = makeStore() + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + + await vm.refreshState() + + XCTAssertEqual(vm.credentialState, .absent) + XCTAssertEqual(vm.verificationStatus, .absent) + XCTAssertFalse(vm.hasStoredCredentials) + XCTAssertEqual(vm.statusCardDisplay.title, "No Cliniko credentials") + } + + // MARK: - Save flow + + func test_saveAndTest_withValidKey_storesAndReportsSuccess() async throws { + let secureStore = InMemorySecureStore() + let (store, _, userDefaults) = makeStore(secureStore: secureStore) + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: httpResponseProbe(status: 200)) + vm.apiKeyDraft = "MS-test-au1" + vm.selectedShard = .au1 + + await vm.saveAndTest() + + XCTAssertTrue(vm.hasStoredCredentials) + XCTAssertEqual(vm.verificationStatus, .verified) + XCTAssertEqual(vm.connectionStatus, .success) + XCTAssertEqual(vm.apiKeyDraft, "", "draft must be cleared after a successful save") + XCTAssertEqual(vm.statusCardDisplay.title, "Connected to Cliniko") + + let storedKey = try await secureStore.getString(forKey: ClinikoCredentialStore.apiKeyAccount) + XCTAssertEqual(storedKey, "MS-test-au1") + XCTAssertEqual(userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey), "au1") + } + + func test_saveAndTest_with401_keepsKeyButReportsFailureAndStaysUnverified() async throws { + let secureStore = InMemorySecureStore() + let (store, _, _) = makeStore(secureStore: secureStore) + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: httpResponseProbe(status: 401)) + vm.apiKeyDraft = "MS-bad-au1" + + await vm.saveAndTest() + + // Save succeeded; probe rejected. We keep what they typed (operators + // sometimes paste while offline) but the status card MUST NOT show + // green just because the key is now in the keychain. + XCTAssertTrue(vm.hasStoredCredentials) + XCTAssertEqual(vm.verificationStatus, .unverified, "probe failure must keep card in unverified state") + XCTAssertEqual(vm.connectionStatus, .failure) + XCTAssertNotNil(vm.statusMessage) + XCTAssertEqual(vm.apiKeyDraft, "") + XCTAssertEqual(vm.statusCardDisplay.title, "Saved but not yet verified") + + let storedKey = try await secureStore.getString(forKey: ClinikoCredentialStore.apiKeyAccount) + XCTAssertEqual(storedKey, "MS-bad-au1") + } + + func test_saveAndTest_with500_keepsKeyAndShowsHTTPMessage() async throws { + let secureStore = InMemorySecureStore() + let (store, _, _) = makeStore(secureStore: secureStore) + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: httpResponseProbe(status: 500)) + vm.apiKeyDraft = "MS-test-au1" + + await vm.saveAndTest() + + XCTAssertTrue(vm.hasStoredCredentials) + XCTAssertEqual(vm.verificationStatus, .unverified) + XCTAssertEqual(vm.connectionStatus, .failure) + XCTAssertEqual(vm.statusCardDisplay.title, "Saved but not yet verified") + XCTAssertNotNil(vm.statusMessage) + XCTAssertTrue(vm.statusMessage?.contains("500") == true) + } + + func test_saveAndTest_offlineProbe_keepsKeyAndOffersNetworkGuidance() async throws { + let secureStore = InMemorySecureStore() + let (store, _, _) = makeStore(secureStore: secureStore) + let probe = makeProbe { _ in throw URLError(.notConnectedToInternet) } + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: probe) + vm.apiKeyDraft = "MS-test-au1" + + await vm.saveAndTest() + + XCTAssertTrue(vm.hasStoredCredentials) + XCTAssertEqual(vm.verificationStatus, .unverified) + XCTAssertEqual(vm.connectionStatus, .failure) + XCTAssertEqual(vm.statusMessage, "You appear to be offline. Reconnect and try again.") + } + + func test_saveAndTest_dnsFailure_offersRegionHint() async throws { + let secureStore = InMemorySecureStore() + let (store, _, _) = makeStore(secureStore: secureStore) + let probe = makeProbe { _ in throw URLError(.cannotFindHost) } + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: probe) + vm.apiKeyDraft = "MS-test-au1" + vm.selectedShard = .au1 + + await vm.saveAndTest() + + XCTAssertEqual(vm.verificationStatus, .unverified) + XCTAssertNotNil(vm.statusMessage) + XCTAssertTrue(vm.statusMessage?.contains("region correct") == true, + "DNS failure should suggest checking the shard; got: \(vm.statusMessage ?? "")") + } + + func test_saveAndTest_emptyKey_failsWithoutWriting() async throws { + let secureStore = InMemorySecureStore() + let (store, _, _) = makeStore(secureStore: secureStore) + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + vm.apiKeyDraft = " " + + await vm.saveAndTest() + + XCTAssertFalse(vm.hasStoredCredentials) + XCTAssertEqual(vm.connectionStatus, .failure) + let storedKey = try await secureStore.getString(forKey: ClinikoCredentialStore.apiKeyAccount) + XCTAssertNil(storedKey) + } + + func test_saveAndTest_secureStoreWriteFailure_reportsFailureNoKeyStored() async { + let (store, _, _) = makeStore(secureStore: ThrowingSecureStore()) + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + vm.apiKeyDraft = "MS-test-au1" + + await vm.saveAndTest() + + XCTAssertFalse(vm.hasStoredCredentials) + XCTAssertEqual(vm.verificationStatus, .absent) + XCTAssertEqual(vm.connectionStatus, .failure) + XCTAssertNotNil(vm.statusMessage) + } + + // MARK: - Test connection (separate button) + + func test_testConnection_onSavedCredentials_succeeds() async throws { + let secureStore = InMemorySecureStore() + let (store, _, _) = makeStore(secureStore: secureStore) + try await store.saveCredentials(apiKey: "MS-test-uk1", shard: .uk1) + + let vm = ClinicalNotesSectionViewModel( + credentialStore: store, + authProbe: makeProbe { request in + XCTAssertEqual(request.url?.absoluteString, "https://api.uk1.cliniko.com/v1/users/me") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (response, Data()) + } + ) + await vm.refreshState() + + await vm.testConnection() + + XCTAssertEqual(vm.verificationStatus, .verified) + XCTAssertEqual(vm.connectionStatus, .success) + } + + func test_testConnection_withoutCredentials_reportsFailure() async { + let (store, _, _) = makeStore() + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + + await vm.testConnection() + + XCTAssertEqual(vm.connectionStatus, .failure) + XCTAssertNotNil(vm.statusMessage) + } + + // MARK: - Remove + + func test_remove_clearsKeyShardAndDraft() async throws { + let secureStore = InMemorySecureStore() + let userDefaults = makeUserDefaults() + let (store, _, _) = makeStore(secureStore: secureStore, userDefaults: userDefaults) + try await store.saveCredentials(apiKey: "MS-test-au2", shard: .au2) + + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + await vm.refreshState() + vm.apiKeyDraft = "leftover" + + await vm.removeCredentials() + + XCTAssertFalse(vm.hasStoredCredentials) + XCTAssertEqual(vm.credentialState, .absent) + XCTAssertEqual(vm.verificationStatus, .absent) + XCTAssertEqual(vm.selectedShard, .default) + XCTAssertEqual(vm.apiKeyDraft, "") + XCTAssertEqual(vm.connectionStatus, .idle) + let storedKey = try await secureStore.getString(forKey: ClinikoCredentialStore.apiKeyAccount) + XCTAssertNil(storedKey) + XCTAssertNil(userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey)) + } + + func test_remove_keychainFailure_retainsShardAndShowsBanner() async throws { + // Pre-seed the shard but back the store with a throwing SecureStore. + let userDefaults = makeUserDefaults() + userDefaults.set("uk2", forKey: ClinikoCredentialStore.shardUserDefaultsKey) + let store = ClinikoCredentialStore( + secureStore: ThrowingSecureStore(), + userDefaults: userDefaults + ) + + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + vm.apiKeyDraft = "leftover" + // Refresh will fail (read failure) and mark .readFailed. + await vm.refreshState() + XCTAssertEqual(vm.credentialState, .readFailed) + + await vm.removeCredentials() + + XCTAssertEqual(vm.connectionStatus, .failure) + XCTAssertNotNil(vm.statusMessage) + // On Keychain delete failure the store retains the shard so the + // surviving API key + shard remain a valid pair the user can either + // retry or keep using. The VM's banner tells the user the key may + // still be in Keychain; the shard just stays consistent with that. + XCTAssertEqual(userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey), "uk2", + "shard must be retained when Keychain delete fails so the on-disk pair stays consistent") + } + + // MARK: - Shard picker + + func test_shardPickerChange_persistsShardWhenCredentialsExist() async throws { + let userDefaults = makeUserDefaults() + let secureStore = InMemorySecureStore() + let (store, _, _) = makeStore(secureStore: secureStore, userDefaults: userDefaults) + try await store.saveCredentials(apiKey: "k", shard: .au1) + + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + await vm.refreshState() + XCTAssertEqual(vm.selectedShard, .au1) + + vm.selectedShard = .uk2 + + // didSet on selectedShard hits the nonisolated UserDefaults write + // synchronously, so we can assert without yielding. + XCTAssertEqual(userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey), "uk2") + // Pointing at a different tenant invalidates the prior probe. + XCTAssertEqual(vm.verificationStatus, .unverified) + } + + func test_shardPickerChange_doesNotPersistBeforeCredentialsSaved() { + let userDefaults = makeUserDefaults() + let (store, _, _) = makeStore(userDefaults: userDefaults) + let vm = ClinicalNotesSectionViewModel(credentialStore: store, authProbe: neverInvokedProbe()) + + vm.selectedShard = .uk2 + + // No credentials yet β€” picker change should not write to UserDefaults. + // The shard will be persisted alongside the API key on save. + XCTAssertNil(userDefaults.string(forKey: ClinikoCredentialStore.shardUserDefaultsKey)) + } +} + +// MARK: - Test fakes + +/// `SecureStore` fake that throws on every call. Used to verify failure +/// surfacing without depending on a real Keychain error path. +private actor ThrowingSecureStore: SecureStore { + struct Boom: Error, Equatable {} + + func set(_ data: Data, forKey key: String) async throws { throw Boom() } + func get(forKey key: String) async throws -> Data? { throw Boom() } + func delete(forKey key: String) async throws { throw Boom() } + func deleteAll() async throws { throw Boom() } +} diff --git a/Tests/SpeechToTextTests/Views/MainViewModelTests.swift b/Tests/SpeechToTextTests/Views/MainViewModelTests.swift index 2b7a551..05993f8 100644 --- a/Tests/SpeechToTextTests/Views/MainViewModelTests.swift +++ b/Tests/SpeechToTextTests/Views/MainViewModelTests.swift @@ -89,7 +89,7 @@ final class MainViewModelTests: XCTestCase { // MARK: - SidebarSection Enum Tests func test_sidebarSection_allCasesCount() { - XCTAssertEqual(SidebarSection.allCases.count, 8) + XCTAssertEqual(SidebarSection.allCases.count, 9) } func test_sidebarSection_hasCorrectTitles() { @@ -100,6 +100,7 @@ final class MainViewModelTests: XCTestCase { XCTAssertEqual(SidebarSection.language.title, "Language") XCTAssertEqual(SidebarSection.theme.title, "Theme") XCTAssertEqual(SidebarSection.privacy.title, "Privacy") + XCTAssertEqual(SidebarSection.clinicalNotes.title, "Clinical Notes") XCTAssertEqual(SidebarSection.about.title, "About") } @@ -111,6 +112,7 @@ final class MainViewModelTests: XCTestCase { XCTAssertEqual(SidebarSection.language.icon, "globe") XCTAssertEqual(SidebarSection.theme.icon, "paintbrush") XCTAssertEqual(SidebarSection.privacy.icon, "lock.shield") + XCTAssertEqual(SidebarSection.clinicalNotes.icon, "stethoscope") XCTAssertEqual(SidebarSection.about.icon, "info.circle") } @@ -122,6 +124,7 @@ final class MainViewModelTests: XCTestCase { XCTAssertEqual(SidebarSection.language.accessibilityLabel, "Language section") XCTAssertEqual(SidebarSection.theme.accessibilityLabel, "Theme section") XCTAssertEqual(SidebarSection.privacy.accessibilityLabel, "Privacy section") + XCTAssertEqual(SidebarSection.clinicalNotes.accessibilityLabel, "Clinical Notes section") XCTAssertEqual(SidebarSection.about.accessibilityLabel, "About section") } @@ -133,6 +136,7 @@ final class MainViewModelTests: XCTestCase { XCTAssertEqual(SidebarSection.language.rawValue, "language") XCTAssertEqual(SidebarSection.theme.rawValue, "theme") XCTAssertEqual(SidebarSection.privacy.rawValue, "privacy") + XCTAssertEqual(SidebarSection.clinicalNotes.rawValue, "clinicalNotes") XCTAssertEqual(SidebarSection.about.rawValue, "about") } diff --git a/Tests/SpeechToTextTests/Views/PatientPickerViewModelTests.swift b/Tests/SpeechToTextTests/Views/PatientPickerViewModelTests.swift new file mode 100644 index 0000000..6278c27 --- /dev/null +++ b/Tests/SpeechToTextTests/Views/PatientPickerViewModelTests.swift @@ -0,0 +1,369 @@ +import Foundation +import Testing +@testable import SpeechToText + +/// VM-level tests for `PatientPickerViewModel`. These exercise the +/// debounce + cancellation contract from #9's acceptance criteria using +/// in-test actor fakes for the patient / appointment services. No HTTP +/// stub here β€” the service layer is covered by the XCTest-based service +/// tests; this suite is about the VM's own state machine. +/// +/// `@Suite(.serialized)` because `@MainActor` work isn't inherently +/// parallel-safe across these tests when each one drives a fresh +/// SessionStore. +@Suite("PatientPickerViewModel", .tags(.fast), .serialized) +@MainActor +struct PatientPickerViewModelTests { + + // MARK: - Debounce + + @Test("first keystroke does not call the patient service before debounce expires") + func debounce_firstKeystroke_noNetworkCall() async throws { + let patients = FakePatientSearcher(result: .success([])) + let appointments = FakeAppointmentLoader(result: .success([])) + let store = SessionStore() + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 100 + ) + + vm.updateQuery("S") + + // Don't sleep at all β€” give the runtime a single yield so the + // sleep-task is scheduled, then assert no call has been made. + await Task.yield() + + let count = await patients.callCount + #expect(count == 0) + } + + @Test("rapid keystrokes within the debounce window collapse to a single call") + func debounce_rapidKeystrokes_singleCall() async throws { + let patients = FakePatientSearcher(result: .success([])) + let appointments = FakeAppointmentLoader(result: .success([])) + let store = SessionStore() + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 30 + ) + + vm.updateQuery("S") + vm.updateQuery("Sa") + vm.updateQuery("Sam") + vm.updateQuery("Samp") + vm.updateQuery("Sample") + + // Wait long enough for the debounce + a small buffer, then settle + // any continuations. + try await Task.sleep(nanoseconds: 150_000_000) + + let count = await patients.callCount + let lastQuery = await patients.lastQuery + #expect(count == 1) + #expect(lastQuery == "Sample") + } + + @Test("whitespace-only query resets to .idle without firing a search") + func whitespaceQuery_idle_noCall() async throws { + let patients = FakePatientSearcher(result: .success([])) + let appointments = FakeAppointmentLoader(result: .success([])) + let store = SessionStore() + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.updateQuery(" ") + try await Task.sleep(nanoseconds: 50_000_000) + + let count = await patients.callCount + #expect(count == 0) + #expect(vm.searchPhase == .idle) + } + + // MARK: - Phase transitions + + @Test("non-empty result populates .results") + func search_results() async throws { + let patient = Patient(id: 1, firstName: "Sample", lastName: "Patient") + let patients = FakePatientSearcher(result: .success([patient])) + let appointments = FakeAppointmentLoader(result: .success([])) + let store = SessionStore() + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.updateQuery("Sample") + try await Task.sleep(nanoseconds: 50_000_000) + + if case .results(let list) = vm.searchPhase { + #expect(list == [patient]) + } else { + Issue.record("expected .results, got \(vm.searchPhase)") + } + } + + @Test("empty result populates .empty") + func search_empty() async throws { + let patients = FakePatientSearcher(result: .success([])) + let appointments = FakeAppointmentLoader(result: .success([])) + let store = SessionStore() + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.updateQuery("zzznomatch") + try await Task.sleep(nanoseconds: 50_000_000) + + #expect(vm.searchPhase == .empty) + } + + @Test("service error surfaces as .error(error)") + func search_error() async throws { + let patients = FakePatientSearcher(result: .failure(.unauthenticated)) + let appointments = FakeAppointmentLoader(result: .success([])) + let store = SessionStore() + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.updateQuery("anything") + try await Task.sleep(nanoseconds: 50_000_000) + + #expect(vm.searchPhase == .error(.unauthenticated)) + } + + @Test(".cancelled service errors are swallowed silently") + func search_cancelled_silent() async throws { + let patients = FakePatientSearcher(result: .failure(.cancelled)) + let appointments = FakeAppointmentLoader(result: .success([])) + let store = SessionStore() + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.updateQuery("anything") + try await Task.sleep(nanoseconds: 50_000_000) + + // `.cancelled` doesn't render an error to the user (the typing- + // race case), but the VM resets the stuck `.searching` phase to + // `.idle` so the UI never spins forever waiting for a result + // that won't come. + #expect(vm.searchPhase == .idle) + } + + // MARK: - Patient selection + + @Test("selecting a patient writes selectedPatientID to the SessionStore") + func selectPatient_writesToSession() async throws { + let store = SessionStore() + // Need a recording session to start the SessionStore lifecycle. + let recording = RecordingSession.empty() + store.start(from: recording) + + let appointments = FakeAppointmentLoader(result: .success([])) + let patients = FakePatientSearcher(result: .success([])) + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + let patient = Patient(id: 1234, firstName: "Sample", lastName: "Patient") + vm.selectPatient(patient) + + #expect(store.active?.selectedPatientID == "1234") + #expect(vm.selectedPatient == patient) + #expect(vm.appointmentPhase == .loading) + } + + @Test("selectAppointment(id:) writes to the SessionStore; nil = no appointment") + func selectAppointment_writesToSession() async throws { + let store = SessionStore() + store.start(from: RecordingSession.empty()) + + let patients = FakePatientSearcher(result: .success([])) + let appointments = FakeAppointmentLoader(result: .success([])) + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.selectAppointment(id: 5678) + #expect(store.active?.selectedAppointmentID == "5678") + #expect(vm.selectedAppointmentID == 5678) + + vm.selectAppointment(id: nil) + #expect(store.active?.selectedAppointmentID == nil) + #expect(vm.selectedAppointmentID == nil) + } + + // MARK: - Appointment loading + + @Test("after patient selection, appointmentPhase reaches .loaded with the fake's results") + func appointmentPhase_loaded() async throws { + let store = SessionStore() + store.start(from: RecordingSession.empty()) + + let appointment = Appointment( + id: 9000, + startsAt: Date(timeIntervalSince1970: 1_700_000_000), + endsAt: Date(timeIntervalSince1970: 1_700_001_800) + ) + let patients = FakePatientSearcher(result: .success([])) + let appointments = FakeAppointmentLoader(result: .success([appointment])) + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.selectPatient(Patient(id: 1, firstName: "S", lastName: "P")) + try await Task.sleep(nanoseconds: 50_000_000) + + if case .loaded(let list) = vm.appointmentPhase { + #expect(list == [appointment]) + } else { + Issue.record("expected .loaded, got \(vm.appointmentPhase)") + } + } + + @Test("appointment service error surfaces as .error") + func appointmentPhase_error() async throws { + let store = SessionStore() + store.start(from: RecordingSession.empty()) + + let patients = FakePatientSearcher(result: .success([])) + let appointments = FakeAppointmentLoader(result: .failure(.transport(.notConnectedToInternet))) + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.selectPatient(Patient(id: 1, firstName: "S", lastName: "P")) + try await Task.sleep(nanoseconds: 50_000_000) + + #expect(vm.appointmentPhase == .error(.transport(.notConnectedToInternet))) + } + + // MARK: - Clear + + @Test("clearSelection resets state and clears SessionStore selections") + func clearSelection_resets() async throws { + let store = SessionStore() + store.start(from: RecordingSession.empty()) + + let patients = FakePatientSearcher(result: .success([])) + let appointments = FakeAppointmentLoader(result: .success([])) + let vm = PatientPickerViewModel( + patientService: patients, + appointmentService: appointments, + sessionStore: store, + debounceMillis: 0 + ) + + vm.selectPatient(Patient(id: 1, firstName: "S", lastName: "P")) + vm.selectAppointment(id: 9) + vm.clearSelection() + + #expect(vm.selectedPatient == nil) + #expect(vm.selectedAppointmentID == nil) + #expect(vm.searchPhase == .idle) + #expect(vm.appointmentPhase == .idle) + #expect(store.active?.selectedPatientID == nil) + #expect(store.active?.selectedAppointmentID == nil) + } +} + +// MARK: - In-test actor fakes + +/// Fake `ClinikoPatientSearching`. Records each call so tests can assert on +/// the call count and last query. +actor FakePatientSearcher: ClinikoPatientSearching { + enum FakeResult { + case success([Patient]) + case failure(ClinikoError) + } + + var result: FakeResult + private(set) var callCount: Int = 0 + private(set) var lastQuery: String? + + init(result: FakeResult) { + self.result = result + } + + func searchPatients(query: String) async throws -> [Patient] { + callCount += 1 + lastQuery = query + switch result { + case .success(let patients): return patients + case .failure(let error): throw error + } + } +} + +/// Fake `ClinikoAppointmentLoading`. Records the last patientID + reference. +actor FakeAppointmentLoader: ClinikoAppointmentLoading { + enum FakeResult { + case success([Appointment]) + case failure(ClinikoError) + } + + var result: FakeResult + private(set) var callCount: Int = 0 + private(set) var lastPatientID: String? + private(set) var lastReference: Date? + + init(result: FakeResult) { + self.result = result + } + + func recentAndTodayAppointments( + forPatientID patientID: String, + reference: Date + ) async throws -> [Appointment] { + callCount += 1 + lastPatientID = patientID + lastReference = reference + switch result { + case .success(let appointments): return appointments + case .failure(let error): throw error + } + } +} + +// MARK: - RecordingSession test helper + +/// Local helper for tests that need a `RecordingSession` placeholder. We +/// keep this scoped to the test file rather than `RecordingSession.swift` +/// itself so production callers can't accidentally instantiate "empty". +private extension RecordingSession { + static func empty() -> RecordingSession { + RecordingSession() + } +} diff --git a/Tests/SpeechToTextTests/Views/PatientPickerViewRenderTests.swift b/Tests/SpeechToTextTests/Views/PatientPickerViewRenderTests.swift new file mode 100644 index 0000000..72558ec --- /dev/null +++ b/Tests/SpeechToTextTests/Views/PatientPickerViewRenderTests.swift @@ -0,0 +1,174 @@ +// PatientPickerViewRenderTests.swift +// macOS Local Speech-to-Text Application +// +// ViewInspector + crash-detection tests for PatientPickerView. +// Catches the @Observable + actor-existential crash pattern documented +// in `.claude/references/concurrency.md` Β§1, plus the body-evaluation +// crashes that only surface at runtime. + +import SwiftUI +import ViewInspector +import XCTest +@testable import SpeechToText + +extension PatientPickerView: Inspectable {} + +@MainActor +final class PatientPickerViewRenderTests: XCTestCase { + + // MARK: - Helpers + + private func makeViewModel( + patientResult: ClinicalNotesPickerStubs.SearchResult = .empty, + appointmentResult: ClinicalNotesPickerStubs.AppointmentResult = .empty + ) -> PatientPickerViewModel { + let store = SessionStore() + return PatientPickerViewModel( + patientService: ClinicalNotesPickerStubs.PatientSearcher(result: patientResult), + appointmentService: ClinicalNotesPickerStubs.AppointmentLoader(result: appointmentResult), + sessionStore: store, + debounceMillis: 0 + ) + } + + // MARK: - Crash-detection: instantiation + + /// Critical: catches the `@Observable` + actor-existential pattern + /// that crashes at runtime if `@ObservationIgnored` is missing. + func test_picker_instantiatesWithoutCrash() { + let viewModel = makeViewModel() + let view = PatientPickerView(viewModel: viewModel) + XCTAssertNotNil(view) + } + + /// Critical: body access is where the crash actually surfaces in + /// production β€” the executor check fires when SwiftUI walks the + /// View hierarchy. + func test_picker_bodyAccessDoesNotCrash() { + let viewModel = makeViewModel() + let view = PatientPickerView(viewModel: viewModel) + let body = view.body + XCTAssertNotNil(body) + } + + // MARK: - Phase rendering β€” the view should not crash in any phase + + func test_picker_idlePhase_rendersWithoutCrash() { + let viewModel = makeViewModel() + let view = PatientPickerView(viewModel: viewModel) + XCTAssertNotNil(view.body) + XCTAssertEqual(viewModel.searchPhase, .idle) + } + + func test_picker_searchingPhase_rendersWithoutCrash() async throws { + let viewModel = makeViewModel() + viewModel.updateQuery("Sample") + // The VM transitions through .searching synchronously; the + // results land asynchronously but we only assert the rendered + // body doesn't crash mid-flight. + let view = PatientPickerView(viewModel: viewModel) + XCTAssertNotNil(view.body) + } + + func test_picker_resultsPhase_rendersWithoutCrash() async throws { + let patient = Patient( + id: 1, + firstName: "Sample", + lastName: "Patient", + dateOfBirth: "1980-01-01", + email: "sample@example.test" + ) + let viewModel = makeViewModel(patientResult: .success([patient])) + viewModel.updateQuery("Sample") + try await Task.sleep(nanoseconds: 50_000_000) + + let view = PatientPickerView(viewModel: viewModel) + XCTAssertNotNil(view.body) + if case .results(let list) = viewModel.searchPhase { + XCTAssertEqual(list, [patient]) + } else { + XCTFail("expected .results, got \(viewModel.searchPhase)") + } + } + + func test_picker_emptyPhase_rendersWithoutCrash() async throws { + let viewModel = makeViewModel(patientResult: .success([])) + viewModel.updateQuery("zzznomatch") + try await Task.sleep(nanoseconds: 50_000_000) + + let view = PatientPickerView(viewModel: viewModel) + XCTAssertNotNil(view.body) + XCTAssertEqual(viewModel.searchPhase, .empty) + } + + func test_picker_errorPhase_rendersWithoutCrash() async throws { + let viewModel = makeViewModel(patientResult: .failure(.unauthenticated)) + viewModel.updateQuery("Sample") + try await Task.sleep(nanoseconds: 50_000_000) + + let view = PatientPickerView(viewModel: viewModel) + XCTAssertNotNil(view.body) + XCTAssertEqual(viewModel.searchPhase, .error(.unauthenticated)) + } + + // MARK: - Appointment-pane phases + + func test_picker_appointmentLoadedPhase_rendersWithoutCrash() async throws { + let appointment = Appointment( + id: 9000, + startsAt: Date(timeIntervalSince1970: 1_700_000_000), + endsAt: Date(timeIntervalSince1970: 1_700_001_800) + ) + let viewModel = makeViewModel(appointmentResult: .success([appointment])) + let store = SessionStore() + store.start(from: RecordingSession()) + viewModel.selectPatient(Patient(id: 1, firstName: "S", lastName: "P")) + try await Task.sleep(nanoseconds: 50_000_000) + + let view = PatientPickerView(viewModel: viewModel) + XCTAssertNotNil(view.body) + } +} + +// MARK: - Test stubs (private to this file via enum namespace) + +enum ClinicalNotesPickerStubs { + enum SearchResult { + case success([Patient]) + case failure(ClinikoError) + static var empty: SearchResult { .success([]) } + } + + enum AppointmentResult { + case success([Appointment]) + case failure(ClinikoError) + static var empty: AppointmentResult { .success([]) } + } + + actor PatientSearcher: ClinikoPatientSearching { + let result: SearchResult + init(result: SearchResult) { self.result = result } + + func searchPatients(query: String) async throws -> [Patient] { + switch result { + case .success(let patients): return patients + case .failure(let error): throw error + } + } + } + + actor AppointmentLoader: ClinikoAppointmentLoading { + let result: AppointmentResult + init(result: AppointmentResult) { self.result = result } + + func recentAndTodayAppointments( + forPatientID patientID: String, + reference: Date + ) async throws -> [Appointment] { + switch result { + case .success(let appointments): return appointments + case .failure(let error): throw error + } + } + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..eb70c8b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,78 @@ +# Codecov configuration for mac-speech-to-text +# +# Docs: https://docs.codecov.com/docs/codecov-yaml +# +# The project has a set of services that are deliberately hardware-gated +# from CI β€” they need a real microphone, Accessibility TCC grant, display +# server, or a user keychain, and the macOS-14 GitHub Actions runner has +# none of those. These files have real tests, but those tests run only +# on the remote-Mac pre-push hook (see +# `.claude/references/testing-conventions.md` and the `--skip` list in +# `.github/workflows/ci.yml`). +# +# Without this config, every PR that touches one of those files has its +# `codecov/patch` check fail with "0% of diff hit", which trains +# reviewers to ignore the signal β€” worse than no check. We exclude the +# hardware-gated paths from *coverage reporting* (not from CI), so the +# patch/project checks stay honest on the code CI can actually exercise. + +coverage: + status: + project: + default: + # Baseline project coverage; small threshold to tolerate noise. + target: auto + threshold: 1% + # Don't fail the build on small drops β€” informational only. Real + # floor-enforcement would require deciding a project-wide target + # first; tracked for later. + informational: true + patch: + default: + # New code should be well-tested, but hardware-gated files are + # excluded via `ignore` below. For everything else, require the + # patch to meet the current project baseline. + target: auto + threshold: 5% + informational: false + +# Paths Codecov should not factor into the coverage report. +# +# Keep this list in lockstep with the `--skip` list in +# `.github/workflows/ci.yml`'s `Run unit tests` step. If a file is +# migrated off the skip list (its tests become hardware-free), drop it +# from here too. +ignore: + # Hardware-gated service sources β€” no CI test can reach them. + - "Sources/Services/AudioCaptureService.swift" + - "Sources/Services/PermissionService.swift" + - "Sources/Services/TextInsertionService.swift" + - "Sources/Services/VoiceTriggerMonitoringService.swift" + - "Sources/Services/WakeWordService.swift" + # App-lifecycle code: NSApplicationDelegate callbacks, NSApp wiring, + # menu-bar / notification observer setup. Exercising these in a unit + # test would require mocking NSApplication, NSStatusBar, etc β€” not + # proportionate for lifecycle-only code. The AppStateTests suite + # covers the testable parts of app state; AppDelegate itself is + # functional under the pre-push smoke test on real hardware. + - "Sources/SpeechToTextApp/AppDelegate.swift" + # SwiftUI view components whose only logic is render + animation + # drivers. ViewInspector render-crash tests cover instantiation but + # don't fire `onAppear`-driven animation callbacks. Per-file crash + # tests are opt-in in this repo; coverage on these files is not an + # actionable signal. + - "Sources/Views/Components/ParticleVortexWaveform.swift" + # Test files themselves β€” coverage of tests isn't a useful signal. + - "Tests/**" + - "UITests/**" + # Build + tooling. + - "scripts/**" + - ".claude/**" + - ".gemini/**" + - ".github/**" + # Generated / vendored. + - "Sources/Resources/**" + - "Vendor/**" + +# Comment on PRs is handled by codecov/codecov-action in the workflow; +# keep the default layout. diff --git a/docs/CONCURRENCY_PATTERNS.md b/docs/CONCURRENCY_PATTERNS.md index d64ada9..94ed913 100644 --- a/docs/CONCURRENCY_PATTERNS.md +++ b/docs/CONCURRENCY_PATTERNS.md @@ -1,266 +1,18 @@ # Swift Concurrency Patterns and Pitfalls -Common Swift concurrency issues and how to avoid them. - -## Critical: @Observable + Actor Existential Types - -### Issue - -`@Observable` classes with actor existential properties can crash: - -```text -EXC_BAD_ACCESS (SIGSEGV) -KERN_INVALID_ADDRESS (possible pointer authentication failure) -``` - -### Cause - -The `@Observable` macro scans all properties. Actor existential types -trigger executor checks that can fail on ARM64. - -### Solution - -Mark actor existential properties with `@ObservationIgnored`: - -```swift -// WRONG - Can crash -@Observable -class MyViewModel { - private let actorService: any MyActorProtocol -} - -// CORRECT - Safe -@Observable -class MyViewModel { - @ObservationIgnored private let actorService: any MyActorProtocol -} -``` - -### Detection - -SwiftLint rule `observable_actor_existential_warning` detects this. - ---- - -## nonisolated(unsafe) Properties - -### Issue (nonisolated) - -`nonisolated(unsafe)` bypasses actor isolation, risking data races. - -### When to Use (nonisolated) - -1. Accessing properties from `deinit` (which is nonisolated) -2. Audio/system callbacks that run on background threads -3. When you have a clear synchronization strategy (e.g., thread-safe types) - -### Example (nonisolated) - -```swift -@Observable @MainActor -class MyViewModel { - private var timer: Timer? - @ObservationIgnored private nonisolated(unsafe) var deinitTimer: Timer? - - func startTimer() { - let newTimer = Timer.scheduledTimer(...) - timer = newTimer - deinitTimer = newTimer - } - - deinit { - deinitTimer?.invalidate() - } -} -``` - -### Detection (nonisolated) - -SwiftLint rule `nonisolated_unsafe_warning` flags these usages. - ---- - -## Audio Callbacks and @MainActor - -### Issue (Audio) - -Core Audio callbacks run on a real-time audio thread. Calling `@MainActor` -methods directly from these callbacks causes actor isolation crashes. - -### Cause (Audio) - -```swift -// WRONG - Crashes with actor isolation violation -@MainActor class AudioCaptureService { - func processBuffer(_ buffer: AVAudioPCMBuffer) { ... } - - func start() { - inputNode.installTap(...) { buffer, time in - self.processBuffer(buffer) // Called from audio thread! - } - } -} -``` - -### Solution (Audio) - -1. Make the callback handler `nonisolated` -2. Use `nonisolated(unsafe)` for thread-safe properties accessed from callback -3. Hop to MainActor via `Task` for state updates - -```swift -@MainActor class AudioCaptureService { - // Thread-safe types can be nonisolated(unsafe) - private nonisolated(unsafe) let pendingWrites = PendingWritesCounter() - private nonisolated(unsafe) let throttler = AudioLevelThrottler() - - func start() { - inputNode.installTap(...) { [weak self] buffer, time in - self?.processBuffer(buffer) - } - } - - // nonisolated - safe to call from audio thread - private nonisolated func processBuffer(_ buffer: AVAudioPCMBuffer) { - let samples = convertToInt16(buffer) - - pendingWrites.increment() - Task { @MainActor [weak self] in - defer { self?.pendingWrites.decrement() } - // Update MainActor-isolated state here - await self?.streamingBuffer?.append(samples) - } - } -} -``` - -### Key Points (Audio) - -- Audio callbacks are synchronous; cannot use `await` -- Use `Task { @MainActor ... }` to hop to MainActor -- Thread-safe utility classes (`@unchecked Sendable`) can be `nonisolated(unsafe)` -- Never access `@MainActor` `var` properties from `nonisolated` context - ---- - -## AVAudioEngine Format Compatibility - -### Issue (Format) - -Requesting a specific audio format (e.g., 16kHz Int16) that hardware doesn't -support can cause `audioEngine.start()` to fail. - -### Solution (Format) - -Use the native format and convert in the callback: - -```swift -// WRONG - May fail on some hardware -let format = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 16000, ...) -inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { ... } - -// CORRECT - Use native format, convert manually -let nativeFormat = inputNode.outputFormat(forBus: 0) -inputNode.installTap( - onBus: 0, bufferSize: 1024, format: nativeFormat -) { buffer, _ in - // Convert float32 to Int16 in callback - if let floatData = buffer.floatChannelData { - let samples = floatData[0].map { Int16($0 * Float(Int16.max)) } - } -} -``` - ---- - -## Task Lifecycle in SwiftUI - -### Issue (Tasks) - -Tasks in `onAppear` may outlive the view, causing crashes or leaks. - -### Solution (Tasks) - -Use `.task(id:)` modifier for automatic cancellation: - -```swift -struct MyView: View { - @State private var taskId: UUID? - - var body: some View { - Text("Hello") - .task(id: taskId) { - guard taskId != nil else { return } - // Work automatically cancelled on disappear - } - .onAppear { taskId = UUID() } - } -} -``` - ---- - -## Actor Protocol Conformance - -### Issue (Actors) - -Swift actors cannot be inherited, preventing mock subclasses. - -### Solution (Actors) - -Use protocols constrained to `Actor`: - -```swift -protocol FluidAudioServiceProtocol: Actor { - func transcribe(samples: [Int16]) async throws -> TranscriptionResult -} - -actor FluidAudioService: FluidAudioServiceProtocol { ... } -actor MockFluidAudioService: FluidAudioServiceProtocol { ... } -``` - ---- - -## Testing Concurrency Issues - -### Limitations - -Some bugs only manifest on real hardware: - -- Pointer authentication failures (ARM64) -- Race conditions under load -- SwiftUI rendering issues - -### Strategy - -1. **Unit Tests**: Logic and state transitions -2. **ViewInspector**: View structure and rendering -3. **Smoke Tests**: Brief app runs checking for crashes -4. **XCUITest**: Full E2E flows - -### Crash Detection Test - -```swift -func test_viewModel_instantiatesWithoutCrash() { - let viewModel = MyViewModel() - XCTAssertNotNil(viewModel) -} -``` - ---- - -## Checklist: Adding Actor Services - -- [ ] Mark property with `@ObservationIgnored` -- [ ] Use protocol constraint to `Actor` -- [ ] Create actor-based mock for tests -- [ ] Add render crash detection test -- [ ] Run on actual hardware before merging - ---- - -## Related Files - -- `.swiftlint.yml` - Custom rules for dangerous patterns -- `Tests/.../RecordingModalRenderTests.swift` - Render crash tests -- `.github/workflows/ci.yml` - CI pipeline +> **Moved.** This file has been superseded by +> [`.claude/references/concurrency.md`](../.claude/references/concurrency.md), +> part of the topic-router reorganisation in issue #25 (F6). The new +> file covers the same patterns plus more: +> +> - `@Observable` + actor existential types (EXC_BAD_ACCESS) +> - `nonisolated(unsafe)` β€” when it's the right call +> - Audio callbacks crossing `@MainActor` +> - `AVAudioEngine` format compatibility +> - SwiftUI task lifecycle +> - Actor-constrained protocols for mockability +> - Checklist for adding actor services +> +> This stub is kept so the SwiftLint config comments, `AppState.swift` +> comments, and previous `.claude/CLAUDE.md` references still resolve. +> Please update any new references to point at the new location. diff --git a/scripts/ci-summary.py b/scripts/ci-summary.py new file mode 100755 index 0000000..935bde5 --- /dev/null +++ b/scripts/ci-summary.py @@ -0,0 +1,828 @@ +#!/usr/bin/env python3 +""" +Emit a structured Markdown summary of a CI job to `$GITHUB_STEP_SUMMARY` +for fast PR triage. Parses `swift build` / `swift test` output captured +to log files. + +Usage (inside a GitHub Actions step): + + python3 scripts/ci-summary.py \\ + --job "Build and Test" \\ + --build-log build.log \\ + --test-log test.log \\ + --out "$GITHUB_STEP_SUMMARY" + +Any log argument may be omitted β€” the script only summarises the logs +it's given. Missing or empty logs are noted in the summary. + +Why Python (not shell): the parsing is stateful (dedup, bucketing, +severity ordering) and Python's `re` keeps the regex set readable. +macOS-14 runners ship `/usr/bin/env python3` so no extra setup step. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + + +# --------------------------------------------------------------------------- +# Regexes +# --------------------------------------------------------------------------- + +# Compiler diagnostic β€” warning or error. +# /path/Foo.swift:249:25: warning: cannot use inout ... [#TemporaryPointers] +# /path/Bar.swift:10:3: error: oops +# The category tag has shipped since Swift 5.10 and most of our noise is +# already tagged; untagged diagnostics fall into an "Uncategorised" bucket. +# Category character class allows `.` and `-` for forward-compat with +# categories like `#StrictConcurrency.Availability`. +SWIFT_DIAG = re.compile( + r"^(?P[^:\n]+\.swift):(?P\d+):(?P\d+):\s+" + r"(?Pwarning|error):\s+" + r"(?P.+?)(?:\s+\[#(?P[A-Za-z0-9_.\-]+)\])?$" +) + +# Canary: anything that looks like a warning or error diagnostic. If this +# matches but SWIFT_DIAG doesn't, our regex is stale and we're silently +# dropping diagnostics β€” the parser surfaces that in the summary rather +# than rendering a misleading "βœ… Clean". +# +# We deliberately exclude `note:` and `remark:` from the canary. swiftc +# emits them as continuation lines for the primary warning/error (e.g. +# "note: insert 'try'" after a try-failure warning), they don't match +# SWIFT_DIAG by design, and counting them here generates per-PR noise +# without signalling real drift. +SWIFT_DIAG_CANARY = re.compile( + r"^(?P[^:\n]+\.swift):\d+:\d+:\s+(warning|error):" +) + +# Swift Testing failure marker. +# ✘ Test "foo" recorded an issue at Path.swift:42:3 +# ✘ Test "bar" failed after 0.001 seconds. +SWIFT_TEST_FAIL = re.compile(r"^✘ Test \"(?P[^\"]+)\"\s*(?P.*)$") + +# XCTest per-case failure. +# /path/File.swift:411: error: -[SuiteName.ClassName test_foo] : XCTAssertLessThan failed: ... +XCTEST_FAIL = re.compile( + r"^(?P[^:\n]+):(?P\d+):\s+error:\s+" + r"-\[(?P[A-Za-z0-9_.]+)\s+(?P[A-Za-z0-9_]+)\]\s*:\s*" + r"(?P.+)$" +) + +# Swift Testing run summary. +# βœ” Test run with 34 tests in 4 suites passed after 0.007 seconds. +# ✘ Test run with 34 tests in 4 suites failed after 0.012 seconds. +SWIFT_TEST_TOTAL = re.compile( + r"Test run with (?P\d+) tests?.*\s" + r"(?Ppassed|failed)\s+after\s+(?P[0-9.]+)\s+seconds" +) + +# XCTest suite summary (one per suite in --parallel; we take the max). +# Executed 35 tests, with 0 failures (0 unexpected) in 5.929 (5.935) seconds +XCTEST_TOTAL = re.compile( + r"Executed\s+(?P\d+)\s+tests?,\s+with\s+(?P\d+)\s+failure" +) + +# Prefixes to strip from file paths so the tables are readable. +RUNNER_PREFIXES = ( + "/Users/runner/work/mac-speech-to-text/mac-speech-to-text/", + "/Users/runner/_work/mac-speech-to-text/mac-speech-to-text/", +) + +# Diagnostic sources to suppress β€” not our code / not actionable in this PR. +DIAG_IGNORE_PATTERNS = ( + ".build/", # build cache + "checkouts/", # SPM checkouts + "/Frameworks/", # vendored frameworks + "/DerivedData/", +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _strip_prefix(path: str) -> str: + for pfx in RUNNER_PREFIXES: + if path.startswith(pfx): + return path[len(pfx):] + return path + + +def _is_our_source(path: str) -> bool: + for pat in DIAG_IGNORE_PATTERNS: + if pat in path: + return False + return True + + +def _truncate(msg: str, limit: int = 140) -> str: + msg = msg.strip() + if len(msg) <= limit: + return msg + return msg[: limit - 1] + "…" + + +def _read_lines(path: str | None) -> list[str]: + if not path: + return [] + p = Path(path) + if not p.is_file(): + return [] + # errors="replace" guards against odd bytes in compiler output + # (ANSI colour codes survive utf-8 round-trip; non-utf-8 would not). + with p.open(encoding="utf-8", errors="replace") as f: + return [line.rstrip("\n") for line in f] + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + +def parse_warnings(path: str | None) -> tuple[dict[str, list[dict[str, str]]], int]: + """ + Return (buckets, unrecognised) where: + - buckets is {category: [diagnostic, …]} with file/line/severity/message, + deduped on (file, line, severity, message); + - unrecognised counts diagnostic-shaped lines that the full-fidelity + regex didn't match (canary for regex drift). + """ + buckets: dict[str, list[dict[str, str]]] = defaultdict(list) + seen: set[tuple[str, str, str, str]] = set() + unrecognised = 0 + for line in _read_lines(path): + m = SWIFT_DIAG.match(line) + if not m: + # If it looks like a diagnostic but the full regex failed, bump + # the canary so the summary can surface a "regex drift" warning. + canary = SWIFT_DIAG_CANARY.match(line) + if canary: + canary_path = _strip_prefix(canary.group("path")) + if _is_our_source(canary_path): + unrecognised += 1 + continue + file_path = _strip_prefix(m.group("path")) + if not _is_our_source(file_path): + continue + key = (file_path, m.group("line"), m.group("severity"), m.group("message")) + if key in seen: + continue + seen.add(key) + category = m.group("category") or "Uncategorised" + buckets[category].append({ + "file": file_path, + "line": m.group("line"), + "severity": m.group("severity"), + "message": m.group("message"), + }) + return dict(buckets), unrecognised + + +def parse_test_failures(path: str | None) -> list[dict[str, str]]: + """Return an ordered list of test failures (XCTest + Swift Testing).""" + failures: list[dict[str, str]] = [] + for line in _read_lines(path): + m = XCTEST_FAIL.match(line) + if m: + failures.append({ + "kind": "xctest", + # e.g. SpeechToTextTests.PendingWritesCounterTests β†’ PendingWritesCounterTests + "class": m.group("clazz").split(".")[-1], + "test": m.group("test"), + "assertion": _truncate(m.group("assertion")), + "location": f"{_strip_prefix(m.group('path'))}:{m.group('line')}", + }) + continue + m = SWIFT_TEST_FAIL.match(line) + if m: + # SWIFT_TEST_FAIL requires a quoted name, so the run-summary + # line `✘ Test run with 34 tests …` (unquoted) already can't + # match here. No extra filter needed. + failures.append({ + "kind": "swift-testing", + "name": m.group("name"), + "detail": _truncate(m.group("rest").strip()), + }) + return failures + + +# XCTest's "Test Suite 'Selected tests' (passed|failed)" marks the +# end-of-process line under `--parallel`. Each test-class process emits +# three "Executed N tests" lines (inner suite, `*.xctest` wrapper, and +# `Selected tests`), all with the same N. Summing only the `Executed` +# line that follows the `Selected tests` marker gives one entry per +# process; under non-parallel (single process) that's one entry total. +XCTEST_SELECTED_MARKER = re.compile(r"Test Suite 'Selected tests' (passed|failed)") + + +def parse_test_totals(path: str | None) -> dict[str, Any]: + """ + Return {'swift_testing': {…}|None, 'xctest': {…}|None}. + + Swift Testing: the final `Test run with N tests … (passed|failed)` line + is authoritative; we take the last one we see. + + XCTest: under `--parallel`, each test-class process writes three + identical "Executed N tests" lines (inner, `*.xctest`, and + `Selected tests`). We accumulate only the line that follows the + `Selected tests` marker so per-process totals sum cleanly and no + suite is silently dropped (previously a `max()` here hid failures + in non-largest suites β€” issue #39 review). + """ + totals: dict[str, Any] = {"swift_testing": None, "xctest": None} + xctest_total = 0 + xctest_failures = 0 + saw_selected_marker = False + + for line in _read_lines(path): + m = SWIFT_TEST_TOTAL.search(line) + if m: + totals["swift_testing"] = { + "total": int(m.group("total")), + "result": m.group("result"), + "secs": float(m.group("secs")), + } + + if XCTEST_SELECTED_MARKER.search(line): + saw_selected_marker = True + continue + + m = XCTEST_TOTAL.search(line) + if m and saw_selected_marker: + xctest_total += int(m.group("total")) + xctest_failures += int(m.group("failures")) + saw_selected_marker = False + + if xctest_total > 0 or xctest_failures > 0: + totals["xctest"] = {"total": xctest_total, "failures": xctest_failures} + + return totals + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + +def _log_status(path: str | None) -> str: + """ + Classify a log path into one of: + - "absent": caller didn't pass the argument + - "missing": argument passed but file doesn't exist + - "empty": file exists but has zero bytes + - "present": file exists with content + Distinguishing these is critical: `empty` or `missing` must not render + as "βœ… Clean" β€” that's exactly the silent-failure mode this script is + supposed to prevent. + """ + if not path: + return "absent" + p = Path(path) + if not p.is_file(): + return "missing" + try: + if p.stat().st_size == 0: + return "empty" + except OSError: + return "missing" + return "present" + + +def render( + job: str, + warnings: dict[str, list[dict[str, str]]], + unrecognised_diag_lines: int, + failures: list[dict[str, str]], + totals: dict[str, Any], + build_log_status: str, + test_log_status: str, + job_status: str | None = None, +) -> str: + lines: list[str] = [f"## {job} β€” CI summary", ""] + + warn_count = sum(len(v) for v in warnings.values()) + error_count = sum( + 1 for items in warnings.values() for w in items if w["severity"] == "error" + ) + fail_count = len(failures) + + # Did either log fail to materialise? If so, we can't claim "clean". + log_problems: list[str] = [] + for label, status in (("build", build_log_status), ("test", test_log_status)): + if status == "missing": + log_problems.append(f"{label} log missing (step likely errored before capture)") + elif status == "empty": + log_problems.append(f"{label} log empty (0 bytes β€” `tee` failed or step exited early)") + + # If the caller didn't pass either log at all, nothing to say. + if build_log_status == "absent" and test_log_status == "absent": + lines.append("_No build or test log captured β€” nothing to summarise._") + lines.append("") + return "\n".join(lines) + + # Headline chips. + chips: list[str] = [] + if job_status and job_status.lower() not in ("success", ""): + chips.append(f"🚨 **Job status: {job_status}**") + if error_count: + chips.append(f"πŸ›‘ **{error_count}** compile error{'s' if error_count != 1 else ''}") + if fail_count: + chips.append(f"❌ **{fail_count}** test failure{'s' if fail_count != 1 else ''}") + if warn_count - error_count > 0: + w = warn_count - error_count + chips.append(f"⚠️ **{w}** warning{'s' if w != 1 else ''}") + if log_problems: + chips.append(f"⚠️ **{len(log_problems)}** log capture issue{'s' if len(log_problems) != 1 else ''}") + if unrecognised_diag_lines: + chips.append( + f"πŸ” **{unrecognised_diag_lines}** diagnostic-shaped line" + f"{'s' if unrecognised_diag_lines != 1 else ''} the parser didn't understand" + ) + if not chips: + chips.append("βœ… **Clean** β€” no warnings, no failures") + lines.append(" Β· ".join(chips)) + lines.append("") + + # Log capture issues section β€” surfaces cases where we genuinely can't + # claim to know what happened. + if log_problems: + lines.append("### ⚠️ Log capture issues") + lines.append("") + for note in log_problems: + lines.append(f"- {note}") + lines.append("") + lines.append( + "_A summary rendered against missing/empty logs may not reflect the " + "real build state β€” check the raw workflow log._" + ) + lines.append("") + + # Regex drift canary. + if unrecognised_diag_lines: + lines.append("### πŸ” Possible regex drift") + lines.append("") + lines.append( + f"- {unrecognised_diag_lines} line(s) matched `\\.swift:N:N: " + "(warning|error|note|remark):` but not the full parser. Swift's " + "diagnostic format may have shifted β€” audit `SWIFT_DIAG` in " + "`scripts/ci-summary.py` and refresh the `--self-test` corpus." + ) + lines.append("") + + # Failures. + if failures: + lines.append(f"### ❌ Test failures ({fail_count})") + lines.append("") + for f in failures[:25]: + if f["kind"] == "swift-testing": + lines.append(f"- **{f['name']}**") + if f["detail"]: + lines.append(f" - {f['detail']}") + else: + lines.append(f"- **{f['class']}.{f['test']}** \\\n `{f['location']}`") + lines.append(f" - {f['assertion']}") + if fail_count > 25: + lines.append(f"- _… {fail_count - 25} more; see the raw log_") + lines.append("") + + # Test totals. + st = totals.get("swift_testing") + xc = totals.get("xctest") + if st or xc: + lines.append("### πŸ§ͺ Test totals") + lines.append("") + if st: + icon = "βœ…" if st["result"] == "passed" else "❌" + lines.append( + f"- {icon} Swift Testing: **{st['total']}** tests β€” " + f"{st['result']} in {st['secs']:.2f}s" + ) + if xc: + icon = "βœ…" if xc["failures"] == 0 else "❌" + lines.append( + f"- {icon} XCTest: **{xc['total']}** tests β€” " + f"**{xc['failures']}** failure{'s' if xc['failures'] != 1 else ''}" + ) + lines.append("") + + # Diagnostics, grouped by category (errors first, then warnings by count). + if warnings: + lines.append(f"### ⚠️ Swift compiler diagnostics ({warn_count})") + lines.append("") + lines.append("
Expand by category") + lines.append("") + + def _cat_sort_key(name: str) -> tuple[int, int, str]: + items = warnings[name] + has_error = any(w["severity"] == "error" for w in items) + # Errors first, then by descending count, then by name. + return (0 if has_error else 1, -len(items), name) + + for cat in sorted(warnings, key=_cat_sort_key): + items = warnings[cat] + lines.append(f"#### `#{cat}` ({len(items)})") + lines.append("") + lines.append("| File | Line | Severity | Message |") + lines.append("|---|---|---|---|") + for w in items[:20]: + msg = _truncate(w["message"], 120).replace("|", r"\|") + file_cell = w["file"].replace("|", r"\|") + lines.append( + f"| `{file_cell}` | {w['line']} | {w['severity']} | {msg} |" + ) + if len(items) > 20: + lines.append(f"| _… {len(items) - 20} more_ | | | |") + lines.append("") + lines.append("
") + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Self-test +# --------------------------------------------------------------------------- + +SELF_TEST_BUILD = """\ +[2/9] Compiling SpeechToText AudioBuffer.swift +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/Sources/Services/AudioCaptureService.swift:249:25: warning: cannot use inout expression here; argument 'mInputData' must be a pointer that outlives the call to 'init(...)' [#TemporaryPointers] +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/Sources/Services/AudioCaptureService.swift:249:25: warning: cannot use inout expression here; argument 'mInputData' must be a pointer that outlives the call to 'init(...)' [#TemporaryPointers] +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/Sources/Services/AudioCaptureService.swift:239:13: warning: variable 'deviceIdSize' was never mutated; consider changing to 'let' constant +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/Sources/SpeechToTextApp/AppDelegate.swift:293:49: warning: call to main actor-isolated instance method 'load()' in a synchronous nonisolated context [#ActorIsolatedCall] +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/Sources/Services/Future.swift:10:3: warning: dotted.compound category example [#StrictConcurrency.Availability] +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/.build/checkouts/FluidAudio/Sources/x.swift:10:1: warning: some dep warning +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/Sources/Mystery.swift:42:1: note: a note line must not bucket as diagnostic OR as canary +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/Sources/Drift.swift:7:1: warning hypothetical drift missing colon after severity +Build complete! +""" + +# Multi-process parallel XCTest output β€” two test classes, each emitting +# the triple `Executed N tests` pattern. Summing without the selected-marker +# filter would triple-count; max() would hide SuiteB's failures entirely. +SELF_TEST_TEST_PARALLEL = """\ +Test Suite 'SuiteA' started at 2026-04-24 10:00:00.000. +Test Suite 'SuiteA' passed at 2026-04-24 10:00:00.500. +\t Executed 10 tests, with 0 failures (0 unexpected) in 0.100 (0.200) seconds +Test Suite 'SpeechToTextPackageTests.xctest' passed at 2026-04-24 10:00:00.500. +\t Executed 10 tests, with 0 failures (0 unexpected) in 0.100 (0.200) seconds +Test Suite 'Selected tests' passed at 2026-04-24 10:00:00.500. +\t Executed 10 tests, with 0 failures (0 unexpected) in 0.100 (0.200) seconds +Test Suite 'SuiteB' started at 2026-04-24 10:00:00.600. +/path/B.swift:1: error: -[SpeechToTextTests.SuiteB test_one] : XCTAssertEqual failed +/path/B.swift:2: error: -[SpeechToTextTests.SuiteB test_two] : XCTAssertEqual failed +/path/B.swift:3: error: -[SpeechToTextTests.SuiteB test_three] : XCTAssertEqual failed +Test Suite 'SuiteB' failed at 2026-04-24 10:00:00.900. +\t Executed 5 tests, with 3 failures (0 unexpected) in 0.100 (0.200) seconds +Test Suite 'SpeechToTextPackageTests.xctest' failed at 2026-04-24 10:00:00.900. +\t Executed 5 tests, with 3 failures (0 unexpected) in 0.100 (0.200) seconds +Test Suite 'Selected tests' failed at 2026-04-24 10:00:00.900. +\t Executed 5 tests, with 3 failures (0 unexpected) in 0.100 (0.200) seconds +""" + +SELF_TEST_TEST_FAIL = """\ +βœ” Test "UUIDs produced in quick succession are distinct" passed after 0.001 seconds. +/Users/runner/work/mac-speech-to-text/mac-speech-to-text/Tests/SpeechToTextTests/Services/AudioCaptureServiceTests.swift:411: error: -[SpeechToTextTests.PendingWritesCounterTests test_waitForCompletion_waitsForPendingWrites] : XCTAssertLessThan failed: ("0.204306960105896") is not less than ("0.2") +✘ Test "Some flaky Swift Testing test" failed after 0.002 seconds. +Test Suite 'Selected tests' failed at 2026-04-24 10:00:00.900. +\t Executed 35 tests, with 1 failures (0 unexpected) in 5.929 (5.935) seconds +βœ” Test run with 34 tests in 4 suites passed after 0.007 seconds. +""" + +SELF_TEST_TEST_CLEAN = """\ +βœ” Test "UUIDs produced in quick succession are distinct" passed after 0.001 seconds. +Test Suite 'Selected tests' passed at 2026-04-24 10:00:00.000. +\t Executed 35 tests, with 0 failures (0 unexpected) in 5.929 (5.935) seconds +βœ” Test run with 34 tests in 4 suites passed after 0.007 seconds. +""" + + +def _self_test() -> int: + import tempfile + + failures_found = 0 + + def _write_tmp(contents: str) -> str: + fd = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".log") + fd.write(contents) + fd.close() + return fd.name + + def _assert(cond: bool, message: str) -> None: + nonlocal failures_found + if not cond: + print(f"FAIL: {message}", file=sys.stderr) + failures_found += 1 + else: + print(f"ok : {message}") + + # --- parse_warnings --- + + build_path = _write_tmp(SELF_TEST_BUILD) + warnings, unrecognised = parse_warnings(build_path) + _assert("TemporaryPointers" in warnings, "TemporaryPointers category bucketed") + _assert("ActorIsolatedCall" in warnings, "ActorIsolatedCall category bucketed") + _assert("Uncategorised" in warnings, "uncategorised diagnostic bucketed") + _assert( + "StrictConcurrency.Availability" in warnings, + "dotted category name (\".\") parsed", + ) + _assert(len(warnings["TemporaryPointers"]) == 1, "duplicate diagnostic deduped") + all_files = {w["file"] for items in warnings.values() for w in items} + _assert( + all(not f.startswith("/Users/runner") for f in all_files), + "runner workspace prefix stripped", + ) + _assert( + all(".build/" not in f for f in all_files), + "build-cache diagnostics filtered", + ) + _assert( + unrecognised == 0, + "note/remark lines do NOT bump the canary (continuation noise, not drift)", + ) + + # --- parse_test_failures --- + + fail_path = _write_tmp(SELF_TEST_TEST_FAIL) + failures = parse_test_failures(fail_path) + _assert(len(failures) == 2, "two test failures parsed (xctest + swift-testing)") + xc = next((f for f in failures if f["kind"] == "xctest"), None) + _assert(xc is not None and xc["class"] == "PendingWritesCounterTests", + "xctest class extracted") + _assert(xc is not None and "XCTAssertLessThan failed" in xc["assertion"], + "xctest assertion captured") + st = next((f for f in failures if f["kind"] == "swift-testing"), None) + _assert(st is not None and st["name"] == "Some flaky Swift Testing test", + "swift testing name captured") + + # --- parse_test_totals: single-process --- + + totals = parse_test_totals(fail_path) + _assert(totals["swift_testing"] is not None + and totals["swift_testing"]["total"] == 34, + "swift testing total parsed") + _assert(totals["xctest"] is not None + and totals["xctest"]["total"] == 35 + and totals["xctest"]["failures"] == 1, + "xctest total+failures parsed (single process)") + + # --- parse_test_totals: parallel / multi-process (regression test for + # the bug the code-reviewer flagged β€” previously the `max()` version + # reported 10/0 instead of 15/3, hiding SuiteB's failures). --- + + parallel_path = _write_tmp(SELF_TEST_TEST_PARALLEL) + parallel_totals = parse_test_totals(parallel_path) + _assert( + parallel_totals["xctest"] is not None + and parallel_totals["xctest"]["total"] == 15, + "parallel xctest total sums across processes (was: max, hid suites)", + ) + _assert( + parallel_totals["xctest"] is not None + and parallel_totals["xctest"]["failures"] == 3, + "parallel xctest failures sum across processes", + ) + + # --- _log_status --- + + import os + _assert(_log_status(None) == "absent", "_log_status absent") + _assert(_log_status("/nonexistent/path.log") == "missing", "_log_status missing") + empty_path = _write_tmp("") + _assert(_log_status(empty_path) == "empty", "_log_status empty") + _assert(_log_status(build_path) == "present", "_log_status present") + + # --- render: clean run --- + + clean_path = _write_tmp(SELF_TEST_TEST_CLEAN) + body = render( + job="Self test", + warnings={}, + unrecognised_diag_lines=0, + failures=parse_test_failures(clean_path), + totals=parse_test_totals(clean_path), + build_log_status="present", + test_log_status="present", + ) + _assert("Clean" in body, "clean run renders a 'Clean' headline") + _assert("Test failures" not in body, "clean run has no failures section") + _assert("Swift compiler diagnostics" not in body, + "clean run has no diagnostics section") + + # --- render: with failures + warnings + drift canary --- + + body = render( + job="Build and Test", + warnings=warnings, + unrecognised_diag_lines=unrecognised, + failures=failures, + totals=totals, + build_log_status="present", + test_log_status="present", + ) + _assert("Test failures (2)" in body, "failures headline rendered") + _assert("`#TemporaryPointers`" in body, "category heading rendered") + _assert("PendingWritesCounterTests.test_waitForCompletion_waitsForPendingWrites" in body, + "xctest case rendered") + + # Canary rendering β€” simulate drift by passing a non-zero count directly. + drift_body = render( + job="Build and Test", + warnings={}, + unrecognised_diag_lines=3, + failures=[], + totals={"swift_testing": None, "xctest": None}, + build_log_status="present", + test_log_status="present", + ) + _assert("regex drift" in drift_body.lower(), + "non-zero canary count surfaces 'regex drift' section") + _assert("3 line" in drift_body, + "drift count rendered in copy") + + # --- render: missing/empty log must NOT claim "Clean" --- + + missing_body = render( + job="Build and Test", + warnings={}, + unrecognised_diag_lines=0, + failures=[], + totals={"swift_testing": None, "xctest": None}, + build_log_status="missing", + test_log_status="absent", + ) + _assert("Clean" not in missing_body, + "missing build log does NOT render 'Clean'") + _assert("log missing" in missing_body.lower(), + "missing-log headline surfaced") + + empty_body = render( + job="Build and Test", + warnings={}, + unrecognised_diag_lines=0, + failures=[], + totals={"swift_testing": None, "xctest": None}, + build_log_status="empty", + test_log_status="absent", + ) + _assert("Clean" not in empty_body, + "empty build log does NOT render 'Clean'") + _assert("0 bytes" in empty_body, + "empty-log headline surfaced") + + # --- render: job_status != success propagates to the headline --- + + failed_job_body = render( + job="Build and Test", + warnings={}, + unrecognised_diag_lines=0, + failures=[], + totals={"swift_testing": None, "xctest": None}, + build_log_status="present", + test_log_status="present", + job_status="failure", + ) + _assert("Job status: failure" in failed_job_body, + "non-success job_status surfaced in render") + + # --- _cap_body: large input gets truncated cleanly --- + + big = "x" * (STEP_SUMMARY_SOFT_CAP_BYTES + 50_000) + capped = _cap_body(big) + _assert( + len(capped.encode("utf-8")) <= STEP_SUMMARY_SOFT_CAP_BYTES + 500, + "_cap_body enforces the step-summary soft cap", + ) + _assert("truncated" in capped, "_cap_body annotates truncation") + + # --- pipe-escape in file cell (defensive) --- + + pipe_warn = { + "Test": [{ + "file": "Sources/Weird|Name.swift", + "line": "1", + "severity": "warning", + "message": "pipe|in|message", + }] + } + pipe_body = render( + job="Build and Test", + warnings=pipe_warn, + unrecognised_diag_lines=0, + failures=[], + totals={"swift_testing": None, "xctest": None}, + build_log_status="present", + test_log_status="present", + ) + _assert(r"Weird\|Name" in pipe_body, "pipe in file cell escaped") + _assert(r"pipe\|in\|message" in pipe_body, "pipe in message cell escaped") + + # Clean up tmp files + for p in (build_path, fail_path, clean_path, empty_path, parallel_path): + try: + os.unlink(p) + except OSError: + pass + + if failures_found: + print(f"\n{failures_found} self-test failure(s).", file=sys.stderr) + return 1 + print("\nAll self-tests passed.") + return 0 + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + +# GitHub's `$GITHUB_STEP_SUMMARY` is capped at 1 MiB. We aim well under +# to leave room for any preceding summary content the step emitted. +STEP_SUMMARY_SOFT_CAP_BYTES = 900 * 1024 + + +def _cap_body(body: str) -> str: + """Truncate body if it would blow past the step-summary size cap.""" + encoded = body.encode("utf-8") + if len(encoded) <= STEP_SUMMARY_SOFT_CAP_BYTES: + return body + # Chop at a line boundary near the cap, preserving the first block. + truncated = encoded[:STEP_SUMMARY_SOFT_CAP_BYTES].decode("utf-8", errors="ignore") + last_newline = truncated.rfind("\n") + if last_newline > 0: + truncated = truncated[:last_newline] + truncated += ( + "\n\n_… summary truncated near the 1 MiB step-summary cap. " + "Full diagnostics are in the raw workflow log._\n" + ) + return truncated + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--job", help="Job name for the summary heading.") + ap.add_argument("--build-log", help="Path to the captured swift build output.") + ap.add_argument("--test-log", help="Path to the captured swift test output.") + ap.add_argument("--job-status", + help="Pass `${{ job.status }}` from the workflow so the " + "summary can flag non-success jobs explicitly.") + ap.add_argument("--out", help="Destination file (usually $GITHUB_STEP_SUMMARY).") + ap.add_argument("--self-test", action="store_true", + help="Run parser/render sanity checks and exit.") + args = ap.parse_args() + + if args.self_test: + return _self_test() + + if not args.job or not args.out: + ap.error("--job and --out are required unless --self-test is set") + + try: + warnings, unrecognised = parse_warnings(args.build_log) + failures = parse_test_failures(args.test_log) + totals = parse_test_totals(args.test_log) + + body = render( + job=args.job, + warnings=warnings, + unrecognised_diag_lines=unrecognised, + failures=failures, + totals=totals, + build_log_status=_log_status(args.build_log), + test_log_status=_log_status(args.test_log), + job_status=args.job_status, + ) + body = _cap_body(body) + except Exception as exc: # noqa: BLE001 β€” broad catch is the whole point + # Never silently swallow: the summary is a diagnostic tool; a + # diagnostic tool that hides its own failures is worse than useless. + # Emit a visible fallback so the reviewer knows *something* went wrong + # with the summary step even if the job itself was green. + body = ( + f"## {args.job} β€” CI summary\n\n" + f"🚨 **Summary generator crashed:** `{type(exc).__name__}: {exc}`\n\n" + f"_See the raw workflow log for the underlying build/test output. " + f"If this keeps happening, check `scripts/ci-summary.py` and run " + f"`python3 scripts/ci-summary.py --self-test` locally._\n" + ) + + # Append to step summary. If the target is unwritable (permission, + # quota), fall back to stdout β€” still visible in the workflow log. + try: + with open(args.out, "a", encoding="utf-8") as f: + f.write(body) + f.write("\n") + except OSError as exc: + print(f"::warning::ci-summary: failed to write to {args.out}: {exc}", + file=sys.stderr) + + # Also echo to stdout so the content is part of the standard log. + print(body) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/remote-test.sh b/scripts/remote-test.sh index ef769b9..11dfac8 100755 --- a/scripts/remote-test.sh +++ b/scripts/remote-test.sh @@ -271,7 +271,12 @@ resolve_packages() { run_tests() { print_header "Running Tests on ${SSH_HOST}" - print_status "Executing: swift test --parallel" + # Compose the swift test command with optional tag filters passed in + # via `SWIFT_TEST_EXTRA` (e.g. SWIFT_TEST_EXTRA="--skip-tag requiresHardware" + # or "--filter-tag fast"). Leave unset to run the full suite. + local test_args="--parallel${SWIFT_TEST_EXTRA:+ ${SWIFT_TEST_EXTRA}}" + + print_status "Executing: swift test ${test_args}" print_status "Timeout: ${TEST_TIMEOUT} seconds" echo "" @@ -283,7 +288,7 @@ run_tests() { # Use a temp file to capture both output and exit code set +e - test_output=$(timeout "${TEST_TIMEOUT}" ssh "${SSH_HOST}" "cd ${REMOTE_PROJECT_PATH} && swift test --parallel 2>&1; echo \"EXIT_CODE:\$?\"") + test_output=$(timeout "${TEST_TIMEOUT}" ssh "${SSH_HOST}" "cd ${REMOTE_PROJECT_PATH} && swift test ${test_args} 2>&1; echo \"EXIT_CODE:\$?\"") local timeout_exit=$? set -e