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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 63 additions & 7 deletions Packages/CrowCore/Sources/CrowCore/Models/AppConfig.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
127 changes: 127 additions & 0 deletions Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift
Original file line number Diff line number Diff line change
@@ -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) // @{
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<Enum, Value> 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)
}
Loading
Loading