What's best practice for using optional feature sub-dependencies vs. a direct dependency?

I'm using a package called menu which provides a library crate with an optional noline feature that depends on a package of that same name. Or rather, it looks like the menu package declares an optional dependency on that package:

[dependencies]
noline = { version = "0.5.0", optional = true }

…but does not list it as an explicit feature. If I'm understanding https://doc.rust-lang.org/cargo/reference/features.html#optional-dependencies correctly optional dependencies implicitly become package features (at least in lieu of any further explicit configuration).

With that background, my question is whether in the Rust community it's considered best practice to simply rely on this dependency-feature for my own code's usage. That is, in order to use this noline feature of menu then my own crate will use noline::builder::EditorBuilder in its own source code.

My general practice at least in other languages is that any dependencies I import myself get listed as top-level dependencies even if they "happen to" already be hanging around as sub-dependencies of something. But now in this case it seems like it could be argued either way:

  • no, leave it out. it is redundant to directly depend on noline because I'm already explicitly asking for a compatible version by using the noline dependency-feature on my own menu dependency. it doesn't just "happen to" be installed — I opted into an optional (sub-)dependency and so my code is good to use it too
    • but… could a future version of menu choose to implement its noline feature in a different way that leaves me without the actual noline dependency? potentially I am snooping into internal implementation details that the menu = { version = "0.6.1", features = ["noline"] } feature is one that comes from an optional dependency that I want to also depend on.
  • yes, depend on noline directly myself alongside the menu crate with that feature enabled — for basically the reasons in the sub-bullet above, that how a package implements a feature is not really "public API" and I shouldn't rely on it
    • but now, don't I risk getting or later updating to a different version of the noline shared-ish dependency, and so my code will be passing in an object from noline == 0.5.1 or noline == 0.6.42 when maybe the menu crate still expects noline == 0.5.0

What would be idiomatic Rust/Cargo usage here? Should I include in my own package just:

[dependencies]
menu = { version = "0.6.1", features = ["noline"] }

or should I explicitly:

[dependencies]
menu = { version = "0.6.1", features = ["noline"] }
noline = "0.5.1"

Or perhaps put another way, would it be considered a semver-major change for menu to change what packages its own noline feature makes available to my own code — or is that an implementation detail I shouldn't be relying on?

If I'm understanding Features - The Cargo Book correctly optional dependencies implicitly become package features (at least in lieu of any further explicit configuration).

Yes, that is correct. Implicit features might someday be deprecated — the reason for the explicit form is to allow libraries to avoid having public feature names that aren't part of their intentional design, and the implicit form could result in accidentally doing so. But none of that matters to the rest of your question.

My general practice at least in other languages is that any dependencies I import myself get listed as top-level dependencies even if they "happen to" already be hanging around as sub-dependencies of something.

This principle does not apply to Cargo, because you cannot make direct use of a dependency you didn't declare. That is, if you don't have

[dependencies]
noline = { ... }

then use noline::sync_editor::Editor; is an error.

but… could a future version of menu choose to implement its noline feature in a different way that leaves me without the actual noline dependency?

If you don't declare a noline dependency then you cannot use noline directly. If menu were to re-export noline (in which case you would use paths like menu::noline::...), then it is a breaking change for menu to stop re-exporting noline. If menu uses types from noline 0.5 in its public API, then it is a breaking change for menu to change those types to be something other than noline 0.5's. (Unless otherwise explicitly documented.)

You should not include a noline dependency that you do not actually use by name in your code, unless you need to either

  • enable specific features of noline, or
  • constrain the version of noline in use more strongly than menu does.

menu's optional dependency does not make noline available to your code, unless there are re-exports involved. The semver significance of menu changing its dependencies is determined by how, if at all, menu re-exports or otherwise exposes items of noline to you.

2 Likes

No – if it reexports the appropriate parts of API. Yes – it it's designed to make you import both packages.

That's precisely why packages reexport dependencies (not your case from what I'm seeing).

How many of these allow you to use two different versions of the exact same dependency in one program?

Note that in Rust it's perfectly fine for menu to import (and reexport) noline version 0.5 while your code may simultaneously import noline version 0.4 or 0.6.

Then you would have to use both noline 0.5 and noline version 0.4 or 0.6

That's doable, usually, but non-trivial and if you only access noline via menu then everything is just much simpler.

If you need to access features of noline that menu doesn't reexport… then you would need to do something more complicated.

Augh, you're right! Sorry… before posting I tried that in my editor and didn't get any angry squiggly lines so I plowed ahead. But yeah, it doesn't cargo build and as you say makes my whole question kind of moot.

I guess any potential incompatibilities between whatever noline version I depend on vs. what version menu expects to be handed in to its feature just get chased out in dev/testing?

[Update: for context, it looks like menu does NOT re-export its own copy of noline.]

If menu doesn't reexport noline and expects you to use it directly then it's breaking change to pick different, incompatible, version of noline.

Only if menu would reexport appropriate parts of noline API for you to use it wouldn't be breaking change.

1 Like

I guess any potential incompatibilities between whatever noline version I depend on vs. what version menu expects to be handed in to its feature just get chased out in dev/testing?

Concretely, it looks like the only place menu exposes anything from noline is in this optional function:

#[cfg(feature = "noline")]
impl<'a, I, T, B, H> Runner<'a, I, T, Editor<B, H>>
where
    B: Buffer,
    H: History,
    I: embedded_io::Read + embedded_io::Write,
{
    pub fn input_line(&mut self, context: &mut T) -> Result<(), NolineError> {

So, if menu changes the major version of noline it is using, then you will get a compilation error if and only if you did something that requires the returned error type to be equal to your noline::NolineError. (And that would be menu’s fault for making a breaking change.) If you just, say, print or unwrap() the error, then you won’t notice and the program will continue functioning normally (except for any effects of no-longer-shared feature flags).

In this type of situation, it would be desirable for menu to re-export NolineError, or transform it into menu's own error type, so that you don't have to worry about the version match. (Transforming the error has the advantage that there is no longer any possibility of a version mismatch and menu could freely upgrade its dependency on noline.)

3 Likes

Okay, yep… maybe starting to get a handle on this. According to Avoid multiple usage of the same crate? - #3 by kornel unless things have since changed basically if I specify noline = "0.5.1" while menu specifies noline = "0.5.0" it will get resolved to one and the same package (perhaps actually `noline = "0.5.99"…) anyway.

Via a similar Reddit discussion it looks like there's even a trick if I want to depend on noline = "*" that could be an option, but personally I think I'll stick with declaring my own code's expected version.

And just for my own curiosity I deliberately tried depending on a different-enough version basically:

[dependencies]
menu = { version = "0.6.1", features = ["noline"] }    # noline = "0.5.0"
noline = "0.4.0"

and got an error (albeit a somewhat cryptic one) that I think basically boiled down to that the buffer: B that I was passing in wasn't matching the noline::line_buffer::Buffer in the bounds of that input_line method you found.

error[E0599]: no method named `input_line` found for struct `menu::Runner<'_, IOWrapper, Context, noline::sync_editor::Editor<Vec<u8>, UnboundedHistory>>` in the current scope
   --> src/main.rs:152:25
    |
152 |     while let Ok(_) = r.input_line(&mut context) {}
    |                         ^^^^^^^^^^ method not found in `Runner<'_, IOWrapper, Context, Editor<Vec<u8>, UnboundedHistory>>`
    |
    = note: the method was found for
            - `menu::Runner<'a, I, T, noline::sync_editor::Editor<B, H>>`

Probably in other simpler situations the error would be clearer although https://doc.rust-lang.org/cargo/reference/resolver.html#version-incompatibility-hazards implies that it never really disambiguates what would look like the same name from different crates [i.e. different versions of the same crate]. I guess you see that here where it says "no method found for …noline::sync_editor::Editor…" while also noting "the method was found for …noline::sync_editor::Editor…" — knowing what to look for the error does subtly imply there must be two different types displayed as noline::sync_editor::Editor floating around.

Anyway this is probably getting off in the weeds at this point, just wanted to more fully fill in my own knowledge gaps here while I was in the thick of it. Thanks for the tips!

In summary:

  • you can only use direct dependencies
  • small semver differences coalesce into the exact same (shared) crate
  • semver-incompatible differences introduce multiple copies of a crate into play, but whose respective types will be completely distinct from each other (except in human-visible naming)

This method

  • is not guaranteed to unify your dependency and menu's, and
  • exposes you to all breaking changes of noline's API.

It may be slightly convenient in a single application with a carefully managed lock file, but it is not robust. "*" dependencies in general are almost never correct.

3 Likes

I dislike when crates require adding another dependency with a correct version in order to use the crate's API.

IMHO the correct approach is for crates to have their public API self-contained and complete as much as possible, and re-export everything that is necessary to use them.

If menu requires noline to use (when the feature is enabled), then menu should have:

#[cfg(feature = "noline")]
pub use noline;

so that you can use menu::noline, and always get the right crate. This way it's simpler — you don't need to add another dependency. You don't need to find which version it wants. You don't need to update the two crates in sync. You don't need whack-a-mole with lockfiles and * deps. It just works.

Re-exports can't fix everything. Some crates are an ecosystem-wide "glue" between unrelated crates, like serde. But such interoperability crates should also take compatibility seriously, and not bump 0.x versions lightly.

If a 0.x unstable crate is in your public API, then either hide it and abstract it away, or re-export it to make it your public API for real.

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.