use crate::adapter::Render;
use crate::profile::Paths;
use crate::render::Server;
use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Layer {
Personal,
Shared,
Local,
}
#[derive(Debug, PartialEq)]
pub struct Setting {
pub key: String,
pub value: String,
pub layer: Layer,
pub shadows: Vec<Layer>,
}
#[derive(Debug, PartialEq)]
pub struct Written {
pub path: PathBuf,
pub layer: Layer,
pub committed: bool,
}
impl Layer {
pub const ALL: [Layer; 3] = [Self::Personal, Self::Shared, Self::Local];
pub fn file(&self, paths: &Paths) -> PathBuf {
match self {
Self::Personal => paths.root.join("settings.toml"),
Self::Shared => paths.repo.join(".omh").join("settings.toml"),
Self::Local => paths.repo.join(".omh").join(crate::settings::LOCAL),
}
}
pub fn content_dir(&self, paths: &Paths) -> Option<PathBuf> {
match self {
Self::Personal => Some(paths.root.clone()),
Self::Shared => Some(paths.repo.join(".omh")),
Self::Local => None,
}
}
pub fn whose(&self) -> &'static str {
match self {
Self::Personal => "your catalogue",
Self::Shared => "this repo",
Self::Local => "local",
}
}
pub fn is_committed(&self) -> bool {
matches!(self, Self::Shared)
}
}
impl std::str::FromStr for Layer {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
Layer::ALL
.into_iter()
.find(|l| l.to_string() == s)
.with_context(|| format!("unknown layer `{s}` — expected personal, shared, or local"))
}
}
impl std::fmt::Display for Layer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Personal => "personal",
Self::Shared => "shared",
Self::Local => "local",
})
}
}
pub fn policy(paths: &Paths) -> Result<Vec<Setting>> {
let mut found = Vec::new();
for layer in Layer::ALL {
let path = layer.file(paths);
let Some(raw) = read_layer(&path)? else {
continue;
};
let table: toml::Table =
toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
for (key, value) in table {
if value.is_table() {
continue;
}
found.push((key, repr(&value), layer));
}
}
Ok(resolve(found))
}
fn read_layer(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())),
}
}
pub fn hooks(paths: &Paths) -> Result<Vec<Setting>> {
let mut found = Vec::new();
for layer in Layer::ALL {
let Some(dir) = layer.content_dir(paths).map(|d| d.join("hooks")) else {
continue;
};
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
};
for entry in entries {
let path = entry
.with_context(|| format!("reading {}", dir.display()))?
.path();
if !path.extension().is_some_and(|e| e == "json") {
continue;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let hook = crate::hook::Hook::parse(&raw, &path.display().to_string())?;
found.push((name, hook.does().to_string(), layer));
}
}
Ok(resolve(found))
}
pub fn servers(paths: &Paths) -> Result<Vec<Setting>> {
refuse_a_repo_server(paths)?;
let mut found = Vec::new();
let path = mcp_path(paths);
if let Some(raw) = read_layer(&path)? {
let doc: serde_json::Value =
serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
if let Some(servers) = doc.get("mcpServers").and_then(|v| v.as_object()) {
for (name, spec) in servers {
let server: Server = serde_json::from_value(spec.clone()).with_context(|| {
format!(
"{}: server `{name}` is not one omh can run. omh launches \
stdio servers — {{\"command\": …, \"args\": […], \"env\": {{…}}}} \
— and has no support for remote/HTTP ones yet.",
path.display()
)
})?;
found.push((name.clone(), server.command, Layer::Personal));
}
}
}
Ok(resolve(found))
}
fn refuse_a_repo_server(paths: &Paths) -> Result<()> {
let stray = paths.repo.join(".omh").join("mcp.json");
if stray.exists() {
anyhow::bail!(
"{}: a repo names servers from your catalogue, it cannot declare one — \
nothing reads this file. Add it with `omh config mcp add`, and put a \
token for this repo alone under `[mcp.<name>.env]` in .omh/{}.",
stray.display(),
crate::settings::LOCAL
);
}
Ok(())
}
pub fn set(paths: &Paths, key: &str, raw: &str, layer: Layer) -> Result<Written> {
edit_layer(paths, layer, |doc| {
refuse_a_table(doc, key)?;
doc[key] = toml_edit::Item::Value(parse_value(raw));
Ok(())
})
}
pub fn unset(paths: &Paths, key: &str, layer: Layer) -> Result<bool> {
let path = layer.file(paths);
let mut doc = read_doc(&path)?;
refuse_a_table(&doc, key).with_context(|| format!("editing {}", path.display()))?;
if doc.remove(key).is_none() {
return Ok(false);
}
std::fs::write(&path, doc.to_string())
.with_context(|| format!("writing {}", path.display()))?;
Ok(true)
}
fn refuse_a_table(doc: &toml_edit::DocumentMut, key: &str) -> Result<()> {
if doc
.as_table()
.get(key)
.is_some_and(|item| item.as_table_like().is_some())
{
anyhow::bail!(
"`{key}` is a table, not a setting — omh will not replace it with a value. \
`[omh]` is `omh repo enable`/`disable`, `[use]` is `omh use`/`unuse`, and \
`[mcp.<name>.env]` is edited by hand."
);
}
Ok(())
}
pub const USE: &str = "use";
pub const OMH: &str = "omh";
pub fn write_selection(
paths: &Paths,
layer: Layer,
lists: &BTreeMap<crate::adapter::Capability, Vec<String>>,
) -> Result<Written> {
write_table(paths, layer, USE, |table| {
for (cap, names) in lists {
let mut array = toml_edit::Array::new();
for name in names {
array.push(name.as_str());
}
table[&cap.to_string()] = toml_edit::value(array);
}
})
}
pub fn write_feature(paths: &Paths, layer: Layer, feature: &str, on: bool) -> Result<Written> {
write_table(paths, layer, OMH, |table| {
table[feature] = toml_edit::value(on);
})
}
pub fn declaring(paths: &Paths, table: &str, key: &str) -> Result<Vec<Layer>> {
let mut out = vec![Layer::Shared];
if declares_key(paths, Layer::Local, table, key)? {
out.push(Layer::Local);
}
Ok(out)
}
pub fn declares(paths: &Paths, layer: Layer, table: &str) -> Result<bool> {
Ok(read_doc(&layer.file(paths))?.contains_key(table))
}
fn declares_key(paths: &Paths, layer: Layer, table: &str, key: &str) -> Result<bool> {
Ok(read_doc(&layer.file(paths))?
.get(table)
.and_then(|item| item.as_table_like())
.is_some_and(|t| t.contains_key(key)))
}
fn write_table(
paths: &Paths,
layer: Layer,
name: &str,
edit: impl FnOnce(&mut toml_edit::Table),
) -> Result<Written> {
edit_layer(paths, layer, |doc| {
let item = doc
.as_table_mut()
.entry(name)
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
let Some(table) = item.as_table_mut() else {
anyhow::bail!("`{name}` is not a table — omh will not overwrite it");
};
edit(table);
Ok(())
})
}
fn edit_layer(
paths: &Paths,
layer: Layer,
edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<()>,
) -> Result<Written> {
let path = layer.file(paths);
let mut doc = read_doc(&path)?;
edit(&mut doc).with_context(|| format!("editing {}", path.display()))?;
std::fs::create_dir_all(path.parent().unwrap())?;
std::fs::write(&path, doc.to_string())
.with_context(|| format!("writing {}", path.display()))?;
Ok(Written {
path,
layer,
committed: layer.is_committed(),
})
}
fn read_doc(path: &Path) -> Result<toml_edit::DocumentMut> {
let Some(raw) = read_layer(path)? else {
return Ok(toml_edit::DocumentMut::new());
};
raw.parse()
.with_context(|| format!("parsing {}", path.display()))
}
fn parse_value(raw: &str) -> toml_edit::Value {
raw.parse::<toml_edit::Value>()
.unwrap_or_else(|_| toml_edit::Value::from(raw))
}
fn resolve(found: Vec<(String, String, Layer)>) -> Vec<Setting> {
let mut by_key: BTreeMap<String, Vec<(String, Layer)>> = BTreeMap::new();
for (key, value, layer) in found {
by_key.entry(key).or_default().push((value, layer));
}
by_key
.into_iter()
.map(|(key, mut hits)| {
let (value, layer) = hits.pop().expect("entry exists because it was inserted");
Setting {
key,
value,
layer,
shadows: hits.into_iter().map(|(_, l)| l).collect(),
}
})
.collect()
}
fn repr(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
#[derive(Debug, Default, PartialEq)]
pub struct Imported {
pub added: Vec<String>,
pub conflicts: Vec<String>,
pub unchanged: Vec<String>,
}
pub fn mcp_add(paths: &Paths, name: &str, server: Server) -> Result<Written> {
let path = mcp_path(paths);
let mut all = read_servers(&path)?;
all.insert(name.to_string(), server);
write_servers(&path, &all)?;
Ok(Written {
path,
layer: Layer::Personal,
committed: false,
})
}
pub fn mcp_remove(paths: &Paths, name: &str) -> Result<bool> {
let path = mcp_path(paths);
let mut all = read_servers(&path)?;
if all.remove(name).is_none() {
return Ok(false);
}
write_servers(&path, &all)?;
Ok(true)
}
pub fn mcp_import(
paths: &Paths,
incoming: BTreeMap<String, Server>,
force: bool,
dry_run: bool,
) -> Result<Imported> {
let path = mcp_path(paths);
let mut all = read_servers(&path)?;
let mut report = Imported::default();
for (name, server) in incoming {
match all.get(&name) {
Some(existing) if *existing == server => report.unchanged.push(name),
Some(_) if !force => report.conflicts.push(name),
_ => {
all.insert(name.clone(), server);
report.added.push(name);
}
}
}
if !dry_run && !report.added.is_empty() {
write_servers(&path, &all)?;
}
Ok(report)
}
pub fn mcp_path(paths: &Paths) -> PathBuf {
paths.root.join("mcp.json")
}
fn read_servers(path: &Path) -> Result<BTreeMap<String, Server>> {
let Some(raw) = read_layer(path)? else {
return Ok(BTreeMap::new());
};
crate::render::parse(Render::McpJson, &raw)
.with_context(|| format!("reading {}", path.display()))
}
fn write_servers(path: &Path, servers: &BTreeMap<String, Server>) -> Result<()> {
std::fs::create_dir_all(path.parent().unwrap())?;
let doc = serde_json::json!({ "mcpServers": servers });
std::fs::write(path, serde_json::to_string_pretty(&doc)? + "\n")?;
Ok(())
}
pub fn policy_list(paths: &Paths, key: &str) -> Vec<String> {
let Some(repr) = policy(paths)
.ok()
.and_then(|s| s.into_iter().find(|s| s.key == key))
else {
return Vec::new();
};
toml::from_str::<toml::Table>(&format!("v = {}", repr.value))
.ok()
.and_then(|t| t.get("v").and_then(|v| v.as_array()).cloned())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn fixture() -> (tempfile::TempDir, Paths) {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
(dir, paths)
}
fn seed(paths: &Paths, layer: Layer, name: &str, body: &str) {
let p = layer
.content_dir(paths)
.unwrap_or_else(|| panic!("{layer} holds no content"))
.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
fn settings(paths: &Paths, layer: Layer, body: &str) {
let p = layer.file(paths);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
#[test]
fn a_setting_and_a_feature_switch_share_one_file() {
let (_d, paths) = fixture();
std::fs::create_dir_all(paths.repo.join(".omh")).unwrap();
std::fs::write(
paths.repo.join(".omh/settings.toml"),
"carry_in = [\".env\"]\n\n[omh]\ncodegraph = false\n",
)
.unwrap();
let found = policy(&paths).unwrap();
assert_eq!(get(&found, "carry_in").value, "[\".env\"]");
assert_eq!(get(&found, "carry_in").layer, Layer::Shared);
}
#[test]
fn a_table_is_not_reported_as_a_setting() {
let (_d, paths) = fixture();
std::fs::create_dir_all(paths.repo.join(".omh")).unwrap();
std::fs::write(
paths.repo.join(".omh/settings.toml"),
"[omh]\ncodegraph = false\n",
)
.unwrap();
assert!(
policy(&paths).unwrap().is_empty(),
"a table is not a setting with a value"
);
}
#[test]
fn hooks_resolve_with_the_layer_they_came_from() {
let (_d, paths) = fixture();
seed(
&paths,
Layer::Personal,
"hooks/graph-read.json",
r#"{"on":"turn-end","run":"a"}"#,
);
seed(
&paths,
Layer::Shared,
"hooks/mine.json",
r#"{"on":"turn-end","run":"b"}"#,
);
let found = hooks(&paths).unwrap();
let by = |k: &str| {
found
.iter()
.find(|s| s.key == k)
.unwrap_or_else(|| panic!("{k}"))
};
assert_eq!(by("graph-read").value, "a");
assert_eq!(by("graph-read").layer, Layer::Personal, "your catalogue");
assert_eq!(by("mine").layer, Layer::Shared, "this repo's");
}
#[test]
fn a_project_hook_shadows_a_catalogue_hook_of_the_same_name() {
let (_d, paths) = fixture();
seed(
&paths,
Layer::Personal,
"hooks/format.json",
r#"{"on":"turn-end","run":"yours"}"#,
);
seed(
&paths,
Layer::Shared,
"hooks/format.json",
r#"{"on":"turn-end","run":"this repo's"}"#,
);
let found = hooks(&paths).unwrap();
let x = found.iter().find(|s| s.key == "format").unwrap();
assert_eq!(x.value, "this repo's", "project beats catalogue");
assert_eq!(x.shadows, vec![Layer::Personal]);
}
#[test]
fn a_hook_that_does_nothing_is_an_error_not_an_empty_string() {
let (_d, paths) = fixture();
seed(
&paths,
Layer::Shared,
"hooks/broken.json",
r#"{"on":"turn-end"}"#,
);
let err = format!("{:#}", hooks(&paths).unwrap_err());
assert!(err.contains("broken.json"), "must name the file: {err}");
assert!(
err.contains("run") && err.contains("inject"),
"must say what is missing: {err}"
);
}
#[test]
fn a_server_omh_cannot_run_is_an_error_not_a_blank_command() {
let (_d, paths) = fixture();
seed(
&paths,
Layer::Personal,
"mcp.json",
r#"{"mcpServers":{"linear":{"command":["npx","-y","mcp-remote"]}}}"#,
);
let err = format!("{:#}", servers(&paths).unwrap_err());
assert!(err.contains("linear"), "must name the server: {err}");
assert!(err.contains("mcp.json"), "and the file: {err}");
}
#[cfg(unix)]
#[test]
fn an_unreadable_file_is_never_overwritten_with_an_empty_one() {
use std::os::unix::fs::PermissionsExt;
let (_d, paths) = fixture();
let settings = Layer::Local.file(&paths);
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
std::fs::write(&settings, [b'k', b'=', b'"', 0xff, b'"']).unwrap();
assert!(
set(&paths, "idle_timeout", "30m", Layer::Local).is_err(),
"an unreadable settings file must stop the write, not replace it"
);
let catalogue = mcp_path(&paths);
std::fs::create_dir_all(catalogue.parent().unwrap()).unwrap();
std::fs::write(&catalogue, [0xff, 0xfe, b'{']).unwrap();
let before = std::fs::read(&catalogue).unwrap();
assert!(
mcp_add(&paths, "new", server("c")).is_err(),
"an unreadable catalogue must stop the write, not replace it"
);
assert_eq!(
std::fs::read(&catalogue).unwrap(),
before,
"and must leave every server you had on disk"
);
let _ = std::fs::set_permissions(&catalogue, std::fs::Permissions::from_mode(0o644));
}
#[test]
fn a_repo_declaring_an_mcp_server_is_an_error_naming_the_catalogue() {
let (_d, paths) = fixture();
seed(
&paths,
Layer::Shared,
"mcp.json",
r#"{"mcpServers":{"linear":{"command":"npx"}}}"#,
);
let err = format!("{:#}", servers(&paths).unwrap_err());
assert!(err.contains("mcp.json"), "must name the file: {err}");
assert!(
err.contains("omh config mcp add"),
"and where a server goes instead: {err}"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_layer_is_an_error_not_an_absent_one() {
use std::os::unix::fs::PermissionsExt;
let (_d, paths) = fixture();
seed(
&paths,
Layer::Personal,
"mcp.json",
r#"{"mcpServers":{"codegraph":{"command":"c"}}}"#,
);
let path = mcp_path(&paths);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = servers(&paths);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = result
.expect_err("an unreadable layer must not read as empty")
.to_string();
assert!(err.contains("mcp.json"), "must name the file: {err}");
}
#[test]
fn an_absent_layer_is_not_an_error() {
let (_d, paths) = fixture();
assert!(servers(&paths).unwrap().is_empty());
assert!(policy(&paths).unwrap().is_empty());
assert!(hooks(&paths).unwrap().is_empty());
}
#[test]
fn a_non_json_file_in_the_hooks_directory_is_ignored() {
let (_d, paths) = fixture();
seed(&paths, Layer::Personal, "hooks/notes.md", "not a hook");
seed(
&paths,
Layer::Personal,
"hooks/real.json",
r#"{"on":"turn-end","run":"c"}"#,
);
let found = hooks(&paths).unwrap();
assert_eq!(found.len(), 1, "got: {found:?}");
assert_eq!(found[0].key, "real");
}
fn get<'a>(settings: &'a [Setting], key: &str) -> &'a Setting {
settings
.iter()
.find(|s| s.key == key)
.unwrap_or_else(|| panic!("no key {key}"))
}
#[test]
fn only_the_shared_layer_is_committed() {
assert!(Layer::Shared.is_committed());
assert!(!Layer::Personal.is_committed());
assert!(!Layer::Local.is_committed(), "local must be gitignored");
}
#[test]
fn layers_map_to_their_files() {
let (_d, paths) = fixture();
assert_eq!(
Layer::Personal.file(&paths),
paths.root.join("settings.toml")
);
assert_eq!(
Layer::Shared.file(&paths),
paths.repo.join(".omh/settings.toml")
);
assert_eq!(
Layer::Local.file(&paths),
paths.repo.join(".omh/settings.local.toml")
);
}
#[test]
fn the_gitignored_layer_holds_no_content() {
let (_d, paths) = fixture();
assert!(Layer::Local.content_dir(&paths).is_none());
}
#[test]
fn layers_round_trip_through_their_names() {
for layer in Layer::ALL {
assert_eq!(Layer::from_str(&layer.to_string()).unwrap(), layer);
}
assert!(Layer::from_str("nonsense").is_err());
}
#[test]
fn policy_reports_the_winning_layer() {
let (_d, paths) = fixture();
settings(&paths, Layer::Personal, "idle_timeout = \"30m\"");
let settings = policy(&paths).unwrap();
assert_eq!(get(&settings, "idle_timeout").layer, Layer::Personal);
}
#[test]
fn later_layers_win_and_the_loser_is_named() {
let (_d, paths) = fixture();
settings(&paths, Layer::Personal, "idle_timeout = \"30m\"");
settings(&paths, Layer::Shared, "idle_timeout = \"5m\"");
settings(&paths, Layer::Local, "idle_timeout = \"2h\"");
let s = policy(&paths).unwrap();
let t = get(&s, "idle_timeout");
assert_eq!(t.value, "2h", "local wins");
assert_eq!(t.layer, Layer::Local);
assert_eq!(
t.shadows,
vec![Layer::Personal, Layer::Shared],
"a value that beat others must say so, or the merge is undebuggable"
);
}
#[test]
fn unshadowed_settings_report_no_losers() {
let (_d, paths) = fixture();
settings(&paths, Layer::Shared, "carry_in = [\".env\"]");
assert!(get(&policy(&paths).unwrap(), "carry_in").shadows.is_empty());
}
#[test]
fn missing_layers_are_not_an_error() {
let (_d, paths) = fixture();
assert!(policy(&paths).unwrap().is_empty());
assert!(servers(&paths).unwrap().is_empty());
}
#[test]
fn mcp_servers_resolve_from_the_catalogue() {
let (_d, paths) = fixture();
seed(
&paths,
Layer::Personal,
"mcp.json",
r#"{"mcpServers":{"codegraph":{"command":"codebase-memory-mcp"},
"omh-memory":{"command":"omh-mcp"}}}"#,
);
let s = servers(&paths).unwrap();
assert_eq!(s.len(), 2);
assert_eq!(get(&s, "codegraph").layer, Layer::Personal);
assert_eq!(get(&s, "codegraph").value, "codebase-memory-mcp");
assert!(
get(&s, "codegraph").shadows.is_empty(),
"one catalogue, so nothing to shadow"
);
}
#[test]
fn set_creates_the_layer_file_when_absent() {
let (_d, paths) = fixture();
let w = set(&paths, "idle_timeout", "30m", Layer::Local).unwrap();
assert_eq!(w.path, Layer::Local.file(&paths));
assert!(!w.committed);
assert_eq!(get(&policy(&paths).unwrap(), "idle_timeout").value, "30m");
}
#[test]
fn a_scalar_never_replaces_a_table() {
let (_d, paths) = fixture();
write_feature(&paths, Layer::Shared, "codegraph", false).unwrap();
write_selection(
&paths,
Layer::Shared,
&BTreeMap::from([(crate::adapter::Capability::Skills, vec!["mine".to_string()])]),
)
.unwrap();
for key in [OMH, USE] {
let before = std::fs::read_to_string(Layer::Shared.file(&paths)).unwrap();
let err = set(&paths, key, "false", Layer::Shared)
.expect_err("`{key}` names a table, and a table is not a setting");
assert!(format!("{err:#}").contains(key), "name it: {err:#}");
assert_eq!(
std::fs::read_to_string(Layer::Shared.file(&paths)).unwrap(),
before,
"and the refusal has to leave the file alone"
);
}
assert!(set(&paths, "idle_timeout", "30m", Layer::Shared).is_ok());
}
#[test]
fn unset_refuses_to_take_a_table_away() {
let (_d, paths) = fixture();
write_feature(&paths, Layer::Shared, "codegraph", false).unwrap();
let err = unset(&paths, OMH, Layer::Shared)
.expect_err("removing `[omh]` is not removing a setting");
assert!(format!("{err:#}").contains(OMH), "name it: {err:#}");
assert!(
std::fs::read_to_string(Layer::Shared.file(&paths))
.unwrap()
.contains("codegraph"),
"and the table survives"
);
}
#[test]
fn writing_to_the_shared_layer_is_flagged_as_committed() {
let (_d, paths) = fixture();
let w = set(&paths, "carry_in", "[\".env\"]", Layer::Shared).unwrap();
assert!(w.committed);
assert_eq!(w.layer, Layer::Shared);
}
#[test]
fn set_preserves_unrelated_keys() {
let (_d, paths) = fixture();
settings(
&paths,
Layer::Local,
"carry_in = [\".env\"]\nidle_timeout = \"5m\"",
);
set(&paths, "idle_timeout", "1h", Layer::Local).unwrap();
let s = policy(&paths).unwrap();
assert_eq!(get(&s, "idle_timeout").value, "1h");
assert_eq!(
get(&s, "carry_in").value,
"[\".env\"]",
"must not clobber siblings"
);
}
#[test]
fn set_accepts_arrays_as_well_as_scalars() {
let (_d, paths) = fixture();
set(&paths, "carry_in", "[\".env\", \"certs/\"]", Layer::Local).unwrap();
assert_eq!(
get(&policy(&paths).unwrap(), "carry_in").value,
"[\".env\", \"certs/\"]"
);
}
#[test]
fn unset_touches_only_the_named_layer() {
let (_d, paths) = fixture();
settings(&paths, Layer::Personal, "idle_timeout = \"30m\"");
settings(&paths, Layer::Local, "idle_timeout = \"2h\"");
assert!(unset(&paths, "idle_timeout", Layer::Local).unwrap());
let resolved = policy(&paths).unwrap();
let t = get(&resolved, "idle_timeout");
assert_eq!(t.value, "30m", "the personal layer must resurface");
assert_eq!(t.layer, Layer::Personal);
}
#[test]
fn unset_reports_when_nothing_was_removed() {
let (_d, paths) = fixture();
assert!(!unset(&paths, "absent", Layer::Local).unwrap());
}
fn server(command: &str) -> Server {
Server {
command: command.into(),
args: vec![],
env: BTreeMap::new(),
}
}
fn names(paths: &Paths) -> Vec<String> {
servers(paths).unwrap().into_iter().map(|s| s.key).collect()
}
#[test]
fn mcp_add_creates_the_file_when_absent() {
let (_d, paths) = fixture();
let w = mcp_add(&paths, "g", server("c")).unwrap();
assert_eq!(w.path, mcp_path(&paths));
assert_eq!(names(&paths), ["g"]);
}
#[test]
fn mcp_add_writes_to_the_catalogue_and_commits_nothing() {
let (_d, paths) = fixture();
let w = mcp_add(&paths, "g", server("c")).unwrap();
assert!(!w.committed);
assert!(w.path.starts_with(&paths.root), "got: {}", w.path.display());
}
#[test]
fn mcp_add_preserves_existing_servers() {
let (_d, paths) = fixture();
mcp_add(&paths, "first", server("a")).unwrap();
mcp_add(&paths, "second", server("b")).unwrap();
assert_eq!(names(&paths), ["first", "second"]);
}
#[test]
fn mcp_rm_takes_the_server_out_of_the_catalogue() {
let (_d, paths) = fixture();
mcp_add(&paths, "keep", server("a")).unwrap();
mcp_add(&paths, "drop", server("b")).unwrap();
assert!(mcp_remove(&paths, "drop").unwrap());
assert_eq!(names(&paths), ["keep"]);
}
#[test]
fn mcp_rm_reports_when_nothing_was_removed() {
let (_d, paths) = fixture();
assert!(!mcp_remove(&paths, "absent").unwrap());
}
fn incoming() -> BTreeMap<String, Server> {
BTreeMap::from([
("a".to_string(), server("a-cmd")),
("b".to_string(), server("b-cmd")),
])
}
#[test]
fn import_reports_what_it_would_add() {
let (_d, paths) = fixture();
let r = mcp_import(&paths, incoming(), false, false).unwrap();
assert_eq!(r.added, ["a", "b"]);
assert!(r.conflicts.is_empty() && r.unchanged.is_empty());
assert_eq!(names(&paths), ["a", "b"]);
}
#[test]
fn dry_run_writes_nothing() {
let (_d, paths) = fixture();
let r = mcp_import(&paths, incoming(), false, true).unwrap();
assert_eq!(r.added, ["a", "b"], "the plan is still reported");
assert!(names(&paths).is_empty(), "but nothing was written");
}
#[test]
fn importing_twice_changes_nothing() {
let (_d, paths) = fixture();
mcp_import(&paths, incoming(), false, false).unwrap();
let second = mcp_import(&paths, incoming(), false, false).unwrap();
assert_eq!(second.unchanged, ["a", "b"]);
assert!(second.added.is_empty());
}
#[test]
fn a_changed_server_is_a_conflict_not_a_silent_overwrite() {
let (_d, paths) = fixture();
mcp_add(&paths, "a", server("mine")).unwrap();
let r = mcp_import(&paths, incoming(), false, false).unwrap();
assert_eq!(r.conflicts, ["a"]);
assert_eq!(r.added, ["b"], "unconflicted servers still import");
assert_eq!(
get(&servers(&paths).unwrap(), "a").value,
"mine",
"kept, not clobbered"
);
}
#[test]
fn force_resolves_a_conflict_by_overwriting() {
let (_d, paths) = fixture();
mcp_add(&paths, "a", server("mine")).unwrap();
let r = mcp_import(&paths, incoming(), true, false).unwrap();
assert_eq!(r.added, ["a", "b"]);
assert!(r.conflicts.is_empty());
assert_eq!(get(&servers(&paths).unwrap(), "a").value, "a-cmd");
}
#[test]
fn importing_nothing_is_not_an_error() {
let (_d, paths) = fixture();
assert_eq!(
mcp_import(&paths, BTreeMap::new(), false, false).unwrap(),
Imported::default()
);
}
#[test]
fn a_list_setting_comes_back_as_a_list() {
let (_d, paths) = fixture();
settings(&paths, Layer::Shared, "carry_in = [\".env\", \"certs/\"]");
assert_eq!(policy_list(&paths, "carry_in"), vec![".env", "certs/"]);
}
#[test]
fn an_absent_list_is_empty_not_an_error() {
let (_d, paths) = fixture();
assert!(policy_list(&paths, "carry_in").is_empty());
}
#[test]
fn a_later_layer_replaces_the_list() {
let (_d, paths) = fixture();
settings(&paths, Layer::Personal, "carry_in = [\".env\"]");
settings(&paths, Layer::Local, "carry_in = [\".env.local\"]");
assert_eq!(policy_list(&paths, "carry_in"), vec![".env.local"]);
}
}