Skip to content

Commit 57f65f4

Browse files
authored
fix(filter): apply defaults.tags.exclude to mappings with own tags (#90)
1 parent 794ec6f commit 57f65f4

4 files changed

Lines changed: 620 additions & 108 deletions

File tree

crates/ocync-sync/src/filter.rs

Lines changed: 200 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
5971
pub 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

Comments
 (0)