├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ ├── CoreTests.xcscheme │ ├── Run.xcscheme │ ├── SwiftPackageInfo.xcscheme │ ├── swift-package-info-Package.xcscheme │ └── swift-package-info.xcscheme ├── LICENSE ├── MeasurementApp.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── MeasurementApp ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── 100.png │ │ ├── 1024.png │ │ ├── 114.png │ │ ├── 120.png │ │ ├── 128.png │ │ ├── 144.png │ │ ├── 152.png │ │ ├── 16.png │ │ ├── 167.png │ │ ├── 180.png │ │ ├── 20.png │ │ ├── 256.png │ │ ├── 29.png │ │ ├── 32.png │ │ ├── 40.png │ │ ├── 50.png │ │ ├── 512.png │ │ ├── 57.png │ │ ├── 58.png │ │ ├── 60.png │ │ ├── 64.png │ │ ├── 72.png │ │ ├── 76.png │ │ ├── 80.png │ │ ├── 87.png │ │ └── Contents.json │ └── Contents.json ├── ContentView.swift ├── Info.plist ├── MeasurementApp.swift └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── Package.swift ├── README.md ├── Sources ├── App │ ├── Providers │ │ ├── BinarySizeProvider │ │ │ ├── AppManager.swift │ │ │ ├── BinarySizeProvider.swift │ │ │ └── SizeMeasurer.swift │ │ ├── DependenciesProvider │ │ │ └── DependenciesProvider.swift │ │ └── PlatformsProvider │ │ │ └── PlatformsProvider.swift │ └── Services │ │ ├── SwiftPackageService.swift │ │ └── SwiftPackageValidator.swift ├── Core │ ├── Console.swift │ ├── Extensions │ │ ├── AbsolutePath+Core.swift │ │ ├── Array+Core.swift │ │ ├── JSONEncoder+Core.swift │ │ ├── KeyPath+Core.swift │ │ ├── Result+Core.swift │ │ └── URL+Core.swift │ ├── InfoProvider.swift │ ├── Models │ │ ├── PackageDefinition.swift │ │ ├── PackageModel.swift │ │ ├── ResurceState.swift │ │ └── SizeOnDisk.swift │ ├── PackageLoader.swift │ └── Shell.swift ├── CoreTestSupport │ ├── Doubles │ │ ├── Fixtures │ │ │ ├── Fixture+ProvidedInfo.swift │ │ │ ├── Fixture+SwiftPackage.swift │ │ │ └── Fixture.swift │ │ └── Mocks │ │ │ ├── ProgressAnimationMock.swift │ │ │ └── TerminalControllerMock.swift │ └── Extensions │ │ └── XCTest+TestSupport.swift ├── Reports │ ├── Generators │ │ ├── ConsoleReportGenerator.swift │ │ ├── JSONDumpReportGenerator.swift │ │ └── ReportGenerating.swift │ ├── Report.swift │ ├── ReportCell.swift │ ├── ReportFormat.swift │ └── Reporting.swift ├── Run │ ├── ExpressibleByArgument+Run.swift │ ├── Subcommands │ │ ├── BinarySize.swift │ │ ├── Dependencies.swift │ │ ├── FullAnalyzes.swift │ │ └── Platforms.swift │ └── SwiftPackageInfo.swift └── SwiftPackageInfo │ └── SwiftPackageInfoLibrary.swift └── Tests ├── AppTests ├── Fixtures │ └── Fixture+PackageContent.swift └── Providers │ ├── BinarySizeProvider │ ├── BinarySizeProviderErrorTests.swift │ └── BinarySizeProviderTests.swift │ ├── DependenciesProvider │ └── DependenciesProviderTests.swift │ └── PlatformsProvider │ └── PlatformsProviderTests.swift ├── CoreTests ├── Helpers │ └── FileManager+CoreTests.swift ├── Models │ ├── PackageDefinitionTests.swift │ └── SizeOnDiskTests.swift ├── ProvidedInfoTests.swift └── URLExtensionTests.swift ├── ReportsTests └── Generators │ ├── ConsoleReportGeneratorTests.swift │ └── JSONDumpReportGeneratorTests.swift ├── RunTests └── RunTests.swift └── SwiftPackageInfoTests └── LibraryTests.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | env: 8 | DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer 9 | 10 | concurrency: 11 | group: '${{ github.workflow }}-${{ github.head_ref }}' 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | name: "Test" 17 | runs-on: macos-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Check cache for Swift dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: .build 25 | key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} 26 | restore-keys: | 27 | ${{ runner.os }}-spm- 28 | 29 | - name: Build 30 | run: swift build 31 | 32 | - name: Run tests 33 | run: swift test --build-path PROJECT_DIR 34 | 35 | - name: Report code coverage 36 | uses: codecov/codecov-action@v4 37 | continue-on-error: true 38 | timeout-minutes: 10 39 | with: 40 | fail_ci_if_error: true 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | exclude: Tests/**/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## macOS 2 | *.DS_Store 3 | 4 | ## User settings 5 | xcuserdata/ 6 | 7 | ## App packaging 8 | *.ipa 9 | *.dSYM.zip 10 | *.dSYM 11 | 12 | # Swift Package Manager 13 | .build/ 14 | Packages 15 | Package.pins 16 | Package.resolved 17 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/CoreTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Run.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 57 | 63 | 64 | 65 | 71 | 77 | 78 | 79 | 80 | 81 | 86 | 87 | 89 | 95 | 96 | 97 | 99 | 105 | 106 | 107 | 109 | 115 | 116 | 117 | 119 | 125 | 126 | 127 | 128 | 129 | 139 | 141 | 147 | 148 | 149 | 150 | 156 | 158 | 164 | 165 | 166 | 167 | 169 | 170 | 173 | 174 | 175 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/SwiftPackageInfo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 70 | 72 | 78 | 79 | 80 | 81 | 83 | 84 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Felipe Lefèvre Marino 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MeasurementApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MeasurementApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/100.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/114.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/144.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/50.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/57.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/72.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/76.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marinofelipe/swift-package-info/15cc8592bae32b3b3bcce3ac1187e49457c9bbeb/MeasurementApp/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "29.png", 17 | "idiom" : "iphone", 18 | "scale" : "1x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "58.png", 23 | "idiom" : "iphone", 24 | "scale" : "2x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "87.png", 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "29x29" 32 | }, 33 | { 34 | "filename" : "80.png", 35 | "idiom" : "iphone", 36 | "scale" : "2x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "120.png", 41 | "idiom" : "iphone", 42 | "scale" : "3x", 43 | "size" : "40x40" 44 | }, 45 | { 46 | "filename" : "57.png", 47 | "idiom" : "iphone", 48 | "scale" : "1x", 49 | "size" : "57x57" 50 | }, 51 | { 52 | "filename" : "114.png", 53 | "idiom" : "iphone", 54 | "scale" : "2x", 55 | "size" : "57x57" 56 | }, 57 | { 58 | "filename" : "120.png", 59 | "idiom" : "iphone", 60 | "scale" : "2x", 61 | "size" : "60x60" 62 | }, 63 | { 64 | "filename" : "180.png", 65 | "idiom" : "iphone", 66 | "scale" : "3x", 67 | "size" : "60x60" 68 | }, 69 | { 70 | "filename" : "20.png", 71 | "idiom" : "ipad", 72 | "scale" : "1x", 73 | "size" : "20x20" 74 | }, 75 | { 76 | "filename" : "40.png", 77 | "idiom" : "ipad", 78 | "scale" : "2x", 79 | "size" : "20x20" 80 | }, 81 | { 82 | "filename" : "29.png", 83 | "idiom" : "ipad", 84 | "scale" : "1x", 85 | "size" : "29x29" 86 | }, 87 | { 88 | "filename" : "58.png", 89 | "idiom" : "ipad", 90 | "scale" : "2x", 91 | "size" : "29x29" 92 | }, 93 | { 94 | "filename" : "40.png", 95 | "idiom" : "ipad", 96 | "scale" : "1x", 97 | "size" : "40x40" 98 | }, 99 | { 100 | "filename" : "80.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "40x40" 104 | }, 105 | { 106 | "filename" : "50.png", 107 | "idiom" : "ipad", 108 | "scale" : "1x", 109 | "size" : "50x50" 110 | }, 111 | { 112 | "filename" : "100.png", 113 | "idiom" : "ipad", 114 | "scale" : "2x", 115 | "size" : "50x50" 116 | }, 117 | { 118 | "filename" : "72.png", 119 | "idiom" : "ipad", 120 | "scale" : "1x", 121 | "size" : "72x72" 122 | }, 123 | { 124 | "filename" : "144.png", 125 | "idiom" : "ipad", 126 | "scale" : "2x", 127 | "size" : "72x72" 128 | }, 129 | { 130 | "filename" : "76.png", 131 | "idiom" : "ipad", 132 | "scale" : "1x", 133 | "size" : "76x76" 134 | }, 135 | { 136 | "filename" : "152.png", 137 | "idiom" : "ipad", 138 | "scale" : "2x", 139 | "size" : "76x76" 140 | }, 141 | { 142 | "filename" : "167.png", 143 | "idiom" : "ipad", 144 | "scale" : "2x", 145 | "size" : "83.5x83.5" 146 | }, 147 | { 148 | "filename" : "1024.png", 149 | "idiom" : "ios-marketing", 150 | "scale" : "1x", 151 | "size" : "1024x1024" 152 | }, 153 | { 154 | "filename" : "16.png", 155 | "idiom" : "mac", 156 | "scale" : "1x", 157 | "size" : "16x16" 158 | }, 159 | { 160 | "filename" : "32.png", 161 | "idiom" : "mac", 162 | "scale" : "2x", 163 | "size" : "16x16" 164 | }, 165 | { 166 | "filename" : "32.png", 167 | "idiom" : "mac", 168 | "scale" : "1x", 169 | "size" : "32x32" 170 | }, 171 | { 172 | "filename" : "64.png", 173 | "idiom" : "mac", 174 | "scale" : "2x", 175 | "size" : "32x32" 176 | }, 177 | { 178 | "filename" : "128.png", 179 | "idiom" : "mac", 180 | "scale" : "1x", 181 | "size" : "128x128" 182 | }, 183 | { 184 | "filename" : "256.png", 185 | "idiom" : "mac", 186 | "scale" : "2x", 187 | "size" : "128x128" 188 | }, 189 | { 190 | "filename" : "256.png", 191 | "idiom" : "mac", 192 | "scale" : "1x", 193 | "size" : "256x256" 194 | }, 195 | { 196 | "filename" : "512.png", 197 | "idiom" : "mac", 198 | "scale" : "2x", 199 | "size" : "256x256" 200 | }, 201 | { 202 | "filename" : "512.png", 203 | "idiom" : "mac", 204 | "scale" : "1x", 205 | "size" : "512x512" 206 | }, 207 | { 208 | "filename" : "1024.png", 209 | "idiom" : "mac", 210 | "scale" : "2x", 211 | "size" : "512x512" 212 | } 213 | ], 214 | "info" : { 215 | "author" : "xcode", 216 | "version" : 1 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /MeasurementApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /MeasurementApp/ContentView.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import SwiftUI 22 | 23 | struct ContentView: View { 24 | var body: some View { 25 | Text("Hello, world!") 26 | .padding() 27 | } 28 | } 29 | 30 | struct ContentView_Previews: PreviewProvider { 31 | static var previews: some View { 32 | ContentView() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /MeasurementApp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | 28 | UIApplicationSupportsIndirectInputEvents 29 | 30 | UILaunchScreen 31 | 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | UISupportedInterfaceOrientations~ipad 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationPortraitUpsideDown 46 | UIInterfaceOrientationLandscapeLeft 47 | UIInterfaceOrientationLandscapeRight 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /MeasurementApp/MeasurementApp.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import SwiftUI 22 | 23 | @main 24 | struct MeasurementAppApp: App { 25 | var body: some Scene { 26 | WindowGroup { 27 | ContentView() 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /MeasurementApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-package-info", 8 | platforms: [ 9 | .macOS(.v13) 10 | ], 11 | products: [ 12 | .executable( 13 | name: "swift-package-info", 14 | targets: [ 15 | "Run" 16 | ] 17 | ), 18 | .library( 19 | name: "SwiftPackageInfo", 20 | targets: ["SwiftPackageInfo"] 21 | ) 22 | ], 23 | dependencies: [ 24 | // Can't update and benefit from latest Swift 6 warnings fixes because the latest 25 | // swift-package-manager release still relies on older versions of the swift-argument-parser 26 | .package( 27 | url: "https://github.com/apple/swift-argument-parser", 28 | .upToNextMinor(from: "1.2.1") 29 | ), 30 | .package( 31 | url: "https://github.com/tuist/XcodeProj.git", 32 | .upToNextMinor(from: "8.7.1") 33 | ), 34 | .package( 35 | url: "https://github.com/marinofelipe/http_client", 36 | .upToNextMinor(from: "0.0.4") 37 | ), 38 | // - Pinned to the the Swift 6.0.3 release / Xcode 16.2 39 | // It auto exports SwiftToolsSupport, so no need to directly depend it 🙏 40 | .package( 41 | url: "https://github.com/apple/swift-package-manager", 42 | revision: "swift-6.0.3-RELEASE" 43 | ), 44 | ], 45 | targets: [ 46 | .executableTarget( 47 | name: "Run", 48 | dependencies: [ 49 | .target(name: "App"), 50 | .target(name: "Reports"), 51 | .target(name: "Core"), 52 | .product( 53 | name: "ArgumentParser", 54 | package: "swift-argument-parser" 55 | ), 56 | ], 57 | swiftSettings: [ 58 | .enableExperimentalFeature("StrictConcurrency"), 59 | .enableExperimentalFeature("InferSendableFromCaptures"), 60 | ] 61 | ), 62 | .testTarget( 63 | name: "RunTests", 64 | dependencies: [ 65 | .target(name: "Run") 66 | ], 67 | swiftSettings: [ 68 | .enableExperimentalFeature("StrictConcurrency"), 69 | .enableExperimentalFeature("InferSendableFromCaptures"), 70 | ] 71 | ), 72 | .target( 73 | name: "SwiftPackageInfo", // Library 74 | dependencies: [ 75 | .target(name: "App"), 76 | .target(name: "Core") 77 | ], 78 | swiftSettings: [ 79 | .enableExperimentalFeature("StrictConcurrency"), 80 | .enableExperimentalFeature("InferSendableFromCaptures"), 81 | ] 82 | ), 83 | .testTarget( 84 | name: "SwiftPackageInfoTests", 85 | dependencies: [ 86 | .target(name: "SwiftPackageInfo"), 87 | .target(name: "CoreTestSupport") 88 | ], 89 | swiftSettings: [ 90 | .enableExperimentalFeature("StrictConcurrency"), 91 | .enableExperimentalFeature("InferSendableFromCaptures"), 92 | ] 93 | ), 94 | .target( 95 | name: "App", 96 | dependencies: [ 97 | .product( 98 | name: "XcodeProj", 99 | package: "XcodeProj" 100 | ), 101 | .product( 102 | name: "CombineHTTPClient", 103 | package: "http_client" 104 | ), 105 | .target(name: "Core") 106 | ], 107 | swiftSettings: [ 108 | .enableExperimentalFeature("StrictConcurrency"), 109 | .enableExperimentalFeature("InferSendableFromCaptures"), 110 | ] 111 | ), 112 | .testTarget( 113 | name: "AppTests", 114 | dependencies: [ 115 | .target(name: "App"), 116 | .target(name: "CoreTestSupport") 117 | ], 118 | swiftSettings: [ 119 | .enableExperimentalFeature("StrictConcurrency"), 120 | .enableExperimentalFeature("InferSendableFromCaptures"), 121 | ] 122 | ), 123 | .target( 124 | name: "Reports", 125 | dependencies: [ 126 | .target(name: "Core") 127 | ], 128 | swiftSettings: [ 129 | .enableExperimentalFeature("StrictConcurrency"), 130 | .enableExperimentalFeature("InferSendableFromCaptures"), 131 | ] 132 | ), 133 | .testTarget( 134 | name: "ReportsTests", 135 | dependencies: [ 136 | .target(name: "Reports"), 137 | .target(name: "CoreTestSupport") 138 | ], 139 | swiftSettings: [ 140 | .enableExperimentalFeature("StrictConcurrency"), 141 | .enableExperimentalFeature("InferSendableFromCaptures"), 142 | ] 143 | ), 144 | .target( 145 | name: "Core", 146 | dependencies: [ 147 | .product( 148 | name: "SwiftPM", 149 | package: "swift-package-manager" 150 | ), 151 | ], 152 | swiftSettings: [ 153 | .enableExperimentalFeature("StrictConcurrency"), 154 | .enableExperimentalFeature("InferSendableFromCaptures"), 155 | ] 156 | ), 157 | .testTarget( 158 | name: "CoreTests", 159 | dependencies: [ 160 | .target(name: "Core"), 161 | .target(name: "CoreTestSupport") 162 | ], 163 | swiftSettings: [ 164 | .enableExperimentalFeature("StrictConcurrency"), 165 | .enableExperimentalFeature("InferSendableFromCaptures"), 166 | ] 167 | ), 168 | .target( 169 | name: "CoreTestSupport", 170 | dependencies: [ 171 | .target(name: "Core") 172 | ], 173 | swiftSettings: [ 174 | .enableExperimentalFeature("StrictConcurrency"), 175 | .enableExperimentalFeature("InferSendableFromCaptures"), 176 | ] 177 | ) 178 | ], 179 | swiftLanguageVersions: [ 180 | .v4, 181 | .v5 182 | ] 183 | ) 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CI](https://github.com/marinofelipe/swift-package-info/workflows/CI/badge.svg) 2 | [![Swift Package Manager](https://rawgit.com/jlyonsmith/artwork/master/SwiftPackageManager/swiftpackagemanager-compatible.svg)](https://swift.org/package-manager/) 3 | [![Twitter](https://img.shields.io/badge/twitter-@_marinofelipe-blue.svg?style=flat)](https://twitter.com/_marinofelipe) 4 | 5 | # Swift Package Info 6 | CLI tool that provides information about a *given Swift Package product*, such as a *measurament of its binary size impact*. 7 | It's built on top of [Swift Argument Parser](https://github.com/apple/swift-argument-parser). 8 | 9 | ## Usage 10 | ``` 11 | OVERVIEW: A tool for analyzing Swift Packages 12 | 13 | Provides valuable information about a given Swift Package, 14 | that can be used in your favor when deciding whether to 15 | adopt or not a Swift Package as a dependency on your app. 16 | 17 | USAGE: swift-package-info 18 | 19 | OPTIONS: 20 | --version Show the version. 21 | -h, --help Show help information. 22 | 23 | SUBCOMMANDS: 24 | binary-size Estimated binary size of a Swift Package product. 25 | platforms Shows platforms supported b a Package product. 26 | dependencies List dependencies of a Package product. 27 | full-analyzes (default) All available information about a Swift Package product. 28 | 29 | See 'swift-package-info help ' for detailed help. 30 | ``` 31 | 32 | ### Examples 33 | - Run a full analyzes 34 | ``` 35 | swift-package-info --for https://github.com/ReactiveX/RxSwift -v 6.0.0 --product RxSwift 36 | ``` 37 | 38 | - Check supported platforms (sub command) 39 | ``` 40 | swift-package-info platforms --for https://github.com/krzyzanowskim/CryptoSwift -v 1.3.8 --product CryptoSwift 41 | ``` 42 | 43 | - See binary size of a local pacakge (e.g. under development framework) 44 | ``` 45 | swift-package-info binary-size --path ../project/my-framework 46 | ``` 47 | 48 | ### Report 49 | ``` 50 | swift-package-info --for https://github.com/ReactiveX/RxSwift -v 6.0.0 --product RxSwift 51 | ``` 52 | ``` 53 | +------------------------------------------------+ 54 | | Swift Package Info | 55 | | | 56 | | RxSwift, 6.0.0 | 57 | +--------------+---------------------------------+ 58 | | Provider | Results | 59 | +--------------+---------------------------------+ 60 | | Binary Size | Binary size increases by 963 KB | 61 | | Platforms | System default | 62 | | Dependencies | No third-party dependencies :) | 63 | +--------------+---------------------------------+ 64 | > Total of 3 providers used. 65 | ``` 66 | 67 | A custom report strategy can be passed via the `report` argument _(check --help for supported values)_ 68 | ``` 69 | swift-package-info --for https://github.com/ReactiveX/RxSwift -v 6.0.0 --product RxSwift --report jsonDump 70 | ``` 71 | ```JSON 72 | { 73 | "binarySize" : { 74 | "amount" : 962560, 75 | "formatted" : "963 KB" 76 | }, 77 | "dependencies" : [ 78 | 79 | ], 80 | "platforms" : { 81 | 82 | } 83 | } 84 | ``` 85 | 86 | ## Installation 87 | * Install [mint](https://github.com/yonaskolb/Mint) 88 | * _Optionally_ [add mint to your $PATH](https://github.com/yonaskolb/Mint?tab=readme-ov-file#linking) 89 | * Run: `mint install marinofelipe/swift-package-info` to install the latest version 90 | 91 | ## Running it 92 | * `mint run swift-package-info` 93 | * or simply, `swift-package-info` in case it was symlinked 94 | 95 | ## Building 96 | Build from Swift Package Manager 97 | 98 | * `swift build` in the top level directory 99 | * The built utility can be found in `.build/debug/swift-package-info` 100 | * Run with `swift run` 101 | 102 | ## Running tests 103 | Run from Xcode 104 | 105 | * Add the project directory to `swift-package-info` scheme customWorkingDirectory 106 | * Run the tests 107 | 108 | Run from command line 109 | 110 | * `swift test --build-path PROJECT_DIR` 111 | 112 | ## Dependencies 113 | * [CombineHTTPClient from HTTPClient](https://github.com/marinofelipe/http_client/blob/main/Package.swift) 114 | * [Swift Argument Parser](https://github.com/apple/swift-argument-parser) 115 | * [swift-tools-support-core from SwiftToolsSupport-auto](https://github.com/apple/swift-tools-support-core/blob/main/Package.swift) 116 | * [XcodeProj](https://github.com/tuist/XcodeProj.git) 117 | 118 | ## Binary size report 119 | Its methodology is inspired by [cocoapods-size](https://github.com/google/cocoapods-size), and thus works by comparing archives with no bitcode and ARM64 arch. 120 | Such strategy has proven to be consistent with the size added to iOS apps downloaded and installed via TestFlight. 121 | 122 | ## Thanks 123 | Special thanks to [@unnamedd](https://github.com/unnamedd) for sharing his experience with [swift-tools-support-core](https://github.com/apple/swift-tools-support-core) and on how to build a pretty 👌 report. 124 | 125 | ## Contributions 126 | *Swift Package Info* is fully open and your contributions are more than welcome. 127 | -------------------------------------------------------------------------------- /Sources/App/Providers/BinarySizeProvider/BinarySizeProvider.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Core 22 | public import Foundation 23 | 24 | // MARK: - Types 25 | 26 | enum BinarySizeProviderError: LocalizedError, Equatable { 27 | case unableToGenerateArchive(errorMessage: String) 28 | case unableToCloneEmptyApp(errorMessage: String) 29 | case unableToGetBinarySizeOnDisk(underlyingError: NSError) 30 | case unableToRetrieveAppProject(atPath: String) 31 | case unexpectedError(underlyingError: NSError, isVerbose: Bool) 32 | 33 | var errorDescription: String? { 34 | let step: String 35 | let message: String 36 | switch self { 37 | case let .unableToGenerateArchive(errorMessage): 38 | step = "Archiving" 39 | message = errorMessage 40 | case let .unableToCloneEmptyApp(errorMessage): 41 | step = "Cloning empty app" 42 | message = errorMessage 43 | case let .unableToGetBinarySizeOnDisk(underlyingError): 44 | step = "Reading binary size" 45 | message = "Failed to read binary size from archive. Details: \(underlyingError.localizedDescription)" 46 | case let .unableToRetrieveAppProject(path): 47 | step = "Read measurement app project" 48 | message = "Failed to get MeasurementApp project from XcodeProj at path: \(path)" 49 | case let .unexpectedError(underlyingError, isVerboseOn): 50 | step = "Undefined" 51 | message = """ 52 | Unexpected failure. \(underlyingError.description). 53 | \(isVerboseOn ? "" : "Please run with --verbose enabled for more details.") 54 | """ 55 | } 56 | 57 | return """ 58 | Failed to measure binary size 59 | Step: \(step) 60 | Error: \(message) 61 | """ 62 | } 63 | } 64 | 65 | struct BinarySizeInformation: Equatable, Encodable, CustomConsoleMessagesConvertible { 66 | private let amount: Int 67 | private let formatted: String 68 | 69 | var messages: [ConsoleMessage] { buildConsoleMessages() } 70 | 71 | init(binarySize: SizeOnDisk) { 72 | self.amount = binarySize.amount 73 | self.formatted = binarySize.formatted 74 | } 75 | 76 | private enum CodingKeys: String, CodingKey { 77 | case amount 78 | case formatted 79 | } 80 | 81 | private func buildConsoleMessages() -> [ConsoleMessage] { 82 | [ 83 | .init( 84 | text: "Binary size increases by ", 85 | color: .noColor, 86 | isBold: false, 87 | hasLineBreakAfter: false 88 | ), 89 | .init( 90 | text: formatted, 91 | color: .yellow, 92 | isBold: true, 93 | hasLineBreakAfter: false 94 | ) 95 | ] 96 | } 97 | } 98 | 99 | // MARK: - Provider 100 | 101 | public struct BinarySizeProvider { 102 | @Sendable 103 | public static func binarySize( 104 | for packageDefinition: PackageDefinition, 105 | resolvedPackage: PackageWrapper, 106 | xcconfig: URL?, 107 | verbose: Bool 108 | ) async throws -> ProvidedInfo { // throws(InfoProviderError): typed throws only supported from macOS 15 runtime 109 | let sizeMeasurer = await defaultSizeMeasurer(xcconfig, verbose) 110 | var binarySize: SizeOnDisk = .zero 111 | 112 | let isProductDynamicLibrary = resolvedPackage.products 113 | .first{ $0.name == packageDefinition.product }? 114 | .isDynamicLibrary ?? false 115 | 116 | do { 117 | binarySize = try await sizeMeasurer( 118 | packageDefinition, 119 | isProductDynamicLibrary 120 | ) 121 | } catch let error as LocalizedError { 122 | throw InfoProviderError(localizedError: error) 123 | } catch { 124 | throw InfoProviderError( 125 | localizedError: BinarySizeProviderError.unexpectedError( 126 | underlyingError: error as NSError, 127 | isVerbose: verbose 128 | ) 129 | ) 130 | } 131 | 132 | return ProvidedInfo( 133 | providerName: "Binary Size", 134 | providerKind: .binarySize, 135 | information: BinarySizeInformation( 136 | binarySize: binarySize 137 | ) 138 | ) 139 | } 140 | } 141 | 142 | // MARK: - Provider - library 143 | 144 | public extension BinarySizeProvider { 145 | struct Result: Sendable { 146 | public let amount: Int 147 | public let formatted: String 148 | } 149 | 150 | @Sendable 151 | static func binarySize( 152 | for packageDefinition: PackageDefinition, 153 | resolvedPackage: PackageWrapper, 154 | xcConfig: URL? 155 | ) async throws(InfoProviderError) -> Result { 156 | let sizeMeasurer = await defaultSizeMeasurer(xcConfig, false) 157 | var binarySize: SizeOnDisk = .zero 158 | 159 | let isProductDynamicLibrary = resolvedPackage.products 160 | .first{ $0.name == packageDefinition.product }? 161 | .isDynamicLibrary ?? false 162 | 163 | do { 164 | binarySize = try await sizeMeasurer( 165 | packageDefinition, 166 | isProductDynamicLibrary 167 | ) 168 | } catch let error as LocalizedError { 169 | throw InfoProviderError(localizedError: error) 170 | } catch { 171 | throw InfoProviderError( 172 | localizedError: BinarySizeProviderError.unexpectedError( 173 | underlyingError: error as NSError, 174 | isVerbose: false 175 | ) 176 | ) 177 | } 178 | 179 | return Result( 180 | amount: binarySize.amount, 181 | formatted: binarySize.formatted 182 | ) 183 | } 184 | } 185 | 186 | #if DEBUG 187 | // debug only 188 | nonisolated(unsafe) var defaultSizeMeasurer: (URL?, Bool) async -> SizeMeasuring = { xcconfig, verbose in 189 | await SizeMeasurer(verbose: verbose, xcconfig: xcconfig).binarySize 190 | } 191 | #else 192 | let defaultSizeMeasurer: (URL?, Bool) async -> SizeMeasuring = { xcconfig, verbose in 193 | await SizeMeasurer(verbose: verbose, xcconfig: xcconfig).binarySize 194 | } 195 | #endif 196 | -------------------------------------------------------------------------------- /Sources/App/Providers/BinarySizeProvider/SizeMeasurer.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | internal import Foundation 22 | internal import Core 23 | 24 | typealias SizeMeasuring = ( 25 | _ swiftPackage: PackageDefinition, 26 | _ isDynamic: Bool 27 | ) async throws -> SizeOnDisk 28 | 29 | final class SizeMeasurer { 30 | private var appManager: AppManager 31 | private let console: Console 32 | private let verbose: Bool 33 | 34 | public convenience init(verbose: Bool, xcconfig: URL?) async { 35 | await self.init( 36 | appManager: .init( 37 | console: .default, 38 | xcconfig: xcconfig, 39 | verbose: verbose 40 | ), 41 | console: .default, 42 | verbose: verbose 43 | ) 44 | } 45 | 46 | init( 47 | appManager: AppManager, 48 | console: Console, 49 | verbose: Bool 50 | ) async { 51 | self.appManager = appManager 52 | self.console = console 53 | self.verbose = verbose 54 | } 55 | 56 | private static let stepsCount = 7 57 | private var currentStep = 1 58 | private static let second: Double = 1_000_000 59 | 60 | deinit { 61 | try? appManager.cleanUp() 62 | } 63 | 64 | public func binarySize( 65 | for swiftPackage: PackageDefinition, 66 | isDynamic: Bool 67 | ) async throws -> SizeOnDisk { 68 | await console.lineBreak() 69 | 70 | let emptyAppSize = try await measureEmptyAppSize() 71 | let appSizeWithDependencyAdded = try await measureAppSize( 72 | with: swiftPackage, 73 | isDynamic: isDynamic 74 | ) 75 | 76 | await completeLoading() 77 | let increasedSize = appSizeWithDependencyAdded - emptyAppSize 78 | 79 | return increasedSize 80 | } 81 | } 82 | 83 | // MARK: - Private 84 | 85 | private extension SizeMeasurer { 86 | func measureEmptyAppSize() async throws -> SizeOnDisk { 87 | if verbose == false { 88 | await showOrUpdateLoading(withText: "Cleaning up empty app directory...") 89 | } 90 | try appManager.cleanUp() 91 | 92 | if verbose { 93 | await console.lineBreakAndWrite("Cloning empty app") 94 | } else { 95 | await showOrUpdateLoading(withText: "Cloning empty app...") 96 | } 97 | try await appManager.cloneEmptyApp() 98 | 99 | if verbose { 100 | await console.lineBreakAndWrite( 101 | .init( 102 | text: "Measuring empty app size", 103 | color: .green, 104 | isBold: true 105 | ) 106 | ) 107 | } 108 | 109 | if verbose == false { 110 | await showOrUpdateLoading(withText: "Generating archive for empty app...") 111 | } 112 | try await appManager.generateArchive() 113 | 114 | if verbose == false { 115 | await showOrUpdateLoading(withText: "Calculating binary size...") 116 | } 117 | return try await appManager.calculateBinarySize() 118 | } 119 | 120 | func measureAppSize( 121 | with swiftPackage: PackageDefinition, 122 | isDynamic: Bool 123 | ) async throws -> SizeOnDisk { 124 | if verbose { 125 | await console.lineBreakAndWrite( 126 | .init( 127 | text: "Measuring app size with \(swiftPackage.product) added as dependency", 128 | color: .green, 129 | isBold: true 130 | ) 131 | ) 132 | } 133 | 134 | if verbose == false { 135 | await showOrUpdateLoading(withText: "Adding \(swiftPackage.product) as dependency...") 136 | } 137 | try appManager.add( 138 | asDependency: swiftPackage, 139 | isDynamic: isDynamic 140 | ) 141 | 142 | if verbose == false { 143 | await showOrUpdateLoading(withText: "Generating archive for updated app...") 144 | } 145 | try await appManager.generateArchive() 146 | 147 | if verbose == false { 148 | await showOrUpdateLoading(withText: "Calculating updated binary size...") 149 | } 150 | return try await appManager.calculateBinarySize() 151 | } 152 | 153 | func showOrUpdateLoading(withText text: String) async { 154 | usleep(UInt32(Self.second * 0.5)) 155 | await console.showLoading(step: currentStep, total: Self.stepsCount, text: text) 156 | currentStep += 1 157 | } 158 | 159 | func completeLoading() async { 160 | await console.completeLoading(success: true) 161 | currentStep = 0 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/App/Providers/DependenciesProvider/DependenciesProvider.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Core 22 | public import Foundation 23 | 24 | enum DependenciesProviderError: LocalizedError, Equatable { 25 | case failedToMatchProduct 26 | 27 | var errorDescription: String? { 28 | switch self { 29 | case .failedToMatchProduct: 30 | return "Failed to match product when evaluating dependencies" 31 | } 32 | } 33 | } 34 | 35 | public struct DependenciesProvider { 36 | @Sendable 37 | public static func dependencies( 38 | for packageDefinition: PackageDefinition, 39 | resolvedPackage: PackageWrapper, 40 | xcconfig: URL?, 41 | verbose: Bool 42 | ) async throws -> ProvidedInfo { 43 | guard 44 | let product = resolvedPackage.products.first(where: { $0.name == packageDefinition.product }) 45 | else { 46 | throw DependenciesProviderError.failedToMatchProduct 47 | } 48 | 49 | let productTargets = product.targets 50 | let externalDependencies = getExternalDependencies( 51 | forTargets: productTargets, 52 | package: resolvedPackage 53 | ) 54 | 55 | return ProvidedInfo( 56 | providerName: "Dependencies", 57 | providerKind: .dependencies, 58 | information: DependenciesInformation(dependencies: externalDependencies) 59 | ) 60 | } 61 | 62 | private static func getExternalDependencies( 63 | forTargets targets: [PackageWrapper.Target], 64 | package: PackageWrapper 65 | ) -> [DependenciesInformation.Dependency] { 66 | let externalDependencies = targets 67 | .map(\.productDependencies) 68 | .reduce( 69 | [], 70 | + 71 | ) 72 | 73 | let transitiveExternalDependencies = targets 74 | .map(\.allTransitiveProductDependencies) 75 | .reduce([], +) 76 | 77 | let allExternalDependencies = externalDependencies + transitiveExternalDependencies 78 | 79 | let dependencies = allExternalDependencies.map(DependenciesInformation.Dependency.init) 80 | 81 | return dependencies.sorted(by: { $0.product < $1.product }) 82 | } 83 | } 84 | 85 | private extension PackageWrapper.Target { 86 | var productDependencies: [PackageWrapper.Product] { 87 | dependencies.compactMap(\.product) 88 | } 89 | 90 | var targetDependencies: [PackageWrapper.Target] { 91 | dependencies.compactMap(\.target) 92 | } 93 | 94 | var allTransitiveProductDependencies: [PackageWrapper.Product] { 95 | mapTransitiveProducts(targets: targetDependencies) 96 | } 97 | 98 | private func mapTransitiveProducts(targets: [PackageWrapper.Target]) -> [PackageWrapper.Product] { 99 | let productDependencies = targets.map(\.dependencies) 100 | .reduce([], +) 101 | .compactMap(\.product) 102 | 103 | let targetDependencies = targets.map(\.targetDependencies) 104 | .reduce([], +) 105 | 106 | if targetDependencies.isEmpty == false { 107 | return mapTransitiveProducts(targets: targetDependencies) 108 | } else { 109 | return productDependencies 110 | } 111 | } 112 | } 113 | 114 | struct DependenciesInformation: Equatable, CustomConsoleMessagesConvertible { 115 | struct Dependency: Equatable, Encodable { 116 | let product: String 117 | let package: String 118 | } 119 | 120 | let dependencies: [Dependency] 121 | private let consoleDependencies: [Dependency] 122 | 123 | init(dependencies: [Dependency]) { 124 | self.dependencies = dependencies 125 | self.consoleDependencies = Array(dependencies.prefix(3)) 126 | } 127 | 128 | var messages: [ConsoleMessage] { buildConsoleMessages() } 129 | 130 | private func buildConsoleMessages() -> [ConsoleMessage] { 131 | if dependencies.isEmpty { 132 | [ 133 | .init( 134 | text: "No third-party dependencies :)", 135 | hasLineBreakAfter: false 136 | ) 137 | ] 138 | } else { 139 | consoleDependencies.enumerated().map { index, dependency -> [ConsoleMessage] in 140 | var messages: [ConsoleMessage] = [ 141 | .init( 142 | text: "\(dependency.product)", 143 | hasLineBreakAfter: false 144 | ), 145 | ] 146 | 147 | let isLast = index == consoleDependencies.count - 1 148 | if isLast == false { 149 | messages.append( 150 | .init( 151 | text: " | ", 152 | hasLineBreakAfter: false 153 | ) 154 | ) 155 | } 156 | 157 | if isLast && dependencies.count > 3 { 158 | messages.append( 159 | .init( 160 | text: " | ", 161 | hasLineBreakAfter: false 162 | ) 163 | ) 164 | messages.append( 165 | .init( 166 | text: "Use `--report jsonDump` to see all..", 167 | hasLineBreakAfter: false 168 | ) 169 | ) 170 | } 171 | 172 | return messages 173 | } 174 | .reduce( 175 | [], 176 | + 177 | ) 178 | } 179 | } 180 | } 181 | 182 | extension DependenciesInformation: Encodable { 183 | func encode(to encoder: Encoder) throws { 184 | var container = encoder.singleValueContainer() 185 | try container.encode(dependencies) 186 | } 187 | } 188 | 189 | extension DependenciesInformation.Dependency { 190 | init( 191 | from dependency: PackageWrapper.Product 192 | ) { 193 | self.init( 194 | product: dependency.name, 195 | package: dependency.package ?? "" 196 | ) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /Sources/App/Providers/PlatformsProvider/PlatformsProvider.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Core 22 | public import Foundation 23 | 24 | public struct PlatformsProvider { 25 | @Sendable 26 | public static func platforms( 27 | for packageDefinition: PackageDefinition, 28 | resolvedPackage: PackageWrapper, 29 | xcconfig: URL?, 30 | verbose: Bool 31 | ) async throws -> ProvidedInfo { 32 | .init( 33 | providerName: "Platforms", 34 | providerKind: .platforms, 35 | information: PlatformsInformation( 36 | platforms: resolvedPackage.platforms 37 | ) 38 | ) 39 | } 40 | } 41 | 42 | struct PlatformsInformation: Equatable, Encodable, CustomConsoleMessagesConvertible { 43 | let platforms: [PackageWrapper.Platform] 44 | let iOS: String? 45 | let macOS: String? 46 | let tvOS: String? 47 | let watchOS: String? 48 | 49 | var messages: [ConsoleMessage] { buildConsoleMessages() } 50 | 51 | init(platforms: [PackageWrapper.Platform]) { 52 | self.platforms = platforms 53 | self.iOS = platforms.iOSVersion 54 | self.macOS = platforms.macOSVersion 55 | self.tvOS = platforms.tvOSVersion 56 | self.watchOS = platforms.watchOSVersion 57 | } 58 | 59 | private enum CodingKeys: String, CodingKey { 60 | case iOS, macOS, tvOS, watchOS 61 | } 62 | 63 | private func buildConsoleMessages() -> [ConsoleMessage] { 64 | if platforms.isEmpty { 65 | [ 66 | .init( 67 | text: "System default", 68 | hasLineBreakAfter: false 69 | ) 70 | ] 71 | } else { 72 | platforms 73 | .map { platform -> [ConsoleMessage] in 74 | var messages = [ 75 | ConsoleMessage( 76 | text: "\(platform.platformName) from v. \(platform.version)", 77 | isBold: true, 78 | hasLineBreakAfter: false 79 | ) 80 | ] 81 | 82 | let isLastPlatform = platform == platforms.last 83 | if isLastPlatform == false { 84 | messages.append( 85 | .init( 86 | text: " | ", 87 | hasLineBreakAfter: false 88 | ) 89 | ) 90 | } 91 | 92 | return messages 93 | } 94 | .reduce( 95 | [], 96 | + 97 | ) 98 | } 99 | } 100 | } 101 | 102 | extension Array where Element == PackageWrapper.Platform { 103 | var iOSVersion: String? { 104 | first(where: \.platformName == "ios")?.version 105 | } 106 | 107 | var macOSVersion: String? { 108 | first(where: \.platformName == "macos")?.version 109 | } 110 | 111 | var tvOSVersion: String? { 112 | first(where: \.platformName == "tvos")?.version 113 | } 114 | 115 | var watchOSVersion: String? { 116 | first(where: \.platformName == "watchos")?.version 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/App/Services/SwiftPackageValidator.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Foundation 22 | internal import PackageModel 23 | 24 | public import Core 25 | 26 | public enum SwiftPackageValidationError: Error, Equatable { 27 | case invalidURL 28 | case failedToLoadPackage 29 | case noProductFound(packageURL: URL) 30 | } 31 | 32 | public protocol SwiftPackageValidating { 33 | func validate( 34 | packageDefinition: inout PackageDefinition, 35 | isVerbose: Bool 36 | ) async throws(SwiftPackageValidationError) -> PackageWrapper 37 | } 38 | 39 | /// Uses the `SPM` library to load the package from its local or remote source, and then validates and adjusts 40 | /// its properties. 41 | /// 42 | /// Depending on the result, it can mutate the ``SwiftPackage`` with: 43 | /// - a valid first `Product`, if no product is passed or invalid 44 | /// - the latest `tag` as `resolution`, in case the passed tag is invalid 45 | public struct SwiftPackageValidator: SwiftPackageValidating { 46 | private let swiftPackageService: SwiftPackageService 47 | private let console: Console? 48 | 49 | public init(console: Console? = nil) { 50 | self.init( 51 | swiftPackageService: .init(), 52 | console: console 53 | ) 54 | } 55 | 56 | init( 57 | swiftPackageService: SwiftPackageService = .init(), 58 | console: Console? = nil 59 | ) { 60 | self.swiftPackageService = swiftPackageService 61 | self.console = console 62 | } 63 | 64 | public func validate( 65 | packageDefinition: inout PackageDefinition, 66 | isVerbose: Bool = false 67 | ) async throws(SwiftPackageValidationError) -> PackageWrapper { 68 | let packageResponse: SwiftPackageValidationResult 69 | do { 70 | packageResponse = try await swiftPackageService.validate( 71 | swiftPackage: packageDefinition, 72 | verbose: isVerbose 73 | ) 74 | } catch { 75 | throw .failedToLoadPackage 76 | } 77 | 78 | switch packageResponse.sourceInformation { 79 | case let .remote(isRepositoryValid, tagState, latestTag): 80 | guard isRepositoryValid else { 81 | throw SwiftPackageValidationError.invalidURL 82 | } 83 | 84 | switch packageDefinition.source.remoteResolution { 85 | case let .revision(revision): 86 | await console?.lineBreakAndWrite("Resolved revision: \(revision)") 87 | case .version: 88 | switch tagState { 89 | case .undefined, .invalid: 90 | await console?.lineBreakAndWrite("Package version was \(tagState.description)") 91 | 92 | if let latestTag { 93 | await console?.lineBreakAndWrite("Defaulting to latest found semver tag: \(latestTag)") 94 | packageDefinition.source = .remote( 95 | url: packageDefinition.url, 96 | resolution: .version(latestTag) 97 | ) 98 | } 99 | case .valid: 100 | break 101 | } 102 | case .none: 103 | break 104 | } 105 | case .local: 106 | break 107 | } 108 | 109 | guard let firstProduct = packageResponse.availableProducts.first else { 110 | throw .noProductFound(packageURL: packageDefinition.url) 111 | } 112 | 113 | if packageResponse.isProductValid == false { 114 | await console?.lineBreakAndWrite("Invalid product: \(packageDefinition.product)") 115 | await console?.lineBreakAndWrite("Using first found product instead: \(packageDefinition)") 116 | 117 | packageDefinition.product = firstProduct 118 | } 119 | 120 | return packageResponse.package 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /Sources/Core/Console.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Console.swift 3 | // 4 | // Acknowledgement: This piece of code is inspired by Raycast's script-commands repository: 5 | // https://github.com/raycast/script-commands/blob/master/Tools/Toolkit/Sources/ToolkitLibrary/Core/Console.swift 6 | // 7 | // Created by Marino Felipe on 28.12.20. 8 | // 9 | 10 | @preconcurrency import TSCBasic 11 | import TSCUtility 12 | 13 | // MARK: - ConsoleColor - Wrapper 14 | 15 | public enum ConsoleColor: Sendable { 16 | case noColor 17 | case red 18 | case green 19 | case yellow 20 | case cyan 21 | case white 22 | case black 23 | case gray 24 | } 25 | 26 | private extension ConsoleColor { 27 | var terminalColor: TerminalController.Color { 28 | switch self { 29 | case .black: return .black 30 | case .cyan: return .cyan 31 | case .green: return .green 32 | case .gray: return .gray 33 | case .noColor: return .noColor 34 | case .red: return .red 35 | case .white: return .white 36 | case .yellow: return .yellow 37 | } 38 | } 39 | } 40 | 41 | // MARK: - TerminalControlling 42 | 43 | public protocol TerminalControlling { 44 | func endLine() 45 | func write( 46 | _ string: String, 47 | inColor _: ConsoleColor, 48 | bold: Bool 49 | ) 50 | } 51 | 52 | extension TerminalController: TerminalControlling { 53 | public func write( 54 | _ string: String, 55 | inColor color: ConsoleColor, 56 | bold: Bool 57 | ) { 58 | write( 59 | string, 60 | inColor: color.terminalColor, 61 | bold: bold 62 | ) 63 | } 64 | } 65 | 66 | final class PrintController: TerminalControlling { 67 | func endLine() { 68 | print() 69 | } 70 | 71 | func write(_ string: String, inColor _: ConsoleColor, bold: Bool) { 72 | print(string) 73 | } 74 | 75 | } 76 | 77 | // MARK: - ConsoleMessage 78 | 79 | public struct ConsoleMessage: Equatable, ExpressibleByStringLiteral, ExpressibleByStringInterpolation, Sendable { 80 | public let text: String 81 | let color: ConsoleColor 82 | let isBold: Bool 83 | let hasLineBreakAfter: Bool 84 | 85 | public init( 86 | text: String, 87 | color: ConsoleColor, 88 | isBold: Bool = false, 89 | hasLineBreakAfter: Bool = true 90 | ) { 91 | self.text = text 92 | self.color = color 93 | self.isBold = isBold 94 | self.hasLineBreakAfter = hasLineBreakAfter 95 | } 96 | 97 | public init( 98 | text: String, 99 | isBold: Bool = false, 100 | hasLineBreakAfter: Bool = true 101 | ) { 102 | self.text = text 103 | self.color = .noColor 104 | self.isBold = isBold 105 | self.hasLineBreakAfter = hasLineBreakAfter 106 | } 107 | 108 | public init(stringLiteral value: String) { 109 | self.init(text: value, color: .noColor, isBold: false, hasLineBreakAfter: true) 110 | } 111 | } 112 | 113 | // MARK: - CustomConsoleMessageConvertible 114 | 115 | public protocol CustomConsoleMessageConvertible { 116 | /// A console message representation. 117 | var message: ConsoleMessage { get } 118 | } 119 | 120 | public protocol CustomConsoleMessagesConvertible: Sendable { 121 | /// A console message representation that is split into and composed by multiple messages. 122 | var messages: [ConsoleMessage] { get } 123 | } 124 | 125 | // MARK: - Console 126 | 127 | @MainActor 128 | public final class Console { 129 | private var isOutputColored: Bool 130 | private let terminalController: TerminalControlling 131 | private let progressAnimation: ProgressAnimationProtocol 132 | 133 | public init( 134 | isOutputColored: Bool, 135 | terminalController: TerminalControlling = TerminalControllerFactory.make(), 136 | progressAnimation: ProgressAnimationProtocol = NinjaProgressAnimation(stream: stdoutStream) 137 | ) { 138 | self.isOutputColored = isOutputColored 139 | self.terminalController = terminalController 140 | self.progressAnimation = progressAnimation 141 | } 142 | 143 | public func write(_ message: ConsoleMessage) { 144 | write( 145 | message: message.text, 146 | color: isOutputColored ? message.color : .noColor, 147 | bold: message.isBold, 148 | addLineBreakAfter: message.hasLineBreakAfter 149 | ) 150 | } 151 | 152 | public func lineBreakAndWrite(_ message: ConsoleMessage) { 153 | lineBreak() 154 | write( 155 | message: message.text, 156 | color: isOutputColored ? message.color : .noColor, 157 | bold: message.isBold, 158 | addLineBreakAfter: message.hasLineBreakAfter 159 | ) 160 | } 161 | 162 | public func lineBreak() { 163 | terminalController.endLine() 164 | } 165 | } 166 | 167 | public final class TerminalControllerFactory { 168 | public static func make(stream: WritableByteStream = stdoutStream) -> TerminalControlling { 169 | if let controller = TerminalController(stream: stream) { 170 | return controller 171 | } else { 172 | return PrintController() 173 | } 174 | } 175 | } 176 | 177 | // MARK: - Loading 178 | 179 | public extension Console { 180 | func showLoading( 181 | step: Int , 182 | total: Int = 10, 183 | text: String 184 | ) { 185 | progressAnimation.update( 186 | step: step, 187 | total: total, 188 | text: text 189 | ) 190 | } 191 | 192 | func completeLoading(success: Bool) { 193 | progressAnimation.complete(success: success) 194 | } 195 | } 196 | 197 | // MARK: - Private 198 | 199 | private extension Console { 200 | func write( 201 | message: String, 202 | color: ConsoleColor, 203 | bold: Bool, 204 | addLineBreakAfter: Bool 205 | ) { 206 | terminalController.write(message, inColor: color, bold: bold) 207 | if addLineBreakAfter { self.lineBreak() } 208 | } 209 | } 210 | 211 | #if DEBUG 212 | extension Console { 213 | public static var `default` = Console(isOutputColored: false) 214 | } 215 | #else 216 | extension Console { 217 | public static let `default` = Console(isOutputColored: true) 218 | } 219 | #endif 220 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/AbsolutePath+Core.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Basics 22 | internal import Foundation 23 | 24 | public extension AbsolutePath { 25 | /// The path to the program’s current directory. 26 | static let currentDir: Self = { 27 | guard let currentDir = localFileSystem.currentWorkingDirectory else { 28 | do { 29 | return try AbsolutePath(validating: currentDirPath) 30 | } catch { 31 | preconditionFailure( 32 | "Unable to make AbsolutePath from currentDir, error: \(error.localizedDescription)" 33 | ) 34 | } 35 | } 36 | 37 | return currentDir 38 | }() 39 | } 40 | 41 | #if DEBUG 42 | //debug only 43 | nonisolated(unsafe) var currentDirPath = FileManager.default.currentDirectoryPath 44 | #else 45 | let currentDirPath = FileManager.default.currentDirectoryPath 46 | #endif 47 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/Array+Core.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | internal import Foundation 22 | 23 | public extension Array { 24 | subscript(safeIndex index: Int) -> Element? { 25 | guard index >= 0, index < endIndex else { return nil } 26 | 27 | return self[index] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/JSONEncoder+Core.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Foundation 22 | 23 | public extension JSONEncoder { 24 | static let sortedAndPrettyPrinted: JSONEncoder = { 25 | let encoder = JSONEncoder() 26 | encoder.outputFormatting = [.sortedKeys, .prettyPrinted] 27 | return encoder 28 | }() 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/KeyPath+Core.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public func ==(lhs: KeyPath, rhs: V) -> (T) -> Bool { 22 | return { $0[keyPath: lhs] == rhs } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/Result+Core.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public extension Result { 22 | var value: Success? { 23 | guard case let .success(value) = self else { return nil } 24 | return value 25 | } 26 | 27 | var error: Failure? { 28 | guard case let .failure(error) = self else { return nil } 29 | return error 30 | } 31 | 32 | @discardableResult 33 | func onSuccess(_ action: (Success) -> Void) -> Result { 34 | guard case let .success(value) = self else { return self } 35 | action(value) 36 | return self 37 | } 38 | 39 | @discardableResult 40 | func onFailure(_ action: (Failure) -> Void) -> Result { 41 | guard case let .failure(error) = self else { return self } 42 | action(error) 43 | return self 44 | } 45 | 46 | @discardableResult 47 | func onSuccess(_ action: (Success) throws -> Void) rethrows -> Result { 48 | guard case let .success(value) = self else { return self } 49 | try action(value) 50 | return self 51 | } 52 | 53 | @discardableResult 54 | func onFailure(_ action: (Failure) throws -> Void) rethrows -> Result { 55 | guard case let .failure(error) = self else { return self } 56 | try action(error) 57 | return self 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/Core/Extensions/URL+Core.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Core.swift 3 | // 4 | // Acknowledgement: This piece of code is inspired by StackOverflow post's top-voted answer on how to get directory size on OS X: 5 | // https://stackoverflow.com/questions/32814535/how-to-get-directory-size-with-swift-on-os-x 6 | // 7 | // Created by Marino Felipe on 28.12.20. 8 | // 9 | 10 | public import Foundation 11 | 12 | // MARK: - Size on disk 13 | 14 | extension URL { 15 | // MARK: Public 16 | 17 | nonisolated(unsafe) public static let fileByteCountFormatter: ByteCountFormatter = { 18 | let byteCountFormatter = ByteCountFormatter() 19 | byteCountFormatter.countStyle = .file 20 | return byteCountFormatter 21 | }() 22 | 23 | @MainActor 24 | public func sizeOnDisk() throws -> SizeOnDisk { 25 | let size = try isDirectory() 26 | ? try directoryTotalAllocatedSize(includingSubfolders: true) 27 | : try totalFileAllocatedSize() 28 | 29 | guard let formattedByteCount = URL.fileByteCountFormatter.string(for: size) else { 30 | throw URLSizeReadingError.unableToCountCountBytes(filePath: self.path) 31 | } 32 | return .init(amount: size, formatted: formattedByteCount) 33 | } 34 | 35 | // MARK: Internal 36 | 37 | func totalFileAllocatedSize() throws -> Int { 38 | try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize ?? 0 39 | } 40 | 41 | func directoryTotalAllocatedSize( 42 | fileManager: FileManager = .default, 43 | includingSubfolders: Bool = false 44 | ) throws -> Int { 45 | let allocatedSizeWithoutSubfolders = { 46 | return try fileManager.contentsOfDirectory(at: self, includingPropertiesForKeys: nil).totalAllocatedSize() 47 | } 48 | 49 | if includingSubfolders { 50 | guard let urls = fileManager.enumerator(at: self, includingPropertiesForKeys: nil)?.allObjects as? [URL] else { 51 | return try allocatedSizeWithoutSubfolders() 52 | } 53 | 54 | return try urls.totalAllocatedSize() 55 | } 56 | 57 | return try allocatedSizeWithoutSubfolders() 58 | } 59 | 60 | func isDirectory() throws -> Bool { 61 | try resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true 62 | } 63 | } 64 | 65 | enum URLSizeReadingError: LocalizedError { 66 | case unableToCountCountBytes(filePath: String) 67 | 68 | var errorDescription: String? { 69 | switch self { 70 | case let .unableToCountCountBytes(filePath): 71 | return "Unable to count bytes for file at: \(filePath)" 72 | } 73 | } 74 | } 75 | 76 | extension Array where Element == URL { 77 | func totalAllocatedSize() throws -> Int { 78 | try lazy.reduce(0) { try $1.totalFileAllocatedSize() + $0 } 79 | } 80 | } 81 | 82 | // MARK: - Extension - Local & remote 83 | 84 | public extension URL { 85 | static let isValidURLRegex = "^(https?://)?(www\\.)?([-a-z0-9]{1,63}\\.)*?[a-z0-9][-a-z0-9]{0,61}[a-z0-9]\\.[a-z]{2,6}(/[-\\w@\\+\\.~#\\?&/=%]*)?$" 86 | 87 | var isValidRemote: Bool { 88 | NSPredicate(format:"SELF MATCHES %@", Self.isValidURLRegex) 89 | .evaluate(with: absoluteString) 90 | } 91 | 92 | func isLocalDirectoryContainingPackageDotSwift( 93 | fileManager: FileManager = .default 94 | ) throws -> Bool { 95 | try fileManager.contentsOfDirectory(atPath: path).contains("Package.swift") 96 | } 97 | 98 | func isLocalXCConfigFileValid( 99 | fileManager: FileManager = .default 100 | ) -> Bool { 101 | fileManager.isReadableFile(atPath: path) && self.pathExtension == "xcconfig" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/Core/InfoProvider.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Foundation 22 | 23 | public struct InfoProviderError: LocalizedError, Equatable, CustomConsoleMessageConvertible { 24 | public let message: ConsoleMessage 25 | 26 | public private(set) var errorDescription: String? 27 | 28 | public init( 29 | localizedError: LocalizedError, 30 | customConsoleMessage: ConsoleMessage? = nil 31 | ) { 32 | self.errorDescription = localizedError.errorDescription 33 | self.message = customConsoleMessage ?? .init( 34 | text: localizedError.errorDescription ?? "", 35 | color: .red, 36 | isBold: true, 37 | hasLineBreakAfter: true 38 | ) 39 | } 40 | } 41 | 42 | public enum ProviderKind: String, CodingKey { 43 | case binarySize 44 | case dependencies 45 | case platforms 46 | } 47 | 48 | public typealias InfoProvider = @Sendable ( 49 | _ packageDefinition: PackageDefinition, 50 | _ resolvedPackage: PackageWrapper, 51 | _ xcconfig: URL?, 52 | _ verbose: Bool 53 | ) async throws -> ProvidedInfo 54 | //throws(InfoProviderError) , typed throws only supported from macOS 15.0 runtime 55 | 56 | public struct ProvidedInfo: Encodable, CustomConsoleMessagesConvertible, Sendable { 57 | public let providerName: String 58 | public let providerKind: ProviderKind 59 | public var messages: [ConsoleMessage] { 60 | informationMessagesConvertible.messages 61 | } 62 | 63 | private let informationEncoder: @Sendable (Encoder) throws -> Void 64 | private let informationMessagesConvertible: CustomConsoleMessagesConvertible 65 | 66 | public init( 67 | providerName: String, 68 | providerKind: ProviderKind, 69 | information: T 70 | ) where T: Encodable, T: CustomConsoleMessagesConvertible { 71 | self.providerName = providerName 72 | self.providerKind = providerKind 73 | self.informationMessagesConvertible = information 74 | self.informationEncoder = { encoder in 75 | var container = encoder.singleValueContainer() 76 | try container.encode(information) 77 | } 78 | } 79 | 80 | public func encode(to encoder: Encoder) throws { 81 | try informationEncoder(encoder) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/Core/Models/PackageDefinition.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import struct Foundation.URL 22 | public import Basics 23 | 24 | /// Defines a Swift Package product. 25 | /// The initial input needed to resolve the package graph and provide the required information. 26 | @dynamicMemberLookup 27 | public struct PackageDefinition: Equatable, CustomStringConvertible, Sendable { 28 | /// The remote repository resolution, either a git tag or revision. 29 | public enum RemoteResolution: Equatable, CustomStringConvertible, Sendable { 30 | /// Semantic version of the Swift Package. If not valid, the latest semver tag is used 31 | case version(String) 32 | /// A single git commit, SHA-1 hash, or branch name 33 | case revision(String) 34 | 35 | public var description: String { 36 | switch self { 37 | case let .revision(revision): 38 | return "Revision: \(revision)" 39 | case let .version(tag): 40 | return "Version: \(tag)" 41 | } 42 | } 43 | 44 | var version: String? { 45 | switch self { 46 | case .revision: nil 47 | case let .version(tag): tag 48 | } 49 | } 50 | 51 | var revision: String? { 52 | switch self { 53 | case let .revision(revision): revision 54 | case .version: nil 55 | } 56 | } 57 | } 58 | 59 | /// The source reference for the Package repository. 60 | public enum Source: Equatable, Sendable, CustomStringConvertible { 61 | /// A relative local directory path that contains a `Package.swift`. **Full paths not supported**. 62 | case local(AbsolutePath) 63 | /// A valid git repository URL that contains a `Package.swift` and it's resolution method, either the git version or revision. 64 | case remote(url: URL, resolution: RemoteResolution) 65 | 66 | public var description: String { 67 | switch self { 68 | case let .local(path): 69 | "Local path: \(path)" 70 | case let .remote(url, resolution): 71 | """ 72 | Repository URL: \(url) 73 | \(resolution.description) 74 | """ 75 | } 76 | } 77 | 78 | public var remoteResolution: RemoteResolution? { 79 | switch self { 80 | case .local: nil 81 | case let .remote(_, resolution): resolution 82 | } 83 | } 84 | 85 | public var url: URL { 86 | switch self { 87 | case let .local(path): path.asURL 88 | case let .remote(remoteURL, _): remoteURL 89 | } 90 | } 91 | 92 | public var version: String? { 93 | switch self { 94 | case .local: nil 95 | case let .remote(_, resolution): resolution.version 96 | } 97 | } 98 | 99 | public var revision: String? { 100 | switch self { 101 | case .local: nil 102 | case let .remote(_, resolution): resolution.revision 103 | } 104 | } 105 | } 106 | 107 | /// A ``PackageDefinition`` initialization error. 108 | public enum Error: Swift.Error { 109 | case invalidURL 110 | } 111 | 112 | public var source: Source 113 | public var product: String 114 | 115 | /// Initializes a ``PackageDefinition`` 116 | /// - Parameters: 117 | /// - source: The source reference for the Package repository. 118 | /// - product: Name of the product to be checked. If not passed in the first available product is used. 119 | public init( 120 | source: Source, 121 | product: String? 122 | ) throws(PackageDefinition.Error) { 123 | switch source { 124 | case let .local(absolutePath): 125 | let isValidLocalDirectory = try? absolutePath.asURL.isLocalDirectoryContainingPackageDotSwift() 126 | guard isValidLocalDirectory ?? false else { 127 | throw Error.invalidURL 128 | } 129 | case let .remote(url, _): 130 | guard url.isValidRemote else { 131 | throw Error.invalidURL 132 | } 133 | } 134 | 135 | self.source = source 136 | // N.B. Set as `undefined` which is used later on for replacing it for a valid Product 137 | self.product = product ?? ResourceState.undefined.description 138 | } 139 | 140 | /// Initializes a ``PackageDefinition`` from CLI arguments 141 | /// - Parameters: 142 | /// - url: Either a valid git repository URL or a relative local directory path that contains a `Package.swift`. For local packages **full paths are discouraged and unsupported**. 143 | /// - version: Semantic version of the Swift Package. If not passed and `revision` is not set, the latest semver tag is used. 144 | /// - revision: A single git commit, SHA-1 hash, or branch name. Applied when `packageVersion` is not set. 145 | /// - product: Name of the product to be checked. If not passed in the first available product is used. 146 | @_disfavoredOverload 147 | public init( 148 | url: URL, 149 | version: String?, 150 | revision: String?, 151 | product: String? 152 | ) throws(PackageDefinition.Error) { 153 | let isValidRemoteURL = url.isValidRemote 154 | let isValidLocalDirectory = (try? url.isLocalDirectoryContainingPackageDotSwift()) ?? false 155 | 156 | guard isValidRemoteURL || isValidLocalDirectory else { 157 | throw Error.invalidURL 158 | } 159 | 160 | if isValidLocalDirectory { 161 | let path: AbsolutePath 162 | do { 163 | path = try AbsolutePath( 164 | validating: url.absoluteString, 165 | relativeTo: .currentDir 166 | ) 167 | } catch { 168 | throw PackageDefinition.Error.invalidURL 169 | } 170 | self.source = .local(path) 171 | } else { 172 | let resolvedVersion = version ?? ResourceState.undefined.description 173 | if let revision = revision, resolvedVersion == ResourceState.undefined.description { 174 | self.source = .remote(url: url, resolution: .revision(revision)) 175 | } else { 176 | self.source = .remote(url: url, resolution: .version(resolvedVersion)) 177 | } 178 | } 179 | 180 | // N.B. Set as `undefined` which is used later on for replacing it for a valid Product 181 | self.product = product ?? ResourceState.undefined.description 182 | } 183 | 184 | public subscript(dynamicMember keyPath: KeyPath) -> T { 185 | source[keyPath: keyPath] 186 | } 187 | 188 | public var description: String { 189 | """ 190 | \(source.description) 191 | Product: \(product) 192 | """ 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Sources/Core/Models/PackageModel.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import PackageModel 22 | 23 | /// A wrapper over SwiftPM library Package type. 24 | /// `Equatable` and `easily testable` 25 | public struct PackageWrapper: Equatable, Sendable { 26 | public struct Product: Equatable, Sendable { 27 | public let name: String 28 | public let package: String? 29 | public let isDynamicLibrary: Bool? 30 | public let targets: [Target] 31 | } 32 | 33 | public struct Target: Equatable, Sendable { 34 | public enum Dependency: Equatable, Sendable { 35 | case target(Target) 36 | case product(Product) 37 | 38 | public var target: Target? { 39 | switch self { 40 | case let .target(target): 41 | return target 42 | case .product: 43 | return nil 44 | } 45 | } 46 | 47 | public var product: Product? { 48 | switch self { 49 | case .target: 50 | return nil 51 | case let .product(product): 52 | return product 53 | } 54 | } 55 | } 56 | 57 | public let name: String 58 | public let dependencies: [Dependency] 59 | } 60 | 61 | public struct Platform: Equatable, Sendable { 62 | public let platformName: String 63 | public let version: String 64 | } 65 | 66 | public let products: [Product] 67 | public let platforms: [Platform] 68 | public let targets: [Target] 69 | } 70 | 71 | extension PackageWrapper { 72 | public init(from package: Package) { 73 | products = package.products.map(Product.init(from:)) 74 | platforms = package.manifest.platforms.map(Platform.init(from:)) 75 | targets = package.modules.map(Target.init(from:)) 76 | } 77 | } 78 | 79 | // MARK: - Mappers 80 | 81 | extension PackageWrapper.Target { 82 | init(from module: PackageModel.Module) { 83 | name = module.name 84 | dependencies = module.dependencies.map(Dependency.init(from:)) 85 | } 86 | } 87 | 88 | extension PackageWrapper.Target.Dependency { 89 | init(from dependency: PackageModel.Module.Dependency) { 90 | switch dependency { 91 | case let .module(target, _): 92 | self = .target(PackageWrapper.Target(from: target)) 93 | case let .product(product, _): 94 | self = .product(PackageWrapper.Product(from: product)) 95 | } 96 | } 97 | } 98 | 99 | extension PackageWrapper.Product { 100 | init(from product: PackageModel.Product) { 101 | name = product.name 102 | package = nil 103 | isDynamicLibrary = product.isDynamicLibrary 104 | targets = product.modules.map(PackageWrapper.Target.init(from:)) 105 | } 106 | } 107 | 108 | extension PackageWrapper.Product { 109 | init(from product: PackageModel.Module.ProductReference) { 110 | name = product.name 111 | package = product.package 112 | isDynamicLibrary = nil 113 | targets = [] 114 | } 115 | } 116 | 117 | extension PackageWrapper.Platform { 118 | init(from platformDescription: PackageModel.PlatformDescription) { 119 | platformName = platformDescription.platformName 120 | version = platformDescription.version 121 | } 122 | } 123 | 124 | // MARK: - PackageModel Extensions 125 | 126 | private extension Product { 127 | var isDynamicLibrary: Bool { 128 | switch type { 129 | case .library(.dynamic): 130 | return true 131 | default: 132 | return false 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Sources/Core/Models/ResurceState.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public enum ResourceState: Equatable, CustomStringConvertible, Sendable { 22 | case undefined 23 | case valid 24 | case invalid 25 | 26 | public var description: String { 27 | switch self { 28 | case .undefined: 29 | return "undefined" 30 | case .valid: 31 | return "valid" 32 | case .invalid: 33 | return "invalid" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Core/Models/SizeOnDisk.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | internal import Foundation 22 | 23 | public struct SizeOnDisk: Equatable, Sendable { 24 | /// Literal size quantity, in `Kilobytes` 25 | public let amount: Int 26 | public let formatted: String 27 | 28 | public init( 29 | amount: Int, 30 | formatted: String 31 | ) { 32 | self.amount = amount 33 | self.formatted = formatted 34 | } 35 | 36 | public static let empty: SizeOnDisk = .init( 37 | amount: 0, 38 | formatted: "0.0" 39 | ) 40 | } 41 | 42 | extension SizeOnDisk: CustomStringConvertible { 43 | public var description: String { 44 | "Size on disk: \(formatted)" 45 | } 46 | } 47 | 48 | extension SizeOnDisk: CustomConsoleMessageConvertible { 49 | public var message: ConsoleMessage { 50 | .init( 51 | text: description, 52 | color: .yellow, 53 | isBold: false 54 | ) 55 | } 56 | } 57 | 58 | extension SizeOnDisk: AdditiveArithmetic { 59 | public static let zero: SizeOnDisk = .empty 60 | 61 | public static func - (lhs: SizeOnDisk, rhs: SizeOnDisk) -> SizeOnDisk { 62 | let finalAmount = lhs.amount - rhs.amount 63 | 64 | return .init( 65 | amount: finalAmount, 66 | formatted: URL.fileByteCountFormatter.string( 67 | for: finalAmount 68 | ) ?? "" 69 | ) 70 | } 71 | 72 | public static func + (lhs: SizeOnDisk, rhs: SizeOnDisk) -> SizeOnDisk { 73 | let finalAmount = lhs.amount + rhs.amount 74 | 75 | return .init( 76 | amount: finalAmount, 77 | formatted: URL.fileByteCountFormatter.string( 78 | for: finalAmount 79 | ) ?? "" 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Core/PackageLoader.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Basics 22 | public import PackageModel 23 | internal import Workspace 24 | 25 | /// Loads the content of a Package.swift, the dependency graph included 26 | /// 27 | /// The ``PackageLoader`` uses the SPM library to load the package representation 28 | public struct PackageLoader: Sendable { 29 | /// Loads a Package.swift at a given `packagePath` 30 | public var load: @Sendable (AbsolutePath) async throws -> Package 31 | } 32 | 33 | extension PackageLoader { 34 | /// Makes a **Live** ``PackageLoader`` instance 35 | public static let live: Self = { 36 | .init( 37 | load: { packagePath in 38 | let observability = ObservabilitySystem { print("\($0): \($1)") } 39 | 40 | let workspace = try Workspace(forRootPackage: packagePath) 41 | 42 | return try await workspace.loadRootPackage( 43 | at: packagePath, 44 | observabilityScope: observability.topScope 45 | ) 46 | } 47 | ) 48 | }() 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Core/Shell.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import Foundation 22 | 23 | public enum Shell { 24 | public struct Output: Equatable { 25 | public let succeeded: Bool 26 | public let data: Data 27 | public let errorData: Data 28 | } 29 | 30 | @discardableResult 31 | public static func run( 32 | workingDirectory: String? = FileManager.default.currentDirectoryPath, 33 | outputPipe: Pipe = .init(), 34 | errorPipe: Pipe = .init(), 35 | arguments: String..., 36 | verbose: Bool, 37 | timeout: TimeInterval? = 30 38 | ) async throws -> Output { 39 | try await runProcess( 40 | workingDirectory: workingDirectory, 41 | outputPipe: outputPipe, 42 | errorPipe: errorPipe, 43 | arguments: arguments, 44 | verbose: verbose, 45 | timeout: timeout 46 | ) 47 | } 48 | 49 | @discardableResult 50 | public static func run( 51 | _ command: String, 52 | outputPipe: Pipe = .init(), 53 | errorPipe: Pipe = .init(), 54 | workingDirectory: String? = FileManager.default.currentDirectoryPath, 55 | verbose: Bool, 56 | timeout: TimeInterval? = 30 57 | ) async throws -> Output { 58 | let commands = command.split(whereSeparator: \.isWhitespace) 59 | 60 | let arguments: [String] 61 | if commands.count > 1 { 62 | arguments = commands.map { String($0) } 63 | } else { 64 | arguments = command 65 | .split { [" -", " --"].contains(String($0)) } 66 | .map { String($0) } 67 | } 68 | 69 | return try await runProcess( 70 | workingDirectory: workingDirectory, 71 | outputPipe: outputPipe, 72 | errorPipe: errorPipe, 73 | arguments: arguments, 74 | verbose: verbose, 75 | timeout: timeout 76 | ) 77 | } 78 | } 79 | 80 | public extension Shell { 81 | @discardableResult 82 | static func performShallowGitClone( 83 | workingDirectory: String? = FileManager.default.currentDirectoryPath, 84 | repositoryURLString: String, 85 | branchOrTag: String, 86 | verbose: Bool, 87 | timeout: TimeInterval? = 15 88 | ) async throws -> Output { 89 | try await Shell.run( 90 | "git clone --branch \(branchOrTag) --depth 1 \(repositoryURLString)", 91 | workingDirectory: workingDirectory, 92 | verbose: verbose, 93 | timeout: timeout 94 | ) 95 | } 96 | } 97 | 98 | // MARK: - Private 99 | 100 | private extension Shell { 101 | static func runProcess( 102 | workingDirectory: String?, 103 | outputPipe: Pipe, 104 | errorPipe: Pipe, 105 | arguments: [String], 106 | verbose: Bool, 107 | timeout: TimeInterval? 108 | ) async throws -> Output { 109 | let process = Process.make( 110 | workingDirectory: workingDirectory, 111 | outputPipe: outputPipe, 112 | errorPipe: errorPipe, 113 | arguments: arguments, 114 | verbose: verbose 115 | ) 116 | 117 | if verbose { 118 | await Console.default.lineBreakAndWrite( 119 | .init( 120 | text: "Running Shell command", 121 | color: .yellow 122 | ) 123 | ) 124 | } 125 | 126 | var outputData = Data() 127 | var errorData = Data() 128 | 129 | try process.run() 130 | 131 | await withTaskGroup(of: Void.self) { taskGroup in 132 | outputData = await readData( 133 | from: outputPipe, 134 | isError: false, 135 | verbose: verbose 136 | ) 137 | errorData = await readData( 138 | from: errorPipe, 139 | isError: true, 140 | verbose: verbose 141 | ) 142 | } 143 | 144 | process.waitUntilExit() 145 | 146 | if verbose { 147 | await Console.default.lineBreakAndWrite( 148 | .init( 149 | text: "Finished Shell command", 150 | color: .yellow 151 | ) 152 | ) 153 | } 154 | 155 | return .init( 156 | succeeded: process.terminationStatus == 0, 157 | data: outputData, 158 | errorData: errorData 159 | ) 160 | } 161 | 162 | static func readData( 163 | from pipe: Pipe, 164 | isError: Bool, 165 | verbose: Bool 166 | ) async -> Data { 167 | await withCheckedContinuation { continuation in 168 | pipe.fileHandleForReading.readabilityHandler = { fileHandle in 169 | // readData(ofLength:) is used here to avoid unwanted performance side effects, 170 | // as described in the findings from: 171 | // https://stackoverflow.com/questions/49184623/nstask-race-condition-with-readabilityhandler-block 172 | let data = fileHandle.readData(ofLength: .max) 173 | 174 | let cleanAndResume = { 175 | pipe.fileHandleForReading.readabilityHandler = nil 176 | continuation.resume(returning: data) 177 | } 178 | 179 | if data.isEmpty == false { 180 | guard verbose else { 181 | cleanAndResume() 182 | return 183 | } 184 | String(data: data, encoding: .utf8).map { 185 | let text = $0 186 | if isError { 187 | Task { @MainActor in 188 | Console.default.lineBreakAndWrite( 189 | .init( 190 | text: text, 191 | color: .red 192 | ) 193 | ) 194 | } 195 | } else { 196 | Task { @MainActor in 197 | Console.default.write( 198 | .init( 199 | text: text, 200 | hasLineBreakAfter: false 201 | ) 202 | ) 203 | } 204 | } 205 | } 206 | cleanAndResume() 207 | } else { 208 | cleanAndResume() 209 | } 210 | } 211 | } 212 | } 213 | } 214 | 215 | extension Process { 216 | static func make( 217 | launchPath: String = "/usr/bin/env", 218 | workingDirectory: String? = FileManager.default.currentDirectoryPath, 219 | outputPipe: Pipe, 220 | errorPipe: Pipe, 221 | arguments: [String], 222 | verbose: Bool 223 | ) -> Process { 224 | let process = Process() 225 | process.launchPath = launchPath 226 | process.arguments = arguments 227 | process.standardError = errorPipe 228 | process.standardOutput = outputPipe 229 | process.qualityOfService = .userInteractive 230 | if let workingDirectory = workingDirectory { 231 | process.currentDirectoryPath = workingDirectory 232 | } 233 | return process 234 | } 235 | } 236 | 237 | struct SendableData: Equatable, Sendable { 238 | private(set) var _data: Data = .init() 239 | var data: Data { 240 | get { 241 | _data 242 | } 243 | set { 244 | NSLock().withLock { 245 | self._data.append(newValue) 246 | } 247 | } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /Sources/CoreTestSupport/Doubles/Fixtures/Fixture+ProvidedInfo.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Core 22 | 23 | public extension Fixture { 24 | struct ProvidedInformation: Encodable, CustomConsoleMessagesConvertible { 25 | let name: String 26 | let value: Int 27 | 28 | public var messages: [ConsoleMessage] { 29 | [ 30 | .init( 31 | text: name, 32 | color: .green, 33 | isBold: true, 34 | hasLineBreakAfter: false 35 | ), 36 | .init( 37 | text: "Value is \(value)", 38 | color: .noColor, 39 | isBold: false, 40 | hasLineBreakAfter: true 41 | ), 42 | ] 43 | } 44 | } 45 | 46 | static func makeProvidedInfoInformation( 47 | name: String = "name", 48 | value: Int = 10 49 | ) -> ProvidedInformation { 50 | .init( 51 | name: name, 52 | value: value 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/CoreTestSupport/Doubles/Fixtures/Fixture+SwiftPackage.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Foundation 22 | 23 | import Core 24 | 25 | public extension Fixture { 26 | static func makePackageDefinition( 27 | url: URL = URL(string: "https://www.apple.com")!, 28 | version: String = "1.0.0", 29 | revision: String? = nil, 30 | product: String = "Some" 31 | ) throws -> PackageDefinition { 32 | try PackageDefinition( 33 | url: url, 34 | version: version, 35 | revision: revision, 36 | product: product 37 | ) 38 | } 39 | 40 | static func makePackageDefinition( 41 | source: PackageDefinition.Source = .remote( 42 | url: URL(string: "https://www.apple.com")!, 43 | resolution: .version("1.0.0") 44 | ), 45 | product: String = "Some" 46 | ) throws -> PackageDefinition { 47 | try PackageDefinition( 48 | source: source, 49 | product: product 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/CoreTestSupport/Doubles/Fixtures/Fixture.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public enum Fixture {} 22 | -------------------------------------------------------------------------------- /Sources/CoreTestSupport/Doubles/Mocks/ProgressAnimationMock.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import TSCUtility 22 | 23 | public final class ProgressAnimationMock: ProgressAnimationProtocol { 24 | public private(set) var updateCallsCount: Int = 0 25 | public private(set) var completeCallsCount: Int = 0 26 | public private(set) var clearCallsCount: Int = 0 27 | public private(set) var lastUpdateStep: Int? 28 | public private(set) var lastUpdateTotal: Int? 29 | public private(set) var lastUpdateText: String? 30 | public private(set) var lastCompleteSuccess: Bool? 31 | 32 | public init() { } 33 | 34 | public func update( 35 | step: Int, 36 | total: Int, 37 | text: String 38 | ) { 39 | updateCallsCount += 1 40 | lastUpdateStep = step 41 | lastUpdateTotal = total 42 | lastUpdateText = text 43 | } 44 | 45 | public func complete(success: Bool) { 46 | completeCallsCount += 1 47 | lastCompleteSuccess = success 48 | } 49 | 50 | public func clear() { 51 | clearCallsCount += 1 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/CoreTestSupport/Doubles/Mocks/TerminalControllerMock.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Core 22 | 23 | public final class TerminalControllerMock: TerminalControlling { 24 | public private(set) var endLineCallsCount: Int = 0 25 | public private(set) var writeCallsCount: Int = 0 26 | public private(set) var writeStrings: [String] = [] 27 | public private(set) var writeColors: [ConsoleColor] = [] 28 | public private(set) var writeBolds: [Bool] = [] 29 | 30 | public init() { } 31 | 32 | public func endLine() { 33 | endLineCallsCount += 1 34 | } 35 | 36 | public func write( 37 | _ string: String, 38 | inColor color: ConsoleColor, 39 | bold: Bool 40 | ) { 41 | writeCallsCount += 1 42 | writeStrings.append(string) 43 | writeColors.append(color) 44 | writeBolds.append(bold) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/CoreTestSupport/Extensions/XCTest+TestSupport.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | 23 | public extension XCTestCase { 24 | func dataFromJSON( 25 | named name: String, 26 | bundle: Bundle, 27 | file: StaticString = #filePath, 28 | line: UInt = #line 29 | ) throws -> Data { 30 | let jsonData = try bundle.url(forResource: name, withExtension: "json") 31 | .flatMap { try Data(contentsOf: $0) } 32 | return try XCTUnwrap( 33 | jsonData, 34 | file: file, 35 | line: line 36 | ) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Reports/Generators/JSONDumpReportGenerator.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Core 22 | import Foundation 23 | 24 | struct JSONDumpReportGenerator: Sendable { 25 | private let console: Console 26 | private let encoder: JSONEncoder 27 | 28 | init( 29 | console: Console, 30 | encoder: JSONEncoder = .sortedAndPrettyPrinted 31 | ) { 32 | self.console = console 33 | self.encoder = encoder 34 | } 35 | 36 | @Sendable 37 | func renderDump( 38 | for swiftPackage: PackageDefinition, 39 | providedInfos: [ProvidedInfo] 40 | ) throws { 41 | let reportData = ReportData( 42 | providedInfos: providedInfos 43 | ) 44 | 45 | let data = try encoder.encode(reportData) 46 | let stringData = String(data: data, encoding: .utf8) ?? "" 47 | 48 | Task { @MainActor in 49 | console.write( 50 | .init(text: stringData) 51 | ) 52 | } 53 | } 54 | } 55 | 56 | struct ReportData: Encodable { 57 | let providedInfos: [ProvidedInfo] 58 | 59 | func encode(to encoder: Encoder) throws { 60 | var container = encoder.container(keyedBy: ProviderKind.self) 61 | 62 | if let binarySizeInfo = providedInfos.first(where: \.providerKind == .binarySize) { 63 | try container.encode(binarySizeInfo, forKey: .binarySize) 64 | } 65 | if let dependenciesInfo = providedInfos.first(where: \.providerKind == .dependencies) { 66 | try container.encode(dependenciesInfo, forKey: .dependencies) 67 | } 68 | if let platformsInfo = providedInfos.first(where: \.providerKind == .platforms) { 69 | try container.encode(platformsInfo, forKey: .platforms) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Reports/Generators/ReportGenerating.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Core 22 | 23 | typealias ReportGenerating = ( 24 | _ swiftPackage: PackageDefinition, 25 | _ providedInfos: [ProvidedInfo] 26 | ) async throws -> Void 27 | -------------------------------------------------------------------------------- /Sources/Reports/Report.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import TSCBasic 22 | import Core 23 | 24 | public final class Report: Reporting { 25 | let packageDefinition: PackageDefinition 26 | private let console: Console 27 | 28 | public init( 29 | packageDefinition: PackageDefinition, 30 | console: Console 31 | ) { 32 | self.packageDefinition = packageDefinition 33 | self.console = console 34 | } 35 | 36 | public func generate( 37 | for providedInfo: ProvidedInfo, 38 | format: ReportFormat 39 | ) async throws { 40 | try await generate( 41 | for: [providedInfo], 42 | format: format 43 | ) 44 | } 45 | 46 | public func generate( 47 | for providedInfos: [ProvidedInfo], 48 | format: ReportFormat 49 | ) async throws { 50 | let reportGenerator = format.makeReportGenerator(console: console) 51 | try await reportGenerator( 52 | packageDefinition, 53 | providedInfos 54 | ) 55 | } 56 | } 57 | 58 | // MARK: - SwiftPackage: CustomConsoleMessageConvertible 59 | 60 | extension PackageDefinition: CustomConsoleMessageConvertible { 61 | public var message: ConsoleMessage { 62 | .init( 63 | text: "\(product), \(versionOrRevision)", 64 | color: .cyan, 65 | isBold: false, 66 | hasLineBreakAfter: false 67 | ) 68 | } 69 | 70 | private var versionOrRevision: String { 71 | switch source.remoteResolution { 72 | case let .revision(revision): 73 | revision 74 | case let .version(tag): 75 | tag 76 | case .none: 77 | "local package" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Reports/ReportCell.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Core 22 | 23 | struct ReportCell: Equatable { 24 | let messages: [ConsoleMessage] 25 | let size: Int 26 | let textSize: Int 27 | 28 | init(messages: [ConsoleMessage], customSize: Int? = nil) { 29 | self.messages = messages 30 | 31 | let textSize = messages 32 | .map(\.text.count) 33 | .reduce(0, +) 34 | self.size = customSize ?? textSize 35 | self.textSize = textSize 36 | } 37 | 38 | static func makeColumnHeaderCell(title: String, size: Int) -> Self { 39 | .init( 40 | messages: [ 41 | .init( 42 | text: title, 43 | color: .noColor, 44 | isBold: false, 45 | hasLineBreakAfter: false 46 | ) 47 | ], 48 | customSize: size 49 | ) 50 | } 51 | 52 | static func makeProviderTitleCell(named name: String, size: Int) -> Self { 53 | .init( 54 | messages: [ 55 | .init( 56 | text: name, 57 | color: .yellow, 58 | isBold: true, 59 | hasLineBreakAfter: false 60 | ) 61 | ], 62 | customSize: size 63 | ) 64 | } 65 | 66 | static func makeForProvidedInfo(providedInfo: ProvidedInfo, size: Int) -> Self { 67 | .init( 68 | messages: providedInfo.messages, 69 | customSize: size 70 | ) 71 | } 72 | 73 | static func makeTitleCell(text: String) -> Self { 74 | .init( 75 | messages: [ 76 | .init( 77 | text: text, 78 | color: .cyan, 79 | isBold: true, 80 | hasLineBreakAfter: false 81 | ) 82 | ] 83 | ) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Reports/ReportFormat.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Core 22 | 23 | public enum ReportFormat: String, CaseIterable, Sendable { 24 | case consoleMessage 25 | case jsonDump 26 | } 27 | 28 | extension ReportFormat { 29 | func makeReportGenerator(console: Console) -> ReportGenerating { 30 | switch self { 31 | case .consoleMessage: 32 | ConsoleReportGenerator(console: console).renderReport 33 | case .jsonDump: 34 | JSONDumpReportGenerator(console: console).renderDump 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Reports/Reporting.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Core 22 | 23 | protocol Reporting { 24 | var packageDefinition: PackageDefinition { get } 25 | 26 | func generate(for _: ProvidedInfo, format: ReportFormat) async throws 27 | func generate(for _: [ProvidedInfo], format: ReportFormat) async throws 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Run/ExpressibleByArgument+Run.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | public import ArgumentParser 22 | public import Foundation 23 | public import Reports 24 | public import TSCUtility 25 | 26 | extension Foundation.URL: @retroactive ExpressibleByArgument { 27 | public init?(argument: String) { 28 | self.init(string: argument) 29 | } 30 | } 31 | 32 | extension TSCUtility.Version: @retroactive ExpressibleByArgument { 33 | public init?(argument: String) { 34 | self.init(argument) 35 | } 36 | 37 | public var defaultValueDescription: String { "1.2.12" } 38 | } 39 | 40 | extension ReportFormat: ExpressibleByArgument { 41 | public init?(argument: String) { 42 | self.init(rawValue: argument) 43 | } 44 | 45 | public var defaultValueDescription: String { 46 | ReportFormat.consoleMessage.rawValue 47 | } 48 | 49 | static public var allValueStrings: [String] { 50 | ReportFormat.allCases.map(\.rawValue) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Run/Subcommands/BinarySize.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import ArgumentParser 22 | import Core 23 | import App 24 | import Reports 25 | 26 | extension SwiftPackageInfo { 27 | public struct BinarySize: AsyncParsableCommand { 28 | static let estimatedSizeNote = """ 29 | * Note: The estimated size may not reflect the exact amount since it doesn't account optimizations such as app thinning. 30 | Its methodology is inspired by [cocoapods-size](https://github.com/google/cocoapods-size), 31 | and thus works by comparing archives with no bitcode and ARM64 arch. 32 | Such a strategy has proven to be very consistent with the size added to iOS apps downloaded and installed via TestFlight. 33 | """ 34 | 35 | public static let configuration = CommandConfiguration( 36 | abstract: "Estimated binary size of a Swift Package product.", 37 | discussion: """ 38 | Measures the estimated binary size impact of a Swift Package product, 39 | such as "ArgumentParser" declared on `swift-argument-parser`. 40 | 41 | \(estimatedSizeNote) 42 | """, 43 | version: SwiftPackageInfo.configuration.version 44 | ) 45 | 46 | @OptionGroup var allArguments: AllArguments 47 | 48 | public init() {} 49 | 50 | public func run() async throws { 51 | try runArgumentsValidation(arguments: allArguments) 52 | var packageDefinition = try makePackageDefinition(from: allArguments) 53 | 54 | Task { @MainActor in 55 | try packageDefinition.messages.forEach(Console.default.lineBreakAndWrite) 56 | } 57 | 58 | let validator = await SwiftPackageValidator(console: .default) 59 | let package: PackageWrapper 60 | do { 61 | package = try await validator.validate(packageDefinition: &packageDefinition) 62 | } catch { 63 | throw CleanExit.make(from: error) 64 | } 65 | 66 | let finalPackageDefinition = packageDefinition 67 | 68 | let providedInfo: ProvidedInfo? 69 | do { 70 | providedInfo = try await BinarySizeProvider.binarySize( 71 | for: packageDefinition, 72 | resolvedPackage: package, 73 | xcconfig: allArguments.xcconfig, 74 | verbose: allArguments.verbose 75 | ) 76 | } catch { 77 | providedInfo = nil 78 | if let providerError = error as? InfoProviderError { 79 | Task { @MainActor in 80 | Console.default.write(providerError.message) 81 | } 82 | } 83 | } 84 | 85 | if let providedInfo { 86 | let report = await Report(packageDefinition: finalPackageDefinition, console: .default) 87 | try await report.generate( 88 | for: providedInfo, 89 | format: allArguments.report 90 | ) 91 | } 92 | } 93 | } 94 | } 95 | 96 | extension PackageDefinition: CustomConsoleMessagesConvertible { 97 | public var messages: [ConsoleMessage] { 98 | [ 99 | .init( 100 | text: "Identified Swift Package:", 101 | color: .green, 102 | isBold: true, 103 | hasLineBreakAfter: false 104 | ), 105 | .init( 106 | text: description, 107 | color: .noColor, 108 | isBold: false 109 | ) 110 | ] 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/Run/Subcommands/Dependencies.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import ArgumentParser 22 | import Core 23 | import App 24 | import Reports 25 | 26 | extension SwiftPackageInfo { 27 | public struct Dependencies: AsyncParsableCommand { 28 | public static let configuration = CommandConfiguration( 29 | abstract: "List dependencies of a Package product.", 30 | discussion: """ 31 | Show direct and indirect dependencies of a product, listing 32 | all dependencies that are linked to its binary. 33 | """, 34 | version: SwiftPackageInfo.configuration.version 35 | ) 36 | 37 | @OptionGroup var allArguments: AllArguments 38 | 39 | public init() {} 40 | 41 | public func run() async throws { 42 | try runArgumentsValidation(arguments: allArguments) 43 | 44 | var packageDefinition = try makePackageDefinition(from: allArguments) 45 | Task { @MainActor in 46 | try packageDefinition.messages.forEach(Console.default.lineBreakAndWrite) 47 | } 48 | 49 | let validator = await SwiftPackageValidator(console: .default) 50 | let package: PackageWrapper 51 | do { 52 | package = try await validator.validate(packageDefinition: &packageDefinition) 53 | } catch { 54 | throw CleanExit.make(from: error) 55 | } 56 | 57 | do { 58 | let providedInfo = try await DependenciesProvider.dependencies( 59 | for: packageDefinition, 60 | resolvedPackage: package, 61 | xcconfig: allArguments.xcconfig, 62 | verbose: allArguments.verbose 63 | ) 64 | 65 | let report = await Report(packageDefinition: packageDefinition, console: .default) 66 | try await report.generate( 67 | for: providedInfo, 68 | format: allArguments.report 69 | ) 70 | } catch { 71 | if let providerError = error as? InfoProviderError { 72 | Task { @MainActor in 73 | Console.default.write(providerError.message) 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Run/Subcommands/FullAnalyzes.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import ArgumentParser 22 | import App 23 | import Core 24 | import Foundation 25 | import Reports 26 | 27 | extension SwiftPackageInfo { 28 | public struct FullAnalyzes: AsyncParsableCommand { 29 | public static let configuration = CommandConfiguration( 30 | abstract: "All available information about a Swift Package product.", 31 | discussion: """ 32 | Runs all available providers (each one available via a subcommand, e.g. BinarySize), 33 | and generates a full report of a given Swift Package product for a specific version. 34 | """, 35 | version: SwiftPackageInfo.configuration.version 36 | ) 37 | 38 | @OptionGroup var allArguments: AllArguments 39 | 40 | public init() {} 41 | 42 | public func run() async throws { 43 | try runArgumentsValidation(arguments: allArguments) 44 | var packageDefinition = try makePackageDefinition(from: allArguments) 45 | packageDefinition.messages.forEach { 46 | let message = $0 47 | Task { @MainActor in 48 | Console.default.lineBreakAndWrite(message) 49 | } 50 | } 51 | 52 | let validator = await SwiftPackageValidator(console: .default) 53 | let package = try await validator.validate(packageDefinition: &packageDefinition) 54 | 55 | let finalPackageDefinition = packageDefinition 56 | 57 | // All copies to silence Swift 6 concurrency `sending` warnings 58 | let xcconfig = allArguments.xcconfig 59 | let isVerbose = allArguments.verbose 60 | let providedInfos: [ProvidedInfo] = try await withThrowingTaskGroup( 61 | of: ProvidedInfo.self, 62 | returning: [ProvidedInfo].self 63 | ) { taskGroup in 64 | SwiftPackageInfo.subcommandsProviders.forEach { subcommandProvider in 65 | taskGroup.addTask { 66 | try await subcommandProvider( 67 | finalPackageDefinition, 68 | package, 69 | xcconfig, 70 | isVerbose 71 | ) 72 | } 73 | } 74 | 75 | var providedInfos: [ProvidedInfo] = [] 76 | for try await result in taskGroup { 77 | providedInfos.append(result) 78 | } 79 | return providedInfos 80 | } 81 | 82 | let report = await Report(packageDefinition: finalPackageDefinition, console: .default) 83 | try await report.generate( 84 | for: providedInfos, 85 | format: allArguments.report 86 | ) 87 | } 88 | } 89 | } 90 | 91 | extension CommandConfiguration: @retroactive @unchecked Sendable {} 92 | 93 | 94 | // 1. validates and updates the package input 95 | // 2. calls info provider -> provides info 96 | // 3. calls reporter -> provides report 97 | 98 | // Executable 99 | // Needs both validation, provider and reporter 100 | 101 | // Library 102 | // Needs (input) -> output 103 | // with validation included, and ideally internally resolved 104 | 105 | // Library target 106 | // - has wrapper around providers 107 | // - for that we need the validation to be moved out 108 | // - the validation should be safer - no inout 109 | 110 | // Action items 111 | // - Make validation safer and improve executable 112 | // - then build library target 113 | -------------------------------------------------------------------------------- /Sources/Run/Subcommands/Platforms.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import ArgumentParser 22 | import Core 23 | import App 24 | import Reports 25 | 26 | extension SwiftPackageInfo { 27 | public struct Platforms: AsyncParsableCommand { 28 | public static let configuration = CommandConfiguration( 29 | abstract: "Shows platforms supported b a Package product.", 30 | discussion: """ 31 | Informs supported platforms by a given Package.swift and its products, 32 | e.g 'iOS with 9.0 minimum deployment target'. 33 | """, 34 | version: SwiftPackageInfo.configuration.version 35 | ) 36 | 37 | @OptionGroup var allArguments: AllArguments 38 | 39 | public init() {} 40 | 41 | public func run() async throws { 42 | try runArgumentsValidation(arguments: allArguments) 43 | 44 | var packageDefinition = try makePackageDefinition(from: allArguments) 45 | Task { @MainActor in 46 | try packageDefinition.messages.forEach(Console.default.lineBreakAndWrite) 47 | } 48 | 49 | let validator = await SwiftPackageValidator(console: .default) 50 | let package: PackageWrapper 51 | do { 52 | package = try await validator.validate(packageDefinition: &packageDefinition) 53 | } catch { 54 | throw CleanExit.make(from: error) 55 | } 56 | 57 | do { 58 | let providedInfo = try await PlatformsProvider.platforms( 59 | for: packageDefinition, 60 | resolvedPackage: package, 61 | xcconfig: allArguments.xcconfig, 62 | verbose: allArguments.verbose 63 | ) 64 | 65 | let report = await Report(packageDefinition: packageDefinition, console: .default) 66 | try await report.generate( 67 | for: providedInfo, 68 | format: allArguments.report 69 | ) 70 | } catch { 71 | if let providerError = error as? InfoProviderError { 72 | Task { @MainActor in 73 | Console.default.write(providerError.message) 74 | } 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Run/SwiftPackageInfo.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import ArgumentParser 22 | import struct Foundation.URL 23 | import Core 24 | import App 25 | import Reports 26 | 27 | import PackageModel 28 | 29 | // MARK: - Main parsable command 30 | 31 | @main 32 | public struct SwiftPackageInfo: AsyncParsableCommand { 33 | public static let configuration = CommandConfiguration( 34 | abstract: "A tool for analyzing Swift Packages", 35 | discussion: """ 36 | Provides valuable information about a given Swift Package, 37 | that can be used in your favor when deciding whether to 38 | adopt or not a Swift Package as a dependency on your app. 39 | """, 40 | version: "1.6.0", 41 | subcommands: [ 42 | BinarySize.self, 43 | Platforms.self, 44 | Dependencies.self, 45 | FullAnalyzes.self 46 | ], 47 | defaultSubcommand: FullAnalyzes.self 48 | ) 49 | 50 | static let subcommandsProviders: [InfoProvider] = [ 51 | BinarySizeProvider.binarySize(for:resolvedPackage:xcconfig:verbose:), 52 | PlatformsProvider.platforms(for:resolvedPackage:xcconfig:verbose:), 53 | DependenciesProvider.dependencies(for:resolvedPackage:xcconfig:verbose:) 54 | ] 55 | 56 | public init() {} 57 | } 58 | 59 | // MARK: - Available arguments 60 | 61 | struct AllArguments: ParsableArguments { 62 | @Option( 63 | name: [ 64 | .long, 65 | .customLong("for"), 66 | .customLong("package"), 67 | .customLong("repo-url"), 68 | .customLong("path"), 69 | .customLong("local-path"), 70 | ], 71 | help: """ 72 | Either a valid git repository or the relative or absolute path to a local directory that contains a `Package.swift`. 73 | """ 74 | ) 75 | var url: URL 76 | 77 | @Option( 78 | name: [ 79 | .long, 80 | .customShort("v") 81 | ], 82 | help: "Semantic version of the Swift Package. If not passed and `revision` is not set, the latest semver tag is used" 83 | ) 84 | var packageVersion: String? 85 | 86 | @Option( 87 | name: [ 88 | .long, 89 | .customShort("r") 90 | ], 91 | help: "A single git commit, SHA-1 hash, or branch name. Applied when `packageVersion` is not set" 92 | ) 93 | var revision: String? 94 | 95 | @Option( 96 | name: [ 97 | .long, 98 | .customLong("product-named"), 99 | .customLong("product-name") 100 | ], 101 | help: "Name of the product to be checked. If not passed in the first available product is used" 102 | ) 103 | var product: String? 104 | 105 | @Option( 106 | name: [ 107 | .long, 108 | .customLong("report-format"), 109 | .customLong("output"), 110 | .customLong("output-format") 111 | ], 112 | help: """ 113 | Define the report output format/strategy. Supported values are: 114 | - \( 115 | ReportFormat.allCases.map(\.rawValue) 116 | .joined(separator: "\n- ") 117 | ) 118 | """ 119 | ) 120 | var report: ReportFormat = .consoleMessage 121 | 122 | @Option( 123 | name: [ 124 | .customLong("xcconfig"), 125 | ], 126 | help: """ 127 | A valid relative local directory path that point to a file of type `.xcconfig` 128 | """ 129 | ) 130 | var xcconfig: URL? = nil 131 | 132 | @Flag( 133 | name: .long, 134 | help: "Increase verbosity of informational output" 135 | ) 136 | var verbose = false 137 | } 138 | 139 | // MARK: - Common ParsableCommand extension 140 | 141 | extension ParsableCommand { 142 | func runArgumentsValidation(arguments: AllArguments) throws { 143 | guard CommandLine.argc > 0 else { throw CleanExit.helpRequest() } 144 | 145 | let isValidRemoteURL = arguments.url.isValidRemote 146 | let isValidLocalDirectory = try? arguments.url.isLocalDirectoryContainingPackageDotSwift() 147 | let isValidLocalCustomFile = arguments.xcconfig?.isLocalXCConfigFileValid() 148 | 149 | guard isValidRemoteURL || isValidLocalDirectory == true else { 150 | throw CleanExit.message( 151 | """ 152 | Error: Invalid argument '--url ' 153 | Usage: The URL must be either: 154 | - A valid git repository URL that contains a `Package.swift`, e.g `https://github.com/Alamofire/Alamofire`; or 155 | - A relative or absolute path to a local directory that has a `Package.swift`, e.g. `../other-dir/my-project` 156 | """ 157 | ) 158 | } 159 | 160 | if isValidLocalCustomFile == false { 161 | throw CleanExit.message( 162 | """ 163 | Error: Invalid argument '--xcconfig ' 164 | Usage: The URL must be a relative local file path that has point to a `.xcconfig` file, e.g. `../other-dir/CustomConfiguration.xcconfig` 165 | """ 166 | ) 167 | } 168 | } 169 | 170 | func makePackageDefinition(from arguments: AllArguments) throws -> PackageDefinition { 171 | try PackageDefinition( 172 | url: arguments.url, 173 | version: arguments.packageVersion, 174 | revision: arguments.revision, 175 | product: arguments.product 176 | ) 177 | } 178 | } 179 | 180 | extension CleanExit { 181 | static func make(from validationError: SwiftPackageValidationError) -> Self { 182 | switch validationError { 183 | case .invalidURL: 184 | CleanExit.message( 185 | """ 186 | Error: Invalid argument '--url ' 187 | Usage: The URL must be a valid git repository URL that contains 188 | a `Package.swift`, e.g `https://github.com/Alamofire/Alamofire` 189 | """ 190 | ) 191 | case .failedToLoadPackage: 192 | CleanExit.message("") 193 | case let .noProductFound(packageURL): 194 | CleanExit.message( 195 | "Error: \(packageURL) doesn't contain any product declared on Package.swift" 196 | ) 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Sources/SwiftPackageInfo/SwiftPackageInfoLibrary.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | internal import App 22 | public import Core 23 | public import Foundation 24 | 25 | public enum BinarySize {} 26 | 27 | public extension BinarySize { 28 | protocol Providing { 29 | func getBinarySize( 30 | for packageDefinition: PackageDefinition, 31 | xcConfig: URL? 32 | ) async throws -> Result 33 | } 34 | } 35 | 36 | public extension BinarySize { 37 | /// A type that provides the binary size of a given Swift Package Product. 38 | final class Provider: Providing { 39 | private let validator: SwiftPackageValidating 40 | 41 | public convenience init() { 42 | self.init(validator: SwiftPackageValidator()) 43 | } 44 | 45 | init(validator: SwiftPackageValidating = SwiftPackageValidator()) { 46 | self.validator = validator 47 | } 48 | 49 | public func getBinarySize( 50 | for packageDefinition: PackageDefinition, 51 | xcConfig: URL? //// A valid relative local directory path that point to a file of type `.xcconfig` 52 | ) async throws(BinarySize.Error) -> Result { 53 | if 54 | let xcConfig = xcConfig, 55 | !xcConfig.isLocalXCConfigFileValid() 56 | { 57 | throw Error.invalidXcConfigFile(xcConfig) 58 | } 59 | 60 | var finalPackageDefinition = packageDefinition 61 | 62 | let package: PackageWrapper 63 | do { 64 | package = try await validator.validate( 65 | packageDefinition: &finalPackageDefinition, 66 | isVerbose: false 67 | ) 68 | } catch { 69 | throw BinarySize.Error.invalidPackageDefinition( 70 | ValidationError.make(from: error) 71 | ) 72 | } 73 | 74 | let result: App.BinarySizeProvider.Result 75 | do { 76 | result = try await App.BinarySizeProvider.binarySize( 77 | for: packageDefinition, 78 | resolvedPackage: package, 79 | xcConfig: xcConfig 80 | ) 81 | } catch { 82 | throw BinarySize.Error.failedToProvideInfo(error) 83 | } 84 | 85 | return Result(amount: result.amount, formatted: result.formatted) 86 | } 87 | } 88 | } 89 | 90 | extension BinarySize { 91 | public struct Result: Sendable { 92 | let amount: Int 93 | let formatted: String 94 | } 95 | 96 | public enum Error: Swift.Error, Sendable, Equatable { 97 | case invalidPackageDefinition(ValidationError) 98 | case invalidXcConfigFile(URL) 99 | case failedToProvideInfo(InfoProviderError) 100 | } 101 | 102 | // Allows for `App` to be internally imported 103 | public enum ValidationError: Swift.Error, Equatable, Sendable { 104 | case invalidURL 105 | case failedToLoadPackage 106 | case noProductFound(packageURL: URL) 107 | 108 | static func make(from error: SwiftPackageValidationError) -> Self { 109 | switch error { 110 | case .invalidURL: 111 | .invalidURL 112 | case .failedToLoadPackage: 113 | .failedToLoadPackage 114 | case let .noProductFound(packageURL): 115 | .noProductFound(packageURL: packageURL) 116 | } 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Tests/AppTests/Fixtures/Fixture+PackageContent.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | // Copyright (c) 2025 Felipe Marino 21 | // 22 | // Permission is hereby granted, free of charge, to any person obtaining a copy 23 | // of this software and associated documentation files (the "Software"), to deal 24 | // in the Software without restriction, including without limitation the rights 25 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 26 | // copies of the Software, and to permit persons to whom the Software is 27 | // furnished to do so, subject to the following conditions: 28 | // 29 | // The above copyright notice and this permission notice shall be included in all 30 | // copies or substantial portions of the Software. 31 | // 32 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 33 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 34 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 35 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 36 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 37 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 38 | // SOFTWARE. 39 | 40 | import Foundation 41 | import CoreTestSupport 42 | 43 | @testable import Core 44 | 45 | public extension Fixture { 46 | static func makePackageWrapper( 47 | products: [PackageWrapper.Product] = [], 48 | platforms: [PackageWrapper.Platform] = [], 49 | targets: [PackageWrapper.Target] = [], 50 | swiftLanguageVersions: [String]? = [] 51 | ) -> PackageWrapper { 52 | PackageWrapper( 53 | products: products, 54 | platforms: platforms, 55 | targets: targets 56 | ) 57 | } 58 | 59 | static func makePackageContentPlatform( 60 | platformName: String = "ios", 61 | version: String = "13.5" 62 | ) -> PackageWrapper.Platform { 63 | .init( 64 | platformName: platformName, 65 | version: version 66 | ) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Tests/AppTests/Providers/BinarySizeProvider/BinarySizeProviderErrorTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | @testable import App 23 | 24 | final class BinarySizeProviderErrorTests: XCTestCase { 25 | func testUnableToGenerateArchiveLocalizedMessage() { 26 | let error = BinarySizeProviderError.unableToGenerateArchive(errorMessage: "some") 27 | XCTAssertEqual( 28 | error.localizedDescription, 29 | """ 30 | Failed to measure binary size 31 | Step: Archiving 32 | Error: some 33 | """ 34 | ) 35 | } 36 | 37 | func testUnableToCloneEmptyAppLocalizedMessage() { 38 | let error = BinarySizeProviderError.unableToCloneEmptyApp(errorMessage: "some") 39 | XCTAssertEqual( 40 | error.localizedDescription, 41 | """ 42 | Failed to measure binary size 43 | Step: Cloning empty app 44 | Error: some 45 | """ 46 | ) 47 | } 48 | 49 | func testUnableToGetBinarySizeOnDiskLocalizedMessage() { 50 | let error = BinarySizeProviderError.unableToGetBinarySizeOnDisk(underlyingError: FakeError() as NSError) 51 | XCTAssertEqual( 52 | error.localizedDescription, 53 | """ 54 | Failed to measure binary size 55 | Step: Reading binary size 56 | Error: Failed to read binary size from archive. Details: some 57 | """ 58 | ) 59 | } 60 | 61 | func testUnableToRetrieveAppProjectLocalizedMessage() { 62 | let error = BinarySizeProviderError.unableToRetrieveAppProject(atPath: "path") 63 | XCTAssertEqual( 64 | error.localizedDescription, 65 | """ 66 | Failed to measure binary size 67 | Step: Read measurement app project 68 | Error: Failed to get MeasurementApp project from XcodeProj at path: path 69 | """ 70 | ) 71 | } 72 | 73 | func testUnexpectedErrorLocalizedMessageWhenVerboseTrue() { 74 | let error = BinarySizeProviderError.unexpectedError( 75 | underlyingError: URLError(.badURL) as NSError, 76 | isVerbose: true 77 | ) 78 | XCTAssertEqual( 79 | error.localizedDescription, 80 | """ 81 | Failed to measure binary size 82 | Step: Undefined 83 | Error: Unexpected failure. Error Domain=NSURLErrorDomain Code=-1000 "(null)". 84 | 85 | """ 86 | ) 87 | } 88 | 89 | func testUnexpectedErrorLocalizedMessageWhenVerboseFalse() { 90 | let error = BinarySizeProviderError.unexpectedError( 91 | underlyingError: URLError(.badURL) as NSError, 92 | isVerbose: false 93 | ) 94 | XCTAssertEqual( 95 | error.localizedDescription, 96 | """ 97 | Failed to measure binary size 98 | Step: Undefined 99 | Error: Unexpected failure. Error Domain=NSURLErrorDomain Code=-1000 "(null)". 100 | Please run with --verbose enabled for more details. 101 | """ 102 | ) 103 | } 104 | } 105 | 106 | private struct FakeError: LocalizedError { 107 | let errorDescription: String? = "some" 108 | } 109 | -------------------------------------------------------------------------------- /Tests/AppTests/Providers/BinarySizeProvider/BinarySizeProviderTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | import CoreTestSupport 23 | 24 | @testable import App 25 | @testable import Core 26 | 27 | final class BinarySizeProviderTests: XCTestCase { 28 | func testFetchInformation() async throws { 29 | var defaultSizeMeasurerCallsCount = 0 30 | var lastVerbose: Bool? 31 | 32 | var sizeMeasurerCallsCount = 0 33 | var lastSwiftPackage: PackageDefinition? 34 | var lastIsDynamic: Bool? 35 | 36 | defaultSizeMeasurer = { xcconfig, verbose in 37 | lastVerbose = verbose 38 | defaultSizeMeasurerCallsCount += 1 39 | 40 | return { swiftPackage, isDynamic in 41 | lastSwiftPackage = swiftPackage 42 | lastIsDynamic = isDynamic 43 | sizeMeasurerCallsCount += 1 44 | 45 | return .init( 46 | amount: 908, 47 | formatted: "908 kb" 48 | ) 49 | } 50 | } 51 | 52 | let productName = "Product" 53 | let swiftPackage = try Fixture.makePackageDefinition( 54 | product: productName 55 | ) 56 | let providedInfo = try await BinarySizeProvider.binarySize( 57 | for: swiftPackage, 58 | resolvedPackage: Fixture.makePackageWrapper( 59 | products: [ 60 | .init( 61 | name: productName, 62 | package: nil, 63 | isDynamicLibrary: true, 64 | targets: [ 65 | .init( 66 | name: "Target", 67 | dependencies: [] 68 | ) 69 | ] 70 | ) 71 | ] 72 | ), 73 | xcconfig: nil, 74 | verbose: true 75 | ) 76 | 77 | XCTAssertEqual( 78 | providedInfo.providerName, 79 | "Binary Size" 80 | ) 81 | XCTAssertEqual( 82 | providedInfo.providerKind, 83 | .binarySize 84 | ) 85 | 86 | XCTAssertEqual( 87 | defaultSizeMeasurerCallsCount, 88 | 1 89 | ) 90 | XCTAssertEqual( 91 | lastVerbose, 92 | true 93 | ) 94 | XCTAssertEqual( 95 | sizeMeasurerCallsCount, 96 | 1 97 | ) 98 | XCTAssertEqual( 99 | lastSwiftPackage, 100 | swiftPackage 101 | ) 102 | XCTAssertEqual( 103 | lastIsDynamic, 104 | true 105 | ) 106 | 107 | XCTAssertEqual( 108 | providedInfo.messages, 109 | [ 110 | ConsoleMessage( 111 | text: "Binary size increases by ", 112 | color: .noColor, 113 | isBold: false, 114 | hasLineBreakAfter: false 115 | ), 116 | ConsoleMessage( 117 | text: "908 kb", 118 | color: .yellow, 119 | isBold: true, 120 | hasLineBreakAfter: false 121 | ) 122 | ] 123 | ) 124 | 125 | let encodedProvidedInfo = try JSONEncoder.sortedAndPrettyPrinted.encode(providedInfo) 126 | let encodedProvidedInfoString = String( 127 | data: encodedProvidedInfo, 128 | encoding: .utf8 129 | ) 130 | 131 | XCTAssertEqual( 132 | encodedProvidedInfoString, 133 | #""" 134 | { 135 | "amount" : 908, 136 | "formatted" : "908 kb" 137 | } 138 | """# 139 | ) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Tests/AppTests/Providers/PlatformsProvider/PlatformsProviderTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | import CoreTestSupport 23 | 24 | @testable import App 25 | @testable import Core 26 | 27 | final class PlatformsProviderTests: XCTestCase { 28 | func testFetchInformation() async throws { 29 | let providedInfo = try await PlatformsProvider.platforms( 30 | for: Fixture.makePackageDefinition(), 31 | resolvedPackage: Fixture.makePackageWrapper( 32 | platforms: [ 33 | Fixture.makePackageContentPlatform(), 34 | Fixture.makePackageContentPlatform( 35 | platformName: "macos", 36 | version: "10.15" 37 | ), 38 | Fixture.makePackageContentPlatform( 39 | platformName: "watchos", 40 | version: "7.3.2" 41 | ), 42 | Fixture.makePackageContentPlatform( 43 | platformName: "tvos", 44 | version: "14.0" 45 | ), 46 | ] 47 | ), 48 | xcconfig: nil, 49 | verbose: true 50 | ) 51 | 52 | XCTAssertEqual( 53 | providedInfo.providerName, 54 | "Platforms" 55 | ) 56 | XCTAssertEqual( 57 | providedInfo.providerKind, 58 | .platforms 59 | ) 60 | 61 | let expectedPlatformMessage: (String) -> ConsoleMessage = { contentText in 62 | ConsoleMessage( 63 | text: contentText, 64 | color: .noColor, 65 | isBold: true, 66 | hasLineBreakAfter: false 67 | ) 68 | } 69 | let expectedSeparatorMessage = ConsoleMessage( 70 | text: " | ", 71 | hasLineBreakAfter: false 72 | ) 73 | XCTAssertEqual( 74 | providedInfo.messages, 75 | [ 76 | expectedPlatformMessage("ios from v. 13.5"), 77 | expectedSeparatorMessage, 78 | expectedPlatformMessage("macos from v. 10.15"), 79 | expectedSeparatorMessage, 80 | expectedPlatformMessage("watchos from v. 7.3.2"), 81 | expectedSeparatorMessage, 82 | expectedPlatformMessage("tvos from v. 14.0") 83 | ] 84 | ) 85 | 86 | let encodedProvidedInfo = try JSONEncoder.sortedAndPrettyPrinted.encode(providedInfo) 87 | let encodedProvidedInfoString = String( 88 | data: encodedProvidedInfo, 89 | encoding: .utf8 90 | ) 91 | 92 | XCTAssertEqual( 93 | encodedProvidedInfoString, 94 | #""" 95 | { 96 | "iOS" : "13.5", 97 | "macOS" : "10.15", 98 | "tvOS" : "14.0", 99 | "watchOS" : "7.3.2" 100 | } 101 | """# 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Tests/CoreTests/Helpers/FileManager+CoreTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import Foundation 22 | 23 | enum FileManagerError: LocalizedError { 24 | case fileAlreadyExists(atPath: String) 25 | case directoryAlreadyExists(atPath: String) 26 | 27 | var errorDescription: String? { 28 | switch self { 29 | case let .fileAlreadyExists(path): 30 | return "Unable to create file since file already exists at \(path)" 31 | case let .directoryAlreadyExists(path): 32 | return "Unable to directory since directory already exists at \(path)" 33 | } 34 | } 35 | } 36 | 37 | extension FileManager { 38 | func createFile( 39 | atPath path: String, 40 | content data: Data 41 | ) throws { 42 | if fileExists(atPath: path) == false { 43 | createFile(atPath: path, contents: data, attributes: nil) 44 | } else { 45 | throw FileManagerError.fileAlreadyExists(atPath: path) 46 | } 47 | } 48 | 49 | func createDirectory(atPath path: String) throws { 50 | if fileExists(atPath: path) == false { 51 | try createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) 52 | } else { 53 | throw FileManagerError.directoryAlreadyExists(atPath: path) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/CoreTests/Models/PackageDefinitionTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | internal import Basics 22 | import XCTest 23 | import CoreTestSupport 24 | 25 | @testable import Core 26 | 27 | final class PackageDefinitionTests: XCTestCase { 28 | // MARK: - Init 29 | 30 | func testSourcePermutations() throws { 31 | struct Permutation: Equatable { 32 | let version: String 33 | let revision: String? 34 | let expectedSource: PackageDefinition.Source 35 | } 36 | 37 | let url = try Fixture.makePackageDefinition().url 38 | 39 | let permutations = [ 40 | Permutation( 41 | version: "1.2.3", 42 | revision: "3fag5v0", 43 | expectedSource: .remote(url: url, resolution: .version("1.2.3")) 44 | ), 45 | Permutation( 46 | version: ResourceState.undefined.description, 47 | revision: nil, 48 | expectedSource: .remote( 49 | url: url, 50 | resolution: .version(ResourceState.undefined.description) 51 | ) 52 | ), 53 | Permutation( 54 | version: ResourceState.undefined.description, 55 | revision: "3fag5v0", 56 | expectedSource: .remote( 57 | url: url, 58 | resolution: .revision("3fag5v0") 59 | ) 60 | ), 61 | Permutation( 62 | version: ResourceState.invalid.description, 63 | revision: "3fag5v0", 64 | expectedSource: .remote( 65 | url: url, 66 | resolution: .version(ResourceState.invalid.description) 67 | ) 68 | ), 69 | Permutation( 70 | version: ResourceState.undefined.description, 71 | revision: nil, 72 | expectedSource: .remote( 73 | url: url, 74 | resolution: .version(ResourceState.undefined.description) 75 | ) 76 | ), 77 | Permutation( 78 | version: "", 79 | revision: nil, 80 | expectedSource: .local(try localFileSystem.tempDirectory) 81 | ), 82 | ] 83 | 84 | try permutations.forEach { permutation in 85 | let sut: PackageDefinition 86 | if case let .local(path) = permutation.expectedSource { 87 | sut = try Fixture.makePackageDefinition(source: .local(path)) 88 | } else { 89 | sut = try Fixture.makePackageDefinition( 90 | version: permutation.version, 91 | revision: permutation.revision 92 | ) 93 | } 94 | 95 | XCTAssertEqual( 96 | sut.source, 97 | permutation.expectedSource 98 | ) 99 | } 100 | } 101 | 102 | func testInvalidRemoteAndLocal() throws { 103 | XCTAssertThrowsError( 104 | try Fixture.makePackageDefinition( 105 | url: URL(string: "../directory")! 106 | ) 107 | ) 108 | 109 | do { 110 | _ = try Fixture.makePackageDefinition( 111 | url: URL(string: "../directory")! 112 | ) 113 | } catch { 114 | XCTAssertEqual(error as? PackageDefinition.Error, .invalidURL) 115 | } 116 | } 117 | 118 | // MARK: - Description 119 | 120 | func testDescriptionWhenLocal() throws { 121 | let temporaryDir = try createTemporaryValidLocalDir() 122 | let sut = try Fixture.makePackageDefinition(source: .local(temporaryDir)) 123 | XCTAssertEqual( 124 | sut.description, 125 | """ 126 | Local path: \(temporaryDir) 127 | Product: Some 128 | """ 129 | ) 130 | } 131 | 132 | func testDescriptionWhenRemote() throws { 133 | let sut = try Fixture.makePackageDefinition() 134 | XCTAssertEqual( 135 | sut.description, 136 | """ 137 | Repository URL: https://www.apple.com 138 | Version: 1.0.0 139 | Product: Some 140 | """ 141 | ) 142 | } 143 | 144 | func testDescriptionWhenRevision() throws { 145 | let revision = "f46ab7s" 146 | let sut = try Fixture.makePackageDefinition( 147 | version: ResourceState.undefined.description, 148 | revision: revision 149 | ) 150 | XCTAssertEqual( 151 | sut.description, 152 | """ 153 | Repository URL: https://www.apple.com 154 | Revision: \(revision) 155 | Product: Some 156 | """ 157 | ) 158 | } 159 | } 160 | 161 | private extension PackageDefinitionTests { 162 | func createTemporaryValidLocalDir() throws -> AbsolutePath { 163 | let temporaryDir = URL.temporaryDirectory 164 | let temporaryFilename = "Package.swift" 165 | let temporaryFileURL = temporaryDir.appendingPathComponent(temporaryFilename) 166 | 167 | try? FileManager.default.createFile( 168 | atPath: temporaryFileURL.path(), 169 | content: Data("Test".utf8) 170 | ) 171 | 172 | return try AbsolutePath(validating: temporaryDir.path()) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Tests/CoreTests/Models/SizeOnDiskTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | @testable import Core 23 | 24 | final class SizeOnDiskTests: XCTestCase { 25 | func testEmpty() { 26 | XCTAssertEqual( 27 | SizeOnDisk.empty, 28 | .init(amount: 0, formatted: "0.0") 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Tests/CoreTests/ProvidedInfoTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | @testable import Core 23 | 24 | final class ProvidedInfoTests: XCTestCase { 25 | private struct ProvidedContent: CustomConsoleMessagesConvertible, Encodable { 26 | let binarySize: Float 27 | var messages: [ConsoleMessage] { 28 | [ 29 | .init(stringLiteral: "something") 30 | ] 31 | } 32 | } 33 | 34 | func testConsoleMessages() { 35 | let sut = ProvidedInfo( 36 | providerName: "name", 37 | providerKind: .binarySize, 38 | information: ProvidedContent(binarySize: 300) 39 | ) 40 | 41 | XCTAssertEqual( 42 | sut.messages, 43 | [ 44 | .init(stringLiteral: "something") 45 | ] 46 | ) 47 | } 48 | 49 | func testProviderName() { 50 | let sut = ProvidedInfo( 51 | providerName: "name", 52 | providerKind: .binarySize, 53 | information: ProvidedContent(binarySize: 300) 54 | ) 55 | 56 | XCTAssertEqual( 57 | sut.providerName, 58 | "name" 59 | ) 60 | } 61 | 62 | func testEncodedValue() throws { 63 | let sut = ProvidedInfo( 64 | providerName: "name", 65 | providerKind: .binarySize, 66 | information: ProvidedContent(binarySize: 300) 67 | ) 68 | 69 | let encoded = try JSONEncoder().encode(sut) 70 | let encodedString = String(data: encoded, encoding: .utf8) 71 | 72 | XCTAssertEqual( 73 | encodedString, 74 | #"{"binarySize":300}"# 75 | ) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Tests/ReportsTests/Generators/ConsoleReportGeneratorTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | import CoreTestSupport 23 | 24 | @testable import Core 25 | @testable import Reports 26 | 27 | @MainActor 28 | final class ConsoleReportGeneratorTests: XCTestCase { 29 | func testRenderReport() throws { 30 | let terminalControllerMock = TerminalControllerMock() 31 | let progressAnimationMock = ProgressAnimationMock() 32 | 33 | Console.default = Console( 34 | isOutputColored: false, 35 | terminalController: terminalControllerMock, 36 | progressAnimation: progressAnimationMock 37 | ) 38 | 39 | let sut = ConsoleReportGenerator( 40 | console: .default 41 | ) 42 | 43 | sut.renderReport( 44 | for: try Fixture.makePackageDefinition(), 45 | providedInfos: [ 46 | ProvidedInfo.init( 47 | providerName: "Name", 48 | providerKind: .binarySize, 49 | information: Fixture.makeProvidedInfoInformation() 50 | ) 51 | ] 52 | ) 53 | 54 | XCTAssertEqual(progressAnimationMock.completeCallsCount, 0) 55 | XCTAssertEqual(progressAnimationMock.clearCallsCount, 0) 56 | XCTAssertEqual(progressAnimationMock.updateCallsCount, 0) 57 | 58 | XCTAssertEqual(terminalControllerMock.writeCallsCount, 39) 59 | XCTAssertEqual(terminalControllerMock.endLineCallsCount, 13) 60 | 61 | XCTAssertEqual( 62 | terminalControllerMock.writeStrings, 63 | [ 64 | "+----------------------------+", 65 | "|", 66 | " ", 67 | "Swift Package Info", 68 | " ", 69 | "|", 70 | "|", 71 | " ", 72 | "|", 73 | "|", 74 | " ", 75 | "Some, 1.0.0", 76 | " ", 77 | "|", 78 | "+----------+-----------------+", 79 | "|", 80 | " ", 81 | "Provider", 82 | " ", 83 | "|", 84 | " ", 85 | "Results", 86 | " ", 87 | "|", 88 | "+----------+-----------------+", 89 | "|", 90 | " ", 91 | "Name", 92 | " ", 93 | "|", 94 | " ", 95 | "name", 96 | "Value is 10", 97 | " ", 98 | "|", 99 | "+----------+-----------------+", 100 | "> Total of ", 101 | "1", 102 | " provider used." 103 | ] 104 | ) 105 | 106 | XCTAssertEqual( 107 | terminalControllerMock.writeColors.count, 108 | 39 109 | ) 110 | XCTAssertEqual( 111 | Set(terminalControllerMock.writeColors), 112 | Set([.noColor]) 113 | ) 114 | XCTAssertEqual( 115 | terminalControllerMock.writeBolds, 116 | [ 117 | false, 118 | false, 119 | false, 120 | true, 121 | false, 122 | false, 123 | false, 124 | false, 125 | false, 126 | false, 127 | false, 128 | false, 129 | false, 130 | false, 131 | false, 132 | false, 133 | false, 134 | false, 135 | false, 136 | false, 137 | false, 138 | false, 139 | false, 140 | false, 141 | false, 142 | false, 143 | false, 144 | true, 145 | false, 146 | false, 147 | false, 148 | true, 149 | false, 150 | false, 151 | false, 152 | false, 153 | false, 154 | true, 155 | false 156 | ] 157 | ) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /Tests/ReportsTests/Generators/JSONDumpReportGeneratorTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | import CoreTestSupport 23 | 24 | @testable import Core 25 | @testable import Reports 26 | 27 | @MainActor 28 | final class JSONDumpReportGeneratorTests: XCTestCase { 29 | func testRenderDumpWithOneKindOfProvidedInfoOnly() async throws { 30 | let terminalControllerMock = TerminalControllerMock() 31 | let progressAnimationMock = ProgressAnimationMock() 32 | 33 | Console.default = Console( 34 | isOutputColored: false, 35 | terminalController: terminalControllerMock, 36 | progressAnimation: progressAnimationMock 37 | ) 38 | 39 | let sut = JSONDumpReportGenerator(console: .default) 40 | 41 | try sut.renderDump( 42 | for: Fixture.makePackageDefinition(), 43 | providedInfos: [ 44 | ProvidedInfo.init( 45 | providerName: "Name", 46 | providerKind: .binarySize, 47 | information: Fixture.makeProvidedInfoInformation() 48 | ) 49 | ] 50 | ) 51 | 52 | await Task.yield() 53 | await Task.yield() 54 | 55 | XCTAssertEqual(progressAnimationMock.completeCallsCount, 0) 56 | XCTAssertEqual(progressAnimationMock.clearCallsCount, 0) 57 | XCTAssertEqual(progressAnimationMock.updateCallsCount, 0) 58 | 59 | XCTAssertEqual(terminalControllerMock.writeCallsCount, 1) 60 | XCTAssertEqual(terminalControllerMock.endLineCallsCount, 1) 61 | 62 | XCTAssertEqual( 63 | terminalControllerMock.writeStrings, 64 | [ 65 | #""" 66 | { 67 | "binarySize" : { 68 | "name" : "name", 69 | "value" : 10 70 | } 71 | } 72 | """# 73 | ] 74 | ) 75 | 76 | XCTAssertEqual( 77 | terminalControllerMock.writeColors, 78 | [ 79 | .noColor 80 | ] 81 | ) 82 | XCTAssertEqual( 83 | terminalControllerMock.writeBolds, 84 | [ 85 | false 86 | ] 87 | ) 88 | } 89 | 90 | func testRenderDumpWithAllProvidedInfoKinds() async throws { 91 | let terminalControllerMock = TerminalControllerMock() 92 | let progressAnimationMock = ProgressAnimationMock() 93 | 94 | Console.default = Console( 95 | isOutputColored: false, 96 | terminalController: terminalControllerMock, 97 | progressAnimation: progressAnimationMock 98 | ) 99 | 100 | let sut = JSONDumpReportGenerator(console: .default) 101 | 102 | try sut.renderDump( 103 | for: Fixture.makePackageDefinition(), 104 | providedInfos: [ 105 | ProvidedInfo.init( 106 | providerName: "Name", 107 | providerKind: .binarySize, 108 | information: Fixture.makeProvidedInfoInformation() 109 | ), 110 | ProvidedInfo.init( 111 | providerName: "Other", 112 | providerKind: .dependencies, 113 | information: Fixture.makeProvidedInfoInformation( 114 | name: "other", 115 | value: 9999 116 | ) 117 | ), 118 | ProvidedInfo.init( 119 | providerName: "Yet another", 120 | providerKind: .platforms, 121 | information: Fixture.makeProvidedInfoInformation( 122 | name: "yet another", 123 | value: 17 124 | ) 125 | ) 126 | ] 127 | ) 128 | 129 | await Task.yield() 130 | await Task.yield() 131 | 132 | XCTAssertEqual(progressAnimationMock.completeCallsCount, 0) 133 | XCTAssertEqual(progressAnimationMock.clearCallsCount, 0) 134 | XCTAssertEqual(progressAnimationMock.updateCallsCount, 0) 135 | 136 | XCTAssertEqual(terminalControllerMock.writeCallsCount, 1) 137 | XCTAssertEqual(terminalControllerMock.endLineCallsCount, 1) 138 | 139 | XCTAssertEqual( 140 | terminalControllerMock.writeStrings, 141 | [ 142 | #""" 143 | { 144 | "binarySize" : { 145 | "name" : "name", 146 | "value" : 10 147 | }, 148 | "dependencies" : { 149 | "name" : "other", 150 | "value" : 9999 151 | }, 152 | "platforms" : { 153 | "name" : "yet another", 154 | "value" : 17 155 | } 156 | } 157 | """# 158 | ] 159 | ) 160 | 161 | XCTAssertEqual( 162 | terminalControllerMock.writeColors, 163 | [ 164 | .noColor 165 | ] 166 | ) 167 | XCTAssertEqual( 168 | terminalControllerMock.writeBolds, 169 | [ 170 | false 171 | ] 172 | ) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Tests/RunTests/RunTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | import XCTest 22 | import Foundation 23 | 24 | @available(macOS 10.13, *) 25 | final class RunTests: XCTestCase { 26 | func testWithInvalidRemoteURL() throws { 27 | try runToolProcessAndAssert( 28 | command: "--url somethingElse --package-version 6.0.0 --product RxSwift", 29 | expectedOutput: """ 30 | Error: Invalid argument '--url ' 31 | Usage: The URL must be either: 32 | - A valid git repository URL that contains a `Package.swift`, e.g `https://github.com/Alamofire/Alamofire`; or 33 | - A relative or absolute path to a local directory that has a `Package.swift`, e.g. `../other-dir/my-project` 34 | 35 | """, 36 | expectedError: "" 37 | ) 38 | } 39 | 40 | func testLocalURLWithoutPackage() throws { 41 | try runToolProcessAndAssert( 42 | command: "--url ../path", 43 | expectedOutput: """ 44 | Error: Invalid argument '--url ' 45 | Usage: The URL must be either: 46 | - A valid git repository URL that contains a `Package.swift`, e.g `https://github.com/Alamofire/Alamofire`; or 47 | - A relative or absolute path to a local directory that has a `Package.swift`, e.g. `../other-dir/my-project` 48 | 49 | """, 50 | expectedError: "" 51 | ) 52 | } 53 | 54 | func testHelp() throws { 55 | let expectedOutput = """ 56 | OVERVIEW: A tool for analyzing Swift Packages 57 | 58 | Provides valuable information about a given Swift Package,\nthat can be used in your favor when deciding whether to\nadopt or not a Swift Package as a dependency on your app. 59 | 60 | USAGE: swift-package-info 61 | 62 | OPTIONS: 63 | --version Show the version. 64 | -h, --help Show help information.\n\nSUBCOMMANDS: 65 | binary-size Estimated binary size of a Swift Package product. 66 | platforms Shows platforms supported b a Package product. 67 | dependencies List dependencies of a Package product. 68 | full-analyzes (default) All available information about a Swift Package\n product. 69 | 70 | See \'swift-package-info help \' for detailed help. 71 | 72 | """ 73 | 74 | try runToolProcessAndAssert( 75 | command: "--help", 76 | expectedOutput: expectedOutput, 77 | expectedError: "" 78 | ) 79 | } 80 | 81 | /// Returns path to the built products directory. 82 | var productsDirectory: URL { 83 | #if os(macOS) 84 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 85 | return bundle.bundleURL.deletingLastPathComponent() 86 | } 87 | fatalError("couldn't find the products directory") 88 | #else 89 | return Bundle.main.bundleURL 90 | #endif 91 | } 92 | 93 | // MARK: - Helpers 94 | 95 | private func runToolProcessAndAssert( 96 | _ file: StaticString = #filePath, 97 | _ function: StaticString = #function, 98 | _ line: UInt = #line, 99 | command: String, 100 | expectedOutput: String?, 101 | expectedError: String? 102 | ) throws { 103 | let commands = command.split(whereSeparator: \.isWhitespace) 104 | 105 | let arguments: [String] 106 | if commands.count > 1 { 107 | arguments = commands.map { String($0) } 108 | } else { 109 | arguments = command 110 | .split { [" -", " --"].contains(String($0)) } 111 | .map { String($0) } 112 | } 113 | 114 | let executableURL = productsDirectory.appendingPathComponent("swift-package-info") 115 | 116 | let process = Process() 117 | process.executableURL = executableURL 118 | process.arguments = arguments 119 | 120 | let outputPipe = Pipe() 121 | process.standardOutput = outputPipe 122 | 123 | let errorPipe = Pipe() 124 | process.standardError = errorPipe 125 | 126 | try process.run() 127 | process.waitUntilExit() 128 | 129 | let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() 130 | let outputContent = String(data: outputData, encoding: .utf8) 131 | let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() 132 | let errorContent = String(data: errorData, encoding: .utf8) 133 | 134 | XCTAssertEqual(outputContent, expectedOutput, file: file, line: line) 135 | XCTAssertEqual(errorContent, expectedError, file: file, line: line) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Tests/SwiftPackageInfoTests/LibraryTests.swift: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2025 Felipe Marino 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in all 11 | // copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | // SOFTWARE. 20 | 21 | --------------------------------------------------------------------------------