use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::context::ContextBudget;
use crate::error::{Error, Result};
use crate::mcp::McpServer;
use crate::policy::{Defaults, Effect, Layer, Policy};
use crate::pricing::{Price, PriceTable};
use crate::resilience::{RetryPolicy, StallPolicy};
use crate::sandbox::SandboxConfig;
use crate::toolchain::Toolchain;
use crate::tools::git::Identity;
use crate::TaskContract;
pub const PROJECT_FILE: &str = "io.toml";
pub const LOCAL_FILE: &str = "io.local.toml";
pub const CONFIG_HOME_VAR: &str = "IO_CONFIG_HOME";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
User,
Project,
Local,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct File {
#[serde(default)]
policy: Option<PolicySection>,
#[serde(default)]
sandbox: Option<SandboxSection>,
#[serde(default)]
run: Option<RunSection>,
#[serde(default)]
toolchain: BTreeMap<String, ToolchainSection>,
#[serde(default)]
prices: Option<PricesSection>,
#[serde(default)]
mcp: Vec<McpServer>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PolicySection {
#[serde(default)]
defaults: Option<DefaultsSection>,
#[serde(default)]
layers: Vec<Layer>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct DefaultsSection {
read: Option<Effect>,
write: Option<Effect>,
exec: Option<Effect>,
net: Option<Effect>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct SandboxSection {
#[serde(default)]
limits: Option<LimitsSection>,
allow_network: Option<bool>,
force_floor: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct LimitsSection {
max_cpu_secs: Option<u64>,
max_wall_secs: Option<u64>,
max_memory_bytes: Option<u64>,
max_processes: Option<u64>,
max_open_files: Option<u64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct RunSection {
max_steps: Option<u32>,
max_duration_secs: Option<u64>,
max_tokens: Option<u64>,
max_retries: Option<u32>,
exec_timeout_secs: Option<u64>,
skills: Option<PathBuf>,
retry: Option<RetryPolicy>,
stall: Option<StallPolicy>,
context: Option<ContextBudget>,
commit_identity: Option<Identity>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ToolchainSection {
manager: Option<String>,
install: Option<Vec<String>>,
build: Option<Vec<String>>,
test: Option<Vec<String>>,
lint: Option<Vec<String>>,
format: Option<Vec<String>>,
run: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PricesSection {
as_of: String,
#[serde(default)]
models: BTreeMap<String, Price>,
}
#[derive(Debug, Clone, Default)]
pub struct Config {
file: File,
sources: Vec<(Scope, PathBuf)>,
}
impl Config {
pub fn discover(root: impl AsRef<Path>) -> Result<Self> {
let root = root.as_ref();
let mut candidates: Vec<(Scope, PathBuf)> = Vec::new();
if let Some(user) = user_path() {
candidates.push((Scope::User, user));
}
candidates.push((Scope::Project, root.join(PROJECT_FILE)));
candidates.push((Scope::Local, root.join(LOCAL_FILE)));
let mut merged = toml::value::Table::new();
let mut sources = Vec::new();
for (scope, path) in candidates {
if !path.is_file() {
continue;
}
let table = read_scope(&path)?;
merge(&mut merged, table, &mut Vec::new());
sources.push((scope, path));
}
Ok(Self {
file: deserialize(toml::Value::Table(merged), Path::new("<merged>"))?,
sources,
})
}
pub fn from_toml(text: &str) -> Result<Self> {
let path = Path::new(PROJECT_FILE);
let table = parse(text, path)?;
Ok(Self {
file: deserialize(toml::Value::Table(table), path)?,
sources: Vec::new(),
})
}
pub fn sources(&self) -> &[(Scope, PathBuf)] {
&self.sources
}
pub fn is_empty(&self) -> bool {
self.file.policy.is_none()
&& self.file.sandbox.is_none()
&& self.file.run.is_none()
&& self.file.toolchain.is_empty()
&& self.file.prices.is_none()
&& self.file.mcp.is_empty()
}
pub fn policy(&self) -> Option<Policy> {
let section = self.file.policy.as_ref()?;
let base = Policy::default();
let defaults = match §ion.defaults {
None => base.defaults,
Some(d) => Defaults {
read: d.read.unwrap_or(base.defaults.read),
write: d.write.unwrap_or(base.defaults.write),
exec: d.exec.unwrap_or(base.defaults.exec),
net: d.net.unwrap_or(base.defaults.net),
},
};
let mut layers = base.layers;
layers.extend(section.layers.iter().cloned());
Some(Policy { layers, defaults })
}
pub fn sandbox(&self) -> Option<SandboxConfig> {
let section = self.file.sandbox.as_ref()?;
let mut config = SandboxConfig::new();
if let Some(limits) = §ion.limits {
let base = &mut config.limits;
for (from, to) in [
(limits.max_cpu_secs, &mut base.max_cpu_secs),
(limits.max_wall_secs, &mut base.max_wall_secs),
(limits.max_memory_bytes, &mut base.max_memory_bytes),
(limits.max_processes, &mut base.max_processes),
(limits.max_open_files, &mut base.max_open_files),
] {
if let Some(v) = from {
*to = if v == 0 { None } else { Some(v) };
}
}
}
if let Some(v) = section.allow_network {
config.allow_network = v;
}
if let Some(v) = section.force_floor {
config.force_floor = v;
}
Some(config)
}
pub fn prices(&self) -> Option<PriceTable> {
let section = self.file.prices.as_ref()?;
let mut table = PriceTable::new(section.as_of.clone());
for (model, price) in §ion.models {
table = table.with(model.clone(), *price);
}
Some(table)
}
pub fn toolchain(&self, detected: Toolchain) -> Toolchain {
let Some(section) = self.file.toolchain.get(&detected.ecosystem) else {
return detected;
};
let mut out = detected;
if let Some(v) = §ion.manager {
out.manager = v.clone();
}
for (from, to) in [
(§ion.install, &mut out.install),
(§ion.build, &mut out.build),
(§ion.test, &mut out.test),
(§ion.lint, &mut out.lint),
(§ion.format, &mut out.format),
(§ion.run, &mut out.run),
] {
if let Some(v) = from {
*to = v.clone();
}
}
out
}
pub fn mcp_servers(&self) -> &[McpServer] {
&self.file.mcp
}
#[must_use]
pub fn apply_to(&self, contract: TaskContract) -> TaskContract {
let mut out = contract;
if !self.file.mcp.is_empty() {
out = out.with_mcp(self.file.mcp.iter().cloned());
}
let Some(run) = &self.file.run else {
return out;
};
if let Some(v) = run.max_steps {
out = out.with_max_steps(v);
}
if let Some(v) = run.max_duration_secs {
out = out.with_time_budget(Duration::from_secs(v));
}
if let Some(v) = run.max_tokens {
out = out.with_token_budget(v);
}
if let Some(v) = run.max_retries {
out = out.with_max_retries(v);
}
if let Some(v) = run.exec_timeout_secs {
out = out.with_exec_timeout(Duration::from_secs(v));
}
if let Some(v) = &run.skills {
out = out.with_skills(v.clone());
}
if let Some(v) = run.retry {
out = out.with_retry_policy(v);
}
if let Some(v) = run.stall {
out = out.with_stall_policy(v);
}
if let Some(v) = run.context {
out = out.with_context_budget(v);
}
if let Some(v) = &run.commit_identity {
out = out.with_commit_identity(v.name.clone(), v.email.clone());
}
out
}
}
#[must_use]
pub fn user_path() -> Option<PathBuf> {
if let Some(dir) = env_dir(CONFIG_HOME_VAR) {
return Some(dir.join(PROJECT_FILE));
}
#[cfg(windows)]
let base = env_dir("APPDATA")?;
#[cfg(not(windows))]
let base = match env_dir("XDG_CONFIG_HOME") {
Some(dir) => dir,
None => env_dir("HOME")?.join(".config"),
};
Some(base.join("io").join(PROJECT_FILE))
}
fn env_dir(var: &str) -> Option<PathBuf> {
let value = std::env::var_os(var)?;
if value.is_empty() {
return None;
}
Some(PathBuf::from(value))
}
fn read_scope(path: &Path) -> Result<toml::value::Table> {
let text = std::fs::read_to_string(path)
.map_err(|e| Error::Config(format!("{}: {e}", path.display())))?;
let table = parse(&text, path)?;
let _: File = deserialize(toml::Value::Table(table.clone()), path)?;
Ok(table)
}
fn parse(text: &str, path: &Path) -> Result<toml::value::Table> {
let mut table: toml::value::Table = toml::from_str(text)
.map_err(|e| Error::Config(format!("{}: {}", path.display(), e.message())))?;
let dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let mut key = Vec::new();
for (k, v) in table.iter_mut() {
key.push(k.clone());
substitute(v, &dir, &mut key, path)?;
key.pop();
}
Ok(table)
}
fn deserialize(value: toml::Value, path: &Path) -> Result<File> {
value
.try_into()
.map_err(|e: toml::de::Error| Error::Config(format!("{}: {}", path.display(), e.message())))
}
fn substitute(
value: &mut toml::Value,
dir: &Path,
key: &mut Vec<String>,
path: &Path,
) -> Result<()> {
match value {
toml::Value::String(s) => *s = expand(s, dir, key, path)?,
toml::Value::Array(items) => {
for (i, item) in items.iter_mut().enumerate() {
key.push(format!("[{i}]"));
substitute(item, dir, key, path)?;
key.pop();
}
}
toml::Value::Table(table) => {
for (k, v) in table.iter_mut() {
key.push(k.clone());
substitute(v, dir, key, path)?;
key.pop();
}
}
_ => {}
}
Ok(())
}
fn expand(raw: &str, dir: &Path, key: &[String], path: &Path) -> Result<String> {
let mut out = String::new();
let mut rest = raw;
while let Some(at) = rest.find("${") {
out.push_str(&rest[..at]);
let after = &rest[at + 2..];
let Some(end) = after.find('}') else {
return Err(bad_key(path, key, "unterminated `${` substitution"));
};
let inner = &after[..end];
let Some((kind, arg)) = inner.split_once(':') else {
return Err(bad_key(
path,
key,
format!("`${{{inner}}}` names neither `env:` nor `file:`"),
));
};
let value = match kind {
"env" => std::env::var(arg).map_err(|_| {
bad_key(
path,
key,
format!("environment variable `{arg}` is not set"),
)
})?,
"file" => {
let at = dir.join(arg);
std::fs::read_to_string(&at)
.map_err(|e| {
bad_key(path, key, format!("cannot read `{}`: {e}", at.display()))
})?
.trim()
.to_string()
}
other => {
return Err(bad_key(
path,
key,
format!("`{other}:` is not a substitution this crate knows"),
))
}
};
if value.is_empty() {
return Err(bad_key(
path,
key,
format!("`${{{inner}}}` resolved to nothing, and an empty value is never what a config meant"),
));
}
out.push_str(&value);
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(out)
}
fn bad_key(path: &Path, key: &[String], why: impl std::fmt::Display) -> Error {
Error::Config(format!(
"{}: key `{}`: {why}",
path.display(),
key.join(".")
))
}
const APPENDING: &[&[&str]] = &[&["policy", "layers"]];
fn merge(base: &mut toml::value::Table, over: toml::value::Table, at: &mut Vec<String>) {
for (key, value) in over {
at.push(key.clone());
match (base.get_mut(&key), value) {
(Some(toml::Value::Table(b)), toml::Value::Table(o)) => merge(b, o, at),
(Some(toml::Value::Array(b)), toml::Value::Array(o))
if APPENDING.iter().any(|p| p == &at.as_slice()) =>
{
b.extend(o);
}
(_, value) => {
base.insert(key.clone(), value);
}
}
at.pop();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(text: &str) -> toml::value::Table {
toml::from_str(text).unwrap()
}
#[test]
fn a_later_scope_wins_one_key_and_leaves_its_siblings() {
let mut base = table("[sandbox.limits]\nmax_wall_secs = 120\nmax_cpu_secs = 60\n");
merge(
&mut base,
table("[sandbox.limits]\nmax_wall_secs = 5\n"),
&mut Vec::new(),
);
let limits = base["sandbox"]["limits"].as_table().unwrap();
assert_eq!(limits["max_wall_secs"].as_integer(), Some(5));
assert_eq!(
limits["max_cpu_secs"].as_integer(),
Some(60),
"a sibling key the later scope never named is not disturbed"
);
}
#[test]
fn policy_layers_append_and_every_other_array_replaces() {
let mut base = table(
"[[policy.layers]]\nname = \"ops\"\nrules = []\n\
[toolchain.cargo]\ntest = [\"cargo\", \"test\"]\n",
);
merge(
&mut base,
table(
"[[policy.layers]]\nname = \"mine\"\nrules = []\n\
[toolchain.cargo]\ntest = [\"cargo\", \"nextest\", \"run\"]\n",
),
&mut Vec::new(),
);
let layers = base["policy"]["layers"].as_array().unwrap();
assert_eq!(layers.len(), 2, "layers append");
assert_eq!(layers[0]["name"].as_str(), Some("ops"));
assert_eq!(layers[1]["name"].as_str(), Some("mine"));
let test = base["toolchain"]["cargo"]["test"].as_array().unwrap();
assert_eq!(test.len(), 3, "an ordinary array is replaced whole");
}
#[test]
fn substitution_resolves_or_fails_and_never_empties() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("token"), " s3cret\n").unwrap();
std::env::set_var("IO_HARNESS_TEST_SET", "from-the-environment");
std::env::set_var("IO_HARNESS_TEST_EMPTY", "");
let key = ["run".to_string()];
let path = Path::new("io.toml");
assert_eq!(
expand("${env:IO_HARNESS_TEST_SET}", dir.path(), &key, path).unwrap(),
"from-the-environment"
);
assert_eq!(
expand("Bearer ${file:token}", dir.path(), &key, path).unwrap(),
"Bearer s3cret",
"a file's value is trimmed, and substitution is inside a larger string"
);
for (input, expect) in [
("${env:IO_HARNESS_TEST_UNSET}", "is not set"),
("${env:IO_HARNESS_TEST_EMPTY}", "resolved to nothing"),
("${file:absent}", "cannot read"),
("${nope:x}", "not a substitution"),
("${env:X", "unterminated"),
] {
let err = expand(input, dir.path(), &key, path)
.unwrap_err()
.to_string();
assert!(err.contains(expect), "{input}: {err}");
assert!(err.contains("key `run`"), "the error names the key: {err}");
}
assert_eq!(
expand("plain $HOME value", dir.path(), &key, path).unwrap(),
"plain $HOME value"
);
}
}