1use std::collections::HashSet;
22use std::fmt;
23use std::path::{Path, PathBuf};
24
25use oxibrain_core::extraction::ExtractMechanism;
26use oxibrain_ports::LlmCapabilities;
27use serde::{Deserialize, Serialize};
28
29pub const SCHEMA_VERSION: u32 = 1;
34
35#[allow(dead_code)]
41pub const ALLOWED_ROLES: &[&str] = &[
42 "memory.extract",
43 "memory.consolidate",
44 "coding.primary",
45 "assistant.general",
46];
47
48pub const SECRET_FIELD_NAMES: &[&str] = &[
52 "api_key",
53 "apikey",
54 "api-token",
55 "bearer",
56 "access_token",
57 "refresh_token",
58 "secret",
59 "password",
60 "private_key",
61];
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum ProfileRole {
70 #[serde(rename = "memory.extract")]
71 MemoryExtract,
72 #[serde(rename = "memory.consolidate")]
73 MemoryConsolidate,
74 #[serde(rename = "coding.primary")]
75 CodingPrimary,
76 #[serde(rename = "assistant.general")]
77 AssistantGeneral,
78}
79
80impl ProfileRole {
81 #[allow(dead_code)]
85 pub fn as_str(self) -> &'static str {
86 match self {
87 ProfileRole::MemoryExtract => "memory.extract",
88 ProfileRole::MemoryConsolidate => "memory.consolidate",
89 ProfileRole::CodingPrimary => "coding.primary",
90 ProfileRole::AssistantGeneral => "assistant.general",
91 }
92 }
93
94 pub fn parse(s: &str) -> Option<Self> {
95 Some(match s {
96 "memory.extract" => ProfileRole::MemoryExtract,
97 "memory.consolidate" => ProfileRole::MemoryConsolidate,
98 "coding.primary" => ProfileRole::CodingPrimary,
99 "assistant.general" => ProfileRole::AssistantGeneral,
100 _ => return None,
101 })
102 }
103}
104
105impl fmt::Display for ProfileRole {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 f.write_str(self.as_str())
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct SecretLocator {
116 pub service: String,
117 pub account: String,
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(default, deny_unknown_fields)]
126pub struct DeclaredCapabilities {
127 pub grammar: bool,
128 pub structured_output: bool,
129 pub tool_call: bool,
130 pub json_schema: bool,
131}
132
133impl DeclaredCapabilities {
134 pub fn satisfies(&self, mechanism: ExtractMechanism) -> bool {
140 match mechanism {
141 ExtractMechanism::Grammar => self.grammar,
142 ExtractMechanism::JsonSchema => self.json_schema || self.structured_output,
143 ExtractMechanism::ToolCall => self.tool_call,
144 ExtractMechanism::JsonMode => true,
145 }
146 }
147
148 #[allow(dead_code, clippy::wrong_self_convention)]
153 pub fn as_llm_capabilities(self) -> LlmCapabilities {
154 LlmCapabilities {
155 grammar: self.grammar,
156 structured_output: self.structured_output,
157 tool_call: self.tool_call,
158 json_schema: self.json_schema,
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct ProviderProfile {
167 pub id: String,
168 pub provider: String,
169 pub model: String,
170 pub roles: Vec<ProfileRole>,
171 pub credential: SecretLocator,
172 #[serde(default)]
178 pub capabilities: DeclaredCapabilities,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct FoundationProfiles {
184 pub schema_version: u32,
185 pub profiles: Vec<ProviderProfile>,
186}
187
188#[derive(Debug, Clone)]
190pub struct ResolvedProfiles {
191 pub profiles: Vec<ProviderProfile>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum FoundationError {
201 UnsupportedSchemaVersion(u32),
203 InvalidShape(String),
205 SecretFieldPresent(String),
207 DuplicateProfileId(String),
209 #[allow(dead_code)]
216 UnknownRole(String),
217 DuplicateRole(ProfileRole),
219 EmptyField(&'static str),
221 EmptyRoles,
223 IoError(String),
225 SecretUnavailable {
231 service: String,
232 account: String,
233 reason: String,
234 },
235 #[allow(dead_code)]
244 CapabilityUnsatisfied {
245 profile_id: String,
246 mechanism: ExtractMechanism,
247 },
248 #[allow(dead_code)]
254 RoleDenied {
255 profile_id: String,
256 requested: ProfileRole,
257 },
258}
259
260impl fmt::Display for FoundationError {
261 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262 match self {
263 FoundationError::UnsupportedSchemaVersion(v) => {
264 write!(
265 f,
266 "profiles.json schema_version={v} is not supported (expected 1)"
267 )
268 }
269 FoundationError::InvalidShape(detail) => {
270 write!(f, "profiles.json shape invalid: {detail}")
271 }
272 FoundationError::SecretFieldPresent(field) => write!(
273 f,
274 "profiles.json rejected: carries secret-shaped field `{field}` (§2.5)"
275 ),
276 FoundationError::DuplicateProfileId(id) => {
277 write!(f, "profiles.json rejected: duplicate profile id `{id}`")
278 }
279 FoundationError::UnknownRole(role) => write!(
280 f,
281 "profiles.json rejected: role `{role}` is not one of memory.extract / memory.consolidate / coding.primary / assistant.general"
282 ),
283 FoundationError::DuplicateRole(role) => write!(
284 f,
285 "profiles.json rejected: role `{role}` appears twice in the same profile"
286 ),
287 FoundationError::EmptyField(field) => write!(
288 f,
289 "profiles.json rejected: field `{field}` is the empty string"
290 ),
291 FoundationError::EmptyRoles => {
292 write!(f, "profiles.json rejected: a profile lists no roles")
293 }
294 FoundationError::IoError(detail) => write!(f, "profiles.json I/O error: {detail}"),
295 FoundationError::SecretUnavailable {
296 service,
297 account,
298 reason,
299 } => write!(
300 f,
301 "Foundation profile secret unavailable (Keychain service=`{service}` account=`{account}`): {reason}"
302 ),
303 FoundationError::CapabilityUnsatisfied {
304 profile_id,
305 mechanism,
306 } => write!(
307 f,
308 "Foundation profile `{profile_id}` rejected: declared capabilities do not satisfy extraction mechanism {mechanism:?}"
309 ),
310 FoundationError::RoleDenied {
311 profile_id,
312 requested,
313 } => write!(
314 f,
315 "Foundation profile `{profile_id}` rejected: does not declare role `{requested}`"
316 ),
317 }
318 }
319}
320
321impl std::error::Error for FoundationError {}
322
323pub trait SecretResolver: Send + Sync {
330 fn resolve(&self, locator: &SecretLocator) -> Result<String, FoundationError>;
334}
335
336#[derive(Debug, Default, Clone)]
341pub struct InMemorySecretResolver {
342 entries: std::collections::HashMap<(String, String), String>,
343}
344
345impl InMemorySecretResolver {
346 #[allow(dead_code)]
350 pub fn new() -> Self {
351 Self::default()
352 }
353
354 #[allow(dead_code)]
360 pub fn with_secret(
361 mut self,
362 service: impl Into<String>,
363 account: impl Into<String>,
364 secret: impl Into<String>,
365 ) -> Self {
366 self.entries
367 .insert((service.into(), account.into()), secret.into());
368 self
369 }
370}
371
372impl SecretResolver for InMemorySecretResolver {
373 fn resolve(&self, locator: &SecretLocator) -> Result<String, FoundationError> {
374 self.entries
375 .get(&(locator.service.clone(), locator.account.clone()))
376 .cloned()
377 .ok_or_else(|| FoundationError::SecretUnavailable {
378 service: locator.service.clone(),
379 account: locator.account.clone(),
380 reason: "no entry in InMemorySecretResolver (test default)".into(),
381 })
382 }
383}
384
385#[cfg(feature = "os-keychain")]
395pub struct OsKeychainResolver {
396 service_prefix: String,
397}
398
399#[cfg(feature = "os-keychain")]
400impl OsKeychainResolver {
401 pub fn new() -> Self {
402 Self {
403 service_prefix: "oxibrain/foundation/v1/".to_string(),
404 }
405 }
406
407 #[allow(dead_code)]
411 pub fn with_service_prefix(prefix: impl Into<String>) -> Self {
412 Self {
413 service_prefix: prefix.into(),
414 }
415 }
416}
417
418#[cfg(feature = "os-keychain")]
419impl Default for OsKeychainResolver {
420 fn default() -> Self {
421 Self::new()
422 }
423}
424
425#[cfg(feature = "os-keychain")]
426impl SecretResolver for OsKeychainResolver {
427 fn resolve(&self, locator: &SecretLocator) -> Result<String, FoundationError> {
428 use std::collections::BTreeMap;
429 thread_local! {
432 static CACHE: std::cell::RefCell<BTreeMap<(String, String), Result<String, String>>> =
433 const { std::cell::RefCell::new(BTreeMap::new()) };
434 }
435 let service = format!("{}{}", self.service_prefix, locator.service);
436 let key = (service.clone(), locator.account.clone());
437
438 CACHE.with(|cache| {
439 if let Some(cached) = cache.borrow().get(&key) {
440 return cached
441 .clone()
442 .map_err(|reason| FoundationError::SecretUnavailable {
443 service: locator.service.clone(),
444 account: locator.account.clone(),
445 reason,
446 });
447 }
448 let entry = keyring::Entry::new(&service, &locator.account);
449 let outcome = match entry.and_then(|e| e.get_password()) {
450 Ok(secret) => Ok(secret),
451 Err(e) => Err(e.to_string()),
452 };
453 cache.borrow_mut().insert(key, outcome.clone());
454 outcome.map_err(|reason| FoundationError::SecretUnavailable {
455 service: locator.service.clone(),
456 account: locator.account.clone(),
457 reason,
458 })
459 })
460 }
461}
462
463pub fn default_secret_resolver() -> Box<dyn SecretResolver> {
471 #[cfg(feature = "os-keychain")]
472 {
473 Box::new(OsKeychainResolver::new())
474 }
475 #[cfg(not(feature = "os-keychain"))]
476 {
477 Box::new(InMemorySecretResolver::new())
478 }
479}
480
481pub fn foundation_home() -> PathBuf {
489 if let Some(home) = std::env::var_os("OXI_FOUNDATION_HOME") {
490 PathBuf::from(home)
491 } else if let Some(home) = std::env::var_os("HOME") {
492 PathBuf::from(home)
493 .join(".oxi")
494 .join("foundation")
495 .join("v1")
496 } else {
497 PathBuf::from(".oxi").join("foundation").join("v1")
498 }
499}
500
501fn profiles_path(home: &Path) -> PathBuf {
502 home.join("profiles.json")
503}
504
505pub fn load_profiles(home: &Path) -> Result<Option<ResolvedProfiles>, FoundationError> {
515 let path = profiles_path(home);
516 let bytes = match std::fs::read(&path) {
517 Ok(b) => b,
518 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
519 Err(e) => {
520 return Err(FoundationError::IoError(format!("{}: {e}", path.display())));
521 }
522 };
523
524 let raw: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
528 FoundationError::InvalidShape(format!("profiles.json is not valid JSON: {e}"))
529 })?;
530
531 let obj = raw
532 .as_object()
533 .ok_or_else(|| FoundationError::InvalidShape("root is not a JSON object".into()))?;
534
535 for profile_value in obj
539 .get("profiles")
540 .and_then(|p| p.as_array())
541 .ok_or_else(|| FoundationError::InvalidShape("`profiles` is not an array".into()))?
542 {
543 let profile_obj = profile_value.as_object().ok_or_else(|| {
544 FoundationError::InvalidShape("a profile entry is not a JSON object".into())
545 })?;
546 for key in profile_obj.keys() {
547 if SECRET_FIELD_NAMES.iter().any(|s| s == key) {
548 return Err(FoundationError::SecretFieldPresent(key.clone()));
549 }
550 }
551 }
552
553 let parsed: FoundationProfiles = serde_json::from_value(raw)
555 .map_err(|e| FoundationError::InvalidShape(format!("profiles.json: {e}")))?;
556
557 if parsed.schema_version != SCHEMA_VERSION {
558 return Err(FoundationError::UnsupportedSchemaVersion(
559 parsed.schema_version,
560 ));
561 }
562
563 let mut seen_ids: HashSet<String> = HashSet::new();
565 for profile in &parsed.profiles {
566 if profile.id.is_empty() {
567 return Err(FoundationError::EmptyField("id"));
568 }
569 if profile.provider.is_empty() {
570 return Err(FoundationError::EmptyField("provider"));
571 }
572 if profile.model.is_empty() {
573 return Err(FoundationError::EmptyField("model"));
574 }
575 if profile.credential.service.is_empty() {
576 return Err(FoundationError::EmptyField("credential.service"));
577 }
578 if profile.credential.account.is_empty() {
579 return Err(FoundationError::EmptyField("credential.account"));
580 }
581 if !seen_ids.insert(profile.id.clone()) {
582 return Err(FoundationError::DuplicateProfileId(profile.id.clone()));
583 }
584 if profile.roles.is_empty() {
585 return Err(FoundationError::EmptyRoles);
586 }
587 let mut seen_roles: HashSet<ProfileRole> = HashSet::new();
588 for role in &profile.roles {
589 if !seen_roles.insert(*role) {
590 return Err(FoundationError::DuplicateRole(*role));
591 }
592 }
593 }
594
595 Ok(Some(ResolvedProfiles {
596 profiles: parsed.profiles,
597 }))
598}
599
600impl ResolvedProfiles {
601 #[allow(dead_code)]
611 pub fn pick_for_role(
612 &self,
613 role: ProfileRole,
614 mechanism: ExtractMechanism,
615 ) -> Result<&ProviderProfile, FoundationError> {
616 for profile in &self.profiles {
617 if !profile.roles.contains(&role) {
618 continue;
619 }
620 if !profile.capabilities.clone().satisfies(mechanism) {
621 return Err(FoundationError::CapabilityUnsatisfied {
622 profile_id: profile.id.clone(),
623 mechanism,
624 });
625 }
626 return Ok(profile);
627 }
628 Err(FoundationError::RoleDenied {
629 profile_id: self
632 .profiles
633 .first()
634 .map(|p| p.id.clone())
635 .unwrap_or_default(),
636 requested: role,
637 })
638 }
639
640 pub fn iter(&self) -> std::slice::Iter<'_, ProviderProfile> {
642 self.profiles.iter()
643 }
644}
645
646#[derive(Debug, Clone, Copy, PartialEq, Eq)]
654pub enum ProviderKind {
655 Anthropic,
656 OpenAi,
657}
658
659impl ProviderKind {
660 pub fn parse(s: &str) -> Option<Self> {
661 Some(match s {
662 "anthropic" | "claude" => ProviderKind::Anthropic,
663 "openai" | "gpt" => ProviderKind::OpenAi,
664 _ => return None,
665 })
666 }
667
668 #[allow(dead_code)]
673 pub fn as_str(self) -> &'static str {
674 match self {
675 ProviderKind::Anthropic => "anthropic",
676 ProviderKind::OpenAi => "openai",
677 }
678 }
679}
680
681#[cfg(test)]
684mod tests {
685 use super::*;
686
687 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
693
694 fn write_profiles(dir: &Path, body: &str) {
695 std::fs::create_dir_all(dir).unwrap();
696 std::fs::write(dir.join("profiles.json"), body).unwrap();
697 }
698
699 #[test]
700 fn missing_file_is_not_an_error() {
701 let dir = tempfile::tempdir().unwrap();
702 let got = load_profiles(dir.path()).unwrap();
703 assert!(got.is_none());
704 }
705
706 #[test]
707 fn rejects_secret_shaped_fields() {
708 let dir = tempfile::tempdir().unwrap();
709 write_profiles(
710 dir.path(),
711 r#"{
712 "schema_version": 1,
713 "profiles": [
714 {
715 "id": "leaky",
716 "provider": "anthropic",
717 "model": "claude-sonnet-4-5",
718 "roles": ["memory.extract"],
719 "credential": {"service": "oxibrain", "account": "a7"},
720 "api_key": "sk-test"
721 }
722 ]
723 }"#,
724 );
725 let err = load_profiles(dir.path()).unwrap_err();
726 assert!(matches!(&err, FoundationError::SecretFieldPresent(f) if f == "api_key"));
727 }
728
729 #[test]
730 fn rejects_each_secret_field_by_name() {
731 for field in SECRET_FIELD_NAMES {
732 let dir = tempfile::tempdir().unwrap();
733 let body = format!(
734 r#"{{"schema_version":1,"profiles":[{{"id":"p","provider":"anthropic","model":"m","roles":["memory.extract"],"credential":{{"service":"s","account":"a"}},"{field}":"x"}}]}}"#
735 );
736 write_profiles(dir.path(), &body);
737 let err = load_profiles(dir.path()).unwrap_err();
738 assert!(
739 matches!(&err, FoundationError::SecretFieldPresent(f) if f == field),
740 "expected SecretFieldPresent({field}), got {err:?}"
741 );
742 }
743 }
744
745 #[test]
746 fn rejects_unsupported_schema_version() {
747 let dir = tempfile::tempdir().unwrap();
748 write_profiles(dir.path(), r#"{"schema_version":2,"profiles":[]}"#);
749 assert!(matches!(
750 load_profiles(dir.path()),
751 Err(FoundationError::UnsupportedSchemaVersion(2))
752 ));
753 }
754
755 #[test]
756 fn empty_profiles_array_is_valid() {
757 let dir = tempfile::tempdir().unwrap();
758 write_profiles(dir.path(), r#"{"schema_version":1,"profiles":[]}"#);
759 let got = load_profiles(dir.path()).unwrap().unwrap();
760 assert!(got.profiles.is_empty());
761 }
762
763 #[test]
764 fn rejects_duplicate_profile_id() {
765 let dir = tempfile::tempdir().unwrap();
766 let body = r#"{
767 "schema_version": 1,
768 "profiles": [
769 {"id":"same","provider":"anthropic","model":"m","roles":["memory.extract"],"credential":{"service":"s","account":"a"}},
770 {"id":"same","provider":"openai","model":"m","roles":["memory.extract"],"credential":{"service":"s","account":"a"}}
771 ]
772 }"#;
773 write_profiles(dir.path(), body);
774 assert!(matches!(
775 &load_profiles(dir.path()),
776 Err(FoundationError::DuplicateProfileId(id)) if id == "same"
777 ));
778 }
779
780 #[test]
781 fn rejects_unknown_role() {
782 let dir = tempfile::tempdir().unwrap();
783 let body = r#"{
784 "schema_version": 1,
785 "profiles": [
786 {"id":"p","provider":"anthropic","model":"m","roles":["memory.unknown"],"credential":{"service":"s","account":"a"}}
787 ]
788 }"#;
789 write_profiles(dir.path(), body);
790 let err = load_profiles(dir.path()).unwrap_err();
791 assert!(matches!(err, FoundationError::InvalidShape(_)));
794 }
795
796 #[test]
797 fn rejects_empty_roles() {
798 let dir = tempfile::tempdir().unwrap();
799 let body = r#"{
800 "schema_version": 1,
801 "profiles": [
802 {"id":"p","provider":"anthropic","model":"m","roles":[],"credential":{"service":"s","account":"a"}}
803 ]
804 }"#;
805 write_profiles(dir.path(), body);
806 assert!(matches!(
807 &load_profiles(dir.path()),
808 Err(FoundationError::EmptyRoles)
809 ));
810 }
811
812 #[test]
813 fn rejects_duplicate_role_in_profile() {
814 let dir = tempfile::tempdir().unwrap();
815 let body = r#"{
816 "schema_version": 1,
817 "profiles": [
818 {"id":"p","provider":"anthropic","model":"m","roles":["memory.extract","memory.extract"],"credential":{"service":"s","account":"a"}}
819 ]
820 }"#;
821 write_profiles(dir.path(), body);
822 assert!(matches!(
823 &load_profiles(dir.path()),
824 Err(FoundationError::DuplicateRole(ProfileRole::MemoryExtract))
825 ));
826 }
827
828 #[test]
829 fn rejects_empty_provider_or_model() {
830 let dir = tempfile::tempdir().unwrap();
831 let body = r#"{
832 "schema_version": 1,
833 "profiles": [
834 {"id":"p","provider":"","model":"m","roles":["memory.extract"],"credential":{"service":"s","account":"a"}}
835 ]
836 }"#;
837 write_profiles(dir.path(), body);
838 assert!(matches!(
839 &load_profiles(dir.path()),
840 Err(FoundationError::EmptyField("provider"))
841 ));
842 }
843
844 #[test]
845 fn rejects_empty_credential_locator() {
846 let dir = tempfile::tempdir().unwrap();
847 let body = r#"{
848 "schema_version": 1,
849 "profiles": [
850 {"id":"p","provider":"anthropic","model":"m","roles":["memory.extract"],"credential":{"service":"","account":"a"}}
851 ]
852 }"#;
853 write_profiles(dir.path(), body);
854 assert!(matches!(
855 &load_profiles(dir.path()),
856 Err(FoundationError::EmptyField("credential.service"))
857 ));
858 }
859
860 #[test]
861 fn accepts_well_formed_canonical_profile() {
862 let dir = tempfile::tempdir().unwrap();
863 let body = r#"{
864 "schema_version": 1,
865 "profiles": [
866 {
867 "id": "work-summariser",
868 "provider": "anthropic",
869 "model": "claude-sonnet-4-5",
870 "roles": ["memory.consolidate", "assistant.general"],
871 "credential": {"service": "oxibrain", "account": "work"}
872 }
873 ]
874 }"#;
875 write_profiles(dir.path(), body);
876 let got = load_profiles(dir.path()).unwrap().unwrap();
877 assert_eq!(got.profiles.len(), 1);
878 assert_eq!(got.profiles[0].id, "work-summariser");
879 assert_eq!(got.profiles[0].provider, "anthropic");
880 assert_eq!(
881 got.profiles[0].roles,
882 vec![
883 ProfileRole::MemoryConsolidate,
884 ProfileRole::AssistantGeneral
885 ]
886 );
887 }
888
889 #[test]
890 fn pick_for_role_skips_non_members() {
891 let dir = tempfile::tempdir().unwrap();
892 let body = r#"{
893 "schema_version": 1,
894 "profiles": [
895 {"id":"a","provider":"anthropic","model":"m","roles":["coding.primary"],"credential":{"service":"s","account":"a"}},
896 {"id":"b","provider":"openai","model":"m","roles":["memory.extract"],"credential":{"service":"s","account":"b"},"capabilities":{"grammar":false,"structured_output":true,"tool_call":true,"json_schema":true}}
897 ]
898 }"#;
899 write_profiles(dir.path(), body);
900 let got = load_profiles(dir.path()).unwrap().unwrap();
901 let pick = got
902 .pick_for_role(ProfileRole::MemoryExtract, ExtractMechanism::JsonSchema)
903 .unwrap();
904 assert_eq!(pick.id, "b");
905 }
906
907 #[test]
908 fn pick_for_role_rejects_when_capabilities_unsatisfy() {
909 let dir = tempfile::tempdir().unwrap();
910 let body = r#"{
913 "schema_version": 1,
914 "profiles": [
915 {
916 "id":"constrained",
917 "provider":"anthropic",
918 "model":"m",
919 "roles":["memory.extract"],
920 "credential":{"service":"s","account":"a"},
921 "capabilities":{"grammar":true,"structured_output":false,"tool_call":false,"json_schema":false}
922 }
923 ]
924 }"#;
925 write_profiles(dir.path(), body);
926 let got = load_profiles(dir.path()).unwrap().unwrap();
927 let err = got
928 .pick_for_role(ProfileRole::MemoryExtract, ExtractMechanism::JsonSchema)
929 .unwrap_err();
930 assert!(
931 matches!(&err, FoundationError::CapabilityUnsatisfied { profile_id, .. } if profile_id == "constrained")
932 );
933 }
934
935 #[test]
936 fn pick_for_role_role_denied_when_no_match() {
937 let dir = tempfile::tempdir().unwrap();
938 let body = r#"{
939 "schema_version": 1,
940 "profiles": [
941 {"id":"a","provider":"anthropic","model":"m","roles":["coding.primary"],"credential":{"service":"s","account":"a"}}
942 ]
943 }"#;
944 write_profiles(dir.path(), body);
945 let got = load_profiles(dir.path()).unwrap().unwrap();
946 let err = got
947 .pick_for_role(ProfileRole::MemoryExtract, ExtractMechanism::JsonSchema)
948 .unwrap_err();
949 assert!(matches!(
950 &err,
951 FoundationError::RoleDenied {
952 requested: ProfileRole::MemoryExtract,
953 ..
954 }
955 ));
956 }
957
958 #[test]
959 fn in_memory_resolver_hits_and_misses() {
960 let resolver =
961 InMemorySecretResolver::new().with_secret("oxibrain", "work", "secret-value");
962 let hit = resolver
963 .resolve(&SecretLocator {
964 service: "oxibrain".into(),
965 account: "work".into(),
966 })
967 .unwrap();
968 assert_eq!(hit, "secret-value");
969 let miss = resolver.resolve(&SecretLocator {
970 service: "oxibrain".into(),
971 account: "missing".into(),
972 });
973 assert!(matches!(
974 miss,
975 Err(FoundationError::SecretUnavailable { .. })
976 ));
977 }
978
979 #[test]
980 fn foundation_home_uses_env_when_set() {
981 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
988 let saved = std::env::var_os("OXI_FOUNDATION_HOME");
989 unsafe {
991 std::env::set_var("OXI_FOUNDATION_HOME", "/tmp/foundation-test-home");
992 }
993 let got = foundation_home();
994 unsafe {
996 match saved {
997 Some(v) => std::env::set_var("OXI_FOUNDATION_HOME", v),
998 None => std::env::remove_var("OXI_FOUNDATION_HOME"),
999 }
1000 }
1001 assert_eq!(got, PathBuf::from("/tmp/foundation-test-home"));
1002 }
1003
1004 #[test]
1005 fn declared_capabilities_satisfy() {
1006 let caps = DeclaredCapabilities {
1007 tool_call: true,
1008 ..DeclaredCapabilities::default()
1009 };
1010 assert!(caps.clone().satisfies(ExtractMechanism::ToolCall));
1011 assert!(!caps.satisfies(ExtractMechanism::JsonSchema));
1012 assert!(!caps.satisfies(ExtractMechanism::Grammar));
1013 }
1014
1015 #[test]
1016 fn openai_profile_with_only_json_schema_passes_capability_check() {
1017 let dir = tempfile::tempdir().unwrap();
1023 let body = r#"{
1024 "schema_version": 1,
1025 "profiles": [
1026 {
1027 "id": "openai-json",
1028 "provider": "openai",
1029 "model": "gpt-4o",
1030 "roles": ["memory.extract"],
1031 "credential": {"service": "oxibrain", "account": "openai"},
1032 "capabilities": {"grammar": false, "structured_output": false, "tool_call": false, "json_schema": true}
1033 }
1034 ]
1035 }"#;
1036 write_profiles(dir.path(), body);
1037 let got = load_profiles(dir.path())
1038 .unwrap()
1039 .expect("profiles present");
1040 let pick = got
1047 .pick_for_role(ProfileRole::MemoryExtract, ExtractMechanism::JsonSchema)
1048 .unwrap();
1049 assert_eq!(pick.id, "openai-json");
1050 }
1051
1052 #[test]
1053 fn role_round_trip() {
1054 for role in [
1055 ProfileRole::MemoryExtract,
1056 ProfileRole::MemoryConsolidate,
1057 ProfileRole::CodingPrimary,
1058 ProfileRole::AssistantGeneral,
1059 ] {
1060 assert_eq!(ProfileRole::parse(role.as_str()), Some(role));
1061 }
1062 assert!(ProfileRole::parse("memory.unknown").is_none());
1063 }
1064}