diff --git a/Package.resolved b/Package.resolved index 19cd7978df8..5a2ab9eb1ef 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1e4d907dfd7f7c760087efd4410e7861f779556f8a1990ea6836b33ba13a4f54", + "originHash" : "6325b6a4a73e22fb29731011bc3458c5507f0ac03322e2261472256f61c2f448", "pins" : [ { "identity" : "aexml", @@ -235,6 +235,15 @@ "version" : "1.6.2" } }, + { + "identity" : "swift-log-file", + "kind" : "remoteSourceControl", + "location" : "https://github.com/crspybits/swift-log-file", + "state" : { + "revision" : "aa94b38bf88c7d9cbc87ceafcdffadaffbc2bffa", + "version" : "0.1.0" + } + }, { "identity" : "swift-log-oslog", "kind" : "remoteSourceControl", @@ -325,6 +334,15 @@ "version" : "2.20.0" } }, + { + "identity" : "xcglogger", + "kind" : "remoteSourceControl", + "location" : "https://github.com/DaveWoodCom/XCGLogger.git", + "state" : { + "revision" : "4def3c1c772ca90ad5e7bfc8ac437c3b0b4276cf", + "version" : "7.1.5" + } + }, { "identity" : "xcodegraph", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 3a489acdcae..feba4c7f313 100644 --- a/Package.swift +++ b/Package.swift @@ -122,6 +122,8 @@ let targets: [Target] = [ "Mockable", "FileSystem", "Command", + .product(name: "LoggingOSLog", package: "swift-log-oslog"), + .product(name: "FileLogging", package: "swift-log-file"), .product(name: "ServiceContextModule", package: "swift-service-context"), ], swiftSettings: [ @@ -517,8 +519,14 @@ let package = Package( .package(url: "https://github.com/tuist/Command.git", .upToNextMajor(from: "0.8.0")), .package(url: "https://github.com/sparkle-project/Sparkle.git", from: "2.6.4"), .package(url: "https://github.com/apple/swift-collections", .upToNextMajor(from: "1.1.4")), - .package(url: "https://github.com/apple/swift-service-context", .upToNextMajor(from: "1.0.0")), - .package(url: "https://github.com/chrisaljoudi/swift-log-oslog.git", .upToNextMajor(from: "0.2.2")), + .package( + url: "https://github.com/apple/swift-service-context", .upToNextMajor(from: "1.0.0") + ), + .package( + url: "https://github.com/chrisaljoudi/swift-log-oslog.git", + .upToNextMajor(from: "0.2.2") + ), + .package(url: "https://github.com/crspybits/swift-log-file", .upToNextMajor(from: "0.1.0")), ], targets: targets ) diff --git a/Sources/TuistKit/Commands/BuildCommand.swift b/Sources/TuistKit/Commands/BuildCommand.swift index a594ab2c118..b0b111beefe 100644 --- a/Sources/TuistKit/Commands/BuildCommand.swift +++ b/Sources/TuistKit/Commands/BuildCommand.swift @@ -120,7 +120,7 @@ public struct BuildOptions: ParsableArguments { } /// Command that builds a target from the project in the current directory. -public struct BuildCommand: AsyncParsableCommand { +public struct BuildCommand: AsyncParsableCommand, LogConfigurableCommand { public init() {} public static var generatorFactory: GeneratorFactorying = GeneratorFactory() public static var cacheStorageFactory: CacheStorageFactorying = EmptyCacheStorageFactory() @@ -132,6 +132,8 @@ public struct BuildCommand: AsyncParsableCommand { ) } + var logFilePathDisplayStrategy: LogFilePathDisplayStrategy = .always + @OptionGroup() var buildOptions: BuildOptions diff --git a/Sources/TuistKit/Commands/TestCommand.swift b/Sources/TuistKit/Commands/TestCommand.swift index e5da594914e..80d929f07c7 100644 --- a/Sources/TuistKit/Commands/TestCommand.swift +++ b/Sources/TuistKit/Commands/TestCommand.swift @@ -7,7 +7,7 @@ import TuistServer import TuistSupport /// Command that tests a target from the project in the current directory. -public struct TestCommand: AsyncParsableCommand { +public struct TestCommand: AsyncParsableCommand, LogConfigurableCommand { public init() {} public static var generatorFactory: GeneratorFactorying = GeneratorFactory() @@ -20,6 +20,8 @@ public struct TestCommand: AsyncParsableCommand { ) } + var logFilePathDisplayStrategy: LogFilePathDisplayStrategy = .always + @Argument( help: "The scheme to be tested. By default it tests all the testable targets of the project in the current directory.", envKey: .testScheme diff --git a/Sources/TuistKit/Commands/TuistCommand.swift b/Sources/TuistKit/Commands/TuistCommand.swift index c3261f55f3c..7e52772d956 100644 --- a/Sources/TuistKit/Commands/TuistCommand.swift +++ b/Sources/TuistKit/Commands/TuistCommand.swift @@ -65,6 +65,7 @@ public struct TuistCommand: AsyncParsableCommand { } public static func main( + logFilePath: AbsolutePath, _ arguments: [String]? = nil, parseAsRoot: ((_ arguments: [String]?) throws -> ParsableCommand) = Self.parseAsRoot ) async throws { @@ -96,6 +97,8 @@ public struct TuistCommand: AsyncParsableCommand { let executeCommand: () async throws -> Void let processedArguments = Array(processArguments(arguments)?.dropFirst() ?? []) var parsedError: Error? + var logFilePathDisplayStrategy: LogFilePathDisplayStrategy = .onError + do { if processedArguments.first == ScaffoldCommand.configuration.commandName { try await ScaffoldCommand.preprocess(processedArguments) @@ -105,6 +108,9 @@ public struct TuistCommand: AsyncParsableCommand { } let command = try parseAsRoot(processedArguments) executeCommand = { + logFilePathDisplayStrategy = (command as? LogConfigurableCommand)? + .logFilePathDisplayStrategy ?? logFilePathDisplayStrategy + let trackableCommand = TrackableCommand( command: command, commandArguments: processedArguments @@ -121,32 +127,44 @@ public struct TuistCommand: AsyncParsableCommand { } do { - defer { WarningController.shared.flush() } try await executeCommand() + outputCompletion(logFilePath: logFilePath, shouldOutputLogFilePath: logFilePathDisplayStrategy == .always) } catch let error as FatalError { - WarningController.shared.flush() errorHandler.fatal(error: error) + self.outputCompletion(logFilePath: logFilePath, shouldOutputLogFilePath: true) _exit(exitCode(for: error).rawValue) } catch let error as ClientError where error.underlyingError is ServerClientAuthenticationError { - WarningController.shared.flush() // swiftlint:disable:next force_cast ServiceContext.current?.logger?.error("\((error.underlyingError as! ServerClientAuthenticationError).description)") + outputCompletion(logFilePath: logFilePath, shouldOutputLogFilePath: true) _exit(exitCode(for: error).rawValue) } catch { - WarningController.shared.flush() if let parsedError { handleParseError(parsedError) } + // Exit cleanly if exitCode(for: error).rawValue == 0 { exit(withError: error) } else { errorHandler.fatal(error: UnhandledError(error: error)) + outputCompletion(logFilePath: logFilePath, shouldOutputLogFilePath: true) _exit(exitCode(for: error).rawValue) } } } + private static func outputCompletion(logFilePath: AbsolutePath, shouldOutputLogFilePath: Bool) { + WarningController.shared.flush() + if shouldOutputLogFilePath { + outputLogFilePath(logFilePath) + } + } + + private static func outputLogFilePath(_ logFilePath: AbsolutePath) { + ServiceContext.current?.logger?.info("\nLogs are available at \(logFilePath.pathString)") + } + private static func executeTask(with processedArguments: [String]) async throws { try await TuistService().run( arguments: processedArguments, diff --git a/Sources/TuistKit/Utils/LogConfigurableCommand.swift b/Sources/TuistKit/Utils/LogConfigurableCommand.swift new file mode 100644 index 00000000000..44c905b74b3 --- /dev/null +++ b/Sources/TuistKit/Utils/LogConfigurableCommand.swift @@ -0,0 +1,18 @@ +enum LogFilePathDisplayStrategy: Decodable { + /// Only shows the path to the log file on error + case onError + + /// Always shows the path to the log file. + case always + + /// The log file path is never shown. + case never +} + +/// This is a protocol that commands can conform to provide their preferences +/// regarding how the log file path should be shown. +/// +/// If a command doesn't conform to this protocol, the default strategy used is "onError" +protocol LogConfigurableCommand { + var logFilePathDisplayStrategy: LogFilePathDisplayStrategy { get } +} diff --git a/Sources/TuistKit/Utils/LogsController.swift b/Sources/TuistKit/Utils/LogsController.swift new file mode 100644 index 00000000000..1095abb24c0 --- /dev/null +++ b/Sources/TuistKit/Utils/LogsController.swift @@ -0,0 +1,66 @@ +import FileSystem +import Foundation +import Path +import TuistSupport + +public struct LogsController { + private let fileSystem: FileSystem + + public init(fileSystem: FileSystem = FileSystem()) { + self.fileSystem = fileSystem + } + + public func setup( + stateDirectory: AbsolutePath, + action: (@escaping @Sendable (String) -> any LogHandler, AbsolutePath) async throws -> Void + ) async throws { + let logFilePath = try await touchLogFile(stateDirectory: stateDirectory) + let machineReadableCommands = [DumpCommand.self] + // swiftformat:disable all + let isCommandMachineReadable = + CommandLine.arguments.count > 1 + && machineReadableCommands.map { $0._commandName }.contains(CommandLine.arguments[1]) + // swiftformat:enable all + let loggingConfig = + if isCommandMachineReadable || CommandLine.arguments.contains("--json") { + LoggingConfig( + loggerType: .json, + verbose: ProcessInfo.processInfo.environment[Constants.EnvironmentVariables.verbose] != nil + ) + } else { + LoggingConfig.default + } + + try await clean(logsDirectory: logFilePath.parentDirectory) + + let loggerHandler = try Logger.defaultLoggerHandler( + config: loggingConfig, logFilePath: logFilePath + ) + + try await action(loggerHandler, logFilePath) + } + + private func clean(logsDirectory: AbsolutePath) async throws { + let fiveDaysAgo = Calendar.current.date(byAdding: .day, value: -5, to: Date())! + + for logPath in try await fileSystem.glob(directory: logsDirectory, include: ["*"]).collect() { + if let creationDate = try FileManager.default.attributesOfItem(atPath: logPath.pathString)[.creationDate] as? Date, + creationDate < fiveDaysAgo + { + try await fileSystem.remove(logPath) + } + } + } + + private func touchLogFile(stateDirectory: AbsolutePath) async throws -> Path.AbsolutePath { + let fileSystem = FileSystem() + let logFilePath = stateDirectory.appending(components: [ + "logs", "\(UUID().uuidString).log", + ]) + if !(try await fileSystem.exists(logFilePath.parentDirectory)) { + try await fileSystem.makeDirectory(at: logFilePath.parentDirectory) + } + try await fileSystem.touch(logFilePath) + return logFilePath + } +} diff --git a/Sources/TuistSupport/Logging/Logger.swift b/Sources/TuistSupport/Logging/Logger.swift index 63276aa207a..0027c2e8018 100644 --- a/Sources/TuistSupport/Logging/Logger.swift +++ b/Sources/TuistSupport/Logging/Logger.swift @@ -1,7 +1,11 @@ -import class Foundation.ProcessInfo +import FileLogging @_exported import Logging +import LoggingOSLog +import Path import ServiceContextModule +import class Foundation.ProcessInfo + private enum LoggerServiceContextKey: ServiceContextKey { typealias Value = Logger } @@ -10,7 +14,8 @@ extension ServiceContext { public var logger: Logger? { get { self[LoggerServiceContextKey.self] - } set { + } + set { self[LoggerServiceContextKey.self] = newValue } } @@ -35,7 +40,10 @@ public struct LoggingConfig { } extension Logger { - public static func defaultLoggerHandler(config: LoggingConfig = .default) -> (String) -> any LogHandler { + public static func defaultLoggerHandler( + config: LoggingConfig = .default, + logFilePath: AbsolutePath + ) throws -> @Sendable (String) -> any LogHandler { let handler: VerboseLogHandler.Type switch config.loggerType { @@ -51,10 +59,35 @@ extension Logger { return quietLogHandler } + let fileLogger = try FileLogging(to: logFilePath.url) + + let baseLoggers = { (label: String) -> [any LogHandler] in + var loggers: [any LogHandler] = [ + FileLogHandler(label: label, fileLogger: fileLogger), + ] + + // OSLog is not needed in development. + // If we include it, the Xcode console will show duplicated logs, making it harder for contributors to debug the + // execution + // within Xcode. + // When run directly from a terminal, logs are not duplicated. + #if RELEASE + loggers.append(LoggingOSLog(label: label)) + #endif + return loggers + } if config.verbose { - return handler.verbose + return { label in + var loggers = baseLoggers(label) + loggers.append(handler.verbose(label: label)) + return MultiplexLogHandler(loggers) + } } else { - return handler.init + return { label in + var loggers = baseLoggers(label) + loggers.append(handler.init(label: label)) + return MultiplexLogHandler(loggers) + } } } } @@ -82,8 +115,8 @@ extension LoggingConfig { // A `VerboseLogHandler` allows for a LogHandler to be initialised with the `debug` logLevel. protocol VerboseLogHandler: LogHandler { - static func verbose(label: String) -> LogHandler - init(label: String) + @Sendable static func verbose(label: String) -> LogHandler + @Sendable init(label: String) } extension DetailedLogHandler: VerboseLogHandler { @@ -110,6 +143,6 @@ extension JSONLogHandler: VerboseLogHandler { } } -private func quietLogHandler(label: String) -> LogHandler { +@Sendable private func quietLogHandler(label: String) -> LogHandler { return StandardLogHandler(label: label, logLevel: .notice) } diff --git a/Sources/TuistSupport/Utils/Environment.swift b/Sources/TuistSupport/Utils/Environment.swift index 31a05786f59..4233cd1f8d9 100644 --- a/Sources/TuistSupport/Utils/Environment.swift +++ b/Sources/TuistSupport/Utils/Environment.swift @@ -1,4 +1,5 @@ import Darwin +import FileSystem import Foundation import Path @@ -24,6 +25,9 @@ public protocol Environmenting: AnyObject, Sendable { /// Returns the path to the cache directory. Configurable via the `XDG_CACHE_HOME` environment variable var cacheDirectory: AbsolutePath { get } + /// Returns the path to the state directory. Configurable via the `XDG_STATE_HOME` environment variable + var stateDirectory: AbsolutePath { get } + /// Returns the path to the directory where the async queue events are persisted. var queueDirectory: AbsolutePath { get } @@ -67,16 +71,18 @@ public final class Environment: Environmenting { /// Returns true if the output of Tuist should be coloured. public var shouldOutputBeColoured: Bool { - let noColor = if let noColorEnvVariable = ProcessInfo.processInfo.environment["NO_COLOR"] { - Constants.trueValues.contains(noColorEnvVariable) - } else { - false - } - let ciColorForce = if let ciColorForceEnvVariable = ProcessInfo.processInfo.environment["CLICOLOR_FORCE"] { - Constants.trueValues.contains(ciColorForceEnvVariable) - } else { - false - } + let noColor = + if let noColorEnvVariable = ProcessInfo.processInfo.environment["NO_COLOR"] { + Constants.trueValues.contains(noColorEnvVariable) + } else { + false + } + let ciColorForce = + if let ciColorForceEnvVariable = ProcessInfo.processInfo.environment["CLICOLOR_FORCE"] { + Constants.trueValues.contains(ciColorForceEnvVariable) + } else { + false + } if noColor { return false } else if ciColorForce { @@ -106,12 +112,18 @@ public final class Environment: Environmenting { } public var isVerbose: Bool { - guard let variable = ProcessInfo.processInfo.environment[Constants.EnvironmentVariables.verbose] else { return false } + guard let variable = ProcessInfo.processInfo.environment[ + Constants.EnvironmentVariables.verbose + ] + else { return false } return Constants.trueValues.contains(variable) } public var isStatsEnabled: Bool { - guard let variable = ProcessInfo.processInfo.environment[Constants.EnvironmentVariables.statsOptOut] else { return true } + guard let variable = ProcessInfo.processInfo.environment[ + Constants.EnvironmentVariables.statsOptOut + ] + else { return true } let userOptedOut = Constants.trueValues.contains(variable) return !userOptedOut } @@ -123,19 +135,38 @@ public final class Environment: Environmenting { { baseCacheDirectory = cacheDirectory } else { - baseCacheDirectory = FileHandler.shared.homeDirectory.appending(components: ".cache") + // swiftlint:disable:next force_try + let homeDirectory = try! Path.AbsolutePath(validating: NSHomeDirectory()) + baseCacheDirectory = homeDirectory.appending(components: ".cache") } return baseCacheDirectory.appending(component: "tuist") } + public var stateDirectory: AbsolutePath { + let baseStateDirectory: AbsolutePath + if let stateDirectoryPathString = ProcessInfo.processInfo.environment["XDG_STATE_HOME"], + let stateDirectory = try? AbsolutePath(validating: stateDirectoryPathString) + { + baseStateDirectory = stateDirectory + } else { + // swiftlint:disable:next force_try + let homeDirectory = try! Path.AbsolutePath(validating: NSHomeDirectory()) + baseStateDirectory = homeDirectory.appending(components: [".local", "state"]) + } + + return baseStateDirectory.appending(component: "tuist") + } + public var automationPath: AbsolutePath? { ProcessInfo.processInfo.environment[Constants.EnvironmentVariables.automationPath] .map { try! AbsolutePath(validating: $0) } // swiftlint:disable:this force_try } public var queueDirectory: AbsolutePath { - if let envVariable = ProcessInfo.processInfo.environment[Constants.EnvironmentVariables.queueDirectory] { + if let envVariable = ProcessInfo.processInfo.environment[ + Constants.EnvironmentVariables.queueDirectory + ] { return try! AbsolutePath(validating: envVariable) // swiftlint:disable:this force_try } else { return cacheDirectory.appending(component: Constants.AsyncQueue.directoryName) diff --git a/Sources/TuistSupportTesting/Utils/MockEnvironment.swift b/Sources/TuistSupportTesting/Utils/MockEnvironment.swift index 152c490e193..dad762d1bcd 100644 --- a/Sources/TuistSupportTesting/Utils/MockEnvironment.swift +++ b/Sources/TuistSupportTesting/Utils/MockEnvironment.swift @@ -29,6 +29,10 @@ public final class MockEnvironment: Environmenting { directory.path.appending(components: ".cache") } + public var stateDirectory: AbsolutePath { + directory.path.appending(component: "state") + } + public var queueDirectory: AbsolutePath { queueDirectoryStub ?? directory.path.appending(component: Constants.AsyncQueue.directoryName) } diff --git a/Sources/tuist/TuistCLI.swift b/Sources/tuist/TuistCLI.swift index c4d8f9c6d39..afa1ef45085 100644 --- a/Sources/tuist/TuistCLI.swift +++ b/Sources/tuist/TuistCLI.swift @@ -1,3 +1,6 @@ +import FileSystem +import Foundation +import Path import ServiceContextModule import TSCBasic import TuistKit @@ -7,11 +10,11 @@ import TuistSupport @_documentation(visibility: private) private enum TuistCLI { static func main() async throws { - if CommandLine.arguments.contains("--quiet") && CommandLine.arguments.contains("--verbose") { + if CommandLine.arguments.contains("--quiet"), CommandLine.arguments.contains("--verbose") { throw TuistCLIError.exclusiveOptionError("quiet", "verbose") } - if CommandLine.arguments.contains("--quiet") && CommandLine.arguments.contains("--json") { + if CommandLine.arguments.contains("--quiet"), CommandLine.arguments.contains("--json") { throw TuistCLIError.exclusiveOptionError("quiet", "json") } @@ -23,29 +26,16 @@ private enum TuistCLI { try? ProcessEnv.setVar(Constants.EnvironmentVariables.quiet, value: "true") } - let machineReadableCommands = [DumpCommand.self] + try await LogsController().setup(stateDirectory: Environment.shared.stateDirectory) { loggerHandler, logFilePath in + /// This is the old initialization method and will eventually go away. + LoggingSystem.bootstrap(loggerHandler) - // swiftformat:disable all - let isCommandMachineReadable = CommandLine.arguments.count > 1 && machineReadableCommands.map { $0._commandName }.contains(CommandLine.arguments[1]) - // swiftformat:enable all - let loggingConfig = if isCommandMachineReadable || CommandLine.arguments.contains("--json") { - LoggingConfig( - loggerType: .json, - verbose: ProcessEnv.vars[Constants.EnvironmentVariables.verbose] != nil - ) - } else { - LoggingConfig.default - } - let loggerHandler = Logger.defaultLoggerHandler(config: loggingConfig) - - /// This is the old initialization method and will eventually go away. - LoggingSystem.bootstrap(loggerHandler) - - var context = ServiceContext.topLevel - context.logger = Logger(label: "dev.tuist.cli", factory: loggerHandler) + var context = ServiceContext.topLevel + context.logger = Logger(label: "dev.tuist.cli", factory: loggerHandler) - try await ServiceContext.withValue(context) { - try await TuistCommand.main() + try await ServiceContext.withValue(context) { + try await TuistCommand.main(logFilePath: logFilePath) + } } } } diff --git a/Tests/TuistKitTests/Services/XcodeBuildServiceTests.swift b/Tests/TuistKitTests/Services/XcodeBuildServiceTests.swift index 714fc018d15..84e1f9e9abb 100644 --- a/Tests/TuistKitTests/Services/XcodeBuildServiceTests.swift +++ b/Tests/TuistKitTests/Services/XcodeBuildServiceTests.swift @@ -11,6 +11,7 @@ import TuistHasher import TuistLoader import TuistServer import XcodeGraph + import protocol XcodeGraphMapper.XcodeGraphMapping @testable import TuistKit @@ -51,7 +52,8 @@ struct XcodeBuildServiceTests { } @Test func throwsErrorWhenSchemeNotFound() async throws { - try await fileSystem.runInTemporaryDirectory(prefix: "XcodeBuildServiceTests") { temporaryPath in + try await fileSystem.runInTemporaryDirectory(prefix: "XcodeBuildServiceTests") { + temporaryPath in // Given let project: Project = .test( schemes: [ @@ -70,7 +72,9 @@ struct XcodeBuildServiceTests { // When / Then await #expect(throws: XcodeBuildServiceError.schemeNotFound("MyScheme")) { - try await subject.run(passthroughXcodebuildArguments: ["test", "-scheme", "MyScheme"]) + try await subject.run(passthroughXcodebuildArguments: [ + "test", "-scheme", "MyScheme", + ]) } } } @@ -85,7 +89,8 @@ struct XcodeBuildServiceTests { } @Test func existsEarlyIfAllTestsAreCached() async throws { - try await fileSystem.runInTemporaryDirectory(prefix: "XcodeBuildServiceTests") { temporaryPath in + try await fileSystem.runInTemporaryDirectory(prefix: "XcodeBuildServiceTests") { + temporaryPath in var context = ServiceContext.current ?? ServiceContext.topLevel let runMetadataStorage = RunMetadataStorage() context.runMetadataStorage = runMetadataStorage @@ -227,7 +232,8 @@ struct XcodeBuildServiceTests { } @Test func skipsCachedTests() async throws { - try await fileSystem.runInTemporaryDirectory(prefix: "XcodeBuildServiceTests") { temporaryPath in + try await fileSystem.runInTemporaryDirectory(prefix: "XcodeBuildServiceTests") { + temporaryPath in var context = ServiceContext.current ?? ServiceContext.topLevel let runMetadataStorage = RunMetadataStorage() context.runMetadataStorage = runMetadataStorage @@ -264,15 +270,15 @@ struct XcodeBuildServiceTests { ] ) - let graph: Graph = .test( - projects: [ - temporaryPath: project, - ] - ) - given(xcodeGraphMapper) .map(at: .any) - .willReturn(graph) + .willReturn( + .test( + projects: [ + temporaryPath: project, + ] + ) + ) given(selectiveTestingGraphHasher) .hash( @@ -343,7 +349,9 @@ struct XcodeBuildServiceTests { .store( .value( [ - CacheStorableItem(name: "BUnitTests", hash: "hash-b-unit-tests"): [AbsolutePath](), + CacheStorableItem(name: "BUnitTests", hash: "hash-b-unit-tests"): [ + AbsolutePath + ](), ] ), cacheCategory: .value(.selectiveTests) @@ -367,15 +375,13 @@ struct XcodeBuildServiceTests { ], ] ) - await #expect( - runMetadataStorage.graph == graph - ) } } } @Test func skipsCachedTestsOfCustomTestPlan() async throws { - try await fileSystem.runInTemporaryDirectory(prefix: "XcodeBuildServiceTests") { temporaryPath in + try await fileSystem.runInTemporaryDirectory(prefix: "XcodeBuildServiceTests") { + temporaryPath in // Given let aUnitTestsTarget: Target = .test(name: "AUnitTests") let bUnitTestsTarget: Target = .test(name: "BUnitTests") @@ -390,7 +396,9 @@ struct XcodeBuildServiceTests { testAction: .test( testPlans: [ TestPlan( - path: temporaryPath.appending(component: "MyTestPlan.xctestplan"), + path: temporaryPath.appending( + component: "MyTestPlan.xctestplan" + ), testTargets: [ TestableTarget( target: TargetReference( diff --git a/Tests/TuistKitTests/Utils/LogsCleanerTests.swift b/Tests/TuistKitTests/Utils/LogsCleanerTests.swift new file mode 100644 index 00000000000..e6c51668206 --- /dev/null +++ b/Tests/TuistKitTests/Utils/LogsCleanerTests.swift @@ -0,0 +1,40 @@ +import FileSystem +import Foundation +import Path +import Testing +import TuistKit + +struct LogsControllerTests { + private let fileSystem = FileSystem() + private let subject = LogsController() + + @Test + func setup() async throws { + try await fileSystem.runInTemporaryDirectory(prefix: UUID().uuidString) { temporaryDirectory in + // Given + let veryOldLogPath = temporaryDirectory.appending(components: ["logs", "\(UUID().uuidString).log"]) + let recentLogPath = temporaryDirectory.appending(components: ["logs", "\(UUID().uuidString).log"]) + try await fileSystem.makeDirectory(at: veryOldLogPath.parentDirectory) + let oldDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())! + + try await fileSystem.touch(veryOldLogPath) + try await fileSystem.touch(recentLogPath) + + try FileManager.default.setAttributes( + [FileAttributeKey.creationDate: oldDate], + ofItemAtPath: veryOldLogPath.pathString + ) + + // When + var newLogFilePath: AbsolutePath? + try await subject.setup(stateDirectory: temporaryDirectory) { _, logsFilePath in + newLogFilePath = logsFilePath + } + + // Then + let got = try await fileSystem.glob(directory: temporaryDirectory, include: ["logs/*"]).collect() + #expect(got.contains(recentLogPath) == true) + #expect(got.contains(try #require(newLogFilePath)) == true) + } + } +} diff --git a/Tuist/ProjectDescriptionHelpers/Module.swift b/Tuist/ProjectDescriptionHelpers/Module.swift index b60ea4c9240..aa9f7216c3f 100644 --- a/Tuist/ProjectDescriptionHelpers/Module.swift +++ b/Tuist/ProjectDescriptionHelpers/Module.swift @@ -282,6 +282,7 @@ public enum Module: String, CaseIterable { .external(name: "ZIPFoundation"), .external(name: "Difference"), .external(name: "Command"), + .external(name: "FileLogging"), .external(name: "LoggingOSLog"), ] case .kit: diff --git a/docs/.vitepress/bars.mjs b/docs/.vitepress/bars.mjs index 8fb05670238..5facca62383 100644 --- a/docs/.vitepress/bars.mjs +++ b/docs/.vitepress/bars.mjs @@ -173,6 +173,19 @@ export function contributorsSidebar(locale) { ), link: `/${locale}/contributors/translate`, }, + { + text: localizedString(locale, "sidebars.contributors.items.cli.text"), + collapsed: true, + items: [ + { + text: localizedString( + locale, + "sidebars.contributors.items.cli.items.logging.text", + ), + link: `/${locale}/contributors/cli/logging`, + }, + ], + }, ], }, ]; diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index ccae5aaa3c8..4664d087051 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -8,7 +8,7 @@ import { serverSidebar, navBar, } from "./bars.mjs"; -import { loadData as loadCLIData } from "./data/cli"; +import { cliSidebar } from "./data/cli"; import { localizedString } from "./i18n.mjs"; async function themeConfig(locale) { @@ -16,7 +16,7 @@ async function themeConfig(locale) { sidebar[`/${locale}/contributors`] = contributorsSidebar(locale); sidebar[`/${locale}/guides/`] = guidesSidebar(locale); sidebar[`/${locale}/server/`] = serverSidebar(locale); - sidebar[`/${locale}/cli/`] = await loadCLIData(locale); + sidebar[`/${locale}/cli/`] = await cliSidebar(locale); sidebar[`/${locale}/references/`] = await referencesSidebar(locale); sidebar[`/${locale}/`] = guidesSidebar(locale); return { diff --git a/docs/.vitepress/data/cli.js b/docs/.vitepress/data/cli.js index 43d94163f93..3840970b768 100644 --- a/docs/.vitepress/data/cli.js +++ b/docs/.vitepress/data/cli.js @@ -118,6 +118,28 @@ export async function paths(locale) { return paths; } +export async function cliSidebar(locale) { + const sidebar = await loadData(locale); + return { + ...sidebar, + items: [ + { + text: "CLI", + items: [ + { + text: localizedString( + locale, + "sidebars.cli.items.cli.items.logging.text", + ), + link: `/${locale}/cli/logging`, + }, + ], + }, + ...sidebar.items, + ], + }; +} + export async function loadData(locale) { function parseCommand( command, @@ -152,6 +174,7 @@ export async function loadData(locale) { items: [ { text: localizedString(locale, "sidebars.cli.items.commands.text"), + collapsed: true, items: subcommands .map((command) => { return { diff --git a/docs/.vitepress/strings/en.json b/docs/.vitepress/strings/en.json index 73e3e997f79..1e71fd2fcd8 100644 --- a/docs/.vitepress/strings/en.json +++ b/docs/.vitepress/strings/en.json @@ -82,6 +82,13 @@ "cli": { "text": "CLI", "items": { + "cli": { + "items": { + "logging": { + "text": "Logging" + } + } + }, "commands": { "text": "Commands" } @@ -120,6 +127,14 @@ }, "translate": { "text": "Translate" + }, + "cli": { + "text": "CLI", + "items": { + "logging": { + "text": "Logging" + } + } } } }, diff --git a/docs/docs/en/cli/logging.md b/docs/docs/en/cli/logging.md new file mode 100644 index 00000000000..20d3bc42539 --- /dev/null +++ b/docs/docs/en/cli/logging.md @@ -0,0 +1,50 @@ +--- +title: Logging +titleTemplate: :title · CLI · Tuist +description: Learn how to enable and configure logging in Tuist. +--- + +# Logging {#logging} + +The CLI logs messages internally to help you diagnose issues. + +## Diagnose issues using logs {#diagnose-issues-using-logs} + +If a command invocation doesn't yield the intended results, you can diagnose the issue by inspecting the logs. The CLI forwards the logs to [OSLog](https://developer.apple.com/documentation/os/oslog) and the file-system. + +In every run, it creates a log file at `$XDG_STATE_HOME/tuist/logs/{uuid}.log` where `$XDG_STATE_HOME` takes the value `~/.local/state` if the environment variable is not set. + +By default, the CLI outputs the logs path when the execution exits unexpectedly. If it doesn't, you can find the logs in the path mentioned above (i.e., the most recent log file). + +> [!IMPORTANT] +> Sensitive information is not redacted, so be cautious when sharing logs. + +### Continuous integration {#diagnose-issues-using-logs-ci} + +In CI, where environments are disposable, you might want to configure your CI pipeline to export Tuist logs. +Exporting artifacts is a common capability across CI services, and the configuration depends on the service you use. +For example, in GitHub Actions, you can use the `actions/upload-artifact` action to upload the logs as an artifact: + +```yaml +name: Node CI + +on: [push] + +env: + $XDG_STATE_HOME: /tmp/tuist + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + # ... other steps + - run: tuist generate + # ... do something with the project + - name: Export Tuist logs + uses: actions/upload-artifact@v4 + with: + name: tuist-logs + path: /tmp/tuist/logs/*.log +``` diff --git a/docs/docs/en/contributors/cli/logging.md b/docs/docs/en/contributors/cli/logging.md new file mode 100644 index 00000000000..ffc16675be3 --- /dev/null +++ b/docs/docs/en/contributors/cli/logging.md @@ -0,0 +1,23 @@ +--- +title: Logging +titleTemplate: :title · CLI · Contributors · Tuist +description: Learn how to contribute to Tuist by reviewing code +--- + +# Logging {#logging} + +The CLI embraces the [swift-log](https://github.com/apple/swift-log) interface for logging. The package abstracts away the implementation details of logging, allowing the CLI to be agnostic to the logging backend. The logger is dependency-injected using [swift-service-context](https://github.com/apple/swift-service-context) and can be accessed anywhere using: + +```bash +ServiceContext.current?.logger +``` + +> [!NOTE] +> `swift-service-context` passes the instance using [task locals](https://developer.apple.com/documentation/swift/tasklocal) which don't propagate the value when using `Dispatch`, so if you run asynchronous code using `Dispatch`, you'll to get the instance from the context and pass it to the asynchronous operation. + +## What to log {#what-to-log} + +Logs are not the CLI's UI. They are a tool to diagnose issues when they arise. +Therefore, the more information you provide, the better. +When building new features, put yourself in the shoes of a developer coming across unexpected behavior, and think about what information would be helpful to them. +Ensure you you use the right [log level](https://www.swift.org/documentation/server/guides/libraries/log-levels.html). Otherwise developers won't be able to filter out the noise.