From 229ece6adc2da6ec2d21193961fcc9592c949be2 Mon Sep 17 00:00:00 2001
From: Seungyeop Yeom <dev.yeom@gmail.com>
Date: Sun, 9 Nov 2025 12:47:22 +0900
Subject: [PATCH] Add logging for actions and state changes

---
 Sources/OneWay/LoggingOptions.swift    | 29 +++++++++++++
 Sources/OneWay/Store.swift             | 56 ++++++++++++++++++++++++++
 Sources/OneWay/ViewStore.swift         | 27 +++++++++++++
 Tests/OneWayTests/StoreTests.swift     | 13 ++++++
 Tests/OneWayTests/ViewStoreTests.swift | 11 +++++
 5 files changed, 136 insertions(+)
 create mode 100644 Sources/OneWay/LoggingOptions.swift

diff --git a/Sources/OneWay/LoggingOptions.swift b/Sources/OneWay/LoggingOptions.swift
new file mode 100644
index 0000000..121077e
--- /dev/null
+++ b/Sources/OneWay/LoggingOptions.swift
@@ -0,0 +1,29 @@
+//
+//  OneWay
+//  The MIT License (MIT)
+//
+//  Copyright (c) 2022-2025 SeungYeop Yeom ( https://github.com/DevYeom ).
+//
+
+import Foundation
+
+/// A set of options that determines what information is logged by a ``Store``.
+public struct LoggingOptions: OptionSet, Sendable {
+    public let rawValue: Int
+
+    public init(rawValue: Int) {
+        self.rawValue = rawValue
+    }
+
+    /// Logs all actions sent to the store.
+    public static let action = LoggingOptions(rawValue: 1 << 0)
+
+    /// Logs all state changes in the store.
+    public static let state = LoggingOptions(rawValue: 1 << 1)
+
+    /// Logs all actions and state changes.
+    public static let all: LoggingOptions = [.action, .state]
+
+    /// Disables all logging.
+    public static let none: LoggingOptions = []
+}
diff --git a/Sources/OneWay/Store.swift b/Sources/OneWay/Store.swift
index 9b1f619..e80306b 100644
--- a/Sources/OneWay/Store.swift
+++ b/Sources/OneWay/Store.swift
@@ -8,6 +8,9 @@
 #if canImport(Foundation)
 import Foundation
 #endif
+#if canImport(OSLog)
+import OSLog
+#endif
 
 /// `Store` is an actor that holds and manages state values.
 ///
@@ -32,6 +35,16 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
         didSet {
             if oldValue != state {
                 continuation.yield(state)
+                #if canImport(OSLog) && DEBUG
+                if loggingOptions.contains(.state) {
+                    let timestamp = Date.now.formatted(iso8601FormatStyle)
+                    logger.debug("""
+                    [\(timestamp)] State changed:
+                    - \(String(describing: oldValue))
+                    + \(String(describing: self.state))
+                    """)
+                }
+                #endif
             }
         }
     }
@@ -48,6 +61,10 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
 
     private let reducer: R
     private let clock: C
+    #if canImport(OSLog) && DEBUG
+    private let logger = Logger(subsystem: "com.devyeom.oneway", category: "Store")
+    #endif
+    private var loggingOptions = LoggingOptions.none
     private let continuation: AsyncStream<State>.Continuation
     private var isProcessing: Bool = false
     private var actionQueue: [Action] = []
@@ -63,16 +80,19 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
     ///   - reducer: The reducer responsible for transitioning the current state to the next
     ///     state in response to actions.
     ///   - state: The initial state used to create the store.
+    ///   - loggingOptions: A set of options for logging. Defaults to `none`.
     ///   - clock: The clock that determines how time-based effects (such as debounce or throttle)
     ///     are scheduled. Defaults to `ContinuousClock`.
     public init(
         reducer: @Sendable @autoclosure () -> R,
         state: State,
+        loggingOptions: LoggingOptions = .none,
         clock: C = ContinuousClock()
     ) {
         self.initialState = state
         self.state = state
         self.reducer = reducer()
+        self.loggingOptions = loggingOptions
         self.clock = clock
         (states, continuation) = AsyncStream<State>.makeStream()
         Task { await bindExternalEffect() }
@@ -93,6 +113,12 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
         isProcessing = true
         await Task.yield()
         for action in actionQueue {
+            #if canImport(OSLog) && DEBUG
+            if loggingOptions.contains(.action) {
+                let timestamp = Date.now.formatted(iso8601FormatStyle)
+                logger.debug("[\(timestamp)] Action: \(String(describing: action))")
+            }
+            #endif
             let effect = reducer.reduce(state: &state, action: action)
             let isThrottled = await throttleIfNeeded(for: effect)
             if !isThrottled {
@@ -117,6 +143,26 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
         throttleTimestamps.removeAll()
     }
 
+    /// Sets the logging options for the store to control what information is logged.
+    ///
+    /// You can use this method to dynamically change the logging behavior of the store after it
+    /// has been initialized. For example, you might want to enable logging only for certain
+    /// user interactions or when debugging a specific issue.
+    ///
+    /// ```swift
+    /// // Enable logging for both actions and state changes.
+    /// await store.debug(.all)
+    ///
+    /// // Disable all logging.
+    /// await store.debug(.none)
+    /// ```
+    ///
+    /// - Parameter loggingOptions: A set of `LoggingOptions` that determines what information
+    ///   is logged.
+    public func debug(_ loggingOptions: LoggingOptions) {
+        self.loggingOptions = loggingOptions
+    }
+
     private func throttleIfNeeded(for effect: AnyEffect<Action>) async -> Bool {
         guard case let .throttle(id, interval, latest) = effect.method else {
             return false
@@ -215,3 +261,13 @@ private struct EffectIDWrapper: Hashable, @unchecked Sendable {
         self.id = id
     }
 }
+
+#if canImport(OSLog) && DEBUG
+private let iso8601FormatStyle = Date.ISO8601FormatStyle()
+    .year()
+    .month()
+    .day()
+    .timeZone(separator: .omitted)
+    .time(includingFractionalSeconds: true)
+    .timeSeparator(.colon)
+#endif
diff --git a/Sources/OneWay/ViewStore.swift b/Sources/OneWay/ViewStore.swift
index fd7a542..5e9c15e 100644
--- a/Sources/OneWay/ViewStore.swift
+++ b/Sources/OneWay/ViewStore.swift
@@ -101,6 +101,33 @@ where R.Action: Sendable, R.State: Sendable & Equatable {
             await store.reset()
         }
     }
+
+    /// Sets the logging options for the store to control what information is logged.
+    ///
+    /// You can use this method to dynamically change the logging behavior of the store after it
+    /// has been initialized. For example, you might want to enable logging only for certain
+    /// user interactions or when debugging a specific issue.
+    ///
+    /// ```swift
+    /// // Enable logging for both actions and state changes.
+    /// @StateObject private var store = ViewStore(
+    ///     reducer: HomeReducer(),
+    ///     state: HomeReducer.State()
+    /// )
+    /// .debug(.all)
+    ///
+    /// // Disable all logging.
+    /// store.debug(.none)
+    /// ```
+    ///
+    /// - Parameter loggingOptions: A set of `LoggingOptions` that determines what information
+    ///   is logged.
+    public func debug(_ loggingOptions: LoggingOptions) -> Self {
+        Task { @MainActor in
+            await store.debug(loggingOptions)
+        }
+        return self
+    }
 }
 
 #if canImport(Combine)
diff --git a/Tests/OneWayTests/StoreTests.swift b/Tests/OneWayTests/StoreTests.swift
index 9be0f12..4853f9e 100644
--- a/Tests/OneWayTests/StoreTests.swift
+++ b/Tests/OneWayTests/StoreTests.swift
@@ -50,6 +50,7 @@ final class StoreTests: XCTestCase {
     }
 
     func test_sendSeveralActions() async {
+        await sut.debug(.all)
         await sut.send(.increment)
         await sut.send(.increment)
         await sut.send(.twice)
@@ -236,6 +237,18 @@ final class StoreTests: XCTestCase {
         await clock.advance(by: .seconds(100))
         await sut.expect(\.count, 4)
     }
+
+    func test_logging_options() async {
+        let all = Store(
+            reducer: TestReducer(clock: TestClock()),
+            state: TestReducer.State(count: 0, text: ""),
+            loggingOptions: .all
+        )
+        await all.debug(.all)
+        await all.debug(.none)
+        await all.debug(.action)
+        await all.debug(.state)
+    }
 }
 
 #if canImport(Combine)
diff --git a/Tests/OneWayTests/ViewStoreTests.swift b/Tests/OneWayTests/ViewStoreTests.swift
index 7c7038f..7015689 100644
--- a/Tests/OneWayTests/ViewStoreTests.swift
+++ b/Tests/OneWayTests/ViewStoreTests.swift
@@ -178,6 +178,17 @@ final class ViewStoreTests: XCTestCase {
             ]
         )
     }
+
+    func test_logging_options() async {
+        let _ = await ViewStore(
+            reducer: TestReducer(),
+            state: TestReducer.State(count: 0)
+        )
+        .debug(.all)
+        .debug(.none)
+        .debug(.action)
+        .debug(.state)
+    }
 }
 
 private struct TestReducer: Reducer {
