tilo_app/framework/
rows.rs1use tilo_actions::{
7 ActionSpec, OperationKind, Resource, ResourcePresentation, ResourceSemanticIdentity,
8};
9use tilo_resource::Effect;
10
11#[derive(Clone)]
26pub struct LiveRow {
27 resource: Resource,
28}
29
30impl LiveRow {
31 pub fn new(id: impl Into<String>, kind: impl Into<String>, label: impl Into<String>) -> Self {
35 Self {
36 resource: Resource::new(id, kind, label),
37 }
38 }
39
40 #[must_use]
45 pub fn semantic_identity(mut self, identity: ResourceSemanticIdentity) -> Self {
46 self.resource.semantic_identity = identity.is_valid().then_some(identity);
47 self
48 }
49
50 pub fn from_resource(resource: Resource) -> Self {
55 Self {
56 resource: resolved_outside_document(resource),
57 }
58 }
59
60 #[must_use]
66 pub fn operation(mut self, operation: ActionSpec) -> Self {
67 if admits(&operation, self.id()) {
68 self.resource.actions.push(operation);
69 }
70 self
71 }
72
73 #[must_use]
77 pub fn presentation(mut self, presentation: ResourcePresentation) -> Self {
78 self.resource.presentation = Some(presentation);
79 self
80 }
81
82 #[must_use]
86 pub fn effect<E: Effect>(self, effect: &E) -> Self {
87 self.operation(effect.bound())
88 }
89
90 #[must_use]
94 pub fn view(mut self, view: tilo_actions::ResourceViewSpec) -> Self {
95 self.resource = self.resource.with_view(view);
96 self
97 }
98
99 #[must_use]
101 pub fn row(mut self, row: LiveRow) -> Self {
102 self.resource.children.push(row.resource);
103 self
104 }
105
106 pub fn id(&self) -> &str {
108 self.resource.id.as_str()
109 }
110
111 pub fn into_resource(mut self) -> Resource {
113 tilo_resource::share::offer_tree(&mut self.resource);
114 self.resource
115 }
116}
117
118fn admits(operation: &ActionSpec, on: &str) -> bool {
121 let admitted = operation.kind != OperationKind::Document;
122 debug_assert!(
123 admitted,
124 "`{on}` offers document action `{}` outside the resource's actions; \
125 a document change is the resource's Action through its Dispatcher",
126 operation.id
127 );
128 admitted
129}
130
131pub(crate) fn outside_document(mut resource: Resource) -> Resource {
134 let on = resource.id.as_str().to_owned();
135 resource.actions.retain(|action| admits(action, &on));
136 resource.children = resource
137 .children
138 .into_iter()
139 .map(outside_document)
140 .collect();
141 resource
142}
143
144pub(crate) fn resolved_outside_document(mut resource: Resource) -> Resource {
148 resource
149 .actions
150 .retain(|action| action.kind != OperationKind::Document);
151 resource.children = resource
152 .children
153 .into_iter()
154 .map(resolved_outside_document)
155 .collect();
156 resource
157}
158
159pub(crate) fn merge(children: &mut Vec<Resource>, rows: impl IntoIterator<Item = Resource>) {
164 for row in rows {
165 match children.iter_mut().find(|child| child.id == row.id) {
166 Some(existing) => join(existing, row),
167 None => children.push(row),
168 }
169 }
170}
171
172pub(crate) fn join(existing: &mut Resource, row: Resource) {
175 if existing.presentation.is_none() {
176 existing.presentation = row.presentation;
177 }
178 if existing.semantic_identity.is_none() {
179 existing.semantic_identity = row
180 .semantic_identity
181 .filter(ResourceSemanticIdentity::is_valid);
182 }
183 for action in row.actions {
184 if !existing.actions.iter().any(|known| known.id == action.id) {
185 existing.actions.push(action);
186 }
187 }
188 let mut views_added = false;
189 for view in row.views {
190 if !existing.views.iter().any(|known| known.id == view.id) {
191 existing.views.push(view);
192 views_added = true;
193 }
194 }
195 if views_added {
196 tilo_resource::share::reoffer(existing);
198 }
199 merge(&mut existing.children, row.children);
200}
201
202pub(crate) fn find(resources: &[Resource], id: &str) -> Option<Resource> {
204 resources.iter().find_map(|resource| {
205 if resource.id.as_str() == id {
206 Some(resource.clone())
207 } else {
208 find(&resource.children, id)
209 }
210 })
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn merging_retains_the_selector_identity_without_overwriting_the_first_publisher() {
219 let identity = ResourceSemanticIdentity::new("chat.agent", "chat.agent:scribe").unwrap();
220 let row = || {
221 LiveRow::new("agent:1", "chat.agent", "Scribe")
222 .semantic_identity(identity.clone())
223 .into_resource()
224 };
225 let mut children = vec![Resource::new("agent:1", "chat.agent", "Document label")];
226 merge(&mut children, [row()]);
227 assert_eq!(children.len(), 1);
228 assert_eq!(children[0].semantic_identity, Some(identity.clone()));
229 assert_eq!(children[0].label, "Document label");
230
231 let first = ResourceSemanticIdentity::new("chat.agent", "chat.agent:reed").unwrap();
232 children[0].semantic_identity = Some(first.clone());
233 merge(&mut children, [row()]);
234 assert_eq!(children[0].semantic_identity, Some(first));
235 }
236
237 #[test]
238 fn live_row_retains_only_a_valid_typed_semantic_identity() {
239 let identity = ResourceSemanticIdentity::new("chat.agent", "chat.agent:scribe")
240 .expect("the bounded typed identity is valid");
241 let resource = LiveRow::new("opaque:1", "chat.agent", "Scribe")
242 .semantic_identity(identity.clone())
243 .into_resource();
244 assert_eq!(resource.semantic_identity, Some(identity));
245
246 let invalid = ResourceSemanticIdentity {
247 kind: "chat agent".into(),
248 key: "chat.agent:scribe".into(),
249 };
250 assert!(
251 LiveRow::new("opaque:2", "chat.agent", "Scribe")
252 .semantic_identity(invalid)
253 .into_resource()
254 .semantic_identity
255 .is_none(),
256 "a live row never publishes malformed selector metadata"
257 );
258 }
259}