use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use serde::Serialize;
use toml::Value;
use crate::aliases::{self, Alias};
use crate::cli::{ConfigInitArgs, ConfigShowArgs};
use crate::profile::{self, ConfigFile, Pool, Profile};
pub async fn run_init(args: ConfigInitArgs) -> Result<()> {
if let Some(target) = &args.write {
let path = write_target_path(target);
if path.exists() {
bail!("{}", clobber_msg(&path));
}
}
let pool = profile::load_pool()?;
let rendered = render_merged_pool(&pool)?;
let inherited = (!rendered.trim().is_empty()).then_some(rendered);
let prompt = init_prompt(args.description.as_deref(), inherited.as_deref());
let raw = crate::draft::generate_inspecting(prompt, args.model.as_deref(), "roba: config init")
.await?;
let content = validate_config(&raw)?;
if let Some(target) = &args.write {
let path = write_no_clobber(target, &content)?;
eprintln!("wrote {}", path.display());
eprintln!(
"this file joins the config pool (closer-to-cwd files win); `roba profile list` will now show the merged set"
);
}
print!("{content}");
Ok(())
}
pub fn run_show(args: ConfigShowArgs) -> Result<()> {
if args.sources.is_some() {
return run_show_sources(args);
}
let pool = profile::load_pool()?;
let active = profile::cmd::active_profile(&pool)?;
if args.json {
return emit_json(&pool, active.map(|(name, _)| name));
}
print_show_header(&active, &pool);
print!("{}", render_merged_pool(&pool)?);
Ok(())
}
fn print_show_header(active: &Option<(String, &'static str)>, pool: &Pool) {
match active {
Some((name, reason)) => eprintln!("active profile: {name} ({reason})"),
None => eprintln!("no profile auto-applies"),
}
if pool.sources.is_empty() {
eprintln!("sources: (none)");
} else {
eprintln!("sources:");
for s in &pool.sources {
eprintln!(" {}", s.display());
}
}
}
fn run_show_sources(args: ConfigShowArgs) -> Result<()> {
let key_filter = args.sources.clone().flatten();
let pool = profile::load_pool()?;
let active = profile::cmd::active_profile(&pool)?;
let layers = profile::pool::load_layers()?;
let env: HashMap<String, String> = std::env::vars().collect();
let (effective, unattributed) = compute_effective(&layers, &active, &pool, &env)?;
if args.json {
return emit_sources_json(&effective, key_filter.as_deref());
}
print_show_header(&active, &pool);
if !unattributed.is_empty() {
eprintln!(
"note: env vars set but not attributed per-key: {}",
unattributed.join(", ")
);
}
match key_filter {
Some(key) => match effective.get(&key) {
Some((value, source)) => print!("{}", render_key_line(&key, value, source)?),
None => eprintln!("{key} is not set by any config layer"),
},
None => {
for (key, (value, source)) in &effective {
print!("{}", render_key_line(key, value, source)?);
}
}
}
Ok(())
}
type LayerEntry = (String, Value, String);
type Effective = BTreeMap<String, (Value, String)>;
fn compute_effective(
layers: &[(PathBuf, ConfigFile)],
active: &Option<(String, &'static str)>,
pool: &Pool,
env: &HashMap<String, String>,
) -> Result<(Effective, Vec<String>)> {
let mut entries: Vec<LayerEntry> = Vec::new();
for (path, cfg) in layers {
let label = format!("{} (top-level)", path.display());
for (k, v) in profile_table(&cfg.defaults)? {
entries.push((k, v, label.clone()));
}
}
if let Some((name, _)) = active
&& let Some(profile) = pool.get(name)
{
let label = format!("[profile.{name}]");
for (k, v) in profile_table(profile)? {
entries.push((k, v, label.clone()));
}
}
let mut unattributed = Vec::new();
entries.extend(env_entries(env, &mut unattributed));
let mut effective: BTreeMap<String, (Value, String)> = BTreeMap::new();
for (k, v, label) in entries {
effective.insert(k, (v, label));
}
Ok((effective, unattributed))
}
fn profile_table(profile: &Profile) -> Result<toml::Table> {
match Value::try_from(profile).context("serializing a config layer")? {
Value::Table(t) => Ok(t),
_ => Ok(toml::Table::new()),
}
}
fn render_key_line(key: &str, value: &Value, source: &str) -> Result<String> {
let mut t = toml::Table::new();
t.insert(key.to_string(), value.clone());
let body = toml::to_string(&t).context("serializing an effective key")?;
Ok(format!("{} # {source}\n", body.trim_end_matches('\n')))
}
fn emit_sources_json(effective: &Effective, key_filter: Option<&str>) -> Result<()> {
#[derive(Serialize)]
struct KeyProvenance<'a> {
value: &'a Value,
source: &'a str,
}
#[derive(Serialize)]
struct Report<'a> {
effective: BTreeMap<&'a str, KeyProvenance<'a>>,
}
let mut map: BTreeMap<&str, KeyProvenance> = BTreeMap::new();
for (k, (v, s)) in effective {
if key_filter.is_some_and(|f| f != k) {
continue;
}
map.insert(
k.as_str(),
KeyProvenance {
value: v,
source: s,
},
);
}
let report = Report { effective: map };
println!(
"{}",
serde_json::to_string_pretty(&crate::VersionedResult::new(&report))?
);
Ok(())
}
#[derive(Clone, Copy)]
enum EnvKind {
Bool,
Str,
List,
Uint,
Float,
Continue,
Worktree,
Choice(&'static [&'static str]),
}
const ENV_MAP: &[(&str, &str, EnvKind)] = &[
("ROBA_PREPEND", "prepend", EnvKind::List),
("ROBA_APPEND", "append", EnvKind::List),
("ROBA_ATTACH", "attach", EnvKind::List),
("ROBA_GIT_DIFF", "git_diff", EnvKind::Bool),
("ROBA_GIT_LOG", "git_log", EnvKind::Uint),
("ROBA_GIT_STATUS", "git_status", EnvKind::Bool),
("ROBA_READONLY", "readonly", EnvKind::Bool),
("ROBA_WRITABLE", "writable", EnvKind::Bool),
("ROBA_FULL_AUTO", "full_auto", EnvKind::Bool),
("ROBA_CONTINUE", "continue", EnvKind::Continue),
("ROBA_ALLOW_TOOL", "allow_tool", EnvKind::List),
("ROBA_DENY_TOOL", "deny_tool", EnvKind::List),
("ROBA_MODEL", "model", EnvKind::Str),
(
"ROBA_EFFORT",
"effort",
EnvKind::Choice(&["low", "medium", "high", "xhigh", "max"]),
),
("ROBA_AGENT", "agent", EnvKind::Str),
("ROBA_STREAM", "stream", EnvKind::Bool),
("ROBA_SHOW_THINKING", "show_thinking", EnvKind::Bool),
("ROBA_ECHO", "echo", EnvKind::Bool),
("ROBA_PLAIN", "plain", EnvKind::Bool),
("ROBA_QUIET", "quiet", EnvKind::Bool),
("ROBA_JSON", "json", EnvKind::Bool),
("ROBA_EDITOR_HISTORY", "editor_history", EnvKind::Uint),
("ROBA_WORKTREE", "worktree", EnvKind::Worktree),
("ROBA_NO_RETRY", "no_retry", EnvKind::Bool),
("ROBA_BARE", "bare", EnvKind::Bool),
("ROBA_TRACE", "trace", EnvKind::Str),
("ROBA_RATES_FILE", "rates_file", EnvKind::Str),
("ROBA_NO_DOLLARS", "no_dollars", EnvKind::Bool),
("ROBA_NO_AGENT_CHECK", "no_agent_check", EnvKind::Bool),
(
"ROBA_PERMISSION_MODE",
"permission_mode",
EnvKind::Choice(&[
"acceptEdits",
"auto",
"bypassPermissions",
"default",
"dontAsk",
"plan",
]),
),
("ROBA_SYSTEM_PROMPT", "system_prompt", EnvKind::Str),
(
"ROBA_APPEND_SYSTEM_PROMPT",
"append_system_prompt",
EnvKind::Str,
),
("ROBA_MAX_TURNS", "max_turns", EnvKind::Uint),
("ROBA_MAX_BUDGET_USD", "max_budget_usd", EnvKind::Float),
("ROBA_JSON_SCHEMA", "json_schema", EnvKind::Str),
("ROBA_MCP_CONFIG", "mcp_config", EnvKind::List),
("ROBA_STRICT_MCP_CONFIG", "strict_mcp_config", EnvKind::Bool),
("ROBA_ADD_DIR", "add_dir", EnvKind::List),
("ROBA_FALLBACK_MODEL", "fallback_model", EnvKind::Str),
(
"ROBA_NO_SESSION_PERSISTENCE",
"no_session_persistence",
EnvKind::Bool,
),
("ROBA_NO_AGENT_NOTICE", "no_agent_notice", EnvKind::Bool),
("ROBA_AGENT_NOTICE", "agent_notice", EnvKind::Str),
];
fn env_truthy(v: &str) -> bool {
matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")
}
fn env_falsy(v: &str) -> bool {
matches!(
v.to_ascii_lowercase().as_str(),
"0" | "false" | "no" | "off"
)
}
fn env_entries(env: &HashMap<String, String>, unattributed: &mut Vec<String>) -> Vec<LayerEntry> {
let mut out = Vec::new();
for &(var, key, kind) in ENV_MAP {
let raw = match env.get(var) {
Some(s) if !s.is_empty() => s,
_ => continue,
};
let value = match kind {
EnvKind::Bool => env_truthy(raw).then_some(Value::Boolean(true)),
EnvKind::Str => Some(Value::String(raw.clone())),
EnvKind::List => {
let items: Vec<Value> = raw
.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| Value::String(p.to_string()))
.collect();
(!items.is_empty()).then_some(Value::Array(items))
}
EnvKind::Uint => raw.parse::<u64>().ok().map(|n| Value::Integer(n as i64)),
EnvKind::Float => raw.parse::<f64>().ok().map(Value::Float),
EnvKind::Continue | EnvKind::Worktree => {
if env_truthy(raw) {
Some(Value::Boolean(true))
} else if env_falsy(raw) {
None
} else {
Some(Value::String(raw.clone()))
}
}
EnvKind::Choice(allowed) => {
match allowed.iter().find(|a| a.eq_ignore_ascii_case(raw)) {
Some(canon) => Some(Value::String((*canon).to_string())),
None => {
unattributed.push(var.to_string());
None
}
}
}
};
if let Some(v) = value {
out.push((key.to_string(), v, format!("env ({var})")));
}
}
if env.keys().any(|k| k.starts_with("ROBA_VAR_")) {
unattributed.push("ROBA_VAR_* (per-key vars)".to_string());
}
out
}
fn render_merged_pool(pool: &Pool) -> Result<String> {
let mut out = String::new();
if !pool.defaults.is_empty() {
let block = toml::to_string_pretty(&pool.defaults).context("re-serializing defaults")?;
push_block(&mut out, &block);
}
let mut profiles: Vec<&String> = pool.profiles.keys().collect();
profiles.sort();
for name in profiles {
let block = profile::cmd::render_named_profile(name, &pool.profiles[name])?;
push_block(&mut out, &block);
}
let mut aliases: Vec<&String> = pool.aliases.keys().collect();
aliases.sort();
for name in aliases {
let block = aliases::render_alias_toml(name, &pool.aliases[name])?;
push_block(&mut out, &block);
}
if !pool.sessions.is_empty() {
push_block(&mut out, &render_session_table(&pool.sessions)?);
}
Ok(out)
}
fn push_block(out: &mut String, block: &str) {
let block = block.trim_end_matches('\n');
if block.is_empty() {
return;
}
if !out.is_empty() {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
out.push_str(block);
out.push('\n');
}
fn render_session_table(sessions: &std::collections::HashMap<String, String>) -> Result<String> {
let sorted: BTreeMap<&String, &String> = sessions.iter().collect();
let mut wrapper: BTreeMap<&str, BTreeMap<&String, &String>> = BTreeMap::new();
wrapper.insert("session", sorted);
toml::to_string_pretty(&wrapper).context("re-serializing sessions")
}
fn emit_json(pool: &Pool, active_profile: Option<String>) -> Result<()> {
#[derive(Serialize)]
struct Report<'a> {
active_profile: Option<String>,
sources: Vec<String>,
defaults: &'a Profile,
profiles: BTreeMap<&'a String, &'a Profile>,
aliases: BTreeMap<&'a String, &'a Alias>,
sessions: BTreeMap<&'a String, &'a String>,
}
let report = Report {
active_profile,
sources: pool
.sources
.iter()
.map(|p| p.display().to_string())
.collect(),
defaults: &pool.defaults,
profiles: pool.profiles.iter().collect(),
aliases: pool.aliases.iter().collect(),
sessions: pool.sessions.iter().collect(),
};
println!(
"{}",
serde_json::to_string_pretty(&crate::VersionedResult::new(&report))?
);
Ok(())
}
fn init_prompt(description: Option<&str>, inherited: Option<&str>) -> String {
let sample = crate::profile::STARTER_CONFIG_TOML;
let steering = match description {
Some(d) => format!("\n\nThe user added this steer; weave it into the draft:\n{d}"),
None => String::new(),
};
let inherited_section = match inherited {
Some(text) => format!(
"\n\nThis config is ALREADY inherited by this project (the user \
config plus any `roba.toml` in a parent directory); roba merges \
it into every run here automatically:\n\n\
{text}\n\n\
Emit only the project-specific DELTA: do NOT re-state keys that \
are already inherited, and do NOT override an inherited key \
unless there is a clear project-specific reason to.",
),
None => String::new(),
};
format!(
"You are drafting a STARTER project `roba.toml` for the project in the \
current directory.\n\n\
roba is a CLI that sugars `claude -p`. A project `roba.toml` sets \
top-level defaults plus named `[profile.NAME]` overlays and \
`[alias.NAME]` shortcut verbs. Here is the complete, annotated config \
schema -- every valid key appears and is commented. Use ONLY keys that \
appear here; do not invent fields:\n\n\
{sample}\n\n\
The three config layers have distinct purposes -- place things in the \
right one:\n\
- top-level keys are AMBIENT, always-on knobs for the KIND of work in \
this scope; they merge per-key across the pool and auto-apply silently \
to every run, so put only SAFE things here.\n\
- `[profile.NAME]` is a named, opt-in-able MODE selected as a unit \
(`--profile NAME`); it is the home for loaded guns, named and deliberate.\n\
- `[alias.NAME]` is a profile PLUS a prompt template, invoked as a verb; \
only worth it when there is a template, args, or agent.\n\n\
First, briefly inspect THIS project with the Read/Glob/Grep tools (the \
README, the package manifest, the top-level layout) so the draft fits \
what you see. Skim, do not spelunk.\
{inherited_section}\n\n\
Then produce a starter project roba.toml that:\n\
- Opens with conservative, SAFE top-level defaults appropriate to this project.\n\
- NEVER puts `full_auto` or an anonymous `worktree` (`worktree = true`) at \
the TOP LEVEL -- those are loaded guns that would auto-apply silently to \
every run (and a top-level anonymous worktree silently defeats `-c`/`--resume`). \
If the steer asks for full-auto or worktree isolation, put them in a NAMED \
`[profile.NAME]` (e.g. `[profile.worker]`) so they are an explicit, named \
opt-in. Ship a SAFE default; NAME the loaded guns.\n\
- Defines a couple of useful `[profile.NAME]` overlays fitted to the project.\n\
- Defines AT MOST one or two `[alias.NAME]` shortcut verbs, and only if clearly useful.\n\
- Generates REUSABLE verbs, not a one-shot plan: an `[alias.NAME]` is for \
something run repeatedly (e.g. `roba review ${{pr}}`). Do NOT emit a numbered \
family of one-time build steps (`phase0`, `phase1`, ...) -- that is a PLAN, \
and a plan's home is GitHub issues driven by an orchestrator, not a config \
file. If the project has a phased roadmap, note it in a `#` comment rather \
than encoding the plan as aliases.\n\
- Keeps every profile/permission posture READ-ONLY unless the steer below asks otherwise.\n\
- Has a brief `#` comment on every key explaining what it does.\n\
- Does NOT include a `[session]` table (session UUIDs are machine-local).\n\
- Keeps `template`, `args`, and `flags` to `[alias.NAME]` tables ONLY -- \
they are alias keys and a `[profile.NAME]` table will REJECT them (a profile \
that wants a prompt template, args, or extra flags is actually an alias).\n\
- Uses ONLY keys from the schema above; the whole file must parse as valid TOML.\
{steering}\n\n\
Output requirements (follow exactly):\n\
- Output the roba.toml file content and NOTHING else.\n\
- Do NOT wrap the output in markdown code fences.\n\
- Do NOT include any prose or explanation outside TOML `#` comments."
)
}
fn validate_config(raw: &str) -> Result<String> {
let cleaned = extract_config_toml(raw);
crate::profile::pool::parse_config_str(&cleaned).map_err(|e| {
anyhow::anyhow!("drafted config did not parse: {e:#}\n\n--- raw model output ---\n{raw}")
})?;
Ok(cleaned)
}
fn extract_config_toml(raw: &str) -> String {
let trimmed = raw.trim();
let body = fenced_block(trimmed).unwrap_or_else(|| trimmed.to_string());
strip_leading_preamble(&body)
}
fn fenced_block(s: &str) -> Option<String> {
let open = s.find("```")?;
let after_open = &s[open + 3..];
let nl = after_open.find('\n')?;
let body_start = open + 3 + nl + 1;
let close_rel = s[body_start..].find("```")?;
Some(s[body_start..body_start + close_rel].trim_end().to_string())
}
fn strip_leading_preamble(s: &str) -> String {
let lines: Vec<&str> = s.lines().collect();
match lines.iter().position(|l| looks_like_toml_line(l)) {
Some(i) => lines[i..].join("\n"),
None => s.to_string(),
}
}
fn looks_like_toml_line(line: &str) -> bool {
let t = line.trim();
if t.is_empty() {
return false;
}
if t.starts_with('#') || t.starts_with('[') {
return true;
}
match t.split_once('=') {
Some((key, _)) => {
let key = key.trim();
!key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '"'))
}
None => false,
}
}
fn write_target_path(target: &Option<PathBuf>) -> PathBuf {
target.clone().unwrap_or_else(|| PathBuf::from("roba.toml"))
}
fn clobber_msg(path: &std::path::Path) -> String {
format!(
"{} already exists; refusing to overwrite a whole-file config. Print to stdout and merge into it by hand instead.",
path.display()
)
}
fn write_no_clobber(target: &Option<PathBuf>, content: &str) -> Result<PathBuf> {
let path = write_target_path(target);
if path.exists() {
bail!("{}", clobber_msg(&path));
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent directory for {}", path.display()))?;
}
std::fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_config_accepts_the_bundled_sample() {
let content = validate_config(crate::profile::STARTER_CONFIG_TOML).unwrap();
assert!(content.contains("[profile.review]"), "{content}");
}
#[test]
fn validate_config_strips_fences_first() {
let raw = "```toml\nreadonly = true\n\n[profile.x]\ngit_diff = true\n```";
let content = validate_config(raw).unwrap();
assert!(content.starts_with("readonly = true"), "{content}");
assert!(!content.contains("```"), "{content}");
}
#[test]
fn validate_config_drops_leading_prose_preamble() {
let raw = "Perfect. Here's the starter `roba.toml`:\n\n\
# top-level defaults\n\
readonly = true\n\n\
[profile.review]\n\
git_diff = true\n";
let content = validate_config(raw).unwrap();
assert!(content.starts_with("# top-level defaults"), "{content}");
assert!(!content.contains("Perfect."), "{content}");
assert!(content.contains("[profile.review]"), "{content}");
}
#[test]
fn validate_config_handles_preamble_then_fence() {
let raw = "Here is the file:\n```toml\nreadonly = true\n```";
let content = validate_config(raw).unwrap();
assert_eq!(content, "readonly = true");
}
#[test]
fn validate_config_rejects_unknown_top_level_key() {
let raw = "made_up_top_level = true\n";
let err = validate_config(raw).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("did not parse"), "{msg}");
assert!(msg.contains("made_up_top_level"), "{msg}");
assert!(msg.contains("raw model output"), "{msg}");
}
#[test]
fn validate_config_rejects_unknown_profile_key() {
let raw = "[profile.x]\nbogus_profile_key = true\n";
let err = validate_config(raw).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("did not parse"), "{msg}");
assert!(msg.contains("bogus_profile_key"), "{msg}");
}
#[test]
fn write_no_clobber_refuses_existing_target() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("roba.toml");
std::fs::write(&path, "readonly = true\n").unwrap();
let err = write_no_clobber(&Some(path.clone()), "writable = true\n").unwrap_err();
assert!(format!("{err:#}").contains("already exists"), "{err:#}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "readonly = true\n");
}
#[test]
fn write_no_clobber_writes_a_fresh_target() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("roba.toml");
let written = write_no_clobber(&Some(path.clone()), "readonly = true\n").unwrap();
assert_eq!(written, path);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "readonly = true\n");
}
#[test]
fn init_prompt_includes_steering_and_schema() {
let p = init_prompt(Some("focus on PR review and a docs-build verb"), None);
assert!(
p.contains("focus on PR review and a docs-build verb"),
"{p}"
);
assert!(p.contains("[profile.review]"), "schema missing from prompt");
assert!(p.contains("[session]"), "{p}");
}
#[test]
fn init_prompt_without_steering_is_clean() {
let p = init_prompt(None, None);
assert!(p.contains("STARTER project"), "{p}");
assert!(!p.contains("The user added this steer"), "{p}");
}
#[test]
fn init_prompt_steers_off_top_level_loaded_guns_and_plans() {
let p = init_prompt(None, None);
assert!(p.contains("the TOP LEVEL"), "{p}");
assert!(p.contains("full_auto"), "{p}");
assert!(p.contains("worktree = true"), "{p}");
assert!(p.contains("NAME the loaded guns"), "{p}");
assert!(p.contains("REUSABLE verbs"), "{p}");
assert!(p.contains("phase0"), "{p}");
assert!(p.contains("plan's home is GitHub issues"), "{p}");
assert!(p.contains("AMBIENT, always-on knobs"), "{p}");
assert!(p.contains("named, opt-in-able MODE"), "{p}");
assert!(p.contains("profile PLUS a prompt template"), "{p}");
assert!(
p.contains("they are alias keys and a `[profile.NAME]` table will REJECT them"),
"{p}"
);
}
#[test]
fn init_prompt_injects_inherited_pool_and_delta_instruction() {
let p = init_prompt(Some("PR review"), Some("readonly = true\n"));
assert!(p.contains("readonly = true"), "{p}");
assert!(p.contains("ALREADY inherited by this project"), "{p}");
assert!(p.contains("project-specific DELTA"), "{p}");
assert!(
p.contains("do NOT re-state keys that are already inherited"),
"{p}"
);
}
#[test]
fn init_prompt_omits_inherited_section_when_none() {
let p = init_prompt(None, None);
assert!(!p.contains("ALREADY inherited by this project"), "{p}");
assert!(!p.contains("project-specific DELTA"), "{p}");
}
#[test]
fn render_merged_pool_round_trips() {
let mut pool = Pool::default();
pool.defaults.readonly = Some(true);
pool.defaults
.vars
.insert("TEAM".to_string(), "core".to_string());
pool.profiles.insert(
"worker".to_string(),
Profile {
full_auto: Some(true),
max_turns: Some(80),
..Default::default()
},
);
pool.aliases.insert(
"review".to_string(),
Alias {
description: Some("review a PR".to_string()),
..Default::default()
},
);
pool.sessions.insert(
"meta".to_string(),
"11111111-1111-4111-8111-111111111111".to_string(),
);
let rendered = render_merged_pool(&pool).unwrap();
let cfg = crate::profile::pool::parse_config_str(&rendered)
.unwrap_or_else(|e| panic!("merged pool did not re-parse: {e:#}\n---\n{rendered}"));
assert_eq!(cfg.defaults.readonly, Some(true));
assert_eq!(
cfg.defaults.vars.get("TEAM").map(String::as_str),
Some("core")
);
assert_eq!(cfg.profile["worker"].full_auto, Some(true));
assert_eq!(cfg.profile["worker"].max_turns, Some(80));
assert_eq!(
cfg.alias["review"].description.as_deref(),
Some("review a PR")
);
assert_eq!(
cfg.session.get("meta").map(String::as_str),
Some("11111111-1111-4111-8111-111111111111")
);
}
#[test]
fn render_merged_pool_empty_is_blank() {
let pool = Pool::default();
assert_eq!(render_merged_pool(&pool).unwrap(), "");
}
fn file_layer(path: &str, defaults: Profile) -> (PathBuf, ConfigFile) {
(
PathBuf::from(path),
ConfigFile {
defaults,
..Default::default()
},
)
}
#[test]
fn compute_effective_highest_layer_wins() {
let layers = vec![
file_layer(
"/repo/roba.toml",
Profile {
readonly: Some(true),
model: Some("sonnet".into()),
..Default::default()
},
),
file_layer(
"/repo/sub/roba.toml",
Profile {
model: Some("opus".into()),
..Default::default()
},
),
];
let mut pool = Pool::default();
pool.profiles.insert(
"worker".to_string(),
Profile {
max_turns: Some(80),
..Default::default()
},
);
let active = Some(("worker".to_string(), "auto-applied"));
let env = HashMap::from([("ROBA_WORKTREE".to_string(), "1".to_string())]);
let (eff, unattributed) = compute_effective(&layers, &active, &pool, &env).unwrap();
let (v, src) = &eff["readonly"];
assert_eq!(v, &Value::Boolean(true));
assert!(src.contains("/repo/roba.toml"), "{src}");
let (v, src) = &eff["model"];
assert_eq!(v, &Value::String("opus".into()));
assert!(src.contains("/repo/sub/roba.toml"), "{src}");
let (v, src) = &eff["max_turns"];
assert_eq!(v, &Value::Integer(80));
assert_eq!(src, "[profile.worker]");
let (v, src) = &eff["worktree"];
assert_eq!(v, &Value::Boolean(true));
assert_eq!(src, "env (ROBA_WORKTREE)");
assert!(unattributed.is_empty());
}
#[test]
fn compute_effective_profile_overrides_top_level() {
let layers = vec![file_layer(
"/repo/roba.toml",
Profile {
model: Some("sonnet".into()),
..Default::default()
},
)];
let mut pool = Pool::default();
pool.profiles.insert(
"p".to_string(),
Profile {
model: Some("opus".into()),
..Default::default()
},
);
let active = Some(("p".to_string(), "auto-applied"));
let (eff, _) = compute_effective(&layers, &active, &pool, &HashMap::new()).unwrap();
let (v, src) = &eff["model"];
assert_eq!(v, &Value::String("opus".into()));
assert_eq!(src, "[profile.p]");
}
#[test]
fn compute_effective_only_shows_set_keys() {
let layers = vec![file_layer(
"/repo/roba.toml",
Profile {
readonly: Some(true),
..Default::default()
},
)];
let (eff, _) =
compute_effective(&layers, &None, &Pool::default(), &HashMap::new()).unwrap();
assert!(eff.contains_key("readonly"));
assert!(!eff.contains_key("writable"));
}
#[test]
fn env_entries_value_semantics() {
let mut un = Vec::new();
let env = HashMap::from([
("ROBA_READONLY".to_string(), "yes".to_string()),
("ROBA_GIT_DIFF".to_string(), "garbage".to_string()), ("ROBA_ALLOW_TOOL".to_string(), "Edit, Write".to_string()),
("ROBA_MAX_TURNS".to_string(), "40".to_string()),
("ROBA_MODEL".to_string(), "opus".to_string()),
]);
let entries = env_entries(&env, &mut un);
let by_key: HashMap<&str, &Value> =
entries.iter().map(|(k, v, _)| (k.as_str(), v)).collect();
assert_eq!(by_key["readonly"], &Value::Boolean(true));
assert!(!by_key.contains_key("git_diff")); assert_eq!(
by_key["allow_tool"],
&Value::Array(vec![
Value::String("Edit".into()),
Value::String("Write".into())
])
);
assert_eq!(by_key["max_turns"], &Value::Integer(40));
assert_eq!(by_key["model"], &Value::String("opus".into()));
assert!(un.is_empty());
}
#[test]
fn env_entries_unattributed_choice_and_vars() {
let mut un = Vec::new();
let env = HashMap::from([
("ROBA_EFFORT".to_string(), "ludicrous".to_string()), ("ROBA_VAR_TICKET".to_string(), "ABC-123".to_string()),
]);
let entries = env_entries(&env, &mut un);
assert!(entries.iter().all(|(k, _, _)| k != "effort"));
assert!(un.iter().any(|s| s.contains("ROBA_EFFORT")), "{un:?}");
assert!(un.iter().any(|s| s.contains("ROBA_VAR_")), "{un:?}");
}
#[test]
fn render_key_line_formats_scalars_and_arrays() {
assert_eq!(
render_key_line("model", &Value::String("opus".into()), "[profile.worker]").unwrap(),
"model = \"opus\" # [profile.worker]\n"
);
assert_eq!(
render_key_line("full_auto", &Value::Boolean(true), "env (ROBA_FULL_AUTO)").unwrap(),
"full_auto = true # env (ROBA_FULL_AUTO)\n"
);
assert_eq!(
render_key_line(
"allow_tool",
&Value::Array(vec![Value::String("Edit".into())]),
"/repo/roba.toml (top-level)"
)
.unwrap(),
"allow_tool = [\"Edit\"] # /repo/roba.toml (top-level)\n"
);
}
}