1use std::str::FromStr;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
60pub enum Family {
61 Core,
63 Lifecycle,
65 Graph,
68 Governance,
71 Power,
74 Meta,
76 Archive,
78 Other,
80}
81
82pub const ALWAYS_ON_TOOLS: &[&str] = &["memory_capabilities"];
88
89impl Family {
90 #[must_use]
97 pub fn for_tool(name: &str) -> Option<Self> {
98 match name {
99 "memory_store" | "memory_recall" | "memory_list" | "memory_get" | "memory_search" => {
101 Some(Self::Core)
102 }
103 "memory_update" | "memory_delete" | "memory_forget" | "memory_gc"
105 | "memory_promote" => Some(Self::Lifecycle),
106 "memory_kg_query"
108 | "memory_kg_timeline"
109 | "memory_kg_invalidate"
110 | "memory_link"
111 | "memory_get_links"
112 | "memory_entity_register"
113 | "memory_entity_get_by_alias"
114 | "memory_get_taxonomy" => Some(Self::Graph),
115 "memory_pending_list"
117 | "memory_pending_approve"
118 | "memory_pending_reject"
119 | "memory_namespace_set_standard"
120 | "memory_namespace_get_standard"
121 | "memory_namespace_clear_standard"
122 | "memory_subscribe"
123 | "memory_unsubscribe" => Some(Self::Governance),
124 "memory_consolidate"
126 | "memory_detect_contradiction"
127 | "memory_check_duplicate"
128 | "memory_auto_tag"
129 | "memory_expand_query"
130 | "memory_inbox" => Some(Self::Power),
131 "memory_capabilities"
133 | "memory_agent_register"
134 | "memory_agent_list"
135 | "memory_session_start"
136 | "memory_stats" => Some(Self::Meta),
137 "memory_archive_list"
139 | "memory_archive_purge"
140 | "memory_archive_restore"
141 | "memory_archive_stats" => Some(Self::Archive),
142 "memory_list_subscriptions" | "memory_notify" => Some(Self::Other),
144 _ => None,
145 }
146 }
147
148 #[must_use]
150 pub const fn name(self) -> &'static str {
151 match self {
152 Self::Core => "core",
153 Self::Lifecycle => "lifecycle",
154 Self::Graph => "graph",
155 Self::Governance => "governance",
156 Self::Power => "power",
157 Self::Meta => "meta",
158 Self::Archive => "archive",
159 Self::Other => "other",
160 }
161 }
162
163 #[must_use]
166 pub const fn all() -> &'static [Family] {
167 &[
168 Self::Core,
169 Self::Lifecycle,
170 Self::Graph,
171 Self::Governance,
172 Self::Power,
173 Self::Meta,
174 Self::Archive,
175 Self::Other,
176 ]
177 }
178
179 #[must_use]
182 pub const fn expected_tool_count(self) -> usize {
183 match self {
184 Self::Core | Self::Lifecycle | Self::Meta => 5,
185 Self::Graph | Self::Governance => 8,
186 Self::Power => 6,
187 Self::Archive => 4,
188 Self::Other => 2,
189 }
190 }
191}
192
193impl FromStr for Family {
194 type Err = ProfileParseError;
195 fn from_str(s: &str) -> Result<Self, Self::Err> {
196 if s.chars().any(|c| c.is_ascii_uppercase()) {
198 return Err(ProfileParseError::CaseMismatch(s.to_string()));
199 }
200 match s {
201 "core" => Ok(Self::Core),
202 "lifecycle" => Ok(Self::Lifecycle),
203 "graph" => Ok(Self::Graph),
204 "governance" => Ok(Self::Governance),
205 "power" => Ok(Self::Power),
206 "meta" => Ok(Self::Meta),
207 "archive" => Ok(Self::Archive),
208 "other" => Ok(Self::Other),
209 unknown => Err(ProfileParseError::UnknownFamily(unknown.to_string())),
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct Profile {
218 families: Vec<Family>,
219}
220
221impl Profile {
222 #[must_use]
233 pub fn core() -> Self {
234 Self {
235 families: vec![Family::Core],
236 }
237 }
238
239 #[must_use]
241 pub fn graph() -> Self {
242 Self {
243 families: vec![Family::Core, Family::Graph],
244 }
245 }
246
247 #[must_use]
249 pub fn admin() -> Self {
250 Self {
251 families: vec![Family::Core, Family::Lifecycle, Family::Governance],
252 }
253 }
254
255 #[must_use]
257 pub fn power() -> Self {
258 Self {
259 families: vec![Family::Core, Family::Power],
260 }
261 }
262
263 #[must_use]
265 pub fn full() -> Self {
266 Self {
267 families: Family::all().to_vec(),
268 }
269 }
270
271 #[must_use]
273 pub fn families(&self) -> &[Family] {
274 &self.families
275 }
276
277 #[must_use]
279 pub fn includes(&self, family: Family) -> bool {
280 self.families.contains(&family)
281 }
282
283 #[must_use]
286 pub fn expected_tool_count(&self) -> usize {
287 self.families.iter().map(|f| f.expected_tool_count()).sum()
288 }
289
290 #[must_use]
295 pub fn loads(&self, tool_name: &str) -> bool {
296 if ALWAYS_ON_TOOLS.contains(&tool_name) {
297 return true;
298 }
299 Family::for_tool(tool_name).is_some_and(|f| self.includes(f))
300 }
301
302 pub fn parse(s: &str) -> Result<Self, ProfileParseError> {
314 let trimmed = s.trim();
315 if trimmed.is_empty() {
316 return Ok(Self::core());
317 }
318
319 if trimmed.chars().any(|c| c.is_ascii_uppercase()) {
323 return Err(ProfileParseError::CaseMismatch(trimmed.to_string()));
324 }
325
326 match trimmed {
328 "core" => return Ok(Self::core()),
329 "graph" => return Ok(Self::graph()),
330 "admin" => return Ok(Self::admin()),
331 "power" => return Ok(Self::power()),
332 "full" => return Ok(Self::full()),
333 _ => {}
334 }
335
336 let mut families = Vec::with_capacity(8);
340 for raw_token in trimmed.split(',') {
341 let token = raw_token.trim();
342 if token.is_empty() {
343 continue;
344 }
345 match token {
347 "core" => merge(&mut families, Self::core().families()),
348 "graph" => merge(&mut families, Self::graph().families()),
349 "admin" => merge(&mut families, Self::admin().families()),
350 "power" => merge(&mut families, Self::power().families()),
351 "full" => return Ok(Self::full()),
352 _ => {
353 let f = Family::from_str(token)?;
354 if !families.contains(&f) {
355 families.push(f);
356 }
357 }
358 }
359 }
360
361 if !families.contains(&Family::Core) {
365 families.insert(0, Family::Core);
366 }
367
368 families.sort_unstable();
372 families.dedup();
373
374 Ok(Self { families })
375 }
376}
377
378impl Default for Profile {
379 fn default() -> Self {
380 Self::core()
381 }
382}
383
384fn merge(dst: &mut Vec<Family>, src: &[Family]) {
385 for f in src {
386 if !dst.contains(f) {
387 dst.push(*f);
388 }
389 }
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum ProfileParseError {
395 UnknownFamily(String),
397 CaseMismatch(String),
400}
401
402impl std::fmt::Display for ProfileParseError {
403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404 match self {
405 Self::UnknownFamily(name) => {
406 let valid: Vec<&str> = Family::all().iter().map(|f| f.name()).collect();
407 let profiles = "core, graph, admin, power, full";
408 write!(
409 f,
410 "unknown profile or family '{name}'. \
411 Valid profiles: {profiles}. \
412 Valid families: {valid}.",
413 valid = valid.join(", ")
414 )
415 }
416 Self::CaseMismatch(s) => {
417 write!(
418 f,
419 "profile '{s}' contains uppercase letters; \
420 profile vocabulary is case-sensitive lowercase \
421 (e.g. 'core', not 'Core')"
422 )
423 }
424 }
425 }
426}
427
428impl std::error::Error for ProfileParseError {}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
437 fn family_all_has_eight_entries() {
438 assert_eq!(Family::all().len(), 8);
439 }
440
441 #[test]
442 fn family_expected_tool_counts_sum_to_43() {
443 let total: usize = Family::all().iter().map(|f| f.expected_tool_count()).sum();
444 assert_eq!(
445 total, 43,
446 "v0.6.3.1 baseline is 43 tools — if this drifts, update \
447 Family::expected_tool_count and the family map docs together"
448 );
449 }
450
451 #[test]
452 fn family_from_str_lowercase_canonical() {
453 assert_eq!(Family::from_str("core").unwrap(), Family::Core);
454 assert_eq!(Family::from_str("meta").unwrap(), Family::Meta);
455 assert_eq!(Family::from_str("graph").unwrap(), Family::Graph);
456 }
457
458 #[test]
459 fn family_from_str_rejects_mixed_case() {
460 assert!(matches!(
461 Family::from_str("Core"),
462 Err(ProfileParseError::CaseMismatch(_))
463 ));
464 assert!(matches!(
465 Family::from_str("CORE"),
466 Err(ProfileParseError::CaseMismatch(_))
467 ));
468 }
469
470 #[test]
471 fn family_from_str_unknown_returns_diagnostic() {
472 let err = Family::from_str("xyz").unwrap_err();
473 match err {
474 ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
475 _ => panic!("expected UnknownFamily, got {err:?}"),
476 }
477 }
478
479 #[test]
482 fn profile_core_has_five_tools() {
483 let p = Profile::core();
484 assert_eq!(p.expected_tool_count(), 5);
485 assert!(p.includes(Family::Core));
486 assert!(!p.includes(Family::Meta));
491 assert!(!p.includes(Family::Lifecycle));
492 }
493
494 #[test]
495 fn profile_graph_has_thirteen_tools() {
496 let p = Profile::graph();
497 assert_eq!(p.expected_tool_count(), 5 + 8);
498 assert!(p.includes(Family::Graph));
499 }
500
501 #[test]
502 fn profile_admin_has_eighteen_tools() {
503 let p = Profile::admin();
504 assert_eq!(p.expected_tool_count(), 5 + 5 + 8);
505 }
506
507 #[test]
508 fn profile_power_has_eleven_tools() {
509 let p = Profile::power();
510 assert_eq!(p.expected_tool_count(), 5 + 6);
511 }
512
513 #[test]
514 fn profile_full_has_forty_three_tools() {
515 let p = Profile::full();
516 assert_eq!(p.expected_tool_count(), 43);
517 }
518
519 #[test]
522 fn parse_empty_returns_core() {
523 assert_eq!(Profile::parse("").unwrap(), Profile::core());
524 assert_eq!(Profile::parse(" ").unwrap(), Profile::core());
525 }
526
527 #[test]
528 fn parse_named_profiles() {
529 assert_eq!(Profile::parse("core").unwrap(), Profile::core());
530 assert_eq!(Profile::parse("graph").unwrap(), Profile::graph());
531 assert_eq!(Profile::parse("admin").unwrap(), Profile::admin());
532 assert_eq!(Profile::parse("power").unwrap(), Profile::power());
533 assert_eq!(Profile::parse("full").unwrap(), Profile::full());
534 }
535
536 #[test]
537 fn parse_custom_comma_list_dedup() {
538 let p = Profile::parse("core,graph").unwrap();
542 assert!(p.includes(Family::Core));
543 assert!(!p.includes(Family::Meta));
544 assert!(p.includes(Family::Graph));
545 assert_eq!(p.expected_tool_count(), 13);
546 }
547
548 #[test]
549 fn parse_custom_dedupes_repeated_token() {
550 let p = Profile::parse("core,core").unwrap();
551 assert_eq!(p, Profile::core());
552 }
553
554 #[test]
555 fn parse_custom_with_full_subsumes() {
556 let p = Profile::parse("graph,full").unwrap();
557 assert_eq!(p, Profile::full());
558 }
559
560 #[test]
561 fn parse_custom_implicitly_includes_core() {
562 let p = Profile::parse("archive").unwrap();
565 assert!(p.includes(Family::Core));
566 assert!(p.includes(Family::Archive));
567 }
568
569 #[test]
570 fn parse_custom_unknown_family_errors() {
571 let err = Profile::parse("core,xyz").unwrap_err();
572 match err {
573 ProfileParseError::UnknownFamily(s) => assert_eq!(s, "xyz"),
574 _ => panic!("expected UnknownFamily, got {err:?}"),
575 }
576 }
577
578 #[test]
579 fn parse_rejects_mixed_case() {
580 assert!(matches!(
581 Profile::parse("Core"),
582 Err(ProfileParseError::CaseMismatch(_))
583 ));
584 assert!(matches!(
585 Profile::parse("core,Graph"),
586 Err(ProfileParseError::CaseMismatch(_))
587 ));
588 }
589
590 #[test]
591 fn parse_skips_whitespace_only_tokens() {
592 let p = Profile::parse("core, ,graph").unwrap();
594 assert_eq!(p, Profile::graph());
595 }
596
597 #[test]
598 fn parse_order_independence() {
599 let a = Profile::parse("core,graph").unwrap();
601 let b = Profile::parse("graph,core").unwrap();
602 assert_eq!(a, b);
603 }
604
605 #[test]
606 fn parse_diagnostic_error_lists_valid_options() {
607 let err = Profile::parse("xyz").unwrap_err();
608 let msg = err.to_string();
609 assert!(msg.contains("core"));
612 assert!(msg.contains("graph"));
613 assert!(msg.contains("full"));
614 assert!(msg.contains("xyz"));
615 }
616
617 #[test]
618 fn default_is_core() {
619 assert_eq!(Profile::default(), Profile::core());
620 }
621
622 #[test]
625 fn family_for_tool_resolves_every_baseline_name() {
626 let baseline = [
630 "memory_store",
632 "memory_recall",
633 "memory_list",
634 "memory_get",
635 "memory_search",
636 "memory_update",
638 "memory_delete",
639 "memory_forget",
640 "memory_gc",
641 "memory_promote",
642 "memory_kg_query",
644 "memory_kg_timeline",
645 "memory_kg_invalidate",
646 "memory_link",
647 "memory_get_links",
648 "memory_entity_register",
649 "memory_entity_get_by_alias",
650 "memory_get_taxonomy",
651 "memory_pending_list",
653 "memory_pending_approve",
654 "memory_pending_reject",
655 "memory_namespace_set_standard",
656 "memory_namespace_get_standard",
657 "memory_namespace_clear_standard",
658 "memory_subscribe",
659 "memory_unsubscribe",
660 "memory_consolidate",
662 "memory_detect_contradiction",
663 "memory_check_duplicate",
664 "memory_auto_tag",
665 "memory_expand_query",
666 "memory_inbox",
667 "memory_capabilities",
669 "memory_agent_register",
670 "memory_agent_list",
671 "memory_session_start",
672 "memory_stats",
673 "memory_archive_list",
675 "memory_archive_purge",
676 "memory_archive_restore",
677 "memory_archive_stats",
678 "memory_list_subscriptions",
680 "memory_notify",
681 ];
682 assert_eq!(baseline.len(), 43, "baseline list itself must be 43");
683 for name in baseline {
684 assert!(
685 Family::for_tool(name).is_some(),
686 "Family::for_tool({name}) returned None — update the family map"
687 );
688 }
689 }
690
691 #[test]
692 fn family_for_tool_returns_none_for_unknown() {
693 assert!(Family::for_tool("memory_does_not_exist").is_none());
694 assert!(Family::for_tool("").is_none());
695 }
696
697 #[test]
698 fn loads_includes_core_tools_under_core_profile() {
699 let p = Profile::core();
700 assert!(p.loads("memory_store"));
701 assert!(p.loads("memory_recall"));
702 assert!(!p.loads("memory_kg_query"));
703 assert!(p.loads("memory_capabilities"));
705 }
706
707 #[test]
708 fn loads_full_profile_includes_every_tool() {
709 let p = Profile::full();
710 for name in [
712 "memory_store",
713 "memory_kg_query",
714 "memory_consolidate",
715 "memory_archive_list",
716 "memory_notify",
717 "memory_capabilities",
718 ] {
719 assert!(p.loads(name), "full profile should load {name}");
720 }
721 }
722
723 #[test]
724 fn loads_unknown_tool_returns_false() {
725 let p = Profile::full();
726 assert!(!p.loads("memory_does_not_exist"));
727 }
728
729 #[test]
730 fn always_on_tools_loaded_in_every_profile() {
731 for p in [
732 Profile::core(),
733 Profile::graph(),
734 Profile::admin(),
735 Profile::power(),
736 Profile::full(),
737 ] {
738 for name in ALWAYS_ON_TOOLS {
739 assert!(p.loads(name), "{name} must load in every profile");
740 }
741 }
742 }
743}