use crate::base::Manifest;
use crate::profile::Paths;
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize)]
struct File {
#[serde(default)]
omh: BTreeMap<String, bool>,
#[serde(default)]
mcp: BTreeMap<String, ServerOverride>,
#[serde(default, rename = "use")]
uses: BTreeMap<String, Vec<String>>,
#[serde(flatten)]
rest: toml::Table,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ServerOverride {
#[serde(default)]
env: BTreeMap<String, String>,
}
pub const LOCAL: &str = "settings.local.toml";
fn layers(paths: &Paths) -> [PathBuf; 3] {
crate::config::Layer::ALL.map(|l| l.file(paths))
}
pub fn resolve(paths: &Paths, manifest: &Manifest) -> Result<RepoPolicy> {
let mut state: BTreeMap<String, bool> = BTreeMap::new();
let mut mcp_env: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
let mut selection = crate::selection::Selection::owning(manifest.owns());
for path in layers(paths) {
let Some(raw) = read(&path)? else {
continue;
};
let file: File =
toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
let is_table_like = |v: &toml::Value| {
v.is_table()
|| v.as_array()
.is_some_and(|a| a.iter().any(toml::Value::is_table))
};
for (key, value) in &file.rest {
if is_table_like(value) {
anyhow::bail!(
"{}: `[{key}]` is read by nobody. This file holds settings at the \
top level, `[omh]` for omh's own features, and `[mcp.<name>.env]` \
for a server's environment in this repo.",
path.display()
);
}
}
for (key, on) in file.omh {
validate(&key, manifest, &path)?;
state.insert(key, on);
}
for (name, over) in file.mcp {
mcp_env.entry(name).or_default().extend(over.env);
}
selection.apply(&file.uses, &path)?;
}
let off: BTreeSet<String> = state
.into_iter()
.filter(|(_, on)| !on)
.map(|(name, _)| name)
.collect();
let mut policy = RepoPolicy::switching_off(manifest, off);
policy.mcp_env = mcp_env;
policy.selection = selection;
Ok(policy)
}
#[cfg_attr(test, derive(Default))]
#[derive(Debug, Clone)]
pub struct RepoPolicy {
pub off: BTreeSet<String>,
pub disabled_servers: BTreeSet<String>,
pub mcp_env: BTreeMap<String, BTreeMap<String, String>>,
pub selection: crate::selection::Selection,
}
impl RepoPolicy {
pub fn switching_off(manifest: &Manifest, off: BTreeSet<String>) -> Self {
Self {
disabled_servers: manifest
.entries
.iter()
.filter(|e| e.kind == crate::base::Kind::Mcp && off.contains(&e.feature))
.map(|e| e.name.clone())
.collect(),
off,
mcp_env: BTreeMap::new(),
selection: crate::selection::Selection::owning(manifest.owns()),
}
}
}
fn validate(key: &str, manifest: &Manifest, path: &Path) -> Result<()> {
let features: BTreeSet<&str> = manifest
.entries
.iter()
.map(|e| e.feature.as_str())
.collect();
if features.contains(key) {
return Ok(());
}
if let Some(entry) = manifest.entry(key) {
anyhow::bail!(
"{}: `{key}` is part of the `{}` feature, not a feature itself. \
Write `{} = false` to switch off all of it — there is no way to \
keep the rest and drop this one.",
path.display(),
entry.feature,
entry.feature
);
}
anyhow::bail!(
"{}: `{key}` is not one of omh's features ({})",
path.display(),
features.into_iter().collect::<Vec<_>>().join(", ")
)
}
fn read(path: &Path) -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(raw) => Ok(Some(raw)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/base");
fn fixture() -> (tempfile::TempDir, Paths, Manifest) {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
for d in [&paths.root, &paths.repo.join(".omh")] {
std::fs::create_dir_all(d).unwrap();
}
let manifest = Manifest::load_dir(Path::new(BASE)).unwrap();
(dir, paths, manifest)
}
fn write(path: PathBuf, body: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
#[test]
fn the_gitignored_layer_is_the_file_init_ignores() {
let (_d, paths, _m) = fixture();
let last = layers(&paths).last().unwrap().clone();
assert_eq!(last.file_name().unwrap().to_string_lossy(), LOCAL);
assert!(last.starts_with(paths.repo.join(".omh")));
}
#[test]
fn a_repo_with_no_settings_has_everything_on() {
let (_d, paths, m) = fixture();
assert!(resolve(&paths, &m).unwrap().off.is_empty());
}
#[test]
fn a_feature_named_false_is_off() {
let (_d, paths, m) = fixture();
write(
paths.repo.join(".omh/settings.toml"),
"[omh]\ncodegraph = false\n",
);
assert_eq!(
resolve(&paths, &m).unwrap().off,
BTreeSet::from(["codegraph".to_string()])
);
}
#[test]
fn a_later_layer_wins() {
let (_d, paths, m) = fixture();
write(paths.root.join("settings.toml"), "[omh]\nmemory = false\n");
write(
paths.repo.join(".omh/settings.local.toml"),
"[omh]\nmemory = true\n",
);
assert!(
resolve(&paths, &m).unwrap().off.is_empty(),
"this repo turned it back on"
);
}
#[test]
fn a_hook_name_where_a_feature_belongs_names_the_feature() {
let (_d, paths, m) = fixture();
write(
paths.repo.join(".omh/settings.toml"),
"[omh]\ngraph-first = false\n",
);
let err = resolve(&paths, &m).unwrap_err().to_string();
assert!(
err.contains("`graph-first` is part of the `codegraph` feature"),
"must say what it belongs to: {err}"
);
assert!(
err.contains("codegraph = false"),
"and what to write instead: {err}"
);
}
#[test]
fn an_unknown_feature_lists_the_features() {
let (_d, paths, m) = fixture();
write(
paths.repo.join(".omh/settings.toml"),
"[omh]\nteleport = false\n",
);
let err = resolve(&paths, &m).unwrap_err().to_string();
assert!(err.contains("teleport"), "got: {err}");
assert!(err.contains("codegraph") && err.contains("memory"), "{err}");
}
#[test]
fn a_repo_overrides_a_servers_env_and_only_that() {
let (_d, paths, m) = fixture();
write(
paths.repo.join(".omh/settings.local.toml"),
"[mcp.linear.env]\nLINEAR_API_KEY = \"secret\"\n",
);
let r = resolve(&paths, &m).unwrap();
assert_eq!(r.mcp_env["linear"]["LINEAR_API_KEY"], "secret");
write(
paths.repo.join(".omh/settings.local.toml"),
"[mcp.linear]\ncommand = \"mine\"\n",
);
let err = format!("{:#}", resolve(&paths, &m).unwrap_err());
assert!(err.contains("command"), "must name the key: {err}");
}
#[test]
fn env_overrides_merge_variable_by_variable() {
let (_d, paths, m) = fixture();
write(
paths.root.join("settings.toml"),
"[mcp.linear.env]\nTOKEN = \"t\"\n",
);
write(
paths.repo.join(".omh/settings.toml"),
"[mcp.linear.env]\nREGION = \"eu\"\n",
);
let env = &resolve(&paths, &m).unwrap().mcp_env["linear"];
assert_eq!(env["TOKEN"], "t");
assert_eq!(env["REGION"], "eu");
}
#[test]
fn use_is_read_from_every_layer_and_the_last_one_wins() {
let (_d, paths, m) = fixture();
write(
paths.root.join("settings.toml"),
"[use]\nskills = [\"mine\"]\nrules = [\"tdd\"]\n",
);
write(
paths.repo.join(".omh/settings.toml"),
"[use]\nskills = [\"ours\"]\n",
);
let s = resolve(&paths, &m).unwrap().selection;
assert!(s.allows(crate::adapter::Capability::Skills, "ours"));
assert!(
!s.allows(crate::adapter::Capability::Skills, "mine"),
"the repo replaced the personal list rather than adding to it"
);
assert!(
s.allows(crate::adapter::Capability::Rules, "tdd"),
"and a capability only the personal layer named still stands"
);
write(
paths.repo.join(".omh/settings.local.toml"),
"[use]\nskills = [\"just-here\"]\n",
);
let s = resolve(&paths, &m).unwrap().selection;
assert!(s.allows(crate::adapter::Capability::Skills, "just-here"));
assert!(
!s.allows(crate::adapter::Capability::Skills, "ours"),
"the gitignored layer has the last word, which is why omh use writes it too"
);
}
#[test]
fn a_table_nobody_reads_is_refused_by_name() {
let (_d, paths, m) = fixture();
write(
paths.repo.join(".omh/settings.toml"),
"[omhh]\ncodegraph = false\n",
);
let err = format!("{:#}", resolve(&paths, &m).unwrap_err());
assert!(err.contains("omhh"), "must name the table: {err}");
assert!(err.contains("settings.toml"), "and the file: {err}");
write(
paths.repo.join(".omh/settings.toml"),
"carry_in = [\".env\"]\n",
);
assert!(
resolve(&paths, &m).is_ok(),
"a setting is not an unknown key"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_settings_file_is_an_error_not_an_absent_one() {
use std::os::unix::fs::PermissionsExt;
let (_d, paths, m) = fixture();
let path = paths.repo.join(".omh/settings.toml");
write(path.clone(), "[omh]\ncodegraph = false\n");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let err = resolve(&paths, &m).unwrap_err().to_string();
assert!(err.contains("settings.toml"), "must name the file: {err}");
}
}