use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Family {
Core,
Lifecycle,
Graph,
Governance,
Power,
Meta,
Archive,
Other,
}
pub const ALWAYS_ON_TOOLS: &[&str] = &["memory_capabilities"];
impl Family {
#[must_use]
pub fn for_tool(name: &str) -> Option<Self> {
match name {
"memory_store" | "memory_recall" | "memory_list" | "memory_get" | "memory_search" => {
Some(Self::Core)
}
"memory_update" | "memory_delete" | "memory_forget" | "memory_gc"
| "memory_promote" => Some(Self::Lifecycle),
"memory_kg_query"
| "memory_kg_timeline"
| "memory_kg_invalidate"
| "memory_link"
| "memory_get_links"
| "memory_entity_register"
| "memory_entity_get_by_alias"
| "memory_get_taxonomy" => Some(Self::Graph),
"memory_pending_list"
| "memory_pending_approve"
| "memory_pending_reject"
| "memory_namespace_set_standard"
| "memory_namespace_get_standard"
| "memory_namespace_clear_standard"
| "memory_subscribe"
| "memory_unsubscribe" => Some(Self::Governance),
"memory_consolidate"
| "memory_detect_contradiction"
| "memory_check_duplicate"
| "memory_auto_tag"
| "memory_expand_query"
| "memory_inbox" => Some(Self::Power),
"memory_capabilities"
| "memory_agent_register"
| "memory_agent_list"
| "memory_session_start"
| "memory_stats" => Some(Self::Meta),
"memory_archive_list"
| "memory_archive_purge"
| "memory_archive_restore"
| "memory_archive_stats" => Some(Self::Archive),
"memory_list_subscriptions" | "memory_notify" => Some(Self::Other),
_ => None,
}
}
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Core => "core",
Self::Lifecycle => "lifecycle",
Self::Graph => "graph",
Self::Governance => "governance",
Self::Power => "power",
Self::Meta => "meta",
Self::Archive => "archive",
Self::Other => "other",
}
}
#[must_use]
pub const fn all() -> &'static [Family] {
&[
Self::Core,
Self::Lifecycle,
Self::Graph,
Self::Governance,
Self::Power,
Self::Meta,
Self::Archive,
Self::Other,
]
}
#[must_use]
pub const fn expected_tool_count(self) -> usize {
match self {
Self::Core | Self::Lifecycle | Self::Meta => 5,
Self::Graph | Self::Governance => 8,
Self::Power => 6,
Self::Archive => 4,
Self::Other => 2,
}
}
}
impl FromStr for Family {
type Err = ProfileParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.chars().any(|c| c.is_ascii_uppercase()) {
return Err(ProfileParseError::CaseMismatch(s.to_string()));
}
match s {
"core" => Ok(Self::Core),
"lifecycle" => Ok(Self::Lifecycle),
"graph" => Ok(Self::Graph),
"governance" => Ok(Self::Governance),
"power" => Ok(Self::Power),
"meta" => Ok(Self::Meta),
"archive" => Ok(Self::Archive),
"other" => Ok(Self::Other),
unknown => Err(ProfileParseError::UnknownFamily(unknown.to_string())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Profile {
families: Vec<Family>,
}
impl Profile {
#[must_use]
pub fn core() -> Self {
Self {
families: vec![Family::Core],
}
}
#[must_use]
pub fn graph() -> Self {
Self {
families: vec![Family::Core, Family::Graph],
}
}
#[must_use]
pub fn admin() -> Self {
Self {
families: vec![Family::Core, Family::Lifecycle, Family::Governance],
}
}
#[must_use]
pub fn power() -> Self {
Self {
families: vec![Family::Core, Family::Power],
}
}
#[must_use]
pub fn full() -> Self {
Self {
families: Family::all().to_vec(),
}
}
#[must_use]
pub fn families(&self) -> &[Family] {
&self.families
}
#[must_use]
pub fn includes(&self, family: Family) -> bool {
self.families.contains(&family)
}
#[must_use]
pub fn expected_tool_count(&self) -> usize {
self.families.iter().map(|f| f.expected_tool_count()).sum()
}
#[must_use]
pub fn loads(&self, tool_name: &str) -> bool {
if ALWAYS_ON_TOOLS.contains(&tool_name) {
return true;
}
Family::for_tool(tool_name).is_some_and(|f| self.includes(f))
}
pub fn parse(s: &str) -> Result<Self, ProfileParseError> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Ok(Self::core());
}
if trimmed.chars().any(|c| c.is_ascii_uppercase()) {
return Err(ProfileParseError::CaseMismatch(trimmed.to_string()));
}
match trimmed {
"core" => return Ok(Self::core()),
"graph" => return Ok(Self::graph()),
"admin" => return Ok(Self::admin()),
"power" => return Ok(Self::power()),
"full" => return Ok(Self::full()),
_ => {}
}
let mut families = Vec::with_capacity(8);
for raw_token in trimmed.split(',') {
let token = raw_token.trim();
if token.is_empty() {
continue;
}
match token {
"core" => merge(&mut families, Self::core().families()),
"graph" => merge(&mut families, Self::graph().families()),
"admin" => merge(&mut families, Self::admin().families()),
"power" => merge(&mut families, Self::power().families()),
"full" => return Ok(Self::full()),
_ => {
let f = Family::from_str(token)?;
if !families.contains(&f) {
families.push(f);
}
}
}
}
if !families.contains(&Family::Core) {
families.insert(0, Family::Core);
}
families.sort_unstable();
families.dedup();
Ok(Self { families })
}
}
impl Default for Profile {
fn default() -> Self {
Self::core()
}
}
fn merge(dst: &mut Vec<Family>, src: &[Family]) {
for f in src {
if !dst.contains(f) {
dst.push(*f);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileParseError {
UnknownFamily(String),
CaseMismatch(String),
}
impl std::fmt::Display for ProfileParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownFamily(name) => {
let valid: Vec<&str> = Family::all().iter().map(|f| f.name()).collect();
let profiles = "core, graph, admin, power, full";
write!(
f,
"unknown profile or family '{name}'. \
Valid profiles: {profiles}. \
Valid families: {valid}.",
valid = valid.join(", ")
)
}
Self::CaseMismatch(s) => {
write!(
f,
"profile '{s}' contains uppercase letters; \
profile vocabulary is case-sensitive lowercase \
(e.g. 'core', not 'Core')"
)
}
}
}
}
impl std::error::Error for ProfileParseError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn family_all_has_eight_entries() {
assert_eq!(Family::all().len(), 8);
}
#[test]
fn family_expected_tool_counts_sum_to_43() {
let total: usize = Family::all().iter().map(|f| f.expected_tool_count()).sum();
assert_eq!(
total, 43,
"v0.6.3.1 baseline is 43 tools — if this drifts, update \
Family::expected_tool_count and the family map docs together"
);
}
#[test]
fn family_from_str_lowercase_canonical() {
assert_eq!(Family::from_str("core").unwrap(), Family::Core);
assert_eq!(Family::from_str("meta").unwrap(), Family::Meta);
assert_eq!(Family::from_str("graph").unwrap(), Family::Graph);
}
#[test]
fn family_from_str_rejects_mixed_case() {
assert!(matches!(
Family::from_str("Core"),
Err(ProfileParseError::CaseMismatch(_))
));
assert!(matches!(
Family::from_str("CORE"),
Err(ProfileParseError::CaseMismatch(_))
));
}
#[test]
fn family_from_str_unknown_returns_diagnostic() {
let err = Family::from_str("xyz").unwrap_err();
match err {
ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
_ => panic!("expected UnknownFamily, got {err:?}"),
}
}
#[test]
fn profile_core_has_five_tools() {
let p = Profile::core();
assert_eq!(p.expected_tool_count(), 5);
assert!(p.includes(Family::Core));
assert!(!p.includes(Family::Meta));
assert!(!p.includes(Family::Lifecycle));
}
#[test]
fn profile_graph_has_thirteen_tools() {
let p = Profile::graph();
assert_eq!(p.expected_tool_count(), 5 + 8);
assert!(p.includes(Family::Graph));
}
#[test]
fn profile_admin_has_eighteen_tools() {
let p = Profile::admin();
assert_eq!(p.expected_tool_count(), 5 + 5 + 8);
}
#[test]
fn profile_power_has_eleven_tools() {
let p = Profile::power();
assert_eq!(p.expected_tool_count(), 5 + 6);
}
#[test]
fn profile_full_has_forty_three_tools() {
let p = Profile::full();
assert_eq!(p.expected_tool_count(), 43);
}
#[test]
fn parse_empty_returns_core() {
assert_eq!(Profile::parse("").unwrap(), Profile::core());
assert_eq!(Profile::parse(" ").unwrap(), Profile::core());
}
#[test]
fn parse_named_profiles() {
assert_eq!(Profile::parse("core").unwrap(), Profile::core());
assert_eq!(Profile::parse("graph").unwrap(), Profile::graph());
assert_eq!(Profile::parse("admin").unwrap(), Profile::admin());
assert_eq!(Profile::parse("power").unwrap(), Profile::power());
assert_eq!(Profile::parse("full").unwrap(), Profile::full());
}
#[test]
fn parse_custom_comma_list_dedup() {
let p = Profile::parse("core,graph").unwrap();
assert!(p.includes(Family::Core));
assert!(!p.includes(Family::Meta));
assert!(p.includes(Family::Graph));
assert_eq!(p.expected_tool_count(), 13);
}
#[test]
fn parse_custom_dedupes_repeated_token() {
let p = Profile::parse("core,core").unwrap();
assert_eq!(p, Profile::core());
}
#[test]
fn parse_custom_with_full_subsumes() {
let p = Profile::parse("graph,full").unwrap();
assert_eq!(p, Profile::full());
}
#[test]
fn parse_custom_implicitly_includes_core() {
let p = Profile::parse("archive").unwrap();
assert!(p.includes(Family::Core));
assert!(p.includes(Family::Archive));
}
#[test]
fn parse_custom_unknown_family_errors() {
let err = Profile::parse("core,xyz").unwrap_err();
match err {
ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
_ => panic!("expected UnknownFamily, got {err:?}"),
}
}
#[test]
fn parse_rejects_mixed_case() {
assert!(matches!(
Profile::parse("Core"),
Err(ProfileParseError::CaseMismatch(_))
));
assert!(matches!(
Profile::parse("core,Graph"),
Err(ProfileParseError::CaseMismatch(_))
));
}
#[test]
fn parse_skips_whitespace_only_tokens() {
let p = Profile::parse("core, ,graph").unwrap();
assert_eq!(p, Profile::graph());
}
#[test]
fn parse_order_independence() {
let a = Profile::parse("core,graph").unwrap();
let b = Profile::parse("graph,core").unwrap();
assert_eq!(a, b);
}
#[test]
fn parse_diagnostic_error_lists_valid_options() {
let err = Profile::parse("xyz").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("core"));
assert!(msg.contains("graph"));
assert!(msg.contains("full"));
assert!(msg.contains("xyz"));
}
#[test]
fn default_is_core() {
assert_eq!(Profile::default(), Profile::core());
}
#[test]
fn family_for_tool_resolves_every_baseline_name() {
let baseline = [
"memory_store",
"memory_recall",
"memory_list",
"memory_get",
"memory_search",
"memory_update",
"memory_delete",
"memory_forget",
"memory_gc",
"memory_promote",
"memory_kg_query",
"memory_kg_timeline",
"memory_kg_invalidate",
"memory_link",
"memory_get_links",
"memory_entity_register",
"memory_entity_get_by_alias",
"memory_get_taxonomy",
"memory_pending_list",
"memory_pending_approve",
"memory_pending_reject",
"memory_namespace_set_standard",
"memory_namespace_get_standard",
"memory_namespace_clear_standard",
"memory_subscribe",
"memory_unsubscribe",
"memory_consolidate",
"memory_detect_contradiction",
"memory_check_duplicate",
"memory_auto_tag",
"memory_expand_query",
"memory_inbox",
"memory_capabilities",
"memory_agent_register",
"memory_agent_list",
"memory_session_start",
"memory_stats",
"memory_archive_list",
"memory_archive_purge",
"memory_archive_restore",
"memory_archive_stats",
"memory_list_subscriptions",
"memory_notify",
];
assert_eq!(baseline.len(), 43, "baseline list itself must be 43");
for name in baseline {
assert!(
Family::for_tool(name).is_some(),
"Family::for_tool({name}) returned None — update the family map"
);
}
}
#[test]
fn family_for_tool_returns_none_for_unknown() {
assert!(Family::for_tool("memory_does_not_exist").is_none());
assert!(Family::for_tool("").is_none());
}
#[test]
fn loads_includes_core_tools_under_core_profile() {
let p = Profile::core();
assert!(p.loads("memory_store"));
assert!(p.loads("memory_recall"));
assert!(!p.loads("memory_kg_query"));
assert!(p.loads("memory_capabilities"));
}
#[test]
fn loads_full_profile_includes_every_tool() {
let p = Profile::full();
for name in [
"memory_store",
"memory_kg_query",
"memory_consolidate",
"memory_archive_list",
"memory_notify",
"memory_capabilities",
] {
assert!(p.loads(name), "full profile should load {name}");
}
}
#[test]
fn loads_unknown_tool_returns_false() {
let p = Profile::full();
assert!(!p.loads("memory_does_not_exist"));
}
#[test]
fn always_on_tools_loaded_in_every_profile() {
for p in [
Profile::core(),
Profile::graph(),
Profile::admin(),
Profile::power(),
Profile::full(),
] {
for name in ALWAYS_ON_TOOLS {
assert!(p.loads(name), "{name} must load in every profile");
}
}
}
}