use crate::adapter::Capability;
use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::path::Path;
const EVERYTHING: &str = "*";
#[derive(Debug, Clone, PartialEq, Eq)]
enum Chosen {
All,
These(Vec<String>),
}
#[cfg_attr(test, derive(Default))]
#[derive(Debug, Clone)]
pub struct Selection {
chosen: BTreeMap<Capability, Chosen>,
owned: Owned,
}
pub type Owned = BTreeMap<Capability, BTreeMap<String, String>>;
impl Selection {
pub fn owning(owned: Owned) -> Self {
Self {
chosen: BTreeMap::new(),
owned,
}
}
pub fn apply(&mut self, table: &BTreeMap<String, Vec<String>>, whence: &Path) -> Result<()> {
for (key, names) in table {
let cap = Capability::from_key(key).ok_or_else(|| {
anyhow::anyhow!(
"{}: `[use] {key}` is not a capability — expected {}",
whence.display(),
Capability::ALL
.iter()
.map(Capability::to_string)
.collect::<Vec<_>>()
.join(", ")
)
})?;
self.chosen.insert(cap, self.read_list(cap, names, whence)?);
}
Ok(())
}
fn read_list(&self, cap: Capability, names: &[String], whence: &Path) -> Result<Chosen> {
if names.iter().any(|n| n == EVERYTHING) {
if names.len() > 1 {
bail!(
"{}: `[use] {cap}` names `*` beside {} other entr{} — `*` is the \
whole catalogue, so the rest cannot add to it. Drop the `*`, or \
drop the names.",
whence.display(),
names.len() - 1,
if names.len() == 2 { "y" } else { "ies" }
);
}
return Ok(Chosen::All);
}
let mut out = Vec::with_capacity(names.len());
for name in names {
validate_entry_name(name, cap, whence)?;
if let Some(feature) = self.owner(cap, name) {
bail!(
"{}: `[use] {cap}` names `{name}`, which is omh's — part of the \
`{feature}` feature. `[use]` names your entries; a feature is all \
or nothing and `omh repo disable {feature}` is its switch.",
whence.display()
);
}
if !out.contains(name) {
out.push(name.clone());
}
}
Ok(Chosen::These(out))
}
pub fn allows(&self, cap: Capability, name: &str) -> bool {
if self.is_omhs(cap, name) {
return true;
}
match self.chosen.get(&cap) {
None | Some(Chosen::All) => true,
Some(Chosen::These(names)) => names.iter().any(|n| n == name),
}
}
pub fn order(&self, cap: Capability) -> Option<&[String]> {
match self.chosen.get(&cap) {
Some(Chosen::These(names)) => Some(names),
_ => None,
}
}
pub fn unselected(&self, cap: Capability, available: &[String]) -> Vec<String> {
match self.chosen.get(&cap) {
Some(Chosen::These(_)) => available
.iter()
.filter(|n| !self.allows(cap, n))
.cloned()
.collect(),
_ => Vec::new(),
}
}
pub fn missing(&self, cap: Capability, available: &[String]) -> Vec<String> {
match self.chosen.get(&cap) {
Some(Chosen::These(names)) => names
.iter()
.filter(|n| !available.iter().any(|a| a == *n) && !self.is_omhs(cap, n))
.cloned()
.collect(),
_ => Vec::new(),
}
}
pub fn is_omhs(&self, cap: Capability, name: &str) -> bool {
self.owner(cap, name).is_some()
}
pub fn owner(&self, cap: Capability, name: &str) -> Option<&str> {
self.owned.get(&cap)?.get(name).map(String::as_str)
}
}
pub fn validate_entry_name(name: &str, cap: Capability, whence: &Path) -> Result<()> {
let bad = name.is_empty()
|| name.starts_with('.')
|| name.contains('/')
|| name.contains('\\')
|| name.contains('\0');
if bad {
bail!(
"{}: `{name}` is not a `{cap}` entry — an entry is a name in your \
catalogue, never a path",
whence.display()
);
}
Ok(())
}
impl Capability {
pub fn from_key(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.to_string() == key)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn owned() -> Owned {
let entry = |name: &str, feature: &str| (name.to_string(), feature.to_string());
BTreeMap::from([
(
Capability::Mcp,
BTreeMap::from([entry("codegraph", "codegraph"), entry("memory", "memory")]),
),
(
Capability::Hooks,
BTreeMap::from([entry("graph-first", "codegraph")]),
),
])
}
fn selection(pairs: &[(&str, &[&str])]) -> Selection {
let mut s = Selection::owning(owned());
let table = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|n| n.to_string()).collect()))
.collect();
s.apply(&table, Path::new("settings.toml")).unwrap();
s
}
fn refused(pairs: &[(&str, &[&str])]) -> String {
let mut s = Selection::owning(owned());
let table = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|n| n.to_string()).collect()))
.collect();
format!(
"{:#}",
s.apply(&table, Path::new("settings.toml"))
.expect_err("should have been refused")
)
}
#[test]
fn a_repo_with_no_use_table_gets_the_whole_catalogue() {
let s = Selection::owning(owned());
for cap in Capability::ALL {
assert!(s.allows(cap, "anything at all"), "{cap} should be open");
}
assert!(s.unselected(Capability::Skills, &["a".into()]).is_empty());
}
#[test]
fn an_empty_list_selects_nothing_and_a_star_selects_everything() {
let none = selection(&[("skills", &[])]);
assert!(!none.allows(Capability::Skills, "review-diff"));
let all = selection(&[("skills", &["*"])]);
assert!(all.allows(Capability::Skills, "review-diff"));
assert!(
all.unselected(Capability::Skills, &["review-diff".into()])
.is_empty(),
"`*` follows the catalogue, so nothing is ever unselected under it"
);
}
#[test]
fn a_later_layer_replaces_a_capabilitys_list_wholesale() {
let mut s = Selection::owning(owned());
let layer = |pairs: &[(&str, &[&str])]| -> BTreeMap<String, Vec<String>> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|n| n.to_string()).collect()))
.collect()
};
s.apply(
&layer(&[("skills", &["mine", "yours"]), ("rules", &["tdd"])]),
Path::new("personal"),
)
.unwrap();
s.apply(&layer(&[("skills", &["yours"])]), Path::new("repo"))
.unwrap();
assert!(
!s.allows(Capability::Skills, "mine"),
"replaced, not merged"
);
assert!(s.allows(Capability::Skills, "yours"));
assert!(
s.allows(Capability::Rules, "tdd"),
"and a capability the later layer said nothing about is untouched"
);
}
#[test]
fn a_capability_key_outside_the_six_is_refused() {
let err = refused(&[("plugins", &["x"])]);
assert!(err.contains("plugins"), "must name it: {err}");
assert!(err.contains("subagents"), "and list the six: {err}");
}
#[test]
fn a_name_that_climbs_out_of_the_catalogue_is_refused() {
for bad in ["../../../.ssh/id_rsa", "..", "a/b", "", ".hidden", "a\\b"] {
let err = refused(&[("skills", &[bad])]);
assert!(
err.contains("never a path"),
"{bad:?} slipped through: {err}"
);
}
}
#[test]
fn a_name_omh_owns_is_not_selectable_in_any_capability() {
for (cap, name) in [("mcp", "codegraph"), ("hooks", "graph-first")] {
let err = refused(&[(cap, &[name])]);
assert!(err.contains(name), "must name it: {err}");
assert!(
err.contains("omh repo disable"),
"and point at the switch that does work: {err}"
);
}
}
#[test]
fn an_empty_selection_leaves_omhs_own_alone() {
let s = selection(&[("mcp", &[]), ("hooks", &[])]);
assert!(s.allows(Capability::Mcp, "codegraph"));
assert!(s.allows(Capability::Mcp, "memory"));
assert!(s.allows(Capability::Hooks, "graph-first"));
assert!(
!s.allows(Capability::Mcp, "linear"),
"while yours are genuinely off"
);
}
#[test]
fn the_declared_order_is_the_order() {
let s = selection(&[("rules", &["zebra", "apple"])]);
assert_eq!(s.order(Capability::Rules).unwrap(), ["zebra", "apple"]);
assert!(
s.order(Capability::Skills).is_none(),
"a capability with no list has no opinion about order"
);
}
#[test]
fn a_name_repeated_is_still_one_entry() {
let s = selection(&[("rules", &["tdd", "style", "tdd"])]);
assert_eq!(s.order(Capability::Rules).unwrap(), ["tdd", "style"]);
}
#[test]
fn a_star_beside_a_name_is_refused() {
let err = refused(&[("skills", &["*", "review-diff"])]);
assert!(err.contains("whole catalogue"), "got: {err}");
}
#[test]
fn unselected_names_what_the_catalogue_has_and_the_repo_did_not_take() {
let s = selection(&[("skills", &["review-diff"])]);
assert_eq!(
s.unselected(
Capability::Skills,
&["graphify".into(), "review-diff".into()]
),
vec!["graphify"]
);
}
#[test]
fn omhs_own_are_never_reported_as_unselected() {
let s = selection(&[("mcp", &["linear"])]);
assert!(s
.unselected(
Capability::Mcp,
&["codegraph".into(), "memory".into(), "linear".into()]
)
.is_empty());
}
#[test]
fn a_selected_name_nothing_answers_to_is_reported() {
let s = selection(&[("skills", &["reveiw-diff"])]);
assert_eq!(
s.missing(Capability::Skills, &["review-diff".into()]),
vec!["reveiw-diff"]
);
assert!(
s.missing(Capability::Rules, &[]).is_empty(),
"a capability with no list can name nothing missing"
);
}
}