use anyhow::{anyhow, bail, Context, Result};
use std::collections::BTreeSet;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use cliclack::{
confirm, intro, outro, outro_cancel, reset_theme, select, set_theme, Theme, ThemeState,
};
use crate::adapters::{dotenv, python, rust, typescript};
use crate::cli::{AttachArgs, CopyArgs, DoctorArgs, FormatArgs, ListArgs, MutateArgs, RemoveArgs};
use crate::copy_plan::{build_copy_plan, build_root_example_plan};
use crate::discovery::app_workspaces;
use crate::graph::{build_graph, EnvGraph};
use crate::issues::collect_issues;
use crate::models::{
EnvRecord, EnvSurface, FileWritePlan, Fix, Issue, Project, Scope, Severity, VarMutation,
Workspace,
};
use crate::render::{list_rows, render_doctor_inventory, render_list, render_list_json, ListRow};
use crate::sinks::apply_sink_plan;
use crate::util::{color, display_rel, normalize_rel_display, validate_var_name};
fn add_format_issue(issues: &mut Vec<Issue>, format_write_count: usize) {
if format_write_count == 0 {
return;
}
issues.push(Issue {
severity: Severity::Warn,
message: format!("{format_write_count} file(s) need formatting; run crabenv format"),
fix: Some(Fix::Format),
});
}
pub fn run_init(project: &Project) -> Result<()> {
if should_use_cliclack() {
intro("Initialize crabenv")?;
} else {
println!("crabenv init");
}
println!("{} {}", color("Root", "90"), project.root.display());
println!(
"{} {}",
color("Mode", "90"),
if project.is_monorepo {
"monorepo"
} else {
"single app"
}
);
let apps = app_workspaces(project).collect::<Vec<_>>();
if apps.is_empty() {
println!();
println!("{} no app owners found", color("!", "33"));
println!(
"hint: add a workspace under apps/ or create an env surface, then rerun crabenv init"
);
init_outro_cancel("Nothing to initialize.")?;
return Ok(());
}
let mut surface_writes = Vec::<FileWritePlan>::new();
for app in &apps {
collect_init_app_writes(app, &mut surface_writes)?;
}
dedupe_file_writes(&mut surface_writes);
let local_write_labels = planned_local_write_labels(project);
println!();
println!("{}", color("Detected", "1"));
for app in &apps {
println!(
" {} {} {}",
color("●", "36"),
display_rel(&app.rel),
color(format!("({})", app.framework), "90")
);
println!(
" {:<9} {}",
color("template", "90"),
init_status(&dotenv::example_path(app))
);
println!(" {:<9} {}", color("schema", "90"), schema_status(app));
}
println!();
if surface_writes.is_empty() && local_write_labels.is_empty() {
init_outro(format!(
"crabenv init: {}",
color("already initialized", "32")
))?;
return Ok(());
}
println!("{}", color("Plan", "1"));
for write in &surface_writes {
let path = display_path_from_root(project, &write.path)
.display()
.to_string();
println!(" {} {}", color("create", "32"), color(path, "97"));
}
for label in &local_write_labels {
println!(
" {} {}",
color("create", "32"),
color(label.display().to_string(), "97")
);
}
if !should_use_cliclack() {
println!(
"{} run crabenv init in an interactive terminal to create these files",
color("hint:", "90")
);
return Ok(());
}
println!();
let yes = confirm("Create the planned files?").interact()?;
if !yes {
init_outro_cancel("Cancelled.")?;
return Ok(());
}
for write in &surface_writes {
write_init_file(&write)?;
}
write_init_local_files(project)?;
init_outro("Initialized crabenv ✓")?;
Ok(())
}
fn should_use_cliclack() -> bool {
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
fn init_outro(message: impl ToString) -> Result<()> {
let message = message.to_string();
if should_use_cliclack() {
outro(message)?;
} else {
println!("{message}");
}
Ok(())
}
fn init_outro_cancel(message: impl ToString) -> Result<()> {
let message = message.to_string();
if should_use_cliclack() {
outro_cancel(message)?;
} else {
println!("{message}");
}
Ok(())
}
fn collect_init_app_writes(app: &Workspace, writes: &mut Vec<FileWritePlan>) -> Result<()> {
push_missing_file(
writes,
dotenv::example_path(app),
"# Add non-secret example values here. crabenv copy uses this file to create .env.\n",
);
match app.framework.as_str() {
"python" => push_missing_file(writes, python_init_schema_path(app), PYTHON_ENV_TEMPLATE),
"rust" => push_missing_file(writes, rust::config_path(app), RUST_CONFIG_TEMPLATE),
_ => collect_typescript_init_writes(app, writes),
}
Ok(())
}
fn collect_typescript_init_writes(app: &Workspace, writes: &mut Vec<FileWritePlan>) {
let plain = typescript::plain_schema_path(app);
let private = typescript::private_schema_path(app);
let public = typescript::public_schema_path(app);
if plain.exists() {
return;
}
push_missing_file(writes, private, TYPESCRIPT_PRIVATE_ENV_TEMPLATE);
push_missing_file(writes, public, TYPESCRIPT_PUBLIC_ENV_TEMPLATE);
}
fn push_missing_file(writes: &mut Vec<FileWritePlan>, path: PathBuf, contents: impl Into<String>) {
if !path.exists() {
writes.push(FileWritePlan {
path,
contents: contents.into(),
});
}
}
fn dedupe_file_writes(writes: &mut Vec<FileWritePlan>) {
let mut seen = BTreeSet::new();
writes.retain(|write| seen.insert(write.path.clone()));
}
fn planned_local_write_labels(project: &Project) -> Vec<PathBuf> {
let mut labels = Vec::new();
if !project.root.join(".env").exists() {
labels.push(PathBuf::from(".env"));
}
if project.is_monorepo && !project.root.join(".env.example").exists() {
labels.push(PathBuf::from(".env.example"));
}
labels
}
fn write_init_local_files(project: &Project) -> Result<()> {
if !project.root.join(".env").exists() {
let plan = build_copy_plan(project, true, false)?;
for write in plan.writes {
if write.path.exists() {
continue;
}
write_init_file(&write)?;
}
} else if project.is_monorepo && !project.root.join(".env.example").exists() {
if let Some(plan) = build_root_example_plan(project)? {
write_init_file(&plan)?;
}
}
Ok(())
}
fn write_init_file(write: &FileWritePlan) -> Result<()> {
if let Some(parent) = write.path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&write.path, &write.contents)
.with_context(|| format!("failed to write {}", write.path.display()))?;
println!("wrote {}", write.path.display());
Ok(())
}
fn init_status(path: &Path) -> String {
if path.exists() {
color("present", "32")
} else {
color("will create", "33")
}
}
fn schema_status(app: &Workspace) -> String {
match app.framework.as_str() {
"python" => status_for_path(&python_init_schema_path(app), "src/env.py"),
"rust" => status_for_path(&rust::config_path(app), "src/config.rs"),
_ if typescript::should_use_plain_schema(app) => {
format!("plain src/env.ts {}", color("present", "32"))
}
_ if typescript::private_schema_path(app).exists()
|| typescript::public_schema_path(app).exists() =>
{
let private = if typescript::private_schema_path(app).exists() {
color("present", "32")
} else {
color("will create", "33")
};
let public = if typescript::public_schema_path(app).exists() {
color("present", "32")
} else {
color("will create", "33")
};
format!("split private {private}, public {public}")
}
_ => format!(
"split private {}, public {}",
color("will create", "33"),
color("will create", "33")
),
}
}
fn status_for_path(path: &Path, label: &str) -> String {
format!(
"{label} {}",
if path.exists() {
color("present", "32")
} else {
color("will create", "33")
}
)
}
fn python_init_schema_path(app: &Workspace) -> PathBuf {
python::find_env_file(&app.root).unwrap_or_else(|| app.root.join("src/env.py"))
}
const TYPESCRIPT_PRIVATE_ENV_TEMPLATE: &str = r#"import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const privateEnv = createEnv({
emptyStringAsUndefined: true,
runtimeEnv: process.env,
server: {},
});
"#;
const TYPESCRIPT_PUBLIC_ENV_TEMPLATE: &str = r#"import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const publicEnv = createEnv({
emptyStringAsUndefined: true,
clientPrefix: "PUBLIC_",
client: {},
runtimeEnvStrict: {},
});
"#;
const PYTHON_ENV_TEMPLATE: &str = r#"from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Env(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
env = Env()
"#;
const RUST_CONFIG_TEMPLATE: &str = r#"use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {}
impl Config {
pub fn from_env() -> envy::Result<Self> {
envy::from_env::<Self>()
}
}
"#;
pub fn run_list(project: &Project, args: ListArgs) -> Result<()> {
let graph = build_graph(project)?;
let expand = args.expand || !args.compact;
if args.json && !args.print {
anyhow::bail!("crabenv ls --json is only valid with -p/--print");
}
if args.print || !should_use_cliclack() {
if args.json {
render_list_json(project, &graph, expand)?;
} else {
render_list(project, &graph, expand);
}
} else {
run_interactive_list(project, &graph, expand)?;
}
Ok(())
}
fn run_interactive_list(project: &Project, graph: &EnvGraph, expand: bool) -> Result<()> {
let rows = list_rows(project, graph, expand);
intro(format!("{} variable(s)", rows.len()))?;
if rows.is_empty() {
outro("No schema/template env vars found.")?;
return Ok(());
}
set_theme(ListTheme);
let selected = select("Search").filter_mode().max_rows(20);
let mut prompt = selected;
for row in rows {
prompt = prompt.item(row.index.clone(), list_search_label(&row), list_hint(&row));
}
let result = prompt.interact();
reset_theme();
let _ = result?;
outro("Done")?;
Ok(())
}
fn list_label(row: &ListRow) -> String {
let description = if row.description == "-" {
String::new()
} else {
format!(" - {}", row.description)
};
format!("{}. {}{}", row.index, row.name, description)
}
fn list_search_label(row: &ListRow) -> String {
format!("{}\u{1f}{}", list_label(row), list_search_value(row))
}
fn list_search_value(row: &ListRow) -> String {
let name_words = row.name.replace(['_', '-'], " ");
format!(
"{} {} {} {} {} {} {} {} {}",
row.index,
row.name,
name_words,
row.description,
row.example,
row.owner,
row.scope,
row.value_type,
row.surfaces
)
}
fn list_hint(row: &ListRow) -> String {
[
("owner", row.owner.as_str()),
("scope", row.scope.as_str()),
("type", row.value_type.as_str()),
("example", row.example.as_str()),
("surfaces", row.surfaces.as_str()),
]
.into_iter()
.filter(|(_, value)| !value.is_empty() && *value != "-")
.map(|(label, value)| format!("- {label}: {value}"))
.collect::<Vec<_>>()
.join("\n")
}
struct ListTheme;
impl Theme for ListTheme {
fn format_select_item(
&self,
state: &ThemeState,
selected: bool,
label: &str,
hint: &str,
) -> String {
match state {
ThemeState::Cancel | ThemeState::Submit if !selected => return String::new(),
_ => {}
}
let radio = self.radio_symbol(state, selected);
let label_style = if selected {
self.input_style(state)
} else {
self.placeholder_style(state)
};
let display_label = label.split('\u{1f}').next().unwrap_or(label);
let mut output = format!(
"{} {} {}\n",
self.bar_color(state).apply_to("│"),
radio,
label_style.apply_to(display_label)
);
if selected
&& matches!(state, ThemeState::Active | ThemeState::Error(_))
&& !hint.is_empty()
{
let hint_style = self.placeholder_style(state);
for line in hint.lines() {
output.push_str(&format!(
"{} {}\n",
self.bar_color(state).apply_to("│"),
hint_style.apply_to(line)
));
}
}
output
}
fn format_footer(&self, state: &ThemeState) -> String {
match state {
ThemeState::Active => format!(
"{} {}\n",
self.bar_color(state).apply_to("└"),
self.placeholder_style(state)
.apply_to("type to search · Enter closes")
),
_ => Theme::format_footer_with_message(self, state, ""),
}
}
}
pub fn run_doctor(project: &Project, args: DoctorArgs) -> Result<()> {
let format_writes = build_format_plan(project)?;
let graph = build_graph(project)?;
let mut issues = collect_issues(project, &graph)?;
add_format_issue(&mut issues, format_writes.len());
let actionable_issue_count = actionable_doctor_issue_count(&issues);
if actionable_issue_count == 0 {
println!("crabenv doctor: {}", color("no issues found", "32"));
} else {
println!("crabenv doctor: {} issue(s)", actionable_issue_count);
}
if !issues.is_empty() {
for issue in &issues {
println!("{} {}", severity_label(&issue.severity), issue.message);
if issue.fix.is_some() {
println!(" {}", color("fixable", "32"));
}
}
}
render_doctor_inventory(project, &graph);
if args.fix {
let mut fixes = Vec::new();
for issue in &issues {
let Some(fix) = issue.fix.clone() else {
continue;
};
if !fixes.contains(&fix) {
fixes.push(fix);
}
}
if fixes.is_empty() {
println!("no automatic fixes available");
return Ok(());
}
println!();
render_fix_plan(&fixes, args.yes);
if !args.yes {
return Ok(());
}
apply_fixes(project, &fixes)?;
println!("{} applied {} fix(es)", color("✓", "32"), fixes.len());
}
Ok(())
}
fn actionable_doctor_issue_count(issues: &[Issue]) -> usize {
issues
.iter()
.filter(|issue| !matches!(issue.severity, Severity::Info))
.count()
}
pub fn run_format(project: &Project, args: FormatArgs) -> Result<()> {
let writes = build_format_plan(project)?;
if writes.is_empty() {
println!("crabenv format: already formatted");
return Ok(());
}
if args.check {
println!("crabenv format: {} file(s) would change", writes.len());
for write in &writes {
println!(
"would format {}",
display_rel(&display_path_from_root(project, &write.path))
);
}
return Ok(());
}
apply_format_writes(project, &writes)?;
println!("crabenv format: formatted {} file(s)", writes.len());
Ok(())
}
fn apply_format_writes(project: &Project, writes: &[FileWritePlan]) -> Result<()> {
for write in writes {
fs::write(&write.path, &write.contents)
.with_context(|| format!("failed to write {}", write.path.display()))?;
println!(
"formatted {}",
display_rel(&display_path_from_root(project, &write.path))
);
}
Ok(())
}
fn format_project_after_mutation(project: &Project) -> Result<()> {
let writes = build_format_plan(project)?;
apply_format_writes(project, &writes)
}
fn severity_label(severity: &Severity) -> String {
let label = format!("[{}]", severity.as_str());
match severity {
Severity::Info => color(label, "36"),
Severity::Warn => color(label, "33"),
Severity::Error => color(label, "31"),
}
}
fn render_fix_plan(fixes: &[Fix], apply_now: bool) {
println!(
"{} {}",
color("Fix plan", "1"),
color(format!("{} automatic fix(es)", fixes.len()), "90")
);
for fix in fixes {
println!(
" {} {} {}",
color("•", fix_accent_color(fix)),
color(fix_badge(fix), fix_accent_color(fix)),
describe_fix(fix)
);
}
if apply_now {
println!(
" {} {}",
color("→", "32"),
color("applying now because --yes was provided", "90")
);
} else {
println!(
" {} {}",
color("→", "33"),
color("preview only; rerun with --yes to apply", "90")
);
}
}
fn fix_badge(fix: &Fix) -> &'static str {
match fix {
Fix::BackfillExample { .. } => "[template]",
Fix::CreateLocalEnv => "[local]",
Fix::Format => "[format]",
Fix::SyncSinks => "[sinks]",
}
}
fn fix_accent_color(fix: &Fix) -> &'static str {
match fix {
Fix::BackfillExample { .. } => "36",
Fix::CreateLocalEnv => "33",
Fix::Format => "32",
Fix::SyncSinks => "35",
}
}
fn build_format_plan(project: &Project) -> Result<Vec<FileWritePlan>> {
let mut paths = BTreeSet::<PathBuf>::new();
let root_env = project.root.join(".env");
if root_env.exists() {
paths.insert(root_env);
}
let root_example = project.root.join(".env.example");
if root_example.exists() {
paths.insert(root_example);
}
for app in app_workspaces(project) {
let example = dotenv::example_path(app);
if example.exists() {
paths.insert(example);
}
if !project.is_monorepo {
let local = dotenv::local_path(project, app);
if local.exists() {
paths.insert(local);
}
}
for (schema_path, _) in typescript::active_schema_paths(app) {
if schema_path.exists() {
paths.insert(schema_path);
}
}
}
let mut writes = Vec::new();
for path in paths {
let contents = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let formatted = if is_typescript_schema(&path, "env.ts") {
typescript::format_schema_contents(&contents, &Scope::Private).and_then(|contents| {
typescript::format_schema_contents(&contents, &Scope::Public)
})?
} else if is_typescript_schema(&path, "env.private.ts") {
typescript::format_schema_contents(&contents, &Scope::Private)?
} else if is_typescript_schema(&path, "env.public.ts") {
typescript::format_schema_contents(&contents, &Scope::Public)?
} else {
dotenv::format_contents(&contents)
};
if formatted != contents {
writes.push(FileWritePlan {
path,
contents: formatted,
});
}
}
Ok(writes)
}
fn is_typescript_schema(path: &Path, file_name: &str) -> bool {
path.file_name().and_then(|name| name.to_str()) == Some(file_name)
}
fn display_path_from_root(project: &Project, path: &Path) -> PathBuf {
path.strip_prefix(&project.root)
.map(Path::to_path_buf)
.unwrap_or_else(|_| path.to_path_buf())
}
pub fn run_copy(project: &Project, args: CopyArgs) -> Result<()> {
let plan = build_copy_plan(project, !args.no_templates, args.overwrite)?;
if args.dry_run {
for write in &plan.writes {
println!("would write {}", write.path.display());
println!("{}", write.contents);
}
return Ok(());
}
apply_copy_plan(&plan)?;
Ok(())
}
fn apply_copy_plan(plan: &crate::models::CopyPlan) -> Result<()> {
for write in &plan.writes {
fs::write(&write.path, &write.contents)
.with_context(|| format!("failed to write {}", write.path.display()))?;
println!("wrote {}", write.path.display());
}
Ok(())
}
fn apply_file_write(write: &crate::models::FileWritePlan) -> Result<()> {
fs::write(&write.path, &write.contents)
.with_context(|| format!("failed to write {}", write.path.display()))?;
println!("wrote {}", write.path.display());
Ok(())
}
pub fn run_add_or_update(project: &Project, args: MutateArgs, update: bool) -> Result<()> {
let from_cli = args.variable.is_some();
let args = if args.variable.is_none() {
crate::prompt::prompt_add_or_update(project, update)?
} else {
args
};
if update && from_cli && !has_update_edit_flags(&args) {
let variable = args.variable.as_deref().unwrap_or("<VAR>");
bail!(
"crabenv update {variable}: no update flags provided; nothing to update. Pass only the fields you want to edit, e.g. --example VALUE or --description TEXT"
);
}
let variable = args.variable.as_deref().unwrap_or("");
validate_var_name(variable)?;
let selection = select_mutation_apps(project, args.owner.as_deref(), args.shared.as_deref())?;
let graph = if update {
Some(build_graph(project)?)
} else {
None
};
let schema_update = update && has_schema_update_edit_flags(&args);
for app in &selection.apps {
if update {
let record = graph
.as_ref()
.and_then(|graph| graph.get(&(app.rel.clone(), variable.to_string())))
.filter(|record| {
record_has_schema_surface(record) || record_has_template_surface(record)
})
.ok_or_else(|| {
anyhow!(
"{} was not found in {}; use `crabenv add {}` to create it",
variable,
display_rel(&app.rel),
variable
)
})?;
if args.optional == Some(true) {
dotenv::upsert_commented_example(
&dotenv::example_path(app),
variable,
args.example.as_deref().unwrap_or(""),
)?;
} else if let Some(example) = args.example.as_deref() {
dotenv::upsert_example(&dotenv::example_path(app), variable, example)?;
}
if !schema_update {
continue;
}
let mutation = update_mutation_from_record(record, &args);
let scope = update_scope_from_args(record, &args);
if app.framework == "python" {
python::upsert_schema(app, &mutation)?;
} else if app.framework == "rust" {
rust::upsert_schema(app, &mutation)?;
} else {
typescript::upsert_schema(app, &mutation, &scope)?;
}
continue;
}
let scope = if args.public {
Scope::Public
} else {
Scope::Private
};
let env_value = args.example.clone().unwrap_or_default();
let mutation = VarMutation {
variable: variable.to_string(),
..VarMutation::from(&args)
};
upsert_mutation_example(&dotenv::example_path(app), variable, &mutation, &env_value)?;
if app.framework == "python" {
python::upsert_schema(app, &mutation)?;
} else if app.framework == "rust" {
rust::upsert_schema(app, &mutation)?;
} else {
typescript::upsert_schema(app, &mutation, &scope)?;
}
}
print_mutation_result(
if update { "updated" } else { "added" },
variable,
&selection.apps,
&selection.target,
);
if update {
sync_root_example(project)?;
} else {
sync_local_env(project)?;
}
format_project_after_mutation(project)?;
Ok(())
}
fn upsert_mutation_example(
path: &Path,
variable: &str,
mutation: &VarMutation,
value: &str,
) -> Result<()> {
if mutation.optional {
dotenv::upsert_commented_example(path, variable, value)
} else {
dotenv::upsert_example(path, variable, value)
}
}
fn has_update_edit_flags(args: &MutateArgs) -> bool {
args.example.is_some() || has_schema_update_edit_flags(args)
}
fn has_schema_update_edit_flags(args: &MutateArgs) -> bool {
args.description.is_some()
|| args.optional.is_some()
|| args.default_value.is_some()
|| args.string
|| args.numeric
|| args.number
|| args.boolean
|| args.enum_values.is_some()
|| args.test_regex.is_some()
|| args.test_regex_message.is_some()
}
fn update_scope_from_args(record: &EnvRecord, args: &MutateArgs) -> Scope {
if args.public {
Scope::Public
} else if matches!(record.scope, Scope::Public) {
Scope::Public
} else {
Scope::Private
}
}
fn update_mutation_from_record(record: &EnvRecord, args: &MutateArgs) -> VarMutation {
let mut mutation = mutation_from_record(record);
if let Some(description) = &args.description {
mutation.description = Some(description.clone());
}
if let Some(optional) = args.optional {
mutation.optional = optional;
mutation.default_value = None;
}
if let Some(default_value) = &args.default_value {
mutation.default_value = Some(default_value.clone());
mutation.optional = false;
}
if args.string
|| args.numeric
|| args.number
|| args.boolean
|| args.enum_values.is_some()
|| args.test_regex.is_some()
{
mutation.numeric = false;
mutation.number = false;
mutation.boolean = false;
mutation.enum_values = None;
mutation.test_regex = None;
mutation.test_regex_message = None;
}
if args.numeric {
mutation.numeric = true;
}
if args.number {
mutation.number = true;
}
if args.boolean {
mutation.boolean = true;
}
if let Some(enum_values) = &args.enum_values {
mutation.enum_values = Some(enum_values.clone());
}
if let Some(test_regex) = &args.test_regex {
mutation.test_regex = Some(test_regex.clone());
}
if let Some(test_regex_message) = &args.test_regex_message {
mutation.test_regex_message = Some(test_regex_message.clone());
}
mutation
}
pub fn run_attach(project: &Project, args: AttachArgs) -> Result<()> {
let args = if args.variable.is_none() {
crate::prompt::prompt_attach(project)?
} else {
args
};
let variable = args.variable.as_deref().unwrap_or("");
let owner = args
.owner
.as_deref()
.ok_or_else(|| anyhow!("--owner is required (or run without args for interactive mode)"))?;
validate_var_name(variable)?;
let target = select_app(project, Some(owner))?;
let graph = build_graph(project)?;
let source = select_attach_source(&graph, variable, args.from.as_deref())?;
let source_owner = select_app(project, Some(source.owner.as_path()))?;
let scope = source.scope.clone();
if scope == Scope::Unknown {
bail!("{} has no schema scope to attach", variable);
}
let example_value = source
.example_value
.clone()
.or_else(|| example_value_for_name(&graph, variable))
.or_else(|| source.default_value.clone())
.unwrap_or_default();
let mutation = mutation_from_record(source);
let ts_expr = if source.owner == source_owner.rel {
typescript::read_schema_expr(source_owner, variable, &scope)?
} else {
None
};
dotenv::upsert_example(&dotenv::example_path(target), variable, &example_value)?;
if target.framework == "python" {
python::upsert_schema(target, &mutation)?;
} else if target.framework == "rust" {
rust::upsert_schema(target, &mutation)?;
} else if let Some(expr) = ts_expr {
typescript::upsert_schema_expr(target, variable, &scope, &expr)?;
} else {
typescript::upsert_schema(target, &mutation, &scope)?;
}
println!(
"attached {} from {} to {}",
variable,
display_rel(&source.owner),
display_rel(&target.rel)
);
sync_local_env(project)?;
format_project_after_mutation(project)?;
Ok(())
}
pub fn run_remove(project: &Project, args: RemoveArgs) -> Result<()> {
let args = if args.variable.is_none() {
crate::prompt::prompt_remove(project)?
} else {
args
};
let variable = args.variable.as_deref().unwrap_or("");
validate_var_name(variable)?;
let selection = select_remove_targets(project, &args, variable)?;
for (app, scope) in &selection.targets {
remove_from_app(app, variable, scope)?;
}
let apps = selection
.targets
.iter()
.map(|(app, _)| *app)
.collect::<Vec<_>>();
print_mutation_result("removed", variable, &apps, &selection.target);
sync_root_example(project)?;
format_project_after_mutation(project)?;
Ok(())
}
fn select_attach_source<'a>(
graph: &'a EnvGraph,
variable: &str,
owner: Option<&Path>,
) -> Result<&'a EnvRecord> {
let mut matches = graph
.values()
.filter(|record| record.name == variable)
.filter(|record| record_has_schema_surface(record))
.filter(|record| owner.is_none_or(|owner| record.owner == normalize_rel_display(owner)))
.collect::<Vec<_>>();
if matches.is_empty() {
if let Some(owner) = owner {
bail!(
"{} was not found in schema for {}",
variable,
display_rel(owner)
);
}
bail!("{variable} was not found in any schema");
}
if matches.len() > 1 {
let owners = matches
.iter()
.map(|record| display_rel(&record.owner))
.collect::<Vec<_>>()
.join(", ");
bail!("{variable} exists in multiple owners ({owners}); pass --from apps/name");
}
Ok(matches.remove(0))
}
fn record_has_schema_surface(record: &EnvRecord) -> bool {
record.surfaces.contains(&EnvSurface::Schema)
}
fn record_has_template_surface(record: &EnvRecord) -> bool {
record.surfaces.contains(&EnvSurface::Template)
}
fn example_value_for_name(graph: &EnvGraph, variable: &str) -> Option<String> {
graph
.values()
.find(|record| record.name == variable && record.example_value.is_some())
.and_then(|record| record.example_value.clone())
}
fn mutation_from_record(record: &EnvRecord) -> VarMutation {
let value_type = record.value_type.as_deref().unwrap_or("string");
VarMutation {
variable: record.name.clone(),
description: record.description.clone(),
example: record.example_value.clone(),
optional: record.required == Some(false) && record.default_value.is_none(),
default_value: record.default_value.clone(),
numeric: value_type == "regex",
number: value_type == "number",
boolean: value_type == "boolean",
enum_values: record.enum_values.as_ref().map(|values| values.join(",")),
test_regex: None,
test_regex_message: None,
}
}
fn describe_fix(fix: &Fix) -> String {
match fix {
Fix::BackfillExample { app, name } => {
format!("add {name} to {}/.env.example", display_rel(app))
}
Fix::CreateLocalEnv => "create/update local .env from .env.example files".to_string(),
Fix::Format => "format env files and schema entries".to_string(),
Fix::SyncSinks => "sync managed sink blocks".to_string(),
}
}
fn apply_fixes(project: &Project, fixes: &[Fix]) -> Result<()> {
let graph = build_graph(project)?;
let mut should_copy = false;
let mut should_format = false;
let mut should_sync_root_example = false;
let mut should_sync_sinks = false;
for fix in fixes {
match fix {
Fix::BackfillExample { app, name } => {
let workspace = app_workspaces(project)
.find(|workspace| workspace.rel == *app)
.ok_or_else(|| anyhow!("unknown app {}", app.display()))?;
let record = graph.get(&(app.clone(), name.clone()));
let backfill_value = record
.and_then(|record| record.example_value.clone())
.unwrap_or_default();
if record.is_some_and(|record| record.required == Some(false)) {
dotenv::upsert_commented_example(
&dotenv::example_path(workspace),
name,
&backfill_value,
)?;
} else {
dotenv::upsert_example(
&dotenv::example_path(workspace),
name,
&backfill_value,
)?;
}
should_sync_root_example = true;
}
Fix::CreateLocalEnv => {
should_copy = true;
}
Fix::Format => {
should_format = true;
}
Fix::SyncSinks => {
should_sync_sinks = true;
}
}
}
if should_sync_sinks {
let plan = apply_sink_plan(project, &graph)?;
if !plan.writes.is_empty() {
println!("synced {} sink file(s)", plan.writes.len());
}
}
if should_copy {
run_copy(
project,
CopyArgs {
dry_run: false,
overwrite: false,
no_templates: false,
},
)?;
} else if should_sync_root_example {
sync_root_example(project)?;
}
if should_format {
run_format(project, FormatArgs { check: false })?;
}
Ok(())
}
fn sync_local_env(project: &Project) -> Result<()> {
let plan = build_copy_plan(project, true, false)?;
apply_copy_plan(&plan)
}
fn sync_root_example(project: &Project) -> Result<()> {
if let Some(plan) = build_root_example_plan(project)? {
apply_file_write(&plan)?;
}
Ok(())
}
fn select_app<'a>(
project: &'a Project,
owner: Option<&Path>,
) -> Result<&'a crate::models::Workspace> {
let apps = app_workspaces(project).collect::<Vec<_>>();
if let Some(owner) = owner {
let normalized = normalize_rel_display(owner);
return apps
.into_iter()
.find(|workspace| workspace.rel == normalized)
.ok_or_else(|| anyhow!("unknown app owner {}", owner.display()));
}
if apps.len() == 1 {
return Ok(apps[0]);
}
bail!("multiple apps found; pass --owner apps/name");
}
#[derive(Clone, Debug)]
enum RemoveScope {
Scope(Scope),
All,
}
#[derive(Clone, Debug)]
enum SharedTarget {
All,
Selected(Vec<PathBuf>),
}
#[derive(Debug)]
enum MutationTarget {
Single,
Shared(SharedTarget),
}
#[derive(Debug)]
struct MutationSelection<'a> {
apps: Vec<&'a Workspace>,
target: MutationTarget,
}
#[derive(Debug)]
struct RemoveSelection<'a> {
targets: Vec<(&'a Workspace, RemoveScope)>,
target: MutationTarget,
}
fn select_mutation_apps<'a>(
project: &'a Project,
owner: Option<&Path>,
shared: Option<&[PathBuf]>,
) -> Result<MutationSelection<'a>> {
if let Some(shared) = shared_target_from_args(shared) {
let apps = select_shared_apps(project, &shared)?;
return Ok(MutationSelection {
apps,
target: MutationTarget::Shared(shared),
});
}
if let Some(owner) = owner.filter(|owner| is_shared_owner_alias(owner)) {
let shared = shared_target_from_owner_alias(owner);
let apps = select_shared_apps(project, &shared)?;
return Ok(MutationSelection {
apps,
target: MutationTarget::Shared(shared),
});
}
Ok(MutationSelection {
apps: vec![select_app(project, owner)?],
target: MutationTarget::Single,
})
}
fn select_remove_targets<'a>(
project: &'a Project,
args: &RemoveArgs,
variable: &str,
) -> Result<RemoveSelection<'a>> {
if let Some(shared) = shared_target_from_args(args.shared.as_deref()).or_else(|| {
args.owner
.as_deref()
.filter(|owner| is_shared_owner_alias(owner))
.map(shared_target_from_owner_alias)
}) {
let records = definition_records_for_variable(project, variable)?;
if records.is_empty() {
bail!("{} was not found in any app owner", variable);
}
let owners = match &shared {
SharedTarget::All => unique_record_owners(&records),
SharedTarget::Selected(owners) => {
let existing_owners = unique_record_owners(&records)
.into_iter()
.collect::<BTreeSet<_>>();
for owner in owners {
if !existing_owners.contains(owner) {
bail!("{} is not defined in {}", variable, display_rel(owner));
}
}
owners.clone()
}
};
let targets = owners
.into_iter()
.map(|owner| {
let app = select_app(project, Some(owner.as_path()))?;
Ok((app, RemoveScope::All))
})
.collect::<Result<Vec<_>>>()?;
return Ok(RemoveSelection {
targets,
target: MutationTarget::Shared(shared),
});
}
if let Some(owner) = args.owner.as_deref() {
let app = select_app(project, Some(owner))?;
return Ok(RemoveSelection {
targets: vec![(app, RemoveScope::Scope(remove_arg_scope(args)))],
target: MutationTarget::Single,
});
}
let records = definition_records_for_variable(project, variable)?;
if records.len() == 1 {
let record = &records[0];
let app = select_app(project, Some(record.owner.as_path()))?;
let scope = match record.scope {
Scope::Private | Scope::Public => RemoveScope::Scope(record.scope.clone()),
Scope::Unknown => RemoveScope::All,
};
return Ok(RemoveSelection {
targets: vec![(app, scope)],
target: MutationTarget::Single,
});
}
if records.len() > 1 {
let owners = records
.iter()
.map(|record| display_rel(&record.owner))
.collect::<Vec<_>>()
.join(", ");
bail!(
"{} exists in multiple owners ({owners}); pass --owner apps/name to remove one, or --shared to remove all",
variable
);
}
Ok(RemoveSelection {
targets: vec![(
select_app(project, None)?,
RemoveScope::Scope(remove_arg_scope(args)),
)],
target: MutationTarget::Single,
})
}
fn definition_records_for_variable(project: &Project, variable: &str) -> Result<Vec<EnvRecord>> {
Ok(build_graph(project)?
.values()
.filter(|record| record.name == variable)
.filter(|record| record_has_schema_surface(record) || record_has_template_surface(record))
.cloned()
.collect())
}
fn shared_target_from_args(shared: Option<&[PathBuf]>) -> Option<SharedTarget> {
shared.map(|owners| {
if owners.is_empty() || owners.iter().any(|owner| is_shared_owner_alias(owner)) {
SharedTarget::All
} else {
SharedTarget::Selected(normalize_unique_owners(owners))
}
})
}
fn shared_target_from_owner_alias(owner: &Path) -> SharedTarget {
debug_assert!(is_shared_owner_alias(owner));
SharedTarget::All
}
fn normalize_unique_owners(owners: &[PathBuf]) -> Vec<PathBuf> {
let mut normalized = owners
.iter()
.map(|owner| normalize_rel_display(owner))
.collect::<Vec<_>>();
normalized.sort();
normalized.dedup();
normalized
}
fn select_shared_apps<'a>(
project: &'a Project,
target: &SharedTarget,
) -> Result<Vec<&'a Workspace>> {
match target {
SharedTarget::All => {
let apps = app_workspaces(project).collect::<Vec<_>>();
if apps.is_empty() {
bail!("no app owners found");
}
Ok(apps)
}
SharedTarget::Selected(owners) => {
if owners.is_empty() {
bail!("pass at least one app owner to --shared, or use --shared '*' for all");
}
owners
.iter()
.map(|owner| select_app(project, Some(owner.as_path())))
.collect()
}
}
}
fn unique_record_owners(records: &[EnvRecord]) -> Vec<PathBuf> {
let mut owners = records
.iter()
.map(|record| record.owner.clone())
.collect::<Vec<_>>();
owners.sort();
owners.dedup();
owners
}
fn remove_from_app(
app: &crate::models::Workspace,
variable: &str,
scope: &RemoveScope,
) -> Result<()> {
dotenv::remove_key(&dotenv::example_path(app), variable)?;
if app.framework == "python" {
python::remove_schema(app, variable)?;
return Ok(());
}
if app.framework == "rust" {
rust::remove_schema(app, variable)?;
return Ok(());
}
match scope {
RemoveScope::Scope(scope) => typescript::remove_schema(app, variable, scope)?,
RemoveScope::All => {
typescript::remove_schema(app, variable, &Scope::Private)?;
typescript::remove_schema(app, variable, &Scope::Public)?;
}
}
Ok(())
}
fn remove_arg_scope(args: &RemoveArgs) -> Scope {
if args.public {
Scope::Public
} else {
Scope::Private
}
}
fn print_mutation_result(
action: &str,
variable: &str,
apps: &[&Workspace],
target: &MutationTarget,
) {
if apps.len() > 1 || matches!(target, MutationTarget::Shared(_)) {
println!(
"{action} {variable} in {}",
format_mutation_target(apps, target)
);
for app in apps {
println!("- {}", display_rel(&app.rel));
}
} else if let Some(app) = apps.first() {
println!("{action} {variable} in {}", display_rel(&app.rel));
}
}
fn format_mutation_target(apps: &[&Workspace], target: &MutationTarget) -> String {
match target {
MutationTarget::Single => apps
.first()
.map(|app| display_rel(&app.rel))
.unwrap_or_else(|| "-".to_string()),
MutationTarget::Shared(SharedTarget::All) => "shared(all)".to_string(),
MutationTarget::Shared(SharedTarget::Selected(owners)) => {
let owners = if owners.is_empty() {
apps.iter().map(|app| app.rel.clone()).collect::<Vec<_>>()
} else {
owners.clone()
};
format!(
"shared({})",
owners
.iter()
.map(|owner| display_rel(owner))
.collect::<Vec<_>>()
.join(", ")
)
}
}
}
fn is_shared_owner_alias(owner: &Path) -> bool {
let normalized = normalize_rel_display(owner);
matches!(
normalized.to_string_lossy().as_ref(),
"shared" | "all" | "*"
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn test_project(root: &Path) -> Project {
Project {
root: root.to_path_buf(),
is_monorepo: false,
workspaces: vec![Workspace {
root: root.to_path_buf(),
rel: PathBuf::from("."),
kind: crate::models::WorkspaceKind::App,
framework: "typescript".to_string(),
}],
}
}
fn mutate_args(variable: &str) -> MutateArgs {
MutateArgs {
variable: Some(variable.to_string()),
owner: None,
shared: None,
public: false,
example: None,
description: None,
optional: None,
default_value: None,
string: false,
numeric: false,
number: false,
boolean: false,
enum_values: None,
test_regex: None,
test_regex_message: None,
}
}
fn remove_args(variable: &str) -> RemoveArgs {
RemoveArgs {
variable: Some(variable.to_string()),
owner: None,
shared: None,
public: false,
}
}
fn write_typescript_project(root: &Path) {
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("package.json"),
r#"{"dependencies":{"zod":"latest"}}"#,
)
.unwrap();
fs::write(root.join(".env.example"), "NODE_ENV=development\n").unwrap();
fs::write(
root.join("src/env.private.ts"),
r#"import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const privateEnv = createEnv({
runtimeEnv: process.env,
server: {
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
},
});
"#,
)
.unwrap();
}
#[test]
fn doctor_adds_fixable_format_issue_when_files_would_change() {
let mut issues = Vec::new();
add_format_issue(&mut issues, 2);
assert_eq!(issues.len(), 1);
assert!(matches!(issues[0].severity, Severity::Warn));
assert_eq!(issues[0].fix, Some(Fix::Format));
assert_eq!(
issues[0].message,
"2 file(s) need formatting; run crabenv format"
);
}
#[test]
fn doctor_issue_count_ignores_info_notes() {
let issues = vec![Issue {
severity: Severity::Info,
message: "DEV_DISABLE_EMAILS exists in local but is missing from schema and template"
.to_string(),
fix: None,
}];
assert_eq!(actionable_doctor_issue_count(&issues), 0);
}
#[test]
fn doctor_issue_count_counts_warnings_and_errors() {
let issues = vec![
Issue {
severity: Severity::Info,
message: "local-only note".to_string(),
fix: None,
},
Issue {
severity: Severity::Warn,
message: "warning".to_string(),
fix: None,
},
Issue {
severity: Severity::Error,
message: "error".to_string(),
fix: None,
},
];
assert_eq!(actionable_doctor_issue_count(&issues), 2);
}
#[test]
fn doctor_format_fix_applies_format_plan() {
let tempdir = tempdir().unwrap();
let path = tempdir.path().join(".env.example");
fs::write(
&path,
"# ---- apps/web/.env.example ----\nB=2\n# ---- Shared ----\nA=1\n",
)
.unwrap();
let project = test_project(tempdir.path());
assert_eq!(build_format_plan(&project).unwrap().len(), 1);
apply_fixes(&project, &[Fix::Format]).unwrap();
assert!(build_format_plan(&project).unwrap().is_empty());
}
#[test]
fn doctor_backfill_optional_defaults_as_commented_empty_template_entries() {
let tempdir = tempdir().unwrap();
fs::create_dir_all(tempdir.path().join("src")).unwrap();
fs::write(
tempdir.path().join("package.json"),
r#"{"dependencies":{"zod":"latest"}}"#,
)
.unwrap();
fs::write(tempdir.path().join(".env.example"), "").unwrap();
fs::write(
tempdir.path().join("src/env.private.ts"),
r#"import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const privateEnv = createEnv({
runtimeEnv: process.env,
server: {
NODE_ENV: z.enum(["development", "production"]).default("development"),
PORT: z.coerce.number().default(3000),
},
});
"#,
)
.unwrap();
let project = test_project(tempdir.path());
apply_fixes(
&project,
&[
Fix::BackfillExample {
app: PathBuf::from("."),
name: "NODE_ENV".to_string(),
},
Fix::BackfillExample {
app: PathBuf::from("."),
name: "PORT".to_string(),
},
],
)
.unwrap();
let example = fs::read_to_string(tempdir.path().join(".env.example")).unwrap();
assert!(example.contains("# NODE_ENV="));
assert!(example.contains("# PORT="));
assert!(!example.contains("NODE_ENV=development"));
assert!(!example.contains("PORT=3000"));
assert!(!example.contains("NODE_ENV=\"\""));
assert!(!example.contains("PORT=\"\""));
}
#[test]
fn add_formats_schema_and_template_immediately() {
let tempdir = tempdir().unwrap();
fs::create_dir_all(tempdir.path().join("src")).unwrap();
fs::write(
tempdir.path().join("package.json"),
r#"{"dependencies":{"zod":"latest"}}"#,
)
.unwrap();
fs::write(tempdir.path().join(".env.example"), "RESEND_API_KEY=abc\n").unwrap();
fs::write(
tempdir.path().join("src/env.private.ts"),
r#"import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const privateEnv = createEnv({
runtimeEnv: process.env,
server: {
RESEND_API_KEY: z.string(),
},
});
"#,
)
.unwrap();
let project = test_project(tempdir.path());
run_add_or_update(
&project,
MutateArgs {
variable: Some("PORT".to_string()),
owner: None,
shared: None,
public: false,
example: Some("3000".to_string()),
description: None,
optional: None,
default_value: Some("3000".to_string()),
string: false,
numeric: false,
number: true,
boolean: false,
enum_values: None,
test_regex: None,
test_regex_message: None,
},
false,
)
.unwrap();
assert!(build_format_plan(&project).unwrap().is_empty());
let schema = fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap();
assert!(schema.contains(
r#" server: {
PORT: z.coerce.number().default(3000),
RESEND_API_KEY: z.string(),
},"#
));
}
#[test]
fn remove_formats_schema_and_template_immediately() {
let tempdir = tempdir().unwrap();
fs::create_dir_all(tempdir.path().join("src")).unwrap();
fs::write(
tempdir.path().join("package.json"),
r#"{"dependencies":{"zod":"latest"}}"#,
)
.unwrap();
fs::write(
tempdir.path().join(".env.example"),
"# SENTRY_DSN=dsn\nDATABASE_URL=file:./local.db\nNODE_ENV=development\n",
)
.unwrap();
fs::write(
tempdir.path().join("src/env.private.ts"),
r#"import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const privateEnv = createEnv({
runtimeEnv: process.env,
server: {
SENTRY_DSN: z.string().optional(),
DATABASE_URL: z.string(),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
},
});
"#,
)
.unwrap();
let project = test_project(tempdir.path());
run_remove(&project, remove_args("SENTRY_DSN")).unwrap();
assert!(build_format_plan(&project).unwrap().is_empty());
let schema = fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap();
assert!(schema.contains(
r#" server: {
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
DATABASE_URL: z.string(),
},"#
));
let example = fs::read_to_string(tempdir.path().join(".env.example")).unwrap();
assert_eq!(
example,
"NODE_ENV=development\n\nDATABASE_URL=file:./local.db\n"
);
}
#[test]
fn add_optional_without_example_writes_commented_template_entry() {
let tempdir = tempdir().unwrap();
write_typescript_project(tempdir.path());
let project = test_project(tempdir.path());
let mut args = mutate_args("SENTRY_DSN");
args.optional = Some(true);
run_add_or_update(&project, args, false).unwrap();
let example = fs::read_to_string(tempdir.path().join(".env.example")).unwrap();
assert!(example.contains("# SENTRY_DSN="));
assert!(!example.contains("\nSENTRY_DSN="));
let local = fs::read_to_string(tempdir.path().join(".env")).unwrap();
assert!(local.contains("# SENTRY_DSN="));
assert!(!local.contains("\nSENTRY_DSN="));
}
#[test]
fn add_optional_with_example_and_default_writes_commented_example_value() {
let tempdir = tempdir().unwrap();
write_typescript_project(tempdir.path());
let project = test_project(tempdir.path());
let mut args = mutate_args("PORT");
args.optional = Some(true);
args.example = Some("3000".to_string());
args.default_value = Some("3000".to_string());
args.number = true;
run_add_or_update(&project, args, false).unwrap();
let example = fs::read_to_string(tempdir.path().join(".env.example")).unwrap();
assert!(example.contains("# PORT=3000"));
assert!(!example.contains("\nPORT=3000"));
let local = fs::read_to_string(tempdir.path().join(".env")).unwrap();
assert!(local.contains("# PORT=3000"));
assert!(!local.contains("\nPORT=3000"));
}
#[test]
fn update_without_edit_flags_errors_before_writing() {
let tempdir = tempdir().unwrap();
write_typescript_project(tempdir.path());
let project = test_project(tempdir.path());
let before_schema = fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap();
let before_example = fs::read_to_string(tempdir.path().join(".env.example")).unwrap();
let err = run_add_or_update(&project, mutate_args("NODE_ENV"), true).unwrap_err();
assert!(err.to_string().contains("no update flags provided"));
assert_eq!(
fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap(),
before_schema
);
assert_eq!(
fs::read_to_string(tempdir.path().join(".env.example")).unwrap(),
before_example
);
}
#[test]
fn update_example_only_leaves_schema_unchanged() {
let tempdir = tempdir().unwrap();
write_typescript_project(tempdir.path());
let project = test_project(tempdir.path());
let before_schema = fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap();
let mut args = mutate_args("NODE_ENV");
args.example = Some("test".to_string());
run_add_or_update(&project, args, true).unwrap();
assert_eq!(
fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap(),
before_schema
);
assert_eq!(
fs::read_to_string(tempdir.path().join(".env.example")).unwrap(),
"NODE_ENV=test\n"
);
}
#[test]
fn update_description_preserves_existing_enum_type() {
let tempdir = tempdir().unwrap();
write_typescript_project(tempdir.path());
let project = test_project(tempdir.path());
let mut args = mutate_args("NODE_ENV");
args.description = Some("Runtime mode".to_string());
run_add_or_update(&project, args, true).unwrap();
let schema = fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap();
assert!(schema.contains("// Runtime mode"));
assert!(schema.contains(
r#"NODE_ENV: z.enum(["development", "test", "production"]).default("development")"#
));
}
#[test]
fn update_optional_false_marks_existing_optional_schema_required() {
let tempdir = tempdir().unwrap();
write_typescript_project(tempdir.path());
fs::write(
tempdir.path().join(".env.example"),
"NODE_ENV=development\n# WEIRDO=weird\n",
)
.unwrap();
fs::write(
tempdir.path().join("src/env.private.ts"),
r#"import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const privateEnv = createEnv({
runtimeEnv: process.env,
server: {
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
WEIRDO: z.string().optional(),
},
});
"#,
)
.unwrap();
let project = test_project(tempdir.path());
let mut args = mutate_args("WEIRDO");
args.optional = Some(false);
run_add_or_update(&project, args, true).unwrap();
let schema = fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap();
assert!(schema.contains("WEIRDO: z.string(),"));
assert!(!schema.contains("WEIRDO: z.string().optional(),"));
}
#[test]
fn update_optional_true_marks_schema_optional_and_comments_example() {
let tempdir = tempdir().unwrap();
write_typescript_project(tempdir.path());
fs::write(
tempdir.path().join(".env.example"),
"NODE_ENV=development\nWEIRDO=weird\n",
)
.unwrap();
fs::write(
tempdir.path().join("src/env.private.ts"),
r#"import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const privateEnv = createEnv({
runtimeEnv: process.env,
server: {
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
WEIRDO: z.string(),
},
});
"#,
)
.unwrap();
let project = test_project(tempdir.path());
let mut args = mutate_args("WEIRDO");
args.optional = Some(true);
args.example = Some("weird".to_string());
run_add_or_update(&project, args, true).unwrap();
let schema = fs::read_to_string(tempdir.path().join("src/env.private.ts")).unwrap();
assert!(schema.contains("WEIRDO: z.string().optional(),"));
let example = fs::read_to_string(tempdir.path().join(".env.example")).unwrap();
assert!(example.contains("# WEIRDO=weird"));
assert!(!example.contains("\nWEIRDO=weird"));
}
#[test]
fn discovered_project_detects_root_example_format_plan() {
let tempdir = tempdir().unwrap();
fs::write(
tempdir.path().join(".env.example"),
"# ---- apps/web/.env.example ----\nB=2\n# ---- Shared ----\nA=1\n",
)
.unwrap();
let root = tempdir.path().canonicalize().unwrap();
let project = crate::discovery::discover_project(&root).unwrap();
assert_eq!(build_format_plan(&project).unwrap().len(), 1);
}
#[test]
fn shared_target_from_args_treats_empty_and_star_as_all() {
assert!(matches!(
shared_target_from_args(Some(&[])),
Some(SharedTarget::All)
));
let owners = vec![PathBuf::from("*")];
assert!(matches!(
shared_target_from_args(Some(&owners)),
Some(SharedTarget::All)
));
}
#[test]
fn shared_target_from_args_normalizes_and_dedupes_selected_owners() {
let owners = vec![
PathBuf::from("apps/web"),
PathBuf::from("apps/api"),
PathBuf::from("apps/web"),
];
let Some(SharedTarget::Selected(selected)) = shared_target_from_args(Some(&owners)) else {
panic!("expected selected shared owners");
};
assert_eq!(
selected,
vec![PathBuf::from("apps/api"), PathBuf::from("apps/web")]
);
}
#[test]
fn shared_owner_aliases_stay_backward_compatible_all_targets() {
for owner in ["shared", "all", "*"] {
assert!(is_shared_owner_alias(Path::new(owner)));
assert!(matches!(
shared_target_from_owner_alias(Path::new(owner)),
SharedTarget::All
));
}
}
}