Swift によるコマンドラインツール構築入門

:::note info
コマンドラインツール作成に入門できる数年前の英語[ブログ](https://www.swiftbysundell.com/articles/building-a-command-line-tool-using-the-swift-package-manager/)を現在の Swift 環境で動く状態に整理した。
[アップル公式のブートストラップ](https://qiita.com/daikinetic/items/0a7b78dedc79854d65c0)にて、扱われていない引数処理やエラーハンドリング、テスト検証も含めて理解できる。
:::

## 初手
以下のコマンドをもってコマンドラインツール構築を始める。
```terminal
$ mkdir CommandLineTool
$ cd CommandLineTool
$ swift package init --type executable
```
ここでオプション指定している `--type executable` は、SPM に対してフレームワークではなくコマンドラインツールを構築することを教えるためのもの。

## ここで出来上がったもの
ディレクトリ内に次のアイテムが出来上がる。
```terminal: CommandLineTool/
.
├── Package.swift
├── Sources
│   └── main.swift
├── Tests // (ここでは出来上がると書いてあるが、ないので別途作成する。 *1）
...
└── .gitignore
```

- `Package.swift` は SPM のマニフェストファイルで、依存関係のようなプロジェクトのメタデータが記載される。
- `Sources` フォルダにはソースコードを記載する。初期化時に `main.swift` を含んでおり、これがコマンドラインツールのエントリーポイントとなる（このファイルをリネームすることはできない）。
- `Tests` ファルダにはテストコードを記載する。
- `.gitignore` は SPM のビルドフォルダ（`*2`）と Xcode プロジェクトをソースコントロールから無視するように記載されている（`*3`）。

:::note 
- `*1`  `Tests` を作成する。
    ```terminal: 
    $ mkdir Tests
    $ cd Tests
    $ touch tests.swift
    ```
    ↓
    ```terminal: CommandLineTool/
    .
    ├── Package.swift
    ├── Sources
    │   └── main.swift
    ├── Tests
    │   └── tests.swift 
    ...
    └── .gitignore
    ```
- `*2` `.swiftpm` と `.build` というフォルダが生成されている。
    linux の `tree` コマンドなどで確認できる。
    ```terminal
    $ brew install tree
    $ tree -a 
    ```
- `*3` `.gitignore` の中身を確認すると分かりやすい。
    ```txt: .gitignore
    .DS_Store
    /.build
    /Packages
    xcuserdata/
    DerivedData/
    .swiftpm/configuration/registries.json
    .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
    .netrc
   ```
:::

## コードをフレームワークと実行ファイルに分割する

このタイミングで、ソースコードをフレームワークと実行ファイルに分割することを推奨したい。そうすると、テストを書くのが簡単になり、またコマンドラインツールを他のツールから依存関係として使用できるようになる。

そのために、`Sources` にフレームワークと実行ファイルのフォルダをそれぞれ作成する。

```terminal
$ cd Sources
$ mkdir CommandLineTool
$ mv main.swift CommandLineTool/
$ mkdir CommandLineToolCore
```
```terminal: CommandLineTool/
.
├── Package.swift
├── Sources
│   ├── CommandLineTool
│   │   └── main.swift 
│   └── CommandLineToolCore
├── Tests
... └── tests.swift 
```

SPM のひとつの利点は、ファイルシステムを source of truth に扱う所にある。これにより、単に新しいフォルダを作成するだけで新たなモジュールを定義することができる。

では、`Package.swift` を `CommandLineTool` と `CommandLineToolCore` の2つをターゲットとするように編集する。

```swift: Package.swift
import PackageDescription

let package = Package(
    name: "CommandLineTool",
    targets: [
        .executableTarget( // *4
            name: "CommandLineTool",
            dependencies: ["CommandLineToolCore"]
        ),
        .target(name: "CommandLineToolCore")
    ]
)
```

これで、フレームワークに依存関係を持つ実行ファイルを作成することができた。

:::note info
- `*4`
元記事では `.target()` を使用している。swift 5.4 以降、実行可能ファイルのターゲットは `.executableTarget()` とするのが正しい。
:::

## Xcode プロジェクトを作成する
SPM ではファイルシステムに基づいて簡単に Xcode プロジェクトを生成できる。また、すでに見た通り Xcode プロジェクトは git ignore されているので、コンフリクトや更新について対処する必要はなく、単に必要な時に再生成すればいい。

Xcode プロジェクトを生成するため、以下のコマンドをルートフォルダで実行する。（`*5`）

```terminal
$ swift package generate-xcodeproj
```
以下の warning が出るが、後ほど解決するので無視して問題ない。
```terminal:
warning: 'commandlinetool': Source files for target CommandLineToolCore should be located under 'Sources/CommandLineToolCore', or a custom sources path can be set with the 'path' property in Package.swift
```

:::note info
- `*5`
    この手順は飛ばして問題ない。
    `swift package generate-xcodeproj` コマンドは、Swift 5.4 以降で非推奨となり、最終的には削除された。代わりに、Xcode がネイティブで SPM をサポートするようになっている。

    ちなみに上記の warning は以下で確認できる。
    ```terminal
    $ swift build
    ``` 
:::

## エントリーポイントを手動で定義する

コマンドラインとテストどちらからもツールを呼べるようにするために、`main.swift` に責務を持たせすぎないようにするといいだろう。代わりに、ツールを関数で呼び出せるようにする。

そのために、フレームワークとして用意した `Sources/CommandLineToolCore` に `CommandLineTool.swift` を以下の様に作成する。

```swift: CommandLineTool.swift
import Foundation

public final class CommandLineTool {
    private let arguments: [String]

    public init(arguments: [String] = CommandLine.arguments) { 
        self.arguments = arguments
    }

    public func run() throws {
        print("Hello, world")
    }
}
```
で、単にこの `run()` を `main.swift` から呼ぶようにする。

```swift: main.swift
import CommandLineToolCore

let tool = CommandLineTool()

do {
    try tool.run()
} catch {
    print("Whoops! An error occurred: \(error)")
}
```

## Hello World

ここで一度コマンドラインツールを実行しよう。その前に、コンパイルを行う必要がある。コマンドラインツールを作成したルートフォルダで `swift build`、そして `swift run` で実行できる。

```terminal:
$ swift build
$ swift run 
Hello, world
```

実は `swift run` は必要であればコンパイルも行うので、単に `swift run` としても同じ結果が得られる。ただ、水面下で実行されている手順として把握しておくのがよいだろう。

## 依存関係を追加する

作っているコマンドラインツールが少し複雑になってくると、依存関係を追加する必要が出てくることがあるだろう。これらは `Package.swift` に依存関係として指定すればよい。

```swift: Package.swift
import PackageDescription

let package = Package(
    name: "CommandLineTool",
    dependencies: [
        .package(
            url: "https://github.com/johnsundell/files.git",
            from: "4.0.0"
        )
    ],
    targets: [
        .executableTarget(
            name: "CommandLineTool",
            dependencies: ["CommandLineToolCore"]
        ),
        .target(
            name: "CommandLineToolCore",
            dependencies: [
                .product(name: "Files", package: "files") // *6
            ]
        )
    ]
)
```

ここでは [Files](https://github.com/johnsundell/files) を追加した。これは Swift でファイルとフォルダを簡単に編集できるようにするフレームワークで、今回はこれをカレントフォルダにファイルを作成するのに使う。

https://github.com/johnsundell/files

:::note info
- `*6`
    元記事では単に
    ```swift
    ...
    dependencies: ["Files"]
    ...
    ```
    として、ターゲットが依存するパッケージのプロダクトを暗黙的に解決している。swift 5.2 以降、`.product(name: "プロダクト名", package: "パッケージ名")` としてプロダクトの名前やパッケージを明示的に指定するのが正しい。
    
    ちなみに、元記事のままで `swift build` すると以下のエラーが出る。
    ```terminal
    error: 'commandlinetool': dependency 'Files' in target 'CommandLineToolCore' requires explicit declaration; reference the package in the target dependency with '.product(name: "Files", package: "files")
    ```
:::

## 依存関係をインストールする
新しい依存関係を宣言したら、SPM に依存関係の解決とインストールをさせ、Xcode プロジェクトを再生成しよう。（`*7`）
```terminal
$ swift package update
$ swift package generate-xcodeproj 
```

:::note info
- `*7`
    `swift package update` は、`Package.resolved` ファイルに記載されている依存パッケージのバージョンを最新に更新するために使用する。`Package.resolved` は、いまプロジェクトが依存している全てのパッケージのバージョンを追跡している。
    例えば、依存関係のバージョン指定を `from: "1.0.0"` から `from: "1.2.0"` のように更新した場合、その変更を反映させるために `swift package update` を実行する。

    `swift package generate-xcodeproj` は前述 `*5` の通り、Swift 5.4 以降で不要。
:::

## コマンドラインに引数を取る

`CommandLineTool.swift` を編集していこう。`Hello, world` を出力させる代わりに、コマンドラインツールの引数に取ったテキストを名前とするファイルを作成させる様にする。

```swift: CommandLineTool.swift
import Foundation
import Files

public final class CommandLineTool {
    private let arguments: [String]

    public init(arguments: [String] = CommandLine.arguments) { 
        self.arguments = arguments
    }

    public func run() throws {
        guard arguments.count > 1 else {
            throw Error.missingFileName
        }
        
        // The first argument is the execution path
        let fileName = arguments[1]

        do {
            try Folder.current.createFile(at: fileName)
        } catch {
            throw Error.failedToCreateFile
        }
    }
}

public extension CommandLineTool {
    enum Error: Swift.Error {
        case missingFileName
        case failedToCreateFile
    }
}
```

上にみる様に、`Folder.current.createFile()` を `do`, `try`, `catch` で括っているが、これは [unified error API](https://www.swiftbysundell.com/articles/providing-a-unified-swift-error-api/) をユーザーに提供するためのものである。

https://www.swiftbysundell.com/articles/providing-a-unified-swift-error-api/

:::note info
元の記事では指示されていないが、ここで `swift run` してみると、実際に引数で指定した名前でファイルが作成されていることを確認できる。

```terminal
$ swift run CommandLineTool example.swift
```

`*2` で触れた様に、`tree` でファイルが作成されたことを簡単に確認できる。

```terminal
$ tree
.
├── Package.resolved
├── Package.swift
├── Sources
│   ├── CommandLineTool
│   │   └── main.swift
│   └── CommandLineToolCore
│       └── CommandLineTool.swift
├── Tests
│   └── tests.swift
└── example.swift
```
:::

## テストを書く

いよいよ新しいコマンドラインツールをリリースする準備が整ったが、その前にちゃんと動くかテストを書いておきたい。

ツールを早めにフレームワークと実行ファイルに分けておいたおかげで、テストはかなり簡単になっている。あとはプログラムで実行して、指定した名前のファイルができているかどうか確認するだけ。

まず、`Package.swift` ファイルの `targets` にテスト用のモジュールを追加する。

```swift: Package.swift
let package = Package(
    //...
    targets: [
        //...
        .testTarget(
          name: "CommandLineToolTests",
          dependencies: [
            "CommandLineToolCore",
            .product(name: "Files", package: "files")
          ]
        )
    ]
)
```

:::note info
元記事の文脈に合わせて、test のディレクトリ構成とファイル名を変更しておく。
```terminal
.
├── Package.resolved
├── Package.swift
├── Sources
│   ├── CommandLineTool
│   │   └── main.swift
│   └── CommandLineToolCore
│       └── CommandLineTool.swift
├── Tests
│   └── Tests.swift
...
        
↓

.
├── Package.resolved
├── Package.swift
├── Sources
│   ├── CommandLineTool
│   │   └── main.swift
│   ├── CommandLineToolCore
│   │   └── CommandLineTool.swift
│   └── CommandLineToolTests
│       └── CommandLineToolTests.swift
...
```
:::

次に、`CommandLineToolTests.swift` にテストコードを書いていく。

```swift: CommandLineToolTests.swift
import Foundation
import XCTest
import Files
import CommandLineToolCore

class CommandLineToolTests: XCTestCase {
    func testCreatingFile() throws {
        // Setup a temp test folder that can be used as a sandbox
        let tempFolder = Folder.temporary
        let testFolder = try tempFolder.createSubfolderIfNeeded(
            withName: "CommandLineToolTests"
        )

        // Empty the test folder to ensure a clean state
        try testFolder.empty()

        // Make the temp folder the current working folder
        let fileManager = FileManager.default
        fileManager.changeCurrentDirectoryPath(testFolder.path)

        // Create an instance of the command line tool
        let arguments = [testFolder.path, "Hello.swift"]
        let tool = CommandLineTool(arguments: arguments)

        // Run the tool and assert that the file was created
        try tool.run()
        XCTAssertNotNil(try? testFolder.file(named: "Hello.swift"))
    }
}
```

ファイル名が与えられていなかったり、ファイル作成が失敗していたりした場合に、適切なエラーが返されているかどうかを検証するテストを追加するのもよいだろう。

テストを実行するには、`swift test` を呼べばよい。

```terminal
$ swift test           

Building for debugging...
[1/1] Write swift-version--7BD29E5430B547BF.txt
Build complete! (0.11s)
Test Suite 'All tests' started at 2024-08-19 08:53:12.814.
Test Suite 'CommandLineToolPackageTests.xctest' started at 2024-08-19 08:53:12.815.
Test Suite 'CommandLineToolTests' started at 2024-08-19 08:53:12.815.
Test Case '-[CommandLineToolTests.CommandLineToolTests testCreatingFile]' started.
Test Case '-[CommandLineToolTests.CommandLineToolTests testCreatingFile]' passed (0.003 seconds).
Test Suite 'CommandLineToolTests' passed at 2024-08-19 08:53:12.818.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.003) seconds
Test Suite 'CommandLineToolPackageTests.xctest' passed at 2024-08-19 08:53:12.818.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.003) seconds
Test Suite 'All tests' passed at 2024-08-19 08:53:12.818.
	 Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.004) seconds
```

## コマンドラインツールをインストールする

以上でコマンドラインツールの実装とテストを終えたので、このコマンドをどこからでも呼び出せる様にしよう。
そのためにリリース設定でツールをビルドして、コンパイルされたバイナリを `/usr/local/bin` に移す。

```terminal
$ swift build -c release
$ cd .build/release
$ cp -f CommandLineTool /usr/local/bin/commandlinetool
```

## 完成 :tada: 

https://www.swiftbysundell.com/articles/building-a-command-line-tool-using-the-swift-package-manager/

https://github.com/daikinetic/CommandLineTool_Example