Skip to content
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ dependable check . --format json # machine-readable output (also: text)
dependable check . --fail-on vulnerable # exit non-zero for CI
dependable check . --annotations always # GitHub Actions annotations + job summary
dependable check . --manifest-glob 'services/*/Cargo.toml' # one slice of a monorepo
dependable check . --ecosystem rust # one ecosystem of a polyglot repo
dependable list . # every project and what it declares (offline)
dependable tree . # render the dependency tree (Rust)
dependable fix . --dry-run # preview in-place upgrades
Expand Down Expand Up @@ -268,6 +269,31 @@ conflicts with `--manifest`, which names one file and skips discovery altogether
It is available on `fix` for a reason: without it, `dependable fix` would rewrite
manifests that the matching `dependable check` deliberately left out.

To work on part of a *polyglot* repository, filter by ecosystem instead:

```bash
dependable check . --ecosystem rust
dependable list . --ecosystem npm --ecosystem rust --format json
dependable fix . --ecosystem rust --dry-run
```

`--ecosystem` narrows discovery to the manifests belonging to the ecosystems you
name — `rust`, `go`, `npm`, `python`, `php`, `dart`, `csharp`, `elixir`, `jvm`. It
names the *ecosystem*, not a filename, so `--ecosystem npm` covers `package.json`,
`deno.json`, and `pnpm-workspace.yaml` alike. The flag is repeatable and a manifest
in any named ecosystem is kept, it is available on `check`, `list`, and `fix` for
the same reason `--manifest-glob` is, and it conflicts with `--manifest`. When it
selects nothing, `dependable` says which ecosystems it searched and which it found
instead, and still exits 0 — an unused ecosystem must not fail a per-ecosystem CI
matrix job. `list --format json` prints a valid `dependable.list/v1` document with
zero projects in that case, so a shard that pipes into `jq` gets something to
parse rather than empty output.

It only ever **narrows** a run. Naming an ecosystem that `.dependable.toml` has
switched off does not switch it back on: `check --ecosystem jvm` under
`[jvm] enabled = false` discovers the manifests and then reports them as skipped,
exactly as it does without the flag.

#### Inherited versions

A Cargo workspace declares shared versions once, at the root, and members opt in by
Expand Down
50 changes: 33 additions & 17 deletions crates/dependable-core/src/ecosystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,32 @@ pub enum Ecosystem {
}

impl Ecosystem {
/// Every variant, in declaration order.
///
/// Hand-written, because there is no stable way to enumerate an enum's
/// variants and `#[non_exhaustive]` puts an exhaustive match out of reach of
/// every other crate. What keeps it honest is *where it sits*: every method
/// below matches on `self` exhaustively, so adding a variant stops this file
/// compiling, and this list is in front of whoever fixes that. That is a
/// prompt, not a proof — nothing forces the list to grow, so keep it in step
/// with the enum by hand.
///
/// It exists to be pinned against. A frontend that has to cover every
/// ecosystem — the `--ecosystem` values of the `dependable` binary, for one —
/// asserts its own coverage equals this list, so an ecosystem missing from it
/// is an ecosystem that silently reaches no user.
pub const ALL: [Self; 9] = [
Ecosystem::Rust,
Ecosystem::Go,
Ecosystem::Npm,
Ecosystem::Python,
Ecosystem::Php,
Ecosystem::Dart,
Ecosystem::CSharp,
Ecosystem::Elixir,
Ecosystem::Jvm,
];

/// The `package.ecosystem` string used in OSV vulnerability queries.
#[must_use]
pub fn osv_name(self) -> &'static str {
Expand Down Expand Up @@ -227,23 +253,9 @@ impl Ecosystem {
mod tests {
use super::*;

/// Every variant, so a new ecosystem cannot be added without being given
/// its pages.
const ALL: [Ecosystem; 9] = [
Ecosystem::Rust,
Ecosystem::Go,
Ecosystem::Npm,
Ecosystem::Python,
Ecosystem::Php,
Ecosystem::Dart,
Ecosystem::CSharp,
Ecosystem::Elixir,
Ecosystem::Jvm,
];

#[test]
fn every_ecosystem_can_name_a_page_for_a_package() {
for ecosystem in ALL {
for ecosystem in Ecosystem::ALL {
let url = ecosystem.package_url("serde");
assert!(url.starts_with("https://"), "{ecosystem:?}: {url}");
assert!(url.contains("serde"), "{ecosystem:?}: {url}");
Expand Down Expand Up @@ -350,7 +362,11 @@ mod tests {
// conflict resolution.
(Ecosystem::Jvm, BareVersion::Minimum),
];
assert_eq!(expected.len(), ALL.len(), "every variant must be listed");
assert_eq!(
expected.len(),
Ecosystem::ALL.len(),
"every variant must be listed"
);
for (ecosystem, reading) in expected {
assert_eq!(ecosystem.bare_version(), reading, "{ecosystem:?}");
}
Expand All @@ -360,7 +376,7 @@ mod tests {
/// terms of the other, and this pins that they stay that way.
#[test]
fn the_exactness_shorthand_agrees_with_the_full_reading() {
for ecosystem in ALL {
for ecosystem in Ecosystem::ALL {
assert_eq!(
ecosystem.bare_version_is_exact(),
ecosystem.bare_version() == BareVersion::Exact,
Expand Down
121 changes: 121 additions & 0 deletions crates/dependable/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ pub struct CheckArgs {
/// any pattern is kept. `*` and `?` do not cross `/`, `**` does.
#[arg(long, conflicts_with = "manifest")]
pub manifest_glob: Vec<String>,
/// Only use manifests belonging to this ecosystem. Repeatable; a manifest in
/// any of the named ecosystems is kept.
///
/// It narrows discovery and never widens it: naming an ecosystem that
/// `.dependable.toml` has switched off does not switch it back on, and it
/// registers no fetcher that was not already there.
#[arg(long, value_enum, conflicts_with = "manifest")]
pub ecosystem: Vec<EcosystemArg>,
/// Config file path.
#[arg(long, default_value = ".dependable.toml")]
pub config: PathBuf,
Expand Down Expand Up @@ -128,6 +136,14 @@ pub struct ListArgs {
/// any pattern is kept. `*` and `?` do not cross `/`, `**` does.
#[arg(long, conflicts_with = "manifest")]
pub manifest_glob: Vec<String>,
/// Only use manifests belonging to this ecosystem. Repeatable; a manifest in
/// any of the named ecosystems is kept.
///
/// It narrows discovery and never widens it: naming an ecosystem that
/// `.dependable.toml` has switched off does not switch it back on, and it
/// registers no fetcher that was not already there.
#[arg(long, value_enum, conflicts_with = "manifest")]
pub ecosystem: Vec<EcosystemArg>,
/// Config file path. `list` reads only the per-ecosystem `enabled` flags from
/// it, so that an ecosystem you have switched off is not warned about; it does
/// not read registry or network settings.
Expand Down Expand Up @@ -192,6 +208,14 @@ pub struct FixArgs {
/// any pattern is kept. `*` and `?` do not cross `/`, `**` does.
#[arg(long, conflicts_with = "manifest")]
pub manifest_glob: Vec<String>,
/// Only use manifests belonging to this ecosystem. Repeatable; a manifest in
/// any of the named ecosystems is kept.
///
/// It narrows discovery and never widens it: naming an ecosystem that
/// `.dependable.toml` has switched off does not switch it back on, and it
/// registers no fetcher that was not already there.
#[arg(long, value_enum, conflicts_with = "manifest")]
pub ecosystem: Vec<EcosystemArg>,
#[arg(long, default_value = ".dependable.toml")]
pub config: PathBuf,
/// Update all, including beyond the declared constraint.
Expand Down Expand Up @@ -372,3 +396,100 @@ impl From<UnstableFilter> for dependable_fetch::UnstableFilter {
}
}
}

/// An ecosystem nameable on the command line via `--ecosystem`.
///
/// A CLI-local mirror of [`dependable_fetch::Ecosystem`]. `ValueEnum` is clap's
/// trait and `Ecosystem` is a foreign type here, so the orphan rule rules out
/// implementing one for the other; deriving `ValueEnum` upstream instead would
/// put clap into `dependable-core`, which is deliberately IO-free and
/// frontend-agnostic. The [`From`] impl below is the only bridge, and the unit
/// test beside it asserts that its image is exactly
/// [`Ecosystem::ALL`](dependable_fetch::Ecosystem::ALL) — the list the defining
/// crate keeps beside the exhaustive matches a new variant breaks.
///
/// The accepted spellings are the canonical lowercase names and nothing else.
/// Aliases (`kotlin`, `java`, `deno`, `nuget`) are deliberately absent: every
/// accepted string is a permanent compatibility surface, and an alias would
/// outlive the variant it names if an ecosystem later splits — `deno` would go
/// on meaning [`Npm`](Ecosystem::Npm) after a Deno variant existed. Aliases are
/// additive and cheap to add later; they are not removable.
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum EcosystemArg {
Rust,
Go,
Npm,
Python,
Php,
Dart,
// Spelled out because clap's default kebab-casing renders `CSharp` as
// `c-sharp`, which is not a name anybody types. A `///` here would render
// that reasoning beside the value in `--help`, where it is noise.
#[value(name = "csharp")]
CSharp,
Elixir,
Jvm,
}

impl From<EcosystemArg> for dependable_fetch::Ecosystem {
fn from(value: EcosystemArg) -> Self {
match value {
EcosystemArg::Rust => dependable_fetch::Ecosystem::Rust,
EcosystemArg::Go => dependable_fetch::Ecosystem::Go,
EcosystemArg::Npm => dependable_fetch::Ecosystem::Npm,
EcosystemArg::Python => dependable_fetch::Ecosystem::Python,
EcosystemArg::Php => dependable_fetch::Ecosystem::Php,
EcosystemArg::Dart => dependable_fetch::Ecosystem::Dart,
EcosystemArg::CSharp => dependable_fetch::Ecosystem::CSharp,
EcosystemArg::Elixir => dependable_fetch::Ecosystem::Elixir,
EcosystemArg::Jvm => dependable_fetch::Ecosystem::Jvm,
}
}
}

#[cfg(test)]
mod tests {
use super::*;

use dependable_fetch::Ecosystem;

/// Adding an ecosystem without adding its `--ecosystem` value would leave a
/// supported ecosystem unfilterable, and — worse — leave `--ecosystem` unable
/// to say so.
///
/// The comparison is against [`Ecosystem::ALL`], not against a second list
/// written here: a list local to this test would be updated by the same hand
/// that forgot the variant, and would agree with itself for ever. `ALL` lives
/// in `dependable-core` beside the exhaustive matches a new variant does not
/// compile past, so it is the one list a new ecosystem cannot be added
/// without meeting.
#[test]
fn every_ecosystem_can_be_named_on_the_command_line() {
let nameable: Vec<Ecosystem> = EcosystemArg::value_variants()
.iter()
.map(|arg| Ecosystem::from(*arg))
.collect();
assert_eq!(nameable, Ecosystem::ALL.to_vec());
}

/// The one value whose derived spelling is wrong: clap kebab-cases `CSharp`
/// to `c-sharp`. Asserted on the value clap advertises, not on the variant.
#[test]
fn csharp_is_spelled_the_way_it_is_typed() {
let names: Vec<String> = EcosystemArg::value_variants()
.iter()
.map(|arg| {
arg.to_possible_value()
.expect("no variant is skipped")
.get_name()
.to_owned()
})
.collect();
assert_eq!(
names,
[
"rust", "go", "npm", "python", "php", "dart", "csharp", "elixir", "jvm"
]
);
}
}
Loading
Loading