Skip to main content

vtcode_core/utils/
session_debug.rs

1use 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}