Skip to main content

tilo_app/framework/
rows.rs

1//! Live external rows: what a surface shows that lives outside the
2//! resource's document — processes, devices, containers, journal entries,
3//! fetched catalogue rows, running jobs — published beneath the resource for
4//! the palette, agents and scripts.
5
6use tilo_actions::{
7    ActionSpec, OperationKind, Resource, ResourcePresentation, ResourceSemanticIdentity,
8};
9use tilo_resource::Effect;
10
11/// One live external entity a surface publishes beneath its resource, with
12/// the view and effect operations it offers (inspect a process, stop a
13/// container).
14///
15/// A row is not document state: it has no history, no undo and nothing to
16/// sync, so it can never carry a document mutation. A change to the
17/// document is the resource's `Action` through the
18/// [`Dispatcher`](super::Dispatcher), bound to a row's item through
19/// [`ResourceSurface::bound_declarations`](super::ResourceSurface::bound_declarations)
20/// or the resource's `children`.
21///
22/// Rows are merged by id into the resource's published children: a row with
23/// the id of a document child adds its operations to that child instead of
24/// publishing a second resource under the same id.
25#[derive(Clone)]
26pub struct LiveRow {
27    resource: Resource,
28}
29
30impl LiveRow {
31    /// A row with a stable `id` (unique within the resource, and naming the
32    /// same entity for as long as it lives: a process id with its start
33    /// time, a container id), a `kind` tag and a human `label`.
34    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    /// Attach the stable source-domain identity used to find this row across
41    /// live dispatch aliases. It is descriptive metadata only and grants no
42    /// permission or authority. Invalid identities are omitted at this
43    /// publication boundary.
44    #[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    /// `resource`, an entity the surface already describes as a resource
51    /// (a search hit, a catalogue entry), as a row: its presentation,
52    /// views, operations and children. Its document actions are dropped at
53    /// any depth: the resource's own publication carries them.
54    pub fn from_resource(resource: Resource) -> Self {
55        Self {
56            resource: resolved_outside_document(resource),
57        }
58    }
59
60    /// Offer a view or effect operation on the row.
61    ///
62    /// # Panics
63    /// In a debug build, when `operation` is a document mutation; a release
64    /// build drops it.
65    #[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    /// How the entity stands right now (a process's CPU use, what a timer
74    /// has left), shown beside its label. A row merged into a document child
75    /// that has no presentation of its own lends it this one.
76    #[must_use]
77    pub fn presentation(mut self, presentation: ResourcePresentation) -> Self {
78        self.resource.presentation = Some(presentation);
79        self
80    }
81
82    /// Offer `effect`, bound to this row's entity (stop *this* process):
83    /// invoked here, it reaches the surface's or view's handler of `E` as
84    /// that value, under the effect's declared contract.
85    #[must_use]
86    pub fn effect<E: Effect>(self, effect: &E) -> Self {
87        self.operation(effect.bound())
88    }
89
90    /// Offer `view` of the row's entity: a way to show it (a message in
91    /// its conversation, a range of its text) that a link or an agent
92    /// navigates to, opened by one of the row's own operations.
93    #[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    /// Nest `row` beneath this one (a container's logs).
100    #[must_use]
101    pub fn row(mut self, row: LiveRow) -> Self {
102        self.resource.children.push(row.resource);
103        self
104    }
105
106    /// The row's id.
107    pub fn id(&self) -> &str {
108        self.resource.id.as_str()
109    }
110
111    /// The row as the published tree carries it.
112    pub fn into_resource(mut self) -> Resource {
113        tilo_resource::share::offer_tree(&mut self.resource);
114        self.resource
115    }
116}
117
118/// Whether `operation` may ride outside the document: views and effects do,
119/// a document mutation never does.
120fn 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
131/// `resource` without document actions, at any depth: what a content widget
132/// may add to the published tree beside the resource's own publication.
133pub(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
144/// `resource`, resolved by id, without document actions. A lookup is asked
145/// for ids the resource itself publishes too (an item of its own), so this
146/// drops them without complaint: the resource's publication carries them.
147pub(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
159/// Merge `rows` by id into `children`: a row naming an existing child adds
160/// the operations and rows that child lacks, and its presentation when the
161/// child has none; any other row is appended. The first publisher of an id
162/// keeps its label and kind.
163pub(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
172/// Add to `existing` the presentation, semantic identity, operations, views and rows of `row`,
173/// which names it, that it lacks.
174pub(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        // The link actions name the views they were offered with.
197        tilo_resource::share::reoffer(existing);
198    }
199    merge(&mut existing.children, row.children);
200}
201
202/// The resource `id` in `resources`, at any depth.
203pub(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}