Skip to content
930 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; 46] = [
502 "folio_list",
503 "folio_sidebar",
504 "folio",
505 "create_folio",
506 "update_folio",
507 "move_folio",
508 "duplicate_folio",
509 "trash_folio",
510 "restore_folio",
511 "delete_folio",
512 "folio_trash",
513 "favorite_folio",
514 "folio_content",
515 "edit_folio",
516 "folio_access",
517 "set_folio_grant",
518 "set_folio_general_access",
519 "request_folio_access",
520 "join_space",
521 "leave_space",
522 "search_folios",
523 "folio_versions",
524 "folio_version",
525 "restore_folio_version",
526 "folio_templates",
527 "save_folio_template",
528 "delete_folio_template",
529 "export_folio",
530 "folio_suggestions",
531 "decide_folio_suggestion",
532 "folio_proposals",
533 "decide_folio_proposal",
534 "folio_thread",
535 "folio_threads",
536 "query_tile",
537 "query_dataset",
538 "query_dataset_for_agent",
539 "folios_for_agent",
540 "read_folio_for_agent",
541 "create_folio_as_agent",
542 "edit_folio_as_agent",
543 "share_folio_as_agent",
544 "recall_folios_for_agent",
545 "stale_folios_for_agent",
546 "mark_folio_current",
547 "reindex_folios",
548];
549
550/// `folio_list`.
551#[derive(Clone, Debug, Serialize, Deserialize)]
552pub struct FolioListArgs {
553 pub workspace: String,
554 pub viewer: User,
555 pub query: FolioListQuery,
556}
557
558/// `folio`, `duplicate_folio`, `trash_folio`, `restore_folio`,
559/// `delete_folio`, `folio_content`, `folio_access`, `folio_versions`.
560#[derive(Clone, Debug, Serialize, Deserialize)]
561pub struct FolioArgs {
562 pub workspace: String,
563 pub viewer: User,
564 pub folio_id: String,
565}
566
567/// `create_folio`.
568#[derive(Clone, Debug, Serialize, Deserialize)]
569pub struct CreateFolioArgs {
570 pub workspace: String,
571 pub viewer: User,
572 pub input: NewFolio,
573}
574
575/// `update_folio`.
576#[derive(Clone, Debug, Serialize, Deserialize)]
577pub struct UpdateFolioArgs {
578 pub workspace: String,
579 pub viewer: User,
580 pub folio_id: String,
581 pub change: FolioChange,
582}
583
584/// `move_folio`.
585#[derive(Clone, Debug, Serialize, Deserialize)]
586pub struct MoveFolioArgs {
587 pub workspace: String,
588 pub viewer: User,
589 pub folio_id: String,
590 #[serde(rename = "move")]
591 pub to: FolioMove,
592}
593
594/// `edit_folio`: a `FolioAgentEdit`, as JSON.
595#[derive(Clone, Debug, Serialize, Deserialize)]
596pub struct EditFolioArgs {
597 pub workspace: String,
598 pub viewer: User,
599 pub folio_id: String,
600 pub edit: Value,
601}
602
603/// `set_folio_grant` and `set_folio_general_access` (see
604/// [`FolioAccessChange::method`]).
605#[derive(Clone, Debug, Serialize, Deserialize)]
606pub struct FolioAccessArgs {
607 pub workspace: String,
608 pub viewer: User,
609 pub folio_id: String,
610 pub change: FolioAccessChange,
611}
612
613/// `folio_version` and `restore_folio_version`.
614#[derive(Clone, Debug, Serialize, Deserialize)]
615pub struct FolioVersionArgs {
616 pub workspace: String,
617 pub viewer: User,
618 pub folio_id: String,
619 pub version_id: String,
620}
621
622/// `folio_templates`.
623#[derive(Clone, Debug, Serialize, Deserialize)]
624pub struct FolioTemplatesArgs {
625 pub workspace: String,
626 pub viewer: User,
627 pub kind: Option<FolioKind>,
628}
629
630/// `search_folios`.
631#[derive(Clone, Debug, Serialize, Deserialize)]
632pub struct SearchFoliosArgs {
633 pub workspace: String,
634 pub viewer: User,
635 pub query: FolioSearchQuery,
636}
637
638#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
639pub struct FolioSearchQuery {
640 pub q: String,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub kinds: Option<Vec<FolioKind>>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub space_id: Option<String>,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub project: Option<String>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub owner: Option<String>,
649 /// `words` or `hybrid`.
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub mode: Option<String>,
652 #[serde(default, skip_serializing_if = "Option::is_none")]
653 pub limit: Option<u32>,
654}
655
656/// `query_dataset`.
657#[derive(Clone, Debug, Serialize, Deserialize)]
658pub struct QueryDatasetArgs {
659 pub workspace: String,
660 pub viewer: User,
661 pub query: DatasetQuery,
662}
663
664// --- Events -------------------------------------------------------------------
665
666/// The `folio.*` types the docs service publishes, with no `repoId` on
667/// the event: a folio may be private, so it never reaches a repository's
668/// timeline or webhooks. Nothing subscribes to them yet.
669pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"];
670
671/// What every `folio.*` event carries (`FolioEventData` in events.ts).
672/// Event data is camelCase, as every event on the bus.
673#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
674#[serde(rename_all = "camelCase")]
675pub struct FolioEvent {
676 pub workspace: String,
677 pub workspace_id: String,
678 pub folio_id: String,
679 pub kind: FolioKind,
680 pub space_id: Option<String>,
681 /// None unless every member of the workspace can read it.
682 pub title: Option<String>,
683}
684
685/// `folio.updated`: a version.
686#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
687#[serde(rename_all = "camelCase")]
688pub struct FolioUpdated {
689 #[serde(flatten)]
690 pub folio: FolioEvent,
691 pub version_id: String,
692 /// edit, agent, suggestion, proposal or restore.
693 pub version_kind: String,
694 /// Member keys.
695 pub authors: Vec<String>,
696}
697
698/// `folio.shared`: who was given access, never content.
699#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
700#[serde(rename_all = "camelCase")]
701pub struct FolioShared {
702 #[serde(flatten)]
703 pub folio: FolioEvent,
704 pub principals: Vec<String>,
705 pub role: FolioRole,
706}
707
708/// `folio.stale`: code it cites changed.
709#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
710#[serde(rename_all = "camelCase")]
711pub struct FolioStale {
712 #[serde(flatten)]
713 pub folio: FolioEvent,
714 /// `owner/name`.
715 pub repo: String,
716 pub commit: String,
717 pub pull: Option<u32>,
718 pub paths: Vec<String>,
719 pub owners: Vec<String>,
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use serde_json::json;
726
727 const TS: &str = include_str!("../../../packages/contracts/src/folios.ts");
728 const EVENTS_TS: &str = include_str!("../../../packages/contracts/src/events.ts");
729
730 fn fixtures() -> Value {
731 serde_json::from_str(include_str!("../../../packages/contracts/src/folios.fixtures.json")).unwrap()
732 }
733
734 /// The body of `export type <name> = ...` up to its closing `};`.
735 fn ts_type<'a>(source: &'a str, name: &str) -> &'a str {
736 let start = format!("export type {name} = ");
737 source
738 .split_once(&start)
739 .and_then(|(_, rest)| rest.split_once("\n};"))
740 .map(|(body, _)| body)
741 .unwrap_or_else(|| panic!("{name} in the TypeScript"))
742 }
743
744 /// Every key `value` serializes to is a field of one of the TypeScript
745 /// types named.
746 fn fields_match(value: &Value, source: &str, types: &[&str]) {
747 let bodies: Vec<&str> = types.iter().map(|name| ts_type(source, name)).collect();
748 for key in value.as_object().unwrap().keys() {
749 assert!(
750 bodies.iter().any(|body| body.contains(&format!(" {key}: ")) || body.contains(&format!(" {key}?: "))),
751 "{key} is not a field of {types:?}"
752 );
753 }
754 }
755
756 fn folio() -> Folio {
757 serde_json::from_value(json!({
758 "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "kind": "slides", "title": "Q4 roadmap", "icon": null,
759 "slug": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "path": "/acme/-/artifacts/q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra",
760 "workspace_id": "wsp_1", "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" },
761 "parent_id": null, "position": 1.5,
762 "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" },
763 "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t" },
764 "created_at": "2026-10-09T00:00:00.000Z", "updated_at": "2026-10-09T00:00:00.000Z",
765 "edited_by": null, "edited_at": "2026-10-09T00:00:00.000Z", "trashed_at": null,
766 "viewer_role": "edit", "favorite": false, "private": false, "shared_count": 2,
767 "general_access": "none", "general_role": null, "inherit": true,
768 "inherited_from": { "kind": "space", "id": "spc_1", "name": "General" }, "agent_mode": "suggest",
769 "excerpt": "", "preview": { "kind": "slides", "aspect": "16:9", "count": 3, "layout": "title", "title": "Q4" },
770 "source": { "title": "#product", "href": "/acme/-/chat/c/1" }, "stale": false, "has_children": false
771 }))
772 .unwrap()
773 }
774
775 #[test]
776 fn shapes_have_the_typescript_field_names() {
777 fields_match(&serde_json::to_value(folio()).unwrap(), TS, &["FolioRef", "Folio"]);
778 let new = NewFolio {
779 kind: FolioKind::Doc,
780 title: Some("Plan".to_owned()),
781 icon: Some("🗺️".to_owned()),
782 space_id: Some("spc_1".to_owned()),
783 parent_id: Some("fol_1".to_owned()),
784 template_id: Some("tpl_1".to_owned()),
785 content: Some(FolioContentInput { markdown: Some("# Plan".to_owned()), spec: None }),
786 source: Some(FolioSource { title: "t".to_owned(), href: "/h".to_owned() }),
787 share_with: Some(vec![ShareWith { principal: "user:usr_2".to_owned(), role: FolioRole::View }]),
788 };
789 fields_match(&serde_json::to_value(&new).unwrap(), TS, &["NewFolio"]);
790 let query = FolioListQuery {
791 tab: FolioListTab::Shared,
792 kinds: Some(vec![FolioKind::Design]),
793 space_id: Some("s".to_owned()),
794 owner: Some("user:u".to_owned()),
795 project: Some("acme/web".to_owned()),
796 q: Some("roadmap".to_owned()),
797 cursor: Some("c".to_owned()),
798 limit: Some(20),
799 };
800 fields_match(&serde_json::to_value(&query).unwrap(), TS, &["FolioListQuery"]);
801 let change = FolioChange { title: Some("x".to_owned()), icon: Some(None), cover: Some(None), projects: Some(vec![]) };
802 fields_match(&serde_json::to_value(&change).unwrap(), TS, &["FolioChange"]);
803 let to = FolioMove { space_id: None, parent_id: None, before_id: Some("fol_2".to_owned()) };
804 assert_eq!(serde_json::to_value(&to).unwrap(), json!({ "space_id": null, "parent_id": null, "before_id": "fol_2" }));
805 let list = FolioList { items: vec![], next_cursor: None };
806 assert_eq!(serde_json::to_value(&list).unwrap(), json!({ "items": [], "next_cursor": null }));
807 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 };
808 fields_match(&serde_json::to_value(&version).unwrap(), TS, &["FolioVersion"]);
809 let template = FolioTemplate {
810 id: "tpl_1".to_owned(),
811 kind: FolioKind::Dashboard,
812 name: "Engineering health".to_owned(),
813 description: String::new(),
814 icon: None,
815 builtin: true,
816 body: "{}".to_owned(),
817 created_by: None,
818 };
819 fields_match(&serde_json::to_value(&template).unwrap(), TS, &["FolioTemplate"]);
820 // Every field of the TypeScript `Folio` comes back from Rust too.
821 let rust: Vec<String> = serde_json::to_value(folio()).unwrap().as_object().unwrap().keys().cloned().collect();
822 for body in [ts_type(TS, "FolioRef"), ts_type(TS, "Folio")] {
823 for line in body.lines() {
824 let line = line.trim_start();
825 if let Some((key, _)) = line.split_once(':').filter(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) {
826 assert!(rust.contains(&key.to_owned()), "Folio has no {key} in Rust");
827 }
828 }
829 }
830 }
831
832 #[test]
833 fn access_changes_travel_tagged_by_op() {
834 let change = FolioAccessChange::General { access: GeneralAccess::Link, role: Some(FolioRole::Comment) };
835 assert_eq!(serde_json::to_value(&change).unwrap(), json!({ "op": "general", "access": "link", "role": "comment" }));
836 assert_eq!(change.method(), "set_folio_general_access");
837 let grant: FolioAccessChange = serde_json::from_value(json!({ "op": "grant", "principal": "team:design", "role": "edit" })).unwrap();
838 assert_eq!(grant.method(), "set_folio_grant");
839 let mode: FolioAccessChange = serde_json::from_value(json!({ "op": "agent_mode", "agent_mode": "edit" })).unwrap();
840 assert_eq!(mode, FolioAccessChange::AgentMode { agent_mode: Some(AgentMode::Edit) });
841 }
842
843 /// The validators agree with the TypeScript's on the shared fixtures.
844 #[test]
845 fn agrees_with_the_typescript_validators_on_the_fixtures() {
846 let fixtures = fixtures();
847 let check = |case: &Value, outcome: Result<(), String>| match case["error"].as_str() {
848 None => assert_eq!(outcome, Ok(()), "{case}"),
849 Some("*") => assert!(outcome.is_err(), "{case} should be refused"),
850 Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{case}"),
851 };
852 for case in fixtures["new_folio"].as_array().unwrap() {
853 let outcome = serde_json::from_value::<NewFolio>(case["input"].clone()).map_err(|_| "*".to_owned()).and_then(|input| input.validate());
854 check(case, outcome);
855 }
856 for case in fixtures["access_change"].as_array().unwrap() {
857 let outcome = serde_json::from_value::<FolioAccessChange>(case["change"].clone()).map_err(|_| "*".to_owned()).and_then(|change| change.validate());
858 check(case, outcome);
859 }
860 for case in fixtures["agent_edit"].as_array().unwrap() {
861 check(case, agent_edit_error(&case["edit"]).map(|_| ()));
862 }
863 for case in fixtures["folio_ids"].as_array().unwrap() {
864 assert_eq!(folio_id_from(case["segment"].as_str().unwrap()), case["id"].as_str(), "{case}");
865 }
866 }
867
868 #[test]
869 fn the_typescript_mirror_has_the_same_kinds_methods_and_events() {
870 for kind in FolioKind::ALL {
871 assert!(TS.contains(&format!(" {}: \"{}\",", kind.as_str(), kind.label())), "{}", kind.as_str());
872 assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str());
873 }
874 let methods: Vec<&str> = TS
875 .split_once("export const FOLIO_RPC_METHODS = [")
876 .and_then(|(_, rest)| rest.split_once("] as const"))
877 .map(|(list, _)| list)
878 .expect("FOLIO_RPC_METHODS in folios.ts")
879 .lines()
880 .filter_map(|line| line.trim().strip_prefix('"').and_then(|rest| rest.split_once('"')).map(|(name, _)| name))
881 .collect();
882 assert_eq!(methods, FOLIO_RPC_METHODS);
883 for event in FOLIO_EVENTS {
884 assert!(EVENTS_TS.contains(&format!(" \"{event}\": FolioEventData")), "{event} in events.ts");
885 }
886 let event = FolioEvent {
887 workspace: "acme".to_owned(),
888 workspace_id: "wsp_1".to_owned(),
889 folio_id: "fol_1".to_owned(),
890 kind: FolioKind::Doc,
891 space_id: None,
892 title: None,
893 };
894 fields_match(&serde_json::to_value(&event).unwrap(), EVENTS_TS, &["FolioEventData"]);
895 }
896
897 #[test]
898 fn folio_events_read_as_published_and_name_no_repository_id() {
899 let data = json!({
900 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "doc", "spaceId": null, "title": null,
901 "repo": "acme/web", "commit": "abc", "pull": 431, "paths": ["src/export.ts"], "owners": ["user:usr_1"]
902 });
903 let stale: FolioStale = serde_json::from_value(data).unwrap();
904 assert_eq!((stale.folio.folio_id.as_str(), stale.pull), ("fol_1", Some(431)));
905 let json = serde_json::to_value(&stale).unwrap();
906 assert!(json.get("repoId").is_none());
907 let shared: FolioShared = serde_json::from_value(json!({
908 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "slides", "spaceId": "spc_1", "title": "Q4",
909 "principals": ["user:usr_2"], "role": "comment"
910 }))
911 .unwrap();
912 assert_eq!(shared.role, FolioRole::Comment);
913 // Not offered to webhooks.
914 for event in FOLIO_EVENTS {
915 assert!(!crate::webhooks::EVENT_TYPES.contains(&event), "{event}");
916 }
917 }
918
919 #[test]
920 fn principals_and_kinds() {
921 assert!(is_principal("user:usr_01jb"));
922 assert!(is_principal("team:platform-web"));
923 assert!(!is_principal("user:"));
924 assert!(!is_principal("group:x"));
925 assert!(!is_principal("user:a b"));
926 assert!(FolioKind::Doc.can_have_children());
927 assert!(FolioKind::ALL.iter().filter(|kind| kind.can_have_children()).count() == 1);
928 assert!(FolioRole::Manage > FolioRole::Edit && FolioRole::Comment > FolioRole::View);
929 }
930}