@@ -53,21 +53,39 @@ fn system_exclude_set() -> &'static GlobSet {
5353///
5454/// All stages are AND (narrowing). Each stage reduces the set:
5555/// `glob → semver → exclude → sort → latest → min_tags`.
56- /// Tags matching `include:` bypass the glob/semver pipeline (but are still
57- /// subject to user `exclude:`).
56+ /// Tags matching `include:` bypass the glob/semver pipeline AND the soft
57+ /// exclude tier (built-in + defaults). They are still subject to mapping
58+ /// `exclude:` (the hard tier).
59+ ///
60+ /// # Exclude tiers
61+ ///
62+ /// Exclusion has two tiers:
63+ ///
64+ /// - **Soft tier** (built-in `SYSTEM_EXCLUDE` + caller-provided
65+ /// [`defaults_exclude`](Self::defaults_exclude)): bypassable by
66+ /// `include:`. Use for project-wide opinions like "drop `*-dev` unless
67+ /// I say otherwise on a specific mapping."
68+ /// - **Hard tier** ([`exclude`](Self::exclude)): blocks `include:` on the
69+ /// same config. Use for absolute per-mapping denies.
5870#[ derive( Debug , Default ) ]
5971pub struct FilterConfig {
6072 /// Always-include glob patterns. Tags matching any pattern survive
61- /// `glob:`/`semver:` filters and the system- exclude defaults. Not
62- /// subject to `sort:` or `latest:` truncation (those only cap the
63- /// `glob:`/`semver:` pipeline side). Subject to user `exclude:`. Same
64- /// syntax as `exclude:`.
73+ /// `glob:`/`semver:` filters and the soft exclude tier (system + defaults).
74+ /// Not subject to `sort:` or `latest:` truncation (those only cap the
75+ /// `glob:`/`semver:` pipeline side). Subject to mapping
76+ /// [`exclude`](Self::exclude). Same glob syntax as `exclude:`.
6577 pub include : Vec < String > ,
6678 /// Glob patterns (OR semantics). An empty list passes all tags through.
6779 pub glob : Vec < String > ,
6880 /// Semver version range constraint (e.g. `>=1.18.0`).
6981 pub semver : Option < String > ,
70- /// Exclude patterns (OR deny).
82+ /// Soft-tier exclude patterns inherited from a `defaults:` block.
83+ /// Bypassed by [`include`](Self::include), unlike
84+ /// [`exclude`](Self::exclude). Behaves the same as the built-in
85+ /// `SYSTEM_EXCLUDE` list.
86+ pub defaults_exclude : Vec < String > ,
87+ /// Hard-tier exclude patterns (OR deny). Blocks
88+ /// [`include`](Self::include) on the same config.
7189 pub exclude : Vec < String > ,
7290 /// Sort order.
7391 pub sort : Option < SortOrder > ,
@@ -125,8 +143,13 @@ impl FilterConfig {
125143 if let Some ( ref s) = self . semver {
126144 parts. push ( format ! ( "semver {s}" ) ) ;
127145 }
128- if !self . exclude . is_empty ( ) {
129- parts. push ( format ! ( "exclude {}" , self . exclude. join( "," ) ) ) ;
146+ if !self . exclude . is_empty ( ) || !self . defaults_exclude . is_empty ( ) {
147+ // Defaults- and mapping-tier patterns share one summary clause.
148+ // Dry-run carries the tier attribution; the INFO line stays tight.
149+ let mut combined: Vec < & str > =
150+ self . defaults_exclude . iter ( ) . map ( String :: as_str) . collect ( ) ;
151+ combined. extend ( self . exclude . iter ( ) . map ( String :: as_str) ) ;
152+ parts. push ( format ! ( "exclude {}" , combined. join( "," ) ) ) ;
130153 }
131154 if !self . include . is_empty ( ) {
132155 parts. push ( format ! ( "include {}" , self . include. join( "," ) ) ) ;
@@ -170,6 +193,11 @@ impl FilterConfig {
170193 } else {
171194 Some ( build_glob_set ( & self . exclude ) ?)
172195 } ;
196+ let defaults_exclude_set = if self . defaults_exclude . is_empty ( ) {
197+ None
198+ } else {
199+ Some ( build_glob_set ( & self . defaults_exclude ) ?)
200+ } ;
173201 let sys_exclude = system_exclude_set ( ) ;
174202
175203 let include_kept_refs: Vec < & str > = if self . include . is_empty ( ) {
@@ -243,46 +271,69 @@ impl FilterConfig {
243271 }
244272 }
245273
274+ // Exclude stage: three tiers, evaluated in order so the first match
275+ // attributes the drop. Order doesn't change kept tags (they're all
276+ // OR-deny); it only decides which DropKind a tag is reported under.
277+ // Mapping (hard) is checked first because it represents the most
278+ // specific user intent.
246279 let before_exclude = pipeline. len ( ) ;
247- let mut user_dropped: Vec < String > = Vec :: new ( ) ;
248- let mut sys_dropped: Vec < String > = Vec :: new ( ) ;
280+ let mut mapping_dropped: Vec < String > = Vec :: new ( ) ;
281+ let mut defaults_dropped: Vec < String > = Vec :: new ( ) ;
282+ let mut builtin_dropped: Vec < String > = Vec :: new ( ) ;
249283 pipeline. retain ( |t| {
250284 if let Some ( ref s) = user_exclude_set {
251285 if s. is_match ( t) {
252286 if track {
253- user_dropped. push ( ( * t) . to_owned ( ) ) ;
287+ mapping_dropped. push ( ( * t) . to_owned ( ) ) ;
288+ }
289+ return false ;
290+ }
291+ }
292+ if let Some ( ref s) = defaults_exclude_set {
293+ if s. is_match ( t) {
294+ if track {
295+ defaults_dropped. push ( ( * t) . to_owned ( ) ) ;
254296 }
255297 return false ;
256298 }
257299 }
258300 if sys_exclude. is_match ( t) {
259301 if track {
260- sys_dropped . push ( ( * t) . to_owned ( ) ) ;
302+ builtin_dropped . push ( ( * t) . to_owned ( ) ) ;
261303 }
262304 return false ;
263305 }
264306 true
265307 } ) ;
266308 if track {
267- if !user_dropped . is_empty ( ) {
309+ if !mapping_dropped . is_empty ( ) {
268310 drop_reasons. push ( DropReason {
269- kind : DropKind :: UserExclude {
311+ kind : DropKind :: MappingExclude {
270312 patterns : self . exclude . clone ( ) ,
271313 } ,
272- count : user_dropped . len ( ) ,
273- samples : user_dropped ,
314+ count : mapping_dropped . len ( ) ,
315+ samples : mapping_dropped ,
274316 } ) ;
275317 }
276- if !sys_dropped . is_empty ( ) {
318+ if !defaults_dropped . is_empty ( ) {
277319 drop_reasons. push ( DropReason {
278- kind : DropKind :: SystemExclude ,
279- count : sys_dropped. len ( ) ,
280- samples : sys_dropped,
320+ kind : DropKind :: DefaultsExclude {
321+ patterns : self . defaults_exclude . clone ( ) ,
322+ } ,
323+ count : defaults_dropped. len ( ) ,
324+ samples : defaults_dropped,
325+ } ) ;
326+ }
327+ if !builtin_dropped. is_empty ( ) {
328+ drop_reasons. push ( DropReason {
329+ kind : DropKind :: BuiltinExclude ,
330+ count : builtin_dropped. len ( ) ,
331+ samples : builtin_dropped,
281332 } ) ;
282333 }
283334 if before_exclude != pipeline. len ( ) {
284335 pipeline_stages. push ( StageDelta {
285- label : "exclude (user + system )" . to_string ( ) ,
336+ label : "exclude (mapping + defaults + built-in )" . to_string ( ) ,
286337 count_in : before_exclude,
287338 count_out : pipeline. len ( ) ,
288339 } ) ;
@@ -431,13 +482,21 @@ pub enum DropKind {
431482 /// The configured version range string, e.g. `">=1.18.0"`.
432483 range : String ,
433484 } ,
434- /// Tag matched a user-configured `exclude:` pattern.
435- UserExclude {
436- /// User-configured exclude patterns (one or more).
485+ /// Tag matched a per-mapping `exclude:` pattern (hard tier; blocks
486+ /// `include:` on the same mapping).
487+ MappingExclude {
488+ /// Mapping-level exclude patterns (one or more).
489+ patterns : Vec < String > ,
490+ } ,
491+ /// Tag matched a `defaults.tags.exclude:` pattern (soft tier; bypassable
492+ /// by `include:`).
493+ DefaultsExclude {
494+ /// Defaults-level exclude patterns (one or more).
437495 patterns : Vec < String > ,
438496 } ,
439- /// Tag matched the built-in prerelease exclude list.
440- SystemExclude ,
497+ /// Tag matched the built-in prerelease exclude list (soft tier;
498+ /// bypassable by `include:`).
499+ BuiltinExclude ,
441500 /// Tag fell off the end of the `latest: N` truncation.
442501 LatestCap {
443502 /// The configured `latest: N` value.
@@ -450,10 +509,13 @@ impl fmt::Display for DropKind {
450509 match self {
451510 Self :: Glob { patterns } => write ! ( f, "glob {}" , patterns_label( patterns) ) ,
452511 Self :: Semver { range } => write ! ( f, "semver \" {range}\" " ) ,
453- Self :: UserExclude { patterns } => {
454- write ! ( f, "user-exclude {}" , patterns_label( patterns) )
512+ Self :: MappingExclude { patterns } => {
513+ write ! ( f, "exclude (mapping) {}" , patterns_label( patterns) )
514+ }
515+ Self :: DefaultsExclude { patterns } => {
516+ write ! ( f, "exclude (defaults) {}" , patterns_label( patterns) )
455517 }
456- Self :: SystemExclude => f. write_str ( "system- exclude" ) ,
518+ Self :: BuiltinExclude => f. write_str ( "exclude (built-in) " ) ,
457519 Self :: LatestCap { limit } => write ! ( f, "over latest={limit} limit" ) ,
458520 }
459521 }
@@ -1178,6 +1240,108 @@ mod tests {
11781240 assert ! ( !result. contains( & "latest" . to_string( ) ) ) ;
11791241 }
11801242
1243+ // - defaults_exclude tier --------------------------------------------
1244+
1245+ /// `defaults_exclude` drops tags from the pipeline just like the
1246+ /// built-in system exclude. The mapping has no `exclude:` of its own,
1247+ /// so this proves defaults flow through.
1248+ #[ test]
1249+ fn defaults_exclude_drops_tags ( ) {
1250+ let tags = vec ! [ "1.0.0" , "1.0.0-dev" , "1.0.0-r0" ] ;
1251+ let config = FilterConfig {
1252+ defaults_exclude : vec ! [ "*-dev" . into( ) , "*-r[0-9]*" . into( ) ] ,
1253+ ..FilterConfig :: default ( )
1254+ } ;
1255+ let result = config. apply ( & tags) . unwrap ( ) ;
1256+ assert_eq ! ( result, vec![ "1.0.0" . to_string( ) ] ) ;
1257+ }
1258+
1259+ /// `include:` rescues a tag that `defaults_exclude` would drop. This is
1260+ /// the user-facing escape hatch for the project-wide exclude floor.
1261+ #[ test]
1262+ fn include_overrides_defaults_exclude ( ) {
1263+ let tags = vec ! [ "latest" , "latest-dev" , "1.0.0" , "1.0.0-dev" ] ;
1264+ let config = FilterConfig {
1265+ include : vec ! [ "latest-dev" . into( ) ] ,
1266+ defaults_exclude : vec ! [ "*-dev" . into( ) ] ,
1267+ ..FilterConfig :: default ( )
1268+ } ;
1269+ let result = config. apply ( & tags) . unwrap ( ) ;
1270+ assert ! ( result. contains( & "latest" . to_string( ) ) ) ;
1271+ assert ! (
1272+ result. contains( & "latest-dev" . to_string( ) ) ,
1273+ "include should rescue latest-dev from defaults_exclude"
1274+ ) ;
1275+ assert ! ( result. contains( & "1.0.0" . to_string( ) ) ) ;
1276+ // 1.0.0-dev does not match include and is dropped by defaults_exclude.
1277+ assert ! ( !result. contains( & "1.0.0-dev" . to_string( ) ) ) ;
1278+ }
1279+
1280+ /// Mapping-level `exclude:` is the hard tier: it blocks `include:` even
1281+ /// when a `defaults_exclude` is also configured. Negative assertion
1282+ /// preserving existing semantics.
1283+ #[ test]
1284+ fn mapping_exclude_blocks_include_with_defaults_set ( ) {
1285+ let tags = vec ! [ "latest" , "latest-dev" ] ;
1286+ let config = FilterConfig {
1287+ include : vec ! [ "latest" . into( ) , "latest-dev" . into( ) ] ,
1288+ defaults_exclude : vec ! [ "*-dev" . into( ) ] ,
1289+ exclude : vec ! [ "latest" . into( ) ] ,
1290+ ..FilterConfig :: default ( )
1291+ } ;
1292+ let result = config. apply ( & tags) . unwrap ( ) ;
1293+ // mapping.exclude = ["latest"] blocks include of "latest"
1294+ assert ! ( !result. contains( & "latest" . to_string( ) ) ) ;
1295+ // include still rescues latest-dev (matches defaults_exclude soft tier only)
1296+ assert ! ( result. contains( & "latest-dev" . to_string( ) ) ) ;
1297+ }
1298+
1299+ /// `defaults_exclude` and `exclude` (mapping) both apply: their union
1300+ /// drops tags. Stacking, not replacement.
1301+ #[ test]
1302+ fn defaults_and_mapping_exclude_stack ( ) {
1303+ let tags = vec ! [ "1.0.0" , "1.0.0-dev" , "1.0.0-slim" ] ;
1304+ let config = FilterConfig {
1305+ defaults_exclude : vec ! [ "*-dev" . into( ) ] ,
1306+ exclude : vec ! [ "*-slim" . into( ) ] ,
1307+ ..FilterConfig :: default ( )
1308+ } ;
1309+ let result = config. apply ( & tags) . unwrap ( ) ;
1310+ assert_eq ! ( result, vec![ "1.0.0" . to_string( ) ] ) ;
1311+ }
1312+
1313+ /// Dry-run attribution: defaults-tier drops surface as
1314+ /// `DropKind::DefaultsExclude`, distinct from `MappingExclude` and
1315+ /// `BuiltinExclude`. The formatter relies on the variant to render
1316+ /// `(defaults)` / `(mapping)` / `(built-in)`.
1317+ #[ test]
1318+ fn report_attributes_defaults_exclude_separately ( ) {
1319+ let tags = vec ! [ "1.0.0" , "1.0.0-dev" , "1.0.0-rc1" , "1.0.0-slim" ] ;
1320+ let config = FilterConfig {
1321+ defaults_exclude : vec ! [ "*-dev" . into( ) ] ,
1322+ exclude : vec ! [ "*-slim" . into( ) ] ,
1323+ ..FilterConfig :: default ( )
1324+ } ;
1325+ let filtered = config. apply_with_report ( & tags) . unwrap ( ) ;
1326+ let kinds: Vec < & DropKind > = filtered. report . dropped . iter ( ) . map ( |d| & d. kind ) . collect ( ) ;
1327+ assert ! (
1328+ kinds
1329+ . iter( )
1330+ . any( |k| matches!( k, DropKind :: DefaultsExclude { .. } ) ) ,
1331+ "missing DefaultsExclude in {kinds:?}"
1332+ ) ;
1333+ assert ! (
1334+ kinds
1335+ . iter( )
1336+ . any( |k| matches!( k, DropKind :: MappingExclude { .. } ) ) ,
1337+ "missing MappingExclude in {kinds:?}"
1338+ ) ;
1339+ assert ! (
1340+ kinds. iter( ) . any( |k| matches!( k, DropKind :: BuiltinExclude ) ) ,
1341+ "missing BuiltinExclude (1.0.0-rc1 should hit it) in {kinds:?}"
1342+ ) ;
1343+ }
1344+
11811345 #[ test]
11821346 fn latest_n_does_not_cap_include ( ) {
11831347 // Pipeline has 5 candidates; latest:2 should keep only the top 2 of
@@ -1398,12 +1562,12 @@ mod tests {
13981562 assert ! (
13991563 kinds
14001564 . iter( )
1401- . any( |k| matches!( k, DropKind :: UserExclude { .. } ) ) ,
1402- "missing UserExclude in {kinds:?}"
1565+ . any( |k| matches!( k, DropKind :: MappingExclude { .. } ) ) ,
1566+ "missing MappingExclude in {kinds:?}"
14031567 ) ;
14041568 assert ! (
1405- kinds. iter( ) . any( |k| matches!( k, DropKind :: SystemExclude ) ) ,
1406- "missing SystemExclude in {kinds:?}"
1569+ kinds. iter( ) . any( |k| matches!( k, DropKind :: BuiltinExclude ) ) ,
1570+ "missing BuiltinExclude in {kinds:?}"
14071571 ) ;
14081572 }
14091573
@@ -1494,6 +1658,7 @@ mod tests {
14941658 include : vec ! [ "latest" . into( ) ] ,
14951659 glob : vec ! [ "v*" . into( ) ] ,
14961660 semver : Some ( "^1" . into ( ) ) ,
1661+ defaults_exclude : vec ! [ "*-dev" . into( ) ] ,
14971662 exclude : vec ! [ "nightly" . into( ) ] ,
14981663 sort : Some ( SortOrder :: Alpha ) ,
14991664 latest : Some ( 3 ) ,
@@ -1504,7 +1669,7 @@ mod tests {
15041669 "include latest" ,
15051670 "glob v*" ,
15061671 "semver ^1" ,
1507- "exclude nightly" ,
1672+ "exclude *-dev, nightly" ,
15081673 "sort alpha" ,
15091674 "latest=3" ,
15101675 "min_tags=2" ,
0 commit comments