diff --git a/Cargo.lock b/Cargo.lock index af97bd2..f456bed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,6 +114,15 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -175,6 +184,12 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" + [[package]] name = "bytes" version = "1.10.1" @@ -219,6 +234,18 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-dispatch" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a558b9547b590c876e46e301da15d3b0e93b0384fd50d2c7870f7ea86760df5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_builder" version = "4.5.41" @@ -275,7 +302,9 @@ version = "0.1.0" dependencies = [ "clap", "comenq", + "comenqd", "cucumber", + "ortho_config", "serde", "serde_json", "tempfile", @@ -289,9 +318,14 @@ dependencies = [ "anyhow", "clap", "comenq-lib", + "figment", "octocrab", + "ortho_config", + "rstest", "serde", "serde_json", + "serial_test", + "tempfile", "thiserror 1.0.69", "tokio", "tracing", @@ -422,6 +456,19 @@ dependencies = [ "regex-syntax 0.7.5", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.4.0" @@ -442,6 +489,27 @@ dependencies = [ "syn", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -471,6 +539,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.13" @@ -594,6 +668,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -660,6 +740,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "globset" version = "0.4.16" @@ -684,6 +770,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + [[package]] name = "heck" version = "0.4.1" @@ -961,12 +1059,28 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown 0.15.4", +] + [[package]] name = "inflections" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + [[package]] name = "inotify" version = "0.9.6" @@ -1374,6 +1488,39 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ortho_config" +version = "0.3.0" +source = "git+https://github.com/leynos/ortho-config.git?tag=v0.4.0#5c27310a915795b9e7c7749c8e38111291aa6976" +dependencies = [ + "clap", + "clap-dispatch", + "directories", + "figment", + "ortho_config_macros", + "serde", + "thiserror 1.0.69", + "toml", + "uncased", + "xdg", +] + +[[package]] +name = "ortho_config_macros" +version = "0.3.0" +source = "git+https://github.com/leynos/ortho-config.git?tag=v0.4.0#5c27310a915795b9e7c7749c8e38111291aa6976" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "overload" version = "0.1.1" @@ -1403,6 +1550,29 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + [[package]] name = "peg" version = "0.6.3" @@ -1511,6 +1681,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + [[package]] name = "quote" version = "1.0.40" @@ -1565,6 +1748,17 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "regex" version = "1.11.1" @@ -1615,6 +1809,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "ring" version = "0.17.14" @@ -1629,12 +1829,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" +dependencies = [ + "cfg-if", + "glob", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.0.8" @@ -1832,6 +2070,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1844,6 +2091,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e56dd856803e253c8f298af3f4d7eb0ae5e23a737252cd90bb4f3b435033b2d" +dependencies = [ + "dashmap", + "futures", + "lazy_static", + "log", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2216,6 +2488,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.4.13" @@ -2353,6 +2666,15 @@ dependencies = [ "syn", ] +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2407,6 +2729,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -2891,6 +3219,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yaque" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 8b88b60..d88cede 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,9 @@ cucumber = "0.20" tokio = { workspace = true } clap = { workspace = true } comenq = { path = "crates/comenq" } -tempfile = { workspace = true } +comenqd = { path = "crates/comenqd" } +ortho_config = { git = "https://github.com/leynos/ortho-config.git", tag = "v0.4.0" } +tempfile = "3.10" # latest 3.x at time of writing; update as new patch versions release [[test]] name = "cucumber" @@ -39,7 +41,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1.0" thiserror = "1.0" -tempfile = "3" +ortho_config = { git = "https://github.com/leynos/ortho-config.git", tag = "v0.4.0" } [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/crates/comenqd/Cargo.toml b/crates/comenqd/Cargo.toml index 5819566..d8738fc 100644 --- a/crates/comenqd/Cargo.toml +++ b/crates/comenqd/Cargo.toml @@ -3,6 +3,7 @@ name = "comenqd" version = "0.1.0" edition = "2024" + [dependencies] tokio = { workspace = true } clap = { workspace = true } @@ -15,3 +16,10 @@ tracing-subscriber = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } comenq-lib = { path = "../.." } +ortho_config = { workspace = true } +figment = { version = "0.10", default-features = false, features = ["env", "toml"] } + +[dev-dependencies] +rstest = "0.18.0" +tempfile = "3.10" # latest 3.x at time of writing; update as new patch versions release +serial_test = "2" diff --git a/crates/comenqd/src/config.rs b/crates/comenqd/src/config.rs new file mode 100644 index 0000000..47237b7 --- /dev/null +++ b/crates/comenqd/src/config.rs @@ -0,0 +1,226 @@ +//! Configuration loading for the Comenqd daemon. +//! +//! The configuration is stored in `/etc/comenqd/config.toml`. Values may be +//! overridden by environment variables using the `COMENQD_` prefix. + +use clap::Parser; +use figment::providers::Env; +use serde::{Deserialize, Serialize}; +use std::io; +use std::path::{Path, PathBuf}; + +/// Default socket path when none is provided. +const DEFAULT_SOCKET_PATH: &str = "/run/comenq/comenq.sock"; +/// Default queue directory when none is provided. +const DEFAULT_QUEUE_PATH: &str = "/var/lib/comenq/queue"; +/// Default cooldown in seconds between comment posts. +const DEFAULT_COOLDOWN: u64 = 900; + +/// Runtime configuration for the daemon. +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Config { + /// GitHub Personal Access Token. + pub github_token: String, + /// Path to the Unix Domain Socket. + #[serde(default = "default_socket_path")] + pub socket_path: PathBuf, + /// Directory for the persistent queue. + #[serde(default = "default_queue_path")] + pub queue_path: PathBuf, + /// Cooldown between comment posts in seconds. + #[serde(default = "default_cooldown")] + pub cooldown_period_seconds: u64, +} + +/// Command-line overrides for configuration values. +#[derive(Debug, Default, Parser, Serialize)] +struct CliArgs { + /// Path to the configuration file. + #[arg(short, long, value_name = "FILE", default_value = Config::DEFAULT_PATH)] + config: PathBuf, + /// GitHub Personal Access Token. + #[arg(long)] + github_token: Option, + /// Override the Unix Domain Socket path. + #[arg(long)] + socket_path: Option, + /// Override the queue directory. + #[arg(long)] + queue_path: Option, +} + +fn default_socket_path() -> PathBuf { + PathBuf::from(DEFAULT_SOCKET_PATH) +} + +fn default_queue_path() -> PathBuf { + PathBuf::from(DEFAULT_QUEUE_PATH) +} + +fn default_cooldown() -> u64 { + DEFAULT_COOLDOWN +} + +impl Config { + /// Default location of the daemon configuration file. + pub const DEFAULT_PATH: &'static str = "/etc/comenqd/config.toml"; + + /// Load the configuration using command-line overrides and environment + /// variables. + #[expect(clippy::result_large_err, reason = "propagate figment errors")] + pub fn load() -> Result { + let args = CliArgs::parse(); + Self::from_file_with_cli(&args.config, &args) + } + + /// Load the configuration from the specified path, merging `COMENQD_*` + /// environment variables and CLI arguments over file values. + #[expect(clippy::result_large_err, reason = "propagate figment errors")] + pub fn from_file(path: &Path) -> Result { + Self::from_file_with_cli(path, &CliArgs::default()) + } + + #[expect(clippy::result_large_err, reason = "propagate figment errors")] + fn from_file_with_cli(path: &Path, cli: &CliArgs) -> Result { + let mut fig = ortho_config::load_config_file(path)?.ok_or_else(|| { + ortho_config::OrthoError::File { + path: path.to_path_buf(), + source: Box::new(io::Error::new( + io::ErrorKind::NotFound, + "Configuration file not found", + )), + } + })?; + + fig = fig.merge(Env::prefixed("COMENQD_").split("__")); + let mut cfg: Self = fig.extract().map_err(ortho_config::OrthoError::from)?; + + if let Some(token) = &cli.github_token { + cfg.github_token = token.clone(); + } + if let Some(socket) = &cli.socket_path { + cfg.socket_path = socket.clone(); + } + if let Some(queue) = &cli.queue_path { + cfg.queue_path = queue.clone(); + } + Ok(cfg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::fs; + use tempfile::tempdir; + + struct EnvVarGuard { + key: String, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &str, val: &str) -> Self { + let original = std::env::var(key).ok(); + set_env_var(key, val); + Self { + key: key.to_string(), + original, + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(v) => set_env_var(&self.key, v), + None => remove_env_var(&self.key), + } + } + } + + fn remove_env(key: &str) { + remove_env_var(key); + } + + /// Safely set an environment variable for tests. + fn set_env_var(key: &str, val: &str) { + // Safety: tests using `serial_test::serial` run single-threaded. + unsafe { std::env::set_var(key, val) }; + } + + /// Safely remove an environment variable for tests. + fn remove_env_var(key: &str) { + // Safety: tests using `serial_test::serial` run single-threaded. + unsafe { std::env::remove_var(key) }; + } + + #[rstest] + #[serial_test::serial] + fn loads_from_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + "github_token='abc'\nsocket_path='/tmp/s.sock'\nqueue_path='/tmp/q'", + ) + .unwrap(); + remove_env("COMENQD_SOCKET_PATH"); + let cfg = Config::from_file(&path).unwrap(); + assert_eq!(cfg.github_token, "abc"); + assert_eq!(cfg.socket_path, PathBuf::from("/tmp/s.sock")); + assert_eq!(cfg.queue_path, PathBuf::from("/tmp/q")); + } + + #[rstest] + #[serial_test::serial] + fn error_when_missing_file() { + let path = PathBuf::from("/nonexistent/file.toml"); + let res = Config::from_file(&path); + assert!(res.is_err()); + } + + #[rstest] + #[serial_test::serial] + fn env_vars_override_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "github_token='abc'\nsocket_path='/tmp/s.sock'").unwrap(); + let _guard = EnvVarGuard::set("COMENQD_SOCKET_PATH", "/tmp/override.sock"); + let cfg = Config::from_file(&path).unwrap(); + assert_eq!(cfg.socket_path, PathBuf::from("/tmp/override.sock")); + } + + #[rstest] + #[serial_test::serial] + fn error_with_invalid_toml() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "github_token='abc' this is not toml").unwrap(); + let res = Config::from_file(&path); + assert!(res.is_err()); + } + + #[rstest] + #[serial_test::serial] + fn error_when_missing_token() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "socket_path='/tmp/s.sock'").unwrap(); + let res = Config::from_file(&path); + assert!(res.is_err()); + } + + #[rstest] + #[serial_test::serial] + fn defaults_are_applied() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "github_token='abc'").unwrap(); + let cfg = Config::from_file(&path).unwrap(); + assert_eq!(cfg.socket_path, PathBuf::from("/run/comenq/comenq.sock")); + assert_eq!(cfg.queue_path, PathBuf::from("/var/lib/comenq/queue")); + assert_eq!(cfg.cooldown_period_seconds, DEFAULT_COOLDOWN); + } +} diff --git a/crates/comenqd/src/daemon.rs b/crates/comenqd/src/daemon.rs new file mode 100644 index 0000000..4a9b226 --- /dev/null +++ b/crates/comenqd/src/daemon.rs @@ -0,0 +1,98 @@ +//! Asynchronous daemon tasks for comenqd. +//! +//! This module provides the run function used by `main` which spawns the +//! Unix socket listener and the queue worker. + +use crate::config::Config; +use anyhow::Result; +use comenq_lib::CommentRequest; +use octocrab::Octocrab; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::net::{UnixListener, UnixStream}; +use yaque::{Receiver, Sender, channel}; + +fn build_octocrab(token: &str) -> Result { + Ok(Octocrab::builder() + .personal_token(token.to_string()) + .build()?) +} + +fn prepare_listener(path: &Path) -> Result { + if fs::metadata(path).is_ok() { + fs::remove_file(path)?; + } + let listener = UnixListener::bind(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o660))?; + Ok(listener) +} + +/// Start the daemon with the provided configuration. +pub async fn run(config: Config) -> Result<()> { + let octocrab = Arc::new(build_octocrab(&config.github_token)?); + let (tx, rx) = channel(&config.queue_path)?; + let cfg = Arc::new(config); + + let listener = tokio::spawn(run_listener(cfg.clone(), tx)); + let worker = tokio::spawn(run_worker(cfg.clone(), rx, octocrab)); + + tokio::select! { + res = listener => match res { + Ok(inner) => inner?, + Err(e) => return Err(e.into()), + }, + res = worker => match res { + Ok(inner) => inner?, + Err(e) => return Err(e.into()), + }, + } + + Ok(()) +} + +async fn run_listener(config: Arc, mut tx: Sender) -> Result<()> { + let listener = prepare_listener(&config.socket_path)?; + + loop { + let (stream, _) = listener.accept().await?; + if let Err(e) = handle_client(stream, &mut tx).await { + tracing::warn!(error = %e, "Client handling failed"); + } + } +} + +async fn handle_client(mut stream: UnixStream, tx: &mut Sender) -> Result<()> { + let mut buffer = Vec::new(); + stream.read_to_end(&mut buffer).await?; + let request: CommentRequest = serde_json::from_slice(&buffer)?; + let bytes = serde_json::to_vec(&request)?; + tx.send(bytes).await?; + Ok(()) +} + +async fn run_worker(config: Arc, mut rx: Receiver, octocrab: Arc) -> Result<()> { + loop { + let guard = rx.recv().await?; + let request: CommentRequest = serde_json::from_slice(&guard)?; + + let issues = octocrab.issues(&request.owner, &request.repo); + let post = issues.create_comment(request.pr_number, &request.body); + + match tokio::time::timeout(Duration::from_secs(10), post).await { + Ok(Ok(_)) => { + guard.commit()?; + tokio::time::sleep(Duration::from_secs(config.cooldown_period_seconds)).await; + } + Ok(Err(e)) => { + tracing::error!(error = %e, owner = %request.owner, repo = %request.repo, pr = request.pr_number, "GitHub API call failed"); + } + Err(e) => { + tracing::error!(error = %e, "Timed out posting comment"); + } + } + } +} diff --git a/crates/comenqd/src/lib.rs b/crates/comenqd/src/lib.rs new file mode 100644 index 0000000..e811536 --- /dev/null +++ b/crates/comenqd/src/lib.rs @@ -0,0 +1,18 @@ +//! Library components for the Comenqd daemon. +//! +//! # Overview +//! This crate exposes: +//! - [`config::Config`] — typed, validated daemon configuration loaded from +//! `/etc/comenqd/config.toml` with environment and CLI overrides. +//! - Further daemon-specific helpers (to be added). +//! +//! # Examples +//! ```rust,no_run +//! use comenqd::config::Config; +//! +//! let cfg = Config::load().expect("configuration must be valid"); +//! println!("socket: {}", cfg.socket_path.display()); +//! ``` + +pub mod config; +pub mod daemon; diff --git a/crates/comenqd/src/main.rs b/crates/comenqd/src/main.rs index 367988a..03e11b7 100644 --- a/crates/comenqd/src/main.rs +++ b/crates/comenqd/src/main.rs @@ -4,7 +4,15 @@ use tracing::info; -fn main() { +mod config; +mod daemon; +use config::Config; +use daemon::run; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); - info!("Comenqd daemon started"); + let cfg = Config::load()?; + info!(socket = ?cfg.socket_path, queue = ?cfg.queue_path, "Comenqd daemon started"); + run(cfg).await } diff --git a/docs/comenq-design.md b/docs/comenq-design.md index 43fb0ea..135ffc3 100644 --- a/docs/comenq-design.md +++ b/docs/comenq-design.md @@ -513,6 +513,14 @@ at `/etc/comenqd/config.toml` is the conventional choice. | log_level | String | The minimum log level to record (e.g., "info", "debug", "trace"). | info | | cooldown_period_seconds | u64 | The cooling-off period in seconds after each comment post. | 900 | +Configuration is loaded using the `ortho_config` crate. The daemon calls +`Config::load()` which merges values from `/etc/comenqd/config.toml`, +`COMENQD_*` environment variables, and any supplied CLI arguments. CLI +arguments have the highest precedence, followed by environment variables, and +finally the configuration file. Missing optional fields are replaced with +defaults, while an absent `github_token` or invalid TOML results in a +configuration error. + Robust logging is non-negotiable for a background process. The `tracing` crate with `tracing-subscriber` will be used to provide structured, asynchronous logging. Key events to be logged include: @@ -1004,8 +1012,8 @@ async fn run_worker(config: Arc, mut rx: Receiver, octoc The repository initialises the workspace with `comenq-lib` at the root and two binary crates under `crates/`. `CommentRequest` resides in the library and -derives both `Serialize` and `Deserialize`. The binaries currently contain stub -`main` functions awaiting further implementation. +derives both `Serialize` and `Deserialize`. The daemon now spawns a Unix +listener and queue worker as described above. ## Works cited diff --git a/docs/roadmap.md b/docs/roadmap.md index 4624226..7d321d8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -30,7 +30,7 @@ ## Milestone 3: `comenqd` Daemon Core -- [ ] Implement configuration loading from a TOML file +- [x] Implement configuration loading from a TOML file (done) (`/etc/comenqd/config.toml`) for parameters like `github_token`, `socket_path`, and `queue_path`. diff --git a/tests/cucumber.rs b/tests/cucumber.rs index 1d165c0..e0ff087 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -1,12 +1,13 @@ mod steps; use cucumber::World as _; -use steps::{CliWorld, ClientWorld, CommentWorld}; +use steps::{CliWorld, ClientWorld, CommentWorld, ConfigWorld}; #[tokio::main] async fn main() { tokio::join!( - CommentWorld::run("tests/features/comment_request.feature"), CliWorld::run("tests/features/cli.feature"), - ClientWorld::run("tests/features/client_main.feature") + ClientWorld::run("tests/features/client_main.feature"), + CommentWorld::run("tests/features/comment_request.feature"), + ConfigWorld::run("tests/features/config.feature"), ); } diff --git a/tests/features/config.feature b/tests/features/config.feature new file mode 100644 index 0000000..0e19579 --- /dev/null +++ b/tests/features/config.feature @@ -0,0 +1,33 @@ +@serial +Feature: Daemon configuration + + Scenario: loading a valid configuration file + Given a configuration file with token "abc" + When the config is loaded + Then github token is "abc" + + Scenario: missing configuration file + Given a missing configuration file + When the config is loaded + Then config loading fails + + Scenario: environment variable overrides file + Given a configuration file with token "abc" + And environment variable "COMENQD_SOCKET_PATH" is "/tmp/env.sock" + When the config is loaded + Then socket path is "/tmp/env.sock" + + Scenario: invalid TOML syntax + Given an invalid configuration file + When the config is loaded + Then config loading fails + + Scenario: missing required field + Given a configuration file without github_token + When the config is loaded + Then config loading fails + + Scenario: uses default socket path + Given a configuration file with token "abc" and no socket_path + When the config is loaded + Then socket path is "/run/comenq/comenq.sock" diff --git a/tests/steps/config_steps.rs b/tests/steps/config_steps.rs new file mode 100644 index 0000000..51a10d6 --- /dev/null +++ b/tests/steps/config_steps.rs @@ -0,0 +1,168 @@ +//! Behavioural steps for daemon configuration loading. + +use cucumber::{World, given, then, when}; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +use comenqd::config::Config; + +/// RAII guard for temporarily setting an environment variable. +#[derive(Debug)] +struct EnvVarGuard { + key: String, + original: Option, +} + +impl EnvVarGuard { + fn set(key: &str, value: &str) -> Self { + let original = std::env::var(key).ok(); + set_env_var_safe(key, value); + Self { + key: key.to_string(), + original, + } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.original { + Some(val) => set_env_var_safe(&self.key, val), + None => remove_env_var_safe(&self.key), + } + } +} + +fn remove_env(key: &str) { + remove_env_var_safe(key); +} + +fn set_env_var_safe(key: &str, value: &str) { + // Safety: each scenario runs under serial_test, so no concurrent access. + unsafe { std::env::set_var(key, value) }; +} + +fn remove_env_var_safe(key: &str) { + // Safety: each scenario runs under serial_test, so no concurrent access. + unsafe { std::env::remove_var(key) }; +} + +#[derive(Debug, Default, World)] +pub struct ConfigWorld { + dir: Option, + path: Option, + result: Option>, + env_guard: Option, +} + +#[given(regex = r#"^a configuration file with token \"(.+)\"$"#)] +#[expect(clippy::expect_used, reason = "test setup uses expect")] +#[expect( + clippy::needless_pass_by_value, + reason = "cucumber requires owned values" +)] +fn config_file_with_token(world: &mut ConfigWorld, token: String) { + let dir = TempDir::new().expect("create temp dir"); + let path = dir.path().join("config.toml"); + fs::write(&path, format!("github_token='{token}'")).expect("write file"); + world.dir = Some(dir); + world.path = Some(path); + remove_env("COMENQD_SOCKET_PATH"); +} + +#[expect(clippy::expect_used, reason = "test setup uses expect")] +#[given("an invalid configuration file")] +fn invalid_configuration_file(world: &mut ConfigWorld) { + let dir = TempDir::new().expect("create temp dir"); + let path = dir.path().join("config.toml"); + fs::write(&path, "github_token='abc' this is not toml").expect("write file"); + world.dir = Some(dir); + world.path = Some(path); +} + +#[expect(clippy::expect_used, reason = "test setup uses expect")] +#[given("a configuration file without github_token")] +fn config_file_without_token(world: &mut ConfigWorld) { + let dir = TempDir::new().expect("create temp dir"); + let path = dir.path().join("config.toml"); + fs::write(&path, "socket_path='/tmp/s.sock'").expect("write file"); + world.dir = Some(dir); + world.path = Some(path); +} + +#[given(regex = r#"^a configuration file with token \"(.+)\" and no socket_path$"#)] +#[expect(clippy::expect_used, reason = "test setup uses expect")] +#[expect( + clippy::needless_pass_by_value, + reason = "cucumber requires owned values" +)] +fn config_without_socket(world: &mut ConfigWorld, token: String) { + let dir = TempDir::new().expect("create temp dir"); + let path = dir.path().join("config.toml"); + fs::write( + &path, + format!("github_token='{token}'\nqueue_path='/tmp/q'"), + ) + .expect("write file"); + world.dir = Some(dir); + world.path = Some(path); + remove_env("COMENQD_SOCKET_PATH"); +} + +#[given("a missing configuration file")] +fn missing_configuration_file(world: &mut ConfigWorld) { + world.path = Some(PathBuf::from("/nonexistent/nowhere.toml")); +} + +#[expect( + clippy::needless_pass_by_value, + reason = "cucumber requires owned values" +)] +#[given(regex = r#"^environment variable \"(.+)\" is \"(.+)\"$"#)] +fn set_env_var(world: &mut ConfigWorld, key: String, value: String) { + world.env_guard = Some(EnvVarGuard::set(&key, &value)); +} + +#[when("the config is loaded")] +#[expect(clippy::expect_used, reason = "test assertions")] +fn load_config(world: &mut ConfigWorld) { + let path = world.path.as_ref().expect("path set"); + world.result = Some(Config::from_file(path)); +} + +#[then(regex = r#"^github token is \"(.+)\"$"#)] +#[expect( + clippy::needless_pass_by_value, + reason = "cucumber requires owned values" +)] +fn github_token_is(world: &mut ConfigWorld, expected: String) { + match world.result.take() { + Some(Ok(cfg)) => assert_eq!(cfg.github_token, expected), + other => panic!("expected success, got {other:?}"), + } +} + +#[then("config loading fails")] +fn config_loading_fails(world: &mut ConfigWorld) { + match world.result.take() { + Some(Err(_)) => {} + other => panic!("expected error, got {other:?}"), + } +} + +#[then(regex = r#"^socket path is \"(.+)\"$"#)] +fn socket_path_is(world: &mut ConfigWorld, expected: String) { + match world.result.take() { + Some(Ok(cfg)) => assert_eq!(cfg.socket_path, PathBuf::from(expected)), + other => panic!("expected success, got {other:?}"), + } +} + +impl Drop for ConfigWorld { + fn drop(&mut self) { + if let Some(_guard) = self.env_guard.take() { + // dropping the guard restores the previous state + } + } +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index 918d9ae..d58e4b0 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -4,3 +4,9 @@ pub mod cli_steps; pub use cli_steps::CliWorld; pub mod comment_steps; pub use comment_steps::CommentWorld; +pub mod cli_steps; +pub use cli_steps::CliWorld; +pub mod config_steps; +pub use config_steps::ConfigWorld; +pub mod comment_steps; +pub use comment_steps::CommentWorld;