DEV Community

Vasiliy Kattouf
Vasiliy Kattouf

Posted on

Streamline Your Project Workflow Using Swift with Sake: A Practical Guide

Introduction

Managing and automating repetitive tasks in your project can be challenging, especially as it grows, particularly when using shell scripts or Make tools. Shell scripts and Make lack type safety, are harder to read, and become cumbersome to maintain as projects scale and workflows grow more complex. That's where tools like Sake come into play. Sake is a Swift-based tool inspired by Make, designed to simplify the automation of common workflows, making them easier to define and execute. By leveraging Swift's type safety and expressiveness, Sake provides a powerful yet familiar way to manage tasks.

In this guide, we'll explore how to streamline your project's workflow using Sake. We'll start with simple examples like linting and formatting and gradually add more advanced features like managing using tools and testing. By the end of this article, you'll be able to define workflows that are easy to understand and maintain, helping you keep your project in top shape.

For executing CLI commands, we'll be using SwiftShell throughout this guide. However, feel free to use any tool that suits your project, as Sake is flexible enough to work with various options.

Part 1: Setting Up Basic Linting and Formatting Commands

Introduction to Linting and Formatting

Linting and formatting are essential practices in software development to maintain consistent code quality. Using automated tools, you can enforce code style guidelines and ensure your codebase remains clean and readable. In this section, we'll use swiftformat to create commands for linting and formatting your Swift code.

Defining Simple Linting and Formatting Commands

Let's start by defining simple lint and format commands using Sake. These commands will run swiftformat on specific directories to check the code style and apply formatting.

import Sake
import SwiftShell

@main
@CommandGroup
struct Commands: SakeApp {
    public static var lint: Command {
        Command(
            description: "Lint code",
            run: { _ in
                try runAndPrint("swiftformat", "Sources", "--lint")
            }
        )
    }

    public static var format: Command {
        Command(
            description: "Format code",
            run: { _ in
                try runAndPrint("swiftformat", "Sources")
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Once you've defined the command, you can invoke it as follows:

> sake lint
...
> sake format
...
Enter fullscreen mode Exit fullscreen mode

Explanation

  • lint Command: The lint command runs swiftformat in linting mode to check for code style issues in the Sources directory.
  • format Command: The format command runs swiftformat to automatically apply code formatting in the Sources directory.

These commands are simple to define and demonstrate the basic structure of a Sake command. By leveraging these commands, you can easily enforce code style guidelines and maintain code quality throughout your project.

In the next part, we'll explore how to add dependencies to our commands to ensure the necessary tools are available before executing them.

Part 2: Ensuring Required Tools are Available

Problem Statement

When automating tasks, it's important to ensure that the necessary tools are available before running a command. In our case, we need to make sure that swiftformat is installed before running the linting or formatting commands.

Adding a Dependency with a Pre-Installation Command

To address this, we'll add a command that checks if swiftformat is installed and installs it if necessary. We'll keep this command private within our Commands structure, ensuring that it is used internally as a dependency for lint and format.

@main
@CommandGroup
struct Commands: SakeApp {
    public static var lint: Command {
        Command(
            description: "Lint code",
            dependencies: [ensureSwiftFormatInstalled],
            run: { _ in
                try runAndPrint("swiftformat", "Sources", "--lint")
            }
        )
    }

    public static var format: Command {
        Command(
            description: "Format code",
            dependencies: [ensureSwiftFormatInstalled],
            run: { _ in
                try runAndPrint("swiftformat", "Sources")
            }
        )
    }

    private static var ensureSwiftFormatInstalled: Command {
        Command(
            description: "Ensure swiftformat is installed",
            skipIf: { _ in
                run("which", "swiftformat").succeeded
            },
            run: { _ in
                try runAndPrint("brew", "install", "swiftformat")
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • ensureSwiftFormatInstalled Command: This command checks if swiftformat is installed by running which swiftformat. If it is not installed, the command uses Homebrew to install it. By keeping this command private, we ensure that it is used only as a dependency and not exposed as a standalone command.
  • Dependencies: The lint and format commands now depend on ensureSwiftFormatInstalled. This guarantees that swiftformat is available before running either of these commands.

Adding dependencies like this helps automate the setup process and ensures that your project environment is correctly configured before executing any tasks.

In the next part, we'll introduce testing commands and explore how to automate running unit and integration tests.

Part 3: Automating Project Testing

Why Testing Matters

Automated testing is a crucial part of maintaining software quality. It helps ensure that your code behaves as expected and that any new changes do not introduce regressions. In this part, we'll add simple commands to run unit and integration tests for our project.

Defining a Simple Testing Command

To start, we'll define a test command that runs all of the project's tests. This command will use swift test to execute the tests.

@main
@CommandGroup
struct Commands: SakeApp {
    public static var test: Command {
        Command(
            description: "Run tests",
            run: { _ in
                try runAndPrint("swift", "test")
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • test Command: The test command runs all tests using swift test. This command is straightforward and can be used to ensure that your code passes all checks before making any changes or releases.

Adding Separate Unit and Integration Test Commands

In more complex projects, it's often helpful to separate unit tests from integration tests. Let's add two new commands: unitTests and integrationTests.

@main
@CommandGroup
struct Commands: SakeApp {
    public static var unitTests: Command {
        Command(
            description: "Run unit tests",
            run: { _ in
                try runAndPrint(bash: "swift test --filter \"^(?!.*\\bIntegrationTests\\b).*\"")
            }
        )
    }

    public static var integrationTests: Command {
        Command(
            description: "Run integration tests",
            run: { _ in
                try runAndPrint(bash: "swift test --filter IntegrationTests")
            }
        )
    }

    public static var test: Command {
        Command(
            description: "Run all tests",
            dependencies: [unitTests, integrationTests]
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • unitTests Command: This command runs only the unit tests by excluding any tests that match the name pattern IntegrationTests.
  • integrationTests Command: This command runs only the integration tests by filtering for tests named IntegrationTests.
  • test Command: The test command now has dependencies on both unitTests and integrationTests, which means it will run all tests in sequence.

By separating unit and integration tests, you gain more flexibility in how you run your tests, making it easier to focus on specific areas of your project during development.

In the next part, we'll add a clean step before running tests to ensure a clean environment.

Part 4: Ensuring a Clean Testing Environment

Problem Statement

When running tests, it's often important to start with a clean build environment to avoid any interference from previous builds or cached artifacts. This ensures that your tests are executed in a predictable state, reducing the risk of false positives or hard-to-reproduce bugs.

Adding a Clean Command Before Testing

To make this work, we are using ArgumentParser, a powerful Swift package that allows us to define and parse command-line arguments easily. ArgumentParser helps us handle the --clean flag, which gives us control over whether to clean the build artifacts before running tests.

To solve this problem, we'll add a cleanIfNeeded command that cleans the build artifacts before running tests. We'll make it possible to control whether the cleaning happens by adding a --clean flag to our commands.

import ArgumentParser

@main
@CommandGroup
struct Commands: SakeApp {
    struct TestArguments: ParsableArguments {
        @Flag(name: .long, help: "Clean build artifacts before running tests")
        var clean: Bool = false
    }

    static var cleanIfNeeded: Command {
        Command(
            description: "Clean build artifacts",
            skipIf: { context in
                let arguments = try TestArguments.parse(context.arguments)
                return !arguments.clean
            },
            run: { _ in
                try runAndPrint("swift", "package", "clean")
            }
        )
    }

    public static var unitTests: Command {
        Command(
            description: "Run unit tests",
            dependencies: [cleanIfNeeded],
            run: { _ in
                try runAndPrint(bash: "swift test --filter \"^(?!.*\\bIntegrationTests\\b).*\"")
            }
        )
    }

    public static var integrationTests: Command {
        Command(
            description: "Run integration tests",
            dependencies: [cleanIfNeeded],
            run: { _ in
                try runAndPrint(bash: "swift test --filter IntegrationTests")
            }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Now tests can be run like this:

> sake unitTests --clean
Enter fullscreen mode Exit fullscreen mode

Explanation

  • cleanIfNeeded Command: This command runs swift package clean to remove all build artifacts. It uses the skipIf function to determine if the --clean flag has been passed. If the flag is not present, the command is skipped.
  • Test Commands: The unitTests and integrationTests commands now depend on cleanIfNeeded, which ensures that the build environment is cleaned before tests are run, but only if the --clean flag is provided.

Adding this cleaning step before running tests ensures that we start with a fresh environment, making our test results more reliable.

In the next part, we'll discuss how to handle redundant cleaning when multiple tests are run consecutively, and show a solution using argument mapping.

Part 5: Avoiding Redundant Cleaning with Argument Mapping

Problem Statement

When using the --clean flag, the cleaning step runs before every test command. This leads to redundant cleaning when running multiple tests in sequence, like when running both unit and integration tests. Redundant cleaning not only adds unnecessary execution time but can also lead to inefficiencies in the build process.

Solving Redundant Cleaning with Argument Mapping

To solve this problem, we'll use argument mapping to modify the test command's behavior. The idea is to clean the environment once, before running the first test, and skip it for subsequent tests. We can achieve this by filtering out the --clean flag for the dependent commands.

@main
@CommandGroup
struct Commands: SakeApp {
    struct TestArguments: ParsableArguments {
        @Flag(name: .long, help: "Clean build artifacts before running tests")
        var clean: Bool = false
    }

    static var cleanIfNeeded: Command {
        Command(
            description: "Clean build artifacts",
            skipIf: { context in
                let arguments = try TestArguments.parse(context.arguments)
                return !arguments.clean
            },
            run: { _ in
                try runAndPrint("swift", "package", "clean")
            }
        )
    }

    public static var unitTests: Command {
        Command(
            description: "Run unit tests",
            dependencies: [cleanIfNeeded],
            run: { _ in
                try runAndPrint(bash: "swift test --filter \"^(?!.*\\bIntegrationTests\\b).*\"")
            }
        )
    }

    public static var integrationTests: Command {
        Command(
            description: "Run integration tests",
            dependencies: [cleanIfNeeded],
            run: { _ in
                try runAndPrint(bash: "swift test --filter IntegrationTests")
            }
        )
    }

    public static var test: Command {
        Command(
            description: "Run all tests",
            dependencies: [
                cleanIfNeeded,
                unitTests.mapArguments { arguments in arguments.filter { $0 != "--clean" } },
                integrationTests.mapArguments { arguments in arguments.filter { $0 != "--clean" } }
            ]
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation

  • test Command with Argument Mapping: The test command depends on cleanIfNeeded, followed by unitTests and integrationTests. By using .mapArguments, we remove the --clean flag from the arguments passed to the dependent commands. This ensures that the cleaning step runs only once, before the first test, and is not repeated for subsequent commands.

  • Improved Efficiency: By mapping arguments and preventing redundant cleaning, we reduce unnecessary overhead and make the test execution process more efficient.

This approach helps streamline the testing process, allowing you to execute multiple tests in sequence without incurring redundant cleaning steps.

Conclusion

Throughout this guide, we've explored how Sake can help streamline your project's workflow, starting from basic tasks like linting and formatting, through testing, and even setting up a release train. By building commands in Swift, you've gained the ability to automate your processes in a type-safe and expressive way, fully leveraging the Swift ecosystem.

Now that you have the foundation, you can start automating even more aspects of your project. Whether it's adding argument parsing, running shell commands, or integrating with other Swift libraries, Sake provides the flexibility to fit your workflow.

We hope this guide has inspired you to make your project's routine tasks more efficient and enjoyable to work with.

For more information, check out the official Sake documentation and explore commands applied to Sake project itself.

Top comments (0)