vtcode_core/utils/
session_debug.rs1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::{LazyLock, Mutex};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use anyhow::{Context, Result};
7
8use crate::core::SECONDS_PER_DAY;
9use crate::utils::session_archive::SESSION_DIR_ENV;
10use vtcode_commons::VtCodePaths;
11
12#[derive(Debug, Clone, Default)]
13struct RuntimeDebugContext {
14 debug_session_id: Option<String>,
15 archive_session_id: Option<String>,
16 debug_log_path: Option<PathBuf>,
17}
18
19static RUNTIME_DEBUG_CONTEXT: LazyLock<Mutex<RuntimeDebugContext>> =
20 LazyLock::new(|| Mutex::new(RuntimeDebugContext::default()));
21
22pub const DEFAULT_MAX_DEBUG_LOG_SIZE_MB: u64 = 50;
23pub const DEFAULT_MAX_DEBUG_LOG_AGE_DAYS: u32 = 7;
24
25const DEBUG_LOG_FILE_PREFIX: &str = "debug-";
26const DEBUG_BYTES_PER_MB: u64 = 1024 * 1024;
27
28fn with_runtime_debug_context<R>(f: impl FnOnce(&mut RuntimeDebugContext) -> R) -> R {
29 match RUNTIME_DEBUG_CONTEXT.lock() {
30 Ok(mut context) => f(&mut context),
31 Err(poisoned) => {
32 let mut context = poisoned.into_inner();
33 f(&mut context)
34 }
35 }
36}
37
38pub fn configure_runtime_debug_context(debug_session_id: String, archive_session_id: Option<String>) {
39 with_runtime_debug_context(|context| {
40 context.debug_session_id = Some(debug_session_id);
41 context.archive_session_id = archive_session_id;
42 context.debug_log_path = None;
43 });
44}
45
46pub fn set_runtime_archive_session_id(archive_session_id: Option<String>) {
47 with_runtime_debug_context(|context| {
48 context.archive_session_id = archive_session_id;
49 });
50}
51
52pub fn runtime_archive_session_id() -> Option<String> {
53 with_runtime_debug_context(|context| context.archive_session_id.clone())
54}
55
56pub fn runtime_debug_log_path() -> Option<PathBuf> {
57 with_runtime_debug_context(|context| context.debug_log_path.clone())
58}
59
60pub fn set_runtime_debug_log_path(path: impl Into<PathBuf>) {
61 with_runtime_debug_context(|context| {
62 context.debug_log_path = Some(path.into());
63 });
64}
65
66pub fn sanitize_debug_component(value: &str, fallback: &str) -> String {
67 let mut normalized = String::new();
68 let mut last_was_separator = false;
69 for ch in value.chars() {
70 if ch.is_ascii_alphanumeric() {
71 normalized.push(ch.to_ascii_lowercase());
72 last_was_separator = false;
73 } else if matches!(ch, '-' | '_') {
74 if !last_was_separator {
75 normalized.push(ch);
76 last_was_separator = true;
77 }
78 } else if !last_was_separator {
79 normalized.push('-');
80 last_was_separator = true;
81 }
82 }
83
84 let trimmed = normalized.trim_matches(|c| c == '-' || c == '_');
85 if trimmed.is_empty() {
86 fallback.to_string()
87 } else {
88 trimmed.to_string()
89 }
90}
91
92pub fn build_command_debug_session_id(mode_hint: &str) -> String {
93 let mode = sanitize_debug_component(mode_hint, "cmd");
94 let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis();
95 format!("cmd-{mode}-{timestamp}-{}", std::process::id())
96}
97
98pub fn current_debug_session_id() -> String {
99 with_runtime_debug_context(|context| context.debug_session_id.clone())
100 .unwrap_or_else(|| build_command_debug_session_id("default"))
101}
102
103fn debug_log_file_name(session_id: &str) -> String {
104 let normalized = sanitize_debug_component(session_id, "session");
105 format!("{DEBUG_LOG_FILE_PREFIX}{normalized}.log")
106}
107
108pub fn default_sessions_dir() -> Result<PathBuf> {
109 if let Some(custom) = std::env::var_os(SESSION_DIR_ENV) {
110 return Ok(PathBuf::from(custom));
111 }
112
113 VtCodePaths::resolve()
114 .map(|paths| paths.sessions_dir())
115 .context("could not resolve VT Code state directory for sessions")
116}
117
118fn is_debug_log_file(path: &Path) -> bool {
119 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
120 return false;
121 };
122 name.starts_with(DEBUG_LOG_FILE_PREFIX) && name.ends_with(".log")
123}
124
125fn prune_expired_debug_logs(log_dir: &Path, max_age_days: u32) -> Result<()> {
126 let cutoff = if max_age_days == 0 {
127 SystemTime::now()
128 } else {
129 SystemTime::now()
130 .checked_sub(Duration::from_secs(u64::from(max_age_days).saturating_mul(SECONDS_PER_DAY)))
131 .unwrap_or(UNIX_EPOCH)
132 };
133
134 for entry in
135 fs::read_dir(log_dir).with_context(|| format!("Failed to read debug log directory {}", log_dir.display()))?
136 {
137 let entry = match entry {
138 Ok(value) => value,
139 Err(err) => {
140 tracing::warn!(
141 log_dir = %log_dir.display(),
142 error = %err,
143 "Failed to read a debug log entry"
144 );
145 continue;
146 }
147 };
148 let path = entry.path();
149 if !is_debug_log_file(&path) {
150 continue;
151 }
152 let metadata = match entry.metadata() {
153 Ok(value) => value,
154 Err(err) => {
155 tracing::warn!(
156 path = %path.display(),
157 error = %err,
158 "Failed to read debug log metadata"
159 );
160 continue;
161 }
162 };
163 if !metadata.is_file() {
164 continue;
165 }
166 if metadata.modified().unwrap_or(UNIX_EPOCH) <= cutoff
167 && let Err(err) = fs::remove_file(&path)
168 {
169 tracing::warn!(
170 path = %path.display(),
171 error = %err,
172 "Failed to remove expired debug log"
173 );
174 }
175 }
176
177 Ok(())
178}
179
180fn rotate_debug_log_if_needed(log_file: &Path, session_id: &str, max_size_mb: u64) -> Result<()> {
181 if max_size_mb == 0 {
182 return Ok(());
183 }
184
185 let max_bytes = max_size_mb.saturating_mul(DEBUG_BYTES_PER_MB);
186 let metadata = match fs::metadata(log_file) {
187 Ok(value) => value,
188 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
189 Err(err) => {
190 return Err(err).with_context(|| format!("Failed to inspect debug log {}", log_file.display()));
191 }
192 };
193
194 if metadata.len() < max_bytes {
195 return Ok(());
196 }
197
198 let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis();
199 let normalized_session_id = sanitize_debug_component(session_id, "session");
200 let rotated_name =
201 format!("{DEBUG_LOG_FILE_PREFIX}{normalized_session_id}-rotated-{}-{}.log", timestamp, std::process::id());
202 let rotated_path = log_file.parent().unwrap_or_else(|| Path::new(".")).join(rotated_name);
203
204 fs::rename(log_file, &rotated_path)
205 .with_context(|| format!("Failed to rotate debug log {} -> {}", log_file.display(), rotated_path.display()))?;
206 Ok(())
207}
208
209pub fn prepare_debug_log_file(
210 configured_dir: Option<PathBuf>,
211 session_id: &str,
212 max_size_mb: u64,
213 max_age_days: u32,
214) -> Result<PathBuf> {
215 let log_dir = configured_dir.map(Ok).unwrap_or_else(default_sessions_dir)?;
216 VtCodePaths::ensure_user_dir(&log_dir)
217 .with_context(|| format!("Failed to create debug log directory {}", log_dir.display()))?;
218 prune_expired_debug_logs(&log_dir, max_age_days)?;
219 let log_file = log_dir.join(debug_log_file_name(session_id));
220 rotate_debug_log_if_needed(&log_file, session_id, max_size_mb)?;
221 Ok(log_file)
222}