Skip to content

feat!: consolidate cot cli commands into one - #587

Open
ElijahAhianyo wants to merge 51 commits into
masterfrom
elijah/cot-proxy-cmd
Open

feat!: consolidate cot cli commands into one#587
ElijahAhianyo wants to merge 51 commits into
masterfrom
elijah/cot-proxy-cmd

Conversation

@ElijahAhianyo

@ElijahAhianyo ElijahAhianyo commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Background

cot currently exposes CLI commands through two separate entry points:

  • cot-cli crate (cot <command>): handles project scaffolding, migration listing, migration generation, and shell completions.
  • Compiled binary (invoked directly): exposes runtime commands such as check, running migrations (which is also triggered implicitly at startup), and any custom user-defined task commands.

In addition, running and building a cot app relies on Cargo tooling. In summary, there are three ways to invoke CLI commands today:

  1. cot <command> via the cot-cli crate
  2. Invoking the compiled binary directly for commands not available in cot-cli
  3. Using Cargo to build and run apps

This PR focuses on unifying 1 and 2 for ergonomics: cot now acts as the single entry point for all commands, proxying any unrecognized command to the compiled binary if it exists there. Option 3 (Cargo invocation) is out of scope for this PR and can be revisited in a follow-up if proxying those commands makes sense.

Approach

When cot receives a command it does not recognize, it resolves the target binary (target/debug by default, or target/release if --release is passed), queries it for its available commands via a metadata flag, and either forwards the command along with all provided arguments or returns an error if the command is not found in the binary either.

Metadata

To support proxying, the cot crate exposes a --metadata flag. At runtime, the binary uses reflection to enumerate all registered CLI commands and prints them as JSON to stdout. This serves two purposes: it tells cot-cli whether a given command should be forwarded, and it provides the information needed to render accurate help text.

Example:

$ ./target/debug/forms --metadata
{
  "binary_name": "forms",
  "commands": [
    {
      "name": "check",
      "about": "Verifies the configuration, including connections to the database and other services",
      "aliases": [],
      "subcommands": []
    },
    {
      "name": "collect-static",
      "about": "Collects all static files into a static directory",
      "aliases": [],
      "subcommands": []
    }
  ]
}

Caching

Querying the binary on every invocation would be wasteful, so the metadata response is cached in .cot/command-cache.json. The cache stores the binary's modified time (mtime) alongside the metadata and is invalidated automatically whenever the binary is rebuilt.

Workspaces

When working inside a Cargo workspace, a --package (-p) flag must be provided to specify which package's binary should be targeted.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Refactor / cleanup
  • Performance improvement
  • Other (describe above)

@github-actions github-actions Bot added A-deps Area: Dependencies C-cli Crate: cot-cli (issues and Pull Requests related to Cot CLI) C-lib Crate: cot (main library crate) labels Jun 10, 2026
@ElijahAhianyo ElijahAhianyo changed the title consolidate cot cli commands into one feat!: consolidate cot cli commands into one Jun 10, 2026
@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Projectcot
Branchelijah/cot-proxy-cmd
Testbedgithub-ubuntu-latest
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
microseconds (µs)
(Result Δ%)
Upper Boundary
microseconds (µs)
(Limit %)
empty_router/empty_router📈 view plot
🚷 view threshold
13,652.00 µs
(+59.40%)Baseline: 8,564.87 µs
16,300.96 µs
(83.75%)
json_api/json_api📈 view plot
🚷 view threshold
1,054.90 µs
(+0.33%)Baseline: 1,051.42 µs
1,373.79 µs
(76.79%)
nested_routers/nested_routers📈 view plot
🚷 view threshold
989.43 µs
(+0.62%)Baseline: 983.37 µs
1,257.69 µs
(78.67%)
single_root_route/single_root_route📈 view plot
🚷 view threshold
954.81 µs
(+0.85%)Baseline: 946.79 µs
1,219.97 µs
(78.27%)
single_root_route_burst/single_root_route_burst📈 view plot
🚷 view threshold
17,185.00 µs
(+0.53%)Baseline: 17,094.45 µs
21,564.70 µs
(79.69%)
🐰 View full continuous benchmarking report in Bencher

@ElijahAhianyo
ElijahAhianyo marked this pull request as ready for review June 23, 2026 16:41
@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.56331% with 221 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cot-cli/src/test_harness.rs 66.16% 119 Missing and 17 partials ⚠️
cot-cli/src/project.rs 89.91% 43 Missing and 14 partials ⚠️
cot-cli/src/main.rs 87.67% 15 Missing and 3 partials ⚠️
cot/src/project.rs 0.00% 5 Missing ⚠️
cot/src/cli.rs 0.00% 3 Missing ⚠️
cot-cli/src/handlers.rs 99.56% 0 Missing and 2 partials ⚠️
Flag Coverage Δ
rust 90.05% <87.56%> (-0.17%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
cot-cli/src/args.rs 100.00% <100.00%> (ø)
cot-cli/src/utils.rs 96.14% <100.00%> (+2.13%) ⬆️
cot/src/metadata.rs 100.00% <100.00%> (ø)
cot-cli/src/handlers.rs 98.79% <99.56%> (+2.86%) ⬆️
cot/src/cli.rs 86.49% <0.00%> (-0.85%) ⬇️
cot/src/project.rs 88.21% <0.00%> (-0.56%) ⬇️
cot-cli/src/main.rs 88.95% <87.67%> (-11.05%) ⬇️
cot-cli/src/project.rs 89.91% <89.91%> (ø)
cot-cli/src/test_harness.rs 66.16% <66.16%> (ø)

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ElijahAhianyo
ElijahAhianyo requested review from m4tx and seqre June 25, 2026 11:53
Comment thread cot-cli/src/handlers.rs
Comment on lines +134 to +144
#[cfg(unix)]
{
let err = std::process::Command::new(&proj.path).args(args).exec();
anyhow::bail!("Failed to exec {}: {err}", proj.path.display());
}

#[cfg(not(unix))]
{
let status = std::process::Command::new(&proj.path).args(args).status()?;
std::process::exit(status.code().unwrap_or(1));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where's the difference between Unix-like and non-Unix-like platforms coming from? Why do we need separate code paths here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea here is to replace the cot process with that of the child(the binary) whenever we forward to(or run) the binary. The exec call is only available on POSIX. Windows doesn't provide a way to do this, so we just spawn the child and block until it exits. Left a comment to outline this behavior as well.
On a side note, one issue i think isnt being handled is signal forwarding in the non-Unix case

Comment thread cot-cli/src/project.rs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need all this machinery here? Any chance that running cargo run would suffice instead?

Comment thread cot-cli/Cargo.toml Outdated
Comment thread cot/src/metadata.rs
Comment thread cot/src/metadata.rs Outdated
Comment thread cot-cli/src/project.rs Outdated
Comment thread cot-cli/src/project.rs Outdated
@ElijahAhianyo ElijahAhianyo mentioned this pull request Jul 30, 2026
6 tasks
@ElijahAhianyo
ElijahAhianyo requested a review from m4tx August 18, 2026 02:18

@m4tx m4tx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, this is a big change! One big major possible improvement I still see is whether we could offload some of the logic (especially in the cot-cli crate) to some existing crates, or cargo itself. (see the comments) Let me know what you think!

Comment thread cot-cli/src/args.rs
/// argv, before clap has parsed anything. Needed because `project::load`
/// must run before `Cli::parse` for the `--help` interception path.
#[must_use]
pub fn extract_package_arg(raw: &[String]) -> Option<String> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also handle cases like cot check -- -p package here? (and if so, we should have tests for these as well)

@ElijahAhianyo ElijahAhianyo Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is only called in main() after we split on -- to differentiate internal args from args forwarded to the binary. In practice, it wouldn't receive raw args, but I've added a defensive check to the function to handle that case.

Also, shouldn't this be cot check -p package instead of cot check -- -p package? I'd imagine that external args (args after the -- delimiter) are forwarded to the binary, so we should expect this to throw an error.

That also brings me to this scenario: commands like migration rollback, collect-static, and check live in the binary and are treated as external commands from the perspective of cot-cli, just like custom commands. However, we market these commands as first-class cot-cli commands to end users. So users would expect all cot commands to follow the same convention, with args coming before the delimiter, rather than having to figure out which internal commands require args after the delimiter and which require args before.

I think as a follow-up to this PR, we should have some mechanism to make cot-cli aware of what commands to treat as first-class citizens, and also maybe a way to make CliTask aware of flags already registered by cot-cli (like --package and --release).

What do you think?

Comment thread cot-cli/src/project.rs Outdated
Comment thread cot-cli/src/test_harness.rs Outdated
Comment thread cot/src/project.rs Outdated
Comment thread cot/src/project.rs Outdated
Comment thread cot-cli/tests/snapshot_testing/mod.rs Outdated
Comment thread cot-cli/src/test_harness.rs Outdated
Comment thread cot-cli/src/project.rs Outdated
Comment thread cot-cli/src/project.rs Outdated
.context("Cargo.toml has no [package] section and no [[bin]] targets")
}

fn resolve_target_dir(start_dir: &Path) -> PathBuf {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the target dir is set in ~/.cargo/config.toml? Will it also work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be handled by cargo-metadata since it delegates that discovery to the cargo toolchain

Comment thread cot-cli/src/project.rs Outdated
@@ -0,0 +1,946 @@
use std::fmt::Write;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another issue I have with this file is that it duplicates a lot of logic with cargo itself. This seems very brittle, and sounds like we could be missing some edge cases our users could run into (e.g. see my comment about resolving the target directory).

I'm wondering if all of that is really needed? I'm thinking whether we could:

  1. Use cargo itself for some of that, e.g. by using cargo build --mesage-format json
  2. Use a specialised crate for this, e.g. cargo_toml

@ElijahAhianyo ElijahAhianyo Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the main motivation for moving away from cargo run on every run is that on a hot path where no recompile of the binary is needed, cargo still walks up the dependency graph to find fresh/dirty deps that need to be recompiled, which isn't free (time-wise). This isn't a problem for smaller projects, but I'm concerned it can give a bad user experience for larger projects. I figured using cargo build as the last resort (the cold path where the binary isn't compiled at all) would a much better experience.
I also agree on the brittle approach of manually obtaining the target directory/binary. I looked into the cargo_metadata crate, which seems to obtain the outputs of cargo metadata and cargo --message-format=json and correctly resolves the target dir and handles the case where CARGO_TARGET_DIR is set (which cargo_toml crate falls short of). It's also less expensive compared to cargo build when you run with the --no-deps flag. One thing about cargo_metadata to note is that it gets you the information about the target dir and available binaries, which is one part of the job done. However, it leaves it up to you to identify which binary or handle any disambiguations if there are multiple binaries.

Overall, the flow I had in mind looks like this:

  1. Try to find the path to the binary by getting this info from cargo_metadata
    a. If the binary exists, try to obtain metadata information from the cache(hot path) if it exists and binary hasnt been recompiled or from the binary itself(warm path) using the --cot-internal-cli-metadata flag
    b. If the binary does not exist(cold path), build the binary at request of the user using cargo build via the build flag(I'm wondering if this should be implicit and automatic). Then go ahead to obtain metadata information like in step a
  2. After obtaining the metadata, invoke the binary and forward any args to it.
  3. The help command follows the same approach; it tries to check if the binary exists and tries to obtain metadata. If it doesn't exist or metadata extraction fails, it warns the user and falls back to the generic help command by clap, which shows only commands defined in the cot-cli

What do you think of this?

@ElijahAhianyo
ElijahAhianyo requested a review from m4tx August 21, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-deps Area: Dependencies C-cli Crate: cot-cli (issues and Pull Requests related to Cot CLI) C-lib Crate: cot (main library crate)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants