inline_csharp_core 0.1.1

Runtime support crate for inline_csharp — not intended for direct use
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
//! Core runtime support for `inline_csharp`.
//!
//! This crate is an implementation detail of `inline_csharp_macros`.  End users
//! should depend on `inline_csharp` instead of this crate directly.
//!
//! Public items:
//!
//! - [`CsharpError`] — error type returned by [`run_csharp`] and by the `csharp!` /
//!   `csharp_fn!` macros at program runtime.
//! - [`run_csharp`] — compile (if needed) and run a generated C# class.
//! - [`expand_dotnet_args`] — shell-expand an option string into individual args.
//! - [`cache_dir`] — compute the deterministic temp-dir path for a C# class.

use std::time::{SystemTime, UNIX_EPOCH};

use shellexpand::full_with_context_no_errors;
use std::fmt::Write;

/// Parse a string value as a boolean flag: `"true"`, `"1"`, and `"yes"`
/// (case-insensitive) are truthy; everything else is falsy.
fn parse_bool_env(val: &str) -> bool {
	matches!(val.to_lowercase().trim(), "true" | "1" | "yes")
}

/// Return `true` when the `INLINE_CSHARP_CACHE_INVALIDATE` environment variable
/// is set to a truthy value (`"true"`, `"1"`, or `"yes"`, case-insensitive).
fn is_cache_invalidate_set() -> bool {
	std::env::var("INLINE_CSHARP_CACHE_INVALIDATE").is_ok_and(|v| parse_bool_env(&v))
}

/// File extensions considered C#/dotnet-related when scanning directories for
/// mtime changes.  Individual files listed explicitly in references are always
/// tracked regardless of extension.
const CSHARP_RELATED_EXTENSIONS: &[&str] = &["cs", "dll", "nupkg", "zip"];

/// Walk `dir` recursively and return the maximum modification time of any file
/// whose extension is one of [`CSHARP_RELATED_EXTENSIONS`].  Returns `None` if
/// no matching files are found or if `dir` cannot be read.
fn max_mtime_in_dir(dir: &std::path::Path) -> Option<SystemTime> {
	walkdir::WalkDir::new(dir)
		.into_iter()
		.filter_map(Result::ok)
		.filter(|e| e.file_type().is_file())
		.filter(|e| {
			e.path()
				.extension()
				.and_then(|ext| ext.to_str())
				.is_some_and(|ext| CSHARP_RELATED_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
		})
		.filter_map(|e| e.metadata().ok()?.modified().ok())
		.reduce(Ord::max)
}

/// Return the maximum modification time of any file referenced in `references`
/// as nanoseconds since the Unix epoch.
///
/// - If a reference path is a **file**, its mtime is tracked directly.
/// - If a reference path is a **directory**, it is walked recursively and the
///   maximum mtime of any [`CSHARP_RELATED_EXTENSIONS`] file is taken.
///
/// Returns `None` if `references` is empty or no mtimes could be read.
fn references_max_mtime_nanos(references: &[std::path::PathBuf]) -> Option<u128> {
	let max_mtime: Option<SystemTime> = references.iter().fold(None, |acc, path| {
		let mtime = if path.is_dir() {
			max_mtime_in_dir(path)
		} else if path.is_file() {
			path.metadata().ok()?.modified().ok()
		} else {
			None
		};
		match (acc, mtime) {
			(Some(a), Some(b)) => Some(a.max(b)),
			(a, b) => a.or(b),
		}
	});
	max_mtime.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok().map(|d| d.as_nanos()))
}

/// Detect the installed .NET target framework moniker (e.g. `"net8.0"`) by
/// running `dotnet --version` and extracting the major version number.
///
/// # Errors
///
/// Returns [`CsharpError::Io`] if `dotnet` cannot be spawned or its output
/// cannot be parsed as a version string.
pub fn detect_target_framework() -> Result<String, CsharpError> {
	let output = std::process::Command::new("dotnet")
		.arg("--version")
		.output()
		.map_err(|e| CsharpError::Io(format!("failed to run `dotnet --version`: {e}")))?;
	let stdout = String::from_utf8_lossy(&output.stdout);
	// Strip diagnostic lines (warnings, perf notes) that may appear before
	// the actual version line when other dotnet processes hold locks.
	// The version line starts with a digit (e.g. "8.0.125").
	let version_line = stdout
		.lines()
		.find(|l| l.trim().starts_with(|c: char| c.is_ascii_digit()))
		.unwrap_or(stdout.trim());
	let major = version_line
		.trim()
		.split('.')
		.next()
		.and_then(|s| s.parse::<u32>().ok())
		.ok_or_else(|| {
			CsharpError::Io(format!(
				"could not parse major version from `dotnet --version` output: {stdout:?}"
			))
		})?;
	Ok(format!("net{major}.0"))
}

/// All errors that `csharp!` and `csharp_fn!` can return at runtime (and that
/// `ct_csharp!` maps to `compile_error!` diagnostics at build time).
#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
pub enum CsharpError {
	/// An I/O error while creating the temp directory, writing the source
	/// file, or spawning `dotnet` (e.g. the binary is not on `PATH`).
	#[error("inline_csharp: I/O error: {0}")]
	Io(String),

	/// `dotnet build` exited with a non-zero status.  The `0` field contains
	/// the compiler diagnostic output (stderr).
	#[error("inline_csharp: dotnet build failed:\n{0}")]
	CompilationFailed(String),

	/// The dotnet runtime exited with a non-zero status (e.g. an unhandled
	/// exception).  The `0` field contains the exception message and stack
	/// trace (stderr).
	#[error("inline_csharp: dotnet runtime failed:\n{0}")]
	RuntimeFailed(String),

	/// The C# program returned bytes that are not valid UTF-8.
	#[error("inline_csharp: C# output is not valid UTF-8: {0}")]
	InvalidUtf8(#[from] std::string::FromUtf8Error),

	/// The C# program returned a `char` value that is not a valid Unicode
	/// scalar (i.e. a lone surrogate half).
	#[error("inline_csharp: C# char is not a valid Unicode scalar value")]
	InvalidChar,
}

/// Shell-expand `raw` (expanding env vars and `~`), then split into individual
/// arguments (respecting quotes).
/// Returns an empty vec if `raw` is empty.
///
/// # Examples
///
/// ```rust
/// use inline_csharp_core::expand_dotnet_args;
///
/// let args = expand_dotnet_args("--configuration Release --nologo");
/// assert_eq!(args, vec!["--configuration", "Release", "--nologo"]);
///
/// let empty = expand_dotnet_args("");
/// assert!(empty.is_empty());
/// ```
#[must_use]
pub fn expand_dotnet_args(raw: &str) -> Vec<String> {
	if raw.is_empty() {
		return Vec::new();
	}
	let expanded = full_with_context_no_errors(
		raw,
		|| std::env::var("HOME").ok(),
		|var| std::env::var(var).ok(),
	);
	split_args(&expanded)
}

/// Split a shell-style argument string into individual arguments, respecting
/// single- and double-quoted spans.
fn split_args(s: &str) -> Vec<String> {
	let mut args: Vec<String> = Vec::new();
	let mut cur = String::new();
	let mut in_single = false;
	let mut in_double = false;

	for ch in s.chars() {
		match ch {
			'\'' if !in_double => in_single = !in_single,
			'"' if !in_single => in_double = !in_double,
			' ' | '\t' if !in_single && !in_double => {
				if !cur.is_empty() {
					args.push(std::mem::take(&mut cur));
				}
			}
			_ => cur.push(ch),
		}
	}
	if !cur.is_empty() {
		args.push(cur);
	}
	args
}

/// Resolve the root directory used to cache compiled C# assemblies.
///
/// Resolution order:
/// 1. `INLINE_CSHARP_CACHE_DIR` environment variable, if set and non-empty.
/// 2. The XDG / platform cache directory (`~/.cache/inline_csharp` on Linux,
///    `~/Library/Caches/inline_csharp` on macOS,
///    `%LOCALAPPDATA%\inline_csharp` on Windows) via the [`dirs`] crate.
/// 3. `<system_temp>/inline_csharp` as a final fallback.
#[must_use]
pub fn base_cache_dir() -> std::path::PathBuf {
	if let Ok(v) = std::env::var("INLINE_CSHARP_CACHE_DIR")
		&& !v.is_empty()
	{
		return std::path::PathBuf::from(v);
	}
	if let Some(cache) = dirs::cache_dir() {
		return cache.join("inline_csharp");
	}
	std::env::temp_dir().join("inline_csharp")
}

/// Compute the deterministic cache-dir path used to store compiled C# assemblies.
///
/// The path is `<base_cache_dir>/<class_name>_<hex_hash>/` where `hex_hash` is a
/// 64-bit hash of:
/// - `csharp_source` — the complete C# source text
/// - `expand_dotnet_args(build_raw)` — shell-expanded build args
/// - `std::env::current_dir()` — the process working directory at call time
/// - `run_raw` — hashed as a raw string
/// - `references` — the list of reference DLL paths
/// - `target_framework` — the TFM moniker (e.g. `"net8.0"`)
/// - maximum mtime (nanoseconds since Unix epoch) of any C#/dotnet-related file
///   (`.cs`, `.dll`, `.nupkg`, `.zip`) found under directories in `references`,
///   or the mtime of individual files listed there; `None` is hashed when no
///   such paths are present
///
/// The base directory is resolved by [`base_cache_dir`].
#[must_use]
#[allow(clippy::similar_names)]
pub fn cache_dir(
	class_name: &str,
	csharp_source: &str,
	build_raw: &str,
	run_raw: &str,
	references: &[std::path::PathBuf],
	target_framework: &str,
) -> std::path::PathBuf {
	use std::collections::hash_map::DefaultHasher;
	use std::hash::{Hash, Hasher};

	let mut h = DefaultHasher::new();
	csharp_source.hash(&mut h);
	expand_dotnet_args(build_raw).hash(&mut h); // shell-expanded; CWD handles relative paths
	std::env::current_dir().ok().hash(&mut h); // anchors relative paths in build_raw
	run_raw.hash(&mut h);
	references.hash(&mut h);
	target_framework.hash(&mut h);
	references_max_mtime_nanos(references).hash(&mut h); // tracks external file changes

	let hex = format!("{:016x}", h.finish());
	base_cache_dir().join(format!("{class_name}_{hex}"))
}

/// Generate the XML content for a `.csproj` file.
///
/// `target_framework` is the TFM moniker (e.g. `"net8.0"`, `"net10.0"`)
/// returned by [`detect_target_framework`].  When `references` is non-empty,
/// an `<ItemGroup>` with `<Reference>` entries is included; `references` are
/// expected to be absolute DLL paths.
#[must_use]
pub fn generate_csproj(
	class_name: &str,
	target_framework: &str,
	references: &[std::path::PathBuf],
) -> String {
	let mut xml = format!(
		r#"<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>{target_framework}</TargetFramework>
    <AssemblyName>{class_name}</AssemblyName>
    <Nullable>enable</Nullable>
    <ImplicitUsings>disable</ImplicitUsings>
    <Optimize>true</Optimize>
  </PropertyGroup>
"#
	);

	if !references.is_empty() {
		xml.push_str("  <ItemGroup>\n");
		for r in references {
			let path = r.to_string_lossy();
			// Use the DLL stem as the assembly identity and <HintPath> for the
			// file path.  SDK-style projects treat `Include` as an assembly
			// name, not a file path, so a bare path in `Include` is silently
			// ignored by the resolver.
			let name = r
				.file_stem()
				.map(|s| s.to_string_lossy())
				.unwrap_or(path.clone());
			let _ = write!(
				xml,
				"    <Reference Include=\"{name}\">\n\
				       <HintPath>{path}</HintPath>\n\
				     </Reference>\n"
			);
		}
		xml.push_str("  </ItemGroup>\n");
	}

	xml.push_str("</Project>\n");
	xml
}

/// Compile (if needed) and run a generated C# class, returning raw stdout bytes.
///
/// Both the compile step (`dotnet build`) and the run step (`dotnet <dll>`) are
/// guarded by a per-class-name file lock so that concurrent invocations cooperate
/// correctly.  A `.done` sentinel and an optimistic pre-check skip recompilation
/// on subsequent calls without acquiring the lock.
///
/// - `class_name`    — bare class name; used as the project/file name.
/// - `csharp_source` — complete `.cs` source to write.
/// - `build_raw`     — raw `build = "..."` option string (shell-expanded).
/// - `run_raw`       — raw `run = "..."` option string (shell-expanded).
/// - `references`    — paths to reference DLLs (may be relative; will be absolutized).
/// - `stdin_bytes`   — bytes to pipe to the child process's stdin (may be empty).
///
/// # Cache invalidation
///
/// If the `INLINE_CSHARP_CACHE_INVALIDATE` environment variable is set to a
/// truthy value (`"true"`, `"1"`, or `"yes"`, case-insensitive), the cache
/// directory for this class is removed before compilation, forcing a full
/// recompile regardless of the `.done` sentinel.
///
/// # Errors
///
/// Returns [`CsharpError::Io`] if the temp directory, source file, or lock file
/// cannot be created, or if `dotnet` cannot be spawned.
/// Returns [`CsharpError::CompilationFailed`] if `dotnet build` exits with a non-zero status.
/// Returns [`CsharpError::RuntimeFailed`] if `dotnet <dll>` exits with a non-zero status.
#[allow(clippy::similar_names)]
pub fn run_csharp(
	class_name: &str,
	csharp_source: &str,
	build_raw: &str,
	run_raw: &str,
	references: &[&str],
	stdin_bytes: &[u8],
) -> Result<Vec<u8>, CsharpError> {
	use std::io::Write;
	use std::process::Stdio;

	// Absolutize reference paths via current_dir().join(path) — skip
	// canonicalize to avoid "file not found" for not-yet-existing paths.
	let cwd = std::env::current_dir().map_err(|e| CsharpError::Io(e.to_string()))?;
	let abs_refs: Vec<std::path::PathBuf> = references.iter().map(|r| cwd.join(r)).collect();

	let tfm = detect_target_framework()?;
	let tmp_dir = cache_dir(
		class_name,
		csharp_source,
		build_raw,
		run_raw,
		&abs_refs,
		&tfm,
	);

	// Force recompilation when INLINE_CSHARP_CACHE_INVALIDATE is set.
	if is_cache_invalidate_set() && tmp_dir.exists() {
		std::fs::remove_dir_all(&tmp_dir).map_err(|e| CsharpError::Io(e.to_string()))?;
	}

	let build_extra = expand_dotnet_args(build_raw);
	let run_extra = expand_dotnet_args(run_raw);

	if !tmp_dir.join(".done").exists() {
		std::fs::create_dir_all(&tmp_dir).map_err(|e| CsharpError::Io(e.to_string()))?;

		let lock_file = std::fs::OpenOptions::new()
			.create(true)
			.truncate(false)
			.write(true)
			.open(tmp_dir.join(".lock"))
			.map_err(|e| CsharpError::Io(e.to_string()))?;
		let mut lock = fd_lock::RwLock::new(lock_file);
		let _guard = lock.write().map_err(|e| CsharpError::Io(e.to_string()))?;

		if !tmp_dir.join(".done").exists() {
			// Write the C# source file.
			std::fs::write(tmp_dir.join(format!("{class_name}.cs")), csharp_source)
				.map_err(|e| CsharpError::Io(e.to_string()))?;

			// Write the .csproj file.
			std::fs::write(
				tmp_dir.join(format!("{class_name}.csproj")),
				generate_csproj(class_name, &tfm, &abs_refs),
			)
			.map_err(|e| CsharpError::Io(e.to_string()))?;

			// Run: dotnet build <class_name>.csproj <build_extra> -o <tmp_dir>/out/ --nologo
			let mut cmd = std::process::Command::new("dotnet");
			cmd.arg("build")
				.arg(format!("{class_name}.csproj"))
				.args(&build_extra)
				.arg("-o")
				.arg(tmp_dir.join("out"))
				.arg("--nologo")
				.current_dir(&tmp_dir);

			let out = cmd.output().map_err(|e| CsharpError::Io(e.to_string()))?;
			if !out.status.success() {
				// Note: `dotnet build` writes compiler errors to stdout, not stderr.
				// Ref. https://github.com/dotnet/sdk/issues/8481
				return Err(CsharpError::CompilationFailed(
					String::from_utf8_lossy(&out.stdout).into_owned(),
				));
			}

			std::fs::write(tmp_dir.join(".done"), b"")
				.map_err(|e| CsharpError::Io(e.to_string()))?;
		}
	}

	// Run phase: dotnet <tmp_dir>/out/<class_name>.dll <run_extra>
	let dll_path = tmp_dir.join("out").join(format!("{class_name}.dll"));
	let mut cmd = std::process::Command::new("dotnet");
	cmd.arg(&dll_path);
	for arg in &run_extra {
		cmd.arg(arg);
	}
	let mut child = cmd
		.stdin(Stdio::piped())
		.stdout(Stdio::piped())
		.stderr(Stdio::piped())
		.spawn()
		.map_err(|e| CsharpError::Io(e.to_string()))?;

	// Write stdin bytes then drop the handle to signal EOF.
	if stdin_bytes.is_empty() {
		// Drop stdin handle even when empty so the process doesn't block waiting.
		drop(child.stdin.take());
	} else if let Some(mut stdin_handle) = child.stdin.take() {
		stdin_handle
			.write_all(stdin_bytes)
			.map_err(|e| CsharpError::Io(e.to_string()))?;
	}

	let out = child
		.wait_with_output()
		.map_err(|e| CsharpError::Io(e.to_string()))?;

	if !out.status.success() {
		return Err(CsharpError::RuntimeFailed(
			String::from_utf8_lossy(&out.stderr).into_owned(),
		));
	}

	Ok(out.stdout)
}

#[cfg(test)]
mod tests {
	use std::time::{Duration, SystemTime, UNIX_EPOCH};

	use super::cache_dir;

	// -----------------------------------------------------------------------
	// cache_dir is idempotent: two calls with identical arguments return the
	// same path.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_dir_idempotent() {
		let a = cache_dir("MyClass", "class body", "-v quiet", "", &[], "net8.0");
		let b = cache_dir("MyClass", "class body", "-v quiet", "", &[], "net8.0");
		assert_eq!(
			a, b,
			"cache_dir must return the same path for identical args"
		);
	}

	// -----------------------------------------------------------------------
	// cache_dir produces different paths for build_raw strings that expand to
	// different argument lists.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_dir_differs_for_different_build_raw() {
		let a = cache_dir(
			"MyClass",
			"class body",
			"--configuration Debug",
			"",
			&[],
			"net8.0",
		);
		let b = cache_dir(
			"MyClass",
			"class body",
			"--configuration Release",
			"",
			&[],
			"net8.0",
		);
		assert_ne!(
			a, b,
			"cache_dir must differ when build_raw expands to different args"
		);
	}

	// -----------------------------------------------------------------------
	// cache_dir produces different paths when csharp_source differs.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_dir_differs_for_different_csharp_source() {
		let a = cache_dir("MyClass", "class body A", "", "", &[], "net8.0");
		let b = cache_dir("MyClass", "class body B", "", "", &[], "net8.0");
		assert_ne!(a, b, "cache_dir must differ when csharp_source differs");
	}

	// -----------------------------------------------------------------------
	// cache_dir produces different paths when run_raw differs.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_dir_differs_for_different_run_raw() {
		let a = cache_dir(
			"MyClass",
			"class body",
			"",
			"--rollForward Major",
			&[],
			"net8.0",
		);
		let b = cache_dir(
			"MyClass",
			"class body",
			"",
			"--rollForward Minor",
			&[],
			"net8.0",
		);
		assert_ne!(a, b, "cache_dir must differ when run_raw differs");
	}

	// -----------------------------------------------------------------------
	// cache_dir produces different paths when references differ.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_dir_differs_for_different_references() {
		let refs_a = vec![std::path::PathBuf::from("/path/to/Foo.dll")];
		let refs_b = vec![std::path::PathBuf::from("/path/to/Bar.dll")];
		let a = cache_dir("MyClass", "class body", "", "", &refs_a, "net8.0");
		let b = cache_dir("MyClass", "class body", "", "", &refs_b, "net8.0");
		assert_ne!(a, b, "cache_dir must differ when references differ");
	}

	// -----------------------------------------------------------------------
	// cache_dir result is inside base_cache_dir and uses the class_name as a
	// prefix.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_dir_path_structure() {
		let result = cache_dir("InlineCsharp_abc123", "src", "", "", &[], "net8.0");
		let base = super::base_cache_dir();
		assert!(
			result.starts_with(&base),
			"cache_dir result must be under base_cache_dir ({}); got: {}",
			base.display(),
			result.display()
		);
		let file_name = result.file_name().unwrap().to_string_lossy();
		assert!(
			file_name.starts_with("InlineCsharp_abc123_"),
			"cache_dir result filename must start with the class name; got: {file_name}"
		);
	}

	// -----------------------------------------------------------------------
	// cache_dir produces different paths when target_framework differs.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_dir_differs_for_different_target_framework() {
		let a = cache_dir("MyClass", "class body", "", "", &[], "net8.0");
		let b = cache_dir("MyClass", "class body", "", "", &[], "net10.0");
		assert_ne!(a, b, "cache_dir must differ when target_framework differs");
	}

	// -----------------------------------------------------------------------
	// INLINE_CSHARP_CACHE_DIR env var overrides the base cache directory.
	// -----------------------------------------------------------------------
	#[test]
	fn base_cache_dir_respects_env_var() {
		unsafe { std::env::set_var("INLINE_CSHARP_CACHE_DIR", "/custom/cache") };
		let base = super::base_cache_dir();
		unsafe { std::env::remove_var("INLINE_CSHARP_CACHE_DIR") };
		assert_eq!(base, std::path::PathBuf::from("/custom/cache"));
	}

	// -----------------------------------------------------------------------
	// parse_bool_env recognises truthy values and rejects others.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_invalidate_truthy_values() {
		for val in &["true", "True", "TRUE", "1", "yes", "YES"] {
			assert!(super::parse_bool_env(val), "expected truthy for {val:?}");
		}
	}

	#[test]
	fn cache_invalidate_falsy_values() {
		for val in &["false", "False", "0", "no", ""] {
			assert!(!super::parse_bool_env(val), "expected falsy for {val:?}");
		}
	}

	// -----------------------------------------------------------------------
	// references_max_mtime_nanos returns None for empty references.
	// -----------------------------------------------------------------------
	#[test]
	fn mtime_nanos_empty_refs() {
		assert_eq!(super::references_max_mtime_nanos(&[]), None);
	}

	#[test]
	fn mtime_nanos_nonexistent_path_returns_none() {
		let refs = vec![std::path::PathBuf::from("/this/path/does/not/exist/at/all")];
		assert_eq!(super::references_max_mtime_nanos(&refs), None);
	}

	// -----------------------------------------------------------------------
	// cache_dir changes when a referenced DLL file is modified.
	// -----------------------------------------------------------------------
	#[test]
	fn cache_dir_differs_after_reference_modification() {
		use std::io::Write;

		let tmp = std::env::temp_dir().join(format!(
			"inline_csharp_mtime_test_{}",
			SystemTime::now()
				.duration_since(UNIX_EPOCH)
				.unwrap_or_default()
				.as_nanos()
		));
		std::fs::create_dir_all(&tmp).expect("create temp dir");
		let dll_file = tmp.join("Foo.dll");

		// Write initial content and record cache dir.
		std::fs::write(&dll_file, b"fake dll content").expect("write dll file");
		let refs_before = vec![dll_file.clone()];
		let before = cache_dir("MyClass", "class body", "", "", &refs_before, "net8.0");

		// Touch the file (update mtime) — sleep briefly to guarantee a
		// different mtime even on filesystems with 1s resolution.
		std::thread::sleep(Duration::from_millis(1100));
		let mut f = std::fs::OpenOptions::new()
			.append(true)
			.open(&dll_file)
			.expect("open for append");
		f.write_all(b" ").expect("append byte");
		drop(f);

		let refs_after = vec![dll_file.clone()];
		let after = cache_dir("MyClass", "class body", "", "", &refs_after, "net8.0");

		std::fs::remove_dir_all(&tmp).ok();

		assert_ne!(
			before, after,
			"cache_dir must differ after a referenced DLL file is modified"
		);
	}
}