use std::time::{SystemTime, UNIX_EPOCH};
use shellexpand::full_with_context_no_errors;
use std::fmt::Write;
fn parse_bool_env(val: &str) -> bool {
matches!(val.to_lowercase().trim(), "true" | "1" | "yes")
}
fn is_cache_invalidate_set() -> bool {
std::env::var("INLINE_CSHARP_CACHE_INVALIDATE").is_ok_and(|v| parse_bool_env(&v))
}
const CSHARP_RELATED_EXTENSIONS: &[&str] = &["cs", "dll", "nupkg", "zip"];
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)
}
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()))
}
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);
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"))
}
#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
pub enum CsharpError {
#[error("inline_csharp: I/O error: {0}")]
Io(String),
#[error("inline_csharp: dotnet build failed:\n{0}")]
CompilationFailed(String),
#[error("inline_csharp: dotnet runtime failed:\n{0}")]
RuntimeFailed(String),
#[error("inline_csharp: C# output is not valid UTF-8: {0}")]
InvalidUtf8(#[from] std::string::FromUtf8Error),
#[error("inline_csharp: C# char is not a valid Unicode scalar value")]
InvalidChar,
}
#[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)
}
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
}
#[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")
}
#[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); std::env::current_dir().ok().hash(&mut h); run_raw.hash(&mut h);
references.hash(&mut h);
target_framework.hash(&mut h);
references_max_mtime_nanos(references).hash(&mut h);
let hex = format!("{:016x}", h.finish());
base_cache_dir().join(format!("{class_name}_{hex}"))
}
#[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();
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
}
#[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;
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,
);
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() {
std::fs::write(tmp_dir.join(format!("{class_name}.cs")), csharp_source)
.map_err(|e| CsharpError::Io(e.to_string()))?;
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()))?;
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() {
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()))?;
}
}
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()))?;
if stdin_bytes.is_empty() {
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;
#[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"
);
}
#[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"
);
}
#[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");
}
#[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");
}
#[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");
}
#[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}"
);
}
#[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");
}
#[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"));
}
#[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:?}");
}
}
#[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);
}
#[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");
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");
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"
);
}
}