diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd19f6d..f1b6e07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+- Provide FileLogger for persistent logging to a file path with rotation and retention policy support.
## [1.2.5] - 2024-03-23
- Provide javadoc artifacts for Sonatype Maven Central
@@ -13,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Allow empty log messages if you only want to create a log entry about a function being called.
- Add more Apple ARM64 platforms: macOS, tvOS, watchOS
+- Provide FileLogger in addition to ConsoleLogger
- Dependency update:
- [Kotlin 1.9.23](https://kotlinlang.org/docs/whatsnew19.html)
- [Gradle-8.7](https://docs.gradle.org/8.7/release-notes.html)
diff --git a/README.md b/README.md
index fad039c..e880dcc 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,25 @@ class MyApplication : Application() {
}
```
+## Logging to a file
+
+By default, the library only logs to the current platform's console.
+Additionally or instead, add one or multiple file loggers:
+
+```kotlin
+// Log with daily rotation and keep five log files at max:
+Log.loggers += FileLogger(rotate = Rotate.Daily, limit = Limit.Files(max = 5))
+
+// Log to a custom path and rotate every 1000 lines written:
+Log.loggers += FileLogger(rotate = Rotate.After(lines = 1000), logPath = "myLogPath")
+
+// Log with sensible defaults (daily, keep 10 files)
+Log.loggers += FileLogger()
+
+// On huge eternal log file:
+Log.loggers += FileLogger(rotate = Rotate.Never, limit = Limit.Not)
+```
+
## Custom logger (Android Crashlytics example)
The library provides a cross-platform `ConsoleLogger` by default. Custom loggers can easily be added. For instance, to
send only `ERROR` and `ASSERT` messages to Crashlytics in production builds, you could do the following:
diff --git a/log4k/build.gradle.kts b/log4k/build.gradle.kts
index 30b8b22..17b4eda 100644
--- a/log4k/build.gradle.kts
+++ b/log4k/build.gradle.kts
@@ -19,6 +19,10 @@ kotlin {
applyDefaultHierarchyTemplate()
sourceSets {
+ commonMain.dependencies {
+ implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0-RC.2")
+ implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.3.2")
+ }
commonTest.dependencies {
implementation(kotlin("test"))
}
diff --git a/log4k/src/androidMain/AndroidManifest.xml b/log4k/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000..4a37036
--- /dev/null
+++ b/log4k/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/log4k/src/androidMain/kotlin/saschpe/log4k/FileLogger.android.kt b/log4k/src/androidMain/kotlin/saschpe/log4k/FileLogger.android.kt
new file mode 100644
index 0000000..f749348
--- /dev/null
+++ b/log4k/src/androidMain/kotlin/saschpe/log4k/FileLogger.android.kt
@@ -0,0 +1,15 @@
+package saschpe.log4k
+
+import kotlinx.io.files.Path
+import saschpe.log4k.internal.ContextProvider.Companion.applicationContext
+import java.io.File
+
+internal actual val defaultLogPath: Path
+ get() = Path(applicationContext.cacheDir.path)
+
+internal actual fun limitFolderToFilesByCreationTime(path: String, limit: Int) {
+ File(path).listFiles()?.sorted()?.run {
+ val overhead = kotlin.math.max(0, count() - limit)
+ take(overhead).forEach { it.delete() }
+ }
+}
diff --git a/log4k/src/androidMain/kotlin/saschpe/log4k/internal/ContextProvider.kt b/log4k/src/androidMain/kotlin/saschpe/log4k/internal/ContextProvider.kt
new file mode 100644
index 0000000..a1fa666
--- /dev/null
+++ b/log4k/src/androidMain/kotlin/saschpe/log4k/internal/ContextProvider.kt
@@ -0,0 +1,46 @@
+package saschpe.log4k.internal
+
+import android.annotation.SuppressLint
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+
+/**
+ * Hidden initialization content provider.
+ *
+ * Does not provide real content but hides initialization boilerplate from the library user.
+ *
+ * @link https://firebase.googleblog.com/2016/12/how-does-firebase-initialize-on-android.html
+ */
+internal class ContextProvider : ContentProvider() {
+ /**
+ * Called exactly once before Application.onCreate()
+ */
+ override fun onCreate(): Boolean {
+ applicationContext = context?.applicationContext ?: throw Exception("Need the context")
+ return true
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ args: Array?,
+ sortOrder: String?,
+ ): Cursor? = null
+
+ override fun update(uri: Uri, values: ContentValues?, selection: String?, args: Array?): Int = 0
+
+ override fun delete(uri: Uri, selection: String?, args: Array?): Int = 0
+
+ override fun getType(uri: Uri): String? = null
+
+ companion object {
+ @SuppressLint("StaticFieldLeak")
+ lateinit var applicationContext: Context
+ }
+}
diff --git a/log4k/src/androidUnitTest/kotlin/saschpe/log4k/FileLoggerTest.android.kt b/log4k/src/androidUnitTest/kotlin/saschpe/log4k/FileLoggerTest.android.kt
new file mode 100644
index 0000000..d059f48
--- /dev/null
+++ b/log4k/src/androidUnitTest/kotlin/saschpe/log4k/FileLoggerTest.android.kt
@@ -0,0 +1,12 @@
+package saschpe.log4k
+
+import java.io.File
+
+internal actual val expectedExceptionPackage: String = "java.lang."
+internal actual val expectedTraceTag: String = "FileLoggerTest.testMessages"
+
+internal actual fun deleteRecursively(path: String) {
+ File(path).deleteRecursively()
+}
+
+internal actual fun filesInFolder(path: String): Int = File(path).listFiles()?.size ?: 0
diff --git a/log4k/src/androidUnitTest/kotlin/saschpe/log4k/LoggedTest.android.kt b/log4k/src/androidUnitTest/kotlin/saschpe/log4k/LoggedTest.android.kt
deleted file mode 100644
index 548ef95..0000000
--- a/log4k/src/androidUnitTest/kotlin/saschpe/log4k/LoggedTest.android.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package saschpe.log4k
-
-internal actual val expectedListTag: String = "ArrayList"
-internal actual val expectedMapTag: String = "SingletonMap"
diff --git a/log4k/src/appleMain/kotlin/saschpe/log4k/FileLogger.apple.kt b/log4k/src/appleMain/kotlin/saschpe/log4k/FileLogger.apple.kt
new file mode 100644
index 0000000..41eaba8
--- /dev/null
+++ b/log4k/src/appleMain/kotlin/saschpe/log4k/FileLogger.apple.kt
@@ -0,0 +1,28 @@
+package saschpe.log4k
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.io.files.Path
+import kotlinx.io.files.SystemTemporaryDirectory
+import platform.Foundation.NSFileManager
+import kotlin.math.max
+
+internal actual val defaultLogPath: Path
+ get() = SystemTemporaryDirectory
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual fun limitFolderToFilesByCreationTime(path: String, limit: Int) {
+ val fm = NSFileManager.defaultManager
+
+ @Suppress("UNCHECKED_CAST")
+ val files = fm.contentsOfDirectoryAtPath(path, null) as List?
+ println("limitFolderToFilesByCreationTime(path=$path, limit=$limit): files=$files")
+
+ files?.sorted()?.run {
+ val overhead = max(0, size - limit)
+ println("limitFolderToFilesByCreationTime(path=$path, limit=$limit): overhead=$overhead")
+ take(overhead).forEach {
+ println("limitFolderToFilesByCreationTime(path=$path, limit=$limit): remove $it")
+ fm.removeItemAtPath("$path/$it", null)
+ }
+ }
+}
diff --git a/log4k/src/appleTest/kotlin/saschpe/log4k/FileLoggerTest.apple.kt b/log4k/src/appleTest/kotlin/saschpe/log4k/FileLoggerTest.apple.kt
new file mode 100644
index 0000000..605199b
--- /dev/null
+++ b/log4k/src/appleTest/kotlin/saschpe/log4k/FileLoggerTest.apple.kt
@@ -0,0 +1,16 @@
+package saschpe.log4k
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import platform.Foundation.NSFileManager
+
+internal actual val expectedExceptionPackage: String = "kotlin."
+internal actual val expectedTraceTag: String = "XXX"
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual fun deleteRecursively(path: String) {
+ NSFileManager.defaultManager.removeItemAtPath(path, error = null)
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual fun filesInFolder(path: String): Int =
+ NSFileManager.defaultManager.contentsOfDirectoryAtPath(path, null)?.count() ?: 0
diff --git a/log4k/src/appleTest/kotlin/saschpe/log4k/LoggedTest.apple.kt b/log4k/src/appleTest/kotlin/saschpe/log4k/LoggedTest.apple.kt
deleted file mode 100644
index f9c9352..0000000
--- a/log4k/src/appleTest/kotlin/saschpe/log4k/LoggedTest.apple.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package saschpe.log4k
-
-internal actual val expectedListTag: String = ""
-internal actual val expectedMapTag: String = "HashMap"
diff --git a/log4k/src/commonMain/kotlin/saschpe/log4k/FileLogger.kt b/log4k/src/commonMain/kotlin/saschpe/log4k/FileLogger.kt
new file mode 100644
index 0000000..2a5da82
--- /dev/null
+++ b/log4k/src/commonMain/kotlin/saschpe/log4k/FileLogger.kt
@@ -0,0 +1,143 @@
+package saschpe.log4k
+
+import kotlinx.datetime.*
+import kotlinx.datetime.format.char
+import kotlinx.io.buffered
+import kotlinx.io.files.Path
+import kotlinx.io.files.SystemFileSystem
+import kotlinx.io.writeString
+import saschpe.log4k.FileLogger.Limit
+import saschpe.log4k.FileLogger.Rotate
+
+internal expect val defaultLogPath: Path
+internal expect fun limitFolderToFilesByCreationTime(path: String, limit: Int)
+
+/**
+ * Log to files in [logPath] with [rotate] rotation and imposed retention [limit].
+ *
+ * @param rotate Log file rotation, defaults to [Rotate.Daily]
+ * @param limit Log file retention limit, defaults to [Limit.Files]
+ * @param logPath Log file path, defaults to platform-specific temporary directory [defaultLogPath]
+ */
+class FileLogger(
+ private val rotate: Rotate = Rotate.Daily,
+ private val limit: Limit = Limit.Files(10),
+ private val logPath: Path = defaultLogPath,
+) : Logger() {
+ constructor(
+ rotation: Rotate = Rotate.Daily,
+ limit: Limit = Limit.Files(),
+ logPath: String,
+ ) : this(rotation, limit, Path(logPath))
+
+ init {
+ SystemFileSystem.createDirectories(logPath)
+ }
+
+ override fun print(level: Log.Level, tag: String, message: String?, throwable: Throwable?) {
+ val logTag = tag.ifEmpty { getTraceTag() }
+ SystemFileSystem.sink(rotate.logFile(logPath), append = true).buffered().apply {
+ writeString("${level.name.first()}/$logTag: $message")
+ throwable?.let { writeString(" $throwable") }
+ writeString("\n")
+ flush()
+ rotate.lineWritten()
+ }
+ limit.enforce(logPath)
+ }
+
+ private fun getTraceTag(): String = try {
+ val callSite = Throwable().stackTraceToString().split("\n")[4]
+ val callSiteCleaned = callSite.split(" ")[1].split("(").first()
+ val kotlinPackageParts = callSiteCleaned.split(".")
+ "${kotlinPackageParts[kotlinPackageParts.size - 2]}.${kotlinPackageParts.last()}"
+ } catch (e: Exception) {
+ "XXX"
+ }
+
+ /**
+ * Log file rotation.
+ */
+ sealed class Rotate {
+ internal abstract fun logFile(logPath: Path): Path
+ internal abstract fun lineWritten()
+
+ /**
+ * Never rotate the log file.
+ *
+ * Use with caution, may lead to a single huge log file.
+ * Subject to the platforms temporary directory cleanup policy.
+ */
+ data object Never : Rotate() {
+ override fun logFile(logPath: Path) = Path(logPath, "log.txt")
+ override fun lineWritten() = Unit
+ }
+
+ /**
+ * Daily log rotation.
+ */
+ data object Daily : Rotate() {
+ override fun logFile(logPath: Path): Path {
+ val today = Clock.System.now().toLocalDateTime(TimeZone.UTC).date
+ return Path(logPath, "log.daily.${LocalDate.Formats.ISO.format(today)}.txt")
+ }
+
+ override fun lineWritten() = Unit
+ }
+
+ /**
+ * Rotate the current log file after [lines] number of lines written.
+ */
+ class After(private val lines: Int = 10000) : Rotate() {
+ private val dateTimeFormat = LocalDateTime.Format {
+ year()
+ monthNumber()
+ dayOfMonth()
+ char('-')
+ hour()
+ minute()
+ second()
+ secondFraction()
+ }
+ private var linesWritten = 0
+
+ override fun logFile(logPath: Path): Path {
+ val logFile = Path(logPath, "log.after_$lines.txt")
+ if (SystemFileSystem.exists(logFile) && linesWritten >= lines) {
+ val now = Clock.System.now().toLocalDateTime(TimeZone.UTC)
+ val renameTo = Path(logPath, "log.after_$lines.${dateTimeFormat.format(now)}.txt")
+ SystemFileSystem.atomicMove(logFile, renameTo)
+ linesWritten = 0
+ }
+ return logFile
+ }
+
+ override fun lineWritten() {
+ linesWritten += 1
+ }
+ }
+ }
+
+ /**
+ * Log file storage limit.
+ */
+ sealed class Limit {
+ internal abstract fun enforce(logPath: Path)
+
+ /**
+ * There's no limit!
+ */
+ data object Not : Limit() {
+ override fun enforce(logPath: Path) = Unit
+ }
+
+ /**
+ * Keep [max] log files in the [logPath].
+ *
+ * @param max Number of files to keep, defaults to 10
+ */
+ class Files(private val max: Int = 10) : Limit() {
+ override fun enforce(logPath: Path) = limitFolderToFilesByCreationTime(logPath.toString(), max)
+ }
+ }
+}
diff --git a/log4k/src/commonTest/kotlin/saschpe/log4k/FileLoggerTest.kt b/log4k/src/commonTest/kotlin/saschpe/log4k/FileLoggerTest.kt
new file mode 100644
index 0000000..6aa307a
--- /dev/null
+++ b/log4k/src/commonTest/kotlin/saschpe/log4k/FileLoggerTest.kt
@@ -0,0 +1,229 @@
+package saschpe.log4k
+
+import kotlinx.datetime.Clock
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlinx.io.buffered
+import kotlinx.io.files.Path
+import kotlinx.io.readString
+import saschpe.log4k.FileLogger.Limit
+import saschpe.log4k.FileLogger.Rotate
+import kotlin.math.log
+import kotlin.test.*
+import kotlinx.io.files.SystemFileSystem as fileSystem
+
+internal expect val expectedExceptionPackage: String
+internal expect val expectedTraceTag: String
+internal expect fun deleteRecursively(path: String)
+internal expect fun filesInFolder(path: String): Int
+
+class FileLoggerTest {
+ @BeforeTest
+ fun beforePaths() {
+ deleteRecursively(TEST_LOG_PATH_STRING)
+ assertEquals(0, filesInFolder(TEST_LOG_PATH_STRING))
+ }
+
+ @Test
+ fun rotate_daily_logFileName() {
+ val today = Clock.System.now().toLocalDateTime(TimeZone.UTC).date
+ val expectedFileName = "log.daily.${LocalDate.Formats.ISO.format(today)}.txt"
+ assertEquals(expectedFileName, TEST_ROTATE_DAILY_LOG_FILE.name, "Like 'log.daily.2024-03-25.txt")
+ assertFalse { TEST_ROTATE_DAILY_LOG_FILE.isAbsolute }
+ }
+
+ @Test
+ fun rotate_after_5_logFileName() {
+ assertEquals("log.after_5.txt", TEST_ROTATE_AFTER_5_LOG_FILE.name)
+ assertFalse { TEST_ROTATE_AFTER_5_LOG_FILE.isAbsolute }
+ }
+
+ @Test
+ fun rotate_after_9_logFileName() {
+ assertEquals("log.after_9.txt", TEST_ROTATE_AFTER_9_LOG_FILE.name)
+ assertFalse { TEST_ROTATE_AFTER_9_LOG_FILE.isAbsolute }
+ }
+
+ @Test
+ fun log_rotate_after_9_lines() {
+ // Arrange
+ val logger = FileLogger(rotation = TEST_ROTATE_AFTER_9, logPath = TEST_LOG_PATH_STRING)
+
+ // Act
+ logger.testMessages()
+
+ // Assert
+ assertTrue { fileSystem.metadataOrNull(TEST_LOG_PATH)?.isDirectory ?: false }
+ assertTrue { fileSystem.exists(TEST_ROTATE_AFTER_9_LOG_FILE) }
+ assertTrue { fileSystem.metadataOrNull(TEST_ROTATE_AFTER_9_LOG_FILE)?.isRegularFile ?: false }
+ assertEquals(
+ "$TEST_ROTATE_AFTER_9_LOG_CONTENT\n",
+ fileSystem.source(TEST_ROTATE_AFTER_9_LOG_FILE).buffered().readString(),
+ )
+ assertEquals(2, filesInFolder(TEST_LOG_PATH_STRING))
+ }
+
+ @Test
+ fun log_rotate_after_5_lines() {
+ // Arrange
+ val logger = FileLogger(rotate = TEST_ROTATE_AFTER_5, logPath = TEST_LOG_PATH)
+
+ // Act
+ logger.testMessages()
+
+ // Assert
+ assertTrue { fileSystem.metadataOrNull(TEST_LOG_PATH)?.isDirectory ?: false }
+ assertTrue { fileSystem.exists(TEST_ROTATE_AFTER_5_LOG_FILE) }
+ assertTrue { fileSystem.metadataOrNull(TEST_ROTATE_AFTER_5_LOG_FILE)?.isRegularFile ?: false }
+ assertEquals(
+ "$TEST_ROTATE_AFTER_5_LOG_CONTENT\n",
+ fileSystem.source(TEST_ROTATE_AFTER_5_LOG_FILE).buffered().readString(),
+ )
+ assertEquals(3, filesInFolder(TEST_LOG_PATH_STRING))
+ }
+
+ @Test
+ fun log_rotate_daily() {
+ // Arrange
+ val logger = FileLogger(rotate = Rotate.Daily, logPath = TEST_LOG_PATH)
+
+ // Act
+ logger.testMessages()
+
+ // Assert
+ assertTrue { fileSystem.metadataOrNull(TEST_LOG_PATH)?.isDirectory ?: false }
+ assertTrue { fileSystem.exists(TEST_ROTATE_DAILY_LOG_FILE) }
+ assertTrue { fileSystem.metadataOrNull(TEST_ROTATE_DAILY_LOG_FILE)?.isRegularFile ?: false }
+ assertEquals(
+ "$TEST_ROTATE_DAILY_LOG_CONTENT\n",
+ fileSystem.source(TEST_ROTATE_DAILY_LOG_FILE).buffered().readString(),
+ )
+ assertEquals(1, filesInFolder(TEST_LOG_PATH_STRING))
+ }
+
+ @Test
+ fun log_rotate_never() {
+ // Arrange
+ val logger = FileLogger(rotate = Rotate.Never, logPath = TEST_LOG_PATH)
+
+ // Act
+ logger.testMessages()
+
+ // Assert
+ val logFile = Rotate.Never.logFile(TEST_LOG_PATH)
+
+ assertTrue { fileSystem.metadataOrNull(TEST_LOG_PATH)?.isDirectory ?: false }
+ assertTrue { fileSystem.exists(logFile) }
+ assertTrue { fileSystem.metadataOrNull(logFile)?.isRegularFile ?: false }
+ assertEquals(
+ "$TEST_ROTATE_DAILY_LOG_CONTENT\n",
+ fileSystem.source(logFile).buffered().readString(),
+ )
+ assertEquals(1, filesInFolder(TEST_LOG_PATH_STRING))
+ }
+
+ @Test
+ fun log_limit_not() {
+ // Arrange
+ val rotate = Rotate.After(3)
+ val logger = FileLogger(rotate, Limit.Not, TEST_LOG_PATH)
+
+ // Act
+ logger.testMessages()
+ logger.testMessages()
+
+ // Assert
+ assertEquals(10, filesInFolder(TEST_LOG_PATH_STRING))
+ assertTrue { fileSystem.exists(rotate.logFile(TEST_LOG_PATH)) }
+ }
+
+ @Test
+ fun log_limit_to_7_files() {
+ // Arrange
+ val rotate = Rotate.After(3)
+ val logger = FileLogger(rotate, Limit.Files(7), TEST_LOG_PATH)
+
+ // Act
+ logger.testMessages()
+ logger.testMessages()
+
+ // Assert
+ assertEquals(7, filesInFolder(TEST_LOG_PATH_STRING))
+ assertTrue { fileSystem.exists(rotate.logFile(TEST_LOG_PATH)) }
+ }
+
+ @Test
+ fun log_limit_to_4_files() {
+ // Arrange
+ val rotate = Rotate.After(3)
+ val logger = FileLogger(rotate, Limit.Files(3), TEST_LOG_PATH)
+
+ // Act
+ logger.testMessages()
+ logger.testMessages()
+
+ // Assert
+ assertEquals(3, filesInFolder(TEST_LOG_PATH_STRING))
+ assertTrue { fileSystem.exists(rotate.logFile(TEST_LOG_PATH)) }
+ }
+
+ private fun Logger.testMessages() {
+ log(Log.Level.Verbose, "TAG1", "Verbose message", null)
+ log(Log.Level.Verbose, "TAG2", "Verbose message", null)
+ log(Log.Level.Verbose, "TAG3", "Verbose message", null)
+ log(Log.Level.Debug, "TAG1", "Debug message", Exception("Test exception!"))
+ log(Log.Level.Debug, "TAG2", "Debug message", Exception("Test exception!"))
+ log(Log.Level.Debug, "TAG3", "Debug message", Exception("Test exception!"))
+ log(Log.Level.Info, "TAG2", "Info message", null)
+ log(Log.Level.Warning, "TAG3", "Warning message", IllegalStateException("Illegal test state!"))
+ log(Log.Level.Error, "TAG1", "Error message", null)
+ log(Log.Level.Error, "TAG2", "Error message", null)
+ log(Log.Level.Error, "", "Error message", null)
+ log(Log.Level.Assert, "TAG1", "Assert message", null)
+ log(Log.Level.Assert, "TAG2", "Assert message", null)
+ log(Log.Level.Assert, "", "Assert message", null)
+ }
+
+ companion object {
+ private val TEST_LOG_PATH_STRING = "build/${FileLogger::class.simpleName}"
+ private val TEST_LOG_PATH = Path(TEST_LOG_PATH_STRING)
+
+ private val TEST_ROTATE_AFTER_5 = Rotate.After(lines = 5)
+ private val TEST_ROTATE_AFTER_5_LOG_FILE = TEST_ROTATE_AFTER_5.logFile(TEST_LOG_PATH)
+ private val TEST_ROTATE_AFTER_5_LOG_CONTENT = """
+ E/$expectedTraceTag: Error message
+ A/TAG1: Assert message
+ A/TAG2: Assert message
+ A/$expectedTraceTag: Assert message
+ """.trimIndent()
+
+ private val TEST_ROTATE_AFTER_9 = Rotate.After(lines = 9)
+ private val TEST_ROTATE_AFTER_9_LOG_FILE = TEST_ROTATE_AFTER_9.logFile(TEST_LOG_PATH)
+ private val TEST_ROTATE_AFTER_9_LOG_CONTENT = """
+ E/TAG2: Error message
+ E/$expectedTraceTag: Error message
+ A/TAG1: Assert message
+ A/TAG2: Assert message
+ A/$expectedTraceTag: Assert message
+ """.trimIndent()
+
+ private val TEST_ROTATE_DAILY_LOG_FILE = Rotate.Daily.logFile(TEST_LOG_PATH)
+ private val TEST_ROTATE_DAILY_LOG_CONTENT = """
+ V/TAG1: Verbose message
+ V/TAG2: Verbose message
+ V/TAG3: Verbose message
+ D/TAG1: Debug message ${expectedExceptionPackage}Exception: Test exception!
+ D/TAG2: Debug message ${expectedExceptionPackage}Exception: Test exception!
+ D/TAG3: Debug message ${expectedExceptionPackage}Exception: Test exception!
+ I/TAG2: Info message
+ W/TAG3: Warning message ${expectedExceptionPackage}IllegalStateException: Illegal test state!
+ E/TAG1: Error message
+ E/TAG2: Error message
+ E/$expectedTraceTag: Error message
+ A/TAG1: Assert message
+ A/TAG2: Assert message
+ A/$expectedTraceTag: Assert message
+ """.trimIndent()
+ }
+}
diff --git a/log4k/src/commonTest/kotlin/saschpe/log4k/LoggedTest.kt b/log4k/src/commonTest/kotlin/saschpe/log4k/LoggedTest.kt
index a833a89..cb4e8d1 100644
--- a/log4k/src/commonTest/kotlin/saschpe/log4k/LoggedTest.kt
+++ b/log4k/src/commonTest/kotlin/saschpe/log4k/LoggedTest.kt
@@ -4,9 +4,6 @@ import testing.TestLoggerTest
import kotlin.test.Test
import kotlin.test.assertEquals
-internal expect val expectedListTag: String
-internal expect val expectedMapTag: String
-
class LoggedTest : TestLoggerTest() {
@Test
fun logged_Pair() {
diff --git a/log4k/src/commonTest/kotlin/saschpe/log4k/MultipleLoggersTest.kt b/log4k/src/commonTest/kotlin/saschpe/log4k/MultipleLoggersTest.kt
new file mode 100644
index 0000000..f5095ad
--- /dev/null
+++ b/log4k/src/commonTest/kotlin/saschpe/log4k/MultipleLoggersTest.kt
@@ -0,0 +1,29 @@
+package saschpe.log4k
+
+import kotlinx.io.files.Path
+import saschpe.log4k.FileLogger.Rotate
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MultipleLoggersTest {
+ @Test
+ fun log_multipleLoggers() {
+ val before = Log.loggers
+ Log.loggers.clear()
+ Log.loggers += FileLogger(rotate = Rotate.Daily, logPath = TEST_LOG_PATH)
+ Log.loggers += FileLogger(rotate = Rotate.After(40), logPath = TEST_LOG_PATH)
+
+ assertEquals(2, Log.loggers.size)
+
+ Log.debug { "Create them log files!" }
+
+ // Clean up
+ Log.loggers.clear()
+ before.forEach { Log.loggers += it }
+ }
+
+ companion object {
+ private val TEST_LOG_PATH_STRING = "build/${MultipleLoggersTest::class.simpleName}"
+ private val TEST_LOG_PATH = Path(TEST_LOG_PATH_STRING)
+ }
+}
diff --git a/log4k/src/jsMain/kotlin/saschpe/log4k/FileLogger.js.kt b/log4k/src/jsMain/kotlin/saschpe/log4k/FileLogger.js.kt
new file mode 100644
index 0000000..78a43ef
--- /dev/null
+++ b/log4k/src/jsMain/kotlin/saschpe/log4k/FileLogger.js.kt
@@ -0,0 +1,10 @@
+package saschpe.log4k
+
+import kotlinx.io.files.Path
+import kotlinx.io.files.SystemTemporaryDirectory
+
+internal actual val defaultLogPath: Path
+ get() = SystemTemporaryDirectory
+
+internal actual fun limitFolderToFilesByCreationTime(path: String, limit: Int) {
+}
diff --git a/log4k/src/jsTest/kotlin/saschpe/log4k/FileLoggerTest.js.kt b/log4k/src/jsTest/kotlin/saschpe/log4k/FileLoggerTest.js.kt
new file mode 100644
index 0000000..1e8581e
--- /dev/null
+++ b/log4k/src/jsTest/kotlin/saschpe/log4k/FileLoggerTest.js.kt
@@ -0,0 +1,66 @@
+package saschpe.log4k
+
+internal actual val expectedExceptionPackage: String = ""
+internal actual val expectedTraceTag: String = "XXX"
+
+/**
+ * See https://nodejs.org/api/fs.html
+ */
+private external interface Fs {
+ // fun existsSync(path: String): Boolean
+// fun lstatSync(path: String): Stats?
+ fun existsSync(path: String): Boolean
+ fun readdirSync(path: String): List
+ fun rmSync(path: String, options: Map?)
+// fun rmdirSync(path: String)
+// fun unlinkSync(path: String)
+}
+
+private val fs: Fs by lazy {
+ try {
+ js("eval('require')('fs')")
+ } catch (e: Throwable) {
+ throw UnsupportedOperationException("Module 'fs' could not be imported", e)
+ }
+}
+
+private external interface Rimraf {
+ fun sync(path: String)
+}
+
+private val rimraf: Rimraf by lazy {
+ try {
+ js("eval('require')('rimraf')")
+ } catch (e: Throwable) {
+ throw UnsupportedOperationException("Module 'rimraf' could not be imported", e)
+ }
+}
+
+// private external interface Stats {
+// // val mode: Int
+// // val size: Int
+// fun isDirectory(): Boolean
+// }
+
+internal actual fun deleteRecursively(path: String) {
+// if (fs.existsSync(path)) {
+// fs.readdirSync(path).forEach {
+// val curPath = "$path/$it"
+// if (fs.lstatSync(curPath)?.isDirectory() == true) {
+// deleteRecursively(curPath)
+// } else {
+// fs.unlinkSync(curPath);
+// }
+// }
+// fs.rmdirSync(path);
+// }
+// if (fs.existsSync(path)) {
+// fs.rmSync(path)
+ fs.rmSync(path, mapOf("recursive" to true, "force" to true))
+// }
+// rimraf.sync(path)
+}
+
+internal actual fun filesInFolder(path: String): Int {
+ return fs.readdirSync(path).count()
+}
diff --git a/log4k/src/jsTest/kotlin/saschpe/log4k/LoggedTest.js.kt b/log4k/src/jsTest/kotlin/saschpe/log4k/LoggedTest.js.kt
deleted file mode 100644
index e5a6f74..0000000
--- a/log4k/src/jsTest/kotlin/saschpe/log4k/LoggedTest.js.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package saschpe.log4k
-
-internal actual val expectedListTag: String = "ArrayList"
-internal actual val expectedMapTag: String = "HashMap"
diff --git a/log4k/src/jvmMain/kotlin/saschpe/log4k/FileLogger.jvm.kt b/log4k/src/jvmMain/kotlin/saschpe/log4k/FileLogger.jvm.kt
new file mode 100644
index 0000000..c7f6f09
--- /dev/null
+++ b/log4k/src/jvmMain/kotlin/saschpe/log4k/FileLogger.jvm.kt
@@ -0,0 +1,19 @@
+package saschpe.log4k
+
+import kotlinx.io.files.Path
+import kotlinx.io.files.SystemTemporaryDirectory
+import java.io.File
+import kotlin.math.max
+
+internal actual val defaultLogPath: Path
+ get() = SystemTemporaryDirectory
+
+internal actual fun limitFolderToFilesByCreationTime(path: String, limit: Int) {
+ File(path).listFiles()?.sorted()?.run {
+ val overhead = max(0, count() - limit)
+ take(overhead).forEach {
+ println("limitFolderToFilesByCreationTime(path=$path, limit=$limit): remove $it")
+ it.delete()
+ }
+ }
+}
diff --git a/log4k/src/jvmTest/kotlin/saschpe/log4k/FileLoggerTest.jvm.kt b/log4k/src/jvmTest/kotlin/saschpe/log4k/FileLoggerTest.jvm.kt
new file mode 100644
index 0000000..578ba2e
--- /dev/null
+++ b/log4k/src/jvmTest/kotlin/saschpe/log4k/FileLoggerTest.jvm.kt
@@ -0,0 +1,14 @@
+package saschpe.log4k
+
+import java.io.File
+
+internal actual val expectedExceptionPackage: String = "java.lang."
+internal actual val expectedTraceTag: String = "FileLoggerTest.testMessages"
+
+internal actual fun deleteRecursively(path: String) {
+ File(path).deleteRecursively()
+}
+
+internal actual fun filesInFolder(path: String): Int {
+ return File(path).listFiles()?.size ?: 0
+}
diff --git a/log4k/src/jvmTest/kotlin/saschpe/log4k/LoggedTest.jvm.kt b/log4k/src/jvmTest/kotlin/saschpe/log4k/LoggedTest.jvm.kt
deleted file mode 100644
index 548ef95..0000000
--- a/log4k/src/jvmTest/kotlin/saschpe/log4k/LoggedTest.jvm.kt
+++ /dev/null
@@ -1,4 +0,0 @@
-package saschpe.log4k
-
-internal actual val expectedListTag: String = "ArrayList"
-internal actual val expectedMapTag: String = "SingletonMap"