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 nothing a copy
6//! does may add, change or remove a comment — but a thing of its own, read and written
7//! through its own four methods of [`TaskSource`](crate::TaskSource).
8
9use chrono::{DateTime, Utc};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::{NativeId, SourceError};
14
15/// One comment on a task, as its source holds it.
16///
17/// `id` and `body` are always there; every other member is `None` when the source did not
18/// give it, which is not the same as the source saying it is empty.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
20pub struct Comment {
21 /// The comment's own id, exactly as its source issued it.
22 ///
23 /// Opaque, as every [`NativeId`] is, and never qualified: a comment is addressed through
24 /// the qualified id of the task it is on, so its own id needs no source of its own.
25 pub id: NativeId,
26 /// Who wrote it, in the source's own spelling of a person.
27 pub author: Option<String>,
28 /// When the source says it was written.
29 pub created_at: Option<DateTime<Utc>>,
30 /// When the source says it last changed.
31 pub updated_at: Option<DateTime<Utc>>,
32 /// What it says, byte for byte as it was written — a trailing newline included.
33 // 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.
34 pub body: String,
35 /// Where a person can open it.
36 // 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.
37 pub url: Option<String>,
38}
39
40/// What a comment says: anything at all, except nothing.
41///
42/// A newtype rather than a `String` checked by whoever happens to read it, because an empty
43/// comment is refused by every source this product drives and by the command line before
44/// any of them: a value of this type is one a source can be handed without asking again.
45/// Everything else about the text is kept exactly — no trimming, no newline normalisation —
46/// because a body quoting a command or a stack trace is only useful byte for byte.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(try_from = "String", into = "String")]
49pub struct CommentBody(String);
50
51impl CommentBody {
52 /// Wrap `text` as a comment body.
53 ///
54 /// # Errors
55 ///
56 /// Returns [`SourceError::Refused`] when `text` is empty, saying so.
57 pub fn new(text: impl Into<String>) -> Result<Self, SourceError> {
58 let text = text.into();
59 if text.is_empty() {
60 return Err(SourceError::Refused {
61 message: "a comment body cannot be empty; write what the comment says".to_owned(),
62 });
63 }
64 Ok(Self(text))
65 }
66
67 /// The text, exactly as it was given.
68 #[must_use]
69 pub fn as_str(&self) -> &str {
70 &self.0
71 }
72}
73
74impl TryFrom<String> for CommentBody {
75 type Error = String;
76
77 fn try_from(text: String) -> Result<Self, Self::Error> {
78 Self::new(text).map_err(|error| error.to_string())
79 }
80}
81
82impl From<CommentBody> for String {
83 fn from(body: CommentBody) -> Self {
84 body.0
85 }
86}
87
88/// One comment to add to a task.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90pub struct NewComment {
91 /// What it says.
92 pub body: CommentBody,
93 /// Who wrote it, when the caller says.
94 ///
95 /// A source that records the author itself — a hosted service that knows which account
96 /// is signed in — refuses a comment carrying one rather than dropping it, naming why:
97 /// quietly posting under another name than the one asked for is the one wrong answer
98 /// here.
99 // 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.
100 #[serde(default)]
101 pub author: Option<String>,
102}
103
104/// The refusal a source with no comments answers a comment call with.
105///
106/// Spelled once beside [`unwritable`](crate::unwritable) and
107/// [`documentless`](crate::documentless), for the reason they are: a source saying it does not
108/// have that side of the contract at all refuses in the same words whichever plugin it is,
109/// and cannot describe something different from the engine's own message about it.
110#[must_use]
111pub fn commentless(kind: &str) -> SourceError {
112 SourceError::Refused {
113 message: format!("the {kind} plugin has no comments"),
114 }
115}