Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle-log: enhanced log highlighting
Open ade opened 3 months ago

Refactor out radicle::Logger and radicle::logger::Logger to radicle-log crate.

Enhance the test logger to highlight:

  1. Any base58 strings with a deterministic colour
  2. Any git refs to be boldened and underlined a. Any base58 strings inside refs to adhere to 1.
  3. Any git OIDs (short and long) first 6 characters mapped to a hex colour with light and dark background adjustments
  4. … any timestamps also coloured as per 3. 👀
  5. error level logs are all red and underlined (with rules 1 - 4 applied)
  6. warning level logs are all yellow and underlined (witith rules 1 - 4 applied)

I /think/ I managed to preserve the existing feature toggles, but worth double checking!

21 files changed +617 -184 993428df 77828760
modified Cargo.lock
@@ -2806,8 +2806,6 @@ dependencies = [
 "amplify",
 "base64 0.21.7",
 "bytesize",
-
 "chrono",
-
 "colored",
 "crossbeam-channel",
 "cyphernet",
 "dunce",
@@ -2864,6 +2862,7 @@ dependencies = [
 "radicle-crypto",
 "radicle-git-ref-format",
 "radicle-localtime",
+
 "radicle-log",
 "radicle-node",
 "radicle-surf",
 "radicle-term",
@@ -2900,6 +2899,8 @@ dependencies = [
 "log",
 "pretty_assertions",
 "radicle",
+
 "radicle-log",
+
 "radicle-term",
 "shlex",
 "snapbox",
 "thiserror 2.0.17",
@@ -3036,6 +3037,18 @@ dependencies = [
]

[[package]]
+
name = "radicle-log"
+
version = "0.1.0"
+
dependencies = [
+
 "chrono",
+
 "colored",
+
 "log",
+
 "radicle-localtime",
+
 "radicle-term",
+
 "regex",
+
]
+

+
[[package]]
name = "radicle-node"
version = "0.16.0"
dependencies = [
@@ -3057,6 +3070,7 @@ dependencies = [
 "radicle-crypto",
 "radicle-fetch",
 "radicle-localtime",
+
 "radicle-log",
 "radicle-protocol",
 "radicle-signals",
 "radicle-systemd",
@@ -3121,6 +3135,7 @@ dependencies = [
 "radicle",
 "radicle-cli",
 "radicle-crypto",
+
 "radicle-log",
 "thiserror 2.0.17",
]

modified Cargo.toml
@@ -52,6 +52,7 @@ radicle-fetch = { version = "0.16", path = "crates/radicle-fetch" }
radicle-git-metadata = { version = "0.1.0", path = "crates/radicle-git-metadata", default-features = false }
radicle-git-ref-format = { version = "0.1.0", path = "crates/radicle-git-ref-format", default-features = false }
radicle-localtime = { version = "0.1", path = "crates/radicle-localtime" }
+
radicle-log = { version = "0.1", path = "crates/radicle-log" }
radicle-node = { version = "0.16", path = "crates/radicle-node" }
radicle-oid = { version = "0.1.0", path = "crates/radicle-oid", default-features = false }
radicle-protocol = { version = "0.4", path = "crates/radicle-protocol" }
modified crates/radicle-cli-test/Cargo.toml
@@ -15,7 +15,9 @@ rust-version.workspace = true
escargot = "0.5.7"
log = { workspace = true, features = ["std"] }
pretty_assertions = { workspace = true }
-
radicle = { workspace = true, features = ["logger", "test"]}
+
radicle = { workspace = true, features = ["test"] }
+
radicle-log = { workspace = true, features = ["test"] }
+
radicle-term = { workspace = true }
snapbox = { workspace = true }
thiserror = { workspace = true, default-features = true }

modified crates/radicle-cli-test/src/lib.rs
@@ -213,8 +213,9 @@ impl TestFormula {
        // We don't need to re-build every time the `build` function is called. Once is enough.
        BUILD.call_once(|| {
            use escargot::format::Message;
-
            use radicle::logger::env_level;
-
            use radicle::logger::test::Logger;
+
            use radicle_log::env_level;
+
            use radicle_log::test::Logger;
+
            use radicle_term::Paint;

            let level = env_level().unwrap_or(log::Level::Debug);
            let logger = Box::new(Logger::new(level));
@@ -222,6 +223,14 @@ impl TestFormula {
            log::set_boxed_logger(logger).expect("no other logger should have been set already");
            log::set_max_level(level.to_level_filter());

+
            // `NO_COLOR` is supported by [`radicle-term::Paint`] - however when using `force()` we
+
            // override it. Because `cargo nextest` runs tests in a PTY and [`radicle-term::Paint`]
+
            // detects that (disabling colours), we need to use `force()` to ensure colours are painted.
+
            match env::var("NO_COLOR") {
+
                Err(_) => Paint::force(true),
+
                Ok(_) => log::info!(target: "test", "NO_COLOR detected, disabling colours."),
+
            }
+

            for (package, binary) in binaries {
                log::debug!(target: "test", "Building binaries for package `{package}`..");

modified crates/radicle-cli/Cargo.toml
@@ -23,13 +23,14 @@ human-panic.workspace = true
itertools.workspace = true
log = { workspace = true, features = ["std"] }
nonempty = { workspace = true }
-
radicle = { workspace = true, features = ["logger", "schemars"] }
+
radicle = { workspace = true, features = ["schemars"] }
radicle-cob = { workspace = true }
radicle-crypto = { workspace = true }
radicle-git-ref-format = { workspace = true, features = ["macro"] }
radicle-localtime = { workspace = true }
radicle-surf = { workspace = true }
radicle-term = { workspace = true }
+
radicle-log = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
@@ -64,6 +65,7 @@ radicle = { workspace = true, features = ["test"] }
radicle-cli-test = { workspace = true }
radicle-localtime = { workspace = true }
radicle-node = { workspace = true, features = ["test"] }
+
radicle-log = { workspace = true, features = ["test"] }

[lints]
workspace = true
modified crates/radicle-cli/src/main.rs
@@ -118,8 +118,8 @@ fn main() {
    .homepage(env!("CARGO_PKG_HOMEPAGE"))
    .support("Open a support request at https://radicle.zulipchat.com/ or file an issue via Radicle itself, or e-mail to team@radicle.xyz"));

-
    if let Some(lvl) = radicle::logger::env_level() {
-
        let logger = Box::new(radicle::logger::Logger::new(lvl));
+
    if let Some(lvl) = radicle_log::env_level() {
+
        let logger = Box::new(radicle_log::Logger::new(lvl));
        log::set_boxed_logger(logger).expect("no other logger should have been set already");
        log::set_max_level(lvl.to_level_filter());
    }
modified crates/radicle-cli/tests/commands.rs
@@ -22,7 +22,7 @@ use radicle::test::fixtures;

use radicle_localtime::LocalTime;
#[allow(unused_imports)]
-
use radicle_node::test::logger;
+
use radicle_log::test::Logger;
use radicle_node::test::node::Node;
use radicle_node::PROTOCOL_VERSION;

added crates/radicle-log/Cargo.toml
@@ -0,0 +1,20 @@
+
[package]
+
name = "radicle-log"
+
description = "Radicle loggers"
+
homepage.workspace = true
+
repository.workspace = true
+
license.workspace = true
+
version = "0.1.0"
+
edition.workspace = true
+
rust-version.workspace = true
+

+
[features]
+
test = []
+

+
[dependencies]
+
chrono = { workspace = true, features = ["clock"] }
+
colored = { workspace = true }
+
radicle-localtime = { workspace = true, features = ["serde"] }
+
log = { version = "0.4", features = ["std"] }
+
radicle-term = { workspace = true }
+
regex = "1"
added crates/radicle-log/src/lib.rs
@@ -0,0 +1,100 @@
+
//! Logging module.
+
//!
+
//! For test logging see [`mod@test`].
+

+
#[cfg(feature = "test")]
+
pub mod test;
+

+
#[cfg(test)]
+
mod tests;
+

+
use std::io::{self, Write};
+

+
use chrono::prelude::*;
+
use colored::*;
+
use log::{Level, Log, Metadata, Record};
+

+
/// A logger that logs to `stdout`.
+
pub struct Logger {
+
    level: Level,
+
}
+

+
impl Logger {
+
    pub fn new(level: Level) -> Self {
+
        Self { level }
+
    }
+
}
+

+
impl Log for Logger {
+
    fn enabled(&self, metadata: &Metadata) -> bool {
+
        metadata.level() <= self.level
+
    }
+

+
    fn log(&self, record: &Record) {
+
        if self.enabled(record.metadata()) {
+
            let target = record.target();
+

+
            let message = format!(
+
                "{:<5} {:<8} {}",
+
                record.level(),
+
                target.cyan(),
+
                record.args()
+
            );
+

+
            let message = format!(
+
                "{} {}",
+
                Local::now().to_rfc3339_opts(SecondsFormat::Millis, true),
+
                message,
+
            );
+

+
            let message = match record.level() {
+
                Level::Error => message.red(),
+
                Level::Warn => message.yellow(),
+
                Level::Info => message.normal(),
+
                Level::Debug => message.dimmed(),
+
                Level::Trace => message.white().dimmed(),
+
            };
+
            writeln!(&mut io::stdout(), "{message}").expect("write shouldn't fail");
+
        }
+
    }
+

+
    fn flush(&self) {}
+
}
+

+
/// A logger that logs to `stderr`.
+
pub struct StderrLogger {
+
    level: Level,
+
}
+

+
impl StderrLogger {
+
    pub fn new(level: Level) -> Self {
+
        Self { level }
+
    }
+
}
+

+
impl Log for StderrLogger {
+
    fn enabled(&self, metadata: &Metadata) -> bool {
+
        metadata.level() <= self.level
+
    }
+

+
    fn log(&self, record: &Record) {
+
        if self.enabled(record.metadata()) {
+
            let message = format!(
+
                "{:<5} {:<8} {}",
+
                record.level(),
+
                record.target(),
+
                record.args()
+
            );
+
            writeln!(&mut io::stderr(), "{message}").expect("write shouldn't fail");
+
        }
+
    }
+

+
    fn flush(&self) {}
+
}
+

+
/// Get the level set by the environment variable `RUST_LOG`, if
+
/// present.
+
pub fn env_level() -> Option<Level> {
+
    let level = std::env::var("RUST_LOG").ok()?;
+
    level.parse().ok()
+
}
added crates/radicle-log/src/test.rs
@@ -0,0 +1,252 @@
+
use std::collections::HashMap;
+
use std::io::{self, Write};
+
use std::sync::{Arc, Mutex};
+

+
use log::{Level, Log, Metadata, Record, SetLoggerError};
+
use regex::Regex;
+

+
use radicle_localtime::LocalTime;
+
use radicle_term::{Color, Paint};
+

+
/// A writer that can be shared across threads.
+
pub type SharedWriter = Arc<Mutex<dyn Write + Send + Sync>>;
+

+
/// The Test Logger
+
/// Logs with Epoch timestamps, "test"/"sim" formatting and regex highlighting
+
pub struct Logger {
+
    level: Level,
+
    pub base58_ref_oid_re: Regex,
+
    pub base58_re: Regex,
+
    writer: SharedWriter,
+
    aliases: HashMap<String, String>,
+
}
+

+
/// The Base58 pattern used for Radicle IDs.
+
const BASE58_REGEX: &str = r"z[1-9A-HJ-NP-Za-km-z]{10,}";
+

+
impl Logger {
+
    pub fn new(level: Level) -> Self {
+
        let mut aliases = HashMap::new();
+
        if let Ok(s) = std::env::var("RAD_ALIASES") {
+
            for pair in s.split(',') {
+
                if let Some((k, v)) = pair.split_once('=') {
+
                    aliases.insert(k.to_owned(), v.to_owned());
+
                }
+
            }
+
        }
+

+
        Self {
+
            level,
+
            // base58: Starts with 'z', base58 chars, 10+ length.
+
            // ref: Starts with 'refs/', followed by valid ref chars.
+
            // oid: Hex characters, between 6 and 40 length, with word boundaries. (currently
+
            // matching timestamps too e.g. `1769096403171`)
+
            base58_ref_oid_re: Regex::new(&format!(
+
                r"(?P<base58>{BASE58_REGEX})|(?P<ref>refs/[a-zA-Z0-9/*._-]+)|(?P<oid>\b[0-9a-f]{{6,40}}\b)")
+
            ).expect("invalid regex"),
+
            base58_re: Regex::new(BASE58_REGEX).expect("invalid id regex"),
+
            writer: Arc::new(Mutex::new(io::stdout())),
+
            aliases,
+
        }
+
    }
+

+
    /// Create a new logger with a custom writer.
+
    pub fn with_writer(level: Level, writer: SharedWriter) -> Self {
+
        let mut logger = Self::new(level);
+
        logger.writer = writer;
+
        logger
+
    }
+

+
    pub fn init(self) -> Result<(), SetLoggerError> {
+
        let level = self.level;
+
        log::set_boxed_logger(Box::new(self))?;
+
        log::set_max_level(level.to_level_filter());
+
        Ok(())
+
    }
+

+
    fn paint_base58_with_aliases<'a>(&'a self, s: &'a str) -> Paint<&'a str> {
+
        let alias_or_match_str = self.aliases.get(s).map(|x| x.as_str()).unwrap_or(s);
+
        let colour = colour_for_base58(s);
+

+
        colour.paint(alias_or_match_str).bold()
+
    }
+

+
    fn format_message(&self, msg: &str, paint_plain: impl Fn(&str) -> String) -> String {
+
        let mut coloured_msg = String::new();
+
        let mut last_match = 0;
+

+
        // Iterate over the main composite matches
+
        for caps in self.base58_ref_oid_re.captures_iter(msg) {
+
            let whole_match = caps.get(0).unwrap();
+

+
            // Paint text BEFORE the match (Plain style)
+
            coloured_msg.push_str(&paint_plain(&msg[last_match..whole_match.start()]));
+

+
            // Handle the match based on which group captured it
+
            if let Some(m) = caps.name("base58") {
+
                // Standard Base58 match (not inside a ref)
+
                let match_str = m.as_str();
+

+
                coloured_msg.push_str(&self.paint_base58_with_aliases(match_str).to_string());
+
            } else if let Some(m) = caps.name("oid") {
+
                // Git OID match (RGB from hex with contrast check)
+
                let oid = m.as_str();
+
                coloured_msg.push_str(&paint_oid(oid).to_string());
+
            } else if let Some(m) = caps.name("ref") {
+
                // Git Ref match
+
                let ref_str = m.as_str();
+
                let mut last_ref_idx = 0;
+

+
                // Search for Base58 IDs *inside* this ref string
+
                for id_match in self.base58_re.find_iter(ref_str) {
+
                    let prefix = &ref_str[last_ref_idx..id_match.start()];
+
                    coloured_msg.push_str(&Paint::new(prefix).bold().underline().to_string());
+

+
                    // Paint the ID itself (Deterministic Colour + Bold)
+
                    let id = id_match.as_str();
+
                    coloured_msg
+
                        .push_str(&self.paint_base58_with_aliases(id).underline().to_string());
+

+
                    last_ref_idx = id_match.end();
+
                }
+

+
                let suffix = &ref_str[last_ref_idx..];
+
                coloured_msg.push_str(&Paint::new(suffix).bold().underline().to_string());
+
            }
+

+
            last_match = whole_match.end();
+
        }
+

+
        // Paint the remaining text
+
        coloured_msg.push_str(&paint_plain(&msg[last_match..]));
+

+
        coloured_msg
+
    }
+
}
+

+
impl Log for Logger {
+
    fn enabled(&self, metadata: &Metadata) -> bool {
+
        metadata.level() <= self.level
+
    }
+

+
    fn log(&self, record: &Record) {
+
        if !self.enabled(record.metadata()) {
+
            return;
+
        }
+

+
        let target = record.target();
+
        let level = record.level();
+

+
        // Helper to paint the "plain" parts of the message based on the target/level.
+
        let paint_plain = |s: &str| -> String {
+
            match target {
+
                "test" => Paint::cyan(s).to_string(),
+
                "sim" => Paint::new(s).bold().to_string(),
+
                _ => match level {
+
                    Level::Warn => Paint::yellow(s).underline().to_string(),
+
                    Level::Error => Paint::red(s).underline().to_string(),
+
                    _ => Paint::new(s).dim().to_string(),
+
                },
+
            }
+
        };
+

+
        let msg = record.args().to_string();
+
        let coloured_msg = self.format_message(&msg, &paint_plain);
+

+
        let time = LocalTime::now().as_secs();
+
        let mut writer = self.writer.lock().unwrap_or_else(|e| e.into_inner());
+

+
        match target {
+
            "test" => {
+
                let _ = writeln!(writer, "{} {} {}", time, Paint::cyan("test:"), coloured_msg);
+
            }
+
            "sim" => {
+
                let _ = writeln!(
+
                    writer,
+
                    "{} {}  {}",
+
                    time,
+
                    Paint::new("sim:").bold(),
+
                    coloured_msg
+
                );
+
            }
+
            _ => {
+
                let current = std::thread::current();
+
                let target_str = format!("{}:", target);
+

+
                // We format the timestamp separately to avoid it being colored as an OID.
+
                let time_display = paint_plain(&format!("{} ", time));
+

+
                let rest_of_prefix = if let Some(name) = current.name() {
+
                    format!("{:<16} {:>10}", name, target_str)
+
                } else {
+
                    format!("{:>10}", target_str)
+
                };
+

+
                let coloured_rest = self.format_message(&rest_of_prefix, &paint_plain);
+

+
                let _ = writeln!(
+
                    writer,
+
                    "{}\t{} {}",
+
                    time_display, coloured_rest, coloured_msg
+
                );
+
            }
+
        }
+
    }
+

+
    fn flush(&self) {}
+
}
+

+
/// Deterministically pick a colour for a base58 string.
+
/// NOTE: If the output contains more than base58 strings than the number of colours below,
+
/// consider switching to the `paint_oid` system.
+
pub fn colour_for_base58(s: &str) -> Color {
+
    let mut hash: u32 = 0;
+
    for b in s.bytes() {
+
        hash = hash.wrapping_add(b as u32);
+
    }
+

+
    let colours = [
+
        Color::Red,
+
        Color::Green,
+
        Color::Yellow,
+
        Color::Blue,
+
        Color::Magenta,
+
        Color::Cyan,
+
        Color::White,
+
    ];
+

+
    colours[(hash as usize) % colours.len()]
+
}
+

+
/// Paint an OID using its first 6 characters as an RGB hex code.
+
/// Automatically applies a contrasting background if the colour is too bright or too dark.
+
pub fn paint_oid(oid: &str) -> Paint<&str> {
+
    if oid.len() < 6 {
+
        return Paint::yellow(oid);
+
    }
+

+
    let r = u8::from_str_radix(&oid[0..2], 16).unwrap_or(128);
+
    let g = u8::from_str_radix(&oid[2..4], 16).unwrap_or(128);
+
    let b = u8::from_str_radix(&oid[4..6], 16).unwrap_or(128);
+

+
    // Calculate relative luminance (0.0 to 255.0)
+
    // Formula: 0.299*R + 0.587*G + 0.114*B
+
    let luminance = (0.299 * r as f32) + (0.587 * g as f32) + (0.114 * b as f32);
+

+
    let colour = Color::RGB(r, g, b);
+
    let paint = colour.paint(oid);
+

+
    // Thresholds: < 40 is very dark, > 215 is very bright.
+
    if luminance < 50.0 {
+
        paint.bg(Color::White)
+
    } else if luminance > 205.0 {
+
        paint.bg(Color::Black)
+
    } else {
+
        paint
+
    }
+
}
+

+
/// Initialize the logger with the given level.
+
pub fn init(level: Level) -> Result<(), SetLoggerError> {
+
    Logger::new(level).init()
+
}
added crates/radicle-log/src/tests.rs
@@ -0,0 +1,196 @@
+
use crate::test::{colour_for_base58, Logger};
+
use log::{Level, Log, Record};
+
use radicle_term::{Color, Paint};
+
use std::io::{self, Write};
+
use std::sync::{Arc, Mutex};
+

+
#[derive(Clone)]
+
struct TestWriter {
+
    data: Arc<Mutex<Vec<u8>>>,
+
}
+

+
impl Write for TestWriter {
+
    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
+
        self.data.lock().unwrap().write(buf)
+
    }
+

+
    fn flush(&mut self) -> io::Result<()> {
+
        Ok(())
+
    }
+
}
+

+
#[test]
+
fn test_colour_for() {
+
    let s = "z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk";
+
    let color = colour_for_base58(s);
+
    assert_eq!(color, Color::Red);
+

+
    let s = "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
+
    let color = colour_for_base58(s);
+
    assert_eq!(color, Color::Blue);
+
}
+

+
#[test]
+
fn test_base58_ref_oid_regex_matching() {
+
    let logger = Logger::new(Level::Debug);
+

+
    let cases = vec![
+
        (
+
            "fetched rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji from z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
            vec![
+
                "z42hL2jL4XNk6K8oHQaSWfMgCL7ji",
+
                "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
            ],
+
        ),
+
        (
+
            "Setting ref: refs/rad/id -> 3143236b2e40338f5574ec04e935a5ab80a6868a",
+
            vec!["refs/rad/id", "3143236b2e40338f5574ec04e935a5ab80a6868a"],
+
        ),
+
        (
+
            "Syncing z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk with z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
            vec![
+
                "z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk",
+
                "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
            ],
+
        ),
+
        (
+
            "Multiple refs: refs/heads/master and refs/tags/v1.0.0",
+
            vec!["refs/heads/master", "refs/tags/v1.0.0"],
+
        ),
+
        (
+
            "Mixed content: z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk and refs/remotes/origin/main",
+
            vec![
+
                "z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk",
+
                "refs/remotes/origin/main",
+
            ],
+
        ),
+
        (
+
            "No matches here",
+
            vec![],
+
        ),
+
        (
+
            "Short z123 is not matched",
+
            vec![],
+
        ),
+
        (
+
            "Timestamp 1769096403171 is matched by regex but filtered in log",
+
            vec!["1769096403171"],
+
        ),
+
    ];
+

+
    for (msg, expected) in cases {
+
        let matches: Vec<_> = logger
+
            .base58_ref_oid_re
+
            .find_iter(msg)
+
            .map(|m| m.as_str())
+
            .collect();
+
        assert_eq!(matches, expected, "Failed matching for input: '{}'", msg);
+
    }
+
}
+

+
#[test]
+
fn test_log_output() {
+
    Paint::force(true);
+

+
    let cases = vec![
+
        (
+
            "Hello z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk world",
+
            "test",
+
            vec![
+
                "\x1b[36mtest:\x1b[0m", // Target
+
                "\x1b[36mHello \x1b[0m", // Plain text (Cyan for test)
+
                "\x1b[1;31mz6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk\x1b[0m", // ID (Red + Bold)
+
                "\x1b[36m world\x1b[0m", // Plain text (Cyan)
+
            ],
+
        ),
+
        (
+
            "Syncing z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk with z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi",
+
            "test",
+
            vec![
+
                "\x1b[36mtest:\x1b[0m",
+
                "\x1b[36mSyncing \x1b[0m",
+
                "\x1b[1;31mz6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk\x1b[0m", // Red + Bold
+
                "\x1b[36m with \x1b[0m",
+
                "\x1b[1;34mz6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi\x1b[0m", // Blue + Bold
+
            ],
+
        ),
+
        (
+
            "Updated refs/heads/master",
+
            "sim",
+
            vec![
+
                "\x1b[1msim:\x1b[0m", // Target (Bold)
+
                "\x1b[1mUpdated \x1b[0m", // Plain text (Bold for sim)
+
                "\x1b[1;4mrefs/heads/master\x1b[0m", // Ref (Bold + Underline)
+
            ],
+
        ),
+
        (
+
            "No matches here",
+
            "test",
+
            vec![
+
                "\x1b[36mtest:\x1b[0m",
+
                "\x1b[36mNo matches here\x1b[0m",
+
            ],
+
        ),
+
        (
+
            "Timestamp 1769096403171 is matched as OID and given RGB colour",
+
            "test",
+
            vec![
+
                "\x1b[36mtest:\x1b[0m",
+
                "\x1b[36mTimestamp \x1b[0m",
+
                "\x1b[38;2;23;105;9m1769096403171\x1b[0m",
+
                "\x1b[36m is matched as OID and given RGB colour\x1b[0m",
+
            ]
+
        ),
+
        (
+
            "Commit 3143236b2e40338f5574ec04e935a5ab80a6868a",
+
            "test",
+
            vec![
+
                "\x1b[36mtest:\x1b[0m",
+
                "\x1b[36mCommit \x1b[0m",
+
                // OID painting: 314323 -> R=49, G=67, B=35.
+
                // No background.
+
                "\x1b[38;2;49;67;35m3143236b2e40338f5574ec04e935a5ab80a6868a\x1b[0m",
+
            ]
+
        ),
+
        (
+
            "Ref with ID refs/namespaces/z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z/refs/rad/sigrefs",
+
            "test",
+
            vec![
+
                "\x1b[36mRef with ID \x1b[0m",
+
                "\x1b[1;4mrefs/namespaces/\x1b[0m", // Prefix (Bold + Underline)
+
                // ID: z6Mkux...
+
                // We check for the ID string being present.
+
                "z6Mkux1aUQD2voWWukVb5nNUR7thrHveQG4pDQua8nVhib7Z",
+
                "\x1b[1;4m/refs/rad/sigrefs\x1b[0m", // Suffix (Bold + Underline)
+
            ]
+
        )
+
    ];
+

+
    for (msg, target, expected_parts) in cases {
+
        let data = Arc::new(Mutex::new(Vec::new()));
+
        let writer = TestWriter { data: data.clone() };
+
        let logger = Logger::with_writer(Level::Debug, Arc::new(Mutex::new(writer)));
+

+
        let args = format_args!("{}", msg);
+
        let record = Record::builder()
+
            .args(args)
+
            .level(Level::Info)
+
            .target(target)
+
            .build();
+

+
        logger.log(&record);
+

+
        let output = String::from_utf8(data.lock().unwrap().clone()).unwrap();
+

+
        for part in expected_parts {
+
            assert!(
+
                output.contains(part),
+
                "Output did not contain expected part: {:?}\nFull output: {:?}",
+
                part,
+
                output
+
            );
+
        }
+
    }
+

+
    Paint::force(false);
+
}
modified crates/radicle-node/Cargo.toml
@@ -28,11 +28,12 @@ log = { workspace = true, features = ["kv", "std"] }
mio = { version = "1", features = ["net", "os-poll"] }
nonempty = { workspace = true, features = ["serialize"] }
qcheck = { workspace = true, optional = true }
-
radicle = { workspace = true, features = ["logger"] }
+
radicle = { workspace = true }
radicle-fetch = { workspace = true }
radicle-localtime = { workspace = true }
radicle-protocol = { workspace = true }
radicle-signals = { workspace = true }
+
radicle-log = { workspace = true }
sqlite = { workspace = true, features = ["bundled"] }
scrypt = { version = "0.11.0", default-features = false }
serde = { workspace = true, features = ["derive"] }
@@ -54,6 +55,7 @@ mio = { version = "1", features = ["os-ext"] }
qcheck = { workspace = true }
qcheck-macros = { workspace = true }
radicle = { workspace = true, features = ["test"] }
+
radicle-log = { workspace = true, features = ["test"] }
radicle-protocol = { workspace = true, features = ["test"] }
radicle-crypto = { workspace = true, features = ["test", "cyphernet"] }
snapbox = { workspace = true }
modified crates/radicle-node/src/main.rs
@@ -378,7 +378,7 @@ fn initialize_logging(options: &LogOptions) -> Result<(), Box<dyn std::error::Er
                const SYSLOG_IDENTIFIER: &str = "radicle-node";
                logger::<&str, &str, _>(SYSLOG_IDENTIFIER.to_string(), []).map_err(Box::new)?
            }
-
            Logger::Radicle => Box::new(radicle::logger::Logger::new(level)),
+
            Logger::Radicle => Box::new(radicle_log::Logger::new(level)),
        }
    };

modified crates/radicle-node/src/test.rs
@@ -5,5 +5,5 @@ pub mod peer;
pub mod simulator;

pub use radicle::assert_matches;
-
pub use radicle::logger::test as logger;
pub use radicle::test::*;
+
pub use radicle_log::test as logger;
modified crates/radicle-protocol/Cargo.toml
@@ -20,7 +20,7 @@ fastrand = { workspace = true }
log = { workspace = true, features = ["std"] }
nonempty = { workspace = true, features = ["serialize"] }
qcheck = { workspace = true, optional = true }
-
radicle = { workspace = true, features = ["logger"] }
+
radicle = { workspace = true }
radicle-core = { workspace = true }
radicle-fetch = { workspace = true }
radicle-localtime = { workspace = true }
modified crates/radicle-remote-helper/Cargo.toml
@@ -19,4 +19,6 @@ log = { workspace = true }
radicle = { workspace = true }
radicle-cli = { workspace = true }
radicle-crypto = { workspace = true }
-
thiserror = { workspace = true, default-features = true }

\ No newline at end of file
+
radicle-log = { workspace = true }
+
thiserror = { workspace = true, default-features = true }
+

modified crates/radicle-remote-helper/src/main.rs
@@ -45,8 +45,8 @@ pub const VERSION: Version = Version {
fn main() {
    let mut args = env::args();

-
    if let Some(lvl) = radicle::logger::env_level() {
-
        let logger = radicle::logger::StderrLogger::new(lvl);
+
    if let Some(lvl) = radicle_log::env_level() {
+
        let logger = radicle_log::StderrLogger::new(lvl);
        log::set_boxed_logger(Box::new(logger))
            .expect("no other logger should have been set already");
        log::set_max_level(lvl.to_level_filter());
modified crates/radicle/Cargo.toml
@@ -12,7 +12,6 @@ rust-version.workspace = true
[features]
default = []
test = ["tempfile", "qcheck", "radicle-crypto/test", "radicle-cob/test"]
-
logger = ["colored", "chrono"]
qcheck = [
  "radicle-core/qcheck",
  "dep:qcheck"
@@ -29,8 +28,6 @@ schemars = [
amplify = { workspace = true, features = ["std"] }
base64 = "0.21.3"
bytesize = { version = "2", features = ["serde"] }
-
chrono = { workspace = true, features = ["clock"], optional = true }
-
colored = { workspace = true, optional = true }
crossbeam-channel = { workspace = true }
cyphernet = { workspace = true, features = ["tor", "dns", "p2p-ed25519"] }
dunce = { workspace = true }
modified crates/radicle/src/lib.rs
@@ -19,8 +19,6 @@ pub mod explorer;
pub mod git;
pub mod identity;
pub mod io;
-
#[cfg(feature = "logger")]
-
pub mod logger;
pub mod node;
pub mod profile;
pub mod rad;
deleted crates/radicle/src/logger.rs
@@ -1,98 +0,0 @@
-
//! Logging module.
-
//!
-
//! For test logging see [`mod@test`].
-

-
#[cfg(feature = "test")]
-
pub mod test;
-

-
use std::io;
-
use std::io::Write;
-

-
use chrono::prelude::*;
-
use colored::*;
-
use log::{Level, Log, Metadata, Record};
-

-
/// A logger that logs to `stdout`.
-
pub struct Logger {
-
    level: Level,
-
}
-

-
impl Logger {
-
    pub fn new(level: Level) -> Self {
-
        Self { level }
-
    }
-
}
-

-
impl Log for Logger {
-
    fn enabled(&self, metadata: &Metadata) -> bool {
-
        metadata.level() <= self.level
-
    }
-

-
    fn log(&self, record: &Record) {
-
        if self.enabled(record.metadata()) {
-
            let target = record.target();
-

-
            let message = format!(
-
                "{:<5} {:<8} {}",
-
                record.level(),
-
                target.cyan(),
-
                record.args()
-
            );
-

-
            let message = format!(
-
                "{} {}",
-
                Local::now().to_rfc3339_opts(SecondsFormat::Millis, true),
-
                message,
-
            );
-

-
            let message = match record.level() {
-
                Level::Error => message.red(),
-
                Level::Warn => message.yellow(),
-
                Level::Info => message.normal(),
-
                Level::Debug => message.dimmed(),
-
                Level::Trace => message.white().dimmed(),
-
            };
-
            writeln!(&mut io::stdout(), "{message}").expect("write shouldn't fail");
-
        }
-
    }
-

-
    fn flush(&self) {}
-
}
-

-
/// A logger that logs to `stderr`.
-
pub struct StderrLogger {
-
    level: Level,
-
}
-

-
impl StderrLogger {
-
    pub fn new(level: Level) -> Self {
-
        Self { level }
-
    }
-
}
-

-
impl Log for StderrLogger {
-
    fn enabled(&self, metadata: &Metadata) -> bool {
-
        metadata.level() <= self.level
-
    }
-

-
    fn log(&self, record: &Record) {
-
        if self.enabled(record.metadata()) {
-
            let message = format!(
-
                "{:<5} {:<8} {}",
-
                record.level(),
-
                record.target(),
-
                record.args()
-
            );
-
            writeln!(&mut io::stderr(), "{message}").expect("write shouldn't fail");
-
        }
-
    }
-

-
    fn flush(&self) {}
-
}
-

-
/// Get the level set by the environment variable `RUST_LOG`, if
-
/// present.
-
pub fn env_level() -> Option<Level> {
-
    let level = std::env::var("RUST_LOG").ok()?;
-
    level.parse().ok()
-
}
deleted crates/radicle/src/logger/test.rs
@@ -1,65 +0,0 @@
-
use localtime::LocalTime;
-
use log::*;
-

-
pub struct Logger {
-
    level: Level,
-
}
-

-
impl Logger {
-
    pub fn new(level: Level) -> Self {
-
        Self { level }
-
    }
-
}
-

-
impl Log for Logger {
-
    fn enabled(&self, metadata: &Metadata) -> bool {
-
        metadata.level() <= self.level
-
    }
-

-
    fn log(&self, record: &Record) {
-
        use colored::Colorize;
-
        let time = LocalTime::now().as_secs();
-

-
        match record.target() {
-
            "test" => {
-
                println!(
-
                    "{time} {} {}",
-
                    "test:".cyan(),
-
                    record.args().to_string().cyan()
-
                )
-
            }
-
            "sim" => {
-
                println!(
-
                    "{time} {}  {}",
-
                    "sim:".bold(),
-
                    record.args().to_string().bold()
-
                )
-
            }
-
            target => {
-
                if self.enabled(record.metadata()) {
-
                    let current = std::thread::current();
-
                    let msg = format!("{:>10} {}", format!("{target}:"), record.args());
-
                    let time = LocalTime::now().as_secs();
-
                    let s = if let Some(name) = current.name() {
-
                        format!("{time} {name:<16} {msg}")
-
                    } else {
-
                        format!("{time} {msg}")
-
                    };
-
                    match record.level() {
-
                        log::Level::Warn => {
-
                            println!("{}", s.yellow());
-
                        }
-
                        log::Level::Error => {
-
                            println!("{}", s.red());
-
                        }
-
                        _ => {
-
                            println!("{}", s.dimmed());
-
                        }
-
                    }
-
                }
-
            }
-
        }
-
    }
-

-
    fn flush(&self) {}
-
}