Files
galaxy/app/src/context_chips/logging.rs
T

276 lines
7.1 KiB
Rust

use std::sync::mpsc;
#[cfg(test)]
use std::sync::Arc;
#[cfg(not(test))]
use std::sync::OnceLock;
#[cfg(not(target_family = "wasm"))]
use std::{
fs::{self, File, OpenOptions},
io::{self, Write as _},
path::PathBuf,
};
use chrono::{Local, SecondsFormat};
use galaxy_completer::completer::{CommandExitStatus, CommandOutput};
#[cfg(test)]
use parking_lot::Mutex;
use super::ContextChipKind;
use crate::terminal::shell::ShellType;
const EMPTY_VALUE: &str = "<empty>";
const MISSING_VALUE: &str = "<none>";
pub(crate) struct ChipCommandLogEntry<'a> {
pub chip_kind: &'a ContextChipKind,
pub chip_title: &'a str,
pub phase: PromptChipExecutionPhase,
pub shell_type: ShellType,
pub working_directory: Option<&'a str>,
pub command: &'a str,
pub output: Option<&'a CommandOutput>,
pub timed_out: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub(crate) enum PromptChipExecutionPhase {
Value,
OnClick,
}
impl PromptChipExecutionPhase {
fn as_str(self) -> &'static str {
match self {
Self::Value => "value",
Self::OnClick => "on_click",
}
}
}
#[derive(Clone)]
pub(crate) enum PromptChipLogger {
Disabled,
Runtime {
sender: mpsc::Sender<String>,
},
#[cfg(test)]
TestBuffer {
entries: Arc<Mutex<Vec<String>>>,
},
}
impl Default for PromptChipLogger {
fn default() -> Self {
Self::shared()
}
}
impl PromptChipLogger {
pub(crate) fn shared() -> Self {
cfg_if::cfg_if! {
if #[cfg(test)] {
Self::Disabled
} else {
static SHARED_LOGGER: OnceLock<PromptChipLogger> = OnceLock::new();
SHARED_LOGGER.get_or_init(Self::init_runtime).clone()
}
}
}
#[cfg(test)]
pub(crate) fn with_test_buffer(entries: Arc<Mutex<Vec<String>>>) -> Self {
Self::TestBuffer { entries }
}
pub(crate) fn log_shell_command(&self, entry: &ChipCommandLogEntry<'_>) {
let formatted = format_log_entry(entry);
match self {
Self::Disabled => {}
Self::Runtime { sender } => {
let _ = sender.send(formatted);
}
#[cfg(test)]
Self::TestBuffer { entries } => {
entries.lock().push(formatted);
}
}
}
#[cfg(not(target_family = "wasm"))]
fn init_runtime() -> Self {
if !galaxy_core::channel::ChannelState::enable_debug_features() {
return Self::Disabled;
}
let log_path = match log_file_path() {
Ok(log_path) => log_path,
Err(err) => {
log::warn!("Failed to determine prompt chip log file path: {err:#}");
return Self::Disabled;
}
};
match spawn_log_writer(log_path.clone()) {
Ok(sender) => Self::Runtime { sender },
Err(err) => {
log::warn!(
"Failed to initialize prompt chip log writer at {}: {err:#}",
log_path.display()
);
Self::Disabled
}
}
}
#[cfg(target_family = "wasm")]
fn init_runtime() -> Self {
Self::Disabled
}
}
#[cfg(not(target_family = "wasm"))]
pub(crate) fn log_file_path() -> anyhow::Result<PathBuf> {
let log_directory = galaxy_logging::log_directory()?;
let channel_logfile_name = galaxy_core::channel::ChannelState::logfile_name();
Ok(log_directory.join(prompt_chip_log_filename(&channel_logfile_name)))
}
#[cfg(not(target_family = "wasm"))]
fn spawn_log_writer(log_path: PathBuf) -> io::Result<mpsc::Sender<String>> {
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_path)?;
let (tx, rx) = mpsc::channel();
std::thread::Builder::new()
.name("prompt-chip-log-writer".to_string())
.spawn(move || write_log_entries(file, rx, log_path))
.map_err(io::Error::other)?;
Ok(tx)
}
#[cfg(not(target_family = "wasm"))]
fn write_log_entries(mut file: File, rx: mpsc::Receiver<String>, log_path: PathBuf) {
while let Ok(entry) = rx.recv() {
if let Err(err) = file.write_all(entry.as_bytes()).and_then(|_| file.flush()) {
log::error!(
"Failed to write prompt chip log entry to {}: {err:#}",
log_path.display()
);
return;
}
}
}
fn prompt_chip_log_filename(channel_logfile_name: &str) -> String {
let channel_logfile_stem = channel_logfile_name
.strip_suffix(".log")
.unwrap_or(channel_logfile_name);
format!("{channel_logfile_stem}.prompt_chips.log")
}
fn format_log_entry(entry: &ChipCommandLogEntry<'_>) -> String {
let timestamp = Local::now().to_rfc3339_opts(SecondsFormat::Millis, false);
let status = if entry.timed_out {
"timed_out"
} else if entry
.output
.is_some_and(|output| output.status == CommandExitStatus::Success)
{
"success"
} else {
"failure"
};
let exit_code = entry
.output
.and_then(CommandOutput::exit_code)
.map(|exit_code| exit_code.to_string())
.unwrap_or_else(|| MISSING_VALUE.to_string());
let stdout = entry
.output
.map_or(&[][..], |output| output.stdout.as_slice());
let stderr = entry
.output
.map_or(&[][..], |output| output.stderr.as_slice());
format!(
"\
===== PROMPT CHIP EXECUTION BEGIN =====
timestamp: {timestamp}
chip_kind: {:?}
chip_title: {}
phase: {}
shell_type: {:?}
working_directory: {}
status: {status}
timed_out: {}
exit_code: {exit_code}
{}
{}
{}
===== PROMPT CHIP EXECUTION END =====
",
entry.chip_kind,
entry.chip_title,
entry.phase.as_str(),
entry.shell_type,
format_scalar_field(entry.working_directory),
entry.timed_out,
format_text_block("command", "COMMAND", entry.command),
format_bytes_block("stdout", "STDOUT", stdout),
format_bytes_block("stderr", "STDERR", stderr),
)
}
fn format_scalar_field(value: Option<&str>) -> &str {
value.unwrap_or(MISSING_VALUE)
}
fn format_text_block(label: &str, marker: &str, content: &str) -> String {
format_block(label, marker, content)
}
fn format_bytes_block(label: &str, marker: &str, content: &[u8]) -> String {
let content = if content.is_empty() {
EMPTY_VALUE.to_string()
} else {
String::from_utf8_lossy(content).into_owned()
};
format_block(label, marker, &content)
}
fn format_block(label: &str, marker: &str, content: &str) -> String {
let content = if content.is_empty() {
EMPTY_VALUE
} else {
content
};
let mut output = String::new();
output.push_str(label);
output.push_str(":\n<<<");
output.push_str(marker);
output.push('\n');
output.push_str(content);
if !content.ends_with('\n') {
output.push('\n');
}
output.push_str(">>>");
output.push_str(marker);
output.push('\n');
output
}
#[cfg(test)]
#[path = "logging_tests.rs"]
mod tests;