use crate::cli::{AskArgs, EffortLevel, PermMode};
use std::collections::HashMap;
use std::path::PathBuf;
pub fn apply_env_overrides(args: &mut AskArgs) {
let env: HashMap<String, String> = std::env::vars().collect();
apply_env_overrides_from(args, &env);
}
pub fn apply_env_overrides_from(args: &mut AskArgs, env: &HashMap<String, String>) {
tag_cli_sources(args);
if args.model.is_none()
&& let Some(s) = read_string(env, "ROBA_MODEL")
{
args.model = Some(s);
}
if args.effort.is_none()
&& let Some(s) = read_string(env, "ROBA_EFFORT")
{
args.effort = match s.to_ascii_lowercase().as_str() {
"low" => Some(EffortLevel::Low),
"medium" => Some(EffortLevel::Medium),
"high" => Some(EffortLevel::High),
"xhigh" => Some(EffortLevel::Xhigh),
"max" => Some(EffortLevel::Max),
_ => None, };
}
if args.agent.is_none()
&& let Some(s) = read_string(env, "ROBA_AGENT")
{
args.agent = Some(s);
}
if args.system_prompt.is_none()
&& let Some(s) = read_string(env, "ROBA_SYSTEM_PROMPT")
{
args.system_prompt = Some(s);
}
if args.append_system_prompt.is_none()
&& let Some(s) = read_string(env, "ROBA_APPEND_SYSTEM_PROMPT")
{
args.append_system_prompt = Some(s);
}
if args.prepend.is_empty() {
let paths = read_path_list(env, "ROBA_PREPEND");
if !paths.is_empty() {
args.prepend = paths;
}
}
if args.append.is_empty() {
let paths = read_path_list(env, "ROBA_APPEND");
if !paths.is_empty() {
args.append = paths;
}
}
if args.attach.is_empty() {
let attach = read_list(env, "ROBA_ATTACH");
if !attach.is_empty() {
args.attach = attach;
}
}
if !args.git_diff && read_truthy(env, "ROBA_GIT_DIFF") {
args.git_diff = true;
}
if args.git_log.is_none()
&& let Some(n) = read_usize(env, "ROBA_GIT_LOG")
{
args.git_log = Some(n);
}
if !args.git_status && read_truthy(env, "ROBA_GIT_STATUS") {
args.git_status = true;
}
if args.continue_session.is_none()
&& let Some(s) = env.get("ROBA_CONTINUE").filter(|s| !s.is_empty())
{
match s.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => args.continue_session = Some(None),
"0" | "false" | "no" | "off" => {} _ => args.continue_session = Some(Some(s.clone())),
}
}
if args.permission_mode.is_none()
&& let Some(s) = read_string(env, "ROBA_PERMISSION_MODE")
&& let Some(mode) = parse_permission_mode(&s)
{
args.permission_mode = Some(mode);
args.permission_mode_source = Some("env".to_string());
}
if !args.readonly && read_truthy(env, "ROBA_READONLY") {
args.readonly = true;
args.readonly_source = Some("env".to_string());
}
if !args.writable && !args.readonly && read_truthy(env, "ROBA_WRITABLE") {
args.writable = true;
args.writable_source = Some("env".to_string());
}
if !args.full_auto && !args.writable && !args.readonly && read_truthy(env, "ROBA_FULL_AUTO") {
args.full_auto = true;
args.full_auto_source = Some("env".to_string());
}
if args.allow_tool.is_empty() {
let tools = read_list(env, "ROBA_ALLOW_TOOL");
if !tools.is_empty() {
args.allow_tool_sources = vec!["env".to_string(); tools.len()];
args.allow_tool = tools;
}
}
if args.deny_tool.is_empty() {
let tools = read_list(env, "ROBA_DENY_TOOL");
if !tools.is_empty() {
args.deny_tool_sources = vec!["env".to_string(); tools.len()];
args.deny_tool = tools;
}
}
if !args.stream && read_truthy(env, "ROBA_STREAM") {
args.stream = true;
}
if !args.show_thinking && read_truthy(env, "ROBA_SHOW_THINKING") {
args.show_thinking = true;
}
if !args.echo && read_truthy(env, "ROBA_ECHO") {
args.echo = true;
}
if !args.plain && read_truthy(env, "ROBA_PLAIN") {
args.plain = true;
}
if !args.quiet && read_truthy(env, "ROBA_QUIET") {
args.quiet = true;
}
if !args.json && read_truthy(env, "ROBA_JSON") {
args.json = true;
}
if args.editor_history.is_none()
&& let Some(n) = read_usize(env, "ROBA_EDITOR_HISTORY")
{
args.editor_history = Some(n);
}
if args.trace.is_none()
&& let Some(s) = read_string(env, "ROBA_TRACE")
{
args.trace = Some(PathBuf::from(s));
}
if args.rates_file.is_none()
&& let Some(s) = read_string(env, "ROBA_RATES_FILE")
{
args.rates_file = Some(PathBuf::from(s));
}
if !args.no_dollars && read_truthy(env, "ROBA_NO_DOLLARS") {
args.no_dollars = true;
}
if args.worktree.is_none()
&& let Some(s) = env.get("ROBA_WORKTREE").filter(|s| !s.is_empty())
{
let lower = s.to_ascii_lowercase();
match lower.as_str() {
"1" | "true" | "yes" | "on" => args.worktree = Some(None),
"0" | "false" | "no" | "off" => {} _ => args.worktree = Some(Some(s.clone())),
}
}
if !args.no_agent_check && read_truthy(env, "ROBA_NO_AGENT_CHECK") {
args.no_agent_check = true;
}
if !args.no_retry && read_truthy(env, "ROBA_NO_RETRY") {
args.no_retry = true;
}
if !args.bare && read_truthy(env, "ROBA_BARE") {
args.bare = true;
}
if !args.dispatch && read_truthy(env, "ROBA_DISPATCH") {
args.dispatch = true;
}
for (key, value) in env {
if let Some(var_key) = key.strip_prefix("ROBA_VAR_")
&& !args.var.iter().any(|(k, _)| k == var_key)
{
args.var.push((var_key.to_string(), value.clone()));
}
}
}
fn tag_cli_sources(args: &mut AskArgs) {
if args.readonly && args.readonly_source.is_none() {
args.readonly_source = Some("CLI".to_string());
}
if args.writable && args.writable_source.is_none() {
args.writable_source = Some("CLI".to_string());
}
if args.full_auto && args.full_auto_source.is_none() {
args.full_auto_source = Some("CLI".to_string());
}
if !args.allow_tool.is_empty() && args.allow_tool_sources.is_empty() {
args.allow_tool_sources = vec!["CLI".to_string(); args.allow_tool.len()];
}
if !args.deny_tool.is_empty() && args.deny_tool_sources.is_empty() {
args.deny_tool_sources = vec!["CLI".to_string(); args.deny_tool.len()];
}
if args.permission_mode.is_some() && args.permission_mode_source.is_none() {
args.permission_mode_source = Some("CLI".to_string());
}
}
fn read_string(env: &HashMap<String, String>, key: &str) -> Option<String> {
env.get(key).filter(|s| !s.is_empty()).cloned()
}
fn read_truthy(env: &HashMap<String, String>, key: &str) -> bool {
match env.get(key) {
Some(s) => matches!(s.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"),
None => false,
}
}
fn read_usize(env: &HashMap<String, String>, key: &str) -> Option<usize> {
env.get(key)
.filter(|s| !s.is_empty())
.and_then(|s| s.parse().ok())
}
fn read_list(env: &HashMap<String, String>, key: &str) -> Vec<String> {
env.get(key)
.filter(|s| !s.is_empty())
.map(|s| {
s.split(',')
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect()
})
.unwrap_or_default()
}
fn read_path_list(env: &HashMap<String, String>, key: &str) -> Vec<PathBuf> {
read_list(env, key).into_iter().map(PathBuf::from).collect()
}
fn parse_permission_mode(s: &str) -> Option<PermMode> {
match s.to_ascii_lowercase().as_str() {
"acceptedits" | "accept_edits" => Some(PermMode::AcceptEdits),
"auto" => Some(PermMode::Auto),
"bypasspermissions" | "bypass_permissions" => Some(PermMode::BypassPermissions),
"default" => Some(PermMode::Default),
"dontask" | "dont_ask" => Some(PermMode::DontAsk),
"plan" => Some(PermMode::Plan),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
fn empty_args() -> AskArgs {
Cli::try_parse_from(["roba", "placeholder"]).unwrap().ask
}
fn env_with(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect()
}
#[test]
fn model_fills_when_cli_unset() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_MODEL", "sonnet")]));
assert_eq!(args.model.as_deref(), Some("sonnet"));
}
#[test]
fn model_does_not_override_cli() {
let mut args = empty_args();
args.model = Some("opus".into());
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_MODEL", "sonnet")]));
assert_eq!(args.model.as_deref(), Some("opus"));
}
#[test]
fn env_agent_sets_from_roba_agent_var() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_AGENT", "reviewer")]));
assert_eq!(args.agent.as_deref(), Some("reviewer"));
}
#[test]
fn env_agent_does_not_override_cli() {
let mut args = empty_args();
args.agent = Some("planner".into());
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_AGENT", "reviewer")]));
assert_eq!(args.agent.as_deref(), Some("planner"));
}
#[test]
fn writable_accepts_common_truthy_values() {
for val in ["1", "true", "yes", "on", "TRUE", "Yes"] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_WRITABLE", val)]));
assert!(args.writable, "env value {val:?} should enable writable");
}
}
#[test]
fn writable_ignores_falsy_or_garbage() {
for val in ["0", "false", "no", "off", "", "garbage"] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_WRITABLE", val)]));
assert!(
!args.writable,
"env value {val:?} should leave writable off"
);
}
}
#[test]
fn bool_does_not_override_cli_value() {
let mut args = empty_args();
args.writable = true;
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_WRITABLE", "0")]));
assert!(args.writable, "CLI true should survive env false");
}
#[test]
fn env_writable_suppressed_by_cli_readonly() {
let mut args = empty_args();
args.readonly = true;
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_WRITABLE", "1")]));
assert!(
!args.writable,
"CLI --readonly should suppress lower-layer ROBA_WRITABLE"
);
}
#[test]
fn env_full_auto_suppressed_by_cli_readonly() {
let mut args = empty_args();
args.readonly = true;
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_FULL_AUTO", "1")]));
assert!(
!args.full_auto,
"CLI --readonly should suppress lower-layer ROBA_FULL_AUTO"
);
}
#[test]
fn env_full_auto_suppressed_by_cli_writable() {
let mut args = empty_args();
args.writable = true;
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_FULL_AUTO", "1")]));
assert!(
!args.full_auto,
"CLI --writable should suppress lower-layer ROBA_FULL_AUTO"
);
}
#[test]
fn env_no_retry_truthy_values_enable() {
for val in ["1", "true", "yes", "on", "TRUE", "Yes"] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_NO_RETRY", val)]));
assert!(args.no_retry, "env value {val:?} should enable no_retry");
}
}
#[test]
fn env_no_retry_ignores_falsy_or_garbage() {
for val in ["0", "false", "no", "off", "", "garbage"] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_NO_RETRY", val)]));
assert!(
!args.no_retry,
"env value {val:?} should leave no_retry off"
);
}
}
#[test]
fn continue_truthy_means_most_recent() {
for val in ["1", "true", "yes", "on", "TRUE", "Yes"] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_CONTINUE", val)]));
assert_eq!(
args.continue_session,
Some(None),
"env value {val:?} should continue the most recent session"
);
}
}
#[test]
fn continue_falsy_or_empty_stays_fresh() {
for val in ["0", "false", "no", "off", ""] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_CONTINUE", val)]));
assert_eq!(
args.continue_session, None,
"env value {val:?} should leave the session fresh"
);
}
}
#[test]
fn continue_arbitrary_value_is_specific_id() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_CONTINUE", "abc12345")]));
assert_eq!(args.continue_session, Some(Some("abc12345".to_string())));
}
#[test]
fn continue_does_not_override_cli() {
let mut args = empty_args();
args.continue_session = Some(Some("cli-id".to_string()));
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_CONTINUE", "1")]));
assert_eq!(args.continue_session, Some(Some("cli-id".to_string())));
}
#[test]
fn git_log_parses_number() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_GIT_LOG", "7")]));
assert_eq!(args.git_log, Some(7));
}
#[test]
fn git_log_ignores_invalid() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_GIT_LOG", "lots")]));
assert_eq!(args.git_log, None);
}
#[test]
fn allow_tool_comma_separated() {
let mut args = empty_args();
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_ALLOW_TOOL", "Edit, Write , Bash(git status)")]),
);
assert_eq!(
args.allow_tool,
vec![
"Edit".to_string(),
"Write".to_string(),
"Bash(git status)".to_string(),
]
);
}
#[test]
fn list_does_not_override_cli_list() {
let mut args = empty_args();
args.allow_tool = vec!["FromCli".into()];
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_ALLOW_TOOL", "FromEnv")]));
assert_eq!(args.allow_tool, vec!["FromCli".to_string()]);
}
#[test]
fn prepend_parses_paths() {
let mut args = empty_args();
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_PREPEND", "/etc/preamble.md,~/style.md")]),
);
assert_eq!(
args.prepend,
vec![
PathBuf::from("/etc/preamble.md"),
PathBuf::from("~/style.md"),
]
);
}
#[test]
fn var_keys_picked_up_from_prefix() {
let mut args = empty_args();
apply_env_overrides_from(
&mut args,
&env_with(&[
("ROBA_VAR_TICKET", "ABC-123"),
("ROBA_VAR_NAME", "josh"),
("ROBA_PROFILE", "foo"), ]),
);
let map: HashMap<_, _> = args.var.iter().cloned().collect();
assert_eq!(map.get("TICKET").map(String::as_str), Some("ABC-123"));
assert_eq!(map.get("NAME").map(String::as_str), Some("josh"));
assert!(!map.contains_key("PROFILE"));
}
#[test]
fn var_does_not_override_cli_key() {
let mut args = empty_args();
args.var
.push(("TICKET".to_string(), "from-cli".to_string()));
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_VAR_TICKET", "from-env")]));
let map: HashMap<_, _> = args.var.iter().cloned().collect();
assert_eq!(map.get("TICKET").map(String::as_str), Some("from-cli"));
}
#[test]
fn env_trace_sets_from_roba_trace_var() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_TRACE", "/tmp/run.jsonl")]));
assert_eq!(
args.trace.as_deref(),
Some(std::path::Path::new("/tmp/run.jsonl"))
);
}
#[test]
fn env_trace_does_not_override_cli() {
let mut args = empty_args();
args.trace = Some(PathBuf::from("/cli/path.jsonl"));
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_TRACE", "/tmp/run.jsonl")]));
assert_eq!(
args.trace.as_deref(),
Some(std::path::Path::new("/cli/path.jsonl"))
);
}
#[test]
fn env_trace_ignores_empty() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_TRACE", "")]));
assert!(args.trace.is_none());
}
#[test]
fn env_rates_file_sets_and_respects_cli() {
let mut args = empty_args();
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_RATES_FILE", "/tmp/rates.toml")]),
);
assert_eq!(
args.rates_file.as_deref(),
Some(std::path::Path::new("/tmp/rates.toml"))
);
let mut args = empty_args();
args.rates_file = Some(PathBuf::from("/cli/rates.toml"));
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_RATES_FILE", "/tmp/rates.toml")]),
);
assert_eq!(
args.rates_file.as_deref(),
Some(std::path::Path::new("/cli/rates.toml"))
);
}
#[test]
fn env_no_dollars_truthy_enables() {
for val in ["1", "true", "yes", "on"] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_NO_DOLLARS", val)]));
assert!(
args.no_dollars,
"env value {val:?} should enable no_dollars"
);
}
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_NO_DOLLARS", "0")]));
assert!(!args.no_dollars);
}
#[test]
fn output_flags_each_settable() {
let mut args = empty_args();
apply_env_overrides_from(
&mut args,
&env_with(&[
("ROBA_STREAM", "1"),
("ROBA_ECHO", "1"),
("ROBA_PLAIN", "1"),
("ROBA_QUIET", "1"),
("ROBA_JSON", "1"),
]),
);
assert!(args.stream);
assert!(args.echo);
assert!(args.plain);
assert!(args.quiet);
assert!(args.json);
}
#[test]
fn env_effort_sets_from_roba_effort_var() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_EFFORT", "high")]));
assert_eq!(args.effort, Some(EffortLevel::High));
}
#[test]
fn env_effort_does_not_override_cli() {
let mut args = empty_args();
args.effort = Some(EffortLevel::Max);
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_EFFORT", "low")]));
assert_eq!(args.effort, Some(EffortLevel::Max));
}
#[test]
fn env_effort_ignores_invalid_value() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_EFFORT", "ultra")]));
assert!(args.effort.is_none());
}
#[test]
fn env_effort_parses_all_variants() {
for (s, expected) in [
("low", EffortLevel::Low),
("medium", EffortLevel::Medium),
("high", EffortLevel::High),
("xhigh", EffortLevel::Xhigh),
("max", EffortLevel::Max),
] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_EFFORT", s)]));
assert_eq!(args.effort, Some(expected), "variant {s:?}");
}
}
#[test]
fn env_permission_mode_sets_from_roba_permission_mode_var() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_PERMISSION_MODE", "plan")]));
assert!(args.permission_mode.is_some());
}
#[test]
fn env_permission_mode_does_not_override_cli() {
use crate::cli::PermMode;
let mut args = empty_args();
args.permission_mode = Some(PermMode::Auto);
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_PERMISSION_MODE", "plan")]));
assert_eq!(args.permission_mode, Some(PermMode::Auto));
}
#[test]
fn env_permission_mode_ignores_invalid_value() {
let mut args = empty_args();
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_PERMISSION_MODE", "notamode")]),
);
assert!(args.permission_mode.is_none());
}
#[test]
fn env_permission_mode_parses_known_variants() {
use crate::cli::PermMode;
for (s, expected) in [
("plan", PermMode::Plan),
("auto", PermMode::Auto),
("dontAsk", PermMode::DontAsk),
("acceptEdits", PermMode::AcceptEdits),
("default", PermMode::Default),
] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_PERMISSION_MODE", s)]));
assert_eq!(args.permission_mode, Some(expected), "variant {s:?}");
}
}
#[test]
fn env_bare_sets_from_roba_bare_var() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_BARE", "1")]));
assert!(args.bare);
}
#[test]
fn env_bare_does_not_override_cli() {
let mut args = empty_args();
args.bare = true;
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_BARE", "0")]));
assert!(args.bare);
}
#[test]
fn env_bare_ignores_false_value() {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_BARE", "false")]));
assert!(!args.bare);
}
#[test]
fn env_dispatch_sets_from_roba_dispatch_var() {
for val in ["1", "true", "yes", "on", "TRUE", "Yes"] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_DISPATCH", val)]));
assert!(args.dispatch, "env value {val:?} should enable dispatch");
}
}
#[test]
fn env_dispatch_does_not_override_cli() {
let mut args = empty_args();
args.dispatch = true;
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_DISPATCH", "0")]));
assert!(args.dispatch);
}
#[test]
fn env_dispatch_ignores_false_value() {
for val in ["0", "false", "no", "off", "", "garbage"] {
let mut args = empty_args();
apply_env_overrides_from(&mut args, &env_with(&[("ROBA_DISPATCH", val)]));
assert!(
!args.dispatch,
"env value {val:?} should leave dispatch off"
);
}
}
#[test]
fn env_system_prompt_sets_from_roba_system_prompt_var() {
let mut args = empty_args();
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_SYSTEM_PROMPT", "You are helpful.")]),
);
assert_eq!(args.system_prompt.as_deref(), Some("You are helpful."));
}
#[test]
fn env_system_prompt_does_not_override_cli() {
let mut args = empty_args();
args.system_prompt = Some("cli-prompt".to_string());
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_SYSTEM_PROMPT", "env-prompt")]),
);
assert_eq!(args.system_prompt.as_deref(), Some("cli-prompt"));
}
#[test]
fn env_append_system_prompt_sets_from_roba_var() {
let mut args = empty_args();
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_APPEND_SYSTEM_PROMPT", "Be concise.")]),
);
assert_eq!(args.append_system_prompt.as_deref(), Some("Be concise."));
}
#[test]
fn env_append_system_prompt_does_not_override_cli() {
let mut args = empty_args();
args.append_system_prompt = Some("cli-append".to_string());
apply_env_overrides_from(
&mut args,
&env_with(&[("ROBA_APPEND_SYSTEM_PROMPT", "env-append")]),
);
assert_eq!(args.append_system_prompt.as_deref(), Some("cli-append"));
}
}