Skip to main content

open_agent_profile/
composition.rs

1use serde_json::Value;
2use thiserror::Error;
3
4use crate::{AgentProfile, Document, object, spec_digest};
5
6/// Pinned reference supplied to a profile composition loader.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ProfileReference {
9    /// Referenced profile name.
10    pub name: String,
11    /// Referenced profile URI.
12    pub uri: String,
13    /// Required metadata revision.
14    pub revision: u64,
15    /// Required canonical profile digest.
16    pub digest: String,
17}
18
19/// Failure while resolving inherited profiles.
20#[derive(Debug, Error)]
21pub enum CompositionError {
22    /// An inheritance cycle was detected.
23    #[error("inheritance cycle")]
24    Cycle,
25    /// The maximum inheritance depth was exceeded.
26    #[error("inheritance depth exceeds 8")]
27    Depth,
28    /// The loader could not retrieve a referenced profile.
29    #[error("{0}")]
30    Load(String),
31    /// A referenced profile did not match its pinned revision.
32    #[error("{0} revision does not match pin")]
33    Revision(String),
34    /// A referenced profile did not match its pinned digest.
35    #[error("{0} digest does not match pin")]
36    Digest(String),
37    /// A composed profile omitted its required name.
38    #[error("composed profile has no name")]
39    MissingName,
40}
41
42/// Deep-merges a child profile over a base using OAP composition semantics.
43pub fn merge_profile_values(base: &Document, child: &Document) -> Document {
44    let mut result = base.clone();
45    for (key, value) in child {
46        if value.is_null() {
47            result.remove(key);
48        } else if let (Some(left), Some(right)) = (
49            result.get(key).and_then(Value::as_object),
50            value.as_object(),
51        ) {
52            result.insert(
53                key.clone(),
54                Value::Object(merge_profile_values(left, right)),
55            );
56        } else {
57            result.insert(key.clone(), value.clone());
58        }
59    }
60    result
61}
62
63/// Resolves and verifies an `extends` chain with the supplied profile loader.
64pub fn resolve_composition<F>(
65    profile: &AgentProfile,
66    mut load: F,
67) -> Result<AgentProfile, CompositionError>
68where
69    F: FnMut(&ProfileReference) -> Result<AgentProfile, CompositionError>,
70{
71    fn resolve<F>(
72        profile: &AgentProfile,
73        load: &mut F,
74        active: &mut Vec<String>,
75    ) -> Result<AgentProfile, CompositionError>
76    where
77        F: FnMut(&ProfileReference) -> Result<AgentProfile, CompositionError>,
78    {
79        let name = object(profile.get("metadata"))
80            .get("name")
81            .and_then(Value::as_str)
82            .unwrap_or_default()
83            .to_owned();
84        if active.contains(&name) {
85            return Err(CompositionError::Cycle);
86        }
87        if active.len() >= 8 {
88            return Err(CompositionError::Depth);
89        }
90        active.push(name.clone());
91        let mut merged = Document::new();
92        for raw in profile
93            .get("extends")
94            .and_then(Value::as_array)
95            .into_iter()
96            .flatten()
97        {
98            let value = object(Some(raw));
99            let reference = ProfileReference {
100                name: value
101                    .get("name")
102                    .and_then(Value::as_str)
103                    .unwrap_or_default()
104                    .into(),
105                uri: value
106                    .get("uri")
107                    .and_then(Value::as_str)
108                    .unwrap_or_default()
109                    .into(),
110                revision: value.get("revision").and_then(Value::as_u64).unwrap_or(0),
111                digest: value
112                    .get("digest")
113                    .and_then(Value::as_str)
114                    .unwrap_or_default()
115                    .into(),
116            };
117            let base = load(&reference)?;
118            if reference.revision != 0
119                && object(base.get("metadata"))
120                    .get("revision")
121                    .and_then(Value::as_u64)
122                    != Some(reference.revision)
123            {
124                return Err(CompositionError::Revision(reference.name));
125            }
126            if !reference.digest.is_empty()
127                && spec_digest(&base).map_err(|error| CompositionError::Load(error.to_string()))?
128                    != reference.digest
129            {
130                return Err(CompositionError::Digest(reference.name));
131            }
132            let mut resolved = resolve(&base, load, active)?;
133            for key in ["extends", "state", "history"] {
134                resolved.remove(key);
135            }
136            if let Some(metadata) = resolved.get_mut("metadata").and_then(Value::as_object_mut) {
137                for key in ["name", "id", "revision"] {
138                    metadata.remove(key);
139                }
140            }
141            merged = merge_profile_values(&merged, &resolved);
142        }
143        active.pop();
144        merged = merge_profile_values(&merged, profile);
145        merged.insert(
146            "metadata".into(),
147            profile.get("metadata").cloned().unwrap_or_default(),
148        );
149        for key in ["state", "history"] {
150            if let Some(value) = profile.get(key) {
151                merged.insert(key.into(), value.clone());
152            } else {
153                merged.remove(key);
154            }
155        }
156        merged.remove("extends");
157        if object(merged.get("metadata"))
158            .get("name")
159            .and_then(Value::as_str)
160            .unwrap_or_default()
161            .is_empty()
162        {
163            return Err(CompositionError::MissingName);
164        }
165        Ok(merged)
166    }
167    resolve(profile, &mut load, &mut vec![])
168}