1use 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
14pub 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#[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#[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
56pub 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
66pub 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
86fn 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
91fn 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
110pub 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
134pub 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
147pub 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}