use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::model::App;
pub const HEADER: &str = "# Managed by turnout - the line below follows the app's gateway port.";
#[derive(Debug, PartialEq)]
pub enum Outcome {
Nothing,
Unchanged,
Written(String),
Removed,
}
pub fn write(app: &App) -> Result<Outcome> {
let dir = Path::new(&app.path);
if !dir.is_dir() {
return Ok(Outcome::Nothing);
}
let path = dir.join(app.env_file_name());
let existing = match std::fs::read_to_string(&path) {
Ok(text) => Some(text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
};
let Some(url) = app.gateway_url() else {
return match existing {
Some(text) if text.contains(HEADER) => {
let cleared = without_block(&text);
if cleared.trim().is_empty() {
std::fs::remove_file(&path).with_context(|| format!("cannot remove {}", path.display()))?;
} else {
std::fs::write(&path, cleared).with_context(|| format!("cannot write {}", path.display()))?;
}
Ok(Outcome::Removed)
}
_ => Ok(Outcome::Nothing),
};
};
let assignment = format!("{}={url}", app.gateway_env_name());
let rendered = render(existing.as_deref().unwrap_or(""), &assignment);
if existing.as_deref() == Some(rendered.as_str()) {
return Ok(Outcome::Unchanged);
}
std::fs::write(&path, &rendered).with_context(|| format!("cannot write {}", path.display()))?;
ensure_ignored(dir, app.env_file_name())?;
Ok(Outcome::Written(assignment))
}
pub fn render(existing: &str, assignment: &str) -> String {
let block = format!("{HEADER}\n{assignment}\n");
let lines: Vec<&str> = existing.lines().collect();
if let Some(at) = lines.iter().position(|line| *line == HEADER) {
let after = if lines.get(at + 1).is_some_and(|line| is_assignment(line)) {
at + 2
} else {
at + 1
};
let mut out = String::new();
for line in &lines[..at] {
out.push_str(line);
out.push('\n');
}
out.push_str(&block);
for line in &lines[after..] {
out.push_str(line);
out.push('\n');
}
return out;
}
if lines.is_empty() {
return block;
}
let mut out = String::new();
for line in &lines {
out.push_str(line);
out.push('\n');
}
if !lines.last().is_some_and(|line| line.trim().is_empty()) {
out.push('\n');
}
out.push_str(&block);
out
}
fn without_block(existing: &str) -> String {
let lines: Vec<&str> = existing.lines().collect();
let Some(at) = lines.iter().position(|line| *line == HEADER) else {
return existing.to_string();
};
let after = if lines.get(at + 1).is_some_and(|line| is_assignment(line)) {
at + 2
} else {
at + 1
};
let mut kept: Vec<&str> = lines[..at].to_vec();
kept.extend_from_slice(&lines[after..]);
while kept.last().is_some_and(|line| line.trim().is_empty()) {
kept.pop();
}
let mut out = kept.join("\n");
if !out.is_empty() {
out.push('\n');
}
out
}
fn is_assignment(line: &str) -> bool {
let line = line.trim_start();
!line.is_empty() && !line.starts_with('#') && line.contains('=')
}
#[derive(Debug, PartialEq, Eq)]
pub enum Drift {
Nothing,
InStep,
Missing,
Stale { says: String },
Shadowed { line: String },
Leftover,
}
pub fn drift(app: &App) -> Result<Drift> {
let dir = Path::new(&app.path);
if !dir.is_dir() {
return Ok(Drift::Nothing);
}
let path = dir.join(app.env_file_name());
let text = match std::fs::read_to_string(&path) {
Ok(text) => Some(text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
};
Ok(judge(
text.as_deref(),
app.gateway_url().map(|url| format!("{}={url}", app.gateway_env_name())).as_deref(),
))
}
fn judge(text: Option<&str>, assignment: Option<&str>) -> Drift {
let lines: Vec<&str> = text.unwrap_or_default().lines().collect();
let header = lines.iter().position(|line| *line == HEADER);
let Some(assignment) = assignment else {
return if header.is_some() { Drift::Leftover } else { Drift::Nothing };
};
let Some(at) = header else {
return Drift::Missing;
};
let says = lines.get(at + 1).copied().filter(|line| is_assignment(line)).unwrap_or_default();
if says.trim() != assignment {
return Drift::Stale { says: says.trim().to_string() };
}
let variable = assignment.split('=').next().unwrap_or_default();
let again = lines[at + 2..].iter().rev().find(|line| {
let line = line.trim_start();
let line = line.strip_prefix("export ").unwrap_or(line);
is_assignment(line) && line.split('=').next().is_some_and(|name| name.trim() == variable)
});
match again {
Some(line) if line.trim() != assignment => Drift::Shadowed { line: line.trim().to_string() },
_ => Drift::InStep,
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum InGit {
Outside,
Ignored,
Noticed,
Tracked,
}
pub fn in_git(app: &App) -> InGit {
let dir = Path::new(&app.path);
let file = app.env_file_name();
let git = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok()
.and_then(|status| status.code())
};
if !dir.is_dir() {
return InGit::Outside;
}
match git(&["ls-files", "--error-unmatch", "--", file]) {
Some(0) => return InGit::Tracked,
Some(1) => {}
_ => return InGit::Outside,
}
match git(&["check-ignore", "-q", "--", file]) {
Some(0) => InGit::Ignored,
Some(1) => InGit::Noticed,
_ => InGit::Outside,
}
}
pub fn keep_out_of_git(app: &App) -> Result<()> {
let dir = Path::new(&app.path);
if !dir.is_dir() {
return Ok(());
}
ensure_ignored(dir, app.env_file_name())
}
fn ensure_ignored(dir: &Path, file_name: &str) -> Result<()> {
let Some((gitignore, entry)) = ignore_target(dir, file_name) else {
return Ok(());
};
let existing = match std::fs::read_to_string(&gitignore) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error).with_context(|| format!("cannot read {}", gitignore.display())),
};
if existing.lines().any(|line| covers(line.trim(), &entry)) {
return Ok(());
}
let mut text = existing;
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&entry);
text.push('\n');
std::fs::write(&gitignore, text).with_context(|| format!("cannot write {}", gitignore.display()))
}
fn ignore_target(dir: &Path, file_name: &str) -> Option<(PathBuf, String)> {
let mut nearest: Option<PathBuf> = None;
let mut root: Option<&Path> = None;
let mut at = Some(dir);
while let Some(current) = at {
if nearest.is_none() && current.join(".gitignore").is_file() {
nearest = Some(current.to_path_buf());
}
if current.join(".git").exists() {
root = Some(current);
break;
}
at = current.parent();
}
let holder = match (nearest, root) {
(Some(found), _) => found,
(None, Some(root)) => root.to_path_buf(),
(None, None) => return None,
};
let relative = dir.strip_prefix(&holder).ok()?;
let mut entry = String::new();
for part in relative.components() {
entry.push_str(&part.as_os_str().to_string_lossy());
entry.push('/');
}
entry.push_str(file_name);
Some((holder.join(".gitignore"), entry))
}
fn covers(pattern: &str, entry: &str) -> bool {
let anchored = pattern.trim_end_matches('/').starts_with('/');
let pattern = pattern.trim_end_matches('/').trim_start_matches('/');
let entry_parts: Vec<&str> = entry.split('/').collect();
if !pattern.contains('/') && !anchored {
return entry_parts.iter().any(|part| ignores(pattern, part));
}
let pattern_parts: Vec<&str> = pattern.split('/').collect();
if pattern_parts.len() > entry_parts.len() {
return false;
}
pattern_parts.iter().zip(&entry_parts).all(|(pattern, part)| ignores(pattern, part))
}
fn ignores(pattern: &str, file_name: &str) -> bool {
if pattern == file_name {
return true;
}
if !pattern.contains('*') {
return false;
}
let parts: Vec<&str> = pattern.split('*').collect();
let mut rest = file_name;
if let Some(first) = parts.first() {
let Some(after) = rest.strip_prefix(first) else { return false };
rest = after;
}
if let Some(last) = parts.last() {
let Some(before) = rest.strip_suffix(last) else { return false };
rest = before;
}
parts[1..parts.len() - 1].iter().all(|part| match rest.find(part) {
Some(at) => {
rest = &rest[at + part.len()..];
true
}
None => false,
})
}
pub fn substitute(command_line: &str, app: &App) -> Result<String> {
let mut out = command_line.to_string();
if out.contains("{gateway}") || out.contains("{gateway_port}") {
let Some(port) = app.gateway_port else {
bail!(
"the command uses {{gateway}} but app '{0}' has no gateway port - set one with `turnout app edit {0} --port PORT`",
app.name
);
};
out = out
.replace("{gateway}", &format!("http://localhost:{port}"))
.replace("{gateway_port}", &port.to_string());
}
if out.contains("{port}") {
let Some(port) = app.dev_port else {
bail!(
"the command uses {{port}} but app '{0}' has no dev port yet - it is assigned by `turnout dev {0}`, or pinned with `turnout app edit {0} --dev-port PORT`",
app.name
);
};
out = out.replace("{port}", &port.to_string());
}
Ok(out)
}
pub fn path_of(app: &App) -> PathBuf {
Path::new(&app.path).join(app.env_file_name())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn app(port: Option<u16>) -> App {
App {
name: "web".into(),
path: "/tmp/web".into(),
commands: BTreeMap::new(),
dist_dir: None,
gateway_port: port,
gateway_env: None,
env_file: None,
dev_port: None,
servers: Vec::new(),
}
}
#[test]
fn an_empty_file_gets_the_block_alone() {
assert_eq!(render("", "X=1"), format!("{HEADER}\nX=1\n"));
}
#[test]
fn foreign_lines_survive_in_order_and_the_block_lands_after_a_blank_line() {
let existing = "A=1\n# theirs\nB=2\n";
let out = render(existing, "X=1");
assert_eq!(out, format!("A=1\n# theirs\nB=2\n\n{HEADER}\nX=1\n"));
assert_eq!(render("A=1\n\n", "X=1"), format!("A=1\n\n{HEADER}\nX=1\n"));
}
#[test]
fn the_block_is_replaced_in_place_including_a_renamed_variable() {
let first = render("A=1\n", "OLD=http://localhost:7001");
let second = render(&first, "NEW=http://localhost:7002");
assert_eq!(second, format!("A=1\n\n{HEADER}\nNEW=http://localhost:7002\n"));
assert!(!second.contains("OLD="), "the renamed variable left its old line behind");
let with_tail = format!("{first}TAIL=1\n");
let out = render(&with_tail, "X=1");
assert_eq!(out, format!("A=1\n\n{HEADER}\nX=1\nTAIL=1\n"));
}
#[test]
fn rendering_twice_changes_nothing() {
let once = render("A=1\n", "X=1");
assert_eq!(render(&once, "X=1"), once);
}
#[test]
fn a_header_without_an_assignment_is_still_the_block() {
let out = render(&format!("A=1\n{HEADER}\n# note\nB=2\n"), "X=1");
assert_eq!(out, format!("A=1\n{HEADER}\nX=1\n# note\nB=2\n"));
let out = render(&format!("A=1\n{HEADER}\n"), "X=1");
assert_eq!(out, format!("A=1\n{HEADER}\nX=1\n"));
}
#[test]
fn removing_the_block_keeps_the_rest_and_its_own_blank_line_goes_with_it() {
let text = render("A=1\n", "X=1");
assert_eq!(without_block(&text), "A=1\n");
assert_eq!(without_block(&render("", "X=1")), "");
assert_eq!(without_block("A=1\n"), "A=1\n");
}
#[test]
fn gitignore_patterns_that_cover_the_file_are_recognised() {
for entry in [".env.development.local", "apps/web/.env.development.local"] {
for pattern in [".env.development.local", ".env*.local", "*.local", ".env.*", "*"] {
assert!(covers(pattern, entry), "{pattern} should cover {entry}");
}
for pattern in [
"",
"# .env.development.local",
"!.env.development.local",
".env",
".env.local",
"*.log",
".env.production.local",
] {
assert!(!covers(pattern, entry), "{pattern:?} should not cover {entry}");
}
}
assert!(covers("/.env.development.local", ".env.development.local"));
assert!(!covers("/.env.development.local", "apps/web/.env.development.local"));
for pattern in ["", " ", "!*", "#*", "!*.local", "!.env*.local", "!apps/*/.env.development.local", "# *.local"] {
assert!(!covers(pattern, "apps/web/.env.development.local"), "{pattern:?} is not a cover");
}
}
#[test]
fn an_anchored_glob_path_is_recognised_as_covering_the_entry() {
let entry = "examples/vue-demo/.env.development.local";
for pattern in [
"examples/vue-demo/.env.development.local",
"/examples/vue-demo/.env.development.local",
"examples/*/.env.development.local",
"examples/*/.env*.local",
"examples/vue-demo",
"examples/vue-demo/",
"examples",
] {
assert!(covers(pattern, entry), "{pattern} should cover {entry}");
}
for pattern in [
"examples/react-demo/.env.development.local",
"docs/*/.env.development.local",
"examples/vue-demo/.env.development.local/deeper",
"vue-demo/.env.development.local",
] {
assert!(!covers(pattern, entry), "{pattern} should not cover {entry}");
}
}
#[test]
fn the_file_is_judged_against_the_port() {
let line = "VITE_API_URL=http://localhost:7101";
let assignment = Some(line);
let written = render("A=1\n", line);
assert_eq!(judge(Some(&written), assignment), Drift::InStep);
assert_eq!(judge(None, assignment), Drift::Missing, "no file at all");
assert_eq!(judge(Some("A=1\n"), assignment), Drift::Missing, "a file without our block");
let old = render("", "VITE_API_URL=http://localhost:7001");
assert_eq!(
judge(Some(&old), assignment),
Drift::Stale {
says: "VITE_API_URL=http://localhost:7001".into()
}
);
let renamed = render("", "API_URL=http://localhost:7101");
assert!(matches!(judge(Some(&renamed), assignment), Drift::Stale { .. }));
assert_eq!(judge(Some(&format!("{HEADER}\n# note\n")), assignment), Drift::Stale { says: String::new() });
for later in ["VITE_API_URL=http://localhost:3000", "export VITE_API_URL=http://localhost:3000"] {
let shadowed = format!("{written}{later}\n");
assert_eq!(judge(Some(&shadowed), assignment), Drift::Shadowed { line: later.into() });
}
assert_eq!(
judge(Some(&format!("{written}VITE_API_URL=http://localhost:7101\nOTHER=1\n")), assignment),
Drift::InStep
);
assert_eq!(judge(Some(&format!("{written}VITE_API_URL_V2=x\n")), assignment), Drift::InStep);
assert_eq!(judge(Some(&written), None), Drift::Leftover);
assert_eq!(judge(Some("A=1\n"), None), Drift::Nothing);
assert_eq!(judge(None, None), Drift::Nothing);
}
#[test]
fn placeholders_take_the_port_and_refuse_without_one() {
let out = substitute("vite --api {gateway} --p {gateway_port}", &app(Some(7001))).unwrap();
assert_eq!(out, "vite --api http://localhost:7001 --p 7001");
assert_eq!(substitute("pnpm dev", &app(None)).unwrap(), "pnpm dev");
let error = substitute("x {gateway}", &app(None)).unwrap_err().to_string();
assert!(error.contains("has no gateway port"), "{error}");
let error = substitute("vite --port {port}", &app(Some(7001))).unwrap_err().to_string();
assert!(error.contains("has no dev port yet"), "{error}");
let mut with_dev = app(Some(7001));
with_dev.dev_port = Some(5100);
assert_eq!(
substitute("vite --port {port} --api {gateway}", &with_dev).unwrap(),
"vite --port 5100 --api http://localhost:7001"
);
}
#[test]
fn write_round_trips_on_disk() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "node_modules\n").unwrap();
let mut app = app(Some(7001));
app.path = dir.path().display().to_string();
app.gateway_env = Some("VITE_API_URL".into());
assert_eq!(write(&app).unwrap(), Outcome::Written("VITE_API_URL=http://localhost:7001".into()));
assert_eq!(write(&app).unwrap(), Outcome::Unchanged);
let file = dir.path().join(".env.development.local");
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
format!("{HEADER}\nVITE_API_URL=http://localhost:7001\n")
);
assert_eq!(
std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
"node_modules\n.env.development.local\n"
);
app.gateway_port = None;
assert_eq!(write(&app).unwrap(), Outcome::Removed);
assert!(!file.exists(), "an empty managed file was left behind");
assert_eq!(write(&app).unwrap(), Outcome::Nothing);
}
#[test]
fn write_leaves_a_directory_without_git_unlittered() {
let dir = tempfile::tempdir().unwrap();
let mut app = app(Some(7001));
app.path = dir.path().display().to_string();
write(&app).unwrap();
assert!(!dir.path().join(".gitignore").exists(), "a .gitignore was invented outside a repository");
}
#[test]
fn a_monorepo_app_is_ignored_from_the_repository_root() {
let repo = tempfile::tempdir().unwrap();
std::fs::create_dir(repo.path().join(".git")).unwrap();
std::fs::write(repo.path().join(".gitignore"), "node_modules\n").unwrap();
let project = repo.path().join("apps").join("web");
std::fs::create_dir_all(&project).unwrap();
let mut app = app(Some(7001));
app.path = project.display().to_string();
app.gateway_env = Some("VITE_API_URL".into());
write(&app).unwrap();
assert_eq!(
std::fs::read_to_string(repo.path().join(".gitignore")).unwrap(),
"node_modules\napps/web/.env.development.local\n"
);
assert!(!project.join(".gitignore").exists(), "a second .gitignore appeared beside the app");
write(&app).unwrap();
assert_eq!(
std::fs::read_to_string(repo.path().join(".gitignore")).unwrap(),
"node_modules\napps/web/.env.development.local\n"
);
}
#[test]
fn the_nearest_gitignore_inside_the_repository_takes_the_entry() {
let repo = tempfile::tempdir().unwrap();
std::fs::create_dir(repo.path().join(".git")).unwrap();
std::fs::write(repo.path().join(".gitignore"), "node_modules\n").unwrap();
let package = repo.path().join("apps");
let project = package.join("web");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(package.join(".gitignore"), "dist\n").unwrap();
let mut app = app(Some(7001));
app.path = project.display().to_string();
app.gateway_env = Some("VITE_API_URL".into());
write(&app).unwrap();
assert_eq!(
std::fs::read_to_string(package.join(".gitignore")).unwrap(),
"dist\nweb/.env.development.local\n"
);
assert_eq!(std::fs::read_to_string(repo.path().join(".gitignore")).unwrap(), "node_modules\n");
}
#[test]
fn a_repository_without_any_gitignore_gets_one_at_its_root() {
let repo = tempfile::tempdir().unwrap();
std::fs::create_dir(repo.path().join(".git")).unwrap();
let project = repo.path().join("apps").join("web");
std::fs::create_dir_all(&project).unwrap();
let mut app = app(Some(7001));
app.path = project.display().to_string();
app.gateway_env = Some("VITE_API_URL".into());
write(&app).unwrap();
assert_eq!(
std::fs::read_to_string(repo.path().join(".gitignore")).unwrap(),
"apps/web/.env.development.local\n"
);
assert!(!project.join(".gitignore").exists());
}
#[test]
fn a_root_glob_already_covers_a_nested_app() {
let repo = tempfile::tempdir().unwrap();
std::fs::create_dir(repo.path().join(".git")).unwrap();
std::fs::write(repo.path().join(".gitignore"), "*.local\n").unwrap();
let project = repo.path().join("apps").join("web");
std::fs::create_dir_all(&project).unwrap();
let mut app = app(Some(7001));
app.path = project.display().to_string();
app.gateway_env = Some("VITE_API_URL".into());
write(&app).unwrap();
assert_eq!(std::fs::read_to_string(repo.path().join(".gitignore")).unwrap(), "*.local\n");
}
#[test]
fn a_worktree_marker_file_counts_as_a_repository() {
let repo = tempfile::tempdir().unwrap();
std::fs::write(repo.path().join(".git"), "gitdir: /elsewhere/.git/worktrees/w\n").unwrap();
let project = repo.path().join("app");
std::fs::create_dir_all(&project).unwrap();
let mut app = app(Some(7001));
app.path = project.display().to_string();
app.gateway_env = Some("VITE_API_URL".into());
write(&app).unwrap();
assert_eq!(std::fs::read_to_string(repo.path().join(".gitignore")).unwrap(), "app/.env.development.local\n");
}
#[test]
fn a_gitignore_without_a_repository_is_still_written_to() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "node_modules\n").unwrap();
let mut app = app(Some(7001));
app.path = dir.path().display().to_string();
app.gateway_env = Some("VITE_API_URL".into());
write(&app).unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(),
"node_modules\n.env.development.local\n"
);
}
#[test]
fn a_gitignore_outside_the_root_is_not_written_to() {
let outer = tempfile::tempdir().unwrap();
std::fs::write(outer.path().join(".gitignore"), "outer\n").unwrap();
let repo = outer.path().join("inner");
std::fs::create_dir(&repo).unwrap();
std::fs::create_dir(repo.join(".git")).unwrap();
let project = repo.join("app");
std::fs::create_dir_all(&project).unwrap();
let mut app = app(Some(7001));
app.path = project.display().to_string();
app.gateway_env = Some("VITE_API_URL".into());
write(&app).unwrap();
assert_eq!(std::fs::read_to_string(outer.path().join(".gitignore")).unwrap(), "outer\n");
assert_eq!(std::fs::read_to_string(repo.join(".gitignore")).unwrap(), "app/.env.development.local\n");
}
}