Skip to main content

eui_core/host/
record_transitions.rs

1//! eApps R6 (VAPP-222, docs/spec/apps.md §3.3/§3.4): a kind's state machine
2//! on ONE enum field (`records.<kind>.transitions`). A line-by-line port of
3//! `src/host/record-transitions.ts`; `fixtures/record-transitions.json` is
4//! the spec (`tests/record_transitions.rs` replays it).
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use serde::Serialize;
9use serde_json::{Map, Value};
10
11use super::package::PackageIssue;
12use super::record_policy::record_author_of;
13
14/// The key a records row of a kind with transitions carries its legal next states in.
15pub const RECORD_TRANSITIONS_ROW_KEY: &str = "transitions";
16pub const TRANSITION_LEVELS: &[&str] = &["member", "author", "owners"];
17const KEYS: &[&str] = &["field", "initial", "from", "who"];
18const ARROW: &str = "->";
19
20/// A kind's state machine (the well-formed parts).
21#[derive(Clone, Debug, Default, PartialEq, Eq)]
22pub struct RecordTransitions {
23    pub field: String,
24    pub initial: Option<Vec<String>>,
25    pub from: BTreeMap<String, Vec<String>>,
26    pub who: BTreeMap<String, String>,
27}
28
29/// Who moves a record: the member id and their team role.
30#[derive(Clone, Copy, Debug, Default)]
31pub struct TransitionViewer<'a> {
32    pub id: Option<&'a str>,
33    pub role: Option<&'a str>,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
37pub struct TransitionRefusal {
38    pub code: &'static str,
39    pub message: String,
40}
41
42fn issue(path: impl Into<String>, message: impl Into<String>) -> PackageIssue {
43    PackageIssue { path: path.into(), message: message.into() }
44}
45
46fn utf16_sorted<'a>(keys: impl Iterator<Item = &'a String>) -> Vec<&'a String> {
47    let mut v: Vec<&String> = keys.collect();
48    v.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
49    v
50}
51
52fn string_list(v: &Value) -> Option<Vec<String>> {
53    v.as_array()?.iter().map(|s| s.as_str().map(str::to_string)).collect()
54}
55
56/// A kind's transitions when well-formed enough to apply (`field` a string,
57/// `from` an object of lists); else `None`. Bad entries are dropped.
58pub fn kind_transitions(kind: Option<&Value>) -> Option<RecordTransitions> {
59    let t = kind?.get("transitions")?.as_object()?;
60    let field = t.get("field")?.as_str().filter(|f| !f.is_empty())?.to_string();
61    let from: BTreeMap<String, Vec<String>> = t.get("from")?.as_object()?.iter().filter_map(|(k, v)| string_list(v).map(|l| (k.clone(), l))).collect();
62    let who: BTreeMap<String, String> = t.get("who").and_then(Value::as_object).map(|w| w.iter().filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))).collect()).unwrap_or_default();
63    Some(RecordTransitions { field, initial: t.get("initial").and_then(string_list), from, who })
64}
65
66/// The states a new record may start in: `initial`, else every `from` state
67/// no edge enters (else every `from` state), in code-unit order.
68pub fn initial_states(t: &RecordTransitions) -> Vec<String> {
69    if let Some(initial) = &t.initial {
70        return initial.clone();
71    }
72    let states: Vec<String> = utf16_sorted(t.from.keys()).into_iter().cloned().collect();
73    let entered: BTreeSet<&String> = t.from.values().flatten().collect();
74    let sources: Vec<String> = states.iter().filter(|s| !entered.contains(s)).cloned().collect();
75    if sources.is_empty() {
76        states
77    } else {
78        sources
79    }
80}
81
82fn state_of<'a>(data: Option<&'a Value>, field: &str) -> Option<&'a str> {
83    data?.get(field)?.as_str().filter(|s| !s.is_empty())
84}
85
86/// The level of the edge a → b (`who["a->b"]`, else `who["b"]`, else member).
87fn edge_level<'a>(t: &'a RecordTransitions, a: &str, b: &str) -> &'a str {
88    t.who.get(&format!("{a}{ARROW}{b}")).or_else(|| t.who.get(b)).map_or("member", String::as_str)
89}
90
91/// Why the viewer may not take an edge of `level` (`None` = they may).
92fn level_refusal(level: &str, field: &str, to: &str, viewer: &TransitionViewer, before: Option<&Value>, row: Option<&Value>) -> Option<String> {
93    if level == "member" || viewer.role == Some("owner") {
94        return None;
95    }
96    if level == "owners" {
97        return Some(format!("Only team owners can move {field} to {to}"));
98    }
99    if level == "author" {
100        let author = record_author_of(row);
101        return if author.is_some() && author == viewer.id { None } else { Some(format!("Only the record's author or a team owner can move {field} to {to}")) };
102    }
103    if let Some(role_field) = level.strip_prefix("role:") {
104        let holder = state_of(before, role_field);
105        return if holder.is_some() && holder == viewer.id { None } else { Some(format!("Only the record's {role_field} or a team owner can move {field} to {to}")) };
106    }
107    Some(format!("Nobody can move {field} to {to}"))
108}
109
110/// Why a write of a kind with transitions `t` may not land: `before` = the
111/// stored data (`None` = a create), `after` = the data as it would be
112/// stored, `row` = the record's ownership. `None` = it may.
113pub fn transition_refusal(t: Option<&RecordTransitions>, before: Option<&Value>, after: &Value, viewer: &TransitionViewer, row: Option<&Value>) -> Option<TransitionRefusal> {
114    let t = t?;
115    let from = state_of(before, &t.field);
116    let to = state_of(Some(after), &t.field);
117    if from == to {
118        return None;
119    }
120    let Some(from) = from else {
121        let initial = initial_states(t);
122        return match to {
123            Some(to) if !initial.iter().any(|s| s == to) => Some(TransitionRefusal { code: "BAD_REQUEST", message: format!("{}: a new record starts in {}", t.field, initial.join(", ")) }),
124            _ => None,
125        };
126    };
127    let declared = to.is_some_and(|to| t.from.get(from).is_some_and(|next| next.iter().any(|n| n == to)));
128    let Some(to) = to.filter(|_| declared) else {
129        return Some(TransitionRefusal { code: "BAD_REQUEST", message: format!("{}: {from} → {} is not a declared transition", t.field, to.unwrap_or("(none)")) });
130    };
131    level_refusal(edge_level(t, from, to), &t.field, to, viewer, before, row).map(|message| TransitionRefusal { code: "FORBIDDEN", message })
132}
133
134/// The next states a record may move to (in the declared order; `None` = a
135/// new record: the initial states), only those the viewer may take when one
136/// is given.
137pub fn transitions_from(t: Option<&RecordTransitions>, record: Option<&Value>, viewer: Option<&TransitionViewer>, row: Option<&Value>) -> Vec<String> {
138    let Some(t) = t else { return Vec::new() };
139    let Some(from) = state_of(record, &t.field) else { return initial_states(t) };
140    let next = t.from.get(from).cloned().unwrap_or_default();
141    match viewer {
142        Some(viewer) => next.into_iter().filter(|to| level_refusal(edge_level(t, from, to), &t.field, to, viewer, record, row).is_none()).collect(),
143        None => next,
144    }
145}
146
147/// eApps R6: a kind's `transitions`, each issue under `<at>/transitions`.
148pub fn validate_record_transitions(kind: &Value, at: &str, kind_name: &str) -> Vec<PackageIssue> {
149    let Some(t) = kind.get("transitions") else { return Vec::new() };
150    let tp = format!("{at}/transitions");
151    let Some(t) = t.as_object() else { return vec![issue(tp, "transitions is {field, from, initial?, who?}")] };
152    let mut issues = Vec::new();
153    for key in utf16_sorted(t.keys()) {
154        if !KEYS.contains(&key.as_str()) {
155            issues.push(issue(format!("{tp}/{key}"), format!("unknown key {key}")));
156        }
157    }
158    let empty = Map::new();
159    let props = kind.get("schema").and_then(|s| s.get("properties")).and_then(Value::as_object).unwrap_or(&empty);
160    let field = t.get("field").and_then(Value::as_str);
161    let mut states: Option<Vec<String>> = None;
162    match field {
163        None | Some("") => issues.push(issue(format!("{tp}/field"), "field is the name of an enum field")),
164        Some(f) => match props.get(f).and_then(Value::as_object) {
165            None => issues.push(issue(format!("{tp}/field"), format!("{f} is not a declared field"))),
166            Some(def) => match (def.get("type").and_then(Value::as_str), def.get("enum").and_then(string_list)) {
167                (Some("string"), Some(values)) => states = Some(values),
168                _ => issues.push(issue(format!("{tp}/field"), format!("{f} is not an enum field"))),
169            },
170        },
171    }
172    let field_name = field.unwrap_or("");
173    let check_state = |s: &Value, p: String, issues: &mut Vec<PackageIssue>| match s.as_str() {
174        None => issues.push(issue(p, "a state is a string")),
175        Some(s) => {
176            if states.as_ref().is_some_and(|st| !st.iter().any(|x| x == s)) {
177                issues.push(issue(p, format!("{s} is not a state of {field_name}")));
178            }
179        }
180    };
181    let from = t.get("from").and_then(Value::as_object);
182    match from {
183        None => issues.push(issue(format!("{tp}/from"), "from is {state: [next state…]}")),
184        Some(from) => {
185            for s in utf16_sorted(from.keys()) {
186                check_state(&Value::String(s.clone()), format!("{tp}/from/{s}"), &mut issues);
187                match from[s].as_array() {
188                    None => issues.push(issue(format!("{tp}/from/{s}"), "the next states are a list")),
189                    Some(next) => {
190                        for (i, n) in next.iter().enumerate() {
191                            check_state(n, format!("{tp}/from/{s}/{i}"), &mut issues);
192                        }
193                    }
194                }
195            }
196        }
197    }
198    if let Some(initial) = t.get("initial") {
199        match initial.as_array().filter(|l| !l.is_empty()) {
200            None => issues.push(issue(format!("{tp}/initial"), "initial is a non-empty list of states")),
201            Some(list) => {
202                for (i, s) in list.iter().enumerate() {
203                    check_state(s, format!("{tp}/initial/{i}"), &mut issues);
204                }
205            }
206        }
207    }
208    if let Some(who) = t.get("who") {
209        match who.as_object() {
210            None => issues.push(issue(format!("{tp}/who"), r#"who is {"a->b" | state: level}"#)),
211            Some(who) => {
212                let refs = kind.get("refs").and_then(Value::as_object);
213                for key in utf16_sorted(who.keys()) {
214                    let wp = format!("{tp}/who/{key}");
215                    if let Some(arrow) = key.find(ARROW) {
216                        let (a, b) = (&key[..arrow], &key[arrow + ARROW.len()..]);
217                        let declared = from.and_then(|f| f.get(a)).and_then(Value::as_array).is_some_and(|next| next.iter().any(|n| n.as_str() == Some(b)));
218                        if !declared {
219                            issues.push(issue(&wp, format!("{a} → {b} is not a declared transition")));
220                        }
221                    } else {
222                        check_state(&Value::String(key.clone()), wp.clone(), &mut issues);
223                    }
224                    match who[key].as_str() {
225                        Some(level) if level.starts_with("role:") => {
226                            let role_field = &level[5..];
227                            if refs.and_then(|r| r.get(role_field)).and_then(Value::as_str) != Some("member") {
228                                issues.push(issue(&wp, format!("{level}: {role_field} is not a member ref of {kind_name}")));
229                            }
230                        }
231                        Some(level) if TRANSITION_LEVELS.contains(&level) => {}
232                        _ => issues.push(issue(&wp, "a level is member, author, owners or role:<field>")),
233                    }
234                }
235            }
236        }
237    }
238    let computed = kind.get("computed").and_then(Value::as_object);
239    if props.contains_key(RECORD_TRANSITIONS_ROW_KEY) || computed.is_some_and(|c| c.contains_key(RECORD_TRANSITIONS_ROW_KEY)) {
240        issues.push(issue(tp, format!("a kind with transitions declares no field {RECORD_TRANSITIONS_ROW_KEY} (its rows carry the next states there)")));
241    }
242    issues
243}