From 95a46a798f27c6ece3ef6a0cdc08f811d9955462 Mon Sep 17 00:00:00 2001
From: Awiteb
Date: Mon, 22 Sep 2025 21:03:54 +0000
Subject: [PATCH] feat(logs): log to stderr and a file
Signed-off-by: Awiteb
---
CHANGELOG.md | 1 +
src/cli/utils.rs | 43 ++++++++++++++++++++++++++++-
src/error.rs | 5 ++++
src/main.rs | 71 ++++++++++++++++++++++++++++++++----------------
4 files changed, 95 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4d1a23a..70a9ac6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New command `repo state` - by Awiteb
- Support pull requests - by Awiteb
- Add `--personal-fork` flag to `repo announce` command - by Awiteb
+- Log to stderr and a file - by Awiteb
### Breaking Change
diff --git a/src/cli/utils.rs b/src/cli/utils.rs
index a608d0e..9d0cad1 100644
--- a/src/cli/utils.rs
+++ b/src/cli/utils.rs
@@ -14,7 +14,12 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-use std::io::{self, Write};
+use std::{
+ fs,
+ io::{self, Write},
+};
+
+use crate::error::{N34Error, N34Result};
/// Displays the given prompt and reads a line of input from the user.
pub fn read_line(prompt: &str) -> io::Result {
@@ -43,3 +48,39 @@ pub fn prompt_bool(prompt: &str) -> io::Result {
}
}
}
+
+/// Opens the logs file for writing. If the file size exceeds 5MB, it is opened
+/// in write mode, otherwise in append mode.
+pub fn logs_file() -> N34Result {
+ const FIVE_MB: u64 = 1024 * 1024 * 5;
+
+ let logs_path = dirs::data_local_dir()
+ .ok_or(N34Error::CanNotFindDataPath)?
+ .join("n34")
+ .join("logs.log");
+
+ tracing::info!(path = %logs_path.display(), "Logs file");
+
+ if let Some(parent) = logs_path.parent()
+ && !parent.exists()
+ {
+ fs::create_dir_all(parent)?;
+ }
+
+ _ = fs::File::create_new(&logs_path);
+
+ let is_large = if let Ok(file) = fs::File::open(&logs_path)
+ && let Ok(metadata) = file.metadata()
+ {
+ metadata.len() >= FIVE_MB
+ } else {
+ false
+ };
+
+ fs::OpenOptions::new()
+ .write(true)
+ .append(!is_large)
+ .truncate(is_large)
+ .open(&logs_path)
+ .map_err(N34Error::from)
+}
diff --git a/src/error.rs b/src/error.rs
index cd53e6f..cc60957 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -55,6 +55,11 @@ pub enum N34Error {
Keyring(#[from] nostr_keyring::Error),
#[error("{0}")]
Config(#[from] ConfigError),
+ #[error(
+ "Could not determine the default data path: both `$XDG_CONFIG_HOME` and `$HOME` \
+ environment variables are missing or unset."
+ )]
+ CanNotFindDataPath,
#[error("No editor specified in the `EDITOR` environment variable")]
EditorNotFound,
#[error("The file you edited is empty. Please save your changes before exiting the editor.")]
diff --git a/src/main.rs b/src/main.rs
index ff0dc97..62ab692 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,13 +22,14 @@ pub mod error;
pub mod nostr_utils;
use std::{
+ fs::File,
process::ExitCode,
sync::atomic::{AtomicBool, Ordering},
};
use clap::Parser;
use clap_verbosity_flag::Verbosity;
-use tracing::Level;
+use tracing::{Level, level_filters::LevelFilter};
use tracing_subscriber::{Layer, filter, layer::SubscriberExt};
use self::cli::Cli;
@@ -37,42 +38,64 @@ use self::cli::Cli;
/// open.
static EDITOR_OPEN: AtomicBool = AtomicBool::new(false);
+/// Returns the stderr log layer
+fn stderr_log_layer() -> impl Layer
+where
+ S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
+{
+ tracing_subscriber::fmt::layer()
+ .with_ansi(true)
+ .with_writer(std::io::stderr)
+ .without_time()
+}
+
/// Configures the logging level based on the provided verbosity.
///
/// When verbosity is set to TRACE, includes file and line numbers in logs.
-fn set_log_level(verbosity: Verbosity) {
- let is_trace = verbosity
- .tracing_level()
- .is_some_and(|l| l == tracing::Level::TRACE);
-
- let logs_filter = filter::dynamic_filter_fn(move |m, _| {
+fn set_log_level(verbosity: Verbosity, logs_file: File) {
+ let editor_filter = filter::dynamic_filter_fn(move |m, _| {
// Disable all logs while editor is open
verbosity.tracing_level().unwrap_or(Level::ERROR) >= *m.level()
+ && (m.name().starts_with("event src") || m.name().contains("nostr"))
&& !EDITOR_OPEN.load(Ordering::Relaxed)
});
- let logs_layer = tracing_subscriber::fmt::layer()
- .with_file(is_trace)
- .with_line_number(is_trace)
- .without_time();
- let subscriber = tracing_subscriber::registry().with(logs_layer.with_filter(logs_filter));
+ let file_layer = tracing_subscriber::fmt::layer()
+ .with_ansi(false)
+ .with_writer(logs_file)
+ .with_file(true)
+ .with_line_number(true)
+ .with_filter(LevelFilter::TRACE);
+
+ let subscriber = tracing_subscriber::registry()
+ .with(stderr_log_layer().with_filter(editor_filter))
+ .with(file_layer);
tracing::subscriber::set_global_default(subscriber).ok();
}
+async fn try_main() -> error::N34Result<()> {
+ // Initialize a thread-local subscriber for logging during CLI parsing and
+ // post-processing.
+ let guard =
+ tracing::subscriber::set_default(tracing_subscriber::registry().with(stderr_log_layer()));
+
+ let cli = cli::post_cli(Cli::parse())?;
+ let logs_file = cli::utils::logs_file()?;
+
+ // Replace the thread-local subscriber with a global default subscriber based on
+ // the CLI verbosity level.
+ drop(guard);
+ set_log_level(cli.verbosity, logs_file);
+
+ cli.run().await?;
+
+ Ok(())
+}
+
#[tokio::main]
async fn main() -> ExitCode {
- let cli = match cli::post_cli(Cli::parse()) {
- Ok(cli) => cli,
- Err(err) => {
- eprintln!("{err}");
- return ExitCode::FAILURE;
- }
- };
-
- set_log_level(cli.verbosity);
-
- if let Err(err) = cli.run().await {
- tracing::error!("{err}");
+ if let Err(err) = try_main().await {
+ eprintln!("{err}");
return err.exit_code();
}