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"