Skip to main content

onetaskgraph_plugin_api/
comment.rs

1//! Comments on a task, and the one refusal a source with none answers with.
2//!
3//! A comment is the one part of a task a person adds to in pieces, after the task exists
4//! and without rewriting it: evidence appended to an issue somebody else opened. So it is
5//! not a field of [`Task`](crate::Task) — a copy writes a task whole, and a copy without
6//! `--comments` adds, changes and removes no comment — but a thing of its own, read and
7//! written through its own methods of [`TaskSource`](crate::TaskSource).
8//!
9//! A copy with `--comments` keeps a destination's copies of a source's comments in step,
10//! and each copy it writes names the comment it was copied from as its [`CommentOrigin`]. A
11//! source that can store that natively overrides
12//! [`TaskSource::write_comment_copy`](crate::TaskSource::write_comment_copy); every other one
13//! is handed it as a hidden marker at the end of the body, [`CommentOrigin::mark`], which the
14//! engine strips back out of every comment it reads, [`Comment::unmarked`].
15
16use chrono::{DateTime, Utc};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::{NativeId, Page, SourceError, Task};
21
22/// One comment on a task, as its source holds it.
23///
24/// `id` and `body` are always there; every other member is `None` when the source did not
25/// give it, which is not the same as the source saying it is empty.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
27pub struct Comment {
28    /// The comment's own id, exactly as its source issued it.
29    ///
30    /// Opaque, as every [`NativeId`] is, and never qualified: a comment is addressed through
31    /// the qualified id of the task it is on, so its own id needs no source of its own.
32    pub id: NativeId,
33    /// Who wrote it, in the source's own spelling of a person.
34    pub author: Option<String>,
35    /// When the source says it was written.
36    pub created_at: Option<DateTime<Utc>>,
37    /// When the source says it last changed.
38    pub updated_at: Option<DateTime<Utc>>,
39    /// What it says, byte for byte as it was written — a trailing newline included.
40    // llmlint: ignore[invalid_states_unrepresentable] `CommentBody` is the invariant on what this product *writes*, and this is what a source already *holds*: a Linear comment that is only an attachment, or one a person cleared on the service's own page, has an empty body, and refusing it here would fail the whole of a task's comment list over one comment nobody here wrote. The read reports what the source holds; the write is what refuses nothing.
41    pub body: String,
42    /// Where a person can open it.
43    // llmlint: ignore[invalid_states_unrepresentable, boundary_inputs_validated] the reason recorded at `Task::url` in work.rs, at a new site: every entity of this contract carries its web address as `Option<String>`, parsing one would add a URL dependency to the crate AGENTS.md says to keep still, and a comment's address narrowed here alone would describe one thing in two types.
44    pub url: Option<String>,
45    /// The comment this one is a copy of, at its origin — present only on a comment a copy
46    /// wrote, and absent on every comment a person wrote where it is.
47    ///
48    /// On the second hop of a chained copy it still names the *first* origin, never the
49    /// source the copy read it from. Left out of the wire when absent, so a reader written
50    /// before it existed reads exactly what it read before.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub origin: Option<CommentOrigin>,
53}
54
55/// Where a copied comment came from: the comment as its origin holds it.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
57pub struct CommentOrigin {
58    /// The task the comment is on at its origin, qualified: `<source>:<native task id>`.
59    // llmlint: ignore[invalid_states_unrepresentable] A qualified id is `onetaskgraph-core`'s `GlobalId`, which this crate cannot name (a plugin never sees one — AGENTS.md, "The plugin contract"); `TaskRef` admits a bare id, which this must never be. The engine is the one writer, and it writes a qualified id's own rendering.
60    pub task: String,
61    /// The comment's own id at its origin.
62    pub id: NativeId,
63    /// Who wrote it there, in that source's spelling of a person.
64    pub author: Option<String>,
65    /// When the origin says it was written.
66    pub created_at: Option<DateTime<Utc>>,
67    /// When the origin says it last changed.
68    pub updated_at: Option<DateTime<Utc>>,
69    /// Where a person can open it at its origin.
70    // llmlint: ignore[invalid_states_unrepresentable, boundary_inputs_validated] the reason recorded at `Comment::url` above, at a new site.
71    pub url: Option<String>,
72}
73
74/// The opening of the marker [`CommentOrigin::mark`] appends to a body.
75const MARKER_OPEN: &str = "<!-- onetaskgraph.comment_origin `";
76/// The close of that marker.
77const MARKER_CLOSE: &str = "` -->";
78/// What separates the marker from the body it follows.
79const MARKER_SEPARATOR: &str = "\n\n";
80
81impl CommentOrigin {
82    /// Whether `task` is a qualified task id — `<source>:<native task id>`, the source a valid
83    /// source name and the native id not empty — and so can be an origin's [`task`](Self::task).
84    ///
85    /// What a reader holds to before it takes a stored origin as one: the origin's task decides
86    /// which copies a sync owns, edits and deletes, and which comments it never sends back, so
87    /// one naming no task is no origin at all and is read as the text it was stored as.
88    #[must_use]
89    pub fn is_qualified_task(task: &str) -> bool {
90        task.split_once(':').is_some_and(|(source, native)| {
91            !native.is_empty() && crate::SourceName::new(source).is_ok()
92        })
93    }
94
95    /// This origin, refused as [`SourceError::Malformed`] when its task is not a qualified task
96    /// id — what every writer of a copied comment holds an origin to before it writes one, since
97    /// such an origin would be stored and then read back as none.
98    ///
99    /// # Errors
100    ///
101    /// [`SourceError::Malformed`], naming the task it was given.
102    pub fn qualified(&self) -> Result<&Self, SourceError> {
103        if Self::is_qualified_task(&self.task) {
104            Ok(self)
105        } else {
106            Err(SourceError::Malformed {
107                message: format!(
108                    "a copied comment's origin names its task as {:?}, which is not a \
109                     qualified task id `<source>:<native task id>`",
110                    self.task
111                ),
112            })
113        }
114    }
115
116    /// `body` with this origin appended as a hidden marker, for a source that cannot store a
117    /// comment's origin, author or times natively.
118    ///
119    /// One line, an HTML comment whose payload is compact JSON inside a code span —
120    /// ``<!-- onetaskgraph.comment_origin `{…}` -->`` — because that is the spelling both
121    /// hosted services this product drives keep byte for byte (the Linear plugin's module
122    /// documentation records what Linear does to every other one). `<`, `>` and `` ` `` are
123    /// escaped inside the JSON, where the escape means the same character, so no value can
124    /// close the marker early.
125    #[must_use]
126    pub fn mark(&self, body: &str) -> String {
127        let encoded = serde_json::to_string(self)
128            .expect("a comment origin serialises")
129            .replace('<', "\\u003c")
130            .replace('>', "\\u003e")
131            .replace('`', "\\u0060");
132        format!("{body}{MARKER_SEPARATOR}{MARKER_OPEN}{encoded}{MARKER_CLOSE}")
133    }
134}
135
136impl CommentOrigin {
137    /// `body`, carrying over the hidden marker the comment it replaces ends in — `held`, that
138    /// comment's body exactly as its source holds it — when `body` ends in none of its own.
139    ///
140    /// What a source that keeps a copied comment's origin as the marker answers an edit with:
141    /// an edit changes what a comment says and nothing else, so a comment a copy wrote is still
142    /// that copy afterwards, and the next copy keeps it in step rather than writing another
143    /// beside it. A `body` that ends in a marker of its own — a copy rewriting its own comment —
144    /// is left as it is.
145    ///
146    /// # Errors
147    ///
148    /// None in practice, because the body only grows: [`SourceError::Refused`] is what
149    /// [`CommentBody::new`] answers an empty text with.
150    pub fn kept(held: &str, body: &CommentBody) -> Result<CommentBody, SourceError> {
151        let origin_of = |text: &str| {
152            Comment {
153                id: NativeId(String::new()),
154                author: None,
155                created_at: None,
156                updated_at: None,
157                body: text.to_owned(),
158                url: None,
159                origin: None,
160            }
161            .unmarked()
162            .origin
163        };
164        match (origin_of(held), origin_of(body.as_str())) {
165            (Some(origin), None) => CommentBody::new(origin.mark(body.as_str())),
166            _ => Ok(body.clone()),
167        }
168    }
169}
170
171impl Comment {
172    /// This comment with a marker [`CommentOrigin::mark`] wrote taken out of its body and
173    /// reported as its [`origin`](Self::origin); unchanged when it carries an origin already
174    /// or its body ends in no such marker.
175    ///
176    /// A marker that does not parse is left in the body as text: it is not one this product
177    /// wrote, and reading it as an origin would put words in a person's comment. An origin a
178    /// source reports natively — a stdio peer's, say — is held to the same test: one naming no
179    /// qualified task is dropped, so the comment reads as one written where it is, which a copy
180    /// never edits or deletes.
181    #[must_use]
182    pub fn unmarked(mut self) -> Self {
183        if let Some(origin) = &self.origin {
184            if !CommentOrigin::is_qualified_task(&origin.task) {
185                self.origin = None;
186            }
187            return self;
188        }
189        let trimmed = self.body.trim_end();
190        let Some(open) = trimmed.rfind(MARKER_OPEN) else {
191            return self;
192        };
193        let Some(encoded) = trimmed[open + MARKER_OPEN.len()..].strip_suffix(MARKER_CLOSE) else {
194            return self;
195        };
196        let Ok(origin) = serde_json::from_str::<CommentOrigin>(encoded) else {
197            return self;
198        };
199        if !CommentOrigin::is_qualified_task(&origin.task) {
200            return self;
201        }
202        let before = &self.body[..open];
203        let body = before.strip_suffix(MARKER_SEPARATOR).unwrap_or(before);
204        self.body = body.to_owned();
205        self.origin = Some(origin);
206        self
207    }
208}
209
210/// One comment a copy writes: what it says, and the comment it is a copy of.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
212pub struct CommentCopy {
213    /// What it says, as its origin says it.
214    pub body: CommentBody,
215    /// The comment it is a copy of.
216    pub origin: CommentOrigin,
217}
218
219/// What a comment says: anything at all, except nothing.
220///
221/// A newtype rather than a `String` checked by whoever happens to read it, because an empty
222/// comment is refused by every source this product drives and by the command line before
223/// any of them: a value of this type is one a source can be handed without asking again.
224/// Everything else about the text is kept exactly — no trimming, no newline normalisation —
225/// because a body quoting a command or a stack trace is only useful byte for byte.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
227#[serde(try_from = "String", into = "String")]
228pub struct CommentBody(String);
229
230impl CommentBody {
231    /// Wrap `text` as a comment body.
232    ///
233    /// # Errors
234    ///
235    /// Returns [`SourceError::Refused`] when `text` is empty, saying so.
236    pub fn new(text: impl Into<String>) -> Result<Self, SourceError> {
237        let text = text.into();
238        if text.is_empty() {
239            return Err(SourceError::Refused {
240                message: "a comment body cannot be empty; write what the comment says".to_owned(),
241            });
242        }
243        Ok(Self(text))
244    }
245
246    /// The text, exactly as it was given.
247    #[must_use]
248    pub fn as_str(&self) -> &str {
249        &self.0
250    }
251}
252
253impl TryFrom<String> for CommentBody {
254    type Error = String;
255
256    fn try_from(text: String) -> Result<Self, Self::Error> {
257        Self::new(text).map_err(|error| error.to_string())
258    }
259}
260
261impl From<CommentBody> for String {
262    fn from(body: CommentBody) -> Self {
263        body.0
264    }
265}
266
267/// One comment to add to a task.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
269pub struct NewComment {
270    /// What it says.
271    pub body: CommentBody,
272    /// Who wrote it, when the caller says.
273    ///
274    /// A source that records the author itself — a hosted service that knows which account
275    /// is signed in — refuses a comment carrying one rather than dropping it, naming why:
276    /// quietly posting under another name than the one asked for is the one wrong answer
277    /// here.
278    // llmlint: ignore[invalid_states_unrepresentable] an author is the source's own spelling of a person — a login, a display name, a free-form name in a Markdown file — so there is no narrower type every source could agree on; each source refuses what it cannot record, naming it, which is the rule every write of this contract follows.
279    #[serde(default)]
280    pub author: Option<String>,
281}
282
283/// One task as [`TaskSource::get_task_details`](crate::TaskSource::get_task_details) reads it:
284/// the task, and the first page of its comments when they were asked for.
285///
286/// The comments are a result of their own rather than part of the task's, because the two
287/// can fail apart: a task that was read and whose comments could not be — a board draft,
288/// which has none — is still a task a caller is owed, with the comment failure beside it.
289#[derive(Debug, Clone, PartialEq)]
290pub struct TaskDetailRead {
291    /// The task, exactly as [`TaskSource::get_task`](crate::TaskSource::get_task) answers it.
292    pub task: Task,
293    /// The first page of its comments, oldest first: `None` when the read did not ask for
294    /// them, and otherwise exactly what
295    /// [`TaskSource::task_comments`](crate::TaskSource::task_comments) answers for that page
296    /// — `Ok(None)` included, for a task gone between the two halves of a read that made two.
297    pub comments: Option<Result<Option<Page<Comment>>, SourceError>>,
298}
299
300/// The refusal a source with no comments answers a comment call with.
301///
302/// Spelled once beside [`unwritable`](crate::unwritable) and
303/// [`documentless`](crate::documentless), for the reason they are: a source saying it does not
304/// have that side of the contract at all refuses in the same words whichever plugin it is,
305/// and cannot describe something different from the engine's own message about it.
306#[must_use]
307pub fn commentless(kind: &str) -> SourceError {
308    SourceError::Refused {
309        message: format!("the {kind} plugin has no comments"),
310    }
311}