use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricEvent {
pub timestamp: String,
pub source: String,
pub event_type: String,
pub command: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub metadata: serde_json::Value,
}
const METRICS_FILE: &str = "metrics.jsonl";
fn metrics_path(repo_root: &Path) -> std::path::PathBuf {
repo_root.join(".bobbin").join(METRICS_FILE)
}
pub fn resolve_source(cli_flag: Option<&str>, hook_session_id: Option<&str>) -> String {
resolve_source_with_env(
cli_flag,
std::env::var("BOBBIN_METRICS_SOURCE").ok().as_deref(),
hook_session_id,
)
}
fn resolve_source_with_env(
cli_flag: Option<&str>,
env_var: Option<&str>,
hook_session_id: Option<&str>,
) -> String {
for val in [cli_flag, env_var, hook_session_id] {
if let Some(s) = val {
if !s.is_empty() {
return s.to_string();
}
}
}
"unknown".to_string()
}
pub fn emit(repo_root: &Path, event: &MetricEvent) {
let path = metrics_path(repo_root);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(line) = serde_json::to_string(event) else {
return;
};
let Ok(mut file) = OpenOptions::new().create(true).append(true).open(&path) else {
return;
};
let _ = writeln!(file, "{}", line);
}
pub fn read_all(repo_root: &Path) -> Vec<MetricEvent> {
let path = metrics_path(repo_root);
let Ok(content) = std::fs::read_to_string(&path) else {
return Vec::new();
};
content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
pub fn read_by_source(repo_root: &Path, source: &str) -> Vec<MetricEvent> {
read_all(repo_root)
.into_iter()
.filter(|e| e.source == source)
.collect()
}
pub fn clear(repo_root: &Path) {
let path = metrics_path(repo_root);
let _ = std::fs::remove_file(&path);
}
pub fn event(
source: &str,
event_type: &str,
command: &str,
duration_ms: u64,
metadata: serde_json::Value,
) -> MetricEvent {
MetricEvent {
timestamp: chrono::Utc::now().to_rfc3339(),
source: source.to_string(),
event_type: event_type.to_string(),
command: command.to_string(),
duration_ms,
metadata,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> TempDir {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".bobbin")).unwrap();
dir
}
#[test]
fn test_emit_and_read() {
let dir = setup();
let ev = event(
"test-source",
"command",
"search",
42,
serde_json::json!({"query": "hello"}),
);
emit(dir.path(), &ev);
let events = read_all(dir.path());
assert_eq!(events.len(), 1);
assert_eq!(events[0].source, "test-source");
assert_eq!(events[0].event_type, "command");
assert_eq!(events[0].command, "search");
assert_eq!(events[0].duration_ms, 42);
assert_eq!(events[0].metadata["query"], "hello");
}
#[test]
fn test_multiple_events() {
let dir = setup();
emit(
dir.path(),
&event("s1", "command", "search", 10, serde_json::Value::Null),
);
emit(
dir.path(),
&event("s2", "command", "context", 20, serde_json::Value::Null),
);
emit(
dir.path(),
&event(
"s1",
"hook_injection",
"hook inject-context",
5,
serde_json::Value::Null,
),
);
let all = read_all(dir.path());
assert_eq!(all.len(), 3);
let s1 = read_by_source(dir.path(), "s1");
assert_eq!(s1.len(), 2);
let s2 = read_by_source(dir.path(), "s2");
assert_eq!(s2.len(), 1);
}
#[test]
fn test_clear() {
let dir = setup();
emit(
dir.path(),
&event("s", "command", "search", 10, serde_json::Value::Null),
);
assert_eq!(read_all(dir.path()).len(), 1);
clear(dir.path());
assert_eq!(read_all(dir.path()).len(), 0);
}
#[test]
fn test_read_empty() {
let dir = setup();
assert!(read_all(dir.path()).is_empty());
}
#[test]
fn test_read_nonexistent() {
let dir = TempDir::new().unwrap();
assert!(read_all(dir.path()).is_empty());
}
#[test]
fn test_resolve_source_cli_flag() {
assert_eq!(
resolve_source_with_env(Some("cli-val"), Some("env-val"), Some("session-val")),
"cli-val"
);
}
#[test]
fn test_resolve_source_env_var() {
assert_eq!(
resolve_source_with_env(None, Some("env-val"), Some("session-val")),
"env-val"
);
}
#[test]
fn test_resolve_source_session_id() {
assert_eq!(
resolve_source_with_env(None, None, Some("session-123")),
"session-123"
);
}
#[test]
fn test_resolve_source_fallback() {
assert_eq!(resolve_source_with_env(None, None, None), "unknown");
}
#[test]
fn test_resolve_source_empty_values_skipped() {
assert_eq!(
resolve_source_with_env(Some(""), Some(""), Some("session")),
"session"
);
assert_eq!(resolve_source_with_env(Some(""), None, None), "unknown");
}
#[test]
fn test_null_metadata_not_serialized() {
let ev = event("s", "command", "test", 0, serde_json::Value::Null);
let json = serde_json::to_string(&ev).unwrap();
assert!(!json.contains("metadata"));
}
}