trusty-memory 0.1.73

MCP server (stdio + HTTP/SSE) for trusty-memory
Documentation
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
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
//! Automatic project alias discovery.
//!
//! Why: Projects have implicit shorthand (cargo package names that differ from
//! their directory, binary names that differ from packages, common first-
//! letter abbreviations, repo short names) that should be surfaced
//! automatically as `is_alias_for` triples without requiring users to call
//! `add_alias` manually. The model can then resolve "tga" → "trusty-git-
//! analytics" the first time it sees the shorthand, instead of mis-matching it
//! against unrelated KG entries.
//! What: Scans the given project root for Cargo workspace structure, git
//! remote configuration, and other project signals; returns a flat list of
//! `(short, full, source)` discoveries. The MCP `discover_aliases` tool feeds
//! these into the palace KG (deduping against active triples) and rebuilds
//! the prompt cache.
//! Test: Unit tests in this module exercise each discovery source against
//! fixture directories and the live workspace root (cwd).

use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

/// Where a discovered alias was inferred from.
///
/// Why: Surfaced through the MCP tool response so operators can audit *why*
/// a particular alias landed in the KG (and which signal to trust). Also
/// serialised into the triple's `provenance` field so retraction tooling can
/// distinguish auto-discovered facts from hand-asserted ones.
/// What: `Serialize` for direct JSON emission; `Debug` for tracing logs.
/// Test: covered indirectly through `discover_project_aliases` tests.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiscoverySource {
    /// `[package].name` differs from the containing directory name.
    CargoPackageName,
    /// `[[bin]].name` differs from `[package].name`.
    CargoBinaryName,
    /// First-letter abbreviation of a hyphenated package name is globally
    /// unique within the workspace.
    FirstLetterAbbrev,
    /// Short name extracted from the `origin` remote URL in `.git/config`.
    GitRemote,
}

impl DiscoverySource {
    /// Stable string representation for triple provenance + JSON.
    ///
    /// Why: `serde_json::to_string` on the enum yields `"CargoPackageName"`,
    /// but the triple's `provenance` field is plain text — we want a single
    /// canonical spelling that round-trips cleanly.
    /// What: lowercase, snake-case-ish identifiers matching the variant names.
    /// Test: indirectly via `discover_and_assert` triples.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::CargoPackageName => "cargo_package_name",
            Self::CargoBinaryName => "cargo_binary_name",
            Self::FirstLetterAbbrev => "first_letter_abbrev",
            Self::GitRemote => "git_remote",
        }
    }
}

/// A single discovered alias mapping.
///
/// Why: Returned by `discover_project_aliases` and forwarded verbatim to the
/// MCP tool response so callers can see exactly what would be (or was)
/// asserted.
/// What: `short` is the subject ("tga"); `full` is the object
/// ("trusty-git-analytics"); `source` records the discovery signal.
/// Test: each discovery source has a dedicated unit test asserting the
/// resulting `AliasDiscovery` shape.
#[derive(Debug, Clone, Serialize)]
pub struct AliasDiscovery {
    pub short: String,
    pub full: String,
    pub source: DiscoverySource,
}

/// Scan `project_root` for alias signals and return every discovery found.
///
/// Why: One entry point keeps the orchestration logic in the MCP tool simple
/// — it just calls this and decides what to assert.
/// What: Runs each discovery source in order (Cargo workspace, then Cargo
/// single-crate fallback, then git remote, then first-letter abbreviations
/// derived from the cargo discoveries). Deduplicates `(short, full)` pairs
/// within the returned list so the first source wins.
/// Test: `discovers_trusty_git_analytics_alias`,
/// `first_letter_abbrev_tm_for_trusty_memory`,
/// `no_duplicate_short_names_in_results`.
pub async fn discover_project_aliases(project_root: &Path) -> Result<Vec<AliasDiscovery>> {
    let root = project_root.to_path_buf();
    tokio::task::spawn_blocking(move || discover_blocking(&root))
        .await
        .context("join discover_project_aliases")?
}

/// Blocking implementation of [`discover_project_aliases`].
///
/// Why: All work here is filesystem + TOML parsing, which is naturally
/// blocking. Splitting the async wrapper out keeps the algorithm
/// straightforward and unit-testable without a runtime.
/// What: Reads the root `Cargo.toml`, expands workspace members, scans each
/// member's `Cargo.toml`, then walks git config. Returns deduplicated
/// discoveries.
/// Test: exercised by every test in this module (most call it directly).
fn discover_blocking(project_root: &Path) -> Result<Vec<AliasDiscovery>> {
    let mut discoveries: Vec<AliasDiscovery> = Vec::new();
    let mut seen_pairs: HashSet<(String, String)> = HashSet::new();

    // Collect (package_name, dir_name) pairs so the first-letter pass can
    // see every package in the workspace at once.
    let mut packages: Vec<(String, String)> = Vec::new();

    let root_manifest = project_root.join("Cargo.toml");
    if root_manifest.is_file() {
        match std::fs::read_to_string(&root_manifest)
            .context("read root Cargo.toml")
            .and_then(|s| toml::from_str::<toml::Value>(&s).context("parse root Cargo.toml"))
        {
            Ok(root_toml) => {
                let members = workspace_members(&root_toml);
                if !members.is_empty() {
                    // Workspace mode.
                    for member in expand_members(project_root, &members) {
                        scan_member(&member, &mut discoveries, &mut seen_pairs, &mut packages);
                    }
                } else if root_toml.get("package").is_some() {
                    // Single-crate fallback: treat the root manifest as the
                    // only "member".
                    scan_member(
                        project_root,
                        &mut discoveries,
                        &mut seen_pairs,
                        &mut packages,
                    );
                }
            }
            Err(e) => {
                tracing::warn!("discovery: skipping root Cargo.toml: {e:#}");
            }
        }
    }

    // Phase 2: first-letter abbreviations for hyphenated package names that
    // produce a globally-unique abbreviation. Uniqueness is computed across
    // the union of every package name AND every abbreviation derived in
    // this pass — so a package whose own name is the same as another
    // package's abbreviation cannot collide with it.
    add_first_letter_abbreviations(&packages, &mut discoveries, &mut seen_pairs);

    // Phase 3: git remote short name.
    if let Some(d) = discover_git_remote(project_root) {
        push_unique(&mut discoveries, &mut seen_pairs, d);
    }

    Ok(discoveries)
}

/// Extract the `[workspace] members = [...]` patterns from a parsed root
/// `Cargo.toml`.
///
/// Why: Workspaces always live under a top-level `[workspace]` table with a
/// `members` array of glob patterns; reading them at parse time keeps the
/// downstream expansion code unaware of TOML.
/// What: Returns the raw pattern strings (typically `"crates/*"`). An absent
/// or malformed `[workspace]` yields an empty `Vec`.
/// Test: covered by `discovers_trusty_git_analytics_alias` (which exercises
/// this against the live root manifest).
fn workspace_members(root_toml: &toml::Value) -> Vec<String> {
    root_toml
        .get("workspace")
        .and_then(|w| w.get("members"))
        .and_then(|m| m.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(|s| s.to_string()))
                .collect()
        })
        .unwrap_or_default()
}

/// Expand workspace member patterns into concrete directories.
///
/// Why: Cargo permits glob patterns (`crates/*`, `vendor/*/sdk`) in
/// `workspace.members`; we don't pull in the `glob` crate, so a minimal
/// expansion handles the canonical "single trailing `*`" pattern that every
/// workspace in this repo uses, with fallback to a literal directory.
/// What: For each pattern: if it ends with `/*`, list every immediate
/// subdirectory; otherwise treat it as a literal relative path. Skips entries
/// without a `Cargo.toml`.
/// Test: indirectly via `discovers_trusty_git_analytics_alias` (live workspace
/// expansion).
fn expand_members(root: &Path, patterns: &[String]) -> Vec<PathBuf> {
    let mut out = Vec::new();
    for pattern in patterns {
        if let Some(prefix) = pattern.strip_suffix("/*") {
            let dir = root.join(prefix);
            let Ok(entries) = std::fs::read_dir(&dir) else {
                continue;
            };
            for entry in entries.flatten() {
                let path = entry.path();
                if path.is_dir() && path.join("Cargo.toml").is_file() {
                    out.push(path);
                }
            }
        } else {
            let path = root.join(pattern);
            if path.is_dir() && path.join("Cargo.toml").is_file() {
                out.push(path);
            }
        }
    }
    out
}

/// Scan one workspace member directory for cargo-derived aliases.
///
/// Why: Each member can contribute up to two aliases (package-name vs dir
/// name, binary-name vs package name). Centralising the per-member logic
/// lets the caller stay focused on iteration / expansion.
/// What: Reads `<member>/Cargo.toml`, extracts `[package].name`, then walks
/// every `[[bin]]` entry. Pushes one `CargoPackageName` discovery when the
/// package name differs from the directory, and one `CargoBinaryName`
/// discovery per binary whose name differs from the package. Tracks every
/// package in `packages` so the first-letter pass can see the full set.
/// Test: `scan_member_emits_package_and_binary_aliases`.
fn scan_member(
    member_dir: &Path,
    discoveries: &mut Vec<AliasDiscovery>,
    seen_pairs: &mut HashSet<(String, String)>,
    packages: &mut Vec<(String, String)>,
) {
    let manifest = member_dir.join("Cargo.toml");
    let Ok(raw) = std::fs::read_to_string(&manifest) else {
        return;
    };
    let Ok(parsed) = toml::from_str::<toml::Value>(&raw) else {
        tracing::warn!("discovery: failed to parse {}", manifest.display());
        return;
    };

    let dir_name = member_dir
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_string();
    if dir_name.is_empty() {
        return;
    }

    let package_name = parsed
        .get("package")
        .and_then(|p| p.get("name"))
        .and_then(|n| n.as_str())
        .map(|s| s.to_string());

    if let Some(ref pkg) = package_name {
        packages.push((pkg.clone(), dir_name.clone()));
        if pkg != &dir_name {
            push_unique(
                discoveries,
                seen_pairs,
                AliasDiscovery {
                    short: pkg.clone(),
                    full: dir_name.clone(),
                    source: DiscoverySource::CargoPackageName,
                },
            );
        }
    }

    if let Some(bins) = parsed.get("bin").and_then(|b| b.as_array()) {
        let pkg_for_bin = package_name.as_deref().unwrap_or(&dir_name).to_string();
        for bin in bins {
            if let Some(bin_name) = bin.get("name").and_then(|n| n.as_str()) {
                if bin_name != pkg_for_bin {
                    push_unique(
                        discoveries,
                        seen_pairs,
                        AliasDiscovery {
                            short: bin_name.to_string(),
                            full: pkg_for_bin.clone(),
                            source: DiscoverySource::CargoBinaryName,
                        },
                    );
                }
            }
        }
    }
}

/// Compute first-letter abbreviations for hyphenated package names and add
/// the ones that are globally unique within the workspace.
///
/// Why: Operators routinely refer to crates by their initials ("tm" for
/// `trusty-memory`, "tga" for `trusty-git-analytics`). Surfacing these
/// automatically — but only when there's no ambiguity — avoids polluting the
/// prompt with collisions like `tmc` (which could be `trusty-mpm-cli` or
/// `trusty-mpm-core`).
/// What: Splits each package name on `-`, takes the first letter of every
/// segment; counts how many distinct full names each abbreviation maps to.
/// Emits a `FirstLetterAbbrev` discovery only for abbreviations that map to
/// exactly one full name AND don't equal that full name AND don't collide
/// with an existing package name (which would suggest a different crate).
/// Test: `first_letter_abbrev_tm_for_trusty_memory`,
/// `first_letter_abbrev_skips_ambiguous`.
fn add_first_letter_abbreviations(
    packages: &[(String, String)],
    discoveries: &mut Vec<AliasDiscovery>,
    seen_pairs: &mut HashSet<(String, String)>,
) {
    let package_name_set: HashSet<&str> = packages.iter().map(|(p, _)| p.as_str()).collect();

    // abbrev → set of full package names that produce it.
    let mut groups: HashMap<String, Vec<&str>> = HashMap::new();
    for (pkg, _dir) in packages {
        if !pkg.contains('-') {
            continue;
        }
        let abbrev: String = pkg
            .split('-')
            .filter_map(|seg| seg.chars().next())
            .collect();
        if abbrev.len() < 2 {
            continue;
        }
        groups.entry(abbrev).or_default().push(pkg.as_str());
    }

    for (abbrev, fulls) in groups {
        if fulls.len() != 1 {
            continue;
        }
        let full = fulls[0];
        if abbrev == full {
            continue;
        }
        // Don't shadow an existing package name. e.g. if "tm" were itself a
        // package name, we wouldn't want to also assert "tm → trusty-memory".
        if package_name_set.contains(abbrev.as_str()) {
            continue;
        }
        push_unique(
            discoveries,
            seen_pairs,
            AliasDiscovery {
                short: abbrev,
                full: full.to_string(),
                source: DiscoverySource::FirstLetterAbbrev,
            },
        );
    }
}

/// Read `.git/config` and extract the short repo name from `origin`.
///
/// Why: Most repos refer to themselves by the trailing path component of the
/// origin URL ("trusty-tools"), which is rarely the same as the working tree
/// directory name when checked out under a non-default path. Surfacing it as
/// an alias for itself isn't useful, but surfacing the workspace dir name as
/// the canonical full name for the short repo name is — e.g. when working
/// inside a worktree directory the model still knows "trusty-tools" refers
/// to the project.
/// What: Greps `.git/config` for the `[remote "origin"] url = ...` line,
/// strips `.git`, takes the last `/`-separated component. Emits a
/// `GitRemote` discovery only when the short name differs from the directory
/// name and the directory name is non-empty.
/// Test: `git_remote_extracts_short_name_from_origin_url`.
fn discover_git_remote(project_root: &Path) -> Option<AliasDiscovery> {
    let config_path = project_root.join(".git").join("config");
    let raw = std::fs::read_to_string(&config_path).ok()?;
    let url = extract_origin_url(&raw)?;
    let short = short_repo_name(&url)?;
    let dir_name = project_root
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_string();
    if dir_name.is_empty() || short == dir_name {
        return None;
    }
    Some(AliasDiscovery {
        short,
        full: dir_name,
        source: DiscoverySource::GitRemote,
    })
}

/// Extract the `url = ...` value from the `[remote "origin"]` section of a
/// git config file.
///
/// Why: Git config is a stable INI-ish format, but pulling in `gitoxide`
/// just for one field would be wildly disproportionate. A line-based scan is
/// sufficient for the canonical layout used by every git client.
/// What: Walks lines, tracks whether we're inside `[remote "origin"]`, and
/// returns the trimmed value of the first `url = ...` line within that
/// section.
/// Test: `extract_origin_url_handles_typical_config`.
fn extract_origin_url(config: &str) -> Option<String> {
    let mut in_origin = false;
    for line in config.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('[') {
            in_origin = trimmed == "[remote \"origin\"]";
            continue;
        }
        if in_origin {
            if let Some(rest) = trimmed.strip_prefix("url") {
                let rest = rest.trim_start();
                if let Some(rest) = rest.strip_prefix('=') {
                    return Some(rest.trim().to_string());
                }
            }
        }
    }
    None
}

/// Extract the short repo name from a git URL.
///
/// Why: Origin URLs come in three flavours — HTTPS (`https://host/owner/repo.git`),
/// SSH (`git@host:owner/repo.git`), and local paths. All three end with
/// `<name>` or `<name>.git`; returning the last path-component without the
/// suffix gives a stable short name.
/// What: Splits on both `/` and `:`, takes the last component, strips a
/// trailing `.git`. Returns `None` for empty inputs.
/// Test: `short_repo_name_strips_git_suffix_and_path`.
fn short_repo_name(url: &str) -> Option<String> {
    let last = url
        .rsplit(|c: char| c == '/' || c == ':')
        .next()
        .unwrap_or("");
    let stripped = last.strip_suffix(".git").unwrap_or(last).trim();
    if stripped.is_empty() {
        None
    } else {
        Some(stripped.to_string())
    }
}

/// Push a discovery into the result list iff its `short` hasn't been seen yet.
///
/// Why: A subject can only have one *active* `is_alias_for` triple at a time
/// (the temporal KG closes the prior interval whenever a new value is
/// asserted), so emitting two discoveries with the same `short` would force
/// every subsequent `discover_aliases` call to flap between them — endlessly
/// reasserting because neither matches the currently-active object. Deduping
/// on `short` here makes the discovery list inherently idempotent: one
/// authoritative mapping per subject, with the first-seen source winning
/// (`CargoPackageName` > `CargoBinaryName` > `FirstLetterAbbrev` >
/// `GitRemote`, matching the call order in `discover_blocking`).
/// What: Tracks every `short` already pushed; subsequent pushes with the
/// same `short` are dropped. `seen_pairs` is misnamed historically — it now
/// holds the deduped subjects.
/// Test: `no_duplicate_short_names_in_results`,
/// `dispatch_discover_aliases_inserts_new_and_dedupes` (the rerun assertion
/// only passes when this dedup holds).
fn push_unique(
    discoveries: &mut Vec<AliasDiscovery>,
    seen_subjects: &mut HashSet<(String, String)>,
    d: AliasDiscovery,
) {
    // Repurpose the set as a subject-only dedup: store ("subject", "") so
    // the existing call sites keep working without renaming the parameter
    // type across every signature.
    let key = (d.short.clone(), String::new());
    if seen_subjects.insert(key) {
        discoveries.push(d);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Why: Smoke-test the live workspace — the prompt test in the task spec
    /// pins `("tga", "trusty-git-analytics")` as a discovered alias.
    /// What: Locates the workspace root (parent of this crate dir), runs the
    /// blocking discovery, and asserts the canonical pair is present with
    /// the `CargoPackageName` source.
    /// Test: this test itself.
    #[test]
    fn discovers_trusty_git_analytics_alias() {
        let root = workspace_root();
        let discoveries = discover_blocking(&root).expect("discover");
        let hit = discoveries
            .iter()
            .find(|d| d.short == "tga" && d.full == "trusty-git-analytics");
        assert!(
            hit.is_some(),
            "expected tga→trusty-git-analytics in discoveries; got: {discoveries:?}"
        );
        assert_eq!(hit.unwrap().source, DiscoverySource::CargoPackageName);
    }

    /// Why: First-letter abbreviation is the most subtle source — confirm
    /// it fires for at least one crate in the live workspace and pins the
    /// canonical example (`tc → trusty-common`, the longest-lived shared
    /// library crate, has a guaranteed-unique two-letter abbreviation).
    /// Test: this test itself.
    #[test]
    fn first_letter_abbrev_emits_unique_workspace_initials() {
        let root = workspace_root();
        let discoveries = discover_blocking(&root).expect("discover");
        let hit = discoveries.iter().find(|d| {
            d.short == "tc"
                && d.full == "trusty-common"
                && d.source == DiscoverySource::FirstLetterAbbrev
        });
        assert!(
            hit.is_some(),
            "expected tc→trusty-common first-letter abbrev; got: {discoveries:?}"
        );
    }

    /// Why: A synthetic fixture pins the abbreviation algorithm against the
    /// exact scenario the original spec called out — a workspace where
    /// `tm` would uniquely map to `trusty-memory` if there were no other
    /// `t-m-…` crates. The live workspace happens to also expose `tm` as a
    /// binary alias for `trusty-mpm-cli`, which (correctly) takes
    /// precedence; this isolated test confirms the abbreviation logic
    /// itself does the right thing.
    /// Test: this test itself.
    #[test]
    fn first_letter_abbrev_tm_unique_when_only_trusty_memory() {
        let packages = vec![
            ("trusty-memory".to_string(), "trusty-memory".to_string()),
            ("trusty-common".to_string(), "trusty-common".to_string()),
            ("trusty-mpm-cli".to_string(), "trusty-mpm-cli".to_string()),
        ];
        let mut discoveries = Vec::new();
        let mut seen = HashSet::new();
        add_first_letter_abbreviations(&packages, &mut discoveries, &mut seen);
        let tm = discoveries
            .iter()
            .find(|d| d.short == "tm" && d.source == DiscoverySource::FirstLetterAbbrev);
        assert_eq!(
            tm.map(|d| d.full.as_str()),
            Some("trusty-memory"),
            "tm must abbreviate trusty-memory in this fixture; got: {discoveries:?}"
        );
    }

    /// Why: Calling discovery twice must produce the same result — the
    /// helper is pure (no mutation of disk state), and the dedup test in
    /// the spec uses this property to verify idempotency.
    /// Test: this test itself.
    #[tokio::test]
    async fn no_duplicate_short_names_in_results() {
        let root = workspace_root();
        let a = discover_project_aliases(&root).await.expect("discover a");
        let b = discover_project_aliases(&root).await.expect("discover b");
        assert_eq!(a.len(), b.len(), "two calls must yield equal counts");

        // No (short, full) pair appears twice within a single call.
        let mut seen = HashSet::new();
        for d in &a {
            assert!(
                seen.insert((d.short.clone(), d.full.clone())),
                "duplicate discovery: {} → {} ({:?})",
                d.short,
                d.full,
                d.source,
            );
        }
    }

    /// Why: Pin the abbreviation-uniqueness rule against a synthetic
    /// workspace where two crates share an abbreviation — the algorithm
    /// must NOT emit a discovery for the ambiguous prefix.
    /// What: Build two fake packages, both abbreviating to "tm", and assert
    /// no `FirstLetterAbbrev` for "tm" is produced.
    /// Test: this test itself.
    #[test]
    fn first_letter_abbrev_skips_ambiguous() {
        let packages = vec![
            ("trusty-memory".to_string(), "trusty-memory".to_string()),
            ("trusty-monitor".to_string(), "trusty-monitor".to_string()),
        ];
        let mut discoveries = Vec::new();
        let mut seen = HashSet::new();
        add_first_letter_abbreviations(&packages, &mut discoveries, &mut seen);
        let tm = discoveries
            .iter()
            .find(|d| d.short == "tm" && d.source == DiscoverySource::FirstLetterAbbrev);
        assert!(
            tm.is_none(),
            "ambiguous tm must not produce an abbrev discovery; got: {discoveries:?}"
        );
    }

    /// Why: Pin the parser against the typical `[remote "origin"]` block
    /// shape. A regression that loses the URL would silently disable the
    /// GitRemote source.
    #[test]
    fn extract_origin_url_handles_typical_config() {
        let cfg = "\
[core]
\trepositoryformatversion = 0
[remote \"origin\"]
\turl = git@github.com:bobmatnyc/trusty-tools.git
\tfetch = +refs/heads/*:refs/remotes/origin/*
[branch \"main\"]
\tremote = origin
";
        assert_eq!(
            extract_origin_url(cfg),
            Some("git@github.com:bobmatnyc/trusty-tools.git".to_string())
        );
    }

    /// Why: Three URL flavours must all collapse to the same short name.
    #[test]
    fn short_repo_name_strips_git_suffix_and_path() {
        assert_eq!(
            short_repo_name("git@github.com:bobmatnyc/trusty-tools.git").as_deref(),
            Some("trusty-tools")
        );
        assert_eq!(
            short_repo_name("https://github.com/bobmatnyc/trusty-tools.git").as_deref(),
            Some("trusty-tools")
        );
        assert_eq!(
            short_repo_name("https://github.com/bobmatnyc/trusty-tools").as_deref(),
            Some("trusty-tools")
        );
        assert_eq!(short_repo_name("").as_deref(), None);
    }

    /// Why: Scan logic must surface both CargoPackageName and
    /// CargoBinaryName aliases from a single fixture.
    #[test]
    fn scan_member_emits_package_and_binary_aliases() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let member = tmp.path().join("trusty-git-analytics");
        std::fs::create_dir_all(&member).expect("mkdir");
        std::fs::write(
            member.join("Cargo.toml"),
            r#"
[package]
name = "tga"
version = "0.1.0"

[[bin]]
name = "tga_bench"
path = "src/bench.rs"

[[bin]]
name = "tga"
path = "src/main.rs"
"#,
        )
        .expect("write Cargo.toml");

        let mut discoveries = Vec::new();
        let mut seen = HashSet::new();
        let mut packages = Vec::new();
        scan_member(&member, &mut discoveries, &mut seen, &mut packages);

        // Package-name discovery.
        let pkg_disc = discoveries
            .iter()
            .find(|d| d.source == DiscoverySource::CargoPackageName)
            .expect("package alias");
        assert_eq!(pkg_disc.short, "tga");
        assert_eq!(pkg_disc.full, "trusty-git-analytics");

        // Binary-name discovery (only the one that differs from the package).
        let bin_disc = discoveries
            .iter()
            .find(|d| d.source == DiscoverySource::CargoBinaryName)
            .expect("binary alias");
        assert_eq!(bin_disc.short, "tga_bench");
        assert_eq!(bin_disc.full, "tga");

        // The matching-name bin must NOT produce a discovery.
        assert_eq!(
            discoveries
                .iter()
                .filter(|d| d.source == DiscoverySource::CargoBinaryName)
                .count(),
            1
        );
    }

    /// Resolve the workspace root (parent of `crates/trusty-memory`).
    ///
    /// Why: Cargo runs each crate's tests with `CARGO_MANIFEST_DIR` set to
    /// that crate's directory. The live-workspace tests need the workspace
    /// root, which is two levels up.
    fn workspace_root() -> PathBuf {
        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        manifest_dir
            .parent() // crates/
            .and_then(|p| p.parent()) // workspace root
            .expect("workspace root")
            .to_path_buf()
    }
}