Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 63 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ cucumber = "0.20"
tokio = { workspace = true }
clap = { workspace = true }
comenq = { path = "crates/comenq" }
tempfile = { workspace = true }

[[test]]
name = "cucumber"
Expand All @@ -38,6 +39,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1.0"
thiserror = "1.0"
tempfile = "3"

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
Expand Down
2 changes: 2 additions & 0 deletions crates/comenq/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ clap = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
comenq-lib = { path = "../.." }
thiserror = { workspace = true }
tracing = { workspace = true }
166 changes: 166 additions & 0 deletions crates/comenq/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//! Client-side communication with the `comenqd` daemon.
//!
//! This module contains the logic to serialize a comment request and send it to
//! the daemon over its Unix Domain Socket. It is separated from `lib.rs` so
//! that argument parsing remains focused and the network logic is easily
//! testable.

use comenq_lib::CommentRequest;
use thiserror::Error;
use tokio::{io::AsyncWriteExt, net::UnixStream};
use tracing::warn;

use crate::Args;

/// Errors that can occur when interacting with the daemon.
#[derive(Debug, Error)]
pub enum ClientError {
/// Connecting to the daemon failed.
#[error("failed to connect to daemon: {0}")]
Connect(#[from] std::io::Error),
/// Serializing the request failed.
#[error("failed to serialize request: {0}")]
Serialize(#[from] serde_json::Error),
/// The repository slug was invalid.
#[error("invalid repository format")]
BadSlug,
/// Writing the request to the socket failed.
#[error("failed to write to daemon: {0}")]
Write(#[source] std::io::Error),
/// Shutting down the socket failed.
#[error("failed to close connection: {0}")]
Shutdown(#[source] std::io::Error),
}

/// Send a `CommentRequest` to the daemon.
///
/// # Examples
///
/// ```no_run
/// # use comenq::{Args, run};
/// # use std::path::PathBuf;
/// # async fn try_run() -> Result<(), comenq::ClientError> {
/// let args = Args {
/// repo_slug: "owner/repo".into(),
/// pr_number: 1,
/// comment_body: String::from("Hi"),
/// socket: PathBuf::from("/run/comenq/socket"),
/// };
/// run(args).await?;
/// # Ok(())
/// # }
/// ```
pub async fn run(args: Args) -> Result<(), ClientError> {
if crate::validate_repo_slug(&args.repo_slug).is_err() {
return Err(ClientError::BadSlug);
}
let (owner, repo) = parse_slug(&args.repo_slug);
let request = CommentRequest {
owner,
repo,
pr_number: args.pr_number,
body: args.comment_body,
};

let payload = serde_json::to_vec(&request)?;

let mut stream = UnixStream::connect(&args.socket)
.await
.map_err(ClientError::Connect)?;
stream
.write_all(&payload)
.await
.map_err(ClientError::Write)?;
if let Err(e) = stream.shutdown().await {
warn!("failed to close connection: {e}");
return Err(ClientError::Shutdown(e));
}
Ok(())
}

fn parse_slug(slug: &str) -> (String, String) {
// safe expect: `validate_repo_slug` ensures two non-empty parts
let (owner, repo) = slug
.split_once('/')
.expect("slug should have been validated by validate_repo_slug");
(owner.to_owned(), repo.to_owned())
}

#[cfg(test)]
mod tests {
use super::{ClientError, parse_slug, run};
use crate::Args;
use comenq_lib::CommentRequest;
use rstest::rstest;
use tempfile::tempdir;
use tokio::io::AsyncReadExt;
use tokio::net::UnixListener;

#[tokio::test]
async fn run_sends_request() {
let dir = tempdir().expect("temp dir");
let socket = dir.path().join("sock");
let listener = UnixListener::bind(&socket).expect("bind socket");

let accept = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
let mut buf = Vec::new();
stream.read_to_end(&mut buf).await.expect("read");
serde_json::from_slice::<CommentRequest>(&buf).expect("deserialize")
});

let args = Args {
repo_slug: "octocat/hello-world".into(),
pr_number: 1,
comment_body: "Hi".into(),
socket: socket.clone(),
};

run(args).await.expect("run succeeds");
let req = accept.await.expect("join");
assert_eq!(req.owner, "octocat");
assert_eq!(req.repo, "hello-world");
assert_eq!(req.pr_number, 1);
assert_eq!(req.body, "Hi");
}

#[tokio::test]
async fn run_errors_when_socket_missing() {
let dir = tempdir().expect("temp dir");
let socket = dir.path().join("nosock");

let args = Args {
repo_slug: "octocat/hello-world".into(),
pr_number: 1,
comment_body: "Hi".into(),
socket: socket.clone(),
};

let err = run(args).await.expect_err("should error");
assert!(matches!(err, ClientError::Connect(_)));
}

#[tokio::test]
async fn run_errors_on_bad_slug() {
let dir = tempdir().expect("temp dir");
let socket = dir.path().join("sock");
let _listener = UnixListener::bind(&socket).expect("bind socket");

let args = Args {
repo_slug: "badslug".into(),
pr_number: 1,
comment_body: "Hi".into(),
socket,
};

let err = run(args).await.expect_err("should error");
assert!(matches!(err, ClientError::BadSlug));
}

#[test]
fn slug_is_split() {
let (owner, repo) = parse_slug("octocat/hello-world");
assert_eq!(owner, "octocat");
assert_eq!(repo, "hello-world");
}
}
6 changes: 5 additions & 1 deletion crates/comenq/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
use clap::Parser;
Comment thread
leynos marked this conversation as resolved.
use std::path::PathBuf;

mod client;

pub use client::{ClientError, run};

/// Command line arguments for the `comenq` client.
#[derive(Debug, Parser)]
#[derive(Debug, Clone, Parser)]
#[command(name = "comenq", about = "Enqueue a GitHub PR comment")]
pub struct Args {
/// The repository in 'owner/repo' format (e.g., "rust-lang/rust").
Expand Down
15 changes: 8 additions & 7 deletions crates/comenq/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
//! Parses user input and forwards it to the daemon.

use clap::Parser;
use comenq::Args;
use comenq::{Args, run};
use std::process;

fn main() {
#[tokio::main]
async fn main() {
let args = Args::parse();
todo!(
"Connect to daemon at {} to enqueue comment for {}",
args.socket.display(),
args.repo_slug
);
if let Err(e) = run(args).await {
eprintln!("{e}");
process::exit(1);
}
}
Loading