Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet | 1 | //! Folios: what people call artifacts (Artifacts mode, docs/ARTIFACTS_MODE.md). |
| 2 | //! One mode for docs, slides, designs and dashboards, kept by the docs | |
| 3 | //! service (`services/docs`, TypeScript). Code says "folio"; people see | |
| 4 | //! "artifact" in the UI, URLs, the `artifact` MCP tool, REST paths and the | |
| 5 | //! `artifacts:*` scopes. The Cloudflare Artifacts git store and workflow run | |
| 6 | //! artifacts ([`crate::actions`]) are something else. | |
| 7 | //! | |
| 8 | //! This is the subset of `packages/contracts/src/folios.ts` the Rust API | |
| 9 | //! needs: the shapes it sends and reads back, the arguments of the docs | |
| 10 | //! service's RPC, the validators it runs before calling, and the `folio.*` | |
| 11 | //! event types. Agent edits stay JSON ([`serde_json::Value`]): the docs | |
| 12 | //! service applies them; [`agent_edit_error`] checks their envelope. Wire | |
| 13 | //! shapes are snake_case; the tests keep field names, RPC methods and | |
| 14 | //! validators the same as the TypeScript (`folios.fixtures.json`). | |
| 15 | //! | |
| 16 | //! Nothing uses this yet: Phase 1 publishes the events, Phase 3 the API. | |
| 17 | ||
| 18 | use serde::{Deserialize, Serialize}; | |
| 19 | use serde_json::Value; | |
| 20 | ||
| 21 | use crate::User; | |
| 22 | use crate::datasets::DatasetQuery; | |
| 23 | ||
| 24 | // --- Kinds and roles ---------------------------------------------------------- | |
| 25 | ||
| 26 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 27 | #[serde(rename_all = "snake_case")] | |
| 28 | pub enum FolioKind { | |
| 29 | Doc, | |
| 30 | Slides, | |
| 31 | Design, | |
| 32 | Dashboard, | |
| 33 | } | |
| 34 | ||
| 35 | impl FolioKind { | |
| 36 | pub const ALL: [FolioKind; 4] = [FolioKind::Doc, FolioKind::Slides, FolioKind::Design, FolioKind::Dashboard]; | |
| 37 | ||
| 38 | pub fn as_str(self) -> &'static str { | |
| 39 | match self { | |
| 40 | FolioKind::Doc => "doc", | |
| 41 | FolioKind::Slides => "slides", | |
| 42 | FolioKind::Design => "design", | |
| 43 | FolioKind::Dashboard => "dashboard", | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | pub fn parse(text: &str) -> Option<FolioKind> { | |
| 48 | FolioKind::ALL.into_iter().find(|kind| kind.as_str() == text) | |
| 49 | } | |
| 50 | ||
| 51 | /// As the Artifacts home's tiles name it. | |
| 52 | pub fn label(self) -> &'static str { | |
| 53 | match self { | |
| 54 | FolioKind::Doc => "Docs", | |
| 55 | FolioKind::Slides => "Slides", | |
| 56 | FolioKind::Design => "Design", | |
| 57 | FolioKind::Dashboard => "Dashboard", | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | /// One of it, in a sentence: "a doc", "a deck". | |
| 62 | pub fn noun(self) -> &'static str { | |
| 63 | match self { | |
| 64 | FolioKind::Doc => "doc", | |
| 65 | FolioKind::Slides => "deck", | |
| 66 | FolioKind::Design => "design", | |
| 67 | FolioKind::Dashboard => "dashboard", | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | /// Only a doc holds other folios. | |
| 72 | pub fn can_have_children(self) -> bool { | |
| 73 | self == FolioKind::Doc | |
| 74 | } | |
| 75 | ||
| 76 | /// Whether it starts from Markdown (doc, slides) or a JSON spec. | |
| 77 | pub fn takes_markdown(self) -> bool { | |
| 78 | matches!(self, FolioKind::Doc | FolioKind::Slides) | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | /// What someone may do with a folio, weakest first. | |
| 83 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] | |
| 84 | #[serde(rename_all = "snake_case")] | |
| 85 | pub enum FolioRole { | |
| 86 | View, | |
| 87 | Comment, | |
| 88 | Edit, | |
| 89 | Manage, | |
| 90 | } | |
| 91 | ||
| 92 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 93 | #[serde(rename_all = "snake_case")] | |
| 94 | pub enum GeneralAccess { | |
| 95 | None, | |
| 96 | Workspace, | |
| 97 | Link, | |
| 98 | } | |
| 99 | ||
| 100 | impl GeneralAccess { | |
| 101 | pub fn label(self) -> &'static str { | |
| 102 | match self { | |
| 103 | GeneralAccess::None => "Restricted", | |
| 104 | GeneralAccess::Workspace => "Everyone in the workspace", | |
| 105 | GeneralAccess::Link => "Anyone in the workspace with the link", | |
| 106 | } | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | /// How agents change a folio: file suggestions (or proposals), or edit. | |
| 111 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 112 | #[serde(rename_all = "snake_case")] | |
| 113 | pub enum AgentMode { | |
| 114 | Suggest, | |
| 115 | Edit, | |
| 116 | } | |
| 117 | ||
| 118 | /// The most ops one agent edit carries. | |
| 119 | pub const MAX_OPS: usize = 200; | |
| 120 | /// The longest title, in characters. | |
| 121 | pub const MAX_TITLE: usize = 200; | |
| 122 | /// The most people a folio is shared with in one change. | |
| 123 | pub const MAX_SHARE: usize = 50; | |
| 124 | /// The most folios a page of a list holds. | |
| 125 | pub const LIST_MAX: u32 = 100; | |
| 126 | ||
| 127 | /// Whether `text` names who a grant is for: `user:<id>`, `agent:<id>` or | |
| 128 | /// `team:<slug>`. | |
| 129 | pub fn is_principal(text: &str) -> bool { | |
| 130 | let Some((kind, id)) = text.split_once(':') else { return false }; | |
| 131 | matches!(kind, "user" | "agent" | "team") | |
| 132 | && (1..=64).contains(&id.len()) | |
| 133 | && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')) | |
| 134 | } | |
| 135 | ||
| 136 | /// The folio id at the end of an address segment (`q4-roadmap-fol_…`). | |
| 137 | pub fn folio_id_from(segment: &str) -> Option<&str> { | |
| 138 | let start = segment.len().checked_sub(30)?; | |
| 139 | let id = segment.get(start..)?; | |
| 140 | let suffix = id.strip_prefix("fol_")?; | |
| 141 | suffix | |
| 142 | .bytes() | |
| 143 | .all(|b| b"0123456789abcdefghjkmnpqrstvwxyz".contains(&b)) | |
| 144 | .then_some(id) | |
| 145 | } | |
| 146 | ||
| 147 | // --- Shapes ------------------------------------------------------------------- | |
| 148 | ||
| 149 | /// Enough to link to a folio. | |
| 150 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 151 | pub struct FolioRef { | |
| 152 | pub id: String, | |
| 153 | pub kind: FolioKind, | |
| 154 | pub title: String, | |
| 155 | pub icon: Option<String>, | |
| 156 | /// `<title-slug>-<id>`. | |
| 157 | pub slug: String, | |
| 158 | /// `/<ws>/-/artifacts/<slug>`. | |
| 159 | pub path: String, | |
| 160 | } | |
| 161 | ||
| 162 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 163 | pub struct FolioSpaceRef { | |
| 164 | pub id: String, | |
| 165 | pub slug: String, | |
| 166 | pub name: String, | |
| 167 | /// `workspace` (Open), `team` or `private` (Members only). | |
| 168 | pub kind: String, | |
| 169 | } | |
| 170 | ||
| 171 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 172 | pub struct FolioInheritedFrom { | |
| 173 | /// `space` or `folio`. | |
| 174 | pub kind: String, | |
| 175 | pub id: String, | |
| 176 | pub name: String, | |
| 177 | } | |
| 178 | ||
| 179 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 180 | pub struct FolioSource { | |
| 181 | pub title: String, | |
| 182 | pub href: String, | |
| 183 | } | |
| 184 | ||
| 185 | /// A folio for one viewer. People are member profiles (`MemberProfile` in | |
| 186 | /// chat.ts), kept as JSON. | |
| 187 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 188 | pub struct Folio { | |
| 189 | #[serde(flatten)] | |
| 190 | pub folio: FolioRef, | |
| 191 | pub workspace_id: String, | |
| 192 | /// None: its owner's Private section. | |
| 193 | pub space: Option<FolioSpaceRef>, | |
| 194 | pub parent_id: Option<String>, | |
| 195 | pub position: f64, | |
| 196 | pub owner: Value, | |
| 197 | pub created_by: Value, | |
| 198 | pub created_at: String, | |
| 199 | pub updated_at: String, | |
| 200 | pub edited_by: Option<Value>, | |
| 201 | pub edited_at: String, | |
| 202 | pub trashed_at: Option<String>, | |
| 203 | pub viewer_role: FolioRole, | |
| 204 | pub favorite: bool, | |
| 205 | pub private: bool, | |
| 206 | pub shared_count: u32, | |
| 207 | pub general_access: GeneralAccess, | |
| 208 | pub general_role: Option<FolioRole>, | |
| 209 | pub inherit: bool, | |
| 210 | pub inherited_from: Option<FolioInheritedFrom>, | |
| 211 | pub agent_mode: AgentMode, | |
| 212 | pub excerpt: String, | |
| 213 | /// What a card draws; never data values. | |
| 214 | pub preview: Option<Value>, | |
| 215 | pub source: Option<FolioSource>, | |
| 216 | pub stale: bool, | |
| 217 | pub has_children: bool, | |
| 218 | } | |
| 219 | ||
| 220 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 221 | #[serde(rename_all = "snake_case")] | |
| 222 | pub enum FolioListTab { | |
| 223 | All, | |
| 224 | Yours, | |
| 225 | Shared, | |
| 226 | } | |
| 227 | ||
| 228 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 229 | pub struct FolioListQuery { | |
| 230 | pub tab: FolioListTab, | |
| 231 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 232 | pub kinds: Option<Vec<FolioKind>>, | |
| 233 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 234 | pub space_id: Option<String>, | |
| 235 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 236 | pub owner: Option<String>, | |
| 237 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 238 | pub project: Option<String>, | |
| 239 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 240 | pub q: Option<String>, | |
| 241 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 242 | pub cursor: Option<String>, | |
| 243 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 244 | pub limit: Option<u32>, | |
| 245 | } | |
| 246 | ||
| 247 | impl FolioListQuery { | |
| 248 | pub fn validate(&self) -> Result<(), String> { | |
| 249 | match self.limit { | |
| 250 | Some(limit) if !(1..=LIST_MAX).contains(&limit) => Err(format!("limit is between 1 and {LIST_MAX}.")), | |
| 251 | _ => Ok(()), | |
| 252 | } | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 257 | pub struct FolioList { | |
| 258 | pub items: Vec<Folio>, | |
| 259 | pub next_cursor: Option<String>, | |
| 260 | } | |
| 261 | ||
| 262 | /// Content to start from: `markdown` for docs and slides, `spec` for | |
| 263 | /// designs and dashboards (one of them; a union in TypeScript). | |
| 264 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 265 | pub struct FolioContentInput { | |
| 266 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 267 | pub markdown: Option<String>, | |
| 268 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 269 | pub spec: Option<Value>, | |
| 270 | } | |
| 271 | ||
| 272 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 273 | pub struct ShareWith { | |
| 274 | pub principal: String, | |
| 275 | pub role: FolioRole, | |
| 276 | } | |
| 277 | ||
| 278 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 279 | pub struct NewFolio { | |
| 280 | pub kind: FolioKind, | |
| 281 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 282 | pub title: Option<String>, | |
| 283 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 284 | pub icon: Option<String>, | |
| 285 | /// None: the creator's Private section. | |
| 286 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 287 | pub space_id: Option<String>, | |
| 288 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 289 | pub parent_id: Option<String>, | |
| 290 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 291 | pub template_id: Option<String>, | |
| 292 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 293 | pub content: Option<FolioContentInput>, | |
| 294 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 295 | pub source: Option<FolioSource>, | |
| 296 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 297 | pub share_with: Option<Vec<ShareWith>>, | |
| 298 | } | |
| 299 | ||
| 300 | impl NewFolio { | |
| 301 | /// What is wrong with it, in the same words as `newFolioError`. | |
| 302 | /// Whether its space and parent exist and allow it is the docs | |
| 303 | /// service's to say. | |
| 304 | pub fn validate(&self) -> Result<(), String> { | |
| 305 | if self.title.as_deref().unwrap_or_default().chars().count() > MAX_TITLE { | |
| 306 | return Err(format!("A title is at most {MAX_TITLE} characters.")); | |
| 307 | } | |
| 308 | if let Some(content) = &self.content { | |
| 309 | if self.kind.takes_markdown() && content.markdown.is_none() { | |
| 310 | return Err(format!("A {} starts from markdown.", self.kind.noun())); | |
| 311 | } | |
| 312 | if !self.kind.takes_markdown() && !content.spec.as_ref().is_some_and(Value::is_object) { | |
| 313 | return Err(format!("A {} starts from a spec.", self.kind.noun())); | |
| 314 | } | |
| 315 | if self.template_id.is_some() { | |
| 316 | return Err("Start from a template or from content, not both.".to_owned()); | |
| 317 | } | |
| 318 | } | |
| 319 | let share = self.share_with.as_deref().unwrap_or_default(); | |
| 320 | if share.len() > MAX_SHARE { | |
| 321 | return Err(format!("Share with at most {MAX_SHARE} at once.")); | |
| 322 | } | |
| 323 | if let Some(row) = share.iter().find(|row| !is_principal(&row.principal)) { | |
| 324 | return Err(format!("{} is not user:, agent: or team: and an id.", row.principal)); | |
| 325 | } | |
| 326 | Ok(()) | |
| 327 | } | |
| 328 | } | |
| 329 | ||
| 330 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 331 | pub struct FolioChange { | |
| 332 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 333 | pub title: Option<String>, | |
| 334 | /// Some(None) clears it. | |
| 335 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 336 | pub icon: Option<Option<String>>, | |
| 337 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 338 | pub cover: Option<Option<String>>, | |
| 339 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 340 | pub projects: Option<Vec<String>>, | |
| 341 | } | |
| 342 | ||
| 343 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 344 | pub struct FolioMove { | |
| 345 | /// None: Private. | |
| 346 | pub space_id: Option<String>, | |
| 347 | pub parent_id: Option<String>, | |
| 348 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 349 | pub before_id: Option<String>, | |
| 350 | } | |
| 351 | ||
| 352 | /// One change from the share dialog. | |
| 353 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 354 | #[serde(tag = "op", rename_all = "snake_case")] | |
| 355 | pub enum FolioAccessChange { | |
| 356 | Grant { | |
| 357 | principal: String, | |
| 358 | role: FolioRole, | |
| 359 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 360 | notify: Option<String>, | |
| 361 | }, | |
| 362 | Revoke { | |
| 363 | principal: String, | |
| 364 | }, | |
| 365 | General { | |
| 366 | access: GeneralAccess, | |
| 367 | role: Option<FolioRole>, | |
| 368 | }, | |
| 369 | Inherit { | |
| 370 | inherit: bool, | |
| 371 | }, | |
| 372 | AgentMode { | |
| 373 | agent_mode: Option<AgentMode>, | |
| 374 | }, | |
| 375 | } | |
| 376 | ||
| 377 | impl FolioAccessChange { | |
| 378 | /// What is wrong with it, in the same words as `folioAccessChangeError`. | |
| 379 | pub fn validate(&self) -> Result<(), String> { | |
| 380 | match self { | |
| 381 | FolioAccessChange::Grant { principal, notify, .. } => { | |
| 382 | if !is_principal(principal) { | |
| 383 | return Err(format!("{principal} is not user:, agent: or team: and an id.")); | |
| 384 | } | |
| 385 | if notify.as_deref().is_some_and(|text| text.chars().count() > 2_000) { | |
| 386 | return Err("A message is at most 2000 characters.".to_owned()); | |
| 387 | } | |
| 388 | Ok(()) | |
| 389 | } | |
| 390 | FolioAccessChange::Revoke { principal } if !is_principal(principal) => Err(format!("{principal} is not user:, agent: or team: and an id.")), | |
| 391 | FolioAccessChange::General { access: GeneralAccess::None, role } => match role { | |
| 392 | None => Ok(()), | |
| 393 | Some(_) => Err("Restricted takes no role.".to_owned()), | |
| 394 | }, | |
| 395 | FolioAccessChange::General { access, role } => match role { | |
| 396 | None => Err(format!("{} needs a role.", access.label())), | |
| 397 | Some(FolioRole::Manage) => Err("General access gives view, comment or edit, never full access.".to_owned()), | |
| 398 | Some(_) => Ok(()), | |
| 399 | }, | |
| 400 | _ => Ok(()), | |
| 401 | } | |
| 402 | } | |
| 403 | ||
| 404 | /// The docs service method it goes to. | |
| 405 | pub fn method(&self) -> &'static str { | |
| 406 | match self { | |
| 407 | FolioAccessChange::Grant { .. } | FolioAccessChange::Revoke { .. } => "set_folio_grant", | |
| 408 | _ => "set_folio_general_access", | |
| 409 | } | |
| 410 | } | |
| 411 | } | |
| 412 | ||
| 413 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 414 | pub struct FolioVersion { | |
| 415 | pub id: String, | |
| 416 | pub folio_id: String, | |
| 417 | pub created_at: String, | |
| 418 | pub authors: Vec<Value>, | |
| 419 | /// created, edit, agent, suggestion, proposal or restore. | |
| 420 | pub kind: String, | |
| 421 | pub note: Option<String>, | |
| 422 | } | |
| 423 | ||
| 424 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 425 | pub struct FolioTemplate { | |
| 426 | pub id: String, | |
| 427 | pub kind: FolioKind, | |
| 428 | pub name: String, | |
| 429 | pub description: String, | |
| 430 | pub icon: Option<String>, | |
| 431 | pub builtin: bool, | |
| 432 | pub body: String, | |
| 433 | pub created_by: Option<Value>, | |
| 434 | } | |
| 435 | ||
| 436 | // --- Agent edits -------------------------------------------------------------- | |
| 437 | ||
| 438 | /// A JavaScript `String(value)` of a field, for messages that must read the | |
| 439 | /// same as the TypeScript's. | |
| 440 | fn js_string(value: Option<&Value>) -> String { | |
| 441 | match value { | |
| 442 | None => "undefined".to_owned(), | |
| 443 | Some(Value::String(text)) => text.clone(), | |
| 444 | Some(Value::Object(_)) => "[object Object]".to_owned(), | |
| 445 | Some(Value::Array(items)) => items.iter().map(|item| js_string(Some(item))).collect::<Vec<_>>().join(","), | |
| 446 | Some(other) => other.to_string(), | |
| 447 | } | |
| 448 | } | |
| 449 | ||
| 450 | /// What is wrong with an agent edit's envelope (`FolioAgentEdit` in | |
| 451 | /// folios.ts), in the same words as `folioAgentEditError`: its kind, and a | |
| 452 | /// doc's target and Markdown or the other kinds' list of ops. Returns the | |
| 453 | /// kind when it is well formed. Each op is the docs service's to check. | |
| 454 | pub fn agent_edit_error(edit: &Value) -> Result<FolioKind, String> { | |
| 455 | let Some(edit) = edit.as_object() else { return Err("An edit is an object.".to_owned()) }; | |
| 456 | let Some(kind) = edit.get("kind").and_then(Value::as_str).and_then(FolioKind::parse) else { | |
| 457 | return Err(format!("There is no kind of artifact called {}.", js_string(edit.get("kind")))); | |
| 458 | }; | |
| 459 | if edit.get("note").is_some_and(|note| !note.is_null() && !note.is_string()) { | |
| 460 | return Err("note is text.".to_owned()); | |
| 461 | } | |
| 462 | if kind == FolioKind::Doc { | |
| 463 | if !edit.get("markdown").is_some_and(Value::is_string) { | |
| 464 | return Err("A doc edit has markdown.".to_owned()); | |
| 465 | } | |
| 466 | if edit.contains_key("ops") { | |
| 467 | return Err("A doc edit has a target and markdown, not ops.".to_owned()); | |
| 468 | } | |
| 469 | let Some(target) = edit.get("target").and_then(Value::as_object) else { | |
| 470 | return Err("A doc edit has a target.".to_owned()); | |
| 471 | }; | |
| 472 | let text = |key: &str| target.get(key).and_then(Value::as_str); | |
| 473 | return match text("kind") { | |
| 474 | Some("append" | "document") => Ok(kind), | |
| 475 | Some("section") if text("heading").is_some_and(|heading| !heading.is_empty()) => Ok(kind), | |
| 476 | Some("section") => Err("A section target names its heading.".to_owned()), | |
| 477 | Some("blocks") if text("from_block").is_some() && text("to_block").is_some() => Ok(kind), | |
| 478 | Some("blocks") => Err("A blocks target names from_block and to_block.".to_owned()), | |
| 479 | _ => Err("A doc edit's target is append, document, section or blocks.".to_owned()), | |
| 480 | }; | |
| 481 | } | |
| 482 | if edit.contains_key("markdown") || edit.contains_key("target") { | |
| 483 | return Err(format!("A {} edit has ops, not a target and markdown.", kind.noun())); | |
| 484 | } | |
| 485 | let Some(ops) = edit.get("ops").and_then(Value::as_array).filter(|ops| !ops.is_empty()) else { | |
| 486 | return Err(format!("A {} edit has a list of ops.", kind.noun())); | |
| 487 | }; | |
| 488 | if ops.len() > MAX_OPS { | |
| 489 | return Err(format!("An edit has at most {MAX_OPS} ops.")); | |
| 490 | } | |
| 491 | if !ops.iter().all(|op| op.get("op").is_some_and(Value::is_string)) { | |
| 492 | return Err("Each op is an object with an op.".to_owned()); | |
| 493 | } | |
| 494 | Ok(kind) | |
| 495 | } | |
| 496 | ||
| 497 | // --- The docs service's folio RPC --------------------------------------------- | |
| 498 | ||
| 499 | /// Every method the docs service answers for folios, as `FOLIO_RPC_METHODS` | |
| 500 | /// in folios.ts. | |
| The docs service answers folio_page with a folio and what its page shows around it, a space can let its editors share what is in it, and someone asking for access to an artifact is told to wait when they already asked for it in the last day. | 501 | pub const FOLIO_RPC_METHODS: [&str; 47] = [ |
| Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet | 502 | "folio_list", |
| 503 | "folio_sidebar", | |
| 504 | "folio", | |
| The docs service answers folio_page with a folio and what its page shows around it, a space can let its editors share what is in it, and someone asking for access to an artifact is told to wait when they already asked for it in the last day. | 505 | "folio_page", |
| Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet | 506 | "create_folio", |
| 507 | "update_folio", | |
| 508 | "move_folio", | |
| 509 | "duplicate_folio", | |
| 510 | "trash_folio", | |
| 511 | "restore_folio", | |
| 512 | "delete_folio", | |
| 513 | "folio_trash", | |
| 514 | "favorite_folio", | |
| 515 | "folio_content", | |
| 516 | "edit_folio", | |
| 517 | "folio_access", | |
| 518 | "set_folio_grant", | |
| 519 | "set_folio_general_access", | |
| 520 | "request_folio_access", | |
| 521 | "join_space", | |
| 522 | "leave_space", | |
| 523 | "search_folios", | |
| 524 | "folio_versions", | |
| 525 | "folio_version", | |
| 526 | "restore_folio_version", | |
| 527 | "folio_templates", | |
| 528 | "save_folio_template", | |
| 529 | "delete_folio_template", | |
| 530 | "export_folio", | |
| 531 | "folio_suggestions", | |
| 532 | "decide_folio_suggestion", | |
| 533 | "folio_proposals", | |
| 534 | "decide_folio_proposal", | |
| 535 | "folio_thread", | |
| 536 | "folio_threads", | |
| 537 | "query_tile", | |
| 538 | "query_dataset", | |
| 539 | "query_dataset_for_agent", | |
| 540 | "folios_for_agent", | |
| 541 | "read_folio_for_agent", | |
| 542 | "create_folio_as_agent", | |
| 543 | "edit_folio_as_agent", | |
| 544 | "share_folio_as_agent", | |
| 545 | "recall_folios_for_agent", | |
| 546 | "stale_folios_for_agent", | |
| 547 | "mark_folio_current", | |
| 548 | "reindex_folios", | |
| 549 | ]; | |
| 550 | ||
| 551 | /// `folio_list`. | |
| 552 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 553 | pub struct FolioListArgs { | |
| 554 | pub workspace: String, | |
| 555 | pub viewer: User, | |
| 556 | pub query: FolioListQuery, | |
| 557 | } | |
| 558 | ||
| 559 | /// `folio`, `duplicate_folio`, `trash_folio`, `restore_folio`, | |
| 560 | /// `delete_folio`, `folio_content`, `folio_access`, `folio_versions`. | |
| 561 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 562 | pub struct FolioArgs { | |
| 563 | pub workspace: String, | |
| 564 | pub viewer: User, | |
| 565 | pub folio_id: String, | |
| 566 | } | |
| 567 | ||
| 568 | /// `create_folio`. | |
| 569 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 570 | pub struct CreateFolioArgs { | |
| 571 | pub workspace: String, | |
| 572 | pub viewer: User, | |
| 573 | pub input: NewFolio, | |
| 574 | } | |
| 575 | ||
| 576 | /// `update_folio`. | |
| 577 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 578 | pub struct UpdateFolioArgs { | |
| 579 | pub workspace: String, | |
| 580 | pub viewer: User, | |
| 581 | pub folio_id: String, | |
| 582 | pub change: FolioChange, | |
| 583 | } | |
| 584 | ||
| 585 | /// `move_folio`. | |
| 586 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 587 | pub struct MoveFolioArgs { | |
| 588 | pub workspace: String, | |
| 589 | pub viewer: User, | |
| 590 | pub folio_id: String, | |
| 591 | #[serde(rename = "move")] | |
| 592 | pub to: FolioMove, | |
| 593 | } | |
| 594 | ||
| 595 | /// `edit_folio`: a `FolioAgentEdit`, as JSON. | |
| 596 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 597 | pub struct EditFolioArgs { | |
| 598 | pub workspace: String, | |
| 599 | pub viewer: User, | |
| 600 | pub folio_id: String, | |
| 601 | pub edit: Value, | |
| 602 | } | |
| 603 | ||
| 604 | /// `set_folio_grant` and `set_folio_general_access` (see | |
| 605 | /// [`FolioAccessChange::method`]). | |
| 606 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 607 | pub struct FolioAccessArgs { | |
| 608 | pub workspace: String, | |
| 609 | pub viewer: User, | |
| 610 | pub folio_id: String, | |
| 611 | pub change: FolioAccessChange, | |
| 612 | } | |
| 613 | ||
| 614 | /// `folio_version` and `restore_folio_version`. | |
| 615 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 616 | pub struct FolioVersionArgs { | |
| 617 | pub workspace: String, | |
| 618 | pub viewer: User, | |
| 619 | pub folio_id: String, | |
| 620 | pub version_id: String, | |
| 621 | } | |
| 622 | ||
| 623 | /// `folio_templates`. | |
| 624 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 625 | pub struct FolioTemplatesArgs { | |
| 626 | pub workspace: String, | |
| 627 | pub viewer: User, | |
| 628 | pub kind: Option<FolioKind>, | |
| 629 | } | |
| 630 | ||
| 631 | /// `search_folios`. | |
| 632 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 633 | pub struct SearchFoliosArgs { | |
| 634 | pub workspace: String, | |
| 635 | pub viewer: User, | |
| 636 | pub query: FolioSearchQuery, | |
| 637 | } | |
| 638 | ||
| 639 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 640 | pub struct FolioSearchQuery { | |
| 641 | pub q: String, | |
| 642 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 643 | pub kinds: Option<Vec<FolioKind>>, | |
| 644 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 645 | pub space_id: Option<String>, | |
| 646 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 647 | pub project: Option<String>, | |
| 648 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 649 | pub owner: Option<String>, | |
| 650 | /// `words` or `hybrid`. | |
| 651 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 652 | pub mode: Option<String>, | |
| 653 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 654 | pub limit: Option<u32>, | |
| 655 | } | |
| 656 | ||
| 657 | /// `query_dataset`. | |
| 658 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 659 | pub struct QueryDatasetArgs { | |
| 660 | pub workspace: String, | |
| 661 | pub viewer: User, | |
| 662 | pub query: DatasetQuery, | |
| 663 | } | |
| 664 | ||
| 665 | // --- Events ------------------------------------------------------------------- | |
| 666 | ||
| The artifacts plan records what Phase 1 decided, self-hosting and the deploy list name the g1t-folios index and the trash cron, and folio events are listed as published but never offered to webhooks. | 667 | /// The `folio.*` types the docs service publishes, with no `repoId` on |
| 668 | /// the event: a folio may be private, so it never reaches a repository's | |
| 669 | /// timeline or webhooks. Nothing subscribes to them yet. | |
| Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet | 670 | pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"]; |
| 671 | ||
| 672 | /// What every `folio.*` event carries (`FolioEventData` in events.ts). | |
| 673 | /// Event data is camelCase, as every event on the bus. | |
| 674 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 675 | #[serde(rename_all = "camelCase")] | |
| 676 | pub struct FolioEvent { | |
| 677 | pub workspace: String, | |
| 678 | pub workspace_id: String, | |
| 679 | pub folio_id: String, | |
| 680 | pub kind: FolioKind, | |
| 681 | pub space_id: Option<String>, | |
| 682 | /// None unless every member of the workspace can read it. | |
| 683 | pub title: Option<String>, | |
| 684 | } | |
| 685 | ||
| 686 | /// `folio.updated`: a version. | |
| 687 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 688 | #[serde(rename_all = "camelCase")] | |
| 689 | pub struct FolioUpdated { | |
| 690 | #[serde(flatten)] | |
| 691 | pub folio: FolioEvent, | |
| 692 | pub version_id: String, | |
| 693 | /// edit, agent, suggestion, proposal or restore. | |
| 694 | pub version_kind: String, | |
| 695 | /// Member keys. | |
| 696 | pub authors: Vec<String>, | |
| 697 | } | |
| 698 | ||
| 699 | /// `folio.shared`: who was given access, never content. | |
| 700 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 701 | #[serde(rename_all = "camelCase")] | |
| 702 | pub struct FolioShared { | |
| 703 | #[serde(flatten)] | |
| 704 | pub folio: FolioEvent, | |
| 705 | pub principals: Vec<String>, | |
| 706 | pub role: FolioRole, | |
| 707 | } | |
| 708 | ||
| 709 | /// `folio.stale`: code it cites changed. | |
| 710 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 711 | #[serde(rename_all = "camelCase")] | |
| 712 | pub struct FolioStale { | |
| 713 | #[serde(flatten)] | |
| 714 | pub folio: FolioEvent, | |
| 715 | /// `owner/name`. | |
| 716 | pub repo: String, | |
| 717 | pub commit: String, | |
| 718 | pub pull: Option<u32>, | |
| 719 | pub paths: Vec<String>, | |
| 720 | pub owners: Vec<String>, | |
| 721 | } | |
| 722 | ||
| 723 | #[cfg(test)] | |
| 724 | mod tests { | |
| 725 | use super::*; | |
| 726 | use serde_json::json; | |
| 727 | ||
| 728 | const TS: &str = include_str!("../../../packages/contracts/src/folios.ts"); | |
| 729 | const EVENTS_TS: &str = include_str!("../../../packages/contracts/src/events.ts"); | |
| 730 | ||
| 731 | fn fixtures() -> Value { | |
| 732 | serde_json::from_str(include_str!("../../../packages/contracts/src/folios.fixtures.json")).unwrap() | |
| 733 | } | |
| 734 | ||
| 735 | /// The body of `export type <name> = ...` up to its closing `};`. | |
| 736 | fn ts_type<'a>(source: &'a str, name: &str) -> &'a str { | |
| 737 | let start = format!("export type {name} = "); | |
| 738 | source | |
| 739 | .split_once(&start) | |
| 740 | .and_then(|(_, rest)| rest.split_once("\n};")) | |
| 741 | .map(|(body, _)| body) | |
| 742 | .unwrap_or_else(|| panic!("{name} in the TypeScript")) | |
| 743 | } | |
| 744 | ||
| 745 | /// Every key `value` serializes to is a field of one of the TypeScript | |
| 746 | /// types named. | |
| 747 | fn fields_match(value: &Value, source: &str, types: &[&str]) { | |
| 748 | let bodies: Vec<&str> = types.iter().map(|name| ts_type(source, name)).collect(); | |
| 749 | for key in value.as_object().unwrap().keys() { | |
| 750 | assert!( | |
| 751 | bodies.iter().any(|body| body.contains(&format!(" {key}: ")) || body.contains(&format!(" {key}?: "))), | |
| 752 | "{key} is not a field of {types:?}" | |
| 753 | ); | |
| 754 | } | |
| 755 | } | |
| 756 | ||
| 757 | fn folio() -> Folio { | |
| 758 | serde_json::from_value(json!({ | |
| 759 | "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "kind": "slides", "title": "Q4 roadmap", "icon": null, | |
| 760 | "slug": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "path": "/acme/-/artifacts/q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", | |
| 761 | "workspace_id": "wsp_1", "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" }, | |
| 762 | "parent_id": null, "position": 1.5, | |
| 763 | "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" }, | |
| 764 | "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t" }, | |
| 765 | "created_at": "2026-10-09T00:00:00.000Z", "updated_at": "2026-10-09T00:00:00.000Z", | |
| 766 | "edited_by": null, "edited_at": "2026-10-09T00:00:00.000Z", "trashed_at": null, | |
| 767 | "viewer_role": "edit", "favorite": false, "private": false, "shared_count": 2, | |
| 768 | "general_access": "none", "general_role": null, "inherit": true, | |
| 769 | "inherited_from": { "kind": "space", "id": "spc_1", "name": "General" }, "agent_mode": "suggest", | |
| 770 | "excerpt": "", "preview": { "kind": "slides", "aspect": "16:9", "count": 3, "layout": "title", "title": "Q4" }, | |
| 771 | "source": { "title": "#product", "href": "/acme/-/chat/c/1" }, "stale": false, "has_children": false | |
| 772 | })) | |
| 773 | .unwrap() | |
| 774 | } | |
| 775 | ||
| 776 | #[test] | |
| 777 | fn shapes_have_the_typescript_field_names() { | |
| 778 | fields_match(&serde_json::to_value(folio()).unwrap(), TS, &["FolioRef", "Folio"]); | |
| 779 | let new = NewFolio { | |
| 780 | kind: FolioKind::Doc, | |
| 781 | title: Some("Plan".to_owned()), | |
| 782 | icon: Some("🗺️".to_owned()), | |
| 783 | space_id: Some("spc_1".to_owned()), | |
| 784 | parent_id: Some("fol_1".to_owned()), | |
| 785 | template_id: Some("tpl_1".to_owned()), | |
| 786 | content: Some(FolioContentInput { markdown: Some("# Plan".to_owned()), spec: None }), | |
| 787 | source: Some(FolioSource { title: "t".to_owned(), href: "/h".to_owned() }), | |
| 788 | share_with: Some(vec![ShareWith { principal: "user:usr_2".to_owned(), role: FolioRole::View }]), | |
| 789 | }; | |
| 790 | fields_match(&serde_json::to_value(&new).unwrap(), TS, &["NewFolio"]); | |
| 791 | let query = FolioListQuery { | |
| 792 | tab: FolioListTab::Shared, | |
| 793 | kinds: Some(vec![FolioKind::Design]), | |
| 794 | space_id: Some("s".to_owned()), | |
| 795 | owner: Some("user:u".to_owned()), | |
| 796 | project: Some("acme/web".to_owned()), | |
| 797 | q: Some("roadmap".to_owned()), | |
| 798 | cursor: Some("c".to_owned()), | |
| 799 | limit: Some(20), | |
| 800 | }; | |
| 801 | fields_match(&serde_json::to_value(&query).unwrap(), TS, &["FolioListQuery"]); | |
| 802 | let change = FolioChange { title: Some("x".to_owned()), icon: Some(None), cover: Some(None), projects: Some(vec![]) }; | |
| 803 | fields_match(&serde_json::to_value(&change).unwrap(), TS, &["FolioChange"]); | |
| 804 | let to = FolioMove { space_id: None, parent_id: None, before_id: Some("fol_2".to_owned()) }; | |
| 805 | assert_eq!(serde_json::to_value(&to).unwrap(), json!({ "space_id": null, "parent_id": null, "before_id": "fol_2" })); | |
| 806 | let list = FolioList { items: vec![], next_cursor: None }; | |
| 807 | assert_eq!(serde_json::to_value(&list).unwrap(), json!({ "items": [], "next_cursor": null })); | |
| 808 | let version = FolioVersion { id: "ver_1".to_owned(), folio_id: "fol_1".to_owned(), created_at: "t".to_owned(), authors: vec![], kind: "edit".to_owned(), note: None }; | |
| 809 | fields_match(&serde_json::to_value(&version).unwrap(), TS, &["FolioVersion"]); | |
| 810 | let template = FolioTemplate { | |
| 811 | id: "tpl_1".to_owned(), | |
| 812 | kind: FolioKind::Dashboard, | |
| 813 | name: "Engineering health".to_owned(), | |
| 814 | description: String::new(), | |
| 815 | icon: None, | |
| 816 | builtin: true, | |
| 817 | body: "{}".to_owned(), | |
| 818 | created_by: None, | |
| 819 | }; | |
| 820 | fields_match(&serde_json::to_value(&template).unwrap(), TS, &["FolioTemplate"]); | |
| 821 | // Every field of the TypeScript `Folio` comes back from Rust too. | |
| 822 | let rust: Vec<String> = serde_json::to_value(folio()).unwrap().as_object().unwrap().keys().cloned().collect(); | |
| 823 | for body in [ts_type(TS, "FolioRef"), ts_type(TS, "Folio")] { | |
| 824 | for line in body.lines() { | |
| 825 | let line = line.trim_start(); | |
| 826 | if let Some((key, _)) = line.split_once(':').filter(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) { | |
| 827 | assert!(rust.contains(&key.to_owned()), "Folio has no {key} in Rust"); | |
| 828 | } | |
| 829 | } | |
| 830 | } | |
| 831 | } | |
| 832 | ||
| 833 | #[test] | |
| 834 | fn access_changes_travel_tagged_by_op() { | |
| 835 | let change = FolioAccessChange::General { access: GeneralAccess::Link, role: Some(FolioRole::Comment) }; | |
| 836 | assert_eq!(serde_json::to_value(&change).unwrap(), json!({ "op": "general", "access": "link", "role": "comment" })); | |
| 837 | assert_eq!(change.method(), "set_folio_general_access"); | |
| 838 | let grant: FolioAccessChange = serde_json::from_value(json!({ "op": "grant", "principal": "team:design", "role": "edit" })).unwrap(); | |
| 839 | assert_eq!(grant.method(), "set_folio_grant"); | |
| 840 | let mode: FolioAccessChange = serde_json::from_value(json!({ "op": "agent_mode", "agent_mode": "edit" })).unwrap(); | |
| 841 | assert_eq!(mode, FolioAccessChange::AgentMode { agent_mode: Some(AgentMode::Edit) }); | |
| 842 | } | |
| 843 | ||
| 844 | /// The validators agree with the TypeScript's on the shared fixtures. | |
| 845 | #[test] | |
| 846 | fn agrees_with_the_typescript_validators_on_the_fixtures() { | |
| 847 | let fixtures = fixtures(); | |
| 848 | let check = |case: &Value, outcome: Result<(), String>| match case["error"].as_str() { | |
| 849 | None => assert_eq!(outcome, Ok(()), "{case}"), | |
| 850 | Some("*") => assert!(outcome.is_err(), "{case} should be refused"), | |
| 851 | Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{case}"), | |
| 852 | }; | |
| 853 | for case in fixtures["new_folio"].as_array().unwrap() { | |
| 854 | let outcome = serde_json::from_value::<NewFolio>(case["input"].clone()).map_err(|_| "*".to_owned()).and_then(|input| input.validate()); | |
| 855 | check(case, outcome); | |
| 856 | } | |
| 857 | for case in fixtures["access_change"].as_array().unwrap() { | |
| 858 | let outcome = serde_json::from_value::<FolioAccessChange>(case["change"].clone()).map_err(|_| "*".to_owned()).and_then(|change| change.validate()); | |
| 859 | check(case, outcome); | |
| 860 | } | |
| 861 | for case in fixtures["agent_edit"].as_array().unwrap() { | |
| 862 | check(case, agent_edit_error(&case["edit"]).map(|_| ())); | |
| 863 | } | |
| 864 | for case in fixtures["folio_ids"].as_array().unwrap() { | |
| 865 | assert_eq!(folio_id_from(case["segment"].as_str().unwrap()), case["id"].as_str(), "{case}"); | |
| 866 | } | |
| 867 | } | |
| 868 | ||
| 869 | #[test] | |
| 870 | fn the_typescript_mirror_has_the_same_kinds_methods_and_events() { | |
| 871 | for kind in FolioKind::ALL { | |
| 872 | assert!(TS.contains(&format!(" {}: \"{}\",", kind.as_str(), kind.label())), "{}", kind.as_str()); | |
| 873 | assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str()); | |
| 874 | } | |
| 875 | let methods: Vec<&str> = TS | |
| 876 | .split_once("export const FOLIO_RPC_METHODS = [") | |
| 877 | .and_then(|(_, rest)| rest.split_once("] as const")) | |
| 878 | .map(|(list, _)| list) | |
| 879 | .expect("FOLIO_RPC_METHODS in folios.ts") | |
| 880 | .lines() | |
| 881 | .filter_map(|line| line.trim().strip_prefix('"').and_then(|rest| rest.split_once('"')).map(|(name, _)| name)) | |
| 882 | .collect(); | |
| 883 | assert_eq!(methods, FOLIO_RPC_METHODS); | |
| 884 | for event in FOLIO_EVENTS { | |
| 885 | assert!(EVENTS_TS.contains(&format!(" \"{event}\": FolioEventData")), "{event} in events.ts"); | |
| 886 | } | |
| 887 | let event = FolioEvent { | |
| 888 | workspace: "acme".to_owned(), | |
| 889 | workspace_id: "wsp_1".to_owned(), | |
| 890 | folio_id: "fol_1".to_owned(), | |
| 891 | kind: FolioKind::Doc, | |
| 892 | space_id: None, | |
| 893 | title: None, | |
| 894 | }; | |
| 895 | fields_match(&serde_json::to_value(&event).unwrap(), EVENTS_TS, &["FolioEventData"]); | |
| 896 | } | |
| 897 | ||
| 898 | #[test] | |
| 899 | fn folio_events_read_as_published_and_name_no_repository_id() { | |
| 900 | let data = json!({ | |
| 901 | "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "doc", "spaceId": null, "title": null, | |
| 902 | "repo": "acme/web", "commit": "abc", "pull": 431, "paths": ["src/export.ts"], "owners": ["user:usr_1"] | |
| 903 | }); | |
| 904 | let stale: FolioStale = serde_json::from_value(data).unwrap(); | |
| 905 | assert_eq!((stale.folio.folio_id.as_str(), stale.pull), ("fol_1", Some(431))); | |
| 906 | let json = serde_json::to_value(&stale).unwrap(); | |
| 907 | assert!(json.get("repoId").is_none()); | |
| 908 | let shared: FolioShared = serde_json::from_value(json!({ | |
| 909 | "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "slides", "spaceId": "spc_1", "title": "Q4", | |
| 910 | "principals": ["user:usr_2"], "role": "comment" | |
| 911 | })) | |
| 912 | .unwrap(); | |
| 913 | assert_eq!(shared.role, FolioRole::Comment); | |
| 914 | // Not offered to webhooks. | |
| 915 | for event in FOLIO_EVENTS { | |
| 916 | assert!(!crate::webhooks::EVENT_TYPES.contains(&event), "{event}"); | |
| 917 | } | |
| 918 | } | |
| 919 | ||
| 920 | #[test] | |
| 921 | fn principals_and_kinds() { | |
| 922 | assert!(is_principal("user:usr_01jb")); | |
| 923 | assert!(is_principal("team:platform-web")); | |
| 924 | assert!(!is_principal("user:")); | |
| 925 | assert!(!is_principal("group:x")); | |
| 926 | assert!(!is_principal("user:a b")); | |
| 927 | assert!(FolioKind::Doc.can_have_children()); | |
| 928 | assert!(FolioKind::ALL.iter().filter(|kind| kind.can_have_children()).count() == 1); | |
| 929 | assert!(FolioRole::Manage > FolioRole::Edit && FolioRole::Comment > FolioRole::View); | |
| 930 | } | |
| 931 | } |