1use std::collections::HashSet;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use vtcode_config::IdeContextProviderFamily;
9
10use crate::utils::common::{display_language_from_editor_language_id, display_language_from_path};
11
12pub const IDE_CONTEXT_ENV_VAR: &str = "VT_IDE_CONTEXT_FILE";
13pub const LEGACY_VSCODE_CONTEXT_ENV_VAR: &str = "VT_VSCODE_CONTEXT_FILE";
14pub const IDE_CONTEXT_SNAPSHOT_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
17pub struct EditorContextSnapshot {
18 #[serde(default = "default_snapshot_version")]
19 pub version: u32,
20 #[serde(default)]
21 pub provider_family: IdeContextProviderFamily,
22 #[serde(default)]
23 pub editor_name: Option<String>,
24 #[serde(default)]
25 pub workspace_root: Option<PathBuf>,
26 #[serde(default)]
27 pub active_file: Option<EditorFileContext>,
28 #[serde(default)]
29 pub visible_editors: Vec<EditorFileContext>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
33pub struct EditorFileContext {
34 pub path: String,
35 #[serde(default)]
36 pub language_id: Option<String>,
37 #[serde(default)]
38 pub line_range: Option<EditorLineRange>,
39 #[serde(default)]
40 pub dirty: bool,
41 #[serde(default)]
42 pub truncated: bool,
43 #[serde(default)]
44 pub selection: Option<EditorSelectionContext>,
45}
46
47#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
48pub struct EditorLineRange {
49 pub start: usize,
50 pub end: usize,
51}
52
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
54pub struct EditorSelectionRange {
55 pub start_line: usize,
56 pub start_column: usize,
57 pub end_line: usize,
58 pub end_column: usize,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
62pub struct EditorSelectionContext {
63 pub range: EditorSelectionRange,
64 #[serde(default)]
65 pub text: Option<String>,
66}
67
68impl EditorContextSnapshot {
69 pub fn read_from_env() -> Result<Option<Self>> {
70 if let Some(path) = snapshot_path_from_env(IDE_CONTEXT_ENV_VAR) {
71 return Self::read_json_file(&path);
72 }
73
74 if let Some(path) = snapshot_path_from_env(LEGACY_VSCODE_CONTEXT_ENV_VAR) {
75 return Self::read_legacy_markdown_file(&path);
76 }
77
78 Ok(None)
79 }
80
81 pub fn read_json_file(path: &Path) -> Result<Option<Self>> {
82 let Some(content) = read_snapshot_file(path)? else {
83 return Ok(None);
84 };
85 let snapshot: Self = serde_json::from_str(&content).with_context(|| {
86 format!(
87 "failed to parse IDE context JSON snapshot at {}",
88 path.display()
89 )
90 })?;
91 Ok(Some(snapshot.normalized()))
92 }
93
94 pub fn read_legacy_markdown_file(path: &Path) -> Result<Option<Self>> {
95 let Some(content) = read_snapshot_file(path)? else {
96 return Ok(None);
97 };
98 Ok(parse_legacy_markdown_snapshot(&content))
99 }
100
101 pub fn normalized(mut self) -> Self {
102 self.version = if self.version == 0 {
103 IDE_CONTEXT_SNAPSHOT_VERSION
104 } else {
105 self.version
106 };
107
108 if let Some(editor_name) = self.editor_name.as_mut() {
109 let trimmed = editor_name.trim();
110 if trimmed.is_empty() {
111 self.editor_name = None;
112 } else if trimmed != editor_name {
113 *editor_name = trimmed.to_string();
114 }
115 }
116
117 if let Some(active_file) = self.active_file.as_mut() {
118 active_file.normalize();
119 }
120 for editor in &mut self.visible_editors {
121 editor.normalize();
122 }
123 self
124 }
125
126 pub fn active_display_language(&self) -> Option<String> {
127 self.active_file
128 .as_ref()
129 .and_then(EditorFileContext::display_language)
130 }
131
132 pub fn has_explicit_selection(&self) -> bool {
133 self.active_file
134 .as_ref()
135 .and_then(|file| file.selection.as_ref())
136 .is_some_and(EditorSelectionContext::has_explicit_selection)
137 }
138
139 pub fn header_summary(&self, workspace_root: &Path) -> Option<String> {
140 let file = self.active_file.as_ref()?;
141 let mut parts = Vec::new();
142
143 let path_label = file.display_path(workspace_root, self.workspace_root.as_deref());
144 if !path_label.is_empty() {
145 parts.push(format!("File: {}", path_label));
146 }
147
148 if let Some(language) = file.display_language() {
149 if parts.is_empty() {
150 parts.push(format!("Lang: {}", language));
151 } else {
152 parts.push(language);
153 }
154 }
155
156 if let Some(selection) = file
157 .selection
158 .as_ref()
159 .filter(|selection| selection.has_explicit_selection())
160 {
161 parts.push(format!(
162 "Sel {}-{}",
163 selection.range.start_line, selection.range.end_line
164 ));
165 }
166
167 if parts.is_empty() {
168 None
169 } else {
170 Some(parts.join(" · "))
171 }
172 }
173
174 pub fn prompt_block(
175 &self,
176 workspace_root: &Path,
177 include_selection_text: bool,
178 ) -> Option<String> {
179 let file = self.active_file.as_ref()?;
180 let active_path = file.display_path(workspace_root, self.workspace_root.as_deref());
181 let mut lines = Vec::new();
182 lines.push("## Active Editor Context".to_string());
183 lines.push(format!(
184 "- IDE family: {}",
185 provider_family_label(self.provider_family)
186 ));
187 lines.push(format!("- Active file: {}", active_path));
188
189 if let Some(language) = file.display_language() {
190 lines.push(format!("- Language: {}", language));
191 }
192
193 if let Some(line_range) = file.line_range {
194 lines.push(format!("- Editor lines: {}", format_line_range(line_range)));
195 }
196
197 if file.dirty || file.truncated {
198 let mut states = Vec::new();
199 if file.dirty {
200 states.push("unsaved changes");
201 }
202 if file.truncated {
203 states.push("truncated");
204 }
205 lines.push(format!("- Buffer state: {}", states.join(", ")));
206 }
207
208 if let Some(selection) = file
209 .selection
210 .as_ref()
211 .filter(|selection| selection.has_explicit_selection())
212 {
213 lines.push(format!(
214 "- Selection: {}:{}-{}:{}",
215 selection.range.start_line,
216 selection.range.start_column,
217 selection.range.end_line,
218 selection.range.end_column
219 ));
220
221 if include_selection_text
222 && let Some(text) = selection.text.as_deref().map(str::trim)
223 && !text.is_empty()
224 {
225 let fence_language = file.language_id.as_deref().unwrap_or("text");
226 lines.push("- Selected text:".to_string());
227 lines.push(format!("```{}", fence_language));
228 lines.push(text.to_string());
229 lines.push("```".to_string());
230 }
231 }
232
233 let mut seen_paths = HashSet::new();
234 let open_files = self
235 .visible_editors
236 .iter()
237 .map(|editor| editor.display_path(workspace_root, self.workspace_root.as_deref()))
238 .filter(|path| !path.trim().is_empty())
239 .filter(|path| path != &active_path)
240 .filter(|path| seen_paths.insert(path.clone()))
241 .collect::<Vec<_>>();
242 if !open_files.is_empty() {
243 lines.push("- Open files:".to_string());
244 lines.extend(open_files.into_iter().map(|path| format!(" - {}", path)));
245 }
246
247 Some(lines.join("\n"))
248 }
249}
250
251impl EditorFileContext {
252 fn normalize(&mut self) {
253 self.path = self.path.trim().to_string();
254 if let Some(language_id) = self.language_id.as_mut() {
255 let trimmed = language_id.trim();
256 if trimmed.is_empty() {
257 self.language_id = None;
258 } else if trimmed != language_id {
259 *language_id = trimmed.to_string();
260 }
261 }
262
263 if let Some(selection) = self.selection.as_mut()
264 && let Some(text) = selection.text.as_mut()
265 {
266 let normalized = normalize_snapshot_content(text);
267 if normalized.trim().is_empty() {
268 selection.text = None;
269 } else {
270 *text = normalized;
271 }
272 }
273 }
274
275 pub fn display_language(&self) -> Option<String> {
276 self.language_id
277 .as_deref()
278 .and_then(display_language_from_editor_language_id)
279 .or_else(|| display_language_from_path(Path::new(self.path.as_str())))
280 .map(ToOwned::to_owned)
281 }
282
283 pub fn display_path(
284 &self,
285 workspace_root: &Path,
286 snapshot_workspace_root: Option<&Path>,
287 ) -> String {
288 let raw = self.path.trim();
289 if raw.is_empty() {
290 return String::new();
291 }
292
293 if raw.contains("://") || raw.starts_with("untitled:") {
294 return raw.to_string();
295 }
296
297 let candidate = Path::new(raw);
298 if candidate.is_relative() {
299 return raw.to_string();
300 }
301
302 for root in [Some(workspace_root), snapshot_workspace_root] {
303 if let Some(root) = root
304 && let Ok(relative) = candidate.strip_prefix(root)
305 {
306 return relative.display().to_string();
307 }
308 }
309
310 raw.to_string()
311 }
312
313 pub fn has_explicit_selection(&self) -> bool {
314 self.selection
315 .as_ref()
316 .is_some_and(EditorSelectionContext::has_explicit_selection)
317 }
318}
319
320impl EditorSelectionContext {
321 pub fn has_explicit_selection(&self) -> bool {
322 let range = self.range;
323 range.start_line != range.end_line || range.start_column != range.end_column
324 }
325}
326
327const fn default_snapshot_version() -> u32 {
328 IDE_CONTEXT_SNAPSHOT_VERSION
329}
330
331fn snapshot_path_from_env(env_var: &str) -> Option<PathBuf> {
332 env::var(env_var)
333 .ok()
334 .map(|value| value.trim().to_string())
335 .filter(|value| !value.is_empty())
336 .map(PathBuf::from)
337}
338
339fn read_snapshot_file(path: &Path) -> Result<Option<String>> {
340 let content = match fs::read_to_string(path) {
341 Ok(content) => content,
342 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
343 Err(err) => {
344 return Err(err).with_context(|| {
345 format!("failed to read IDE context snapshot {}", path.display())
346 });
347 }
348 };
349
350 let normalized = normalize_snapshot_content(&content);
351 if normalized.trim().is_empty() {
352 return Ok(None);
353 }
354
355 Ok(Some(normalized))
356}
357
358fn normalize_snapshot_content(content: &str) -> String {
359 content.replace("\r\n", "\n")
360}
361
362fn parse_legacy_markdown_snapshot(markdown: &str) -> Option<EditorContextSnapshot> {
363 let mut active_file = None;
364 let mut visible_editors = Vec::new();
365
366 for section in markdown
367 .split("\n### ")
368 .filter(|section| !section.trim().is_empty())
369 {
370 let normalized = if section.starts_with("### ") {
371 section.to_string()
372 } else {
373 format!("### {}", section)
374 };
375
376 if let Some(file) = parse_legacy_editor_section(&normalized, "### Active Editor:") {
377 active_file = Some(file);
378 continue;
379 }
380
381 if let Some(file) = parse_legacy_editor_section(&normalized, "### Editor:") {
382 visible_editors.push(file);
383 }
384 }
385
386 if active_file.is_none() && visible_editors.is_empty() {
387 return None;
388 }
389
390 Some(EditorContextSnapshot {
391 version: IDE_CONTEXT_SNAPSHOT_VERSION,
392 provider_family: IdeContextProviderFamily::VscodeCompatible,
393 editor_name: Some("VS Code".to_string()),
394 workspace_root: None,
395 active_file,
396 visible_editors,
397 })
398}
399
400fn parse_legacy_editor_section(section: &str, prefix: &str) -> Option<EditorFileContext> {
401 let first_line = section.lines().next()?.trim();
402 let heading = first_line.strip_prefix(prefix)?.trim();
403 let (path, details) = split_heading_label_and_details(heading);
404 let language_id = parse_legacy_fence_language(section);
405
406 Some(EditorFileContext {
407 path: path.to_string(),
408 language_id,
409 line_range: details.and_then(parse_line_range_from_details),
410 dirty: details.is_some_and(|detail| detail.contains("unsaved changes")),
411 truncated: details.is_some_and(|detail| detail.contains("truncated")),
412 selection: None,
413 })
414}
415
416fn split_heading_label_and_details(heading: &str) -> (&str, Option<&str>) {
417 let trimmed = heading.trim();
418 if let Some((path, details)) = trimmed.rsplit_once(" (")
419 && let Some(details) = details.strip_suffix(')')
420 {
421 return (path.trim(), Some(details));
422 }
423
424 (trimmed, None)
425}
426
427fn parse_legacy_fence_language(section: &str) -> Option<String> {
428 section.lines().find_map(|line| {
429 line.trim()
430 .strip_prefix("```")
431 .map(str::trim)
432 .filter(|language| !language.is_empty())
433 .map(ToOwned::to_owned)
434 })
435}
436
437fn parse_line_range_from_details(details: &str) -> Option<EditorLineRange> {
438 let marker = "lines ";
439 let line_token = details
440 .split('•')
441 .map(str::trim)
442 .find_map(|entry| entry.strip_prefix(marker))?;
443
444 parse_line_range(line_token)
445}
446
447fn parse_line_range(text: &str) -> Option<EditorLineRange> {
448 let trimmed = text.trim();
449 let (start, end) = trimmed
450 .split_once('-')
451 .map(|(start, end)| (start.trim(), end.trim()))
452 .unwrap_or((trimmed, trimmed));
453
454 let start = start.parse::<usize>().ok()?;
455 let end = end.parse::<usize>().ok()?;
456 Some(EditorLineRange { start, end })
457}
458
459fn provider_family_label(family: IdeContextProviderFamily) -> &'static str {
460 match family {
461 IdeContextProviderFamily::VscodeCompatible => "vscode_compatible",
462 IdeContextProviderFamily::Zed => "zed",
463 IdeContextProviderFamily::Generic => "generic",
464 }
465}
466
467fn format_line_range(range: EditorLineRange) -> String {
468 if range.start == range.end {
469 range.start.to_string()
470 } else {
471 format!("{}-{}", range.start, range.end)
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::{
478 EditorContextSnapshot, EditorFileContext, EditorLineRange, EditorSelectionContext,
479 EditorSelectionRange, IDE_CONTEXT_SNAPSHOT_VERSION,
480 };
481 use std::fs;
482 use std::path::{Path, PathBuf};
483 use tempfile::TempDir;
484 use vtcode_config::IdeContextProviderFamily;
485
486 #[test]
487 fn parses_json_snapshot_file() {
488 let temp = TempDir::new().expect("temp dir");
489 let path = temp.path().join("snapshot.json");
490 fs::write(
491 &path,
492 r#"{
493 "version": 1,
494 "provider_family": "zed",
495 "editor_name": " VS Code ",
496 "workspace_root": "/workspace",
497 "active_file": {
498 "path": "/workspace/src/main.rs",
499 "language_id": "rust",
500 "line_range": { "start": 10, "end": 24 },
501 "dirty": true,
502 "truncated": false,
503 "selection": {
504 "range": {
505 "start_line": 12,
506 "start_column": 1,
507 "end_line": 18,
508 "end_column": 4
509 },
510 "text": "fn main() {}\n"
511 }
512 }
513 }"#,
514 )
515 .expect("write snapshot");
516
517 let snapshot = EditorContextSnapshot::read_json_file(&path)
518 .expect("read snapshot")
519 .expect("snapshot");
520
521 assert_eq!(snapshot.provider_family, IdeContextProviderFamily::Zed);
522 assert_eq!(snapshot.editor_name.as_deref(), Some("VS Code"));
523 assert_eq!(snapshot.active_display_language().as_deref(), Some("Rust"));
524 assert_eq!(
525 snapshot.header_summary(Path::new("/workspace")).as_deref(),
526 Some("File: src/main.rs · Rust · Sel 12-18")
527 );
528 }
529
530 #[test]
531 fn parses_legacy_markdown_snapshot() {
532 let temp = TempDir::new().expect("temp dir");
533 let path = temp.path().join("snapshot.md");
534 fs::write(
535 &path,
536 r#"
537## VS Code Context
538
539### Active Editor: src/app.tsx (lines 12-18 • unsaved changes • truncated)
540
541```typescriptreact
542export function App() {}
543```
544
545### Editor: src/lib.ts (lines 1-4)
546
547```typescript
548export const value = 1;
549```
550"#,
551 )
552 .expect("write snapshot");
553
554 let snapshot = EditorContextSnapshot::read_legacy_markdown_file(&path)
555 .expect("read snapshot")
556 .expect("snapshot");
557
558 let active = snapshot.active_file.expect("active file");
559 assert_eq!(snapshot.editor_name.as_deref(), Some("VS Code"));
560 assert_eq!(active.path, "src/app.tsx");
561 assert_eq!(active.language_id.as_deref(), Some("typescriptreact"));
562 assert_eq!(
563 active.line_range,
564 Some(EditorLineRange { start: 12, end: 18 })
565 );
566 assert!(active.dirty);
567 assert!(active.truncated);
568 assert_eq!(snapshot.visible_editors.len(), 1);
569 }
570
571 #[test]
572 fn prompt_block_includes_selection_text_when_requested() {
573 let snapshot = EditorContextSnapshot {
574 version: IDE_CONTEXT_SNAPSHOT_VERSION,
575 provider_family: IdeContextProviderFamily::Generic,
576 editor_name: None,
577 workspace_root: Some(PathBuf::from("/workspace")),
578 active_file: Some(EditorFileContext {
579 path: "/workspace/src/main.rs".to_string(),
580 language_id: Some("rust".to_string()),
581 line_range: Some(EditorLineRange { start: 1, end: 20 }),
582 dirty: false,
583 truncated: false,
584 selection: Some(EditorSelectionContext {
585 range: EditorSelectionRange {
586 start_line: 4,
587 start_column: 1,
588 end_line: 6,
589 end_column: 2,
590 },
591 text: Some("fn main() {}\n".to_string()),
592 }),
593 }),
594 visible_editors: vec![
595 EditorFileContext {
596 path: "/workspace/src/main.rs".to_string(),
597 language_id: Some("rust".to_string()),
598 line_range: Some(EditorLineRange { start: 1, end: 20 }),
599 dirty: false,
600 truncated: false,
601 selection: None,
602 },
603 EditorFileContext {
604 path: "/workspace/src/lib.rs".to_string(),
605 language_id: Some("rust".to_string()),
606 line_range: Some(EditorLineRange { start: 1, end: 80 }),
607 dirty: false,
608 truncated: false,
609 selection: None,
610 },
611 EditorFileContext {
612 path: "/workspace/src/lib.rs".to_string(),
613 language_id: Some("rust".to_string()),
614 line_range: Some(EditorLineRange { start: 1, end: 80 }),
615 dirty: false,
616 truncated: false,
617 selection: None,
618 },
619 ],
620 };
621
622 let prompt = snapshot
623 .prompt_block(Path::new("/workspace"), true)
624 .expect("prompt");
625
626 assert!(prompt.contains("## Active Editor Context"));
627 assert!(prompt.contains("- Active file: src/main.rs"));
628 assert!(prompt.contains("- Selection: 4:1-6:2"));
629 assert!(prompt.contains("```rust"));
630 assert!(prompt.contains("- Open files:"));
631 assert!(!prompt.contains(" - src/main.rs"));
632 assert!(prompt.contains(" - src/lib.rs"));
633 assert_eq!(prompt.matches(" - src/lib.rs").count(), 1);
634 }
635
636 #[test]
637 fn collapsed_selection_is_not_rendered_in_header_or_prompt() {
638 let snapshot = EditorContextSnapshot {
639 version: IDE_CONTEXT_SNAPSHOT_VERSION,
640 provider_family: IdeContextProviderFamily::VscodeCompatible,
641 editor_name: None,
642 workspace_root: Some(PathBuf::from("/workspace")),
643 active_file: Some(EditorFileContext {
644 path: "/workspace/src/main.rs".to_string(),
645 language_id: Some("rust".to_string()),
646 line_range: Some(EditorLineRange { start: 12, end: 18 }),
647 dirty: false,
648 truncated: false,
649 selection: Some(EditorSelectionContext {
650 range: EditorSelectionRange {
651 start_line: 12,
652 start_column: 4,
653 end_line: 12,
654 end_column: 4,
655 },
656 text: Some(String::new()),
657 }),
658 }),
659 visible_editors: Vec::new(),
660 };
661
662 let header = snapshot
663 .header_summary(Path::new("/workspace"))
664 .expect("header summary");
665 let prompt = snapshot
666 .prompt_block(Path::new("/workspace"), true)
667 .expect("prompt block");
668
669 assert!(!header.contains("Sel "));
670 assert!(!prompt.contains("- Selection:"));
671 assert!(!prompt.contains("- Selected text:"));
672 }
673}