diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 0073c91..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,24 +0,0 @@ -# Agent Guidelines for XcodeProjectCLI - -## Build & Test Commands -- **Build**: `swift build` (release: `swift build -c release`) -- **Test all**: `swift test` -- **Test single**: `swift test --filter ` (e.g., `swift test --filter AddFileCommandTests`) -- **Lint**: SwiftFormat configured via `.swiftformat`, SwiftLint via `.swiftlint.yml` -- **Format**: `swiftformat .` (if installed) - -## Code Style -- **Swift version**: 5.9+, macOS 13+ -- **Indentation**: 4 spaces, max line length 130 (SwiftFormat) / 160 (SwiftLint) -- **Imports**: Foundation/XcodeProj only when needed, no unused imports -- **Types**: Use explicit types for properties, inference for local vars -- **Naming**: camelCase for vars/funcs, PascalCase for types, descriptive names (min 1 char allowed) -- **Error handling**: Use `CLIError` enum with descriptive messages, throw for expected errors -- **Extensions**: Group by functionality, no imports if extending stdlib types -- **Testing**: Use Swift Testing framework (`import Testing`), organize in `SerializedSuite`, use `#expect` -- **Commands**: Use ArgumentParser, group options with `@OptionGroup`, validate inputs in `validate()` -- **Formatting**: Allman false, inline commas, no space in ranges, `void` as tuple `()`, `self` in init only - -## Project Structure -- Core logic in `Core/`, commands in `Subcommands/`, extensions in `Extensions/`, models in `Models/` -- Test resources in `TestResources/`, use `ProjectTests` base class for serialized test execution diff --git a/README.md b/README.md index 4db116e..b283ac5 100644 --- a/README.md +++ b/README.md @@ -24,23 +24,31 @@ OPTIONS: TARGET SUBCOMMANDS: list-targets List project targets. - set-target Set target for an existing file. + set-target Set target for existing file. GROUP SUBCOMMANDS: - add-group Add a group to the project. - delete-group Delete a group from the project. - move-group Move a group to a different location within the project. - rename-group Rename a group within the project. + add-group Add group. + delete-group Delete group. + move-group Move group. + rename-group Rename group. FILE SUBCOMMANDS: - add-file Add a file to specified targets in the project. - delete-file Delete a file from the project. - move-file Move a file to a different location within the project. - rename-file Rename a file within the project. + add-file Add file to specified targets. + delete-file Delete file. + move-file Move file. + rename-file Rename file. BUILD SETTINGS SUBCOMMANDS: - get-build-setting Get a build setting for specified target in the project. - set-build-setting Set a build setting for specified targets in the project. + get-build-setting Get build setting. + set-build-setting Set build setting. + +ASSETS SUBCOMMANDS: + add-image-asset Add image asset. + add-data-asset Add data asset. + add-color-asset Add color asset. + list-assets List all assets. + move-asset Move asset. + delete-asset Delete asset. See 'xcp help ' for detailed help. ``` diff --git a/Sources/XcodeProject/Core/AssetTemplateBuilders/ColorAssetBuilder.swift b/Sources/XcodeProject/Core/AssetTemplateBuilders/ColorAssetBuilder.swift new file mode 100644 index 0000000..6e8f71b --- /dev/null +++ b/Sources/XcodeProject/Core/AssetTemplateBuilders/ColorAssetBuilder.swift @@ -0,0 +1,75 @@ +final class ColorAssetBuilder { + private var hexColor: String? + private var darkHexColor: String? + private var colorSpace = "srgb" + + func setHexColor(_ hex: String) -> ColorAssetBuilder { + hexColor = hex + return self + } + + func setDarkHexColor(_ hex: String?) -> ColorAssetBuilder { + darkHexColor = hex + return self + } + + func setColorSpace(_ space: String) -> ColorAssetBuilder { + colorSpace = space + return self + } + + func build() -> String { + var darkColorTemplate = "" + + if let darkColor = darkHexColor.flatMap(HexColor.init) { + darkColorTemplate = """ + , + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "\(colorSpace)", + "components" : { + "alpha" : "\(darkColor.alpha)", + "blue" : "0x\(darkColor.blue)", + "green" : "0x\(darkColor.green)", + "red" : "0x\(darkColor.red)" + } + }, + "idiom" : "universal" + } + """ + } + + guard let color = hexColor.flatMap(HexColor.init) else { + return "" + } + + return """ + { + "colors" : [ + { + "color" : { + "color-space" : "\(colorSpace)", + "components" : { + "alpha" : "\(color.alpha)", + "blue" : "0x\(color.blue)", + "green" : "0x\(color.green)", + "red" : "0x\(color.red)" + } + }, + "idiom" : "universal" + }\(darkColorTemplate) + ], + "info" : { + "author" : "xcode", + "version" : 1 + } + }\n + """ + } +} diff --git a/Sources/XcodeProject/Core/AssetTemplateBuilders/DataAssetBuilder.swift b/Sources/XcodeProject/Core/AssetTemplateBuilders/DataAssetBuilder.swift new file mode 100644 index 0000000..3e410ca --- /dev/null +++ b/Sources/XcodeProject/Core/AssetTemplateBuilders/DataAssetBuilder.swift @@ -0,0 +1,24 @@ +final class DataAssetBuilder { + private let asset: AssetInfo + + init(_ asset: AssetInfo) { + self.asset = asset + } + + func build() -> String { + """ + { + "data" : [ + { + "filename" : "\(asset.fileName)", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } + }\n + """ + } +} diff --git a/Sources/XcodeProject/Core/AssetTemplateBuilders/ImageAssetBuilder.swift b/Sources/XcodeProject/Core/AssetTemplateBuilders/ImageAssetBuilder.swift new file mode 100644 index 0000000..4016e82 --- /dev/null +++ b/Sources/XcodeProject/Core/AssetTemplateBuilders/ImageAssetBuilder.swift @@ -0,0 +1,62 @@ +final class ImageAssetBuilder { + private var renderingMode: RenderingMode = .default + private var includeDark = false + + private let asset: AssetInfo + + init(_ asset: AssetInfo) { + self.asset = asset + } + + func setRenderingMode(_ mode: RenderingMode) -> ImageAssetBuilder { + renderingMode = mode + return self + } + + func includeDarkAppearance(_ includeDark: Bool) -> ImageAssetBuilder { + self.includeDark = includeDark + return self + } + + func build() -> String { + let templateRendering = renderingMode != .default + ? """ + , + "properties" : { + "template-rendering-intent" : "\(renderingMode.rawValue)" + } + """ + : "" + + let darkAppearance = includeDark + ? """ + , + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "\(asset.darkFileName)", + "idiom" : "universal" + } + """ + : "" + + return """ + { + "images" : [ + { + "filename" : "\(asset.fileName)", + "idiom" : "universal" + }\(darkAppearance) + ], + "info" : { + "author" : "xcode", + "version" : 1 + }\(templateRendering) + }\n + """ + } +} diff --git a/Sources/XcodeProject/Core/Project.swift b/Sources/XcodeProject/Core/Project.swift index 6ec1d31..f8c32f9 100644 --- a/Sources/XcodeProject/Core/Project.swift +++ b/Sources/XcodeProject/Core/Project.swift @@ -12,28 +12,16 @@ public final class Project { public init(xcodeProjectPath: String?) throws { var projectPath = xcodeProjectPath - let pwd = ProcessInfo.processInfo.environment["PWD"] - var currentDir = pwd - - #if DEBUG - if pwd == nil || pwd == "/tmp" { - currentDir = #filePath - .components(separatedBy: "/") - .dropLast(4) - .joined(separator: "/") - } - #endif + let currentDir = PathUtils.processWorkingDirectory() if projectPath == nil, let currentDir { let contents = try FileManager.default.contentsOfDirectory(atPath: currentDir) projectPath = contents.first { $0.hasSuffix(".xcodeproj") } projectPath = InputPath(projectPath ?? "", projectRoot: currentDir).absolutePath - } else if let projectPathUnwrapped = projectPath, let currentDir, !(projectPathUnwrapped as NSString).isAbsolutePath { - projectPath = ("\(currentDir)/\(projectPathUnwrapped)" as NSString).standardizingPath + } else { + projectPath = projectPath.flatMap { PathUtils.toAbsolutePath($0) } } - projectPath = (projectPath as NSString?)?.expandingTildeInPath - guard let projectPath else { throw XcodeProjectError.xcodeProjectNotFound } diff --git a/Sources/XcodeProject/Core/ProjectAssets.swift b/Sources/XcodeProject/Core/ProjectAssets.swift new file mode 100644 index 0000000..38fae20 --- /dev/null +++ b/Sources/XcodeProject/Core/ProjectAssets.swift @@ -0,0 +1,203 @@ +import Foundation + +public final class ProjectAssets { + private let xcassetsPath: InputPath + + public init(xcassetsPath: String) throws { + self.xcassetsPath = xcassetsPath.asAbsoluteInputPath + + if !FileManager.default.fileExists(atPath: self.xcassetsPath.absolutePath) { + throw XcodeProjectError.xcassetsNotFound(self.xcassetsPath) + } + } + + public func listAll() throws -> [AssetType: [String]] { + var assets: [AssetType: [String]] = [:] + + let fileEnumerator = FileManager.default.enumerator( + atPath: xcassetsPath.absolutePath + ) + + let allowedExtensions = AssetType.allCases.map(\.ext) + + while let element = fileEnumerator?.nextObject() as? String { + let url = (element as NSString) + let ext = url.pathExtension + + if allowedExtensions.contains(ext) { + let assetType = AssetType.allCases.first { $0.ext == ext }! + let assetPath = url.deletingPathExtension + assets[assetType, default: []].append(assetPath) + } + } + + return assets + } + + public func addImage( + filePath: InputPath, + darkFilePath: InputPath? = nil, + assetPath: String, + renderingMode: RenderingMode = .default + ) throws { + try ensureFileExists(at: filePath) + + let asset = AssetInfo( + xcassetsPath: xcassetsPath, + assetPath: assetPath, + type: .image + ) + + try FileManager.default.createDirectory( + atPath: asset.assetDirPath, + withIntermediateDirectories: true, + attributes: nil + ) + + try FileManager.default.copyItem( + atPath: filePath.absolutePath, + toPath: asset.filePath + ) + + if let darkFilePath { + try FileManager.default.copyItem( + atPath: darkFilePath.absolutePath, + toPath: asset.darkFilePath + ) + } + + let template = ImageAssetBuilder(asset) + .setRenderingMode(renderingMode) + .includeDarkAppearance(darkFilePath != nil) + .build() + + try template.write( + toFile: asset.contentsPath, + atomically: true, + encoding: .utf8 + ) + } + + public func addData(filePath: InputPath, assetPath: String) throws { + try ensureFileExists(at: filePath) + + let asset = AssetInfo( + xcassetsPath: xcassetsPath, + assetPath: assetPath, + type: .data + ) + + try FileManager.default.createDirectory( + atPath: asset.assetDirPath, + withIntermediateDirectories: true, + attributes: nil + ) + + try FileManager.default.copyItem( + atPath: filePath.absolutePath, + toPath: asset.filePath + ) + + let template = DataAssetBuilder(asset) + .build() + + try template.write( + toFile: asset.contentsPath, + atomically: true, + encoding: .utf8 + ) + } + + public func addColor( + hexColor: String, + darkHexColor: String?, + colorSpace: String = "srgb", + assetPath: String + ) throws { + let asset = AssetInfo( + xcassetsPath: xcassetsPath, + assetPath: assetPath, + type: .color + ) + + try FileManager.default.createDirectory( + atPath: asset.assetDirPath, + withIntermediateDirectories: true, + attributes: nil + ) + + let template = ColorAssetBuilder() + .setHexColor(hexColor) + .setDarkHexColor(darkHexColor) + .setColorSpace(colorSpace) + .build() + + try template.write( + toFile: asset.contentsPath, + atomically: true, + encoding: .utf8 + ) + } + + public func moveAsset( + oldAssetPath: String, + newAssetPath: String + ) throws { + let type = try findAssetType(from: oldAssetPath) + + let oldAsset = AssetInfo( + xcassetsPath: xcassetsPath, + assetPath: oldAssetPath, + type: type + ) + + let newAsset = AssetInfo( + xcassetsPath: xcassetsPath, + assetPath: newAssetPath, + type: type + ) + + try ensureFileExists(at: oldAsset.assetDirPath.asAbsoluteInputPath) + + try FileManager.default.createDirectory( + atPath: newAsset.parentDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + try FileManager.default.moveItem( + atPath: oldAsset.assetDirPath, + toPath: newAsset.assetDirPath + ) + } + + public func deleteAsset(assetPath: String) throws { + let asset = try AssetInfo( + xcassetsPath: xcassetsPath, + assetPath: assetPath, + type: findAssetType(from: assetPath) + ) + + try ensureFileExists(at: asset.assetDirPath.asAbsoluteInputPath) + + try FileManager.default.removeItem(atPath: asset.assetDirPath) + } + + private func findAssetType(from assetPath: String) throws -> AssetType { + let assetType = AssetType.allCases.first { type in + let fullPath = xcassetsPath.appending("\(assetPath).\(type.ext)").absolutePath + return FileManager.default.fileExists(atPath: fullPath) + } + + guard let assetType else { + throw XcodeProjectError.assetNotFound(assetPath) + } + + return assetType + } + + private func ensureFileExists(at path: InputPath) throws { + if !FileManager.default.fileExists(atPath: path.absolutePath) { + throw XcodeProjectError.fileNotFoundOnDisk(path) + } + } +} diff --git a/Sources/XcodeProject/Models/AssetInfo.swift b/Sources/XcodeProject/Models/AssetInfo.swift new file mode 100644 index 0000000..3cc4f5f --- /dev/null +++ b/Sources/XcodeProject/Models/AssetInfo.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct AssetInfo { + let fileName: String + let filePath: String + let darkFileName: String + let darkFilePath: String + let ext: String + let assetDirPath: String + let parentDirectory: String + let contentsPath: String + + init(xcassetsPath: InputPath, assetPath: String, type: AssetType = .image) { + let fileName = (assetPath as NSString).lastPathComponent + let ext = (fileName as NSString).pathExtension + let withoutExtension = (fileName as NSString).deletingPathExtension + let darkFileName = "\(withoutExtension)_dark.\(ext)" + let assetPathWithoutLast = (assetPath as NSString).deletingLastPathComponent + let assetDirPath = xcassetsPath.appending("\(assetPathWithoutLast)/\(withoutExtension).\(type.ext)").absolutePath + let parentDirectory = xcassetsPath.appending(assetPathWithoutLast).absolutePath + let darkFilePath = "\(assetDirPath)/\(darkFileName)" + let contentsPath = "\(assetDirPath)/Contents.json" + + self.fileName = fileName + self.filePath = "\(assetDirPath)/\(fileName)" + self.darkFileName = darkFileName + self.darkFilePath = darkFilePath + self.ext = ext + self.assetDirPath = assetDirPath + self.parentDirectory = parentDirectory + self.contentsPath = contentsPath + } +} diff --git a/Sources/XcodeProject/Models/AssetType.swift b/Sources/XcodeProject/Models/AssetType.swift new file mode 100644 index 0000000..9240acd --- /dev/null +++ b/Sources/XcodeProject/Models/AssetType.swift @@ -0,0 +1,13 @@ +public enum AssetType: Int, CaseIterable { + case image + case data + case color + + public var ext: String { + switch self { + case .image: return "imageset" + case .data: return "dataset" + case .color: return "colorset" + } + } +} diff --git a/Sources/XcodeProject/Models/HexColor.swift b/Sources/XcodeProject/Models/HexColor.swift new file mode 100644 index 0000000..b1f67ab --- /dev/null +++ b/Sources/XcodeProject/Models/HexColor.swift @@ -0,0 +1,32 @@ +struct HexColor { + let red: String + let green: String + let blue: String + let alpha: String + + init?(hex: String) { + var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + + if hexString.hasPrefix("#") { + hexString.remove(at: hexString.startIndex) + } + + guard hexString.count == 6 || hexString.count == 8 else { + return nil + } + + if hexString.count == 8 { + let alphaHex = String(hexString.prefix(2)) + let alphaValue = Double(Int(alphaHex, radix: 16) ?? 255) / 255.0 + let formattedAlpha = String(format: "%.3f", alphaValue) + self.alpha = formattedAlpha + hexString = String(hexString.dropFirst(2)) + } else { + self.alpha = "1.000" + } + + self.red = String(hexString.prefix(2)) + self.green = String(hexString.dropFirst(2).prefix(2)) + self.blue = String(hexString.dropFirst(4).prefix(2)) + } +} diff --git a/Sources/XcodeProject/Models/InputPath.swift b/Sources/XcodeProject/Models/InputPath.swift index 9136760..bcc665d 100644 --- a/Sources/XcodeProject/Models/InputPath.swift +++ b/Sources/XcodeProject/Models/InputPath.swift @@ -108,4 +108,8 @@ public extension String { return InputPath(self, projectRoot: Project.projectRoot) } + + var asAbsoluteInputPath: InputPath { + InputPath(PathUtils.toAbsolutePath(self), projectRoot: "") + } } diff --git a/Sources/XcodeProject/Models/RenderingMode.swift b/Sources/XcodeProject/Models/RenderingMode.swift new file mode 100644 index 0000000..890ded2 --- /dev/null +++ b/Sources/XcodeProject/Models/RenderingMode.swift @@ -0,0 +1,5 @@ +public enum RenderingMode: String { + case `default` + case template + case original +} diff --git a/Sources/XcodeProject/Models/XcodeProjectError.swift b/Sources/XcodeProject/Models/XcodeProjectError.swift index 7af7201..89b5369 100644 --- a/Sources/XcodeProject/Models/XcodeProjectError.swift +++ b/Sources/XcodeProject/Models/XcodeProjectError.swift @@ -9,6 +9,8 @@ public enum XcodeProjectError: Error, Equatable, CustomStringConvertible { case xcodeProjectNotFound case missingTargets([String]) case buildConfigurationNotFound(String) + case xcassetsNotFound(InputPath) + case assetNotFound(String) public var description: String { switch self { @@ -28,6 +30,10 @@ public enum XcodeProjectError: Error, Equatable, CustomStringConvertible { return "Xcode project file not found." case .buildConfigurationNotFound(let configName): return "Build configuration not found: \(configName)" + case .assetNotFound(let assetName): + return "Asset not found: \(assetName)" + case .xcassetsNotFound(let xcassetsPath): + return "xcassets folder not found on disk: \(xcassetsPath)" } } } diff --git a/Sources/XcodeProject/Utils/PathUtils.swift b/Sources/XcodeProject/Utils/PathUtils.swift new file mode 100644 index 0000000..1c2189a --- /dev/null +++ b/Sources/XcodeProject/Utils/PathUtils.swift @@ -0,0 +1,29 @@ +import Foundation + +public enum PathUtils { + public static func processWorkingDirectory() -> String? { + let pwd = ProcessInfo.processInfo.environment["PWD"] + var currentDir = pwd + + #if DEBUG + if pwd == nil || pwd == "/tmp" { + currentDir = #filePath + .components(separatedBy: "/") + .dropLast(4) + .joined(separator: "/") + } + #endif + + return currentDir + } + + public static func toAbsolutePath(_ path: String) -> String { + var result = path + + if let currentDir = processWorkingDirectory(), !(path as NSString).isAbsolutePath { + result = ("\(currentDir)/\(path)" as NSString).standardizingPath + } + + return (result as NSString).expandingTildeInPath + } +} diff --git a/Sources/XcodeProjectCLI/XcodeProjectCLI.swift b/Sources/XcodeProjectCLI/XcodeProjectCLI.swift index 63dfe4b..8c220a6 100644 --- a/Sources/XcodeProjectCLI/XcodeProjectCLI.swift +++ b/Sources/XcodeProjectCLI/XcodeProjectCLI.swift @@ -46,6 +46,17 @@ struct XcodeProjectCLI: ParsableCommand { GetBuildSettingCommand.self, SetBuildSettingCommand.self ] + ), + .init( + name: "Assets", + subcommands: [ + AddImageAssetCommand.self, + AddDataAssetCommand.self, + AddColorAssetCommand.self, + ListAssetsCommand.self, + MoveAssetCommand.self, + DeleteAssetCommand.self + ] ) ] ) diff --git a/Sources/XcodeProjectCommands/Assets/AddColorAssetCommand.swift b/Sources/XcodeProjectCommands/Assets/AddColorAssetCommand.swift new file mode 100644 index 0000000..cd05252 --- /dev/null +++ b/Sources/XcodeProjectCommands/Assets/AddColorAssetCommand.swift @@ -0,0 +1,36 @@ +import ArgumentParser +import XcodeProject + +public struct AddColorAssetCommand: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "add-color-asset", + abstract: "Add color asset." + ) + + @Argument + var xcassetsPath: String + + @Option(help: "Hex color in format #RRGGBB or #RRGGBBAA.") + var color: String + + @Option(help: "Hex color for Dark Appearance in format #RRGGBB or #RRGGBBAA.") + var darkColor: String? + + @Option(help: "Color space (default: srgb).") + var colorSpace: String = "srgb" + + @Option(help: "Asset path relative to xcassets. E.g. 'Folder/ColorName'") + var assetPath: String + + public init() {} + + public func run() throws { + let projectAssets = try ProjectAssets(xcassetsPath: xcassetsPath) + try projectAssets.addColor( + hexColor: color, + darkHexColor: darkColor, + colorSpace: colorSpace, + assetPath: assetPath + ) + } +} diff --git a/Sources/XcodeProjectCommands/Assets/AddDataAssetCommand.swift b/Sources/XcodeProjectCommands/Assets/AddDataAssetCommand.swift new file mode 100644 index 0000000..f2cb822 --- /dev/null +++ b/Sources/XcodeProjectCommands/Assets/AddDataAssetCommand.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import XcodeProject + +public struct AddDataAssetCommand: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "add-data-asset", + abstract: "Add data asset." + ) + + @Argument + var xcassetsPath: String + + @Option(name: .customLong("file"), help: "File path.") + var filePath: String + + @Option(help: "Asset path relative to xcassets. E.g. 'Folder/File.txt'") + var assetPath: String + + public init() {} + + public func run() throws { + let projectAssets = try ProjectAssets(xcassetsPath: xcassetsPath) + try projectAssets.addData( + filePath: filePath.asAbsoluteInputPath, + assetPath: assetPath + ) + } +} diff --git a/Sources/XcodeProjectCommands/Assets/AddImageAssetCommand.swift b/Sources/XcodeProjectCommands/Assets/AddImageAssetCommand.swift new file mode 100644 index 0000000..d673d09 --- /dev/null +++ b/Sources/XcodeProjectCommands/Assets/AddImageAssetCommand.swift @@ -0,0 +1,46 @@ +import ArgumentParser +import XcodeProject + +extension RenderingMode: ExpressibleByArgument {} + +public struct AddImageAssetCommand: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "add-image-asset", + abstract: "Add image asset." + ) + + @Argument + var xcassetsPath: String + + @Option(name: .customLong("file"), help: .init( + "File path.", + discussion: "Image will be added to Any Appearance.", + valueName: "file-path" + )) + var filePath: String + + @Option(name: .customLong("dark-file"), help: .init( + "File path.", + discussion: "Image will be added to Dark Appearance.", + valueName: "file-path" + )) + var darkFilePath: String? + + @Option(help: "Asset path relative to xcassets. E.g. 'Folder/Image.png'") + var assetPath: String + + @Option(name: .customLong("mode"), help: .init("Rendering mode.", valueName: "default|template|original")) + var renderingMode: RenderingMode = .default + + public init() {} + + public func run() throws { + let projectAssets = try ProjectAssets(xcassetsPath: xcassetsPath) + try projectAssets.addImage( + filePath: filePath.asAbsoluteInputPath, + darkFilePath: darkFilePath?.asAbsoluteInputPath, + assetPath: assetPath, + renderingMode: renderingMode + ) + } +} diff --git a/Sources/XcodeProjectCommands/Assets/DeleteAssetCommand.swift b/Sources/XcodeProjectCommands/Assets/DeleteAssetCommand.swift new file mode 100644 index 0000000..7933202 --- /dev/null +++ b/Sources/XcodeProjectCommands/Assets/DeleteAssetCommand.swift @@ -0,0 +1,24 @@ +import ArgumentParser +import XcodeProject + +public struct DeleteAssetCommand: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "delete-asset", + abstract: "Delete asset." + ) + + @Argument + var xcassetsPath: String + + @Option(help: "Asset path relative to xcassets. E.g. 'Folder/File'") + var assetPath: String + + public init() {} + + public func run() throws { + let projectAssets = try ProjectAssets(xcassetsPath: xcassetsPath) + try projectAssets.deleteAsset( + assetPath: assetPath + ) + } +} diff --git a/Sources/XcodeProjectCommands/Assets/ListAssetsCommand.swift b/Sources/XcodeProjectCommands/Assets/ListAssetsCommand.swift new file mode 100644 index 0000000..0040cef --- /dev/null +++ b/Sources/XcodeProjectCommands/Assets/ListAssetsCommand.swift @@ -0,0 +1,66 @@ +import ArgumentParser +import XcodeProject + +public struct ListAssetsCommand: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "list-assets", + abstract: "List all assets." + ) + + @Argument + var xcassetsPath: String + + @Option(help: .init("Filter by asset type (default: all).", valueName: "color|data|image")) + var type: String? + + public init() {} + + public func run() throws { + let projectAssets = try ProjectAssets(xcassetsPath: xcassetsPath) + + let assetType: AssetType? = { + guard let type else { return nil } + switch type.lowercased() { + case "color": return .color + case "data": return .data + case "image": return .image + default: return nil + } + }() + + let assets = try projectAssets.listAll() + + let filteredAssets = assets.filter { asset in + guard let assetType else { return true } + return asset.key == assetType + } + + let allTypes = filteredAssets.keys + .sorted(by: { $0.rawValue < $1.rawValue }) + + for (index, assetType) in allTypes.enumerated() { + if index > 0 { + print("") + } + + if type == nil { + print("> \(assetType.title)") + } + + let fileteredPaths = filteredAssets[assetType] ?? [] + for path in fileteredPaths.sorted() { + print(path) + } + } + } +} + +extension AssetType { + var title: String { + switch self { + case .image: return "Images" + case .data: return "Data Files" + case .color: return "Colors" + } + } +} diff --git a/Sources/XcodeProjectCommands/Assets/MoveAssetCommand.swift b/Sources/XcodeProjectCommands/Assets/MoveAssetCommand.swift new file mode 100644 index 0000000..c9263c7 --- /dev/null +++ b/Sources/XcodeProjectCommands/Assets/MoveAssetCommand.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import XcodeProject + +public struct MoveAssetCommand: ParsableCommand { + public static var configuration = CommandConfiguration( + commandName: "move-asset", + abstract: "Move asset." + ) + + @Argument + var xcassetsPath: String + + @Option(help: "Asset path relative to xcassets. E.g. 'Folder/File'") + var assetPath: String + + @Option(help: "Destination path relative to xcassets. E.g. 'NewFolder/NewFile'") + var dest: String + + public init() {} + + public func run() throws { + let projectAssets = try ProjectAssets(xcassetsPath: xcassetsPath) + try projectAssets.moveAsset( + oldAssetPath: assetPath, + newAssetPath: dest + ) + } +} diff --git a/Sources/XcodeProjectCommands/BuildSettings/GetBuildSettingCommand.swift b/Sources/XcodeProjectCommands/BuildSettings/GetBuildSettingCommand.swift index 06939b9..a68dad2 100644 --- a/Sources/XcodeProjectCommands/BuildSettings/GetBuildSettingCommand.swift +++ b/Sources/XcodeProjectCommands/BuildSettings/GetBuildSettingCommand.swift @@ -4,7 +4,7 @@ import XcodeProject public struct GetBuildSettingCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "get-build-setting", - abstract: "Get a build setting for specified target in the project." + abstract: "Get build setting." ) @OptionGroup diff --git a/Sources/XcodeProjectCommands/BuildSettings/SetBuildSettingCommand.swift b/Sources/XcodeProjectCommands/BuildSettings/SetBuildSettingCommand.swift index 4800d5b..7284d3f 100644 --- a/Sources/XcodeProjectCommands/BuildSettings/SetBuildSettingCommand.swift +++ b/Sources/XcodeProjectCommands/BuildSettings/SetBuildSettingCommand.swift @@ -4,7 +4,7 @@ import XcodeProject public struct SetBuildSettingCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "set-build-setting", - abstract: "Set a build setting for specified targets in the project." + abstract: "Set build setting." ) @OptionGroup diff --git a/Sources/XcodeProjectCommands/Files/AddFileCommand.swift b/Sources/XcodeProjectCommands/Files/AddFileCommand.swift index fe849d2..585b233 100644 --- a/Sources/XcodeProjectCommands/Files/AddFileCommand.swift +++ b/Sources/XcodeProjectCommands/Files/AddFileCommand.swift @@ -4,7 +4,7 @@ import XcodeProject public struct AddFileCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "add-file", - abstract: "Add a file to specified targets in the project." + abstract: "Add file to specified targets." ) @OptionGroup diff --git a/Sources/XcodeProjectCommands/Files/DeleteFileCommand.swift b/Sources/XcodeProjectCommands/Files/DeleteFileCommand.swift index b427523..5f09114 100644 --- a/Sources/XcodeProjectCommands/Files/DeleteFileCommand.swift +++ b/Sources/XcodeProjectCommands/Files/DeleteFileCommand.swift @@ -5,7 +5,7 @@ import XcodeProject public struct DeleteFileCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "delete-file", - abstract: "Delete a file from the project." + abstract: "Delete file." ) @OptionGroup diff --git a/Sources/XcodeProjectCommands/Files/MoveFileCommand.swift b/Sources/XcodeProjectCommands/Files/MoveFileCommand.swift index 48ffa56..3fb219c 100644 --- a/Sources/XcodeProjectCommands/Files/MoveFileCommand.swift +++ b/Sources/XcodeProjectCommands/Files/MoveFileCommand.swift @@ -5,7 +5,7 @@ import XcodeProject public struct MoveFileCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "move-file", - abstract: "Move a file to a different location within the project.", + abstract: "Move file.", discussion: """ - If the destination file already exists in the project, the operation will fail. - The target memberships of the file will be preserved. diff --git a/Sources/XcodeProjectCommands/Files/RenameFileCommand.swift b/Sources/XcodeProjectCommands/Files/RenameFileCommand.swift index 879e0bd..8d34b6d 100644 --- a/Sources/XcodeProjectCommands/Files/RenameFileCommand.swift +++ b/Sources/XcodeProjectCommands/Files/RenameFileCommand.swift @@ -5,7 +5,7 @@ import XcodeProject public struct RenameFileCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "rename-file", - abstract: "Rename a file within the project." + abstract: "Rename file." ) @OptionGroup diff --git a/Sources/XcodeProjectCommands/Groups/AddGroupCommand.swift b/Sources/XcodeProjectCommands/Groups/AddGroupCommand.swift index 42d936d..a168fb4 100644 --- a/Sources/XcodeProjectCommands/Groups/AddGroupCommand.swift +++ b/Sources/XcodeProjectCommands/Groups/AddGroupCommand.swift @@ -5,7 +5,7 @@ import XcodeProject public struct AddGroupCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "add-group", - abstract: "Add a group to the project." + abstract: "Add group." ) @OptionGroup diff --git a/Sources/XcodeProjectCommands/Groups/DeleteGroupCommand.swift b/Sources/XcodeProjectCommands/Groups/DeleteGroupCommand.swift index ccc1247..76c11dc 100644 --- a/Sources/XcodeProjectCommands/Groups/DeleteGroupCommand.swift +++ b/Sources/XcodeProjectCommands/Groups/DeleteGroupCommand.swift @@ -5,7 +5,7 @@ import XcodeProject public struct DeleteGroupCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "delete-group", - abstract: "Delete a group from the project." + abstract: "Delete group." ) @OptionGroup diff --git a/Sources/XcodeProjectCommands/Groups/MoveGroupCommand.swift b/Sources/XcodeProjectCommands/Groups/MoveGroupCommand.swift index b9df608..121a3ef 100644 --- a/Sources/XcodeProjectCommands/Groups/MoveGroupCommand.swift +++ b/Sources/XcodeProjectCommands/Groups/MoveGroupCommand.swift @@ -5,7 +5,7 @@ import XcodeProject public struct MoveGroupCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "move-group", - abstract: "Move a group to a different location within the project.", + abstract: "Move group.", discussion: """ - This command does not allow to move and rename the group at the same time. - Groups will be automatically merged if another group with the same name already exists at the destination. diff --git a/Sources/XcodeProjectCommands/Groups/RenameGroupCommand.swift b/Sources/XcodeProjectCommands/Groups/RenameGroupCommand.swift index 7c070f4..c8b828a 100644 --- a/Sources/XcodeProjectCommands/Groups/RenameGroupCommand.swift +++ b/Sources/XcodeProjectCommands/Groups/RenameGroupCommand.swift @@ -5,7 +5,7 @@ import XcodeProject public struct RenameGroupCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "rename-group", - abstract: "Rename a group within the project." + abstract: "Rename group." ) @OptionGroup diff --git a/Sources/XcodeProjectCommands/Targets/SetTargetCommand.swift b/Sources/XcodeProjectCommands/Targets/SetTargetCommand.swift index 9417ae4..fef78ae 100644 --- a/Sources/XcodeProjectCommands/Targets/SetTargetCommand.swift +++ b/Sources/XcodeProjectCommands/Targets/SetTargetCommand.swift @@ -4,7 +4,7 @@ import XcodeProject public struct SetTargetCommand: ParsableCommand { public static var configuration = CommandConfiguration( commandName: "set-target", - abstract: "Set target for an existing file." + abstract: "Set target for existing file." ) @OptionGroup diff --git a/TestResources/Assets/DataFile.json b/TestResources/Assets/DataFile.json new file mode 100644 index 0000000..665f56e --- /dev/null +++ b/TestResources/Assets/DataFile.json @@ -0,0 +1,12 @@ +{ + "data" : [ + { + "filename" : "DataFile.txt", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/Assets/DataFile.txt b/TestResources/Assets/DataFile.txt new file mode 100644 index 0000000..3f79730 --- /dev/null +++ b/TestResources/Assets/DataFile.txt @@ -0,0 +1 @@ +Sample File \ No newline at end of file diff --git a/TestResources/Assets/Image.png b/TestResources/Assets/Image.png new file mode 100644 index 0000000..ef8bd1c Binary files /dev/null and b/TestResources/Assets/Image.png differ diff --git a/TestResources/Assets/ImageDark.png b/TestResources/Assets/ImageDark.png new file mode 100644 index 0000000..09c86f8 Binary files /dev/null and b/TestResources/Assets/ImageDark.png differ diff --git a/TestResources/Assets/ImageUniversal.json b/TestResources/Assets/ImageUniversal.json new file mode 100644 index 0000000..62beec5 --- /dev/null +++ b/TestResources/Assets/ImageUniversal.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Image.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/Assets/ImageWithDark.json b/TestResources/Assets/ImageWithDark.json new file mode 100644 index 0000000..cb94b0b --- /dev/null +++ b/TestResources/Assets/ImageWithDark.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "Image.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Image_dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/Assets/ImageWithDarkTemplated.json b/TestResources/Assets/ImageWithDarkTemplated.json new file mode 100644 index 0000000..0680b2c --- /dev/null +++ b/TestResources/Assets/ImageWithDarkTemplated.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "Image.png", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Image_dark.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/TestResources/Assets/Red.json b/TestResources/Assets/Red.json new file mode 100644 index 0000000..51bb24a --- /dev/null +++ b/TestResources/Assets/Red.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/Assets/RedAndGreen.json b/TestResources/Assets/RedAndGreen.json new file mode 100644 index 0000000..8f845ca --- /dev/null +++ b/TestResources/Assets/RedAndGreen.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0xFF", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/Assets/RedAndGreenAlpha.json b/TestResources/Assets/RedAndGreenAlpha.json new file mode 100644 index 0000000..7ed2ab1 --- /dev/null +++ b/TestResources/Assets/RedAndGreenAlpha.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.498", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.400", + "blue" : "0x00", + "green" : "0xFF", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/Assets/RedAndGreenAlphaDisplayP3.json b/TestResources/Assets/RedAndGreenAlphaDisplayP3.json new file mode 100644 index 0000000..26aad1d --- /dev/null +++ b/TestResources/Assets/RedAndGreenAlphaDisplayP3.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "0.498", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "0.400", + "blue" : "0x00", + "green" : "0xFF", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/Contents.json b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/SomeImage.imageset/Contents.json b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/SomeImage.imageset/Contents.json new file mode 100644 index 0000000..050dd8e --- /dev/null +++ b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/SomeImage.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "SomeImage.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/SomeImage.imageset/SomeImage.png b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/SomeImage.imageset/SomeImage.png new file mode 100644 index 0000000..6367f86 Binary files /dev/null and b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder/SomeImage.imageset/SomeImage.png differ diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/Contents.json b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/Contents.json b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/NewImage.imageset/Contents.json b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/NewImage.imageset/Contents.json new file mode 100644 index 0000000..050dd8e --- /dev/null +++ b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/NewImage.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "SomeImage.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/NewImage.imageset/SomeImage.png b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/NewImage.imageset/SomeImage.png new file mode 100644 index 0000000..6367f86 Binary files /dev/null and b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/NewImage.imageset/SomeImage.png differ diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/SampleData.dataset/Contents.json b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/SampleData.dataset/Contents.json new file mode 100644 index 0000000..1455191 --- /dev/null +++ b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/SampleData.dataset/Contents.json @@ -0,0 +1,13 @@ +{ + "data" : [ + { + "filename" : "DataFile.txt", + "idiom" : "universal", + "universal-type-identifier" : "public.plain-text" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/SampleData.dataset/DataFile.txt b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/SampleData.dataset/DataFile.txt new file mode 100644 index 0000000..3f79730 --- /dev/null +++ b/TestResources/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets/Folder2/NestedFolder/SampleData.dataset/DataFile.txt @@ -0,0 +1 @@ +Sample File \ No newline at end of file diff --git a/Tests/XcodeProjectCommandsTests/AddColorAssetCommandTests.swift b/Tests/XcodeProjectCommandsTests/AddColorAssetCommandTests.swift new file mode 100644 index 0000000..f89f5b8 --- /dev/null +++ b/Tests/XcodeProjectCommandsTests/AddColorAssetCommandTests.swift @@ -0,0 +1,94 @@ +import Testing +@testable import XcodeProject +@testable import XcodeProjectCommands + +extension SerializedSuite { + @Suite("Add Color Asset Command", .serialized) + final class AddColorAssetCommandTests: ProjectAssetsTests {} +} + +extension SerializedSuite.AddColorAssetCommandTests { + @Test + func addColorAsset_shouldAddUniversalColorAsset() throws { + let assetPath = "Colors/Red" + let command = try AddColorAssetCommand.parse([ + testXCAssetsPath, + "--color", + "#FF0000", + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Colors/Red.colorset/Contents.json", + and: "\(testResourcesPath)/Red.json" + ) + } + + @Test + func addColorAsset_shouldAddLightAndDarkColorAsset() throws { + let assetPath = "Colors/RedAndGreen" + let command = try AddColorAssetCommand.parse([ + testXCAssetsPath, + "--color", + "#FF0000", + "--dark-color", + "#00FF00", + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Colors/RedAndGreen.colorset/Contents.json", + and: "\(testResourcesPath)/RedAndGreen.json" + ) + } + + @Test + func addColorAsset_shouldAddLightAndDarkColorAsset_withAlpha() throws { + let assetPath = "Colors/RedAndGreenAlpha" + let command = try AddColorAssetCommand.parse([ + testXCAssetsPath, + "--color", + "#7FFF0000", + "--dark-color", + "#6600FF00", + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Colors/RedAndGreenAlpha.colorset/Contents.json", + and: "\(testResourcesPath)/RedAndGreenAlpha.json" + ) + } + + @Test + func addColorAsset_shouldAddLightAndDarkColorAsset_withDisplayP3Space() throws { + let assetPath = "Colors/RedAndGreenAlphaDisplayP3" + let command = try AddColorAssetCommand.parse([ + testXCAssetsPath, + "--color", + "#7FFF0000", + "--dark-color", + "#6600FF00", + "--color-space", + "display-p3", + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Colors/RedAndGreenAlphaDisplayP3.colorset/Contents.json", + and: "\(testResourcesPath)/RedAndGreenAlphaDisplayP3.json" + ) + } +} diff --git a/Tests/XcodeProjectCommandsTests/AddDataAssetCommandTests.swift b/Tests/XcodeProjectCommandsTests/AddDataAssetCommandTests.swift new file mode 100644 index 0000000..cd28535 --- /dev/null +++ b/Tests/XcodeProjectCommandsTests/AddDataAssetCommandTests.swift @@ -0,0 +1,52 @@ +import Testing +@testable import XcodeProject +@testable import XcodeProjectCommands + +extension SerializedSuite { + @Suite("Add Data Asset Command", .serialized) + final class AddDataAssetCommandTests: ProjectAssetsTests {} +} + +extension SerializedSuite.AddDataAssetCommandTests { + @Test + func addImageAsset_shouldAddDataAsset() throws { + let assetPath = "Raw/DataFile.txt" + let command = try AddDataAssetCommand.parse([ + testXCAssetsPath, + "--file", + dataFilePath, + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Raw/DataFile.dataset/Contents.json", + and: "\(testResourcesPath)/DataFile.json" + ) + try expectSameFiles( + at: "\(testXCAssetsPath)/Raw/DataFile.dataset/DataFile.txt", + and: "\(testResourcesPath)/DataFile.txt" + ) + } + + @Test + func addDataAsset_shouldThrowException_whenFileDoesntExist() throws { + let assetPath = "Raw/DataFile.txt" + let nonExistentDataFilePath = "\(testResourcesPath)/NonExistentFile.txt" + let command = try AddDataAssetCommand.parse([ + testXCAssetsPath, + "--file", + nonExistentDataFilePath, + "--asset-path", + assetPath + ]) + + #expect(throws: XcodeProjectError.fileNotFoundOnDisk( + nonExistentDataFilePath.asAbsoluteInputPath + )) { + try command.run() + } + } +} diff --git a/Tests/XcodeProjectCommandsTests/AddImageAssetCommandTests.swift b/Tests/XcodeProjectCommandsTests/AddImageAssetCommandTests.swift new file mode 100644 index 0000000..d612c63 --- /dev/null +++ b/Tests/XcodeProjectCommandsTests/AddImageAssetCommandTests.swift @@ -0,0 +1,158 @@ +import Testing +@testable import XcodeProject +@testable import XcodeProjectCommands + +extension SerializedSuite { + @Suite("Add Image Asset Command", .serialized) + final class AddImageAssetCommandTests: ProjectAssetsTests {} +} + +extension SerializedSuite.AddImageAssetCommandTests { + @Test + func addImageAsset_shouldAddImageAsset_light() throws { + let assetPath = "Icons/Image.png" + let command = try AddImageAssetCommand.parse([ + testXCAssetsPath, + "--file", + lightImagePath, + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Icons/Image.imageset/Contents.json", + and: "\(testResourcesPath)/ImageUniversal.json" + ) + try expectSameFiles( + at: "\(testXCAssetsPath)/Icons/Image.imageset/Image.png", + and: "\(testResourcesPath)/Image.png" + ) + } + + @Test + func addImageAsset_shouldAddImageAsset_toExistingFolder() throws { + let assetPath = "Folder/Image.png" + let command = try AddImageAssetCommand.parse([ + testXCAssetsPath, + "--file", + lightImagePath, + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Folder/Image.imageset/Contents.json", + and: "\(testResourcesPath)/ImageUniversal.json" + ) + try expectSameFiles( + at: "\(testXCAssetsPath)/Folder/Image.imageset/Image.png", + and: "\(testResourcesPath)/Image.png" + ) + } + + @Test + func addImageAsset_shouldAddImageAsset_toRoot() throws { + let assetPath = "Image.png" + let command = try AddImageAssetCommand.parse([ + testXCAssetsPath, + "--file", + lightImagePath, + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Image.imageset/Contents.json", + and: "\(testResourcesPath)/ImageUniversal.json" + ) + try expectSameFiles( + at: "\(testXCAssetsPath)/Image.imageset/Image.png", + and: "\(testResourcesPath)/Image.png" + ) + } + + @Test + func addImageAsset_shouldAddImageAsset_dark() throws { + let assetPath = "Icons/Image.png" + let command = try AddImageAssetCommand.parse([ + testXCAssetsPath, + "--file", + lightImagePath, + "--dark-file", + darkImagePath, + "--asset-path", + assetPath + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Icons/Image.imageset/Contents.json", + and: "\(testResourcesPath)/ImageWithDark.json" + ) + try expectSameFiles( + at: "\(testXCAssetsPath)/Icons/Image.imageset/Image.png", + and: "\(testResourcesPath)/Image.png" + ) + try expectSameFiles( + at: "\(testXCAssetsPath)/Icons/Image.imageset/Image_dark.png", + and: "\(testResourcesPath)/ImageDark.png" + ) + } + + @Test + func addImageAsset_shouldAddImageAsset_darkAndTemplated() throws { + let assetPath = "Icons/Image.png" + let command = try AddImageAssetCommand.parse([ + testXCAssetsPath, + "--file", + lightImagePath, + "--dark-file", + darkImagePath, + "--asset-path", + assetPath, + "--mode", + "template" + ]) + + try command.run() + + try expectSameFiles( + at: "\(testXCAssetsPath)/Icons/Image.imageset/Contents.json", + and: "\(testResourcesPath)/ImageWithDarkTemplated.json" + ) + try expectSameFiles( + at: "\(testXCAssetsPath)/Icons/Image.imageset/Image.png", + and: "\(testResourcesPath)/Image.png" + ) + try expectSameFiles( + at: "\(testXCAssetsPath)/Icons/Image.imageset/Image_dark.png", + and: "\(testResourcesPath)/ImageDark.png" + ) + } + + @Test + func addImageAsset_shouldThrowException_whenFileDoesntExist() throws { + let assetPath = "Icons/Image.png" + let nonExistentDataFilePath = "\(testResourcesPath)/NonExistentFile.jpg" + let command = try AddImageAssetCommand.parse([ + testXCAssetsPath, + "--file", + nonExistentDataFilePath, + "--asset-path", + assetPath + ]) + + #expect(throws: XcodeProjectError.fileNotFoundOnDisk( + nonExistentDataFilePath.asAbsoluteInputPath + )) { + try command.run() + } + } +} diff --git a/Tests/XcodeProjectCommandsTests/DeleteAssetCommandTests.swift b/Tests/XcodeProjectCommandsTests/DeleteAssetCommandTests.swift new file mode 100644 index 0000000..a4c9bad --- /dev/null +++ b/Tests/XcodeProjectCommandsTests/DeleteAssetCommandTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import XcodeProject +@testable import XcodeProjectCommands + +extension SerializedSuite { + @Suite("Delete Asset Command", .serialized) + final class DeleteAssetCommandTests: ProjectAssetsTests {} +} + +extension SerializedSuite.DeleteAssetCommandTests { + @Test + func deleteAsset_shouldDeleteAsset() throws { + let assetPath = "Folder/SomeImage" + let command = try DeleteAssetCommand.parse([ + testXCAssetsPath, + "--asset-path", + assetPath + ]) + + try command.run() + + #expect(!FileManager.default.fileExists( + atPath: "\(testXCAssetsPath)/Folder/SomeImage.imageset" + )) + } + + @Test + func deleteAsset_shouldReturnError_whenFileDoesNotExistOnDisk() throws { + let file = "NonExistingFile.png" + + let sut = try DeleteAssetCommand.parse([ + testXCAssetsPath, + "--asset-path", + file + ]) + + #expect(throws: XcodeProjectError.assetNotFound(file)) { + try sut.run() + } + } +} diff --git a/Tests/XcodeProjectCommandsTests/InputPathTests.swift b/Tests/XcodeProjectCommandsTests/InputPathTests.swift index d1cad7c..9e112c9 100644 --- a/Tests/XcodeProjectCommandsTests/InputPathTests.swift +++ b/Tests/XcodeProjectCommandsTests/InputPathTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import XcodeProject @testable import XcodeProjectCommands @@ -165,4 +166,42 @@ struct InputPathTests { #expect(path.fileExtension == "swift") } + + @Test + func inputPath_asAbsoluteInputPath_shouldReturnAbsolutePath_whenRelativePathIsProvided() { + let projectRoot = #filePath + .components(separatedBy: "/") + .dropLast(3) + .joined(separator: "/") + + let path = "Group1/Group2/file.swift".asAbsoluteInputPath + + #expect(path.absolutePath == "\(projectRoot)/Group1/Group2/file.swift") + } + + @Test + func inputPath_asAbsoluteInputPath_shouldReturnAbsolutePath_whenDotPathIsProvided() { + let projectRoot = #filePath + .components(separatedBy: "/") + .dropLast(3) + .joined(separator: "/") + + let path = "../XcodeProjectCLI/Group1/Group2/file.swift".asAbsoluteInputPath + + #expect(path.absolutePath == "\(projectRoot)/Group1/Group2/file.swift") + } + + @Test + func inputPath_asAbsoluteInputPath_shouldReturnAbsolutePath_whenTildePathIsProvided() { + let path = "~/XcodeProjectCLI/Group1/Group2/file.swift".asAbsoluteInputPath + + #expect(path.absolutePath == "\(NSHomeDirectory())/XcodeProjectCLI/Group1/Group2/file.swift") + } + + @Test + func inputPath_asAbsoluteInputPath_shouldReturnAbsolutePath_whenAbsolutePathIsProvided() { + let path = "/XcodeProjectCLI/Group1/Group2/file.swift" + + #expect(path.asAbsoluteInputPath.absolutePath == path) + } } diff --git a/Tests/XcodeProjectCommandsTests/ListAssetsCommandTests.swift b/Tests/XcodeProjectCommandsTests/ListAssetsCommandTests.swift new file mode 100644 index 0000000..025ef16 --- /dev/null +++ b/Tests/XcodeProjectCommandsTests/ListAssetsCommandTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import XcodeProject +@testable import XcodeProjectCommands + +extension SerializedSuite { + @Suite("List Assets Command", .serialized) + final class ListAssetsCommandTests: ProjectAssetsTests {} +} + +extension SerializedSuite.ListAssetsCommandTests { + @Test + func listAssets_shouldReturnAllAssets() throws { + var command = try ListAssetsCommand.parse([ + testXCAssetsPath + ]) + + let output = try runTest(for: &command) + + #expect(output == """ + > Images + Folder/SomeImage + Folder2/NestedFolder/NewImage + + > Data Files + Folder2/NestedFolder/SampleData + + > Colors + AccentColor + """) + } + + @Test + func listAssets_shouldReturnColors() throws { + var command = try ListAssetsCommand.parse([ + testXCAssetsPath, + "--type", + "color" + ]) + + let output = try runTest(for: &command) + + #expect(output == """ + AccentColor + """) + } + + @Test + func listAssets_shouldReturnDataFiles() throws { + var command = try ListAssetsCommand.parse([ + testXCAssetsPath, + "--type", + "data" + ]) + + let output = try runTest(for: &command) + + #expect(output == """ + Folder2/NestedFolder/SampleData + """) + } + + @Test + func listAssets_shouldReturnImageFiles() throws { + var command = try ListAssetsCommand.parse([ + testXCAssetsPath, + "--type", + "image" + ]) + + let output = try runTest(for: &command) + + #expect(output == """ + Folder/SomeImage + Folder2/NestedFolder/NewImage + """) + } + + @Test + func listAssets_shouldThrowException_whenXCAssetsDontExist() throws { + let command = try ListAssetsCommand.parse([ + "\(testXCAssetsPath)/NonExistentAssets.xcassets" + ]) + + #expect(throws: XcodeProjectError.xcassetsNotFound( + "\(testXCAssetsPath)/NonExistentAssets.xcassets".asAbsoluteInputPath + )) { + try command.run() + } + } +} diff --git a/Tests/XcodeProjectCommandsTests/MoveAssetCommandTests.swift b/Tests/XcodeProjectCommandsTests/MoveAssetCommandTests.swift new file mode 100644 index 0000000..ac4324a --- /dev/null +++ b/Tests/XcodeProjectCommandsTests/MoveAssetCommandTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import XcodeProject +@testable import XcodeProjectCommands + +extension SerializedSuite { + @Suite("Move Asset Command", .serialized) + final class MoveAssetCommandTests: ProjectAssetsTests {} +} + +extension SerializedSuite.MoveAssetCommandTests { + @Test + func moveAsset_shouldMoveAsset() throws { + let assetPath = "Folder/SomeImage" + let command = try MoveAssetCommand.parse([ + testXCAssetsPath, + "--asset-path", + assetPath, + "--dest", + "NewFolder/MovedImage" + ]) + + try command.run() + + #expect(!FileManager.default.fileExists( + atPath: "\(testXCAssetsPath)/Folder/SomeImage.imageset" + )) + #expect(FileManager.default.fileExists( + atPath: "\(testXCAssetsPath)/NewFolder/MovedImage.imageset" + )) + } + + @Test + func moveAsset_shouldReturnError_whenFileDoesNotExistOnDisk() throws { + let file = "Folder/NonExistingFile.png" + + let sut = try MoveAssetCommand.parse([ + testXCAssetsPath, + "--asset-path", + file, + "--dest", + "SomeDestination/Image.png" + ]) + + #expect(throws: XcodeProjectError.assetNotFound(file)) { + try sut.run() + } + } +} diff --git a/Tests/XcodeProjectCommandsTests/Utils/ProjectAssetsTests.swift b/Tests/XcodeProjectCommandsTests/Utils/ProjectAssetsTests.swift new file mode 100644 index 0000000..f84bfab --- /dev/null +++ b/Tests/XcodeProjectCommandsTests/Utils/ProjectAssetsTests.swift @@ -0,0 +1,72 @@ +import ArgumentParser +import Foundation +import Testing +@testable import XcodeProject + +class ProjectAssetsTests { + let assets: ProjectAssets + let testXCAssetsPath: String + let testProjectPath: String + let testResourcesPath: String + let lightImagePath: String + let dataFilePath: String + let darkImagePath: String + + init() throws { + let projectRoot = #filePath + .components(separatedBy: "/") + .dropLast(4) + .joined(separator: "/") + + self.testResourcesPath = "\(projectRoot)/TestResources/Assets" + let testXCAssetsPath = "\(projectRoot)/.test/XcodebuildNvimApp/XcodebuildNvimApp/Assets.xcassets" + self.testXCAssetsPath = testXCAssetsPath + self.lightImagePath = "\(testResourcesPath)/Image.png" + self.darkImagePath = "\(testResourcesPath)/ImageDark.png" + self.dataFilePath = "\(testResourcesPath)/DataFile.txt" + + let testPath = "\(projectRoot)/.test" + self.testProjectPath = "\(testPath)/XcodebuildNvimApp" + + try? FileManager.default.removeItem(atPath: testProjectPath) + try FileManager.default.createDirectory(atPath: testPath, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: "\(projectRoot)/TestResources/XcodebuildNvimApp", toPath: testProjectPath) + + self.assets = try ProjectAssets(xcassetsPath: testXCAssetsPath) + } + + deinit { + try? FileManager.default.removeItem(atPath: testProjectPath) + } + + func expectSameFiles(at firstPath: String, and secondPath: String) throws { + let firstData = try Data(contentsOf: URL(fileURLWithPath: firstPath)) + let secondData = try Data(contentsOf: URL(fileURLWithPath: secondPath)) + #expect(firstData == secondData) + } + + func runTest(for command: inout some ParsableCommand) throws -> String { + let output = try captureOutput { + try command.run() + } + return output + } + + func captureOutput(_ block: () throws -> ()) rethrows -> String { + let pipe = Pipe() + let original = dup(STDOUT_FILENO) + + setvbuf(stdout, nil, _IONBF, 0) + dup2(pipe.fileHandleForWriting.fileDescriptor, STDOUT_FILENO) + + try block() + + fflush(stdout) + pipe.fileHandleForWriting.closeFile() + dup2(original, STDOUT_FILENO) + close(original) + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } +}