Skip to main content

radicle_ci_broker/
msg.rs

1//! Messages for communicating with the adapter.
2//!
3//! The broker spawns an adapter child process, and sends it a request
4//! via the child's stdin. The child sends responses via its stdout,
5//! which the broker reads and processes. These messages are
6//! represented using the types in this module.
7//!
8//! The types in this module are meant to be useful for anyone writing
9//! a Radicle CI adapter.
10
11#![deny(missing_docs)]
12
13use std::{
14    fmt,
15    hash::{Hash, Hasher},
16    io::{BufRead, BufReader, Read, Write},
17    str::FromStr,
18};
19
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22use uuid::Uuid;
23
24use radicle::{
25    Profile,
26    identity::Did,
27    node::{Alias, AliasStore},
28    patch::{self, RevisionId},
29    storage::{ReadRepository, ReadStorage, git::paths},
30};
31pub use radicle::{
32    cob::patch::PatchId,
33    prelude::{NodeId, RepoId},
34};
35pub use radicle_surf::Commit;
36
37use crate::{
38    ci_event::{CiEvent, CiEventV1},
39    ergo::Oid,
40    logger,
41};
42
43// This gets put into every [`Request`] message so the adapter can
44// detect its getting a message it knows how to handle.
45const PROTOCOL_VERSION: usize = 1;
46
47/// The type of a run identifier. For maximum generality, this is a
48/// string rather than an integer.
49///
50/// # Example
51/// ```rust
52/// use radicle_ci_broker::msg::RunId;
53/// let id = RunId::from("abracadabra");
54/// println!("{}", id.to_string());
55/// ```
56#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
57pub struct RunId {
58    id: String,
59}
60
61impl Default for RunId {
62    fn default() -> Self {
63        Self {
64            id: Uuid::new_v4().to_string(),
65        }
66    }
67}
68
69impl Hash for RunId {
70    fn hash<H: Hasher>(&self, h: &mut H) {
71        self.id.hash(h);
72    }
73}
74
75impl From<&str> for RunId {
76    fn from(id: &str) -> Self {
77        Self { id: id.into() }
78    }
79}
80
81impl TryFrom<Value> for RunId {
82    type Error = ();
83    fn try_from(id: Value) -> Result<Self, Self::Error> {
84        match id {
85            Value::String(s) => Ok(Self::from(s.as_str())),
86            _ => Err(()),
87        }
88    }
89}
90
91impl fmt::Display for RunId {
92    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
93        write!(f, "{}", self.id)
94    }
95}
96
97impl RunId {
98    /// Return representation of identifier as a string slice.
99    pub fn as_str(&self) -> &str {
100        &self.id
101    }
102}
103
104/// The result of a CI run.
105#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107#[serde(rename_all = "snake_case")]
108#[non_exhaustive]
109pub enum RunResult {
110    /// CI run was successful.
111    Success,
112
113    /// CI run failed.
114    Failure,
115}
116
117impl fmt::Display for RunResult {
118    fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
119        match self {
120            Self::Failure => write!(f, "failure"),
121            Self::Success => write!(f, "success"),
122        }
123    }
124}
125
126/// Build a [`Request`].
127#[derive(Debug, Default)]
128pub struct RequestBuilder<'a> {
129    profile: Option<&'a Profile>,
130    ci_event: Option<&'a CiEvent>,
131}
132
133impl<'a> RequestBuilder<'a> {
134    /// Set the node profile to use.
135    pub fn profile(mut self, profile: &'a Profile) -> Self {
136        self.profile = Some(profile);
137        self
138    }
139
140    /// Set the CI event to use.
141    pub fn ci_event(mut self, event: &'a CiEvent) -> Self {
142        self.ci_event = Some(event);
143        self
144    }
145
146    /// Create a [`Request::Trigger``] message from a [`crate::ci_event::Civet`].
147    pub fn build_trigger_from_ci_event(self) -> Result<Request, MessageError> {
148        fn repository(repo: &RepoId, profile: &Profile) -> Result<Repository, MessageError> {
149            let rad_repo = match profile.storage.repository(*repo) {
150                Err(err) => {
151                    return Err(MessageError::repository_error(err));
152                }
153                Ok(rad_repo) => rad_repo,
154            };
155
156            let project_info = match rad_repo.project() {
157                Err(err) => {
158                    return Err(MessageError::repository_error(err));
159                }
160                Ok(x) => x,
161            };
162            let identity = rad_repo
163                .identity()
164                .map_err(MessageError::repository_error)?;
165            let delegates = rad_repo
166                .delegates()
167                .map_err(MessageError::repository_error)?;
168            Ok(Repository {
169                id: *repo,
170                name: project_info.name().to_string(),
171                description: project_info.description().to_string(),
172                private: !identity.visibility().is_public(),
173                default_branch: project_info.default_branch().to_string(),
174                delegates: delegates.iter().copied().collect(),
175            })
176        }
177
178        fn common_fields(
179            event_type: EventType,
180            repo: &RepoId,
181            profile: &Profile,
182        ) -> Result<EventCommonFields, MessageError> {
183            let repository = match repository(repo, profile) {
184                Err(err) => {
185                    return Err(err)?;
186                }
187                Ok(x) => x,
188            };
189            Ok(EventCommonFields {
190                version: PROTOCOL_VERSION,
191                event_type,
192                repository,
193            })
194        }
195
196        fn author(node: &NodeId, profile: &Profile) -> Result<Author, MessageError> {
197            let did = Did::from(*node);
198            did_to_author(profile, &did)
199        }
200
201        fn commits(
202            git_repo: &radicle_surf::Repository,
203            tip: Oid,
204            base: Oid,
205        ) -> Result<Vec<Oid>, radicle_surf::Error> {
206            // We have an object ID from the `radicle` crate. We need to
207            // convert into a value of the type `radicle-surf` wants, which
208            // is from `radicle-git-ext`. As of 2026-01-16, we have multiple
209            // versions of `radicle-git-ext` in our dependency graph: the latest
210            // version of `radicle-surf` depends on an older version of `radicle-git-ext`
211            // thatn `radicle` itself does.
212
213            // Unwrapping is OK here, because we know `tip` is OK.
214            #[allow(clippy::unwrap_used)]
215            let commit = {
216                let ext_oid = radicle_surf::Oid::try_from(tip.as_ref()).unwrap();
217                git_repo.commit(ext_oid).unwrap()
218            };
219            git_repo
220                .history(commit)?
221                .take_while(|c| {
222                    if let Ok(c) = c {
223                        c.id.as_bytes() != base.as_ref()
224                    } else {
225                        false
226                    }
227                })
228                .filter_map(|result| {
229                    if let Ok(commit) = result {
230                        if let Ok(oid) = Oid::from_str(&commit.id.to_string()) {
231                            Some(Ok(oid))
232                        } else {
233                            None
234                        }
235                    } else {
236                        None
237                    }
238                })
239                .collect::<Result<Vec<Oid>, _>>()
240        }
241
242        fn patch_cob(
243            rad_repo: &radicle::storage::git::Repository,
244            patch_id: &PatchId,
245        ) -> Result<radicle::cob::patch::Patch, MessageError> {
246            let x = match patch::Patches::open(rad_repo) {
247                Err(err) => {
248                    return Err(MessageError::repository_error(err))?;
249                }
250                Ok(x) => x,
251            };
252
253            let x = match x.get(patch_id) {
254                Err(err) => {
255                    return Err(MessageError::cob_store_error(err))?;
256                }
257                Ok(x) => x,
258            };
259
260            let x = match x {
261                None => {
262                    logger::patch_cob_lookup(&rad_repo.id, patch_id);
263                    return Err(MessageError::PatchCob(*patch_id));
264                }
265                Some(x) => x,
266            };
267
268            Ok(x)
269        }
270
271        fn revisions(
272            patch_cob: &radicle::cob::patch::Patch,
273            author: &Author,
274        ) -> Result<Vec<Revision>, MessageError> {
275            patch_cob
276                .revisions()
277                .map(|(rid, r)| {
278                    Ok::<Revision, MessageError>(Revision {
279                        id: rid.into(),
280                        author: author.clone(),
281                        description: r.description().to_string(),
282                        base: *r.base(),
283                        oid: r.head(),
284                        timestamp: r.timestamp().as_secs(),
285                    })
286                })
287                .collect::<Result<Vec<Revision>, MessageError>>()
288        }
289
290        fn patch_base(
291            patch_cob: &radicle::cob::patch::Patch,
292            patch_id: &PatchId,
293            author: &Author,
294        ) -> Result<Oid, MessageError> {
295            let author_pk = radicle::crypto::PublicKey::from(author.id);
296            let (_id, revision) = match patch_cob.latest_by(&author_pk) {
297                None => {
298                    return Err(MessageError::LatestPatchRevision(*patch_id));
299                }
300                Some(x) => x,
301            };
302            Ok(*revision.base())
303        }
304
305        let profile = self.profile.ok_or(MessageError::NoProfile)?;
306
307        match self.ci_event {
308            None => Err(MessageError::CiEventNotSet),
309            Some(CiEvent::V1(CiEventV1::BranchCreated {
310                from_node,
311                repo,
312                branch,
313                tip,
314            })) => {
315                Ok(Request::Trigger {
316                    common: common_fields(EventType::Push, repo, profile)?,
317                    push: Some(PushEvent {
318                        pusher: author(from_node, profile)?,
319                        before: *tip, // Branch created: we only use the tip
320                        after: *tip,
321                        branch: branch.as_str().to_string(),
322                        commits: vec![*tip], // Branch created, only use tip.
323                    }),
324                    patch: None,
325                })
326            }
327            Some(CiEvent::V1(CiEventV1::BranchUpdated {
328                from_node,
329                repo,
330                branch,
331                tip,
332                old_tip,
333            })) => {
334                let git_repo =
335                    radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
336                        .map_err(MessageError::radicle_surf_error)?;
337                let mut commits =
338                    commits(&git_repo, *tip, *old_tip).map_err(MessageError::radicle_surf_error)?;
339                if commits.is_empty() {
340                    commits = vec![*old_tip];
341                }
342
343                Ok(Request::Trigger {
344                    common: common_fields(EventType::Push, repo, profile)?,
345                    push: Some(PushEvent {
346                        pusher: author(from_node, profile)?,
347                        before: *tip, // Branch created: we only use the tip
348                        after: *tip,
349                        branch: branch.as_str().to_string(),
350                        commits,
351                    }),
352                    patch: None,
353                })
354            }
355            Some(CiEvent::V1(CiEventV1::BranchDeleted {
356                from_node,
357                repo,
358                branch,
359                tip,
360            })) => {
361                Ok(Request::Trigger {
362                    common: common_fields(EventType::Push, repo, profile)?,
363                    push: Some(PushEvent {
364                        pusher: author(from_node, profile)?,
365                        before: *tip, // Branch created: we only use the tip
366                        after: *tip,
367                        branch: branch.as_str().to_string(),
368                        commits: vec![*tip],
369                    }),
370                    patch: None,
371                })
372            }
373            Some(CiEvent::V1(CiEventV1::TagCreated {
374                from_node,
375                repo,
376                tag,
377                tip,
378            })) => {
379                Ok(Request::Trigger {
380                    common: common_fields(EventType::Push, repo, profile)?,
381                    push: Some(PushEvent {
382                        pusher: author(from_node, profile)?,
383                        before: *tip, // Branch created: we only use the tip
384                        after: *tip,
385                        branch: tag.as_str().to_string(),
386                        commits: vec![*tip], // Branch created, only use tip.
387                    }),
388                    patch: None,
389                })
390            }
391            Some(CiEvent::V1(CiEventV1::TagUpdated {
392                from_node,
393                repo,
394                tag,
395                tip,
396                old_tip,
397            })) => {
398                let git_repo =
399                    radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
400                        .map_err(MessageError::radicle_surf_error)?;
401                let mut commits =
402                    commits(&git_repo, *tip, *old_tip).map_err(MessageError::radicle_surf_error)?;
403                if commits.is_empty() {
404                    commits = vec![*old_tip];
405                }
406
407                Ok(Request::Trigger {
408                    common: common_fields(EventType::Push, repo, profile)?,
409                    push: Some(PushEvent {
410                        pusher: author(from_node, profile)?,
411                        before: *tip, // Branch created: we only use the tip
412                        after: *tip,
413                        branch: tag.as_str().to_string(),
414                        commits,
415                    }),
416                    patch: None,
417                })
418            }
419            Some(CiEvent::V1(CiEventV1::TagDeleted {
420                from_node,
421                repo,
422                tag,
423                tip,
424            })) => {
425                Ok(Request::Trigger {
426                    common: common_fields(EventType::Push, repo, profile)?,
427                    push: Some(PushEvent {
428                        pusher: author(from_node, profile)?,
429                        before: *tip, // Branch created: we only use the tip
430                        after: *tip,
431                        branch: tag.as_str().to_string(),
432                        commits: vec![*tip],
433                    }),
434                    patch: None,
435                })
436            }
437            Some(CiEvent::V1(CiEventV1::PatchCreated {
438                from_node,
439                repo,
440                patch: patch_id,
441                new_tip,
442            })) => {
443                let rad_repo = profile
444                    .storage
445                    .repository(*repo)
446                    .map_err(MessageError::repository_error)?;
447                let git_repo =
448                    radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
449                        .map_err(MessageError::radicle_surf_error)?;
450                let author = author(from_node, profile)?;
451                let patch_cob = patch_cob(&rad_repo, patch_id)?;
452                let revisions = revisions(&patch_cob, &author)?;
453                let patch_base = patch_base(&patch_cob, patch_id, &author)?;
454                let commits = commits(&git_repo, *new_tip, patch_base)
455                    .map_err(MessageError::radicle_surf_error)?;
456
457                Ok(Request::Trigger {
458                    common: common_fields(EventType::Patch, repo, profile)?,
459                    push: None,
460                    patch: Some(PatchEvent {
461                        action: PatchAction::Created,
462                        patch: Patch {
463                            id: **patch_id,
464                            author,
465                            title: patch_cob.title().to_string(),
466                            state: State {
467                                status: patch_cob.state().to_string(),
468                                conflicts: match patch_cob.state() {
469                                    patch::State::Open { conflicts, .. } => conflicts.to_vec(),
470                                    _ => vec![],
471                                },
472                            },
473                            before: patch_base,
474                            after: *new_tip,
475                            commits,
476                            target: patch_cob
477                                .target()
478                                .head(&rad_repo)
479                                .map_err(MessageError::repository_error)?,
480                            labels: patch_cob.labels().map(|l| l.name().to_string()).collect(),
481                            assignees: patch_cob.assignees().collect(),
482                            revisions,
483                        },
484                    }),
485                })
486            }
487            Some(CiEvent::V1(CiEventV1::PatchUpdated {
488                from_node,
489                repo,
490                patch: patch_id,
491                new_tip,
492            })) => {
493                let rad_repo = profile
494                    .storage
495                    .repository(*repo)
496                    .map_err(MessageError::repository_error)?;
497                let git_repo =
498                    radicle_surf::Repository::open(paths::repository(&profile.storage, repo))
499                        .map_err(MessageError::radicle_surf_error)?;
500                let author = author(from_node, profile)?;
501                let patch_cob = patch_cob(&rad_repo, patch_id)?;
502                let revisions = revisions(&patch_cob, &author)?;
503                let patch_base = patch_base(&patch_cob, patch_id, &author)?;
504                let commits = commits(&git_repo, *new_tip, patch_base)
505                    .map_err(MessageError::radicle_surf_error)?;
506
507                Ok(Request::Trigger {
508                    common: common_fields(EventType::Patch, repo, profile)?,
509                    push: None,
510                    patch: Some(PatchEvent {
511                        action: PatchAction::Updated,
512                        patch: Patch {
513                            id: **patch_id,
514                            author,
515                            title: patch_cob.title().to_string(),
516                            state: State {
517                                status: patch_cob.state().to_string(),
518                                conflicts: match patch_cob.state() {
519                                    patch::State::Open { conflicts, .. } => conflicts.to_vec(),
520                                    _ => vec![],
521                                },
522                            },
523                            before: patch_base,
524                            after: *new_tip,
525                            commits,
526                            target: patch_cob
527                                .target()
528                                .head(&rad_repo)
529                                .map_err(MessageError::repository_error)?,
530                            labels: patch_cob.labels().map(|l| l.name().to_string()).collect(),
531                            assignees: patch_cob.assignees().collect(),
532                            revisions,
533                        },
534                    }),
535                })
536            }
537            Some(event) => Err(MessageError::UnknownCiEvent(event.clone())),
538        }
539    }
540}
541
542/// A request message sent by the broker to its adapter child process.
543#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(tag = "request")]
545#[serde(rename_all = "snake_case")]
546#[non_exhaustive]
547pub enum Request {
548    /// Trigger a run.
549    Trigger {
550        /// Common fields for all message variants.
551        #[serde(flatten)]
552        common: EventCommonFields,
553
554        /// The push event, if any. `branch` may tag name if tag event.
555        #[serde(flatten)]
556        push: Option<PushEvent>,
557
558        /// The patch event, if any.
559        #[serde(flatten)]
560        patch: Option<PatchEvent>,
561    },
562}
563
564impl Request {
565    /// Repository that the event concerns.
566    pub fn repo(&self) -> RepoId {
567        match self {
568            Self::Trigger {
569                common,
570                push: _,
571                patch: _,
572            } => common.repository.id,
573        }
574    }
575
576    /// Return the commit the event concerns. In other words, the
577    /// commit that CI should run against.
578    pub fn commit(&self) -> Result<Oid, MessageError> {
579        match self {
580            Self::Trigger {
581                common: _,
582                push,
583                patch,
584            } => {
585                if let Some(push) = push {
586                    if let Some(oid) = push.commits.first() {
587                        Ok(*oid)
588                    } else {
589                        Err(MessageError::NoCommits)
590                    }
591                } else if let Some(patch) = patch {
592                    if let Some(oid) = patch.patch.commits.first() {
593                        Ok(*oid)
594                    } else {
595                        Err(MessageError::NoCommits)
596                    }
597                } else {
598                    Err(MessageError::UnknownRequest)
599                }
600            }
601        }
602    }
603
604    /// Serialize the request as a pretty JSON, including the newline.
605    /// This is meant for the broker to use.
606    pub fn to_json_pretty(&self) -> Result<String, MessageError> {
607        serde_json::to_string_pretty(&self).map_err(MessageError::serialize_request)
608    }
609
610    /// Serialize the request as a single-line JSON, including the
611    /// newline. This is meant for the broker to use.
612    pub fn to_writer<W: Write>(&self, mut writer: W) -> Result<(), MessageError> {
613        let mut line = serde_json::to_string(&self).map_err(MessageError::serialize_request)?;
614        line.push('\n');
615        writer
616            .write(line.as_bytes())
617            .map_err(MessageError::WriteRequest)?;
618        Ok(())
619    }
620
621    /// Read a request from a reader. This is meant for the adapter to
622    /// use.
623    pub fn from_reader<R: Read>(reader: R) -> Result<Self, MessageError> {
624        let mut line = String::new();
625        let mut r = BufReader::new(reader);
626        r.read_line(&mut line).map_err(MessageError::ReadLine)?;
627        let req: Self =
628            serde_json::from_slice(line.as_bytes()).map_err(MessageError::deserialize_request)?;
629        Ok(req)
630    }
631
632    /// Parse a request from a string. This is meant for tests to use.
633    pub fn try_from_str(s: &str) -> Result<Self, MessageError> {
634        let req: Self =
635            serde_json::from_slice(s.as_bytes()).map_err(MessageError::deserialize_request)?;
636        Ok(req)
637    }
638}
639
640fn did_to_author(profile: &Profile, did: &Did) -> Result<Author, MessageError> {
641    let alias = profile.aliases().alias(did);
642    Ok(Author { id: *did, alias })
643}
644
645impl fmt::Display for Request {
646    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
647        write!(
648            f,
649            "{}",
650            serde_json::to_string(&self).map_err(|_| fmt::Error)?
651        )
652    }
653}
654
655/// Type of event.
656#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
657#[serde(rename_all = "lowercase")]
658pub enum EventType {
659    /// A push event to a branch.
660    Push,
661
662    /// A new or changed patch.
663    Patch,
664
665    /// A new or changed tag.
666    Tag,
667}
668
669/// Common fields in all variations of a [`Request`] message.
670#[derive(Debug, Clone, Serialize, Deserialize)]
671pub struct EventCommonFields {
672    /// Version of the request message.
673    pub version: usize,
674
675    /// The type of the event.
676    pub event_type: EventType,
677
678    /// The repository the event is related to.
679    pub repository: Repository,
680}
681
682/// A push event.
683#[derive(Debug, Clone, Serialize, Deserialize)]
684pub struct PushEvent {
685    /// The author of the change.
686    pub pusher: Author,
687
688    /// The commit on which the change is based.
689    pub before: Oid,
690
691    /// FIXME
692    pub after: Oid,
693
694    /// The branch where the push occurred.
695    pub branch: String,
696
697    /// The commits in the change.
698    pub commits: Vec<Oid>,
699}
700
701/// An event related to a Radicle patch object.
702#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct PatchEvent {
704    /// What action has happened to the patch.
705    pub action: PatchAction,
706
707    /// Metadata about the patch.
708    pub patch: Patch,
709}
710
711/// What action has happened to the patch?
712#[derive(Debug, Clone, Serialize, Deserialize)]
713pub enum PatchAction {
714    /// Patch has been created.
715    Created,
716
717    /// Patch has been updated.
718    Updated,
719}
720
721#[cfg(test)]
722impl PatchAction {
723    fn as_str(&self) -> &str {
724        match self {
725            Self::Created => "created",
726            Self::Updated => "updated",
727        }
728    }
729}
730
731impl TryFrom<&str> for PatchAction {
732    type Error = MessageError;
733    fn try_from(value: &str) -> Result<Self, Self::Error> {
734        match value {
735            "created" => Ok(Self::Created),
736            "updated" => Ok(Self::Updated),
737            _ => Err(Self::Error::UnknownPatchAction(value.into())),
738        }
739    }
740}
741
742/// Fields in a [`Request`] message describing the repository
743/// concerned.
744#[derive(Debug, Clone, Serialize, Deserialize)]
745pub struct Repository {
746    /// The unique repository id.
747    pub id: RepoId,
748
749    /// The name of the repository.
750    pub name: String,
751
752    /// A description of the repository.
753    pub description: String,
754
755    /// Is it a private repository?
756    pub private: bool,
757
758    /// The default branch in the repository: the branch that gets
759    /// updated when a change is merged.
760    pub default_branch: String,
761
762    /// The delegates of the repository: those who can actually merge
763    /// the change.
764    pub delegates: Vec<Did>,
765}
766
767/// Fields describing the author of a change.
768#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
769pub struct Author {
770    /// The DID of the author. This is guaranteed to be unique.
771    pub id: Did,
772
773    /// The alias, or name, of the author. This need not be unique.
774    pub alias: Option<Alias>,
775}
776
777impl std::fmt::Display for Author {
778    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
779        write!(f, "{}", self.id)?;
780        if let Some(alias) = &self.alias {
781            write!(f, " ({alias})")?;
782        }
783        Ok(())
784    }
785}
786
787/// The state of a patch.
788#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
789pub struct State {
790    /// State of the patch.
791    pub status: String,
792
793    /// FIXME.
794    pub conflicts: Vec<(RevisionId, Oid)>,
795}
796
797/// Revision of a patch.
798#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
799pub struct Revision {
800    /// FIXME.
801    pub id: Oid,
802
803    /// Author of the revision.
804    pub author: Author,
805
806    /// Description of the revision.
807    pub description: String,
808
809    /// Base commit on which the revision of the patch should be
810    /// applied.
811    pub base: Oid,
812
813    /// FIXME.
814    pub oid: Oid,
815
816    /// Time stamp of the revision.
817    pub timestamp: u64,
818}
819
820impl std::fmt::Display for Revision {
821    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
822        write!(f, "{}", self.id)
823    }
824}
825
826/// Metadata about a Radicle patch.
827#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
828pub struct Patch {
829    /// The patch id.
830    pub id: Oid,
831
832    /// The author of the patch.
833    pub author: Author,
834
835    /// The title of the patch.
836    pub title: String,
837
838    /// The state of the patch.
839    pub state: State,
840
841    /// The commit preceding the patch.
842    pub before: Oid,
843
844    /// FIXME.
845    pub after: Oid,
846
847    /// The list of commits in the patch.
848    pub commits: Vec<Oid>,
849
850    /// FIXME.
851    pub target: Oid,
852
853    /// Labels assigned to the patch.
854    pub labels: Vec<String>,
855
856    /// Who're in charge of the patch.
857    pub assignees: Vec<Did>,
858
859    /// List of revisions of the patch.
860    pub revisions: Vec<Revision>,
861}
862
863/// A response message from the adapter child process to the broker.
864#[derive(Debug, Clone, Serialize, Deserialize)]
865#[serde(deny_unknown_fields)]
866#[serde(rename_all = "snake_case")]
867#[serde(tag = "response")]
868pub enum Response {
869    /// A CI run has been triggered.
870    Triggered {
871        /// The identifier for the CI run assigned by the adapter.
872        run_id: RunId,
873
874        /// Optional informational URL for the run.
875        info_url: Option<String>,
876    },
877
878    /// A CI run has finished.
879    Finished {
880        /// The result of a CI run.
881        result: RunResult,
882    },
883}
884
885impl Response {
886    /// Create a `Response::Triggered` message without an info URL.
887    pub fn triggered(run_id: RunId) -> Self {
888        Self::Triggered {
889            run_id,
890            info_url: None,
891        }
892    }
893
894    /// Create a `Response::Triggered` message with an info URL.
895    pub fn triggered_with_url(run_id: RunId, url: &str) -> Self {
896        Self::Triggered {
897            run_id,
898            info_url: Some(url.into()),
899        }
900    }
901
902    /// Create a `Response::Finished` message.
903    pub fn finished(result: RunResult) -> Self {
904        Self::Finished { result }
905    }
906
907    /// Does the message indicate a result for the CI run?
908    pub fn result(&self) -> Option<&RunResult> {
909        if let Self::Finished { result } = self {
910            Some(result)
911        } else {
912            None
913        }
914    }
915
916    /// Serialize a response as a single-line JSON, including the
917    /// newline. This is meant for the adapter to use.
918    pub fn to_writer<W: Write>(&self, mut writer: W) -> Result<(), MessageError> {
919        let mut line = serde_json::to_string(&self).map_err(MessageError::serialize_response)?;
920        line.push('\n');
921        writer
922            .write(line.as_bytes())
923            .map_err(MessageError::WriteResponse)?;
924        Ok(())
925    }
926
927    /// Serialize the response as a pretty JSON, including the newline.
928    /// This is meant for the broker to use.
929    pub fn to_json_pretty(&self) -> Result<String, MessageError> {
930        serde_json::to_string_pretty(&self).map_err(MessageError::serialize_response)
931    }
932
933    /// Read a response from a reader. This is meant for the broker to
934    /// use.
935    pub fn from_reader<R: Read + BufRead>(reader: &mut R) -> Result<Option<Self>, MessageError> {
936        let mut line = String::new();
937        let mut r = BufReader::new(reader);
938        let n = r.read_line(&mut line).map_err(MessageError::ReadLine)?;
939        if n == 0 {
940            // Child's stdout was closed.
941            Ok(None)
942        } else {
943            let req: Self = serde_json::from_slice(line.as_bytes())
944                .map_err(MessageError::deserialize_response)?;
945            Ok(Some(req))
946        }
947    }
948
949    /// Read a response from a string slice. This is meant for the
950    /// broker to use.
951    #[allow(clippy::should_implement_trait)]
952    pub fn from_str(line: &str) -> Result<Self, MessageError> {
953        let req: Self =
954            serde_json::from_slice(line.as_bytes()).map_err(MessageError::deserialize_response)?;
955        Ok(req)
956    }
957}
958
959/// All possible errors from the CI broker messages.
960#[derive(Debug, thiserror::Error)]
961pub enum MessageError {
962    /// [`RequestBuilder`] does not have profile set.
963    #[error("RequestBuilder must have profile set")]
964    NoProfile,
965
966    /// [`RequestBuilder`] does not have event set.
967    #[error("RequestBuilder must have broker event set")]
968    NoEvent,
969
970    /// [`RequestBuilder`] does not have event handler set.
971    #[error("RequestBuilder has no event handler set")]
972    NoEventHandler,
973
974    /// We got a CI event we don't know what to do with.
975    #[error("programming error: unknown CI event {0:?}")]
976    UnknownCiEvent(CiEvent),
977
978    /// CI event was not set for [`RequestBuilder`].
979    #[error("programming error: CI event was not set for request builder")]
980    CiEventNotSet,
981
982    /// Request message lacks commits to run CI on.
983    #[error("unacceptable request message: lacks Git commits to run CI on")]
984    NoCommits,
985
986    /// Request message is neither a "push" nor a "patch"
987    #[error("unacceptable request message: neither 'push' nor 'patch'")]
988    UnknownRequest,
989
990    /// Failed to serialize a request message as JSON. This should
991    /// never happen and likely indicates a programming failure.
992    #[error("failed to serialize a request into JSON to a file handle")]
993    SerializeRequest(#[source] Box<dyn std::error::Error + Send + 'static>),
994
995    /// Failed to serialize a response message as JSON. This should never
996    /// happen and likely indicates a programming failure.
997    #[error("failed to serialize a request into JSON to a file handle")]
998    SerializeResponse(#[source] Box<dyn std::error::Error + Send + 'static>),
999
1000    /// Failed to write the serialized request message to an open file.
1001    #[error("failed to write JSON to file handle")]
1002    WriteRequest(#[source] std::io::Error),
1003
1004    /// Failed to write the serialized response message to an open
1005    /// file.
1006    #[error("failed to write JSON to file handle")]
1007    WriteResponse(#[source] std::io::Error),
1008
1009    /// Failed to read a line of JSON from an open file.
1010    #[error("failed to read line from file handle")]
1011    ReadLine(#[source] std::io::Error),
1012
1013    /// Failed to parse JSON as a request or a response.
1014    #[error("failed to read a JSON request from a file handle")]
1015    DeserializeRequest(#[source] Box<dyn std::error::Error + Send + 'static>),
1016
1017    /// Failed to parse JSON as a response or a response.
1018    #[error("failed to read a JSON response from a file handle")]
1019    DeserializeResponse(#[source] Box<dyn std::error::Error + Send + 'static>),
1020
1021    /// Error retrieving context to generate trigger.
1022    #[error("could not generate trigger from event")]
1023    Trigger,
1024
1025    /// Error looking up the patch COB.
1026    #[error("could look up patch COB {0}: not found?")]
1027    PatchCob(PatchId),
1028
1029    /// Error looking up latest revision for a patch COB.
1030    #[error("failed to look up latest revision for patch {0}")]
1031    LatestPatchRevision(PatchId),
1032
1033    /// Error from Radicle repository.
1034    #[error("error from Radicle repository")]
1035    RepositoryError(#[source] Box<dyn std::error::Error + Send + 'static>),
1036
1037    /// Error from Radicle COB.
1038    #[error("error from Radicle collaborative object")]
1039    CobStoreError(#[source] Box<dyn std::error::Error + Send + 'static>),
1040
1041    /// Error from `radicle-surf` crate.
1042    #[error("error from radicle-surf")]
1043    RadicleSurfError(#[source] Box<dyn std::error::Error + Send + 'static>),
1044
1045    /// Trying to create a PatchAction from an invalid value.
1046    #[error("invalid patch action {0:?}")]
1047    UnknownPatchAction(String),
1048}
1049
1050impl MessageError {
1051    fn serialize_request(err: serde_json::Error) -> Self {
1052        Self::SerializeRequest(Box::new(err))
1053    }
1054
1055    fn serialize_response(err: serde_json::Error) -> Self {
1056        Self::SerializeResponse(Box::new(err))
1057    }
1058
1059    fn deserialize_request(err: serde_json::Error) -> Self {
1060        Self::DeserializeRequest(Box::new(err))
1061    }
1062
1063    fn deserialize_response(err: serde_json::Error) -> Self {
1064        Self::DeserializeResponse(Box::new(err))
1065    }
1066
1067    fn repository_error(err: radicle::storage::RepositoryError) -> Self {
1068        Self::RepositoryError(Box::new(err))
1069    }
1070
1071    fn cob_store_error(err: radicle::cob::store::Error) -> Self {
1072        Self::CobStoreError(Box::new(err))
1073    }
1074
1075    fn radicle_surf_error(err: radicle_surf::Error) -> Self {
1076        Self::RadicleSurfError(Box::new(err))
1077    }
1078}
1079
1080#[cfg(test)]
1081#[allow(clippy::unwrap_used)] // OK in tests: panic is fine
1082#[allow(missing_docs)]
1083pub mod trigger_from_ci_event_tests {
1084    use crate::ci_event::{CiEvent, CiEventV1};
1085    use crate::msg::{EventType, Request, RequestBuilder};
1086    use git_ref_format_core::RefString;
1087    use radicle::cob::Title;
1088    use radicle::patch::{MergeTarget, Patches};
1089    use radicle::prelude::Did;
1090    use radicle::storage::ReadRepository;
1091
1092    use crate::test::{MockNode, TestResult};
1093
1094    #[test]
1095    fn trigger_push_from_branch_created() -> TestResult<()> {
1096        let mock_node = MockNode::new()?;
1097        let profile = mock_node.profile()?;
1098
1099        let project = mock_node.node().project();
1100        let (_, repo_head) = project.repo.head()?;
1101        let cmt = radicle::test::fixtures::commit(
1102            "my test commit",
1103            &[repo_head.into()],
1104            &project.backend,
1105        );
1106
1107        let ci_event = CiEvent::V1(CiEventV1::BranchCreated {
1108            from_node: *profile.id(),
1109            repo: project.id,
1110            branch: RefString::try_from("master")?,
1111            tip: cmt,
1112        });
1113
1114        let req = RequestBuilder::default()
1115            .profile(&profile)
1116            .ci_event(&ci_event)
1117            .build_trigger_from_ci_event()?;
1118        let Request::Trigger {
1119            common,
1120            push,
1121            patch,
1122        } = req;
1123
1124        assert!(patch.is_none());
1125        assert!(push.is_some());
1126        assert_eq!(common.event_type, EventType::Push);
1127        assert_eq!(common.repository.id, project.id);
1128        assert_eq!(common.repository.name, project.repo.project()?.name());
1129
1130        let push = push.unwrap();
1131        assert_eq!(push.after, cmt);
1132        assert_eq!(push.before, cmt); // in this case of branch creation
1133        assert_eq!(
1134            push.branch,
1135            "master".replace("$nid", &profile.id().to_string())
1136        );
1137        assert_eq!(push.commits, vec![cmt]);
1138        assert_eq!(push.pusher.id, Did::from(profile.id()));
1139
1140        Ok(())
1141    }
1142
1143    #[test]
1144    fn trigger_push_from_branch_updated() -> TestResult<()> {
1145        let mock_node = MockNode::new()?;
1146        let profile = mock_node.profile()?;
1147
1148        let project = mock_node.node().project();
1149        let (_, repo_head) = project.repo.head()?;
1150        let cmt = radicle::test::fixtures::commit(
1151            "my test commit",
1152            &[repo_head.into()],
1153            &project.backend,
1154        );
1155
1156        let ci_event = CiEvent::V1(CiEventV1::BranchUpdated {
1157            from_node: *profile.id(),
1158            repo: project.id,
1159            branch: RefString::try_from("master")?,
1160            old_tip: repo_head,
1161            tip: cmt,
1162        });
1163
1164        let req = RequestBuilder::default()
1165            .profile(&profile)
1166            .ci_event(&ci_event)
1167            .build_trigger_from_ci_event()?;
1168        let Request::Trigger {
1169            common,
1170            push,
1171            patch,
1172        } = req;
1173
1174        assert!(patch.is_none());
1175        assert!(push.is_some());
1176        assert_eq!(common.event_type, EventType::Push);
1177        assert_eq!(common.repository.id, project.id);
1178        assert_eq!(common.repository.name, project.repo.project()?.name());
1179
1180        let push = push.unwrap();
1181        assert_eq!(push.after, cmt);
1182        assert_eq!(push.before, cmt); // in this case of branch creation
1183        assert_eq!(
1184            push.branch,
1185            "master".replace("$nid", &profile.id().to_string())
1186        );
1187        assert_eq!(push.commits, vec![cmt]);
1188        assert_eq!(push.pusher.id, Did::from(profile.id()));
1189
1190        Ok(())
1191    }
1192
1193    #[test]
1194    fn trigger_patch_from_patch_created() -> TestResult<()> {
1195        let mock_node = MockNode::new()?;
1196        let profile = mock_node.profile()?;
1197
1198        let project = mock_node.node().project();
1199        let (_, repo_head) = project.repo.head()?;
1200        let cmt = radicle::test::fixtures::commit(
1201            "my test commit",
1202            &[repo_head.into()],
1203            &project.backend,
1204        );
1205
1206        let node = mock_node.node();
1207
1208        let mut patches = Patches::open(&project.repo)?;
1209        let mut cache = radicle::cob::cache::NoCache;
1210        let patch_cob = patches.create(
1211            Title::new("my patch title").unwrap(),
1212            "my patch description",
1213            MergeTarget::Delegates,
1214            repo_head,
1215            cmt,
1216            &[],
1217            &mut cache,
1218            &node.signer,
1219        )?;
1220
1221        let ci_event = CiEvent::V1(CiEventV1::PatchCreated {
1222            from_node: *profile.id(),
1223            repo: project.id,
1224            patch: *patch_cob.id(),
1225            new_tip: cmt,
1226        });
1227
1228        let req = RequestBuilder::default()
1229            .profile(&profile)
1230            .ci_event(&ci_event)
1231            .build_trigger_from_ci_event()?;
1232        let Request::Trigger {
1233            common,
1234            push,
1235            patch,
1236        } = req;
1237
1238        assert!(patch.is_some());
1239        assert!(push.is_none());
1240        assert_eq!(common.event_type, EventType::Patch);
1241        assert_eq!(common.repository.id, project.id);
1242        assert_eq!(common.repository.name, project.repo.project()?.name());
1243
1244        let patch = patch.unwrap();
1245        assert_eq!(patch.action.as_str(), "created");
1246        assert_eq!(patch.patch.id.to_string(), patch_cob.id.to_string());
1247        assert_eq!(patch.patch.title, patch_cob.title());
1248        assert_eq!(patch.patch.state.status, patch_cob.state().to_string());
1249        assert_eq!(patch.patch.target, repo_head);
1250        assert_eq!(patch.patch.revisions.len(), 1);
1251        let rev = patch.patch.revisions.first().unwrap();
1252        assert_eq!(rev.id.to_string(), patch_cob.id.to_string());
1253        assert_eq!(rev.base, repo_head);
1254        assert_eq!(rev.oid, cmt);
1255        assert_eq!(rev.author.id, Did::from(profile.id()));
1256        assert_eq!(rev.description, patch_cob.description());
1257        assert_eq!(rev.timestamp, patch_cob.timestamp().as_secs());
1258        assert_eq!(patch.patch.after, cmt);
1259        assert_eq!(patch.patch.before, repo_head);
1260        assert_eq!(patch.patch.commits, vec![cmt]);
1261
1262        Ok(())
1263    }
1264
1265    #[test]
1266    fn trigger_patch_from_patch_updated() -> TestResult<()> {
1267        let mock_node = MockNode::new()?;
1268        let profile = mock_node.profile()?;
1269
1270        let project = mock_node.node().project();
1271        let (_, repo_head) = project.repo.head()?;
1272        let cmt = radicle::test::fixtures::commit(
1273            "my test commit",
1274            &[repo_head.into()],
1275            &project.backend,
1276        );
1277
1278        let node = mock_node.node();
1279
1280        let mut patches = Patches::open(&project.repo)?;
1281        let mut cache = radicle::cob::cache::NoCache;
1282        let patch_cob = patches.create(
1283            Title::new("my patch title").unwrap(),
1284            "my patch description",
1285            MergeTarget::Delegates,
1286            repo_head,
1287            cmt,
1288            &[],
1289            &mut cache,
1290            &node.signer,
1291        )?;
1292
1293        let ci_event = CiEvent::V1(CiEventV1::PatchUpdated {
1294            from_node: *profile.id(),
1295            repo: project.id,
1296            patch: *patch_cob.id(),
1297            new_tip: cmt,
1298        });
1299
1300        let req = RequestBuilder::default()
1301            .profile(&profile)
1302            .ci_event(&ci_event)
1303            .build_trigger_from_ci_event()?;
1304        let Request::Trigger {
1305            common,
1306            push,
1307            patch,
1308        } = req;
1309
1310        assert!(patch.is_some());
1311        assert!(push.is_none());
1312        assert_eq!(common.event_type, EventType::Patch);
1313        assert_eq!(common.repository.id, project.id);
1314        assert_eq!(common.repository.name, project.repo.project()?.name());
1315
1316        let patch = patch.unwrap();
1317        assert_eq!(patch.action.as_str(), "updated");
1318        assert_eq!(patch.patch.id.to_string(), patch_cob.id.to_string());
1319        assert_eq!(patch.patch.title, patch_cob.title());
1320        assert_eq!(patch.patch.state.status, patch_cob.state().to_string());
1321        assert_eq!(patch.patch.target, repo_head);
1322        assert_eq!(patch.patch.revisions.len(), 1);
1323        let rev = patch.patch.revisions.first().unwrap();
1324        assert_eq!(rev.id.to_string(), patch_cob.id.to_string());
1325        assert_eq!(rev.base, repo_head);
1326        assert_eq!(rev.oid, cmt);
1327        assert_eq!(rev.author.id, Did::from(profile.id()));
1328        assert_eq!(rev.description, patch_cob.description());
1329        assert_eq!(rev.timestamp, patch_cob.timestamp().as_secs());
1330        assert_eq!(patch.patch.after, cmt);
1331        assert_eq!(patch.patch.before, repo_head);
1332        assert_eq!(patch.patch.commits, vec![cmt]);
1333
1334        Ok(())
1335    }
1336}
1337
1338/// Helper functions for writing adapters.
1339pub mod helper {
1340
1341    use std::{
1342        fs::{File, OpenOptions},
1343        io::Write,
1344        path::{Path, PathBuf},
1345        process::Command,
1346    };
1347
1348    use nonempty::{NonEmpty, nonempty};
1349    use radicle::prelude::{Profile, RepoId};
1350
1351    use time::{OffsetDateTime, macros::format_description};
1352
1353    use super::{MessageError, Oid, Request, Response, RunId, RunResult};
1354
1355    /// Exit code to indicate we didn't get one from the process.
1356    pub const NO_EXIT: i32 = 999;
1357
1358    /// Read a request from stdin.
1359    pub fn read_request() -> Result<Request, MessageHelperError> {
1360        let req =
1361            Request::from_reader(std::io::stdin()).map_err(MessageHelperError::ReadRequest)?;
1362        Ok(req)
1363    }
1364
1365    // Write response to stdout.
1366    fn write_response(resp: &Response) -> Result<(), MessageHelperError> {
1367        resp.to_writer(std::io::stdout())
1368            .map_err(|e| MessageHelperError::WriteResponse(resp.clone(), Box::new(e)))?;
1369        Ok(())
1370    }
1371
1372    /// Write a "triggered" response to stdout.
1373    pub fn write_triggered(
1374        run_id: &RunId,
1375        info_url: Option<&str>,
1376    ) -> Result<(), MessageHelperError> {
1377        let response = if let Some(url) = info_url {
1378            Response::triggered_with_url(run_id.clone(), url)
1379        } else {
1380            Response::triggered(run_id.clone())
1381        };
1382        write_response(&response)?;
1383        Ok(())
1384    }
1385
1386    /// Write a message indicating failure to stdout.
1387    pub fn write_failed() -> Result<(), MessageHelperError> {
1388        write_response(&Response::Finished {
1389            result: RunResult::Failure,
1390        })?;
1391        Ok(())
1392    }
1393
1394    /// Write a message indicating success to stdout.
1395    pub fn write_succeeded() -> Result<(), MessageHelperError> {
1396        write_response(&Response::Finished {
1397            result: RunResult::Success,
1398        })?;
1399        Ok(())
1400    }
1401
1402    /// Get sources from the local node.
1403    pub fn get_sources(
1404        adminlog: &mut AdminLog,
1405        dry_run: bool,
1406        repoid: RepoId,
1407        commit: Oid,
1408        src: &Path,
1409    ) -> Result<(), MessageHelperError> {
1410        let profile = Profile::load().map_err(MessageHelperError::Profile)?;
1411        let storage = profile.storage.path();
1412        let repo_path = storage.join(repoid.canonical());
1413
1414        git_clone(adminlog, dry_run, &repo_path, src)?;
1415        git_checkout(adminlog, dry_run, commit, src)?;
1416
1417        Ok(())
1418    }
1419
1420    /// Run `git clone` for the repository.
1421    fn git_clone(
1422        adminlog: &mut AdminLog,
1423        dry_run: bool,
1424        repo_path: &Path,
1425        src: &Path,
1426    ) -> Result<(), MessageHelperError> {
1427        let repo_path = repo_path.to_string_lossy();
1428        let src = src.to_string_lossy();
1429        runcmd(
1430            adminlog,
1431            dry_run,
1432            &nonempty!["git", "clone", &repo_path, &src],
1433            Path::new("."),
1434        )?;
1435        Ok(())
1436    }
1437
1438    // Check out the requested commit.
1439    fn git_checkout(
1440        adminlog: &mut AdminLog,
1441        dry_run: bool,
1442        commit: Oid,
1443        src: &Path,
1444    ) -> Result<(), MessageHelperError> {
1445        runcmd(
1446            adminlog,
1447            dry_run,
1448            &nonempty!["git", "config", "advice.detachedHead", "false"],
1449            src,
1450        )?;
1451        let commit = commit.to_string();
1452        runcmd(
1453            adminlog,
1454            dry_run,
1455            &nonempty!["git", "checkout", &commit],
1456            src,
1457        )?;
1458        Ok(())
1459    }
1460
1461    /// Run a program.
1462    pub fn runcmd(
1463        adminlog: &mut AdminLog,
1464        dry_run: bool,
1465        argv: &NonEmpty<&str>,
1466        cwd: &Path,
1467    ) -> Result<(i32, Vec<u8>), MessageHelperError> {
1468        if dry_run {
1469            adminlog
1470                .writeln(&format!("runcmd: pretend to run: argv={argv:?}"))
1471                .map_err(MessageHelperError::AdminLog)?;
1472            return Ok((0, vec![]));
1473        }
1474
1475        adminlog.writeln(&format!("runcmd: argv={argv:?}"))?;
1476        let output = Command::new("bash")
1477            .arg("-c")
1478            .arg(r#""$@" 2>&1"#)
1479            .arg("--")
1480            .args(argv)
1481            .current_dir(cwd)
1482            .output()
1483            .map_err(|err| MessageHelperError::Command("bash", err))?;
1484
1485        let exit = output.status.code().unwrap_or(NO_EXIT);
1486        adminlog.writeln(&format!("runcmd: exit={exit}"))?;
1487
1488        if exit != 0 {
1489            indented(adminlog, "stdout", &output.stdout);
1490            indented(adminlog, "stderr", &output.stderr);
1491        }
1492
1493        Ok((exit, output.stdout))
1494    }
1495
1496    /// Log a string with every line indented.
1497    pub fn indented(adminlog: &mut AdminLog, msg: &str, bytes: &[u8]) {
1498        if !bytes.is_empty() {
1499            adminlog.writeln(&format!("{msg}:")).ok();
1500            let text = String::from_utf8_lossy(bytes);
1501            for line in text.lines() {
1502                adminlog.writeln(&format!("    {line}")).ok();
1503            }
1504        }
1505    }
1506
1507    /// A log for the administrator, whose duty it is to keep the
1508    /// software running.
1509    #[derive(Debug, Default)]
1510    pub struct AdminLog {
1511        filename: Option<PathBuf>,
1512        file: Option<File>,
1513        stderr: bool,
1514        buffer: Option<Vec<u8>>,
1515    }
1516
1517    impl AdminLog {
1518        /// Create an admin log that doesn't write to a file, and
1519        /// neither to stderr.
1520        pub fn null() -> Self {
1521            Self::default()
1522        }
1523
1524        /// Create an admin log that writes to stderr.
1525        pub fn stderr() -> Self {
1526            Self {
1527                filename: None,
1528                file: None,
1529                stderr: true,
1530                buffer: None,
1531            }
1532        }
1533
1534        /// Capture output into a buffer.
1535        pub fn capture() -> Self {
1536            Self {
1537                filename: None,
1538                file: None,
1539                stderr: false,
1540                buffer: Some(vec![]),
1541            }
1542        }
1543
1544        /// Return current buffer created by `capture`.
1545        pub fn capture_buffer(&self) -> Option<&[u8]> {
1546            self.buffer.as_deref()
1547        }
1548
1549        /// Create an admin log that writes to a named file.
1550        pub fn open(filename: &Path) -> Result<Self, LogError> {
1551            let file = OpenOptions::new()
1552                .append(true)
1553                .create(true)
1554                .open(filename)
1555                .map_err(|e| LogError::OpenLogFile(filename.into(), e))?;
1556            Ok(Self {
1557                filename: Some(filename.into()),
1558                file: Some(file),
1559                stderr: false,
1560                buffer: None,
1561            })
1562        }
1563
1564        /// Write a line to the admin log.
1565        pub fn writeln(&mut self, text: &str) -> Result<(), LogError> {
1566            self.write("[")?;
1567            self.write(&now()?)?;
1568            self.write("] ")?;
1569            self.write(text)?;
1570            self.write("\n")?;
1571            Ok(())
1572        }
1573
1574        fn write(&mut self, msg: &str) -> Result<(), LogError> {
1575            if let Some(file) = &mut self.file {
1576                #[allow(clippy::unwrap_used)] // we know it's OK
1577                file.write_all(msg.as_bytes())
1578                    .map_err(|e| LogError::WriteLogFile(self.filename.clone().unwrap(), e))?;
1579            } else if self.stderr {
1580                std::io::stderr()
1581                    .write_all(msg.as_bytes())
1582                    .map_err(LogError::WriteLogStderr)?;
1583            } else if let Some(buf) = self.buffer.as_mut() {
1584                buf.extend_from_slice(msg.as_bytes());
1585            }
1586            Ok(())
1587        }
1588    }
1589
1590    fn now() -> Result<String, LogError> {
1591        let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
1592        OffsetDateTime::now_utc()
1593            .format(fmt)
1594            .map_err(LogError::TimeFormat)
1595    }
1596
1597    /// Possible errors from using the admin log.
1598    #[derive(Debug, thiserror::Error)]
1599    pub enum LogError {
1600        /// Can't open named file.
1601        #[error("failed to open log file {0}")]
1602        OpenLogFile(PathBuf, #[source] std::io::Error),
1603
1604        /// Can't write to file.
1605        #[error("failed to write to log file {0}")]
1606        WriteLogFile(PathBuf, #[source] std::io::Error),
1607
1608        /// Can't write to file.
1609        #[error("failed to write to log file {0}")]
1610        WriteLogStderr(#[source] std::io::Error),
1611
1612        /// Can' format time stamp.
1613        #[error("failed to format time stamp")]
1614        TimeFormat(#[source] time::error::Format),
1615    }
1616
1617    /// Possible errors from this module.
1618    #[derive(Debug, thiserror::Error)]
1619    pub enum MessageHelperError {
1620        /// Error reading request from stdin.
1621        #[error("failed to read request from stdin: {0:?}")]
1622        ReadRequest(#[source] MessageError),
1623
1624        /// Error writing response to stdout.
1625        #[error("failed to write response to stdout: {0:?}")]
1626        WriteResponse(Response, #[source] Box<MessageError>),
1627
1628        /// Can't load Radicle profile.
1629        #[error("failed to load Radicle profile")]
1630        Profile(#[source] radicle::profile::Error),
1631
1632        /// Can't run command and capture its output.
1633        #[error("failed to run command {0}")]
1634        Command(&'static str, #[source] std::io::Error),
1635
1636        /// Admin log error.
1637        #[error(transparent)]
1638        AdminLog(#[from] LogError),
1639    }
1640}