diff --git a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift index ca3d3b4..8810fbb 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift @@ -1,7 +1,11 @@ import Foundation -/// Application configuration stored at {devRoot}/.claude/config.json -public struct AppConfig: Codable, Sendable { +/// Application configuration stored at `{devRoot}/.claude/config.json`. +/// +/// All top-level fields are optional on decode — missing keys fall back to defaults. +/// This means existing config files continue to work when new settings are added +/// (forward compatibility). +public struct AppConfig: Codable, Sendable, Equatable { public var workspaces: [WorkspaceInfo] public var defaults: ConfigDefaults public var notifications: NotificationSettings @@ -33,14 +37,25 @@ public struct AppConfig: Codable, Sendable { } /// A workspace folder configuration. -public struct WorkspaceInfo: Identifiable, Codable, Sendable { +/// +/// Each workspace maps to a directory under the dev root (e.g., `~/Dev/MyOrg`). +/// The `provider` field determines which forge is used (GitHub or GitLab), +/// and `cli` stores the corresponding CLI tool name for backward compatibility. +/// Prefer `derivedCLI` in new code — it's always consistent with `provider`. +public struct WorkspaceInfo: Identifiable, Codable, Sendable, Equatable { public let id: UUID public var name: String public var provider: String // "github" or "gitlab" - public var cli: String // "gh" or "glab" + public var cli: String // "gh" or "glab" — kept for config file compat public var host: String? // GitLab host (e.g., "gitlab.example.com") public var alwaysInclude: [String] // repos to always list in prompt table + /// The CLI tool name derived from the current `provider` value. + /// Unlike `cli` (which may be stale from an old config file), this is always correct. + public var derivedCLI: String { + provider == "github" ? "gh" : "glab" + } + public init( id: UUID = UUID(), name: String, @@ -56,15 +71,56 @@ public struct WorkspaceInfo: Identifiable, Codable, Sendable { self.host = host self.alwaysInclude = alwaysInclude } + + /// Characters that are unsafe in directory names (workspace names become directory names). + private static let unsafeCharacters = CharacterSet(charactersIn: "/:\0") + + /// Validate a workspace name, returning an error message or `nil` if valid. + /// + /// - Parameters: + /// - name: The trimmed workspace name to validate. + /// - existingNames: Names of other workspaces (for duplicate detection). + /// - Returns: A human-readable error string, or `nil` if the name is valid. + public static func validateName(_ name: String, existingNames: [String]) -> String? { + if name.isEmpty { + return "Name is required" + } + if name.unicodeScalars.contains(where: { unsafeCharacters.contains($0) }) { + return "Name cannot contain /, :, or null characters" + } + let lowercased = name.lowercased() + if existingNames.contains(where: { $0.lowercased() == lowercased }) { + return "A workspace with this name already exists" + } + return nil + } } -/// Default settings for new workspaces. -public struct ConfigDefaults: Codable, Sendable { +/// Default settings applied when creating new workspaces or sessions. +public struct ConfigDefaults: Codable, Sendable, Equatable { public var provider: String public var cli: String public var branchPrefix: String public var excludeDirs: [String] + /// Characters that are invalid in git ref names (see `git check-ref-format`). + private static let invalidBranchChars = CharacterSet(charactersIn: " ~^:?*[\\") + + /// Check whether a branch prefix is valid for use in git ref names. + /// + /// Rejects prefixes containing characters forbidden by `git check-ref-format`, + /// as well as patterns like consecutive dots or a trailing dot/slash. + public static func isValidBranchPrefix(_ prefix: String) -> Bool { + guard !prefix.isEmpty else { return true } // empty is allowed (means no prefix) + if prefix.unicodeScalars.contains(where: { invalidBranchChars.contains($0) }) { + return false + } + if prefix.contains("..") { return false } + if prefix.hasSuffix(".") { return false } + if prefix.contains("@{") { return false } + return true + } + public init( provider: String = "github", cli: String = "gh", @@ -79,7 +135,7 @@ public struct ConfigDefaults: Codable, Sendable { } /// Sidebar display preferences. -public struct SidebarSettings: Codable, Sendable { +public struct SidebarSettings: Codable, Sendable, Equatable { public var hideSessionDetails: Bool public init(hideSessionDetails: Bool = false) { diff --git a/Packages/CrowCore/Sources/CrowCore/Models/NotificationEvent.swift b/Packages/CrowCore/Sources/CrowCore/Models/NotificationEvent.swift index 86a77bc..6c87dbd 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/NotificationEvent.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/NotificationEvent.swift @@ -1,7 +1,10 @@ import Foundation /// User-facing notification event categories, mapped from raw Claude Code hook events. -/// Only events that require human attention trigger notifications. +/// +/// Only events that require human attention trigger notifications. Most hook events +/// (e.g., tool execution, streaming responses) are intentionally unmapped — they fire +/// too frequently and don't need the user's immediate attention. public enum NotificationEvent: String, Codable, Sendable, CaseIterable, Identifiable { case taskComplete case agentWaiting diff --git a/Packages/CrowCore/Sources/CrowCore/Models/NotificationSettings.swift b/Packages/CrowCore/Sources/CrowCore/Models/NotificationSettings.swift index b6e39ad..d4f511f 100644 --- a/Packages/CrowCore/Sources/CrowCore/Models/NotificationSettings.swift +++ b/Packages/CrowCore/Sources/CrowCore/Models/NotificationSettings.swift @@ -1,6 +1,14 @@ import Foundation /// Notification preferences stored in AppConfig. +/// +/// Notifications follow a cascading disable model: +/// 1. `globalMute` overrides everything — no sounds or system notifications. +/// 2. `soundEnabled` / `systemNotificationsEnabled` act as global category toggles. +/// 3. Per-event settings in `eventSettings` provide fine-grained control. +/// +/// A notification only fires if the global toggle, the category toggle, **and** the +/// per-event toggle are all enabled. public struct NotificationSettings: Codable, Sendable, Equatable { /// Master mute — suppresses all sounds and system notifications. public var globalMute: Bool @@ -36,6 +44,9 @@ public struct NotificationSettings: Codable, Sendable, Equatable { } /// Get the config for a specific event, falling back to defaults. + /// + /// This ensures forward compatibility: when a new `NotificationEvent` case is added, + /// existing config files that don't include it in `eventSettings` still get sensible defaults. public func config(for event: NotificationEvent) -> EventNotificationConfig { eventSettings[event] ?? EventNotificationConfig(soundName: event.defaultSound) } diff --git a/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift new file mode 100644 index 0000000..7f2478e --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift @@ -0,0 +1,127 @@ +import Foundation +import Testing +@testable import CrowCore + +@Test func appConfigRoundTrip() throws { + let config = AppConfig( + workspaces: [ + WorkspaceInfo(name: "TestOrg", provider: "github", cli: "gh", alwaysInclude: ["repo1"]), + WorkspaceInfo(name: "GitLabOrg", provider: "gitlab", cli: "glab", host: "gitlab.example.com"), + ], + defaults: ConfigDefaults(provider: "gitlab", cli: "glab", branchPrefix: "fix/", excludeDirs: ["vendor"]), + notifications: NotificationSettings(globalMute: true), + sidebar: SidebarSettings(hideSessionDetails: true) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(config) + let decoded = try JSONDecoder().decode(AppConfig.self, from: data) + + #expect(decoded.workspaces.count == 2) + #expect(decoded.workspaces[0].name == "TestOrg") + #expect(decoded.workspaces[0].alwaysInclude == ["repo1"]) + #expect(decoded.workspaces[1].host == "gitlab.example.com") + #expect(decoded.defaults.provider == "gitlab") + #expect(decoded.defaults.branchPrefix == "fix/") + #expect(decoded.defaults.excludeDirs == ["vendor"]) + #expect(decoded.notifications.globalMute == true) + #expect(decoded.sidebar.hideSessionDetails == true) +} + +@Test func appConfigDecodeFromEmptyJSON() throws { + let json = "{}".data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + + #expect(config.workspaces.isEmpty) + #expect(config.defaults.provider == "github") + #expect(config.defaults.branchPrefix == "feature/") + #expect(config.notifications.globalMute == false) + #expect(config.sidebar.hideSessionDetails == false) +} + +@Test func appConfigDecodeWithPartialKeys() throws { + let json = """ + {"workspaces": [{"id": "00000000-0000-0000-0000-000000000001", "name": "Org", "provider": "github", "cli": "gh", "alwaysInclude": []}]} + """.data(using: .utf8)! + let config = try JSONDecoder().decode(AppConfig.self, from: json) + + #expect(config.workspaces.count == 1) + // Other fields should be defaults + #expect(config.defaults.provider == "github") + #expect(config.notifications.soundEnabled == true) + #expect(config.sidebar.hideSessionDetails == false) +} + +@Test func appConfigIgnoresUnknownKeys() throws { + let json = """ + {"futureFeature": true, "workspaces": []} + """.data(using: .utf8)! + // Should not throw + let config = try JSONDecoder().decode(AppConfig.self, from: json) + #expect(config.workspaces.isEmpty) +} + +@Test func appConfigEquality() { + let a = AppConfig() + let b = AppConfig() + #expect(a == b) + + var c = AppConfig() + c.defaults.branchPrefix = "fix/" + #expect(a != c) +} + +// MARK: - WorkspaceInfo + +@Test func workspaceInfoDerivedCLI() { + let github = WorkspaceInfo(name: "Test", provider: "github", cli: "gh") + #expect(github.derivedCLI == "gh") + + let gitlab = WorkspaceInfo(name: "Test", provider: "gitlab", cli: "glab") + #expect(gitlab.derivedCLI == "glab") + + // Even if cli is stale, derivedCLI is correct + let stale = WorkspaceInfo(name: "Test", provider: "gitlab", cli: "gh") + #expect(stale.derivedCLI == "glab") +} + +@Test func workspaceNameValidation() { + // Valid name + #expect(WorkspaceInfo.validateName("MyOrg", existingNames: []) == nil) + + // Empty name + #expect(WorkspaceInfo.validateName("", existingNames: []) != nil) + + // Duplicate name (case-insensitive) + #expect(WorkspaceInfo.validateName("MyOrg", existingNames: ["myorg"]) != nil) + #expect(WorkspaceInfo.validateName("MYORG", existingNames: ["MyOrg"]) != nil) + + // Filesystem-unsafe characters + #expect(WorkspaceInfo.validateName("My/Org", existingNames: []) != nil) + #expect(WorkspaceInfo.validateName("My:Org", existingNames: []) != nil) + + // Valid with existing names that don't conflict + #expect(WorkspaceInfo.validateName("NewOrg", existingNames: ["OtherOrg"]) == nil) +} + +// MARK: - ConfigDefaults + +@Test func branchPrefixValidation() { + // Valid prefixes + #expect(ConfigDefaults.isValidBranchPrefix("feature/") == true) + #expect(ConfigDefaults.isValidBranchPrefix("fix/") == true) + #expect(ConfigDefaults.isValidBranchPrefix("") == true) // empty allowed + + // Invalid prefixes + #expect(ConfigDefaults.isValidBranchPrefix("feature branch/") == false) // space + #expect(ConfigDefaults.isValidBranchPrefix("feature~/") == false) // tilde + #expect(ConfigDefaults.isValidBranchPrefix("feature^/") == false) // caret + #expect(ConfigDefaults.isValidBranchPrefix("feature:/") == false) // colon + #expect(ConfigDefaults.isValidBranchPrefix("feature?/") == false) // question mark + #expect(ConfigDefaults.isValidBranchPrefix("feature*/") == false) // asterisk + #expect(ConfigDefaults.isValidBranchPrefix("feature[/") == false) // bracket + #expect(ConfigDefaults.isValidBranchPrefix("feat..ure/") == false) // consecutive dots + #expect(ConfigDefaults.isValidBranchPrefix("feature.") == false) // trailing dot + #expect(ConfigDefaults.isValidBranchPrefix("feature@{/") == false) // @{ +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/NotificationEventTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/NotificationEventTests.swift new file mode 100644 index 0000000..0f21f1c --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/NotificationEventTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +@testable import CrowCore + +@Test func notificationEventAllCasesCount() { + #expect(NotificationEvent.allCases.count == 2) +} + +@Test func notificationEventDefaultSoundsNonEmpty() { + for event in NotificationEvent.allCases { + #expect(!event.defaultSound.isEmpty) + } +} + +@Test func notificationEventDisplayNamesNonEmpty() { + for event in NotificationEvent.allCases { + #expect(!event.displayName.isEmpty) + #expect(!event.description.isEmpty) + } +} + +// MARK: - from() mapping + +@Test func fromStopMapsToTaskComplete() { + #expect(NotificationEvent.from(eventName: "Stop") == .taskComplete) +} + +@Test func fromPreToolUseAskUserQuestionMapsToAgentWaiting() { + #expect(NotificationEvent.from(eventName: "PreToolUse", toolName: "AskUserQuestion") == .agentWaiting) +} + +@Test func fromPreToolUseOtherToolReturnsNil() { + #expect(NotificationEvent.from(eventName: "PreToolUse", toolName: "Bash") == nil) + #expect(NotificationEvent.from(eventName: "PreToolUse") == nil) +} + +@Test func fromPermissionRequestMapsToAgentWaiting() { + #expect(NotificationEvent.from(eventName: "PermissionRequest") == .agentWaiting) +} + +@Test func fromNotificationPermissionPromptMapsToAgentWaiting() { + #expect(NotificationEvent.from(eventName: "Notification", notificationType: "permission_prompt") == .agentWaiting) +} + +@Test func fromNotificationOtherTypeReturnsNil() { + #expect(NotificationEvent.from(eventName: "Notification", notificationType: "info") == nil) + #expect(NotificationEvent.from(eventName: "Notification") == nil) +} + +@Test func fromUnknownEventReturnsNil() { + #expect(NotificationEvent.from(eventName: "Start") == nil) + #expect(NotificationEvent.from(eventName: "PostToolUse") == nil) + #expect(NotificationEvent.from(eventName: "") == nil) +} diff --git a/Packages/CrowCore/Tests/CrowCoreTests/NotificationSettingsTests.swift b/Packages/CrowCore/Tests/CrowCoreTests/NotificationSettingsTests.swift new file mode 100644 index 0000000..aa122a5 --- /dev/null +++ b/Packages/CrowCore/Tests/CrowCoreTests/NotificationSettingsTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import CrowCore + +@Test func notificationSettingsDefaultInit() { + let settings = NotificationSettings() + + #expect(settings.globalMute == false) + #expect(settings.soundEnabled == true) + #expect(settings.systemNotificationsEnabled == true) + + // Every event case should have an entry + for event in NotificationEvent.allCases { + #expect(settings.eventSettings[event] != nil) + } +} + +@Test func notificationSettingsConfigForEventReturnsStored() { + var settings = NotificationSettings() + let custom = EventNotificationConfig(enabled: false, soundEnabled: false, systemNotificationEnabled: false, soundName: "Ping") + settings.eventSettings[.taskComplete] = custom + + let config = settings.config(for: .taskComplete) + #expect(config.enabled == false) + #expect(config.soundName == "Ping") +} + +@Test func notificationSettingsConfigForEventFallback() { + // Create settings with empty eventSettings + let settings = NotificationSettings(eventSettings: [:]) + let config = settings.config(for: .taskComplete) + + // Should fall back to defaults + #expect(config.enabled == true) + #expect(config.soundName == NotificationEvent.taskComplete.defaultSound) +} + +@Test func notificationSettingsRoundTrip() throws { + var settings = NotificationSettings(globalMute: true, soundEnabled: false) + settings.eventSettings[.agentWaiting] = EventNotificationConfig( + enabled: true, + soundEnabled: true, + systemNotificationEnabled: false, + soundName: "Submarine" + ) + + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(NotificationSettings.self, from: data) + + #expect(decoded.globalMute == true) + #expect(decoded.soundEnabled == false) + #expect(decoded.eventSettings[.agentWaiting]?.soundName == "Submarine") + #expect(decoded.eventSettings[.agentWaiting]?.systemNotificationEnabled == false) +} + +@Test func notificationSettingsDecodeMinimalJSON() throws { + // Encode an empty-eventSettings NotificationSettings to get the correct JSON format, + // since Dictionary may encode as an array of key-value pairs. + let original = NotificationSettings(globalMute: true, eventSettings: [:]) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(NotificationSettings.self, from: data) + + #expect(decoded.globalMute == true) + #expect(decoded.eventSettings.isEmpty) + // config(for:) should still return defaults + let config = decoded.config(for: .taskComplete) + #expect(config.enabled == true) +} diff --git a/Packages/CrowPersistence/Package.swift b/Packages/CrowPersistence/Package.swift index b87dc2c..d2add91 100644 --- a/Packages/CrowPersistence/Package.swift +++ b/Packages/CrowPersistence/Package.swift @@ -12,5 +12,6 @@ let package = Package( ], targets: [ .target(name: "CrowPersistence", dependencies: ["CrowCore"]), + .testTarget(name: "CrowPersistenceTests", dependencies: ["CrowPersistence"]), ] ) diff --git a/Packages/CrowPersistence/Sources/CrowPersistence/AppSupportDirectory.swift b/Packages/CrowPersistence/Sources/CrowPersistence/AppSupportDirectory.swift new file mode 100644 index 0000000..3bfaf13 --- /dev/null +++ b/Packages/CrowPersistence/Sources/CrowPersistence/AppSupportDirectory.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Provides the canonical Application Support directory for Crow, performing +/// a one-time migration from the legacy "rm-ai-ide" directory if needed. +enum AppSupportDirectory { + /// `~/Library/Application Support/crow/`, created on first access. + /// If the directory doesn't exist but a legacy `rm-ai-ide` directory does, + /// the legacy directory is copied over automatically. + static let url: URL = { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let crowDir = appSupport.appendingPathComponent("crow", isDirectory: true) + // One-time migration from the pre-rename "rm-ai-ide" directory + let oldDir = appSupport.appendingPathComponent("rm-ai-ide", isDirectory: true) + if !FileManager.default.fileExists(atPath: crowDir.path), + FileManager.default.fileExists(atPath: oldDir.path) { + try? FileManager.default.copyItem(at: oldDir, to: crowDir) + NSLog("[AppSupportDirectory] Migrated data from rm-ai-ide to crow") + } + return crowDir + }() +} diff --git a/Packages/CrowPersistence/Sources/CrowPersistence/ConfigStore.swift b/Packages/CrowPersistence/Sources/CrowPersistence/ConfigStore.swift index c824267..f34d61c 100644 --- a/Packages/CrowPersistence/Sources/CrowPersistence/ConfigStore.swift +++ b/Packages/CrowPersistence/Sources/CrowPersistence/ConfigStore.swift @@ -2,21 +2,17 @@ import Foundation import CrowCore /// Manages the devRoot pointer and workspace config. +/// +/// Storage is split across two locations: +/// - **App Support** (`~/Library/Application Support/crow/devroot`): a plain-text file +/// containing the path to the user's development root directory. +/// - **Dev Root** (`{devRoot}/.claude/config.json`): the full application config (workspaces, +/// defaults, notification preferences, sidebar settings). +/// +/// All files are written with restrictive permissions (0o600 for files, 0o700 for directories) +/// because the config may contain host names and workspace layout details. public final class ConfigStore: Sendable { - private static let appSupportDir: URL = { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let crowDir = appSupport.appendingPathComponent("crow", isDirectory: true) - // One-time migration: copy data from old "rm-ai-ide" directory if it exists - let oldDir = appSupport.appendingPathComponent("rm-ai-ide", isDirectory: true) - if !FileManager.default.fileExists(atPath: crowDir.path), - FileManager.default.fileExists(atPath: oldDir.path) { - try? FileManager.default.copyItem(at: oldDir, to: crowDir) - NSLog("[ConfigStore] Migrated data from rm-ai-ide to crow") - } - return crowDir - }() - - private static let devRootPointerPath: URL = appSupportDir.appendingPathComponent("devroot") + private static let devRootPointerPath: URL = AppSupportDirectory.url.appendingPathComponent("devroot") // MARK: - devRoot Pointer @@ -32,6 +28,7 @@ public final class ConfigStore: Sendable { /// Write the devRoot path. public static func saveDevRoot(_ path: String) throws { + let appSupportDir = AppSupportDirectory.url try FileManager.default.createDirectory(at: appSupportDir, withIntermediateDirectories: true) // Restrict app support directory to owner-only access try FileManager.default.setAttributes( @@ -43,19 +40,42 @@ public final class ConfigStore: Sendable { // MARK: - App Config - /// Load config from {devRoot}/.claude/config.json + /// Load config from `{devRoot}/.claude/config.json`. + /// + /// Returns `nil` if the file doesn't exist or can't be decoded. Decode errors + /// are logged so malformed configs are diagnosable. public static func loadConfig(devRoot: String) -> AppConfig? { let configURL = URL(fileURLWithPath: devRoot) .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("config.json") + return loadConfig(from: configURL) + } + + /// Load config from an explicit URL (internal, exposed for testing via @testable). + static func loadConfig(from configURL: URL) -> AppConfig? { guard let data = try? Data(contentsOf: configURL) else { return nil } - return try? JSONDecoder().decode(AppConfig.self, from: data) + do { + return try JSONDecoder().decode(AppConfig.self, from: data) + } catch { + NSLog("[ConfigStore] Failed to decode config at %@: %@", configURL.path, error.localizedDescription) + return nil + } } - /// Save config to {devRoot}/.claude/config.json + /// Save config to `{devRoot}/.claude/config.json`. + /// + /// Creates the `.claude/` directory if needed. Both the directory (0o700) and the + /// config file (0o600) are restricted to owner-only access. public static func saveConfig(_ config: AppConfig, devRoot: String) throws { let claudeDir = URL(fileURLWithPath: devRoot).appendingPathComponent(".claude", isDirectory: true) + try saveConfig(config, to: claudeDir) + } + + /// Save config to an explicit directory (internal, exposed for testing via @testable). + static func saveConfig(_ config: AppConfig, to claudeDir: URL) throws { try FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: claudeDir.path) let configURL = claudeDir.appendingPathComponent("config.json") let encoder = JSONEncoder() @@ -68,7 +88,11 @@ public final class ConfigStore: Sendable { // MARK: - Import from CMUX workspace-repos.json - /// Try to import config from ~/.claude/workspace-repos.json (CMUX format). + /// Import config from `~/.claude/workspace-repos.json` (legacy CMUX format). + /// + /// CMUX was the predecessor tool. This imports workspace names, providers, CLI tools, + /// hosts, and always-include repos. Fields that don't exist in Crow's config model + /// (`worktreePattern`, `keywordSources`) are silently dropped. public static func importFromCMUX() -> (devRoot: String, config: AppConfig)? { let home = FileManager.default.homeDirectoryForCurrentUser let cmuxPath = home.appendingPathComponent(".claude/workspace-repos.json") diff --git a/Packages/CrowPersistence/Sources/CrowPersistence/JSONStore.swift b/Packages/CrowPersistence/Sources/CrowPersistence/JSONStore.swift index 0b45db7..6302084 100644 --- a/Packages/CrowPersistence/Sources/CrowPersistence/JSONStore.swift +++ b/Packages/CrowPersistence/Sources/CrowPersistence/JSONStore.swift @@ -34,18 +34,7 @@ public final class JSONStore: Sendable { } public init(directory: URL? = nil) { - let dir = directory ?? { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let crowDir = appSupport.appendingPathComponent("crow", isDirectory: true) - // One-time migration: copy data from old "rm-ai-ide" directory if it exists - let oldDir = appSupport.appendingPathComponent("rm-ai-ide", isDirectory: true) - if !FileManager.default.fileExists(atPath: crowDir.path), - FileManager.default.fileExists(atPath: oldDir.path) { - try? FileManager.default.copyItem(at: oldDir, to: crowDir) - NSLog("[JSONStore] Migrated data from rm-ai-ide to crow") - } - return crowDir - }() + let dir = directory ?? AppSupportDirectory.url try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) self.fileURL = dir.appendingPathComponent("store.json") diff --git a/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift b/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift new file mode 100644 index 0000000..75f2ab0 --- /dev/null +++ b/Packages/CrowPersistence/Tests/CrowPersistenceTests/ConfigStoreTests.swift @@ -0,0 +1,83 @@ +import Foundation +import Testing +@testable import CrowPersistence +@testable import CrowCore + +@Test func configStoreRoundTrip() throws { + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let claudeDir = tmpDir.appendingPathComponent(".claude", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let config = AppConfig( + workspaces: [WorkspaceInfo(name: "TestOrg")], + defaults: ConfigDefaults(branchPrefix: "fix/"), + notifications: NotificationSettings(globalMute: true), + sidebar: SidebarSettings(hideSessionDetails: true) + ) + + try ConfigStore.saveConfig(config, to: claudeDir) + + let configURL = claudeDir.appendingPathComponent("config.json") + let loaded = ConfigStore.loadConfig(from: configURL) + + #expect(loaded != nil) + #expect(loaded?.workspaces.count == 1) + #expect(loaded?.workspaces.first?.name == "TestOrg") + #expect(loaded?.defaults.branchPrefix == "fix/") + #expect(loaded?.notifications.globalMute == true) + #expect(loaded?.sidebar.hideSessionDetails == true) +} + +@Test func configStoreLoadMissingFileReturnsNil() { + let missingURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent("config.json") + let result = ConfigStore.loadConfig(from: missingURL) + #expect(result == nil) +} + +@Test func configStoreLoadMalformedJSONReturnsNil() throws { + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tmpDir) } + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + + let configURL = tmpDir.appendingPathComponent("config.json") + try "not valid json {{{".write(to: configURL, atomically: true, encoding: .utf8) + + let result = ConfigStore.loadConfig(from: configURL) + #expect(result == nil) +} + +@Test func configStoreSaveCreatesDirectory() throws { + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let claudeDir = tmpDir.appendingPathComponent(".claude", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + #expect(!FileManager.default.fileExists(atPath: claudeDir.path)) + + try ConfigStore.saveConfig(AppConfig(), to: claudeDir) + + #expect(FileManager.default.fileExists(atPath: claudeDir.path)) + let configURL = claudeDir.appendingPathComponent("config.json") + #expect(FileManager.default.fileExists(atPath: configURL.path)) +} + +@Test func configStoreSaveSetsPermissions() throws { + let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let claudeDir = tmpDir.appendingPathComponent(".claude", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + try ConfigStore.saveConfig(AppConfig(), to: claudeDir) + + let configURL = claudeDir.appendingPathComponent("config.json") + + // Check file permissions (0o600 = owner read/write only) + let fileAttrs = try FileManager.default.attributesOfItem(atPath: configURL.path) + let filePerms = fileAttrs[.posixPermissions] as? Int + #expect(filePerms == 0o600) + + // Check directory permissions (0o700 = owner read/write/execute only) + let dirAttrs = try FileManager.default.attributesOfItem(atPath: claudeDir.path) + let dirPerms = dirAttrs[.posixPermissions] as? Int + #expect(dirPerms == 0o700) +} diff --git a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift index dae62d9..2da36ab 100644 --- a/Packages/CrowUI/Sources/CrowUI/SettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SettingsView.swift @@ -2,6 +2,10 @@ import SwiftUI import CrowCore /// Settings panel accessible via Cmd+, +/// +/// Three-tab interface: General (devRoot, defaults, sidebar), Workspaces (list + add/edit), +/// and Notifications (global + per-event config). Every change is persisted immediately +/// via the `onSave` callback — there is no explicit "Apply" button. public struct SettingsView: View { @State var devRoot: String @State var config: AppConfig @@ -20,6 +24,13 @@ public struct SettingsView: View { self.onRescaffold = onRescaffold } + /// Names of all workspaces except the one currently being edited. + private func otherWorkspaceNames(excluding id: UUID? = nil) -> [String] { + config.workspaces + .filter { $0.id != id } + .map(\.name) + } + public var body: some View { TabView { generalTab @@ -31,13 +42,18 @@ public struct SettingsView: View { } .frame(width: 520, height: 480) .sheet(isPresented: $isAddingWorkspace) { - WorkspaceEditorView(workspace: nil) { ws in + WorkspaceFormView( + existingNames: otherWorkspaceNames() + ) { ws in config.workspaces.append(ws) save() } } .sheet(item: $editingWorkspace) { ws in - WorkspaceEditorView(workspace: ws) { updated in + WorkspaceFormView( + workspace: ws, + existingNames: otherWorkspaceNames(excluding: ws.id) + ) { updated in if let idx = config.workspaces.firstIndex(where: { $0.id == updated.id }) { config.workspaces[idx] = updated save() @@ -82,6 +98,12 @@ public struct SettingsView: View { TextField("Branch Prefix", text: $config.defaults.branchPrefix) .textFieldStyle(.roundedBorder) .onSubmit { save() } + + if !ConfigDefaults.isValidBranchPrefix(config.defaults.branchPrefix) { + Text("Contains characters invalid in git branch names.") + .font(.caption) + .foregroundStyle(.orange) + } } Section("Sidebar") { @@ -164,75 +186,3 @@ public struct SettingsView: View { onSave?(devRoot, config) } } - -// MARK: - Workspace Editor - -public struct WorkspaceEditorView: View { - @Environment(\.dismiss) private var dismiss - @State private var name: String - @State private var provider: String - @State private var host: String - @State private var alwaysIncludeText: String - - private let existingID: UUID? - private let onSave: (WorkspaceInfo) -> Void - - public init(workspace: WorkspaceInfo?, onSave: @escaping (WorkspaceInfo) -> Void) { - self.existingID = workspace?.id - self._name = State(initialValue: workspace?.name ?? "") - self._provider = State(initialValue: workspace?.provider ?? "github") - self._host = State(initialValue: workspace?.host ?? "") - self._alwaysIncludeText = State(initialValue: workspace?.alwaysInclude.joined(separator: ", ") ?? "") - self.onSave = onSave - } - - public var body: some View { - Form { - Section("Workspace") { - TextField("Name", text: $name) - .textFieldStyle(.roundedBorder) - - Picker("Provider", selection: $provider) { - Text("GitHub").tag("github") - Text("GitLab").tag("gitlab") - } - - if provider == "gitlab" { - TextField("GitLab Host (e.g., gitlab.example.com)", text: $host) - .textFieldStyle(.roundedBorder) - } - - TextField("Always Include Repos", text: $alwaysIncludeText) - .textFieldStyle(.roundedBorder) - } - } - .formStyle(.grouped) - .safeAreaInset(edge: .bottom) { - HStack { - Button("Cancel") { dismiss() } - Spacer() - Button("Save") { - let alwaysInclude = alwaysIncludeText - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - - let ws = WorkspaceInfo( - id: existingID ?? UUID(), - name: name.trimmingCharacters(in: .whitespaces), - provider: provider, - cli: provider == "github" ? "gh" : "glab", - host: provider == "gitlab" && !host.isEmpty ? host : nil, - alwaysInclude: alwaysInclude - ) - onSave(ws) - dismiss() - } - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) - .keyboardShortcut(.defaultAction) - } - .padding() - } - .frame(width: 400, height: 280) - } -} diff --git a/Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift b/Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift index ea6c637..7586bda 100644 --- a/Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift +++ b/Packages/CrowUI/Sources/CrowUI/SetupWizardView.swift @@ -66,7 +66,11 @@ public struct SetupWizardView: View { } .frame(width: 520, height: 420) .sheet(isPresented: $isAddingWorkspace) { - WorkspaceFormSheet(workspaces: $workspaces) + WorkspaceFormView( + existingNames: workspaces.map(\.name) + ) { ws in + workspaces.append(ws) + } } } @@ -209,54 +213,3 @@ public struct SetupWizardView: View { onComplete?(devRoot, config) } } - -// MARK: - Workspace Form Sheet - -struct WorkspaceFormSheet: View { - @Binding var workspaces: [WorkspaceInfo] - @Environment(\.dismiss) private var dismiss - @State private var name = "" - @State private var provider = "github" - @State private var host = "" - - var body: some View { - VStack(spacing: 16) { - Text("Add Workspace") - .font(.title3) - .fontWeight(.semibold) - - TextField("Name (e.g., RadiusMethod)", text: $name) - .textFieldStyle(.roundedBorder) - - Picker("Provider", selection: $provider) { - Text("GitHub").tag("github") - Text("GitLab").tag("gitlab") - } - .pickerStyle(.segmented) - - if provider == "gitlab" { - TextField("GitLab host (e.g., gitlab.example.com)", text: $host) - .textFieldStyle(.roundedBorder) - } - - HStack { - Button("Cancel") { dismiss() } - Spacer() - Button("Add") { - let ws = WorkspaceInfo( - name: name, - provider: provider, - cli: provider == "github" ? "gh" : "glab", - host: provider == "gitlab" && !host.isEmpty ? host : nil - ) - workspaces.append(ws) - dismiss() - } - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) - .keyboardShortcut(.defaultAction) - } - } - .padding(20) - .frame(width: 360) - } -} diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift new file mode 100644 index 0000000..07d70cd --- /dev/null +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -0,0 +1,104 @@ +import SwiftUI +import CrowCore + +/// Shared form for creating or editing a workspace. +/// +/// Used by both the Settings workspace editor and the Setup Wizard. +/// Handles name/provider/host/alwaysInclude fields, validation, and +/// construction of a `WorkspaceInfo` value on save. +public struct WorkspaceFormView: View { + @Environment(\.dismiss) private var dismiss + @State private var name: String + @State private var provider: String + @State private var host: String + @State private var alwaysIncludeText: String + + private let existingID: UUID? + private let existingNames: [String] + private let onSave: (WorkspaceInfo) -> Void + + /// - Parameters: + /// - workspace: An existing workspace to edit, or `nil` to create a new one. + /// - existingNames: Names of other workspaces, used for duplicate detection. + /// - onSave: Called with the validated `WorkspaceInfo` when the user taps Save/Add. + public init( + workspace: WorkspaceInfo? = nil, + existingNames: [String] = [], + onSave: @escaping (WorkspaceInfo) -> Void + ) { + self.existingID = workspace?.id + self._name = State(initialValue: workspace?.name ?? "") + self._provider = State(initialValue: workspace?.provider ?? "github") + self._host = State(initialValue: workspace?.host ?? "") + self._alwaysIncludeText = State(initialValue: workspace?.alwaysInclude.joined(separator: ", ") ?? "") + self.existingNames = existingNames + self.onSave = onSave + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespaces) + } + + private var nameValidationError: String? { + WorkspaceInfo.validateName(trimmedName, existingNames: existingNames) + } + + public var body: some View { + Form { + Section("Workspace") { + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + + if let error = nameValidationError, !trimmedName.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Picker("Provider", selection: $provider) { + Text("GitHub").tag("github") + Text("GitLab").tag("gitlab") + } + + if provider == "gitlab" { + TextField("GitLab Host (e.g., gitlab.example.com)", text: $host) + .textFieldStyle(.roundedBorder) + } + + TextField("Always Include Repos", text: $alwaysIncludeText) + .textFieldStyle(.roundedBorder) + Text("Comma-separated list of repos to always show in the workspace prompt.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .safeAreaInset(edge: .bottom) { + HStack { + Button("Cancel") { dismiss() } + Spacer() + Button(existingID != nil ? "Save" : "Add") { + let alwaysInclude = alwaysIncludeText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + let ws = WorkspaceInfo( + id: existingID ?? UUID(), + name: trimmedName, + provider: provider, + cli: provider == "github" ? "gh" : "glab", + host: provider == "gitlab" && !host.isEmpty ? host : nil, + alwaysInclude: alwaysInclude + ) + onSave(ws) + dismiss() + } + .disabled(nameValidationError != nil) + .keyboardShortcut(.defaultAction) + } + .padding() + } + .frame(width: 400, height: 300) + } +} diff --git a/Sources/Crow/App/AppDelegate.swift b/Sources/Crow/App/AppDelegate.swift index 461d93d..91e3bd8 100644 --- a/Sources/Crow/App/AppDelegate.swift +++ b/Sources/Crow/App/AppDelegate.swift @@ -332,8 +332,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func saveSettings(devRoot: String, config: AppConfig) { self.devRoot = devRoot self.appConfig = config - try? ConfigStore.saveDevRoot(devRoot) - try? ConfigStore.saveConfig(config, devRoot: devRoot) + do { + try ConfigStore.saveDevRoot(devRoot) + } catch { + NSLog("[Crow] Failed to save devRoot: %@", error.localizedDescription) + } + do { + try ConfigStore.saveConfig(config, devRoot: devRoot) + } catch { + NSLog("[Crow] Failed to save config: %@", error.localizedDescription) + } notificationManager?.updateSettings(config.notifications) appState.hideSessionDetails = config.sidebar.hideSessionDetails }