From 0a90d518eb59b04f9dc0a45a0adb3bbd6bcef329 Mon Sep 17 00:00:00 2001 From: Zachary Williams Date: Wed, 28 Jan 2026 11:52:47 -0600 Subject: [PATCH 1/4] feat: `CABIN_TARGET_DIR` env var and `build.target-dir` config support Added partial support for modifying the target directory in a cargo-like way. This allows the target directory to be set with either the `CABIN_TARGET_DIR` environment variable or with the `build.target-dir` configuration value in cabin.toml - with the configuration file taking priority. I plan to also support the `--target-dir` cli option, but that is not included in this commit. --- include/Manifest.hpp | 8 +++++--- lib/Manifest.cc | 18 +++++++++++++++++- src/Builder/Project.cc | 3 ++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/include/Manifest.hpp b/include/Manifest.hpp index 9dc30e415..22afbb322 100644 --- a/include/Manifest.hpp +++ b/include/Manifest.hpp @@ -119,6 +119,7 @@ class Manifest { const std::vector devDependencies; const std::unordered_map profiles; const Lint lint; + const fs::path targetDir; static rs::Result tryParse(fs::path path = fs::current_path() / FILE_NAME, @@ -136,12 +137,13 @@ class Manifest { private: Manifest(fs::path path, Package package, std::vector dependencies, std::vector devDependencies, - std::unordered_map profiles, - Lint lint) noexcept + std::unordered_map profiles, Lint lint, + fs::path targetDir) noexcept : path(std::move(path)), package(std::move(package)), dependencies(std::move(dependencies)), devDependencies(std::move(devDependencies)), - profiles(std::move(profiles)), lint(std::move(lint)) {} + profiles(std::move(profiles)), lint(std::move(lint)), + targetDir(std::move(targetDir)) {} }; rs::Result validatePackageName(std::string_view name) noexcept; diff --git a/lib/Manifest.cc b/lib/Manifest.cc index a7a0d69d0..6fa34512d 100644 --- a/lib/Manifest.cc +++ b/lib/Manifest.cc @@ -628,6 +628,19 @@ parseSystemDep(const std::string& name, const toml::table& info) noexcept { return rs::Ok(SystemDependency(name, rs_try(VersionReq::parse(versionReq)))); } +static rs::Result parseTargetDir(const toml::value& val, + const char* key) noexcept { + const auto targetDir = toml::try_find(val, key, "target-dir"); + if (!targetDir.is_err()) { + spdlog::debug("[{}] not found or not a table", key); + return rs::Ok(targetDir.unwrap()); + } + if (const char* env = std::getenv("CABIN_TARGET_DIR")) { + return rs::Ok(std::string(env)); + } + return rs::Ok(fs::path("cabin-out")); +} + static rs::Result> parseDependencies(const toml::value& val, const char* key) noexcept { const auto tomlDeps = toml::try_find(val, key); @@ -678,9 +691,12 @@ rs::Result Manifest::tryFromToml(const toml::value& data, rs_try(parseProfiles(data)); auto lint = rs_try(Lint::tryFromToml(data)); + auto targetDir = rs_try(parseTargetDir(data, "build")); + return rs::Ok(Manifest(std::move(path), std::move(package), std::move(dependencies), std::move(devDependencies), - std::move(profiles), std::move(lint))); + std::move(profiles), std::move(lint), + std::move(targetDir))); } rs::Result Manifest::findPath(fs::path candidateDir) noexcept { diff --git a/src/Builder/Project.cc b/src/Builder/Project.cc index 16d88f1db..f081f7426 100644 --- a/src/Builder/Project.cc +++ b/src/Builder/Project.cc @@ -6,6 +6,7 @@ #include "TermColor.hpp" #include +#include #include #include #include @@ -78,7 +79,7 @@ static std::vector getEnvFlags(const char* name) { Project::Project(const BuildProfile& buildProfile, Manifest m, CompilerOpts opts) : rootPath(m.path.parent_path()), - outBasePath(rootPath / "cabin-out" / fmt::format("{}", buildProfile)), + outBasePath(rootPath / m.targetDir / fmt::format("{}", buildProfile)), buildOutPath(outBasePath / (m.package.name + ".d")), unittestOutPath(outBasePath / "unit"), integrationTestOutPath(outBasePath / "intg"), manifest(std::move(m)), From 85ce6fc83e56df0067fb1d37696c37c201f03e8d Mon Sep 17 00:00:00 2001 From: Zachary Williams Date: Thu, 29 Jan 2026 07:09:15 -0600 Subject: [PATCH 2/4] feat: support '--target-dir' cli option This adds the '--target-dir' option to 'build', 'run', and 'test'. The effect is the same as setting 'CABIN_TARGET_DIR' or 'build.target-dir'; although the cli option will take precedence over the other two. --- include/Builder/DepGraph.hpp | 6 ++---- include/Manifest.hpp | 2 ++ lib/Builder/DepGraph.cc | 9 +-------- lib/Manifest.cc | 8 +++++++- src/Builder/Builder.cc | 8 ++++---- src/Builder/Builder.hpp | 2 +- src/Cmd/Build.cc | 13 +++++++++++-- src/Cmd/Common.hpp | 6 ++++++ src/Cmd/Run.cc | 13 +++++++++++-- src/Cmd/Test.cc | 13 +++++++++++-- src/Cmd/Tidy.cc | 2 +- 11 files changed, 57 insertions(+), 25 deletions(-) diff --git a/include/Builder/DepGraph.hpp b/include/Builder/DepGraph.hpp index a6526c7ec..d2bfd0ce1 100644 --- a/include/Builder/DepGraph.hpp +++ b/include/Builder/DepGraph.hpp @@ -15,15 +15,13 @@ namespace fs = std::filesystem; class DepGraph { public: - explicit DepGraph(fs::path rootPath) : rootPath(std::move(rootPath)) {} + explicit DepGraph(Manifest manifest) : rootManifest(std::move(manifest)) {} - rs::Result resolve(); rs::Result computeBuildGraph(const BuildProfile& buildProfile) const; private: - fs::path rootPath; - std::optional rootManifest; + Manifest rootManifest; }; } // namespace cabin diff --git a/include/Manifest.hpp b/include/Manifest.hpp index 22afbb322..4aca11efb 100644 --- a/include/Manifest.hpp +++ b/include/Manifest.hpp @@ -134,6 +134,8 @@ class Manifest { installDeps(bool includeDevDeps, const BuildProfile& buildProfile, bool suppressDepDiag = false) const; + Manifest withTargetDir(fs::path targetDir) const; + private: Manifest(fs::path path, Package package, std::vector dependencies, std::vector devDependencies, diff --git a/lib/Builder/DepGraph.cc b/lib/Builder/DepGraph.cc index 11282758b..2be0426c3 100644 --- a/lib/Builder/DepGraph.cc +++ b/lib/Builder/DepGraph.cc @@ -4,16 +4,9 @@ namespace cabin { -rs::Result DepGraph::resolve() { - rootManifest.emplace( - rs_try(Manifest::tryParse(rootPath / Manifest::FILE_NAME))); - return rs::Ok(); -} - rs::Result DepGraph::computeBuildGraph(const BuildProfile& buildProfile) const { - rs_ensure(rootManifest.has_value(), "dependency graph not resolved"); - return BuildGraph::create(*rootManifest, buildProfile); + return BuildGraph::create(rootManifest, buildProfile); } } // namespace cabin diff --git a/lib/Manifest.cc b/lib/Manifest.cc index 6fa34512d..1c95beb49 100644 --- a/lib/Manifest.cc +++ b/lib/Manifest.cc @@ -139,7 +139,7 @@ installPathDependency(const Manifest& manifest, const PathDependency& pathDep, depRoot.string()); } - Builder depBuilder(depRoot, buildProfile); + Builder depBuilder(depManifest, buildProfile); ScheduleOptions depOptions; depOptions.includeDevDeps = includeDevDeps; depOptions.suppressAnalysisLog = true; @@ -680,6 +680,12 @@ rs::Result Manifest::tryParse(fs::path path, return tryFromToml(toml::parse(path), path); } +Manifest Manifest::withTargetDir(fs::path targetDir) const { + return Manifest(std::move(path), std::move(package), std::move(dependencies), + std::move(devDependencies), std::move(profiles), + std::move(lint), std::move(targetDir)); +} + rs::Result Manifest::tryFromToml(const toml::value& data, fs::path path) noexcept { auto package = rs_try(Package::tryFromToml(data)); diff --git a/src/Builder/Builder.cc b/src/Builder/Builder.cc index 5524af312..64b0cabab 100644 --- a/src/Builder/Builder.cc +++ b/src/Builder/Builder.cc @@ -15,14 +15,14 @@ namespace cabin { -Builder::Builder(fs::path rootPath, BuildProfile buildProfile) - : basePath(std::move(rootPath)), buildProfile(std::move(buildProfile)), - depGraph(basePath) {} +Builder::Builder(Manifest rootManifest, BuildProfile buildProfile) + : basePath(rootManifest.path.parent_path()), + buildProfile(std::move(buildProfile)), depGraph(std::move(rootManifest)) { +} rs::Result Builder::schedule(const ScheduleOptions& options) { this->options = options; - rs_try(depGraph.resolve()); graphState.emplace(rs_try(depGraph.computeBuildGraph(buildProfile))); const bool logAnalysis = !options.suppressAnalysisLog; diff --git a/src/Builder/Builder.hpp b/src/Builder/Builder.hpp index 2473c2c79..39276d889 100644 --- a/src/Builder/Builder.hpp +++ b/src/Builder/Builder.hpp @@ -24,7 +24,7 @@ struct ScheduleOptions { class Builder { public: - Builder(fs::path rootPath, BuildProfile buildProfile); + Builder(Manifest rootManifest, BuildProfile buildProfile); rs::Result schedule(const ScheduleOptions& options = {}); rs::Result build(); diff --git a/src/Cmd/Build.cc b/src/Cmd/Build.cc index 2df3dcc5b..36775141e 100644 --- a/src/Cmd/Build.cc +++ b/src/Cmd/Build.cc @@ -33,12 +33,14 @@ const Subcmd BUILD_CMD = .addOpt(Opt{ "--compdb" }.setDesc( "Generate compilation database instead of building")) .addOpt(OPT_JOBS) + .addOpt(OPT_TARGET_DIR) .setMainFn(buildMain); static rs::Result buildMain(const CliArgsView args) { // Parse args BuildProfile buildProfile = BuildProfile::Dev; bool buildCompdb = false; + std::optional targetDir; for (auto itr = args.begin(); itr != args.end(); ++itr) { const std::string_view arg = *itr; @@ -63,13 +65,20 @@ static rs::Result buildMain(const CliArgsView args) { std::from_chars(nextArg.begin(), nextArg.end(), numThreads); rs_ensure(ec == std::errc(), "invalid number of threads: {}", nextArg); setParallelism(numThreads); + } else if (arg == "--target-dir") { + if (itr + 1 == args.end()) { + return Subcmd::missingOptArgumentFor(arg); + } + targetDir.emplace(*++itr); } else { return BUILD_CMD.noSuchArg(arg); } } - const Manifest manifest = rs_try(Manifest::tryParse()); - Builder builder(manifest.path.parent_path(), buildProfile); + const Manifest manifest = [targetDir](const Manifest m) -> Manifest { + return targetDir.has_value() ? m.withTargetDir(targetDir.value()) : m; + }(rs_try(Manifest::tryParse())); + Builder builder(std::move(manifest), buildProfile); rs_try(builder.schedule()); if (buildCompdb) { diff --git a/src/Cmd/Common.hpp b/src/Cmd/Common.hpp index 61e0c5ea3..16ba7cc05 100644 --- a/src/Cmd/Common.hpp +++ b/src/Cmd/Common.hpp @@ -20,4 +20,10 @@ inline const Opt OPT_JOBS = .setPlaceholder("") .setDefault(NUM_DEFAULT_THREADS); +inline const Opt OPT_TARGET_DIR = + Opt{ "--target-dir" } + .setDesc( + "Set directory for all generated artifacts and intermediate files") + .setPlaceholder(""); + } // namespace cabin diff --git a/src/Cmd/Run.cc b/src/Cmd/Run.cc index f738e44f5..1c8fedee4 100644 --- a/src/Cmd/Run.cc +++ b/src/Cmd/Run.cc @@ -30,6 +30,7 @@ const Subcmd RUN_CMD = .setDesc("Build and execute src/main.cc") .addOpt(OPT_RELEASE) .addOpt(OPT_JOBS) + .addOpt(OPT_TARGET_DIR) .setArg(Arg{ "args" } .setDesc("Arguments passed to the program") .setVariadic(true) @@ -40,6 +41,7 @@ static rs::Result runMain(const CliArgsView args) { // Parse args BuildProfile buildProfile = BuildProfile::Dev; auto itr = args.begin(); + std::optional targetDir; for (; itr != args.end(); ++itr) { const std::string_view arg = *itr; @@ -61,6 +63,11 @@ static rs::Result runMain(const CliArgsView args) { std::from_chars(nextArg.begin(), nextArg.end(), numThreads); rs_ensure(ec == std::errc(), "invalid number of threads: {}", nextArg); setParallelism(numThreads); + } else if (arg == "--target-dir") { + if (itr + 1 == args.end()) { + return Subcmd::missingOptArgumentFor(arg); + } + targetDir.emplace(*++itr); } else { // Unknown argument is the start of the program arguments. break; @@ -72,8 +79,10 @@ static rs::Result runMain(const CliArgsView args) { runArgs.emplace_back(*itr); } - const auto manifest = rs_try(Manifest::tryParse()); - Builder builder(manifest.path.parent_path(), buildProfile); + const Manifest manifest = [targetDir](const Manifest m) -> Manifest { + return targetDir.has_value() ? m.withTargetDir(targetDir.value()) : m; + }(rs_try(Manifest::tryParse())); + Builder builder(std::move(manifest), buildProfile); rs_try(builder.schedule()); rs_try(builder.build()); diff --git a/src/Cmd/Test.cc b/src/Cmd/Test.cc index e0b66a5b1..3e16f8a2b 100644 --- a/src/Cmd/Test.cc +++ b/src/Cmd/Test.cc @@ -26,6 +26,7 @@ const Subcmd TEST_CMD = // .setShort("t") .setDesc("Run the tests of a local package") .addOpt(OPT_JOBS) + .addOpt(OPT_TARGET_DIR) .addOpt(Opt{ "--coverage" }.setDesc("Enable code coverage analysis")) .setArg( Arg{ "TESTNAME" }.setRequired(false).setDesc("Test name to launch")) @@ -34,6 +35,7 @@ const Subcmd TEST_CMD = // static rs::Result testMain(const CliArgsView args) { bool enableCoverage = false; std::optional testName; + std::optional targetDir; for (auto itr = args.begin(); itr != args.end(); ++itr) { const std::string_view arg = *itr; @@ -60,13 +62,20 @@ static rs::Result testMain(const CliArgsView args) { enableCoverage = true; } else if (!testName) { testName = arg; + } else if (arg == "--target-dir") { + if (itr + 1 == args.end()) { + return Subcmd::missingOptArgumentFor(arg); + } + targetDir.emplace(*++itr); } else { return TEST_CMD.noSuchArg(arg); } } - const Manifest manifest = rs_try(Manifest::tryParse()); - Builder builder(manifest.path.parent_path(), BuildProfile::Test); + const Manifest manifest = [targetDir](const Manifest m) -> Manifest { + return targetDir.has_value() ? m.withTargetDir(targetDir.value()) : m; + }(rs_try(Manifest::tryParse())); + Builder builder(std::move(manifest), BuildProfile::Test); rs_try(builder.schedule(ScheduleOptions{ .includeDevDeps = true, .enableCoverage = enableCoverage })); return builder.test(std::move(testName)); diff --git a/src/Cmd/Tidy.cc b/src/Cmd/Tidy.cc index c1b5ac651..30485aee6 100644 --- a/src/Cmd/Tidy.cc +++ b/src/Cmd/Tidy.cc @@ -93,7 +93,7 @@ static rs::Result tidyMain(const CliArgsView args) { BuildProfile::Test }; bool isFirstProfile = true; for (const BuildProfile& profile : profiles) { - Builder builder(projectRoot, profile); + Builder builder(manifest, profile); const bool includeDevDeps = (profile == BuildProfile::Test); rs_try(builder.schedule(ScheduleOptions{ .includeDevDeps = includeDevDeps, From 596304a5884364e63484399afc6b80ff7b8cd0dc Mon Sep 17 00:00:00 2001 From: Zachary Williams Date: Thu, 29 Jan 2026 08:33:09 -0600 Subject: [PATCH 3/4] refactor: corrected const moves and initializers This addresses the errors generated by "lint" and "tidy", primarily removed the std::move() calls on const variables and converting Manifest::fromTargetDir() to use a braced initializer. --- lib/Manifest.cc | 5 ++--- src/Cmd/Build.cc | 5 +++-- src/Cmd/Run.cc | 5 +++-- src/Cmd/Test.cc | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/Manifest.cc b/lib/Manifest.cc index 1c95beb49..d3bfa67f8 100644 --- a/lib/Manifest.cc +++ b/lib/Manifest.cc @@ -681,9 +681,8 @@ rs::Result Manifest::tryParse(fs::path path, } Manifest Manifest::withTargetDir(fs::path targetDir) const { - return Manifest(std::move(path), std::move(package), std::move(dependencies), - std::move(devDependencies), std::move(profiles), - std::move(lint), std::move(targetDir)); + return Manifest{ path, package, dependencies, devDependencies, + profiles, lint, std::move(targetDir) }; } rs::Result Manifest::tryFromToml(const toml::value& data, diff --git a/src/Cmd/Build.cc b/src/Cmd/Build.cc index 36775141e..7594ffa65 100644 --- a/src/Cmd/Build.cc +++ b/src/Cmd/Build.cc @@ -19,6 +19,7 @@ #include #include #include +#include #include namespace cabin { @@ -75,10 +76,10 @@ static rs::Result buildMain(const CliArgsView args) { } } - const Manifest manifest = [targetDir](const Manifest m) -> Manifest { + const Manifest manifest = [targetDir](const Manifest& m) -> Manifest { return targetDir.has_value() ? m.withTargetDir(targetDir.value()) : m; }(rs_try(Manifest::tryParse())); - Builder builder(std::move(manifest), buildProfile); + Builder builder(manifest, buildProfile); rs_try(builder.schedule()); if (buildCompdb) { diff --git a/src/Cmd/Run.cc b/src/Cmd/Run.cc index 1c8fedee4..9b7ff6e88 100644 --- a/src/Cmd/Run.cc +++ b/src/Cmd/Run.cc @@ -18,6 +18,7 @@ #include #include #include +#include #include namespace cabin { @@ -79,10 +80,10 @@ static rs::Result runMain(const CliArgsView args) { runArgs.emplace_back(*itr); } - const Manifest manifest = [targetDir](const Manifest m) -> Manifest { + const Manifest manifest = [targetDir](const Manifest& m) -> Manifest { return targetDir.has_value() ? m.withTargetDir(targetDir.value()) : m; }(rs_try(Manifest::tryParse())); - Builder builder(std::move(manifest), buildProfile); + Builder builder(manifest, buildProfile); rs_try(builder.schedule()); rs_try(builder.build()); diff --git a/src/Cmd/Test.cc b/src/Cmd/Test.cc index 3e16f8a2b..8bd31b64b 100644 --- a/src/Cmd/Test.cc +++ b/src/Cmd/Test.cc @@ -72,10 +72,10 @@ static rs::Result testMain(const CliArgsView args) { } } - const Manifest manifest = [targetDir](const Manifest m) -> Manifest { + const Manifest manifest = [targetDir](const Manifest& m) -> Manifest { return targetDir.has_value() ? m.withTargetDir(targetDir.value()) : m; }(rs_try(Manifest::tryParse())); - Builder builder(std::move(manifest), BuildProfile::Test); + Builder builder(manifest, BuildProfile::Test); rs_try(builder.schedule(ScheduleOptions{ .includeDevDeps = true, .enableCoverage = enableCoverage })); return builder.test(std::move(testName)); From f580b3f8ff5e16d0f0396aa4c915d57fc945be56 Mon Sep 17 00:00:00 2001 From: Zachary Williams Date: Thu, 29 Jan 2026 09:46:08 -0600 Subject: [PATCH 4/4] test: added tests for cli flags This added tests for build, run, and test using --target-dir. This revealed a bug with test that is also fixed in this commit. --- src/Cmd/Test.cc | 4 ++-- tests/build.cc | 9 +++++++++ tests/run.cc | 9 +++++++++ tests/test.cc | 9 +++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Cmd/Test.cc b/src/Cmd/Test.cc index 8bd31b64b..f83dd503c 100644 --- a/src/Cmd/Test.cc +++ b/src/Cmd/Test.cc @@ -60,13 +60,13 @@ static rs::Result testMain(const CliArgsView args) { setParallelism(numThreads); } else if (arg == "--coverage") { enableCoverage = true; - } else if (!testName) { - testName = arg; } else if (arg == "--target-dir") { if (itr + 1 == args.end()) { return Subcmd::missingOptArgumentFor(arg); } targetDir.emplace(*++itr); + } else if (!testName) { + testName = arg; } else { return TEST_CMD.noSuchArg(arg); } diff --git a/tests/build.cc b/tests/build.cc index f15fff098..998f015b6 100644 --- a/tests/build.cc +++ b/tests/build.cc @@ -6,6 +6,15 @@ int main() { using boost::ut::expect; using boost::ut::operator""_test; + "cabin build uses cli target-dir"_test = [] { + const tests::TempDir tmp; + tests::runCabin({ "new", "hello_world" }, tmp.path).unwrap(); + const auto project = tmp.path / "hello_world"; + + tests::runCabin({ "build", "--target-dir", "tmpdir" }, project).unwrap(); + expect(tests::fs::is_directory(project / "tmpdir" / "dev")); + }; + "cabin build emits ninja"_test = [] { const tests::TempDir tmp; tests::runCabin({ "new", "ninja_project" }, tmp.path).unwrap(); diff --git a/tests/run.cc b/tests/run.cc index 84f4d5cbb..08957b117 100644 --- a/tests/run.cc +++ b/tests/run.cc @@ -9,6 +9,15 @@ int main() { using boost::ut::expect; using boost::ut::operator""_test; + "cabin run uses cli target-dir"_test = [] { + const tests::TempDir tmp; + tests::runCabin({ "new", "hello_world" }, tmp.path).unwrap(); + const auto project = tmp.path / "hello_world"; + + tests::runCabin({ "run", "--target-dir", "tmpdir" }, project).unwrap(); + expect(tests::fs::is_directory(project / "tmpdir" / "dev")); + }; + "cabin run"_test = [] { const tests::TempDir tmp; tests::runCabin({ "new", "hello_world" }, tmp.path).unwrap(); diff --git a/tests/test.cc b/tests/test.cc index 84c6fd81b..676122385 100644 --- a/tests/test.cc +++ b/tests/test.cc @@ -80,6 +80,15 @@ int main() { using boost::ut::expect; using boost::ut::operator""_test; + "cabin test uses cli target-dir"_test = [] { + const tests::TempDir tmp; + tests::runCabin({ "new", "hello_world" }, tmp.path).unwrap(); + const auto project = tmp.path / "hello_world"; + + tests::runCabin({ "test", "--target-dir", "tmpdir" }, project).unwrap(); + expect(tests::fs::is_directory(project / "tmpdir" / "test")); + }; + "cabin test basic"_test = [] { const tests::TempDir tmp; tests::runCabin({ "new", "test_project" }, tmp.path).unwrap();