forge_primitives/document.rs
1//! The document hand-off's types: what a hand-off names, and what a read
2//! answers with.
3//!
4//! Type-only - the read that produces a [`DocumentRead`] lives in
5//! `forge_agent::env::documents`. The kind crosses every layer (the read
6//! detects it, the wire carries it, a viewer draws it), so it lives here per
7//! the crate placement guide; the resolved path, bytes and reviewable flag
8//! ride beside it rather than as separate parameters everywhere.
9
10use std::path::PathBuf;
11
12use serde::{Deserialize, Serialize};
13
14/// A parked hand-off as the read surface carries it: the id an answer
15/// names, the resolved path the viewer opens at, and the reviewable flag.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct DocumentHandOffAsk {
18 pub id: uuid::Uuid,
19 pub path: String,
20 pub reviewable: bool,
21}
22
23/// How a document hand-off ended, as the reader's answer and the parked
24/// call's release.
25///
26/// **The parked call answers on a decision, not on the open.** The reader
27/// accepts, reads and comments, and then chooses one of two outcomes:
28/// `Approve` or `Feedback`. Both may carry a note and name the review the
29/// finish filed. Approval is the one signal an agent may act on; feedback
30/// means the document is revised and handed off again. A bare done is not
31/// an outcome, so a reader who presses neither leaves the call parked; a
32/// reader who never accepts releases it with `NotNow` instead.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum DocumentHandOffEnding {
36 /// The reader approved the document and the agent may proceed.
37 /// `review_id` names the review the finish filed under the document's
38 /// scope, null when nothing was commented; `note` is the reader's
39 /// own words, carried verbatim.
40 Approve { review_id: Option<String>, note: Option<String> },
41 /// The reader sent feedback. The document is to be revised and handed
42 /// off again - this is never approval, whatever it carries. `review_id`
43 /// and `note` mean what `Approve`'s do.
44 Feedback { review_id: Option<String>, note: Option<String> },
45 /// The reader chose not to open it.
46 NotNow,
47 /// The call's waiter went away before any answer.
48 Abandoned,
49}
50
51/// What a document is, as far as the read can tell - by extension, not by
52/// sniffing the bytes.
53///
54/// `Other` is kept as itself rather than bucketed: a viewer draws the kind
55/// it was handed, and folding every unknown into one variant would leave it
56/// with nothing to say about what the document is. The Files reader's wider
57/// UTF-8 drawing classification is the client's policy, not this kind.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum DocumentKind {
61 Markdown,
62 Pdf,
63 Text,
64 /// An extension the list does not know, lowercased and bare (`"rs"`),
65 /// or empty when the name carries none.
66 Other(String),
67}
68
69/// One document, as the read answered: the path it resolved to, the bytes,
70/// the detected kind, and whether the DOCUMENT can carry a review - the
71/// hand-off's commentable set, by extension.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DocumentRead {
74 /// The resolved absolute path - what a viewer deep-links by, and what
75 /// the hand-off answers with.
76 pub path: PathBuf,
77 pub kind: DocumentKind,
78 pub bytes: Vec<u8>,
79 pub reviewable: bool,
80 /// FNV-1a over the whole document's bytes, the same function a code
81 /// anchor hashes a line with. Threads are placed by their own anchors,
82 /// re-found by their first captured line against the document a read
83 /// carries; this is the read's own fact about the content it answered.
84 pub content_hash: u64,
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 /// **The two answers cross as their own names, note and review
92 /// included.** A reader's approval and a reader's feedback are the one
93 /// signal the agent may act on and may not: an unknown or misread
94 /// ending would let a revision be taken as done.
95 #[test]
96 fn the_two_answers_cross_as_their_own_names_with_their_note() {
97 let approve = DocumentHandOffEnding::Approve {
98 review_id: Some("r-1".to_owned()),
99 note: Some("ship it".to_owned()),
100 };
101 assert_eq!(
102 serde_json::to_value(&approve).expect("approval encodes"),
103 serde_json::json!({"approve": {"review_id": "r-1", "note": "ship it"}}),
104 );
105 let feedback = DocumentHandOffEnding::Feedback { review_id: None, note: None };
106 assert_eq!(
107 serde_json::to_value(&feedback).expect("feedback encodes"),
108 serde_json::json!({"feedback": {"review_id": null, "note": null}}),
109 );
110 let back: DocumentHandOffEnding = serde_json::from_value(
111 serde_json::json!({"feedback": {"review_id": "r-2", "note": "no"}}),
112 )
113 .expect("feedback decodes");
114 assert_eq!(
115 back,
116 DocumentHandOffEnding::Feedback {
117 review_id: Some("r-2".to_owned()),
118 note: Some("no".to_owned()),
119 },
120 );
121 }
122
123 /// A kind the list does not know crosses as itself, and one with no
124 /// extension as an empty name - the two shapes a viewer's narrowing has
125 /// to read, so they are pinned here rather than only in the envelope.
126 #[test]
127 fn other_kinds_cross_as_their_own_name() {
128 let known = serde_json::to_value(DocumentKind::Markdown).expect("a kind encodes");
129 assert_eq!(known, serde_json::json!("markdown"));
130
131 let other = serde_json::to_value(DocumentKind::Other("rs".to_owned())).expect("encodes");
132 assert_eq!(other, serde_json::json!({ "other": "rs" }));
133
134 let back: DocumentKind = serde_json::from_value(other).expect("decodes");
135 assert_eq!(back, DocumentKind::Other("rs".to_owned()));
136 }
137}