1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
use super::*;
impl Config {
/// The full crate universe: top-level `crates` plus every
/// `workspaces[].crates` entry, deduplicated by name (first-seen wins,
/// so a top-level entry shadows a same-named workspace entry).
///
/// Single source of the read-only "all crates that can carry per-crate
/// config" walk. Publisher registration, required/retain gate
/// collapsing, per-crate dispatch, requirement derivation,
/// `--crate`/`--all` selection, tool-need detection, artifact guards,
/// and default-naming decisions must all resolve through this walker so
/// a workspace-only crate carrying a publisher block is either visible
/// everywhere or nowhere — a consumer iterating `config.crates`
/// directly silently excludes workspace crates and hides their
/// publishes. Only two shapes may keep a raw chained walk: mutation
/// passes (`&mut` access — this walker hands out shared borrows) and
/// validation/diagnostics that must see every entry as written,
/// including the shadowed duplicates this walker dedups away.
pub fn crate_universe(&self) -> Vec<&CrateConfig> {
self.crate_universe_walk().0
}
/// The crates a run acts on: [`Self::crate_universe`] narrowed by the
/// run's `--crate` selection.
///
/// An EMPTY `selected` means every crate — that is what a single-crate or
/// lockstep run passes, and what `--all` resolves to. Every stage and
/// publisher that dispatches per crate answers the question here, so a
/// change to what `--crate` selects (by name today) reaches all of them
/// at once.
pub fn selected_crates(&self, selected: &[String]) -> Vec<&CrateConfig> {
self.crate_universe()
.into_iter()
.filter(|c| crate_is_selected(selected, &c.name))
.collect()
}
/// Borrow a crate by name from [`Self::crate_universe`] (top-level wins
/// on a name collision). The single by-name lookup every consumer must
/// use — a `config.crates.iter().find(...)` cannot see workspace-only
/// crates.
pub fn find_crate(&self, name: &str) -> Option<&CrateConfig> {
self.crate_universe().into_iter().find(|c| c.name == name)
}
/// Operator-facing warnings for crate-name collisions in the universe
/// where the colliding entries disagree on `path` — almost certainly a
/// config mistake (two distinct crates sharing a name). The legitimate
/// duplicate (the same crate referenced from both top-level and a
/// workspace) dedups silently. Emitted by the publish stage at entry so
/// the warning appears once per run rather than once per universe walk.
pub fn crate_universe_collision_warnings(&self) -> Vec<String> {
self.crate_universe_walk().1
}
/// The one walk both [`Self::crate_universe`] and
/// [`Self::crate_universe_collision_warnings`] derive from, so the
/// merge/dedup policy and its diagnostics cannot diverge.
fn crate_universe_walk(&self) -> (Vec<&CrateConfig>, Vec<String>) {
let mut out: Vec<&CrateConfig> = self.crates.iter().collect();
let mut warnings = Vec::new();
for ws in self.workspaces.iter().flatten() {
for c in &ws.crates {
if let Some(existing) = out.iter().find(|e| e.name == c.name) {
if existing.path != c.path {
warnings.push(format!(
"workspace '{}' crate '{}' path '{}' shadowed by \
prior entry with path '{}'; workspace entry dropped (name \
collision with different paths — likely a config mistake)",
ws.name, c.name, c.path, existing.path
));
}
continue;
}
out.push(c);
}
}
(out, warnings)
}
/// Whether the configured crate universe creates more than one tag family
/// — the per-crate shape, as opposed to single-crate or lockstep, where
/// every crate shares one `tag_template`.
pub fn mints_multiple_tag_families(&self) -> bool {
let mut seen: Vec<String> = Vec::new();
for c in self.crate_universe() {
let tmpl = c.tag_family_template();
if !seen.contains(&tmpl) {
seen.push(tmpl);
if seen.len() > 1 {
return true;
}
}
}
false
}
/// Every configured tag family EXCEPT `own_template`, deduped — the sibling
/// set a family matcher needs so a narrower family reclaims the tags a broader
/// one would otherwise swallow.
///
/// Keyed by TEMPLATE, not crate name: a caller holding a resolved family (a
/// nightly base taken from a sibling track, a previous-tag look-back) has no
/// crate name to key on, and `excluded_sibling_prefixes` only ever keeps
/// strictly-longer prefixes, so a sibling that shares this family contributes
/// nothing either way.
pub fn sibling_tag_families_of(&self, own_template: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for c in self.crate_universe() {
let tmpl = c.tag_family_template();
if tmpl != own_template && !out.contains(&tmpl) {
out.push(tmpl);
}
}
out
}
/// The tag prefix a repository releasing as one unit uses when the operator
/// names none.
pub const DEFAULT_TAG_PREFIX: &str = "v";
/// The prefix the repo-level tagging path prepends to a version: an explicit
/// `tag.tag_prefix`, else the canonical `v`. The one answer `tag`, `changelog`
/// and the derived tag family all read.
pub fn repo_tag_prefix(&self) -> &str {
self.tag
.as_ref()
.and_then(|t| t.tag_prefix.as_deref())
.unwrap_or(Self::DEFAULT_TAG_PREFIX)
}
/// Return the monorepo tag prefix, if configured.
///
/// Shorthand for `config.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())`.
pub fn monorepo_tag_prefix(&self) -> Option<&str> {
self.monorepo.as_ref().and_then(|m| m.tag_prefix.as_deref())
}
/// Return the monorepo working directory, if configured.
///
/// Shorthand for `config.monorepo.as_ref().and_then(|m| m.dir.as_deref())`.
pub fn monorepo_dir(&self) -> Option<&str> {
self.monorepo.as_ref().and_then(|m| m.dir.as_deref())
}
/// The build targets compiled when neither a per-build `targets` nor
/// `defaults.targets` is set: `defaults.targets` (when non-empty), else the
/// canonical `DEFAULT_TARGETS`. Single source of truth for the target-set
/// fallback — every target enumeration MUST resolve through this rather than
/// re-deriving the fallback, so they never diverge.
pub fn effective_default_targets(&self) -> Vec<String> {
self.defaults
.as_ref()
.and_then(|d| d.targets.clone())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| {
crate::target::DEFAULT_TARGETS
.iter()
.map(|s| (*s).to_string())
.collect()
})
}
/// The cross-compilation strategy applied to a crate that does not set its
/// own `cross:` — `defaults.cross`, else `Auto`. SSOT for the per-crate
/// strategy fallback.
pub fn default_cross_strategy(&self) -> CrossStrategy {
self.defaults
.as_ref()
.and_then(|d| d.cross.clone())
.unwrap_or(CrossStrategy::Auto)
}
// --- Project metadata defaulting helpers ---
//
// Publishers that expose homepage/license/description/maintainer fields
// fall back to these when their own field is unset, so a project only
// needs to declare metadata once. Resolution precedence (highest first):
//
// 1. the per-publisher override (the publisher's own config field)
// 2. a hand-written top-level `metadata:` YAML field
// 3. the value derived from the crate's `Cargo.toml [package]` table
// (populated by `populate_derived_metadata`)
//
// Steps 1 is enforced by the publisher's `or_else(|| cfg.meta_*_for(..))`
// chain; steps 2-3 are enforced inside the `meta_*_for` accessors. A
// publisher that knows which crate it is publishing for should call the
// crate-aware `meta_*_for(crate_name)` variant so workspace/per-crate
// configs resolve each crate's OWN Cargo.toml metadata. The crate-agnostic
// `meta_*` variants resolve the top-level `metadata:` block only (no
// Cargo.toml fallback) and exist for truly project-level callers.
/// Per-crate derived metadata for `crate_name`, if `Cargo.toml` supplied any.
fn derived_for(&self, crate_name: &str) -> Option<&MetadataConfig> {
self.derived_metadata.get(crate_name)
}
/// Name of the primary crate (first declared `crates:` entry, else the
/// first workspace crate). Used as the metadata-derivation source and
/// crate-name fallback for project-level publishers (e.g. top-level
/// `homebrew_casks:`, `npms:`) that are not bound to a single crate.
pub fn primary_crate_name(&self) -> Option<&str> {
self.crate_universe().first().map(|c| c.name.as_str())
}
/// Project homepage: top-level `metadata.homepage` wins, else the primary
/// crate's `Cargo.toml`-derived homepage. For project-level publishers
/// (top-level casks) with no owning crate.
pub fn meta_homepage_project(&self) -> Option<&str> {
self.meta_homepage()
.or_else(|| self.meta_homepage_for(self.primary_crate_name()?))
}
/// Project description: top-level `metadata.description` wins, else the
/// primary crate's `Cargo.toml`-derived description.
pub fn meta_description_project(&self) -> Option<&str> {
self.meta_description()
.or_else(|| self.meta_description_for(self.primary_crate_name()?))
}
/// Project source-repository URL: top-level `metadata.repository` wins, else
/// the primary crate's `Cargo.toml`-derived repository. Backs the
/// `{{ Metadata.Repository }}` template var.
pub fn meta_repository_project(&self) -> Option<&str> {
self.meta_repository()
.or_else(|| self.meta_repository_for(self.primary_crate_name()?))
}
/// Project license: top-level `metadata.license` wins, else the primary
/// crate's `Cargo.toml`-derived license. For the `{{ Metadata.License }}`
/// template var and project-level publishers with no owning crate.
pub fn meta_license_project(&self) -> Option<&str> {
self.meta_license()
.or_else(|| self.meta_license_for(self.primary_crate_name()?))
}
/// Project documentation URL: top-level `metadata.documentation` wins, else
/// the primary crate's `Cargo.toml`-derived documentation URL.
pub fn meta_documentation_project(&self) -> Option<&str> {
self.meta_documentation()
.or_else(|| self.meta_documentation_for(self.primary_crate_name()?))
}
/// Project homepage from `metadata.homepage` (top-level YAML only).
pub fn meta_homepage(&self) -> Option<&str> {
self.metadata.as_ref().and_then(|m| m.homepage.as_deref())
}
/// Project license from `metadata.license` (top-level YAML only).
pub fn meta_license(&self) -> Option<&str> {
self.metadata.as_ref().and_then(|m| m.license.as_deref())
}
/// Project source-repository URL from `metadata.repository` (top-level YAML only).
pub fn meta_repository(&self) -> Option<&str> {
self.metadata.as_ref().and_then(|m| m.repository.as_deref())
}
/// Project description from `metadata.description` (top-level YAML only).
pub fn meta_description(&self) -> Option<&str> {
self.metadata
.as_ref()
.and_then(|m| m.description.as_deref())
}
/// Project documentation URL from `metadata.documentation` (top-level YAML only).
pub fn meta_documentation(&self) -> Option<&str> {
self.metadata
.as_ref()
.and_then(|m| m.documentation.as_deref())
}
/// Project maintainers from `metadata.maintainers` (top-level YAML only).
pub fn meta_maintainers(&self) -> &[String] {
self.metadata
.as_ref()
.and_then(|m| m.maintainers.as_deref())
.unwrap_or(&[])
}
/// First maintainer as `Name <email>` or just `Name` (publisher convention).
/// Returns None when no maintainers are configured.
pub fn meta_first_maintainer(&self) -> Option<&str> {
self.meta_maintainers().first().map(|s| s.as_str())
}
/// Homepage for `crate_name`: top-level `metadata.homepage` wins, else the
/// value derived from the crate's `Cargo.toml [package]`.
pub fn meta_homepage_for(&self, crate_name: &str) -> Option<&str> {
self.meta_homepage()
.or_else(|| self.derived_for(crate_name)?.homepage.as_deref())
}
/// License for `crate_name`: top-level `metadata.license` wins, else the
/// crate's `Cargo.toml [package].license` (never synthesised from
/// `license-file`).
pub fn meta_license_for(&self, crate_name: &str) -> Option<&str> {
self.meta_license()
.or_else(|| self.derived_for(crate_name)?.license.as_deref())
}
/// Source-repository URL for `crate_name`: top-level `metadata.repository`
/// wins, else the crate's `Cargo.toml [package].repository`. Feeds the npm
/// `package.json` `repository` field so npm provenance validation (which
/// matches it against the OIDC-claimed repository) passes without requiring
/// the operator to restate the URL in the publisher config.
pub fn meta_repository_for(&self, crate_name: &str) -> Option<&str> {
self.meta_repository()
.or_else(|| self.derived_for(crate_name)?.repository.as_deref())
}
/// Description for `crate_name`: top-level `metadata.description` wins, else
/// the crate's `Cargo.toml [package].description`.
pub fn meta_description_for(&self, crate_name: &str) -> Option<&str> {
self.meta_description()
.or_else(|| self.derived_for(crate_name)?.description.as_deref())
}
/// Documentation URL for `crate_name`: top-level `metadata.documentation`
/// wins, else the crate's `Cargo.toml [package].documentation`.
pub fn meta_documentation_for(&self, crate_name: &str) -> Option<&str> {
self.meta_documentation()
.or_else(|| self.derived_for(crate_name)?.documentation.as_deref())
}
/// Maintainers for `crate_name`: top-level `metadata.maintainers` wins
/// (when non-empty), else the crate's `Cargo.toml [package].authors`.
pub fn meta_maintainers_for(&self, crate_name: &str) -> &[String] {
let top = self.meta_maintainers();
if !top.is_empty() {
return top;
}
self.derived_for(crate_name)
.and_then(|m| m.maintainers.as_deref())
.unwrap_or(&[])
}
/// First maintainer for `crate_name` as `Name <email>` or just `Name`.
pub fn meta_first_maintainer_for(&self, crate_name: &str) -> Option<&str> {
self.meta_maintainers_for(crate_name)
.first()
.map(|s| s.as_str())
}
/// Vendor / distributing-entity name for `crate_name`: the first
/// maintainer with any `<email>` suffix stripped (e.g.
/// `"Ada Lovelace <ada@x>"` → `"Ada Lovelace"`). `None` when no maintainer
/// is derivable or the result is empty, so a Vendor field is never emitted
/// blank. Reused by the rpm/deb Vendor and the OCI image `vendor` label.
pub fn meta_vendor_for(&self, crate_name: &str) -> Option<String> {
self.meta_first_maintainer_for(crate_name)
.and_then(maintainer_name_only)
}
/// Populate [`Config::derived_metadata`] by reading each crate's
/// `Cargo.toml [package]` table (description / license / homepage /
/// authors), so publishers resolve a plain Rust project's metadata without
/// requiring a top-level `metadata:` YAML block.
///
/// Covers every crate the config knows about: top-level `crates:` plus
/// every `workspaces[].crates[]`, so single-crate, workspace-lockstep, and
/// per-crate configs all populate. Each crate is read from
/// `<crate.path>/Cargo.toml` relative to `base_dir` (the directory the
/// config was loaded from / the monorepo working directory).
///
/// Idempotent and non-destructive: only fills entries; existing
/// `derived_metadata` keys are overwritten with a fresh read. Crates whose
/// `Cargo.toml` is missing or supplies nothing contribute an all-`None`
/// entry (harmless — the accessors treat it as "no value").
pub fn populate_derived_metadata(&mut self, base_dir: &std::path::Path) {
let crate_paths: Vec<(String, String)> = self
.crate_universe()
.into_iter()
.map(|c| (c.name.clone(), c.path.clone()))
.collect();
for (name, path) in crate_paths {
let crate_dir = base_dir.join(&path);
let derived = derive_metadata_from_cargo_toml(&crate_dir);
self.derived_metadata.insert(name, derived);
}
}
/// Fill `tag_template` for every crate that omits one, from the tag family the
/// repository as a whole releases under.
///
/// `anodizer tag` cuts one repo-level tag — `tag.tag_prefix` (default `v`) plus
/// the version — whenever the workspace releases as one unit. Left unfilled,
/// each crate's `tag_family_template()` answers `<name>-v{{ Version }}`
/// instead, so the release stage creates the release on a tag `tag` never cut
/// and crate selection matches nothing. Deriving it here gives `tag`, crate
/// selection, `bump`, `changelog` and the release stage one value to read.
///
/// A crate that names its own `tag_template` (directly or through
/// `defaults.crates.tag_template`) is never touched, and nothing is derived for
/// a config declaring `workspaces:` — that block is an explicit statement that
/// the repository releases several tracks, so the per-crate `<name>-v` families
/// are what keeps them apart.
///
/// Run after `defaults_merge::apply_defaults`, so an explicit
/// `defaults.crates.tag_template` has already been applied. Records the family in
/// `derived_tag_template` when at least one crate was filled.
pub fn populate_derived_tag_templates(&mut self, base_dir: &std::path::Path) {
if self.workspaces.as_ref().is_some_and(|w| !w.is_empty()) {
return;
}
let Some(prefix) = self.derived_repo_tag_prefix(base_dir) else {
return;
};
let template = format!("{prefix}{{{{ Version }}}}");
let mut filled = false;
for c in self.crates.iter_mut() {
// Empty-as-unset, the same predicate `tag_family_template` reads
// with: a crate written as `tag_template: ""` would otherwise skip
// every rung and fall to the per-crate `<name>-v` family while its
// siblings share the repo one.
if c.tag_template.as_deref().is_none_or(str::is_empty) {
c.tag_template = Some(template.clone());
filled = true;
}
}
if filled {
self.derived_tag_template = Some(template);
}
}
/// The repo-wide tag family prefix, when the repository has one: an explicit
/// `tag.tag_prefix`, else `v` for a Cargo lockstep workspace. `None` when
/// neither holds — nothing states that these crates share a tag.
fn derived_repo_tag_prefix(&self, base_dir: &std::path::Path) -> Option<String> {
// The raw `Option`, not `repo_tag_prefix()`: "unset" is the signal that
// the operator named nothing, and the default only applies once the
// Cargo manifest says the workspace releases as one unit.
if let Some(p) = self.tag.as_ref().and_then(|t| t.tag_prefix.as_deref()) {
return Some(p.to_string());
}
workspace_package_version(base_dir).map(|_| Self::DEFAULT_TAG_PREFIX.to_string())
}
/// Populate `depends_on` for every crate entry that OMITS it, by reading
/// the crate's `Cargo.toml` dependency tables (`[dependencies]`,
/// `[build-dependencies]`, and every `[target.'cfg(...)'.dependencies]`)
/// and matching against the real on-disk Cargo workspace's member names
/// ([`discover_cargo_workspace_member_names`]) — the same derivation
/// `anodizer init` performs at scaffold time
/// ([`derive_depends_on_from_cargo_toml`]), now re-run at every
/// config-load so a hand-maintained `crates:` list can never drift stale
/// behind the crate's real Cargo.toml dependencies.
/// An explicit `depends_on` (`Some(_)`) is a user override and is never
/// overwritten.
///
/// Covers every crate the config knows about (top-level `crates:` plus
/// every `workspaces[].crates[]`), mirroring
/// [`Self::populate_derived_metadata`]. A single-crate project (no
/// `crates:` at all) has an empty crate universe, so this is a no-op.
/// A project with no on-disk Cargo workspace (no root `Cargo.toml`, or
/// a plain single-package `Cargo.toml`) has only itself as a "member",
/// so derivation naturally yields no deps.
pub fn populate_derived_depends_on(&mut self, base_dir: &std::path::Path) {
let member_names = discover_cargo_workspace_member_names(base_dir);
if member_names.is_empty() {
return;
}
let derived: HashMap<String, Vec<String>> = self
.crate_universe()
.into_iter()
.filter(|c| c.depends_on.is_none())
.map(|c| {
let crate_dir = base_dir.join(&c.path);
let deps = derive_depends_on_from_cargo_toml(&crate_dir, &member_names);
(c.name.clone(), deps)
})
.collect();
if derived.is_empty() {
return;
}
for c in self.crates.iter_mut().chain(
self.workspaces
.iter_mut()
.flatten()
.flat_map(|ws| ws.crates.iter_mut()),
) {
if c.depends_on.is_none()
&& let Some(deps) = derived.get(&c.name)
{
c.depends_on = Some(deps.clone());
}
}
}
}
/// Whether a crate named `name` is in a run's `--crate` selection.
///
/// The predicate half of [`Config::selected_crates`], for the few walks whose
/// source is not the crate universe (an aggregate changelog set, a Cargo member
/// list). An EMPTY selection means every crate.
pub fn crate_is_selected(selected: &[String], name: &str) -> bool {
selected.is_empty() || selected.iter().any(|s| s == name)
}
#[cfg(test)]
mod tests {
use super::*;
fn krate(name: &str) -> CrateConfig {
CrateConfig {
name: name.to_string(),
path: ".".to_string(),
..Default::default()
}
}
/// An empty `--crate` selection is what a single-crate or lockstep run
/// passes, and it means every crate — a selection that narrowed to nothing
/// there would publish nothing at all.
#[test]
fn an_empty_selection_selects_every_crate() {
let config = Config {
crates: vec![krate("core"), krate("cli")],
..Default::default()
};
let names: Vec<&str> = config
.selected_crates(&[])
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(names, ["core", "cli"]);
}
/// A non-empty selection narrows to the named crates, in the universe's own
/// order, and a name that matches nothing simply selects nothing.
#[test]
fn a_named_selection_narrows_to_those_crates() {
let config = Config {
crates: vec![krate("core"), krate("cli"), krate("xtask")],
..Default::default()
};
let selected = vec!["xtask".to_string(), "core".to_string()];
let names: Vec<&str> = config
.selected_crates(&selected)
.iter()
.map(|c| c.name.as_str())
.collect();
assert_eq!(names, ["core", "xtask"]);
assert!(config.selected_crates(&["nope".to_string()]).is_empty());
}
/// A source fragment with every space that does not separate two word
/// characters removed, so `a . is_empty ( )` and `a.is_empty()` read the
/// same. The surviving spaces keep `if selected` from reading as one
/// identifier named `ifselected`.
fn flatten(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let is_word = |c: char| c.is_alphanumeric() || c == '_';
let mut out = String::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
if !c.is_whitespace() {
out.push(c);
continue;
}
let before = chars[..i].iter().rev().find(|c| !c.is_whitespace());
let after = chars[i + 1..].iter().find(|c| !c.is_whitespace());
if let (Some(&b), Some(&a)) = (before, after)
&& is_word(b)
&& is_word(a)
{
out.push(' ');
}
}
out
}
/// The identifier path immediately left of `text`'s end: `selected`,
/// `selected_crates`, `ctx.options.selected_crates`. Anything that cannot
/// be part of a path — an operator, a bracket, a space already stripped —
/// ends it.
fn identifier_path_before(text: &str) -> &str {
let start = text
.rfind(|c: char| !(c.is_alphanumeric() || c == '_' || c == '.'))
.map_or(0, |i| i + 1);
text[start..].trim_start_matches('.')
}
/// Whether a whitespace-stripped source fragment re-types the selection
/// predicate: an emptiness test on some path, followed by a membership
/// test on the SAME path, whose subject is a crate `name`. Either
/// polarity counts.
///
/// Keyed on the shape rather than on what the local binding is called. A
/// check that looked for the name `selected` was blind to the same
/// predicate written over `selected_crates` or over
/// `ctx.options.selected_crates`, which is how four sites survived the
/// sweep it was meant to close. The `.name` subject is what separates
/// this question from the other emptiness-then-membership pairs in the
/// tree — a publisher allowlist, a string suffix — which have their own
/// answers and must not be dragged onto this accessor.
fn retypes_the_selection_predicate(flat: &str) -> bool {
for separator in [".is_empty()||", ".is_empty()&&!"] {
for (idx, _) in flat.match_indices(separator) {
let path = identifier_path_before(&flat[..idx]);
if path.is_empty() {
continue;
}
let rest = &flat[idx + separator.len()..];
let Some(after) = rest.strip_prefix(path) else {
continue;
};
let membership =
after.starts_with(".contains(") || after.starts_with(".iter().any(");
if membership && after.contains(".name") {
return true;
}
}
}
false
}
/// The four sites that survived the first sweep, spelled as they were
/// before they were converted. Each must be visible to the shape check,
/// or the pin guards a naming habit instead of the rule.
#[test]
fn the_shape_check_sees_the_predicate_under_any_binding_name() {
for line in [
"if !selected_crates.is_empty() && !selected_crates.contains(&krate.name) {",
".filter(|c| selected_crates.is_empty() || selected_crates.contains(&c.name))",
"if !ctx.options.selected_crates.is_empty() \
&& !ctx.options.selected_crates.contains(&c.name)",
"if selected.is_empty() || selected.iter().any(|s| s == &c.name) {",
] {
assert!(
retypes_the_selection_predicate(&flatten(line)),
"the shape check cannot see: {line}"
);
}
assert!(
!retypes_the_selection_predicate(&flatten(
"if names.is_empty() || other.contains(&c.name) {"
)),
"two different paths are not the selection predicate"
);
}
/// "Is this crate in the run's selection" is one question with one answer.
/// It was re-typed at nearly fifty production sites, in two spellings and
/// two polarities, so a change to what `--crate` selects had to be made
/// fifty times. Every walk now asks [`Config::selected_crates`] or
/// [`crate_is_selected`]; this walk fails the moment a new one does not.
#[test]
fn the_selection_predicate_is_spelled_once() {
use crate::test_helpers::test_sources::{production_half, workspace_production_sources};
let sources = workspace_production_sources();
let this_file = std::path::Path::new(file!())
.file_name()
.expect("accessors.rs");
let mut strays: Vec<String> = Vec::new();
for source in &sources {
if source.file_name() == Some(this_file) {
continue;
}
let text = std::fs::read_to_string(source).expect("read source");
let production = production_half(&text);
let lines: Vec<&str> = production.lines().collect();
for (index, line) in lines.iter().enumerate() {
// rustfmt wraps a long condition, so the emptiness test and
// the membership test can span consecutive lines; joining
// the pair reads them as the one expression they are.
let joined = format!("{line}{}", lines.get(index + 1).unwrap_or(&""));
if retypes_the_selection_predicate(&flatten(&joined)) {
strays.push(format!("{}:{}", source.display(), index + 1));
}
}
}
assert!(
strays.is_empty(),
"ask `Config::selected_crates` or `config::crate_is_selected` \
instead of re-typing the selection predicate: {strays:#?}"
);
}
}