use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::path::Path;
use super::pool::{discover_project_configs, load_pool, user_config_path};
use super::resolve::missing_profile_error;
use super::types::Profile;
pub const STARTER_CONFIG_TOML: &str = include_str!("../../roba-config.sample.toml");
pub fn run(action: crate::cli::ProfileAction) -> Result<()> {
use crate::cli::ProfileAction;
match action {
ProfileAction::List => run_list(),
ProfileAction::Show { name } => run_show(&name),
ProfileAction::Init { force } => run_init(force),
ProfileAction::Path => run_path(),
ProfileAction::Active => run_active(),
ProfileAction::Draft(_) => unreachable!("profile draft is dispatched via run_draft"),
}
}
fn run_list() -> Result<()> {
let pool = load_pool()?;
if pool.profiles.is_empty() {
eprintln!("no profiles defined");
if pool.sources.is_empty() {
eprintln!("hint: `roba profile init` to drop a starter file");
} else {
eprintln!("sources checked:");
for s in &pool.sources {
eprintln!(" {}", s.display());
}
}
return Ok(());
}
let mut names: Vec<&String> = pool.profiles.keys().collect();
names.sort();
for name in names {
println!("{name}");
}
Ok(())
}
fn run_show(name: &str) -> Result<()> {
let pool = load_pool()?;
let profile = pool
.get(name)
.cloned()
.ok_or_else(|| missing_profile_error(name, &pool))?;
let rendered = render_named_profile(name, &profile)?;
print!("{rendered}");
Ok(())
}
fn run_init(force: bool) -> Result<()> {
let path = user_config_path()
.ok_or_else(|| anyhow::anyhow!("could not determine config directory"))?;
if path.exists() && !force {
bail!(
"{} already exists -- pass --force to overwrite",
path.display()
);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&path, STARTER_CONFIG_TOML)
.with_context(|| format!("writing {}", path.display()))?;
println!("wrote {}", path.display());
Ok(())
}
fn run_active() -> Result<()> {
let pool = load_pool()?;
let env_name = std::env::var("ROBA_PROFILE").ok().filter(|s| !s.is_empty());
let (name, reason) = if let Some(name) = env_name {
if pool.get(&name).is_none() {
bail!("ROBA_PROFILE={name} but no such profile in the pool");
}
(name, "from ROBA_PROFILE env")
} else if pool.get("default").is_some() {
("default".to_string(), "auto-applied")
} else {
eprintln!("no profile would auto-apply");
if pool.profiles.is_empty() {
eprintln!("hint: `roba profile init` to drop a starter file");
} else {
let mut names: Vec<&String> = pool.profiles.keys().collect();
names.sort();
eprintln!(
"available: {}",
names
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
return Ok(());
};
let profile = pool.get(&name).cloned().expect("checked above");
println!("active: {name} ({reason})");
println!();
let rendered = render_named_profile(&name, &profile)?;
print!("{rendered}");
Ok(())
}
fn run_path() -> Result<()> {
let pool = load_pool()?;
let user = user_config_path();
println!(
"user: {}",
user.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(none)".to_string())
);
let cwd = std::env::current_dir().unwrap_or_default();
let project = discover_project_configs(&cwd);
if project.is_empty() {
println!("project: (none found above {})", cwd.display());
} else {
for (i, p) in project.iter().enumerate() {
let label = if i == 0 { "project:" } else { " " };
println!("{label} {}", p.display());
}
}
if !pool.sources.is_empty() {
println!();
println!("loaded {} source(s):", pool.sources.len());
for s in &pool.sources {
println!(" {}", s.display());
}
}
Ok(())
}
fn render_named_profile(name: &str, profile: &Profile) -> Result<String> {
let mut wrapper: HashMap<String, HashMap<String, Profile>> = HashMap::new();
let mut inner: HashMap<String, Profile> = HashMap::new();
inner.insert(name.to_string(), profile.clone());
wrapper.insert("profile".to_string(), inner);
toml::to_string_pretty(&wrapper).context("re-serializing profile")
}
pub async fn run_draft(args: crate::cli::ProfileDraftArgs) -> Result<()> {
let prompt = draft_prompt(&args.description);
let raw = crate::draft::generate(prompt, args.model.as_deref(), "roba: profile draft").await?;
let (name, profile) = parse_drafted_profile(&raw)?;
let block = render_named_profile(&name, &profile)?;
match &args.write {
Some(target) => {
let path = match target {
Some(p) => p.clone(),
None => user_config_path().ok_or_else(|| {
anyhow::anyhow!(
"--write: cannot locate your user config; pass an explicit path (`--write PATH`)"
)
})?,
};
if file_defines_profile(&path, &name)? {
bail!(
"{} already defines [profile.{name}]; refusing to append a duplicate (it would break the next config load)",
path.display()
);
}
crate::draft::append_block(&path, &block)?;
eprintln!("wrote [profile.{name}] to {}", path.display());
}
None => {
if load_pool()?.profiles.contains_key(&name) {
eprintln!(
"warning: profile `{name}` already exists in your config pool; this draft would shadow or duplicate it"
);
}
}
}
if name == "default" {
eprintln!("note: a profile named `default` auto-applies when no --profile is given");
}
print!("{block}");
Ok(())
}
fn draft_prompt(description: &str) -> String {
let schema = profile_sample_section();
format!(
"You are generating a single roba profile definition in TOML.\n\n\
A roba profile is a named bundle of flag defaults, activated with \
`--profile NAME`. Here is the profile schema, documented by \
example -- these are the ONLY allowed keys, do not invent \
fields:\n\n\
{schema}\n\n\
The user wants a profile for: {description}\n\n\
Output requirements (follow exactly):\n\
- Produce EXACTLY ONE `[profile.NAME]` TOML block and nothing else \
(a sub-table like `[profile.NAME.vars]` is allowed).\n\
- Pick a short, memorable kebab-case or single-word NAME from the description.\n\
- Use ONLY the keys shown above.\n\
- The block must be valid TOML that parses against that schema.\n\
- Do NOT wrap the output in markdown code fences.\n\
- Do NOT include any prose, comments, or explanation -- only the TOML block."
)
}
fn profile_sample_section() -> String {
const SAMPLE: &str = STARTER_CONFIG_TOML;
match SAMPLE.find("# Aliases") {
Some(e) => SAMPLE[..e].trim_end().to_string(),
None => SAMPLE.trim_end().to_string(),
}
}
fn parse_drafted_profile(raw: &str) -> Result<(String, Profile)> {
#[derive(serde::Deserialize)]
struct Wrapper {
#[serde(default)]
profile: HashMap<String, Profile>,
}
let cleaned = crate::draft::strip_code_fences(raw);
let wrapper: Wrapper = toml::from_str(&cleaned).map_err(|e| {
anyhow::anyhow!("drafted profile did not parse: {e}\n\n--- raw model output ---\n{raw}")
})?;
let mut entries: Vec<(String, Profile)> = wrapper.profile.into_iter().collect();
match entries.len() {
1 => Ok(entries.pop().expect("len checked == 1")),
0 => {
bail!(
"drafted output defined no [profile.NAME] block\n\n--- raw model output ---\n{raw}"
)
}
n => bail!(
"drafted output defined {n} profile blocks (expected exactly one)\n\n--- raw model output ---\n{raw}"
),
}
}
fn file_defines_profile(path: &Path, name: &str) -> Result<bool> {
#[derive(serde::Deserialize)]
struct Probe {
#[serde(default)]
profile: HashMap<String, Profile>,
}
if !path.exists() {
return Ok(false);
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading --write target {}", path.display()))?;
let probe: Probe = toml::from_str(&text)
.with_context(|| format!("--write target {} is not valid TOML", path.display()))?;
Ok(probe.profile.contains_key(name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_sample_section_includes_schema_not_aliases() {
let section = profile_sample_section();
assert!(section.contains("[profile.review]"), "got:\n{section}");
assert!(!section.contains("[alias.review]"), "got:\n{section}");
assert!(!section.contains("Named sessions"), "got:\n{section}");
}
#[test]
fn profile_sample_section_falls_back_to_whole_sample() {
let section = profile_sample_section();
assert!(STARTER_CONFIG_TOML.starts_with(§ion[..section.len().min(40)]));
assert!(section.len() < STARTER_CONFIG_TOML.len());
}
#[test]
fn parse_drafted_profile_accepts_one_block() {
let (name, profile) =
parse_drafted_profile("[profile.worker]\nfull_auto = true\nmax_turns = 80").unwrap();
assert_eq!(name, "worker");
assert_eq!(profile.full_auto, Some(true));
assert_eq!(profile.max_turns, Some(80));
}
#[test]
fn parse_drafted_profile_strips_fences_first() {
let (name, _) =
parse_drafted_profile("```toml\n[profile.quick]\nmodel = \"claude-haiku-4-5\"\n```")
.unwrap();
assert_eq!(name, "quick");
}
#[test]
fn parse_drafted_profile_rejects_zero_entries() {
let err = parse_drafted_profile("# nothing here").unwrap_err();
assert!(
format!("{err:#}").contains("no [profile.NAME] block"),
"{err:#}"
);
}
#[test]
fn parse_drafted_profile_rejects_two_entries() {
let raw = "[profile.a]\nreadonly = true\n[profile.b]\nwritable = true";
let err = parse_drafted_profile(raw).unwrap_err();
assert!(format!("{err:#}").contains("2 profile blocks"), "{err:#}");
}
#[test]
fn parse_drafted_profile_rejects_unknown_field() {
let raw = "[profile.x]\nreadonly = true\nmade_up_key = true";
let err = parse_drafted_profile(raw).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("did not parse"), "{msg}");
assert!(msg.contains("made_up_key"), "{msg}");
assert!(msg.contains("raw model output"), "{msg}");
}
#[test]
fn parse_drafted_profile_accepts_default_name() {
let (name, _) = parse_drafted_profile("[profile.default]\nreadonly = true").unwrap();
assert_eq!(name, "default");
}
#[test]
fn file_defines_profile_detects_duplicate() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("roba.toml");
std::fs::write(
&path,
"[alias.review]\ndescription = \"r\"\n\n[profile.review]\nreadonly = true\n",
)
.unwrap();
assert!(file_defines_profile(&path, "review").unwrap());
assert!(!file_defines_profile(&path, "nope").unwrap());
}
#[test]
fn file_defines_profile_missing_file_is_false() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent.toml");
assert!(!file_defines_profile(&path, "anything").unwrap());
}
#[test]
fn render_named_profile_round_trips() {
let (name, profile) =
parse_drafted_profile("[profile.worker]\nfull_auto = true\nmax_turns = 80").unwrap();
let block = render_named_profile(&name, &profile).unwrap();
assert!(block.contains("[profile.worker]"), "{block}");
let (name2, profile2) = parse_drafted_profile(&block).unwrap();
assert_eq!(name2, "worker");
assert_eq!(profile2.max_turns, Some(80));
}
}