lihaaf 0.1.2

Fast compile-fail and compile-pass test harness for Rust proc macros; a faster trybuild-style workflow
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
//! Dylib build + copy (notable implementation notes: build, copy, and
//! platform-safe paths).
//!
//! ## Implementer choices recorded here
//!
//! Three implementation decisions live in this module. Each is explained
//! inline with its rationale anchored to the checks here.
//! the choice:
//!
//! - **Cargo invocation** (the policy — "implementer chooses the specific
//!   cargo subcommand"): `cargo rustc -p <crate> --lib --release
//!   --crate-type=dylib --message-format=json` with
//!   `RUSTFLAGS="-C prefer-dynamic"`. Validated end-to-end by the
//!   inventory-on-dylib spike (verdict `GO_NATIVE`; the policy).
//!   `cargo rustc` is the only subcommand whose `--crate-type=dylib`
//!   flag overrides the consumer's `[lib]` declaration without
//!   modifying its `Cargo.toml`. The `prefer-dynamic` flag is required
//!   for compile-time-link consumers per the spike's findings.
//!
//! - **File copy primitive** (the policy — "implementer chooses the file-copy
//!   primitive"): `std::fs::copy`. POSIX semantics on Linux/macOS,
//!   `CopyFileW` on Windows. The cost (~few hundred ms on a warm cache
//!   is acceptable; the v0.2 reflink optimization is
//!   anchored deferral.
//!
//! - **Dedicated `CARGO_TARGET_DIR`** (spike note): `RUSTFLAGS=
//!   "-C prefer-dynamic"` is part of cargo's fingerprint hash, so
//!   alternating between a normal `cargo build` and the lihaaf dylib
//!   build in the same target dir thrashes the entire dependency graph.
//!   lihaaf unconditionally builds into `target/lihaaf-build/` so the
//!   adopter's normal `cargo test` loop doesn't fight lihaaf's
//!   invocations.

use std::path::{Path, PathBuf};
use std::process::Command;

use serde::Deserialize;

use crate::config::Suite;
use crate::error::{Error, Outcome};
use crate::toolchain::Toolchain;

/// Result of a successful dylib build.
#[derive(Debug, Clone)]
pub struct BuildOutput {
    /// The cargo-emitted dylib path (in the dedicated lihaaf target
    /// dir — `target/lihaaf-build/release/deps/lib<crate>-<hash>.so`).
    pub cargo_dylib_path: PathBuf,
    /// The `target/release/deps` directory containing the rest of the
    /// link tree (rlibs of dev_deps, etc.) — needed for `-L dependency=`.
    pub deps_dir: PathBuf,
    /// The cargo invocation as a single line, for diagnostics if a
    /// later step trips.
    #[allow(dead_code)] // populated for diagnostic plumbing; not currently read by any caller.
    pub invocation: String,
}

/// Parameters needed to build the dylib.
#[derive(Debug, Clone)]
pub struct BuildParams<'a> {
    /// `dylib_crate` from the metadata.
    pub crate_name: &'a str,
    /// `features` from the metadata.
    pub features: &'a [String],
    /// Path to the consumer's `Cargo.toml`.
    pub manifest_path: &'a Path,
    /// Where to put the lihaaf-private target directory. Caller chooses;
    /// session uses `<workspace_target>/lihaaf-build`.
    pub target_dir: &'a Path,
    /// Captured rustc identity, for the diagnostic if cargo can't find
    /// rustc.
    pub toolchain: &'a Toolchain,
}

/// Build the consumer crate as a release-mode dylib.
///
/// Returns the path of the cargo-emitted artifact in the dedicated
/// lihaaf target dir. The caller copies (or symlinks) this artifact to
/// `target/lihaaf/lib<crate>-current-<hash>.so` per the policy.
pub fn build(params: &BuildParams<'_>) -> Result<BuildOutput, Error> {
    std::fs::create_dir_all(params.target_dir).map_err(|e| {
        Error::io(
            e,
            "creating lihaaf-build target dir",
            Some(params.target_dir.to_path_buf()),
        )
    })?;

    // Compose the cargo invocation. `cargo rustc` is the subcommand
    // because it's the only one whose `--crate-type=dylib` overrides
    // `[lib]` without modifying the consumer's Cargo.toml — confirmed
    // by the inventory-on-dylib spike (the policy).
    let mut cmd = Command::new("cargo");
    cmd.arg("rustc")
        .arg("-p")
        .arg(params.crate_name)
        .arg("--lib")
        .arg("--release")
        .arg("--crate-type=dylib")
        .arg("--message-format=json-render-diagnostics")
        .arg("--manifest-path")
        .arg(params.manifest_path)
        .arg("--target-dir")
        .arg(params.target_dir);

    for f in params.features {
        cmd.arg("--features").arg(f);
    }

    // `-C prefer-dynamic` is required for compile-time-link consumers
    // (per the spike's findings). RUSTFLAGS is part of cargo's
    // fingerprint hash; a dedicated target dir avoids thrashing the
    // adopter's normal `cargo build` cache.
    let prior_rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
    let new_rustflags = if prior_rustflags.is_empty() {
        "-C prefer-dynamic".to_string()
    } else {
        format!("{prior_rustflags} -C prefer-dynamic")
    };
    cmd.env("RUSTFLAGS", &new_rustflags);

    // Format the invocation for diagnostics. The Command's
    // shape (program + args + RUSTFLAGS env) is mirrored so the adopter
    // can paste it into a shell verbatim.
    let invocation = format!(
        "RUSTFLAGS={:?} cargo rustc -p {} --lib --release --crate-type=dylib \
         --message-format=json-render-diagnostics --manifest-path {:?} --target-dir {:?}{}",
        new_rustflags,
        params.crate_name,
        params.manifest_path,
        params.target_dir,
        if params.features.is_empty() {
            String::new()
        } else {
            format!(" --features {}", params.features.join(","))
        }
    );

    let output = cmd.output().map_err(|e| Error::SubprocessSpawn {
        program: "cargo".into(),
        source: e,
    })?;

    if !output.status.success() {
        return Err(Error::Session(Outcome::DylibBuildFailed {
            invocation: invocation.clone(),
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        }));
    }

    let stdout = String::from_utf8_lossy(&output.stdout);

    let dylib_path = parse_dylib_path(&stdout, params.crate_name).ok_or_else(|| {
        Error::Session(Outcome::DylibNotFound {
            invocation: invocation.clone(),
            crate_name: params.crate_name.to_string(),
        })
    })?;

    // Cargo emits the dylib at `<target>/release/lib<crate>.so` AND
    // hard-links a copy into `<target>/release/deps/lib<crate>.so`.
    // Fixtures need the deps dir on `-L dependency=` so transitive
    // crates resolve; the path points at deps/ rather than the release/ root.
    let deps_dir = dylib_path
        .parent()
        .map(|p| p.join("deps"))
        .unwrap_or_else(|| params.target_dir.join("release/deps"));

    // Toolchain shape is captured separately for drift checks;
    // recorded on the output for rendering.
    let _ = params.toolchain;

    Ok(BuildOutput {
        cargo_dylib_path: dylib_path,
        deps_dir,
        invocation,
    })
}

/// One `compiler-artifact` JSON message line. Cargo emits one per
/// crate it built; the target is the one whose package identity matches
/// the dylib_crate AND whose target crate-types include `"dylib"`.
#[derive(Debug, Deserialize)]
struct CompilerArtifact {
    reason: String,
    #[serde(default)]
    package_id: Option<String>,
    target: ArtifactTarget,
    filenames: Vec<PathBuf>,
}

#[derive(Debug, Deserialize)]
struct ArtifactTarget {
    name: String,
    #[serde(default)]
    kind: Vec<String>,
    #[serde(default)]
    crate_types: Vec<String>,
}

/// Parse the `--message-format=json-render-diagnostics` stdout stream
/// for the cargo invocation and recover the dylib path matching
/// `crate_name`.
///
/// the policy: "lihaaf finds the `compiler-artifact` message whose
/// package identity equals `dylib_crate` and whose `target.crate_types`
/// includes `"dylib"`, reads the `filenames` array, and selects the first
/// entry matching the platform's dynamic-library extension. Cargo streams
/// from older or synthetic callers may omit `package_id`; when absent,
/// `target.name` is used as the package identity substitute.
/// If multiple `compiler-artifact` messages match, the last one wins.
pub fn parse_dylib_path(stdout: &str, crate_name: &str) -> Option<PathBuf> {
    let extensions = dylib_extensions();
    let mut last_match: Option<PathBuf> = None;
    for line in stdout.lines() {
        if !line.starts_with('{') {
            continue;
        }
        let artifact: CompilerArtifact = match serde_json::from_str(line) {
            Ok(a) => a,
            Err(_) => continue,
        };
        if artifact.reason != "compiler-artifact" {
            continue;
        }
        if !artifact_matches_crate(&artifact, crate_name) {
            continue;
        }
        if !artifact_is_dylib(&artifact.target) {
            continue;
        }
        // Walk filenames; the first whose extension matches wins for
        // this artifact. Cargo orders them deterministically.
        for filename in &artifact.filenames {
            if let Some(ext) = filename.extension().and_then(|e| e.to_str())
                && extensions.contains(&ext)
            {
                last_match = Some(filename.clone());
                break;
            }
        }
    }
    last_match
}

fn artifact_matches_crate(artifact: &CompilerArtifact, crate_name: &str) -> bool {
    if let Some(package_id) = artifact.package_id.as_deref() {
        return package_id_matches_crate(package_id, crate_name);
    }

    artifact.target.name == crate_name
}

fn artifact_is_dylib(target: &ArtifactTarget) -> bool {
    if !target.crate_types.is_empty() {
        return target.crate_types.iter().any(|k| k == "dylib");
    }

    target.kind.iter().any(|k| k == "dylib")
}

fn package_id_matches_crate(package_id: &str, crate_name: &str) -> bool {
    let Some((source, suffix)) = package_id.rsplit_once('#') else {
        return false;
    };
    if package_fragment_matches_crate(suffix, crate_name) {
        return true;
    }

    let source_tail = source
        .trim_end_matches('/')
        .rsplit('/')
        .next()
        .unwrap_or_default();
    package_fragment_matches_crate(source_tail, crate_name)
}

fn package_fragment_matches_crate(fragment: &str, crate_name: &str) -> bool {
    fragment == crate_name
        || fragment.replace('-', "_") == crate_name
        || fragment
            .strip_prefix(crate_name)
            .is_some_and(|rest| rest.starts_with('@'))
}

/// Per-platform dynamic library extensions (no leading dot).
pub fn dylib_extensions() -> &'static [&'static str] {
    if cfg!(target_os = "linux") || cfg!(target_os = "android") {
        &["so"]
    } else if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
        &["dylib"]
    } else if cfg!(target_os = "windows") {
        &["dll"]
    } else {
        // Other Unixes typically use `.so`. Falling through here is
        // honest; an adopter who targets one will surface the failure
        // mode rather than getting a silent wrong guess.
        &["so"]
    }
}

/// Where the lihaaf-managed copy lives.
///
/// `target/lihaaf/lib<crate>-current-<hash>.so` per the policy, with
/// `<hash>` recovered from the cargo-emitted filename
/// (`lib<crate>-<hash>.so`). If the filename doesn't carry a hash
/// (synthetic test paths), `0` is substituted.
pub fn managed_dylib_path(workspace_target: &Path, cargo_dylib: &Path) -> PathBuf {
    let lihaaf_dir = workspace_target.join("lihaaf");
    let stem = cargo_dylib
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("lib");
    let ext = cargo_dylib
        .extension()
        .and_then(|s| s.to_str())
        .unwrap_or("so");
    // `lib<crate>-<hash>` → `<crate>` + `<hash>`.
    let (crate_part, hash_part) = match stem.strip_prefix("lib") {
        Some(rest) => match rest.rfind('-') {
            Some(idx) => (&rest[..idx], &rest[idx + 1..]),
            None => (rest, "0"),
        },
        None => (stem, "0"),
    };
    lihaaf_dir.join(format!("lib{crate_part}-current-{hash_part}.{ext}"))
}

/// Copy the cargo-emitted dylib to the lihaaf-managed location.
/// the policy: copy is unconditional on every session start; the implementer
/// chooses the file-copy primitive (here: `std::fs::copy`).
pub fn copy_dylib(cargo_dylib: &Path, managed: &Path) -> Result<(), Error> {
    if let Some(parent) = managed.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            Error::io(
                e,
                "creating managed dylib parent",
                Some(parent.to_path_buf()),
            )
        })?;
    }
    // Remove any prior file/symlink at the destination to avoid
    // silently overwriting a symlink with a copy or vice versa.
    // The race-free cascade replaces the previous stat-then-act
    // `exists() || symlink_metadata().is_ok()` shape, which had two
    // independent bugs: a TOCTOU window between the check and the
    // removal, and a Windows-side dir-symlink case where
    // DeleteFileW returns PermissionDenied for directories.
    crate::util::remove_path_race_free(managed, "prior managed dylib")?;
    std::fs::copy(cargo_dylib, managed).map_err(|e| {
        Error::io(
            e,
            "copying cargo dylib to managed location",
            Some(managed.to_path_buf()),
        )
    })?;
    Ok(())
}

/// Symlink the cargo-emitted dylib at the lihaaf-managed location.
/// the policy `--use-symlink` opt-in. Unsafe-by-default — the
/// caller asserts no concurrent cargo build will modify `target/`.
pub fn symlink_dylib(cargo_dylib: &Path, managed: &Path) -> Result<(), Error> {
    if let Some(parent) = managed.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            Error::io(
                e,
                "creating managed dylib parent",
                Some(parent.to_path_buf()),
            )
        })?;
    }
    // Same race-free cascade as `copy_dylib` — see that function's
    // comment for the rationale.
    crate::util::remove_path_race_free(managed, "prior managed dylib")?;
    #[cfg(unix)]
    {
        std::os::unix::fs::symlink(cargo_dylib, managed)
            .map_err(|e| Error::io(e, "symlinking cargo dylib", Some(managed.to_path_buf())))?;
    }
    #[cfg(windows)]
    {
        std::os::windows::fs::symlink_file(cargo_dylib, managed)
            .map_err(|e| Error::io(e, "symlinking cargo dylib", Some(managed.to_path_buf())))?;
    }
    #[cfg(not(any(unix, windows)))]
    {
        // Fall back to a copy on platforms with no symlink primitive.
        // Honest: symlink was attempted and fell through.
        copy_dylib(cargo_dylib, managed)?;
    }
    Ok(())
}

/// Read the mtime of a file as Unix seconds.
pub fn mtime_unix_secs(path: &Path) -> Result<i64, Error> {
    let meta = std::fs::metadata(path)
        .map_err(|e| Error::io(e, "stat file for mtime", Some(path.to_path_buf())))?;
    let mtime = meta
        .modified()
        .map_err(|e| Error::io(e, "reading mtime", Some(path.to_path_buf())))?;
    let dur = mtime
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();
    Ok(dur.as_secs() as i64)
}

/// Resolve the workspace target directory from the consumer manifest's
/// directory + `target/`. Adopters with a custom `CARGO_TARGET_DIR` get
/// honored — env wins.
pub fn workspace_target_dir(manifest_path: &Path) -> PathBuf {
    if let Ok(env_dir) = std::env::var("CARGO_TARGET_DIR")
        && !env_dir.is_empty()
    {
        return PathBuf::from(env_dir);
    }
    let crate_dir = manifest_path
        .parent()
        .map(|p| p.to_path_buf())
        .unwrap_or_else(|| PathBuf::from("."));
    crate_dir.join("target")
}

/// Per-suite cargo target directory under `<workspace_target>/`.
///
/// The default suite uses `<workspace_target>/lihaaf-build` (kept stable
/// so adopters who never add a named suite see no cache-key change).
/// Named suites
/// use `<workspace_target>/lihaaf-build-<suite>` so each suite's
/// `--features` set lives in its own cargo cache and never thrashes a
/// sibling suite's build artifacts. Suite names are validated by
/// [`crate::config::parse`] to contain only ASCII alphanumerics, hyphens,
/// and underscores, so the substitution is filename-safe on every
/// supported platform.
pub fn build_dir_for_suite(workspace_target: &Path, suite_name: &str) -> PathBuf {
    if suite_name == crate::config::DEFAULT_SUITE_NAME {
        workspace_target.join("lihaaf-build")
    } else {
        workspace_target.join(format!("lihaaf-build-{suite_name}"))
    }
}

/// True when the build params look usable. Cheap pre-flight check.
#[allow(dead_code)]
pub fn validate_params(_params: &BuildParams<'_>, _suite: &Suite) -> Result<(), Error> {
    // Reserved for future invariants (e.g., dylib_crate is in the
    // workspace's metadata). v0.1 leaves this as a no-op; cargo itself
    // rejects unknown -p targets clearly.
    Ok(())
}

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

    #[test]
    fn parse_dylib_path_picks_dylib_crate_type_and_extension() {
        // Linux: `.so`. The test bakes an artifact line; the parser must pick
        // the first `.so` listed in `filenames`.
        #[cfg(target_os = "linux")]
        let line = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["/p/target/release/deps/libconsumer-abc.so"]}"#;
        #[cfg(target_os = "macos")]
        let line = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["/p/target/release/deps/libconsumer-abc.dylib"]}"#;
        #[cfg(target_os = "windows")]
        let line = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["C:/p/target/release/deps/consumer-abc.dll"]}"#;
        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
        let line = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["/p/target/release/deps/libconsumer-abc.so"]}"#;

        let path = parse_dylib_path(line, "consumer").unwrap();
        assert!(path.to_string_lossy().contains("consumer-abc"));
    }

    #[test]
    fn parse_dylib_path_skips_unrelated_artifacts() {
        let stream = "\
{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"unrelated\",\"kind\":[\"lib\"]},\"filenames\":[\"/p/x.rlib\"]}
{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"consumer\",\"kind\":[\"lib\"]},\"filenames\":[\"/p/libconsumer.rlib\"]}
{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"consumer\",\"kind\":[\"dylib\"]},\"filenames\":[\"/p/libconsumer-abc.so\"]}
";
        // On non-Linux the test still expects the dylib kind to be picked
        // — the extension match guards platform-correctness in the real
        // path, but for parser unit tests `.so` is treated as accepted
        // because the test fixture string says so. Skip on Windows.
        #[cfg(target_os = "windows")]
        let _ = stream;
        #[cfg(not(target_os = "windows"))]
        {
            let p = parse_dylib_path(stream, "consumer").unwrap();
            assert!(p.to_string_lossy().ends_with(".so"));
        }
    }

    #[test]
    fn parse_dylib_path_can_match_package_id_when_lib_target_name_differs() {
        let stream = r#"{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{"name":"consumer_lib","kind":["lib"],"crate_types":["rlib","dylib"]},"filenames":["/p/libconsumer_lib-abc.so"]}"#;
        #[cfg(target_os = "windows")]
        let _ = stream;
        #[cfg(not(target_os = "windows"))]
        {
            let p = parse_dylib_path(stream, "consumer").unwrap();
            assert!(p.to_string_lossy().ends_with(".so"));
        }
    }

    #[test]
    fn parse_dylib_path_prefers_package_id_over_target_name_collision() {
        let ext = dylib_extensions()[0];
        let stream = format!(
            "\
{{\"reason\":\"compiler-artifact\",\"package_id\":\"path+file:///p#consumer@0.1.0\",\"target\":{{\"name\":\"consumer_lib\",\"kind\":[\"lib\"],\"crate_types\":[\"rlib\",\"dylib\"]}},\"filenames\":[\"/p/libconsumer_lib-right.{ext}\"]}}
{{\"reason\":\"compiler-artifact\",\"package_id\":\"path+file:///p#other@0.1.0\",\"target\":{{\"name\":\"consumer\",\"kind\":[\"lib\"],\"crate_types\":[\"rlib\",\"dylib\"]}},\"filenames\":[\"/p/libconsumer-wrong.{ext}\"]}}
"
        );

        let p = parse_dylib_path(&stream, "consumer").unwrap();
        assert!(p.to_string_lossy().contains("right"));
    }

    #[test]
    fn parse_dylib_path_uses_crate_types_when_cargo_provides_them() {
        let ext = dylib_extensions()[0];
        let stream = format!(
            r#"{{"reason":"compiler-artifact","package_id":"path+file:///p#consumer@0.1.0","target":{{"name":"consumer","kind":["dylib"],"crate_types":["rlib"]}},"filenames":["/p/libconsumer-not-dylib.{ext}"]}}"#
        );

        assert!(parse_dylib_path(&stream, "consumer").is_none());
    }

    #[test]
    fn parse_dylib_path_matches_path_package_id_source_tail() {
        let ext = dylib_extensions()[0];
        let stream = format!(
            r#"{{"reason":"compiler-artifact","package_id":"path+file:///workspace/consumer#0.1.0","target":{{"name":"consumer","kind":["dylib"],"crate_types":["dylib"]}},"filenames":["/p/libconsumer.{ext}"]}}"#
        );

        let p = parse_dylib_path(&stream, "consumer").unwrap();
        assert!(p.to_string_lossy().contains("libconsumer"));
    }

    #[test]
    fn managed_dylib_path_preserves_hash() {
        let p = managed_dylib_path(
            Path::new("/p/target"),
            Path::new("/p/target/release/deps/libconsumer-abc123.so"),
        );
        assert!(p.ends_with("libconsumer-current-abc123.so"));
        assert!(p.starts_with("/p/target/lihaaf"));
    }

    #[test]
    fn managed_dylib_path_handles_missing_hash() {
        let p = managed_dylib_path(
            Path::new("/p/target"),
            Path::new("/p/target/release/deps/libconsumer.so"),
        );
        assert!(p.ends_with("libconsumer-current-0.so"));
    }

    #[test]
    fn build_dir_for_default_suite_uses_default_name() {
        let p = build_dir_for_suite(Path::new("/p/target"), crate::config::DEFAULT_SUITE_NAME);
        // Single-suite adopters hash against the unsuffixed cargo target
        // dir for cache-key stability.
        assert_eq!(p, PathBuf::from("/p/target/lihaaf-build"));
    }

    #[test]
    fn build_dir_for_named_suite_includes_name() {
        let p = build_dir_for_suite(Path::new("/p/target"), "spatial");
        assert_eq!(p, PathBuf::from("/p/target/lihaaf-build-spatial"));
    }
}