use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::Path;
use std::sync::Mutex;
use std::time::SystemTime;
use crate::evaluator::{EvaluationDecision, EvaluationResult};
use crate::packs::DecisionMode;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LoggingConfig {
pub enabled: bool,
pub file: Option<String>,
pub format: LogFormat,
pub redaction: RedactionConfig,
pub events: LogEventFilter,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
enabled: false,
file: None,
format: LogFormat::Text,
redaction: RedactionConfig::default(),
events: LogEventFilter::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
#[default]
Text,
Json,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RedactionConfig {
pub enabled: bool,
pub mode: RedactionMode,
pub max_argument_len: usize,
}
impl Default for RedactionConfig {
fn default() -> Self {
Self {
enabled: false,
mode: RedactionMode::Arguments,
max_argument_len: 50,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RedactionMode {
None,
#[default]
Arguments,
Full,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LogEventFilter {
pub deny: bool,
pub warn: bool,
pub allow: bool,
}
impl Default for LogEventFilter {
fn default() -> Self {
Self {
deny: true,
warn: true,
allow: false,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LogEntry {
pub timestamp: String,
pub decision: String,
pub mode: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub normalized_command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pack_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub elapsed_us: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub budget_skip: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowlist_layer: Option<String>,
}
impl LogEntry {
#[must_use]
pub fn from_result(
result: &EvaluationResult,
command: &str,
normalized: Option<&str>,
mode: DecisionMode,
redaction: &RedactionConfig,
elapsed_us: Option<u64>,
) -> Self {
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or_else(
|_| "1970-01-01T00:00:00Z".to_string(),
|d| time_to_iso8601(d.as_secs()),
);
let decision_str = match result.decision {
EvaluationDecision::Allow => "allow",
EvaluationDecision::Deny => match mode {
DecisionMode::Deny => "deny",
DecisionMode::Warn => "warn",
DecisionMode::Log => "log",
},
};
let mode_str = match mode {
DecisionMode::Deny => "deny",
DecisionMode::Warn => "warn",
DecisionMode::Log => "log",
};
let (pack_id, pattern_name, rule_id, reason) =
result
.pattern_info
.as_ref()
.map_or((None, None, None, None), |pm| {
let pack = pm.pack_id.as_deref().map(String::from);
let pattern = pm.pattern_name.as_deref().map(String::from);
let rule = match (&pm.pack_id, &pm.pattern_name) {
(Some(p), Some(n)) => Some(format!("{p}:{n}")),
_ => None,
};
let r = Some(pm.reason.clone());
(pack, pattern, rule, r)
});
let allowlist_layer = result
.allowlist_override
.as_ref()
.map(|o| o.layer.label().to_string());
let redacted_command = redact_command(command, redaction);
let redacted_normalized = normalized.map(|n| redact_command(n, redaction));
Self {
timestamp,
decision: decision_str.to_string(),
mode: mode_str.to_string(),
command: redacted_command,
normalized_command: redacted_normalized,
pack_id,
pattern_name,
rule_id,
reason,
elapsed_us,
budget_skip: if result.skipped_due_to_budget {
Some(true)
} else {
None
},
allowlist_layer,
}
}
#[must_use]
pub fn format_text(&self) -> String {
let mut parts = Vec::with_capacity(8);
parts.push(format!("[{}]", self.timestamp));
parts.push(self.decision.to_uppercase());
if let Some(ref rule_id) = self.rule_id {
parts.push(rule_id.clone());
}
parts.push(format!("\"{}\"", self.command));
if let Some(ref reason) = self.reason {
parts.push(format!("-- {reason}"));
}
if let Some(us) = self.elapsed_us {
parts.push(format!("({us}us)"));
}
if self.budget_skip.unwrap_or(false) {
parts.push("[budget-skip]".to_string());
}
if let Some(ref layer) = self.allowlist_layer {
parts.push(format!("[allowlist:{layer}]"));
}
parts.join(" ")
}
#[must_use]
pub fn format_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
}
pub struct DecisionLogger {
config: LoggingConfig,
writer: Option<Mutex<BufWriter<File>>>,
}
impl DecisionLogger {
#[must_use]
pub fn new(config: &LoggingConfig) -> Option<Self> {
if !config.enabled {
return None;
}
let writer = config.file.as_ref().and_then(|path| {
let expanded = expand_tilde(path);
open_log_file(&expanded)
.ok()
.map(|f| Mutex::new(BufWriter::new(f)))
});
Some(Self {
config: config.clone(),
writer,
})
}
pub fn log(
&self,
result: &EvaluationResult,
command: &str,
normalized: Option<&str>,
mode: DecisionMode,
elapsed_us: Option<u64>,
) {
if !self.should_log(result, mode) {
return;
}
let entry = LogEntry::from_result(
result,
command,
normalized,
mode,
&self.config.redaction,
elapsed_us,
);
let line = match self.config.format {
LogFormat::Text => entry.format_text(),
LogFormat::Json => entry.format_json(),
};
if let Some(ref writer) = self.writer {
if let Ok(mut w) = writer.lock() {
let _ = writeln!(w, "{line}");
let _ = w.flush();
}
}
}
const fn should_log(&self, result: &EvaluationResult, mode: DecisionMode) -> bool {
match result.decision {
EvaluationDecision::Allow => self.config.events.allow,
EvaluationDecision::Deny => match mode {
DecisionMode::Warn => self.config.events.warn,
DecisionMode::Deny | DecisionMode::Log => self.config.events.deny,
},
}
}
}
fn expand_tilde(path: &str) -> String {
if path.starts_with("~/") {
if let Some(home) = std::env::var_os("HOME") {
return format!("{}{}", home.to_string_lossy(), &path[1..]);
}
}
path.to_string()
}
fn open_log_file(path: &str) -> std::io::Result<File> {
if let Some(parent) = Path::new(path).parent() {
std::fs::create_dir_all(parent)?;
}
OpenOptions::new().create(true).append(true).open(path)
}
pub(crate) fn redact_command(command: &str, config: &RedactionConfig) -> String {
if !config.enabled {
return command.to_string();
}
match config.mode {
RedactionMode::None => command.to_string(),
RedactionMode::Full => "[REDACTED]".to_string(),
RedactionMode::Arguments => redact_arguments(command, config.max_argument_len),
}
}
fn redact_arguments(command: &str, max_len: usize) -> String {
let mut result = String::with_capacity(command.len());
let mut in_quote = false;
let mut quote_char = '"';
let mut arg_len = 0;
let mut escaped = false;
for c in command.chars() {
if escaped {
if in_quote && arg_len < max_len {
result.push(c);
arg_len += 1;
}
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
if !in_quote || arg_len < max_len {
result.push(c);
if in_quote {
arg_len += 1;
}
}
continue;
}
if !in_quote && (c == '"' || c == '\'') {
in_quote = true;
quote_char = c;
arg_len = 0;
result.push(c);
continue;
}
if in_quote && c == quote_char {
in_quote = false;
if arg_len > max_len {
result.push_str("...");
}
result.push(c);
continue;
}
if in_quote {
if arg_len < max_len {
result.push(c);
}
arg_len += 1;
} else {
result.push(c);
}
}
result
}
fn time_to_iso8601(secs: u64) -> String {
const SECS_PER_DAY: u64 = 86400;
const DAYS_PER_YEAR: u64 = 365;
const DAYS_PER_4YEARS: u64 = 1461;
const DAYS_PER_100YEARS: u64 = 36524;
const DAYS_PER_400YEARS: u64 = 146_097;
let mut days = secs / SECS_PER_DAY;
let time_of_day = secs % SECS_PER_DAY;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
days += 719_468;
let era = days / DAYS_PER_400YEARS;
let doe = days % DAYS_PER_400YEARS;
let yoe = (doe - doe / DAYS_PER_4YEARS + doe / DAYS_PER_100YEARS - doe / DAYS_PER_400YEARS)
/ DAYS_PER_YEAR;
let year = yoe + era * 400;
let doy = doe - (DAYS_PER_YEAR * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { year + 1 } else { year };
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PackTestLogLevel {
Error,
Warn,
#[default]
Info,
Debug,
Trace,
}
impl PackTestLogLevel {
#[must_use]
pub const fn should_log(&self, threshold: Self) -> bool {
(*self as u8) >= (threshold as u8)
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct PackTestLogConfig {
pub level: PackTestLogLevel,
pub json_mode: bool,
pub show_timing: bool,
pub show_patterns: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct PatternMatchEvent {
pub timestamp: String,
pub pack: String,
pub pattern: String,
pub input: String,
pub matched: bool,
pub duration_us: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TestResultEvent {
pub timestamp: String,
pub pack: String,
pub test_name: String,
pub passed: bool,
pub duration_ms: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern_matched: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TestSummary {
pub pack: String,
pub timestamp: String,
pub total: usize,
pub passed: usize,
pub failed: usize,
pub skipped: usize,
pub duration_ms: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct PackTestReport {
pub pack: String,
pub timestamp: String,
pub tests: Vec<TestResultEvent>,
pub summary: TestSummaryCompact,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern_matches: Option<Vec<PatternMatchEvent>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TestSummaryCompact {
pub total: usize,
pub passed: usize,
pub failed: usize,
}
pub struct PackTestLogger {
level: PackTestLogLevel,
json_mode: bool,
pack_name: String,
show_timing: bool,
show_patterns: bool,
pattern_matches: Vec<PatternMatchEvent>,
test_results: Vec<TestResultEvent>,
start_time: std::time::Instant,
}
impl PackTestLogger {
#[must_use]
pub fn new(pack_name: &str, config: &PackTestLogConfig) -> Self {
Self {
level: config.level,
json_mode: config.json_mode,
pack_name: pack_name.to_string(),
show_timing: config.show_timing,
show_patterns: config.show_patterns,
pattern_matches: Vec::new(),
test_results: Vec::new(),
start_time: std::time::Instant::now(),
}
}
#[must_use]
pub fn default_for_pack(pack_name: &str) -> Self {
Self::new(pack_name, &PackTestLogConfig::default())
}
#[must_use]
pub fn debug_mode(pack_name: &str) -> Self {
Self::new(
pack_name,
&PackTestLogConfig {
level: PackTestLogLevel::Debug,
json_mode: false,
show_timing: true,
show_patterns: true,
},
)
}
pub fn log_pattern_match(
&mut self,
pattern: &str,
input: &str,
matched: bool,
duration_us: u64,
) {
self.log_pattern_match_detailed(pattern, input, matched, duration_us, None, None);
}
pub fn log_pattern_match_detailed(
&mut self,
pattern: &str,
input: &str,
matched: bool,
duration_us: u64,
severity: Option<&str>,
reason: Option<&str>,
) {
if !self.level.should_log(PackTestLogLevel::Debug) && !self.show_patterns {
return;
}
let event = PatternMatchEvent {
timestamp: current_iso8601(),
pack: self.pack_name.clone(),
pattern: pattern.to_string(),
input: input.to_string(),
matched,
duration_us,
severity: severity.map(String::from),
reason: reason.map(String::from),
};
if self.json_mode {
if let Ok(json) = serde_json::to_string(&event) {
eprintln!("{json}");
}
} else if self.level.should_log(PackTestLogLevel::Debug) {
let status = if matched { "MATCH" } else { "no-match" };
let timing = if self.show_timing {
format!(" ({duration_us}us)")
} else {
String::new()
};
eprintln!(
"[DEBUG] {} | {} | {} | {}{}",
self.pack_name, pattern, status, input, timing
);
}
self.pattern_matches.push(event);
}
pub fn log_test_result(&mut self, test_name: &str, passed: bool, details: &str) {
self.log_test_result_detailed(test_name, passed, details, None, None);
}
pub fn log_test_result_detailed(
&mut self,
test_name: &str,
passed: bool,
details: &str,
pattern_matched: Option<&str>,
input: Option<&str>,
) {
let duration_ms = self.start_time.elapsed().as_secs_f64() * 1000.0;
let event = TestResultEvent {
timestamp: current_iso8601(),
pack: self.pack_name.clone(),
test_name: test_name.to_string(),
passed,
duration_ms,
pattern_matched: pattern_matched.map(String::from),
input: input.map(String::from),
error: if passed {
None
} else {
Some(details.to_string())
},
};
if self.json_mode {
if let Ok(json) = serde_json::to_string(&event) {
eprintln!("{json}");
}
} else {
let status = if passed { "PASS" } else { "FAIL" };
let timing = if self.show_timing {
format!(" ({duration_ms:.2}ms)")
} else {
String::new()
};
if passed {
if self.level.should_log(PackTestLogLevel::Info) {
eprintln!("[{status}] {test_name}{timing}");
}
} else {
eprintln!("[{status}] {test_name}{timing}: {details}");
}
}
self.test_results.push(event);
}
pub fn log_summary(&self, total: usize, passed: usize, failed: usize) {
self.log_summary_detailed(total, passed, failed, 0);
}
pub fn log_summary_detailed(&self, total: usize, passed: usize, failed: usize, skipped: usize) {
let duration_ms = self.start_time.elapsed().as_secs_f64() * 1000.0;
let summary = TestSummary {
pack: self.pack_name.clone(),
timestamp: current_iso8601(),
total,
passed,
failed,
skipped,
duration_ms,
};
if self.json_mode {
if let Ok(json) = serde_json::to_string(&summary) {
eprintln!("{json}");
}
} else {
eprintln!();
eprintln!("=== {} Test Summary ===", self.pack_name);
eprintln!(" Total: {total}");
eprintln!(" Passed: {passed}");
eprintln!(" Failed: {failed}");
if skipped > 0 {
eprintln!(" Skipped: {skipped}");
}
if self.show_timing {
eprintln!(" Duration: {duration_ms:.2}ms");
}
eprintln!();
}
}
#[must_use]
pub fn generate_report(&self) -> PackTestReport {
let passed = self.test_results.iter().filter(|r| r.passed).count();
let failed = self.test_results.len() - passed;
PackTestReport {
pack: self.pack_name.clone(),
timestamp: current_iso8601(),
tests: self.test_results.clone(),
summary: TestSummaryCompact {
total: self.test_results.len(),
passed,
failed,
},
pattern_matches: if self.show_patterns {
Some(self.pattern_matches.clone())
} else {
None
},
}
}
#[must_use]
pub fn report_json(&self) -> String {
let report = self.generate_report();
serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
}
#[must_use]
pub fn pattern_match_count(&self) -> usize {
self.pattern_matches.len()
}
#[must_use]
pub fn test_result_count(&self) -> usize {
self.test_results.len()
}
pub fn reset(&mut self) {
self.pattern_matches.clear();
self.test_results.clear();
self.start_time = std::time::Instant::now();
}
}
fn current_iso8601() -> String {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
time_to_iso8601(secs)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AllowOnceEvent {
Issued,
Granted,
Consumed,
Expired,
}
impl AllowOnceEvent {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Issued => "issued",
Self::Granted => "granted",
Self::Consumed => "consumed",
Self::Expired => "expired",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AllowOnceLogEntry {
pub timestamp: String,
pub event: AllowOnceEvent,
pub short_code: String,
pub full_hash: String,
pub command: String,
pub cwd: String,
pub expires_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub single_use: Option<bool>,
}
impl AllowOnceLogEntry {
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn issued(
short_code: &str,
full_hash: &str,
command: &str,
cwd: &str,
expires_at: &str,
reason: &str,
single_use: bool,
redaction: &RedactionConfig,
) -> Self {
Self {
timestamp: current_iso8601(),
event: AllowOnceEvent::Issued,
short_code: short_code.to_string(),
full_hash: full_hash.to_string(),
command: redact_command(command, redaction),
cwd: cwd.to_string(),
expires_at: expires_at.to_string(),
scope_kind: None,
scope_path: None,
reason: Some(reason.to_string()),
single_use: Some(single_use),
}
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn granted(
short_code: &str,
full_hash: &str,
command: &str,
cwd: &str,
expires_at: &str,
scope_kind: &str,
scope_path: &str,
single_use: bool,
redaction: &RedactionConfig,
) -> Self {
Self {
timestamp: current_iso8601(),
event: AllowOnceEvent::Granted,
short_code: short_code.to_string(),
full_hash: full_hash.to_string(),
command: redact_command(command, redaction),
cwd: cwd.to_string(),
expires_at: expires_at.to_string(),
scope_kind: Some(scope_kind.to_string()),
scope_path: Some(scope_path.to_string()),
reason: None,
single_use: Some(single_use),
}
}
#[must_use]
pub fn consumed(
short_code: &str,
full_hash: &str,
command: &str,
cwd: &str,
redaction: &RedactionConfig,
) -> Self {
Self {
timestamp: current_iso8601(),
event: AllowOnceEvent::Consumed,
short_code: short_code.to_string(),
full_hash: full_hash.to_string(),
command: redact_command(command, redaction),
cwd: cwd.to_string(),
expires_at: String::new(),
scope_kind: None,
scope_path: None,
reason: None,
single_use: None,
}
}
#[must_use]
pub fn expired(short_code: &str, full_hash: &str, expires_at: &str) -> Self {
Self {
timestamp: current_iso8601(),
event: AllowOnceEvent::Expired,
short_code: short_code.to_string(),
full_hash: full_hash.to_string(),
command: String::new(),
cwd: String::new(),
expires_at: expires_at.to_string(),
scope_kind: None,
scope_path: None,
reason: None,
single_use: None,
}
}
#[must_use]
pub fn format_text(&self) -> String {
let mut parts = Vec::with_capacity(8);
parts.push(format!("[{}]", self.timestamp));
parts.push(format!("[allow-once:{}]", self.event.label()));
parts.push(format!("code={}", self.short_code));
if !self.command.is_empty() {
parts.push(format!("cmd=\"{}\"", self.command));
}
if !self.cwd.is_empty() {
parts.push(format!("cwd=\"{}\"", self.cwd));
}
if let Some(ref scope) = self.scope_kind {
parts.push(format!("scope={scope}"));
}
if !self.expires_at.is_empty() {
parts.push(format!("expires={}", self.expires_at));
}
if let Some(ref reason) = self.reason {
parts.push(format!("reason=\"{reason}\""));
}
parts.join(" ")
}
#[must_use]
pub fn format_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
}
pub fn log_allow_once_event(
log_file: &str,
entry: &AllowOnceLogEntry,
format: LogFormat,
) -> std::io::Result<()> {
let expanded = expand_tilde(log_file);
let mut file = open_log_file(&expanded)?;
let line = match format {
LogFormat::Text => entry.format_text(),
LogFormat::Json => entry.format_json(),
};
writeln!(file, "{line}")?;
file.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logging_config_defaults() {
let config = LoggingConfig::default();
assert!(!config.enabled);
assert!(config.file.is_none());
assert_eq!(config.format, LogFormat::Text);
}
#[test]
fn redact_full_mode() {
let config = RedactionConfig {
enabled: true,
mode: RedactionMode::Full,
max_argument_len: 50,
};
let result = redact_command("git reset --hard HEAD", &config);
assert_eq!(result, "[REDACTED]");
}
#[test]
fn redact_disabled() {
let config = RedactionConfig {
enabled: false,
mode: RedactionMode::Full,
max_argument_len: 50,
};
let result = redact_command("git reset --hard HEAD", &config);
assert_eq!(result, "git reset --hard HEAD");
}
#[test]
fn time_to_iso8601_epoch() {
assert_eq!(time_to_iso8601(0), "1970-01-01T00:00:00Z");
}
#[test]
fn time_to_iso8601_known_date() {
let result = time_to_iso8601(1_705_321_845);
assert_eq!(result, "2024-01-15T12:30:45Z");
}
#[test]
fn redact_none_mode() {
let config = RedactionConfig {
enabled: true,
mode: RedactionMode::None,
max_argument_len: 50,
};
let result = redact_command("git reset --hard HEAD", &config);
assert_eq!(result, "git reset --hard HEAD");
}
#[test]
fn redact_arguments_truncates_long_strings() {
let config = RedactionConfig {
enabled: true,
mode: RedactionMode::Arguments,
max_argument_len: 10,
};
let result = redact_command(
r#"echo "this is a very long string that should be truncated""#,
&config,
);
assert_eq!(result, r#"echo "this is a ...""#);
}
#[test]
fn redact_arguments_preserves_short_strings() {
let config = RedactionConfig {
enabled: true,
mode: RedactionMode::Arguments,
max_argument_len: 50,
};
let result = redact_command(r#"echo "short""#, &config);
assert_eq!(result, r#"echo "short""#);
}
#[test]
fn expand_tilde_with_home() {
if std::env::var_os("HOME").is_some() {
let result = expand_tilde("~/test/path");
assert!(!result.starts_with("~/"));
assert!(result.ends_with("/test/path"));
}
}
#[test]
fn expand_tilde_without_tilde() {
let result = expand_tilde("/absolute/path");
assert_eq!(result, "/absolute/path");
}
#[test]
fn log_event_filter_defaults() {
let filter = LogEventFilter::default();
assert!(filter.deny);
assert!(filter.warn);
assert!(!filter.allow);
}
#[test]
fn pack_test_log_level_ordering() {
assert!(PackTestLogLevel::Trace.should_log(PackTestLogLevel::Error));
assert!(PackTestLogLevel::Debug.should_log(PackTestLogLevel::Info));
assert!(PackTestLogLevel::Info.should_log(PackTestLogLevel::Info));
assert!(!PackTestLogLevel::Error.should_log(PackTestLogLevel::Info));
}
#[test]
fn pack_test_logger_creation() {
let logger = PackTestLogger::default_for_pack("test.pack");
assert_eq!(logger.pattern_match_count(), 0);
assert_eq!(logger.test_result_count(), 0);
}
#[test]
fn pack_test_logger_debug_mode() {
let logger = PackTestLogger::debug_mode("test.pack");
assert!(logger.show_timing);
assert!(logger.show_patterns);
}
#[test]
fn pack_test_logger_records_pattern_matches() {
let config = PackTestLogConfig {
level: PackTestLogLevel::Debug,
show_patterns: true,
..Default::default()
};
let mut logger = PackTestLogger::new("test.pack", &config);
logger.log_pattern_match("pattern1", "input1", true, 100);
logger.log_pattern_match("pattern2", "input2", false, 50);
assert_eq!(logger.pattern_match_count(), 2);
}
#[test]
fn pack_test_logger_records_test_results() {
let mut logger = PackTestLogger::default_for_pack("test.pack");
logger.log_test_result("test_one", true, "");
logger.log_test_result("test_two", false, "assertion failed");
assert_eq!(logger.test_result_count(), 2);
}
#[test]
fn pack_test_logger_generates_report() {
let config = PackTestLogConfig {
show_patterns: true,
..Default::default()
};
let mut logger = PackTestLogger::new("test.pack", &config);
logger.log_test_result("test_pass", true, "");
logger.log_test_result("test_fail", false, "error");
let report = logger.generate_report();
assert_eq!(report.pack, "test.pack");
assert_eq!(report.summary.total, 2);
assert_eq!(report.summary.passed, 1);
assert_eq!(report.summary.failed, 1);
}
#[test]
fn pack_test_logger_reset_clears_data() {
let mut logger = PackTestLogger::default_for_pack("test.pack");
logger.log_test_result("test", true, "");
assert_eq!(logger.test_result_count(), 1);
logger.reset();
assert_eq!(logger.test_result_count(), 0);
}
#[test]
fn pack_test_log_config_defaults() {
let config = PackTestLogConfig::default();
assert_eq!(config.level, PackTestLogLevel::Info);
assert!(!config.json_mode);
assert!(!config.show_timing);
assert!(!config.show_patterns);
}
#[test]
fn allow_once_event_labels() {
assert_eq!(AllowOnceEvent::Issued.label(), "issued");
assert_eq!(AllowOnceEvent::Granted.label(), "granted");
assert_eq!(AllowOnceEvent::Consumed.label(), "consumed");
assert_eq!(AllowOnceEvent::Expired.label(), "expired");
}
#[test]
fn allow_once_log_entry_issued() {
let redaction = RedactionConfig::default();
let entry = AllowOnceLogEntry::issued(
"abc1",
"full_hash_abc1",
"git reset --hard",
"/repo",
"2026-01-11T12:00:00Z",
"destroys uncommitted changes",
false,
&redaction,
);
assert_eq!(entry.event, AllowOnceEvent::Issued);
assert_eq!(entry.short_code, "abc1");
assert_eq!(entry.full_hash, "full_hash_abc1");
assert_eq!(entry.command, "git reset --hard");
assert_eq!(entry.cwd, "/repo");
assert!(entry.reason.is_some());
assert_eq!(entry.single_use, Some(false));
}
#[test]
fn allow_once_log_entry_granted() {
let redaction = RedactionConfig::default();
let entry = AllowOnceLogEntry::granted(
"abc1",
"full_hash_abc1",
"git reset --hard",
"/repo",
"2026-01-11T12:00:00Z",
"cwd",
"/repo",
true,
&redaction,
);
assert_eq!(entry.event, AllowOnceEvent::Granted);
assert_eq!(entry.scope_kind, Some("cwd".to_string()));
assert_eq!(entry.scope_path, Some("/repo".to_string()));
assert_eq!(entry.single_use, Some(true));
}
#[test]
fn allow_once_log_entry_consumed() {
let redaction = RedactionConfig::default();
let entry = AllowOnceLogEntry::consumed(
"abc1",
"full_hash_abc1",
"git reset --hard",
"/repo",
&redaction,
);
assert_eq!(entry.event, AllowOnceEvent::Consumed);
assert_eq!(entry.short_code, "abc1");
}
#[test]
fn allow_once_log_entry_expired() {
let entry = AllowOnceLogEntry::expired("abc1", "full_hash_abc1", "2026-01-10T12:00:00Z");
assert_eq!(entry.event, AllowOnceEvent::Expired);
assert_eq!(entry.expires_at, "2026-01-10T12:00:00Z");
assert!(entry.command.is_empty());
}
#[test]
fn allow_once_log_entry_format_text() {
let redaction = RedactionConfig::default();
let entry = AllowOnceLogEntry::issued(
"abc1",
"full_hash",
"git reset --hard",
"/repo",
"2026-01-11T12:00:00Z",
"reason",
false,
&redaction,
);
let text = entry.format_text();
assert!(text.contains("[allow-once:issued]"));
assert!(text.contains("code=abc1"));
assert!(text.contains(r#"cmd="git reset --hard""#));
assert!(text.contains("expires=2026-01-11T12:00:00Z"));
}
#[test]
fn allow_once_log_entry_format_json() {
let redaction = RedactionConfig::default();
let entry = AllowOnceLogEntry::issued(
"abc1",
"full_hash",
"git reset --hard",
"/repo",
"2026-01-11T12:00:00Z",
"reason",
false,
&redaction,
);
let json = entry.format_json();
assert!(json.contains(r#""event":"issued""#));
assert!(json.contains(r#""short_code":"abc1""#));
assert!(json.contains(r#""command":"git reset --hard""#));
}
#[test]
fn allow_once_log_entry_redacts_command() {
let redaction = RedactionConfig {
enabled: true,
mode: RedactionMode::Full,
max_argument_len: 50,
};
let entry = AllowOnceLogEntry::issued(
"abc1",
"full_hash",
"git reset --hard",
"/repo",
"2026-01-11T12:00:00Z",
"reason",
false,
&redaction,
);
assert_eq!(entry.command, "[REDACTED]");
}
#[test]
fn allow_once_log_entry_redacts_arguments() {
let redaction = RedactionConfig {
enabled: true,
mode: RedactionMode::Arguments,
max_argument_len: 5,
};
let entry = AllowOnceLogEntry::issued(
"abc1",
"full_hash",
r#"echo "this is a long string""#,
"/repo",
"2026-01-11T12:00:00Z",
"reason",
false,
&redaction,
);
assert_eq!(entry.command, r#"echo "this ...""#);
}
#[test]
fn allow_once_log_file_write() {
let dir = tempfile::TempDir::new().expect("tempdir");
let log_path = dir.path().join("allow_once.log");
let redaction = RedactionConfig::default();
let entry = AllowOnceLogEntry::issued(
"test",
"hash",
"cmd",
"/cwd",
"2026-01-11T12:00:00Z",
"reason",
false,
&redaction,
);
log_allow_once_event(log_path.to_str().unwrap(), &entry, LogFormat::Json).unwrap();
let content = std::fs::read_to_string(&log_path).unwrap();
assert!(content.contains(r#""event":"issued""#));
assert!(content.contains(r#""short_code":"test""#));
}
}