jackdaw 0.4.0

A 3D level editor built with Bevy
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
//! Editor-driven build pipeline for extension and game projects.
//!
//! User-scaffolded projects are plain single-crate cargo projects
//! with `bevy = "0.18"` in `[dependencies]` and `crate-type =
//! ["cdylib"]` on the library. Jackdaw compiles them via `cargo
//! build` with `RUSTC_WRAPPER` pointing at `jackdaw-rustc-wrapper`,
//! which intercepts rustc and rewrites `--extern bevy=<user>.rlib`
//! to `--extern bevy=libjackdaw_sdk.so`. That keeps the user's
//! cdylib `TypeIds` in sync with the editor.
//!
//! Why not `bevy build`? The bevy CLI's build subcommand requires
//! a binary target and errors on library-only projects ("No
//! binaries available!"). Scaffolded jackdaw projects are cdylibs
//! so the editor can `dlopen` them, so `bevy build` can't drive
//! them. We still use `bevy new` for scaffolding; that part of
//! the toolchain fits cleanly.
//!
//! [`build_extension_project`] is the simple entry point.
//! [`build_extension_project_with_progress`] additionally streams
//! per-crate progress + tailing log lines into a shared sink the
//! UI can read each frame. The function blocks until cargo exits;
//! use an `AsyncComputeTaskPool` task for non-blocking builds.

use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;

use crate::new_project::TemplateLinkage;
use crate::sdk_paths::SdkPaths;

/// Everything that can go wrong while building an extension/game
/// project.
#[derive(Debug)]
pub enum BuildError {
    NotADirectory(PathBuf),
    MissingCargoToml(PathBuf),
    SdkNotFound {
        expected_path: PathBuf,
        hint: &'static str,
    },
    WrapperNotFound {
        expected_path: PathBuf,
        hint: &'static str,
    },
    BuildSpawn(std::io::Error),
    BuildFailed {
        status: std::process::ExitStatus,
        stderr_tail: String,
    },
    OutputNotProduced {
        expected: PathBuf,
    },
}

impl std::fmt::Display for BuildError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotADirectory(p) => write!(f, "{} is not a directory", p.display()),
            Self::MissingCargoToml(p) => {
                write!(f, "{} has no Cargo.toml", p.display())
            }
            Self::SdkNotFound {
                expected_path,
                hint,
            } => write!(
                f,
                "SDK dylib not found at {}. {}",
                expected_path.display(),
                hint
            ),
            Self::WrapperNotFound {
                expected_path,
                hint,
            } => write!(
                f,
                "rustc wrapper not found at {}. {}",
                expected_path.display(),
                hint
            ),
            Self::BuildSpawn(e) => write!(f, "failed to spawn cargo: {e}"),
            Self::BuildFailed {
                status,
                stderr_tail,
            } => {
                write!(f, "cargo exited with {status}\n{stderr_tail}")
            }
            Self::OutputNotProduced { expected } => write!(
                f,
                "cargo succeeded but no .so was produced at {}",
                expected.display()
            ),
        }
    }
}

impl std::error::Error for BuildError {}

/// Capacity of the rolling log-tail buffer surfaced in progress UI.
const LOG_TAIL_CAPACITY: usize = 20;

/// Live progress from a running cargo build. Writers: the build
/// helper's stdout/stderr reader threads. Reader: the UI poller
/// that renders the progress bar + log tail each frame. Wrap in
/// `Arc<Mutex<_>>` when handing to a long-running task.
///
/// `artifacts_total` is `Some` once we've run `cargo metadata` to
/// compute the expected number of compile units; until then the UI
/// should render an indeterminate bar (or just the counter).
#[derive(Debug, Default, Clone)]
pub struct BuildProgress {
    pub current_crate: Option<String>,
    pub artifacts_done: u32,
    pub artifacts_total: Option<u32>,
    pub recent_log_lines: VecDeque<String>,
    /// Set to `true` by the helper once cargo exits (success or
    /// failure). The UI can use this to flip the bar to 100%.
    pub finished: bool,
}

impl BuildProgress {
    pub fn push_log(&mut self, line: String) {
        if self.recent_log_lines.len() >= LOG_TAIL_CAPACITY {
            self.recent_log_lines.pop_front();
        }
        self.recent_log_lines.push_back(line);
    }

    /// 0.0 when unknown, 1.0 when done.
    pub fn fraction(&self) -> Option<f32> {
        if self.finished {
            return Some(1.0);
        }
        let total = self.artifacts_total? as f32;
        if total <= 0.0 {
            return None;
        }
        Some((self.artifacts_done as f32 / total).clamp(0.0, 1.0))
    }
}

/// Discover `libjackdaw_sdk` + `jackdaw-rustc-wrapper` on disk, or
/// surface a typed error the Build-and-Install dialog can translate
/// into a user-actionable message.
fn discover_sdk() -> Result<SdkPaths, BuildError> {
    let mut paths = SdkPaths::compute();
    if !paths.dylib_exists() {
        return Err(BuildError::SdkNotFound {
            expected_path: paths.dylib,
            hint: "libjackdaw_sdk was not found. Reinstall jackdaw, \
                   or set JACKDAW_SDK_DIR to the directory that \
                   contains it.",
        });
    }
    if !paths.wrapper_exists() {
        // Dev mode: contributors running `cargo run` get jackdaw built
        // but not jackdaw_rustc_wrapper (the wrapper is a separate
        // workspace member, not a dependency of jackdaw). Auto-build
        // it before giving up so the first scaffold-then-build flow
        // just works.
        if let Some(checkout) = crate::new_project::jackdaw_dev_checkout() {
            bevy::log::info!(
                "Auto-building jackdaw_rustc_wrapper from dev checkout at {}",
                checkout.display()
            );
            let status = std::process::Command::new("cargo")
                .args(["build", "-p", "jackdaw_rustc_wrapper"])
                .current_dir(&checkout)
                .status();
            match status {
                Ok(s) if s.success() => {
                    paths = SdkPaths::compute();
                    if !paths.wrapper_exists() {
                        return Err(BuildError::WrapperNotFound {
                            expected_path: paths.wrapper,
                            hint: "Auto-build of jackdaw_rustc_wrapper \
                                   completed but the binary is still \
                                   missing. Try `cargo build -p \
                                   jackdaw_rustc_wrapper` manually.",
                        });
                    }
                }
                _ => {
                    return Err(BuildError::WrapperNotFound {
                        expected_path: paths.wrapper,
                        hint: "Auto-build of jackdaw_rustc_wrapper \
                               failed. Run `cargo build -p \
                               jackdaw_rustc_wrapper` from the jackdaw \
                               source checkout manually.",
                    });
                }
            }
        } else {
            return Err(BuildError::WrapperNotFound {
                expected_path: paths.wrapper,
                hint: "The jackdaw_rustc_wrapper binary was not found. \
                       Reinstall jackdaw to repair this.",
            });
        }
    }
    Ok(paths)
}

/// Build the extension or game project rooted at `project_dir`.
///
/// Convenience wrapper around
/// [`build_extension_project_with_progress`] that ignores progress.
pub fn build_extension_project(
    project_dir: &Path,
    linkage: TemplateLinkage,
) -> Result<PathBuf, BuildError> {
    build_extension_project_with_progress(project_dir, None, linkage)
}

/// Build the project and (optionally) stream progress into `sink`.
///
/// While cargo runs, a reader thread parses its stdout (JSON
/// records from `--message-format=json-render-diagnostics`) and
/// updates `sink.artifacts_done` + `sink.current_crate` on each
/// `compiler-artifact` message. A separate thread tails stderr
/// (which carries `json-render-diagnostics`' human-readable lines)
/// into `sink.recent_log_lines`.
pub fn build_extension_project_with_progress(
    project_dir: &Path,
    sink: Option<Arc<Mutex<BuildProgress>>>,
    linkage: TemplateLinkage,
) -> Result<PathBuf, BuildError> {
    let project_dir = project_dir
        .canonicalize()
        .map_err(|_| BuildError::NotADirectory(project_dir.to_path_buf()))?;

    if !project_dir.is_dir() {
        return Err(BuildError::NotADirectory(project_dir));
    }
    let manifest = project_dir.join("Cargo.toml");
    if !manifest.is_file() {
        return Err(BuildError::MissingCargoToml(project_dir));
    }

    // The wrapper only applies to dylib builds. A static project
    // depends on `jackdaw` directly; rewriting `--extern bevy` to the
    // SDK dylib gives it a bevy whose hash doesn't match what cargo
    // compiled, and the build fails with `can't find crate for jackdaw`.
    let sdk = match linkage {
        TemplateLinkage::Dylib => Some(discover_sdk()?),
        TemplateLinkage::Static => None,
    };

    // Best-effort: probe the expected artifact count via cargo
    // metadata before kicking off the real build. Runs in the
    // current thread because it's usually <1s and we want the
    // total to be present by the first frame the UI polls.
    if let Some(ref s) = sink
        && let Some(total) = estimate_total_artifacts(&project_dir)
        && let Ok(mut g) = s.lock()
    {
        g.artifacts_total = Some(total);
    }

    let mut cmd = Command::new("cargo");
    cmd.current_dir(&project_dir);
    cmd.args([
        "build",
        "--manifest-path",
        manifest
            .to_str()
            .expect("Cargo.toml path must be valid UTF-8"),
        "--message-format=json-render-diagnostics",
    ]);
    if let Some(sdk) = sdk.as_ref() {
        cmd.env("RUSTC_WRAPPER", &sdk.wrapper);
        cmd.env("JACKDAW_SDK_DYLIB", &sdk.dylib);
        cmd.env("JACKDAW_SDK_DEPS", &sdk.deps);
    }

    run_cargo_with_progress(cmd, sink.as_ref())?;

    match linkage {
        TemplateLinkage::Dylib => {
            let artifact_name = artifact_file_name(&project_dir);
            let artifact = project_dir.join("target/debug").join(&artifact_name);
            if !artifact.is_file() {
                return Err(BuildError::OutputNotProduced { expected: artifact });
            }
            Ok(artifact)
        }
        // Static builds have no cdylib to install; return the project
        // dir so the caller can `enter_project(..)` on it.
        TemplateLinkage::Static => Ok(project_dir),
    }
}

/// Build a static-template project's editor binary with the
/// `editor` cargo feature on. Used by the launcher's
/// background-build flow to produce
/// `<project>/target/debug/editor`, which carries the user's
/// `MyGamePlugin` statically linked next to jackdaw's editor stack.
///
/// Streams progress into `sink` the same way
/// [`build_extension_project_with_progress`] does. Returns the path
/// to the built binary on success; the caller can then spawn it as
/// a subprocess to hand off the editor session.
pub fn build_static_editor_with_progress(
    project_dir: &Path,
    sink: Option<Arc<Mutex<BuildProgress>>>,
) -> Result<PathBuf, BuildError> {
    let project_dir = project_dir
        .canonicalize()
        .map_err(|_| BuildError::NotADirectory(project_dir.to_path_buf()))?;
    if !project_dir.is_dir() {
        return Err(BuildError::NotADirectory(project_dir));
    }
    let manifest = project_dir.join("Cargo.toml");
    if !manifest.is_file() {
        return Err(BuildError::MissingCargoToml(project_dir));
    }

    if let Some(ref s) = sink
        && let Some(total) = estimate_total_artifacts(&project_dir)
        && let Ok(mut g) = s.lock()
    {
        g.artifacts_total = Some(total);
    }

    let mut cmd = Command::new("cargo");
    cmd.current_dir(&project_dir);
    cmd.args([
        "build",
        "--manifest-path",
        manifest
            .to_str()
            .expect("Cargo.toml path must be valid UTF-8"),
        "--bin",
        "editor",
        "--features",
        "editor",
        "--message-format=json-render-diagnostics",
    ]);

    // When the launcher is running from a jackdaw source checkout,
    // route the user-project build into a shared target dir under
    // the checkout. Cargo's content-addressable artifact reuse then
    // means jackdaw + bevy + transitive deps compile once globally
    // and incremental rebuilds finish in seconds, instead of taking
    // 2+ minutes per project per session as the user re-iterates.
    //
    // Falls through to the user-project's own `target/` for
    // released binaries (no dev checkout detected). The released
    // build path is the user's normal cargo target; nothing
    // surprising.
    let editor_target_dir = match crate::new_project::jackdaw_dev_checkout() {
        Some(checkout) => checkout.join("target").join("user-projects"),
        None => project_dir.join("target"),
    };
    let editor_target_str = editor_target_dir
        .to_str()
        .expect("CARGO_TARGET_DIR path must be valid UTF-8");
    cmd.env("CARGO_TARGET_DIR", editor_target_str);

    run_cargo_with_progress(cmd, sink.as_ref())?;

    let bin_name = if cfg!(target_os = "windows") {
        "editor.exe"
    } else {
        "editor"
    };
    let bin = editor_target_dir.join("debug").join(bin_name);
    if !bin.is_file() {
        return Err(BuildError::OutputNotProduced { expected: bin });
    }
    Ok(bin)
}

/// Spawn `cmd` with stdout/stderr piped, pump cargo's JSON
/// progress stream into `sink`, and wait for completion. Marks the
/// sink `finished = true` on the way out (success or failure) and
/// returns a [`BuildError::BuildFailed`] with a short stderr tail
/// when cargo exits non-zero.
///
/// Shared between [`build_extension_project_with_progress`] (used
/// by the dylib install flow + the original Phase D scaffold-time
/// build) and [`build_static_editor_with_progress`] (used by the
/// background static-editor handoff).
fn run_cargo_with_progress(
    mut cmd: Command,
    sink: Option<&Arc<Mutex<BuildProgress>>>,
) -> Result<(), BuildError> {
    cmd.stdout(Stdio::piped());
    cmd.stderr(Stdio::piped());

    let mut child = cmd.spawn().map_err(BuildError::BuildSpawn)?;

    let stdout = child.stdout.take().expect("piped stdout");
    let stderr = child.stderr.take().expect("piped stderr");

    let stdout_sink = sink.cloned();
    let stdout_handle = thread::spawn(move || {
        let reader = BufReader::new(stdout);
        for line in reader.lines().map_while(Result::ok) {
            parse_json_line(&line, stdout_sink.as_ref());
        }
    });

    let stderr_sink = sink.cloned();
    let stderr_tail: Arc<Mutex<VecDeque<String>>> =
        Arc::new(Mutex::new(VecDeque::with_capacity(LOG_TAIL_CAPACITY)));
    let stderr_tail_for_thread = Arc::clone(&stderr_tail);
    let stderr_handle = thread::spawn(move || {
        let reader = BufReader::new(stderr);
        for line in reader.lines().map_while(Result::ok) {
            // Tee to the parent's stderr too, so users running
            // `cargo run` from a terminal see the familiar
            // "Compiling X" stream alongside the launcher modal's
            // crate counter. Without this the user sees a
            // launcher window with no apparent activity for
            // several minutes on first build. `print_stderr` is
            // disallowed in the rest of the editor where logs go
            // through `tracing`, but here we're proxying child
            // process output for the human watching the terminal,
            // not emitting our own diagnostics, so the workspace
            // lint is suppressed deliberately.
            #[expect(
                clippy::print_stderr,
                reason = "tee child cargo stderr to user terminal"
            )]
            {
                eprintln!("{line}");
            }
            if let Some(ref s) = stderr_sink
                && let Ok(mut g) = s.lock()
            {
                g.push_log(line.clone());
            }
            if let Ok(mut tail) = stderr_tail_for_thread.lock() {
                if tail.len() >= LOG_TAIL_CAPACITY {
                    tail.pop_front();
                }
                tail.push_back(line);
            }
        }
    });

    let status = child.wait().map_err(BuildError::BuildSpawn)?;
    let _ = stdout_handle.join();
    let _ = stderr_handle.join();

    if let Some(s) = sink
        && let Ok(mut g) = s.lock()
    {
        g.finished = true;
    }

    if !status.success() {
        let tail = stderr_tail
            .lock()
            .map(|t| t.iter().cloned().collect::<Vec<_>>().join("\n"))
            .unwrap_or_default();
        return Err(BuildError::BuildFailed {
            status,
            stderr_tail: tail,
        });
    }
    Ok(())
}

/// Parse a single line from `cargo --message-format=json-…`. On a
/// `compiler-artifact` record, bump `artifacts_done` + update
/// `current_crate`. Errors are swallowed; cargo sometimes emits
/// non-JSON prefix lines, which we ignore.
fn parse_json_line(line: &str, sink: Option<&Arc<Mutex<BuildProgress>>>) {
    let Some(sink) = sink else { return };
    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
        return;
    };
    let reason = value.get("reason").and_then(|v| v.as_str()).unwrap_or("");
    if reason == "compiler-artifact" {
        let name = value
            .get("target")
            .and_then(|t| t.get("name"))
            .and_then(|n| n.as_str())
            .map(std::string::ToString::to_string);
        if let Ok(mut g) = sink.lock() {
            g.artifacts_done = g.artifacts_done.saturating_add(1);
            if let Some(n) = name {
                g.current_crate = Some(n);
            }
        }
    } else if reason == "compiler-message" {
        // Human-readable rendered text for warnings/errors comes in
        // the `message.rendered` field. Forward those lines into the
        // tail buffer alongside stderr's rendered output.
        if let Some(rendered) = value
            .get("message")
            .and_then(|m| m.get("rendered"))
            .and_then(|r| r.as_str())
            && let Ok(mut g) = sink.lock()
        {
            for l in rendered.lines().take(LOG_TAIL_CAPACITY) {
                g.push_log(l.to_string());
            }
        }
    }
}

/// Run `cargo metadata` to count the packages in the resolve set.
/// Returns `None` on any failure; the progress UI will render an
/// indeterminate bar instead.
///
/// On a fresh bevy project the first `cargo metadata` call takes a
/// few seconds (it resolves the dep graph and hits the registry).
/// Subsequent calls use cargo's cache and finish in <200 ms. The
/// caller runs this on the main thread before spawning the real
/// build, so the progress bar can render a denominator from the
/// first frame onward.
fn estimate_total_artifacts(project_dir: &Path) -> Option<u32> {
    let output = Command::new("cargo")
        .current_dir(project_dir)
        .args(["metadata", "--format-version=1"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let value: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
    let packages = value.get("packages")?.as_array()?;
    // Each package produces roughly one artifact; build scripts and
    // proc-macros add a few more. Close enough for a progress bar.
    Some(packages.len() as u32)
}

/// Run `cargo clean -p <package>` for the project rooted at
/// `project_dir`. Used by the auto-recovery path: when the editor
/// SDK is rebuilt, the user's project `.so` cached in
/// `<project>/target/debug/` still references the old SDK symbol
/// hashes. Cleaning just the user's package (not `-p bevy`) drops
/// that stale artifact without forcing a multi-minute bevy
/// rebuild; the bevy rlib cache stays.
///
/// Blocks until cargo exits. Call from a task pool.
pub fn cargo_clean_project(project_dir: &Path) -> Result<(), BuildError> {
    let project_dir = project_dir
        .canonicalize()
        .map_err(|_| BuildError::NotADirectory(project_dir.to_path_buf()))?;
    let manifest = project_dir.join("Cargo.toml");
    if !manifest.is_file() {
        return Err(BuildError::MissingCargoToml(project_dir));
    }
    let package_name = package_name_from_manifest(&project_dir);

    let mut cmd = Command::new("cargo");
    cmd.current_dir(&project_dir);
    cmd.args([
        "clean",
        "--manifest-path",
        manifest
            .to_str()
            .expect("Cargo.toml path must be valid UTF-8"),
        "-p",
        &package_name,
    ]);
    let output = cmd.output().map_err(BuildError::BuildSpawn)?;
    if !output.status.success() {
        return Err(BuildError::BuildFailed {
            status: output.status,
            stderr_tail: String::from_utf8_lossy(&output.stderr).into_owned(),
        });
    }
    Ok(())
}

/// Parse `name = "..."` out of a project's `Cargo.toml`. Shared
/// with [`artifact_file_name`]; when the manifest doesn't declare
/// a name (shouldn't happen for anything cargo accepted), returns
/// `"unnamed"`.
fn package_name_from_manifest(project_dir: &Path) -> String {
    std::fs::read_to_string(project_dir.join("Cargo.toml"))
        .ok()
        .and_then(|contents| {
            contents.lines().find_map(|line| {
                let trimmed = line.trim();
                trimmed
                    .strip_prefix("name")
                    .and_then(|rest| rest.trim().strip_prefix('='))
                    .map(|rest| rest.trim().trim_matches('"').trim_matches('\'').to_owned())
            })
        })
        .unwrap_or_else(|| "unnamed".to_string())
}

/// Quick scan of a project's `Cargo.toml` to decide whether
/// `cargo build` would actually produce a cdylib. Used at project-
/// open time: if the manifest is a plain binary crate (e.g., the
/// editor's own source tree, or a user opening any non-extension
/// cargo project) we skip the build pipeline entirely and let them
/// in; otherwise `cargo build` compiles the whole dep tree only to
/// fail the artifact check at the end.
///
/// Same line-based parsing style as [`package_name_from_manifest`]
/// to avoid pulling in a toml dep. Handles the two shapes scaffolded
/// projects use: `crate-type = ["cdylib"]` and
/// `crate-type = ["rlib", "cdylib"]`.
pub(crate) fn manifest_declares_cdylib(project_dir: &Path) -> bool {
    let Ok(contents) = std::fs::read_to_string(project_dir.join("Cargo.toml")) else {
        return false;
    };
    contents.lines().any(|line| {
        let trimmed = line.trim();
        let Some(rest) = trimmed.strip_prefix("crate-type") else {
            return false;
        };
        let Some(rest) = rest.trim_start().strip_prefix('=') else {
            return false;
        };
        rest.contains("\"cdylib\"") || rest.contains("'cdylib'")
    })
}

/// Derive the expected cdylib filename from the project's package
/// name. Falls back to `libunnamed.<ext>` if the manifest doesn't
/// declare a name (which cargo would have rejected anyway, but it
/// keeps this helper infallible).
pub(crate) fn artifact_file_name(project_dir: &Path) -> String {
    let package_name = package_name_from_manifest(project_dir);

    if cfg!(target_os = "windows") {
        format!("{package_name}.dll")
    } else if cfg!(target_os = "macos") {
        format!("lib{package_name}.dylib")
    } else {
        format!("lib{package_name}.so")
    }
}