use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use crate::config::FormatterConfig;
fn rust_edition_from_cargo(cwd: &Path) -> Option<String> {
let mut dir = Some(cwd);
while let Some(d) = dir {
let cargo = d.join("Cargo.toml");
if let Ok(text) = std::fs::read_to_string(&cargo)
&& let Ok(value) = text.parse::<toml::Value>()
{
if let Some(ed) = value
.get("package")
.and_then(|p| p.get("edition"))
.and_then(|e| e.as_str())
{
return Some(ed.to_string());
}
if let Some(ed) = value
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("edition"))
.and_then(|e| e.as_str())
{
return Some(ed.to_string());
}
}
dir = d.parent();
}
None
}
fn has_rustfmt_config(cwd: &Path) -> bool {
let mut dir = Some(cwd);
while let Some(d) = dir {
if d.join("rustfmt.toml").is_file() || d.join(".rustfmt.toml").is_file() {
return true;
}
dir = d.parent();
}
false
}
fn effective_args(formatter: &FormatterConfig, cwd: &Path) -> Vec<String> {
let mut args = formatter.args.clone();
let is_rustfmt = Path::new(&formatter.command)
.file_stem()
.and_then(|s| s.to_str())
== Some("rustfmt");
if is_rustfmt
&& !args
.iter()
.any(|a| a == "--edition" || a.starts_with("--edition="))
&& !has_rustfmt_config(cwd)
&& let Some(edition) = rust_edition_from_cargo(cwd)
{
args.push(format!("--edition={edition}"));
}
args
}
const EXTERNAL_TIMEOUT: Duration = Duration::from_secs(5);
const POLL_INTERVAL: Duration = Duration::from_millis(20);
pub fn run_external(formatter: &FormatterConfig, text: &str, cwd: &Path) -> Result<String> {
let args = effective_args(formatter, cwd);
let mut child = Command::new(&formatter.command)
.args(&args)
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawning formatter `{}`", formatter.command))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.with_context(|| format!("writing stdin to `{}`", formatter.command))?;
}
let deadline = Instant::now() + EXTERNAL_TIMEOUT;
loop {
match child
.try_wait()
.with_context(|| format!("polling formatter `{}`", formatter.command))?
{
Some(_) => break,
None => {
if Instant::now() >= deadline {
let _ = child.kill();
bail!(
"formatter `{}` timed out after {:?}",
formatter.command,
EXTERNAL_TIMEOUT
);
}
std::thread::sleep(POLL_INTERVAL);
}
}
}
let output = child
.wait_with_output()
.with_context(|| format!("collecting output from `{}`", formatter.command))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = stderr.trim();
if msg.is_empty() {
bail!(
"formatter `{}` exited with {}",
formatter.command,
output.status
);
}
bail!("formatter `{}`: {}", formatter.command, msg);
}
let out = String::from_utf8(output.stdout).with_context(|| {
format!(
"formatter `{}` produced non-UTF-8 output",
formatter.command
)
})?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_external_pipes_stdin_through_cat() {
let f = FormatterConfig {
command: "cat".into(),
args: vec![],
};
let out = run_external(&f, "hello\nworld\n", Path::new(".")).unwrap();
assert_eq!(out, "hello\nworld\n");
}
#[test]
fn run_external_surfaces_nonzero_exit_with_stderr() {
let f = FormatterConfig {
command: "sh".into(),
args: vec!["-c".into(), "cat >&2; exit 1".into()],
};
let err = run_external(&f, "boom", Path::new("."))
.unwrap_err()
.to_string();
assert!(err.contains("boom"), "stderr should bubble up: {}", err);
}
#[test]
fn run_external_reports_spawn_failure() {
let f = FormatterConfig {
command: "this-binary-does-not-exist-zzz".into(),
args: vec![],
};
assert!(run_external(&f, "x", Path::new(".")).is_err());
}
fn fresh_tmp(label: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!(
"vorto-format-{}-{}-{}",
label,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn rust_edition_picked_up_from_package() {
let root = fresh_tmp("pkg");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n",
)
.unwrap();
assert_eq!(rust_edition_from_cargo(&root), Some("2024".into()));
}
#[test]
fn rust_edition_inherited_from_workspace() {
let root = fresh_tmp("ws");
let crate_dir = root.join("crates/inner");
std::fs::create_dir_all(&crate_dir).unwrap();
std::fs::write(
root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/inner\"]\n\n[workspace.package]\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(
crate_dir.join("Cargo.toml"),
"[package]\nname = \"inner\"\nedition.workspace = true\n",
)
.unwrap();
assert_eq!(rust_edition_from_cargo(&crate_dir), Some("2021".into()));
}
#[test]
fn effective_args_injects_edition_for_rustfmt() {
let root = fresh_tmp("inj");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n",
)
.unwrap();
let f = FormatterConfig {
command: "rustfmt".into(),
args: vec![],
};
assert_eq!(
effective_args(&f, &root),
vec!["--edition=2024".to_string()]
);
}
#[test]
fn effective_args_respects_user_edition() {
let root = fresh_tmp("usr");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n",
)
.unwrap();
let f = FormatterConfig {
command: "rustfmt".into(),
args: vec!["--edition=2021".into()],
};
assert_eq!(
effective_args(&f, &root),
vec!["--edition=2021".to_string()]
);
}
#[test]
fn effective_args_backs_off_when_rustfmt_toml_present() {
let root = fresh_tmp("cfg");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::write(root.join("rustfmt.toml"), "edition = \"2021\"\n").unwrap();
let f = FormatterConfig {
command: "rustfmt".into(),
args: vec![],
};
assert!(effective_args(&f, &root).is_empty());
}
#[test]
fn effective_args_noop_for_non_rustfmt() {
let root = fresh_tmp("other");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n",
)
.unwrap();
let f = FormatterConfig {
command: "gofmt".into(),
args: vec![],
};
assert!(effective_args(&f, &root).is_empty());
}
}