Skip to content

feat!: enforce #[non_exhaustive] policy via clippy lints - #299

Open
crowecawcaw wants to merge 1 commit into
OpenJobDescription:mainfrom
crowecawcaw:feat/non-exhaustive-lints
Open

feat!: enforce #[non_exhaustive] policy via clippy lints#299
crowecawcaw wants to merge 1 commit into
OpenJobDescription:mainfrom
crowecawcaw:feat/non-exhaustive-lints

Conversation

@crowecawcaw

@crowecawcaw crowecawcaw commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What was the problem/requirement? (What/Why)

Some public interfaces should have #[non_exhaustive] applied so expanding the interfaces in the future doesn't force a breaking change. But not all interfaces have it. Also some interfaces should be exhaustive.

What was the solution? (How)

  1. Define a policy for what should be exhaustive vs not. In short, the template itself grows over time with extensions and should not have exhaustive interfaces. New fields are gated behind the extensions opt-in list at runtime. Interfaces not behind the opt-in list should be exhaustive so new fields must be handled in code and cannot be silently ignored and cause runtime errors.
  2. Enforce non_exhaustive with a lint rule. Every public interface must either be non_exhaustive or must have a justification about why it's not.

Recommend reading the spec before reading the diff: https://github.com/crowecawcaw/openjd-rs/blob/279be48524ad2b13a9eb54c8ff15c5ab9f0dab0f/specs/non-exhaustive-policy.md

What is the impact of this change?

non_exhaustive applied consistently across the interfaces.

How was this change tested?

Coverlay

Was this change documented?

Yes

Is this a breaking change?

Yes

Does this change impact security?

No


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@crowecawcaw
crowecawcaw requested a review from a team as a code owner August 3, 2026 22:21
@crowecawcaw
crowecawcaw force-pushed the feat/non-exhaustive-lints branch from 279be48 to 0eab8d2 Compare August 3, 2026 22:35
Comment thread crates/openjd-model/src/template/parameters.rs
@crowecawcaw
crowecawcaw force-pushed the feat/non-exhaustive-lints branch 2 times, most recently from 2565761 to 021c145 Compare August 3, 2026 23:18
Set clippy::exhaustive_enums and clippy::exhaustive_structs to `deny` as
workspace lints so the SemVer growth question is an explicit, reviewed
decision for every public type, and settle the existing surface against
a documented policy (specs/non-exhaustive-policy.md).

The level is `deny`, not `warn`: an undecided public type is a hard error
under a plain `cargo clippy`, so it fails locally while the type is being
written rather than relying on CI's `-D warnings`. openjd-cli (a binary)
and openjd-for-js (publish = false) are exempt at the crate level.

The rule: types that mirror a numbered OpenJD spec section grow by
extension RFC and are gated by the decode-time extension allowlist, so
they are marked #[non_exhaustive] (76 types) — e.g. template::JobTemplate,
HostRequirements, the parameter-definition/UserInterface families, plus
PathFormat, HostContext, and the template growth-axis enums. Types that
represent a decidable concept, are caller-constructed configuration, or
are the instantiated job:: model the sessions runtime constructs and
exhaustively matches (with no allowlist in front of them) stay closed
with an #[expect(..., reason = "...")] recording why (80 sites).

Key distinction, documented in the policy: template::* (deserialize-time
input a consumer reads) is non_exhaustive; job::* (instantiated model the
runtime executes) stays closed, so a new field is a compile error on the
runner rather than a silently ignored `_` arm — the same rule already
applied to the runtime state machines (ActionState, SessionState, ...).

Adds PathMappingRule::new and CallerLimits builder methods as the
supported construction path for the two non_exhaustive types that
callers legitimately build by hand, and migrates call sites. openjd-cli
(a binary) and openjd-for-js (publish = false) are exempted from the
lint at the crate level.

BREAKING CHANGE: many public enums and structs in openjd-expr,
openjd-model, openjd-sessions, and openjd-snapshots are now
#[non_exhaustive]. Downstream Rust consumers must add wildcard match
arms and use `..` patterns / the provided constructors instead of struct
literals. No functional behavior change.

Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
@crowecawcaw
crowecawcaw force-pushed the feat/non-exhaustive-lints branch from 021c145 to 9fc65c7 Compare August 4, 2026 00:07

@mwiebe mwiebe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not convinced that a blanket policy change like this is a good idea. When selecting exhaustive vs non-exhaustive before, I made individual judgements about it, and I think careful audits of those choices would be better than broad policy modification.

/// Path format (POSIX, Windows, or URI).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[non_exhaustive]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I left this exhaustive because supporting a new path format seems worth doing a breaking change for.

/// <https://github.com/OpenJobDescription/openjd-specifications/wiki/How-Jobs-Are-Run#path-mapping>
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We have OpenJobDescription/openjd-specifications#47 suggesting to add the destination path format here, which seems like a good idea to me. But, that would be a breaking change so I don't think non_exhaustive is right for this.

/// the previous split between `FunctionLibrary::with_host_context` and
/// `FunctionLibrary::with_unresolved_host_context`.
#[derive(Debug, Clone, Default)]
#[non_exhaustive]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why shouldn't this one be exhaustive?

PathFormat::Posix | PathFormat::Uri => '/',
// `PathFormat` is `#[non_exhaustive]`; POSIX-style `/` is the correct
// default for any future non-Windows path flavor.
_ => '/',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks worse to me than the way it was before, where adding a path format was a breaking change.

/// §5 Action
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the openjd model objects would be better served by being exhaustive so we get breaking changes when we adjust them.

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 high level idea was that changes to the OJD model are already gated behind the extensions list, so library consumers can control what they handle with extensions instead of the library version. If we leave these as exhaustive, every new extension will be a breaking change and will require consumers to update their code.

I guess it's a bit of an open question. The extensions list suggests extension support is intended to optional and non-breaking, but I could also see the case for breaking changes as a feature, so consumers don't accidentally drop functionality in a default case in a match.

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 spec contains the crux of the PR, curious about your thoughts on it. The individual changes flow out of it: https://github.com/crowecawcaw/openjd-rs/blob/9fc65c701f3a38c071acfa0cb7ae6100b3891a4a/specs/non-exhaustive-policy.md

@crowecawcaw

Copy link
Copy Markdown
Contributor Author

The intent of this PR was that every field must be intentionally assigned to either exhaustive or non-exhaustive, never defaulted, so we don't forget to choose a stance and ship the default wrongly. The enforcement mechanism is requiring either non-exhaustive or a lint rule exception for every public enum and interface. Agree that both stances have a place in this library.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants