use std::fs;
use std::path::Path;
use std::process::Command;
use anyhow::Result;
use crate::env::Env;
use crate::workspace::Workspace;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Ok,
Warn,
Fail,
}
impl Status {
pub fn mark(self) -> &'static str {
match self {
Status::Ok => "✓",
Status::Warn => "!",
Status::Fail => "✗",
}
}
}
#[derive(Debug, Clone)]
pub struct Check {
pub name: String,
pub status: Status,
pub detail: String,
pub fixable: bool,
}
impl Check {
fn ok(name: &str, detail: impl Into<String>) -> Self {
Self { name: name.into(), status: Status::Ok, detail: detail.into(), fixable: false }
}
fn warn(name: &str, detail: impl Into<String>) -> Self {
Self { name: name.into(), status: Status::Warn, detail: detail.into(), fixable: false }
}
fn fixable(name: &str, status: Status, detail: impl Into<String>) -> Self {
Self { name: name.into(), status, detail: detail.into(), fixable: true }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Repair {
Applied,
Skipped,
Failed,
}
impl Repair {
fn mark(self) -> &'static str {
match self {
Repair::Applied => "✓",
Repair::Skipped => "·",
Repair::Failed => "✗",
}
}
}
#[derive(Debug, Clone)]
pub struct Action {
pub name: String,
pub outcome: Repair,
pub detail: String,
}
impl Action {
fn applied(name: &str, detail: impl Into<String>) -> Self {
Self { name: name.into(), outcome: Repair::Applied, detail: detail.into() }
}
fn skipped(name: &str, detail: impl Into<String>) -> Self {
Self { name: name.into(), outcome: Repair::Skipped, detail: detail.into() }
}
fn failed(name: &str, detail: impl Into<String>) -> Self {
Self { name: name.into(), outcome: Repair::Failed, detail: detail.into() }
}
}
pub const METRO_POKE: &str = "METRO_POKE";
pub fn runs_mobile(env: &Env) -> bool {
match env.get("RUN_MOBILE") {
Some(value) => Env::truthy(value),
None => false,
}
}
pub fn docker_available() -> bool {
Command::new("docker")
.args(["compose", "version"])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
pub fn container_metro_poke(container: &str) -> Option<String> {
let output = Command::new("docker")
.args(["inspect", container, "--format", "{{range .Config.Env}}{{println .}}{{end}}"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
.find_map(|line| line.strip_prefix(&format!("{METRO_POKE}=")))
.map(|value| value.trim().to_string())
}
pub fn running_metro_containers() -> Vec<String> {
let output = match Command::new("docker")
.args(["ps", "--format", "{{.Names}}"])
.output()
{
Ok(output) if output.status.success() => output,
_ => return Vec::new(),
};
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|name| name.contains("mobile"))
.map(str::to_string)
.collect()
}
pub fn check_metro_poke(env: &Env, containers: &[String]) -> Vec<Check> {
if !runs_mobile(env) {
return vec![Check::ok("metro poke", "workspace runs no mobile app")];
}
let mut checks = Vec::new();
let configured = env.get(METRO_POKE);
match configured {
Some(value) if !Env::truthy(value) => checks.push(Check::fixable(
"metro poke",
Status::Fail,
format!("{METRO_POKE}={value} - Metro cannot see host edits, so every change needs a cache clear"),
)),
Some(_) => checks.push(Check::ok("metro poke", format!("{METRO_POKE} enabled"))),
None => checks.push(Check::ok("metro poke", "unset - defaults to enabled")),
}
for container in containers {
match container_metro_poke(container) {
Some(value) if !Env::truthy(&value) => checks.push(Check::warn(
"metro poke (running)",
format!("{container} was created with {METRO_POKE}={value} - recreate it to pick up the fix"),
)),
Some(_) => checks.push(Check::ok("metro poke (running)", format!("{container} has it enabled"))),
None => {}
}
}
checks
}
pub fn undeclared_root_apps(workspace: &Workspace, env: &Env) -> Vec<String> {
let declared: Vec<String> = crate::generate::root_apps(env)
.into_iter()
.map(|app| app.dir)
.collect();
let skip: Vec<String> = ["FRONTEND_DIR", "BACKEND_DIR"]
.iter()
.filter_map(|key| env.get(key))
.filter_map(|dir| {
Path::new(dir)
.file_name()
.map(|name| name.to_string_lossy().to_string())
})
.collect();
let entries = match std::fs::read_dir(&workspace.root) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut found: Vec<String> = entries
.flatten()
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.filter(|name| !name.starts_with('.') && name != "node_modules")
.filter(|name| !skip.contains(name) && !declared.contains(name))
.filter(|name| runnable_app(&workspace.root.join(name)))
.collect();
found.sort();
found
}
fn runnable_app(dir: &Path) -> bool {
let manifest = dir.join("package.json");
let Ok(text) = std::fs::read_to_string(&manifest) else {
return false;
};
let Some(scripts) = text.split("\"scripts\"").nth(1) else {
return false;
};
let block = scripts.split('}').next().unwrap_or("");
block.contains("\"dev\"") || block.contains("\"start\"")
}
pub fn check_root_apps(workspace: &Workspace, env: &Env) -> Vec<Check> {
let found = undeclared_root_apps(workspace, env);
if found.is_empty() {
return vec![Check::ok("root apps", "no unconfigured apps beside the frontend repo")];
}
vec![Check::fixable(
"root apps",
Status::Warn,
format!(
"not configured: {} - init --update only scans the frontend repo's apps/",
found.join(", ")
),
)]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnquotedLine {
pub number: usize,
pub key: String,
pub value: String,
}
pub fn unquoted_values(text: &str) -> Vec<UnquotedLine> {
text.lines()
.enumerate()
.filter_map(|(index, raw)| {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, value) = line.split_once('=')?;
let key = key.trim();
if key.is_empty() || !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
let value = value.trim();
if value.is_empty() || !value.contains(char::is_whitespace) {
return None;
}
if (value.starts_with('"') && value.ends_with('"') && value.len() > 1)
|| (value.starts_with('\'') && value.ends_with('\'') && value.len() > 1)
{
return None;
}
Some(UnquotedLine {
number: index + 1,
key: key.to_string(),
value: value.to_string(),
})
})
.collect()
}
pub fn check_env_quoting(workspace: &Workspace) -> Vec<Check> {
let Ok(text) = fs::read_to_string(workspace.env_path()) else {
return vec![Check::ok("env quoting", "no .env to read")];
};
let loose = unquoted_values(&text);
if loose.is_empty() {
return vec![Check::ok("env quoting", "every value is a value, not a command")];
}
let names: Vec<String> = loose
.iter()
.map(|line| format!("{} (line {})", line.key, line.number))
.collect();
vec![Check::fixable(
"env quoting",
Status::Fail,
format!(
"unquoted spaces run as commands when .env is sourced: {}",
names.join(", ")
),
)]
}
fn fix_env_quoting(workspace: &Workspace, dry_run: bool) -> Action {
let path = workspace.env_path();
let Ok(text) = fs::read_to_string(&path) else {
return Action::skipped("env quoting", "no .env to read");
};
let loose = unquoted_values(&text);
if loose.is_empty() {
return Action::skipped("env quoting", "nothing to quote");
}
let (safe, risky): (Vec<_>, Vec<_>) = loose
.iter()
.partition(|line| !line.value.contains('"') && !line.value.contains('\''));
if safe.is_empty() {
return Action::failed(
"env quoting",
format!("{} line(s) mix quotes - quote them by hand", risky.len()),
);
}
if dry_run {
return Action::applied(
"env quoting",
format!("would quote {}", safe.iter().map(|l| l.key.as_str()).collect::<Vec<_>>().join(", ")),
);
}
let numbers: Vec<usize> = safe.iter().map(|line| line.number).collect();
let rewritten: Vec<String> = text
.lines()
.enumerate()
.map(|(index, raw)| {
if !numbers.contains(&(index + 1)) {
return raw.to_string();
}
match raw.split_once('=') {
Some((key, value)) => format!("{key}=\"{}\"", value.trim()),
None => raw.to_string(),
}
})
.collect();
if let Err(error) = fs::write(&path, format!("{}\n", rewritten.join("\n"))) {
return Action::failed("env quoting", error.to_string());
}
let mut detail = format!(
"quoted {}",
safe.iter().map(|l| l.key.as_str()).collect::<Vec<_>>().join(", ")
);
if !risky.is_empty() {
detail.push_str(&format!("; {} mixing quotes left alone", risky.len()));
}
Action::applied("env quoting", detail)
}
pub fn check_overlays(run_dir: &Path) -> Vec<Check> {
if run_dir.join("docker-compose.packages.yml").is_file() {
return vec![Check::ok("overlays", "generated compose overlays present")];
}
vec![Check::fixable(
"overlays",
Status::Fail,
"missing: docker-compose.packages.yml - run `rst generate`".to_string(),
)]
}
pub fn check_docker() -> Vec<Check> {
if docker_available() {
vec![Check::ok("docker", "docker compose v2 available")]
} else {
vec![Check::warn("docker", "'docker compose' unavailable - is docker running?")]
}
}
pub fn run(workspace: &Workspace) -> Result<Vec<Check>> {
let env = Env::load(&workspace.env_path())?;
let containers = if docker_available() { running_metro_containers() } else { Vec::new() };
let mut checks = check_docker();
checks.extend(check_metro_poke(&env, &containers));
checks.extend(check_overlays(&workspace.run_dir));
checks.extend(check_root_apps(workspace, &env));
checks.extend(check_env_quoting(workspace));
Ok(checks)
}
pub fn set_env_key(path: &Path, key: &str, value: &str, note: &str) -> Result<()> {
let text = fs::read_to_string(path).unwrap_or_default();
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
let existing = lines.iter().position(|line| {
let trimmed = line.trim().strip_prefix("export ").unwrap_or(line.trim());
trimmed
.split_once('=')
.map(|(name, _)| name.trim() == key)
.unwrap_or(false)
});
match existing {
Some(index) => lines[index] = format!("{key}={value}"),
None => {
if !lines.is_empty() && !lines.last().map(|l| l.is_empty()).unwrap_or(false) {
lines.push(String::new());
}
for line in note.lines() {
lines.push(format!("# {line}"));
}
lines.push(format!("{key}={value}"));
}
}
fs::write(path, format!("{}\n", lines.join("\n")))?;
Ok(())
}
const POKE_NOTE: &str = "Metro runs in the container, and file events do not cross the bind mount,\nso its watcher never sees host edits. The poker re-touches changed files from\ninside the container, which does raise a real event.";
fn fix_overlays(workspace: &Workspace, dry_run: bool) -> Action {
let missing = check_overlays(&workspace.run_dir)
.into_iter()
.any(|check| check.status != Status::Ok);
if !missing {
return Action::skipped("overlays", "already generated");
}
if dry_run {
return Action::applied("overlays", "would regenerate the compose overlays");
}
let mut env = match Env::load(&workspace.env_path()) {
Ok(env) => env,
Err(error) => return Action::failed("overlays", error.to_string()),
};
env.derive(&workspace.root);
let package = match crate::compose::package_dir() {
Ok(dir) => dir,
Err(error) => return Action::failed("overlays", error.to_string()),
};
match crate::generate::all(&workspace.run_dir, &env, &package) {
Ok(()) => Action::applied("overlays", "regenerated the compose overlays"),
Err(error) => Action::failed("overlays", error.to_string()),
}
}
const ROOT_APPS_NOTE: &str = "Apps beside the frontend repo. They are outside its bind mount and its pnpm\nworkspace, so each gets its own mount and runs its own command.";
fn fix_root_apps(workspace: &Workspace, dry_run: bool) -> Action {
let env = match Env::load(&workspace.env_path()) {
Ok(env) => env,
Err(error) => return Action::failed("root apps", error.to_string()),
};
let found = undeclared_root_apps(workspace, &env);
if found.is_empty() {
return Action::skipped("root apps", "nothing unconfigured beside the frontend repo");
}
let mut declared: Vec<String> = env
.get_or("ROOT_APPS", "")
.split_whitespace()
.map(str::to_string)
.collect();
declared.extend(found.iter().cloned());
let value = declared.join(" ");
if dry_run {
return Action::applied("root apps", format!("would add {} to ROOT_APPS", found.join(", ")));
}
match set_env_key(&workspace.env_path(), "ROOT_APPS", &value, ROOT_APPS_NOTE) {
Ok(()) => Action::applied(
"root apps",
format!("added {} to ROOT_APPS - run `rst up {}` to start", found.join(", "), found[0]),
),
Err(error) => Action::failed("root apps", error.to_string()),
}
}
pub fn fix(workspace: &Workspace, dry_run: bool) -> Result<Vec<Action>> {
let env = Env::load(&workspace.env_path())?;
let mut actions = Vec::new();
if runs_mobile(&env) {
let configured = env.get(METRO_POKE);
let needs_fix = matches!(configured, Some(value) if !Env::truthy(value));
if needs_fix {
if dry_run {
actions.push(Action::applied("metro poke", format!("would set {METRO_POKE}=true")));
} else {
match set_env_key(&workspace.env_path(), METRO_POKE, "true", POKE_NOTE) {
Ok(()) => actions.push(Action::applied(
"metro poke",
format!("set {METRO_POKE}=true - recreate the mobile container to apply it"),
)),
Err(error) => actions.push(Action::failed("metro poke", error.to_string())),
}
}
} else {
actions.push(Action::skipped("metro poke", "already enabled"));
}
} else {
actions.push(Action::skipped("metro poke", "workspace runs no mobile app"));
}
actions.push(fix_overlays(workspace, dry_run));
actions.push(fix_root_apps(workspace, dry_run));
actions.push(fix_env_quoting(workspace, dry_run));
Ok(actions)
}
pub fn format_checks(checks: &[Check]) -> String {
let rows: Vec<Vec<String>> = checks
.iter()
.map(|check| {
vec![
check.status.mark().to_string(),
check.name.clone(),
check.detail.clone(),
]
})
.collect();
let failures = checks.iter().filter(|c| c.status == Status::Fail).count();
let warnings = checks.iter().filter(|c| c.status == Status::Warn).count();
let fixable = checks.iter().filter(|c| c.fixable).count();
let mut out = crate::table::render(&["", "CHECK", "DETAIL"], &rows);
out.push('\n');
out.push_str(&if failures == 0 && warnings == 0 {
"all checks passed".to_string()
} else {
format!("{failures} failure(s), {warnings} warning(s)")
});
if fixable > 0 {
out.push_str(&format!("\n{fixable} can be repaired: rst doctor --fix"));
}
out
}
pub fn format_actions(actions: &[Action], dry_run: bool) -> String {
let rows: Vec<Vec<String>> = actions
.iter()
.map(|action| {
vec![
action.outcome.mark().to_string(),
action.name.clone(),
action.detail.clone(),
]
})
.collect();
let applied = actions.iter().filter(|a| a.outcome == Repair::Applied).count();
let failed = actions.iter().filter(|a| a.outcome == Repair::Failed).count();
let mut out = crate::table::render(&["", "REPAIR", "DETAIL"], &rows);
out.push('\n');
out.push_str(&if failed > 0 {
format!("{applied} fixed, {failed} could not be fixed")
} else if applied > 0 && dry_run {
format!("{applied} would be fixed (dry run)")
} else if applied > 0 {
format!("{applied} fixed")
} else {
"nothing to fix".to_string()
});
out
}
#[cfg(test)]
mod tests {
use super::*;
fn env_from(text: &str) -> Env {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env");
fs::write(&path, text).unwrap();
Env::load(&path).unwrap()
}
#[test]
fn poke_disabled_is_a_fixable_failure() {
let env = env_from("RUN_MOBILE=true\nMETRO_POKE=false\n");
let checks = check_metro_poke(&env, &[]);
assert_eq!(checks[0].status, Status::Fail);
assert!(checks[0].fixable);
assert!(checks[0].detail.contains("cache clear"));
}
#[test]
fn poke_unset_defaults_to_enabled() {
let env = env_from("RUN_MOBILE=true\n");
let checks = check_metro_poke(&env, &[]);
assert_eq!(checks[0].status, Status::Ok);
assert!(!checks[0].fixable);
}
#[test]
fn poke_is_irrelevant_without_a_mobile_app() {
let env = env_from("RUN_MOBILE=false\nMETRO_POKE=false\n");
let checks = check_metro_poke(&env, &[]);
assert_eq!(checks.len(), 1);
assert_eq!(checks[0].status, Status::Ok);
}
#[test]
fn overlays_missing_is_fixable() {
let dir = tempfile::tempdir().unwrap();
let checks = check_overlays(dir.path());
assert_eq!(checks[0].status, Status::Fail);
assert!(checks[0].fixable);
}
#[test]
fn overlays_present_pass() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
assert_eq!(check_overlays(dir.path())[0].status, Status::Ok);
}
#[test]
fn a_workspace_with_no_extra_apps_is_not_a_failure() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("docker-compose.packages.yml"), "services: {}\n").unwrap();
let checks = check_overlays(dir.path());
assert_eq!(checks[0].status, Status::Ok);
assert!(!checks[0].fixable);
}
#[test]
fn set_env_key_replaces_in_place_and_keeps_comments() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env");
fs::write(&path, "# keep me\nRUN_MOBILE=true\nMETRO_POKE=false\nPORT=1\n").unwrap();
set_env_key(&path, METRO_POKE, "true", "why").unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("# keep me"));
assert!(text.contains("METRO_POKE=true"));
assert!(!text.contains("METRO_POKE=false"));
assert!(text.find("RUN_MOBILE").unwrap() < text.find("METRO_POKE").unwrap());
assert!(text.find("METRO_POKE").unwrap() < text.find("PORT").unwrap());
}
#[test]
fn set_env_key_appends_with_the_reason_when_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env");
fs::write(&path, "RUN_MOBILE=true\n").unwrap();
set_env_key(&path, METRO_POKE, "true", "first line\nsecond line").unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("# first line"));
assert!(text.contains("# second line"));
assert!(text.contains("METRO_POKE=true"));
assert!(text.starts_with("RUN_MOBILE=true"));
}
#[test]
fn set_env_key_handles_an_exported_line() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env");
fs::write(&path, "export METRO_POKE=false\n").unwrap();
set_env_key(&path, METRO_POKE, "true", "why").unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("METRO_POKE=true"));
assert!(!text.contains("false"));
}
#[test]
fn a_similar_key_is_not_mistaken_for_the_real_one() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".env");
fs::write(&path, "METRO_POKE_INTERVAL=1\n").unwrap();
set_env_key(&path, METRO_POKE, "true", "why").unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(text.contains("METRO_POKE_INTERVAL=1"));
assert!(text.contains("\nMETRO_POKE=true"));
}
#[test]
fn report_points_at_the_fix_when_something_is_repairable() {
let checks = check_metro_poke(&env_from("RUN_MOBILE=true\nMETRO_POKE=false\n"), &[]);
let report = format_checks(&checks);
assert!(report.contains("rst doctor --fix"));
assert!(report.contains("1 failure(s)"));
}
#[test]
fn report_is_quiet_when_all_is_well() {
let report = format_checks(&[Check::ok("docker", "fine")]);
assert!(report.contains("all checks passed"));
assert!(!report.contains("--fix"));
}
#[test]
fn actions_report_distinguishes_a_dry_run() {
let applied = vec![Action::applied("metro poke", "would set it")];
assert!(format_actions(&applied, true).contains("would be fixed"));
assert!(format_actions(&applied, false).contains("1 fixed"));
assert!(format_actions(&[Action::skipped("x", "y")], false).contains("nothing to fix"));
}
}
#[cfg(test)]
mod root_app_detection_tests {
use super::*;
fn workspace_with(dirs: &[(&str, &str)], env_text: &str) -> (tempfile::TempDir, Workspace) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
fs::create_dir_all(root.join(".run")).unwrap();
fs::write(root.join(".run").join(".env"), env_text).unwrap();
for (name, manifest) in dirs {
fs::create_dir_all(root.join(name)).unwrap();
if !manifest.is_empty() {
fs::write(root.join(name).join("package.json"), manifest).unwrap();
}
}
let workspace = Workspace {
root: root.clone(),
run_dir: root.join(".run"),
};
(dir, workspace)
}
fn env_of(workspace: &Workspace) -> Env {
Env::load(&workspace.env_path()).unwrap()
}
#[test]
fn an_app_beside_the_repo_is_found() {
let (_guard, workspace) = workspace_with(
&[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
"FRONTEND_DIR=/w/platform\n",
);
assert_eq!(undeclared_root_apps(&workspace, &env_of(&workspace)), vec!["seeder"]);
}
#[test]
fn the_frontend_and_backend_repos_are_not_apps() {
let (_guard, workspace) = workspace_with(
&[
("platform", r#"{"scripts":{"dev":"vite"}}"#),
("api", r#"{"scripts":{"start":"node ."}}"#),
],
"FRONTEND_DIR=/w/platform\nBACKEND_DIR=/w/api\n",
);
assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
}
#[test]
fn an_already_declared_app_is_not_offered_twice() {
let (_guard, workspace) = workspace_with(
&[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
"ROOT_APPS=seeder\n",
);
assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
}
#[test]
fn a_name_and_directory_pair_still_counts_as_declared() {
let (_guard, workspace) = workspace_with(
&[("althaqeel-seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
"ROOT_APPS=seeder:althaqeel-seeder\n",
);
assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
}
#[test]
fn a_directory_with_no_manifest_is_not_an_app() {
let (_guard, workspace) = workspace_with(&[("docs", "")], "");
assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
}
#[test]
fn a_library_with_nothing_to_run_is_not_an_app() {
let (_guard, workspace) = workspace_with(
&[("shared", r#"{"scripts":{"build":"tsc","test":"vitest"}}"#)],
"",
);
assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
}
#[test]
fn node_modules_and_hidden_directories_are_ignored() {
let (_guard, workspace) = workspace_with(
&[
("node_modules", r#"{"scripts":{"start":"x"}}"#),
(".cache", r#"{"scripts":{"start":"x"}}"#),
],
"",
);
assert!(undeclared_root_apps(&workspace, &env_of(&workspace)).is_empty());
}
#[test]
fn the_check_says_init_update_cannot_see_them() {
let (_guard, workspace) = workspace_with(
&[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
"",
);
let checks = check_root_apps(&workspace, &env_of(&workspace));
assert_eq!(checks[0].status, Status::Warn);
assert!(checks[0].fixable);
assert!(checks[0].detail.contains("seeder"));
}
#[test]
fn fixing_writes_the_app_into_root_apps() {
let (_guard, workspace) = workspace_with(
&[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
"FRONTEND_DIR=/w/platform\n",
);
let action = fix_root_apps(&workspace, false);
assert_eq!(action.outcome, Repair::Applied);
let text = fs::read_to_string(workspace.env_path()).unwrap();
assert!(text.contains("ROOT_APPS=seeder"));
assert!(text.contains("FRONTEND_DIR=/w/platform"));
}
#[test]
fn a_dry_run_writes_nothing() {
let (_guard, workspace) = workspace_with(
&[("seeder", r#"{"scripts":{"dev":"node server.js"}}"#)],
"",
);
fix_root_apps(&workspace, true);
assert!(!fs::read_to_string(workspace.env_path()).unwrap().contains("ROOT_APPS"));
}
#[test]
fn fixing_keeps_apps_that_are_already_declared() {
let (_guard, workspace) = workspace_with(
&[
("seeder", r#"{"scripts":{"dev":"node server.js"}}"#),
("tools", r#"{"scripts":{"start":"node ."}}"#),
],
"ROOT_APPS=tools\n",
);
fix_root_apps(&workspace, false);
let text = fs::read_to_string(workspace.env_path()).unwrap();
assert!(text.contains("tools"));
assert!(text.contains("seeder"));
}
}
#[cfg(test)]
mod env_quoting_tests {
use super::*;
fn lines(text: &str) -> Vec<String> {
unquoted_values(text).into_iter().map(|l| l.key).collect()
}
#[test]
fn a_bare_command_value_is_caught() {
assert_eq!(
lines("SEEDER_CMD=NO_OPEN=1 HOST=0.0.0.0 npm run dev\n"),
vec!["SEEDER_CMD"]
);
}
#[test]
fn quoted_values_are_fine() {
let text = "A=\"one two\"\nB='three four'\n";
assert!(unquoted_values(text).is_empty());
}
#[test]
fn single_word_values_are_fine() {
assert!(unquoted_values("PORT=4500\nNAME=seeder\n").is_empty());
}
#[test]
fn comments_and_blanks_are_skipped() {
assert!(unquoted_values("# a note with spaces\n\n \n").is_empty());
}
#[test]
fn an_exported_line_is_still_checked() {
assert_eq!(lines("export CMD=npm run dev\n"), vec!["CMD"]);
}
#[test]
fn the_reported_line_number_is_one_based() {
let found = unquoted_values("A=1\nB=npm run dev\n");
assert_eq!(found[0].number, 2);
}
#[test]
fn prose_after_a_hash_is_not_a_setting() {
assert!(unquoted_values("# EXTRA_APPS=reports partner portal\n").is_empty());
}
}
#[cfg(test)]
mod env_quoting_fix_tests {
use super::*;
fn workspace_with(env_text: &str) -> (tempfile::TempDir, Workspace) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
fs::create_dir_all(root.join(".run")).unwrap();
fs::write(root.join(".run").join(".env"), env_text).unwrap();
let workspace = Workspace {
root: root.clone(),
run_dir: root.join(".run"),
};
(dir, workspace)
}
#[test]
fn fixing_quotes_the_value_and_leaves_the_rest_alone() {
let (_guard, workspace) =
workspace_with("# note\nPORT=4500\nCMD=npm run dev\nOTHER=\"a b\"\n");
let action = fix_env_quoting(&workspace, false);
assert_eq!(action.outcome, Repair::Applied);
let text = fs::read_to_string(workspace.env_path()).unwrap();
assert!(text.contains("CMD=\"npm run dev\""));
assert!(text.contains("PORT=4500"));
assert!(text.contains("# note"));
assert!(text.contains("OTHER=\"a b\""));
}
#[test]
fn a_fixed_file_is_clean_on_the_next_pass() {
let (_guard, workspace) = workspace_with("CMD=npm run dev\n");
fix_env_quoting(&workspace, false);
let text = fs::read_to_string(workspace.env_path()).unwrap();
assert!(unquoted_values(&text).is_empty());
}
#[test]
fn a_dry_run_writes_nothing() {
let (_guard, workspace) = workspace_with("CMD=npm run dev\n");
fix_env_quoting(&workspace, true);
assert!(fs::read_to_string(workspace.env_path()).unwrap().contains("CMD=npm run dev\n"));
}
#[test]
fn a_half_quoted_value_is_left_to_a_person() {
let (_guard, workspace) = workspace_with("CMD=say \"hello world\n");
let action = fix_env_quoting(&workspace, false);
assert_eq!(action.outcome, Repair::Failed);
assert!(fs::read_to_string(workspace.env_path()).unwrap().contains("CMD=say \"hello world"));
}
#[test]
fn the_check_reports_the_key_and_line() {
let (_guard, workspace) = workspace_with("A=1\nCMD=npm run dev\n");
let checks = check_env_quoting(&workspace);
assert_eq!(checks[0].status, Status::Fail);
assert!(checks[0].detail.contains("CMD (line 2)"));
}
}