Skip to content
931 linesCodeBlameRaw
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
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::User;
22use 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")]
28pub enum FolioKind {
29 Doc,
30 Slides,
31 Design,
32 Dashboard,
33}
34
35impl 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")]
85pub 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")]
94pub enum GeneralAccess {
95 None,
96 Workspace,
97 Link,
98}
99
100impl 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")]
113pub enum AgentMode {
114 Suggest,
115 Edit,
116}
117
118/// The most ops one agent edit carries.
119pub const MAX_OPS: usize = 200;
120/// The longest title, in characters.
121pub const MAX_TITLE: usize = 200;
122/// The most people a folio is shared with in one change.
123pub const MAX_SHARE: usize = 50;
124/// The most folios a page of a list holds.
125pub const LIST_MAX: u32 = 100;
126
127/// Whether `text` names who a grant is for: `user:<id>`, `agent:<id>` or
128/// `team:<slug>`.
129pub 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_…`).
137pub 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)]
151pub 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)]
163pub 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)]
172pub 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)]
180pub 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)]
188pub 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")]
222pub enum FolioListTab {
223 All,
224 Yours,
225 Shared,
226}
227
228#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
229pub 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
247impl 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)]
257pub 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)]
265pub 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)]
273pub struct ShareWith {
274 pub principal: String,
275 pub role: FolioRole,
276}
277
278#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
279pub 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
300impl 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)]
331pub 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)]
344pub 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")]
355pub 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
377impl 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)]
414pub 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)]
425pub 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.
440fn 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.
454pub 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.
501pub const FOLIO_RPC_METHODS: [&str; 47] = [
502 "folio_list",
503 "folio_sidebar",
504 "folio",
505 "folio_page",
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)]
553pub 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)]
562pub 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)]
570pub 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)]
578pub 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)]
587pub 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)]
597pub 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)]
607pub 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)]
616pub 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)]
625pub 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)]
633pub struct SearchFoliosArgs {
634 pub workspace: String,
635 pub viewer: User,
636 pub query: FolioSearchQuery,
637}
638
639#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
640pub 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)]
659pub struct QueryDatasetArgs {
660 pub workspace: String,
661 pub viewer: User,
662 pub query: DatasetQuery,
663}
664
665// --- Events -------------------------------------------------------------------
666
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.
670pub 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")]
676pub 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")]
689pub 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")]
702pub 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")]
712pub 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)]
724mod 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}