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::dot_config::DotManager;
10use crate::utils::session_archive::SESSION_DIR_ENV;
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(
39 debug_session_id: String,
40 archive_session_id: Option<String>,
41) {
42 with_runtime_debug_context(|context| {
43 context.debug_session_id = Some(debug_session_id);
44 context.archive_session_id = archive_session_id;
45 context.debug_log_path = None;
46 });
47}
48
49pub fn set_runtime_archive_session_id(archive_session_id: Option<String>) {
50 with_runtime_debug_context(|context| {
51 context.archive_session_id = archive_session_id;
52 });
53}
54
55pub fn runtime_archive_session_id() -> Option<String> {
56 with_runtime_debug_context(|context| context.archive_session_id.clone())
57}
58
59pub fn runtime_debug_log_path() -> Option<PathBuf> {
60 with_runtime_debug_context(|context| context.debug_log_path.clone())
61}
62
63pub fn set_runtime_debug_log_path(path: impl Into<PathBuf>) {
64 with_runtime_debug_context(|context| {
65 context.debug_log_path = Some(path.into());
66 });
67}
68
69pub fn sanitize_debug_component(value: &str, fallback: &str) -> String {
70 let mut normalized = String::new();
71 let mut last_was_separator = false;
72 for ch in value.chars() {
73 if ch.is_ascii_alphanumeric() {
74 normalized.push(ch.to_ascii_lowercase());
75 last_was_separator = false;
76 } else if matches!(ch, '-' | '_') {
77 if !last_was_separator {
78 normalized.push(ch);
79 last_was_separator = true;
80 }
81 } else if !last_was_separator {
82 normalized.push('-');
83 last_was_separator = true;
84 }
85 }
86
87 let trimmed = normalized.trim_matches(|c| c == '-' || c == '_');
88 if trimmed.is_empty() {
89 fallback.to_string()
90 } else {
91 trimmed.to_string()
92 }
93}
94
95pub fn build_command_debug_session_id(mode_hint: &str) -> String {
96 let mode = sanitize_debug_component(mode_hint, "cmd");
97 let timestamp = SystemTime::now()
98 .duration_since(UNIX_EPOCH)
99 .unwrap_or_default()
100 .as_millis();
101 format!("cmd-{mode}-{timestamp}-{}", std::process::id())
102}
103
104pub fn current_debug_session_id() -> String {
105 with_runtime_debug_context(|context| context.debug_session_id.clone())
106 .unwrap_or_else(|| build_command_debug_session_id("default"))
107}
108
109fn debug_log_file_name(session_id: &str) -> String {
110 let normalized = sanitize_debug_component(session_id, "session");
111 format!("{DEBUG_LOG_FILE_PREFIX}{normalized}.log")
112}
113
114pub fn default_sessions_dir() -> PathBuf {
115 if let Some(custom) = std::env::var_os(SESSION_DIR_ENV) {
116 return PathBuf::from(custom);
117 }
118 if let Ok(manager) = DotManager::new() {
119 return manager.sessions_dir();
120 }
121 PathBuf::from(".vtcode/sessions")
122}
123
124fn is_debug_log_file(path: &Path) -> bool {
125 let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
126 return false;
127 };
128 name.starts_with(DEBUG_LOG_FILE_PREFIX) && name.ends_with(".log")
129}
130
131fn prune_expired_debug_logs(log_dir: &Path, max_age_days: u32) -> Result<()> {
132 let cutoff = if max_age_days == 0 {
133 SystemTime::now()
134 } else {
135 SystemTime::now()
136 .checked_sub(Duration::from_secs(
137 u64::from(max_age_days).saturating_mul(SECONDS_PER_DAY),
138 ))
139 .unwrap_or(UNIX_EPOCH)
140 };
141
142 for entry in fs::read_dir(log_dir)
143 .with_context(|| format!("Failed to read debug log directory {}", log_dir.display()))?
144 {
145 let entry = match entry {
146 Ok(value) => value,
147 Err(err) => {
148 tracing::warn!(
149 log_dir = %log_dir.display(),
150 error = %err,
151 "Failed to read a debug log entry"
152 );
153 continue;
154 }
155 };
156 let path = entry.path();
157 if !is_debug_log_file(&path) {
158 continue;
159 }
160 let metadata = match entry.metadata() {
161 Ok(value) => value,
162 Err(err) => {
163 tracing::warn!(
164 path = %path.display(),
165 error = %err,
166 "Failed to read debug log metadata"
167 );
168 continue;
169 }
170 };
171 if !metadata.is_file() {
172 continue;
173 }
174 if metadata.modified().unwrap_or(UNIX_EPOCH) <= cutoff
175 && let Err(err) = fs::remove_file(&path)
176 {
177 tracing::warn!(
178 path = %path.display(),
179 error = %err,
180 "Failed to remove expired debug log"
181 );
182 }
183 }
184
185 Ok(())
186}
187
188fn rotate_debug_log_if_needed(log_file: &Path, session_id: &str, max_size_mb: u64) -> Result<()> {
189 if max_size_mb == 0 {
190 return Ok(());
191 }
192
193 let max_bytes = max_size_mb.saturating_mul(DEBUG_BYTES_PER_MB);
194 let metadata = match fs::metadata(log_file) {
195 Ok(value) => value,
196 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
197 Err(err) => {
198 return Err(err)
199 .with_context(|| format!("Failed to inspect debug log {}", log_file.display()));
200 }
201 };
202
203 if metadata.len() < max_bytes {
204 return Ok(());
205 }
206
207 let timestamp = SystemTime::now()
208 .duration_since(UNIX_EPOCH)
209 .unwrap_or_default()
210 .as_millis();
211 let normalized_session_id = sanitize_debug_component(session_id, "session");
212 let rotated_name = format!(
213 "{DEBUG_LOG_FILE_PREFIX}{normalized_session_id}-rotated-{}-{}.log",
214 timestamp,
215 std::process::id()
216 );
217 let rotated_path = log_file
218 .parent()
219 .unwrap_or_else(|| Path::new("."))
220 .join(rotated_name);
221
222 fs::rename(log_file, &rotated_path).with_context(|| {
223 format!(
224 "Failed to rotate debug log {} -> {}",
225 log_file.display(),
226 rotated_path.display()
227 )
228 })?;
229 Ok(())
230}
231
232pub fn prepare_debug_log_file(
233 configured_dir: Option<PathBuf>,
234 session_id: &str,
235 max_size_mb: u64,
236 max_age_days: u32,
237) -> Result<PathBuf> {
238 let log_dir = configured_dir.unwrap_or_else(default_sessions_dir);
239 fs::create_dir_all(&log_dir)
240 .with_context(|| format!("Failed to create debug log directory {}", log_dir.display()))?;
241 prune_expired_debug_logs(&log_dir, max_age_days)?;
242 let log_file = log_dir.join(debug_log_file_name(session_id));
243 rotate_debug_log_if_needed(&log_file, session_id, max_size_mb)?;
244 Ok(log_file)
245}