1use anyhow::{Context, Result};
13use semver::Version;
14use serde::Deserialize;
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone)]
24pub struct CopilotChatInstall {
25 pub version: Version,
27 pub required_vscode_version: String,
29 pub required_node_version: Option<String>,
31 pub extension_path: PathBuf,
33 pub is_active: bool,
35}
36
37#[derive(Debug, Clone)]
39pub struct CopilotVersionReport {
40 pub installed: Vec<CopilotChatInstall>,
42 pub active_version: Option<Version>,
44 pub session_versions: HashMap<String, usize>,
46 pub issues: Vec<VersionIssue>,
48}
49
50#[derive(Debug, Clone)]
52pub struct VersionIssue {
53 pub severity: &'static str,
55 pub message: String,
57}
58
59#[derive(Deserialize, Debug)]
64struct PackageJson {
65 version: Option<String>,
66 engines: Option<Engines>,
67}
68
69#[derive(Deserialize, Debug)]
70struct Engines {
71 vscode: Option<String>,
72 node: Option<String>,
73}
74
75pub struct VersionCompatEntry {
99 pub extension_min: &'static str,
100 pub extension_max: &'static str,
101 pub vscode_min: &'static str,
102 pub session_format: &'static str,
103 pub notes: &'static str,
104}
105
106pub fn known_compatibility() -> Vec<VersionCompatEntry> {
108 vec![
109 VersionCompatEntry {
110 extension_min: "0.25.0",
111 extension_max: "0.36.99",
112 vscode_min: "1.98.0",
113 session_format: "json",
114 notes: "Legacy JSON format (single object)",
115 },
116 VersionCompatEntry {
117 extension_min: "0.37.0",
118 extension_max: "0.37.99",
119 vscode_min: "1.109.0",
120 session_format: "jsonl",
121 notes: "JSONL event-sourced format (kind 0/1/2)",
122 },
123 ]
124}
125
126fn get_vscode_extensions_dir() -> Option<PathBuf> {
132 let home = dirs::home_dir()?;
133 let path = home.join(".vscode").join("extensions");
134 if path.exists() {
135 Some(path)
136 } else {
137 None
138 }
139}
140
141pub fn detect_installed_versions() -> Result<Vec<CopilotChatInstall>> {
146 let extensions_dir = match get_vscode_extensions_dir() {
147 Some(d) => d,
148 None => return Ok(Vec::new()),
149 };
150
151 let mut installs: Vec<CopilotChatInstall> = Vec::new();
152
153 for entry in std::fs::read_dir(&extensions_dir).with_context(|| {
154 format!(
155 "Failed to read extensions dir: {}",
156 extensions_dir.display()
157 )
158 })? {
159 let entry = entry?;
160 let dir_name = entry.file_name().to_string_lossy().to_string();
161
162 if !dir_name.starts_with("github.copilot-chat-") {
164 continue;
165 }
166
167 let version_str = &dir_name["github.copilot-chat-".len()..];
168 let version = match Version::parse(version_str) {
169 Ok(v) => v,
170 Err(_) => continue, };
172
173 let ext_path = entry.path();
174 let pkg_path = ext_path.join("package.json");
175
176 let (required_vscode, required_node) = if pkg_path.exists() {
177 match std::fs::read_to_string(&pkg_path) {
178 Ok(content) => match serde_json::from_str::<PackageJson>(&content) {
179 Ok(pkg) => {
180 let vscode = pkg
181 .engines
182 .as_ref()
183 .and_then(|e| e.vscode.as_ref())
184 .cloned()
185 .unwrap_or_else(|| "unknown".to_string());
186 let node = pkg.engines.as_ref().and_then(|e| e.node.clone());
187 (vscode, node)
188 }
189 Err(_) => ("unknown".to_string(), None),
190 },
191 Err(_) => ("unknown".to_string(), None),
192 }
193 } else {
194 ("unknown".to_string(), None)
195 };
196
197 installs.push(CopilotChatInstall {
198 version,
199 required_vscode_version: required_vscode,
200 required_node_version: required_node,
201 extension_path: ext_path,
202 is_active: false, });
204 }
205
206 installs.sort_by(|a, b| b.version.cmp(&a.version));
208
209 if let Some(first) = installs.first_mut() {
211 first.is_active = true;
212 }
213
214 Ok(installs)
215}
216
217pub fn extract_session_versions(content: &str) -> Vec<String> {
226 let mut versions = Vec::new();
227 let mut seen = std::collections::HashSet::new();
228
229 for line in content.lines() {
230 if line.is_empty() {
231 continue;
232 }
233
234 if !line.contains("extensionVersion") {
236 continue;
237 }
238
239 if let Ok(obj) = serde_json::from_str::<serde_json::Value>(line) {
240 extract_extension_versions_from_value(&obj, &mut versions, &mut seen);
243 }
244 }
245
246 versions
247}
248
249fn extract_extension_versions_from_value(
251 value: &serde_json::Value,
252 versions: &mut Vec<String>,
253 seen: &mut std::collections::HashSet<String>,
254) {
255 match value {
256 serde_json::Value::Object(map) => {
257 if let Some(v) = map.get("extensionVersion").and_then(|v| v.as_str()) {
258 if seen.insert(v.to_string()) {
259 versions.push(v.to_string());
260 }
261 }
262 for (_, v) in map {
263 extract_extension_versions_from_value(v, versions, seen);
264 }
265 }
266 serde_json::Value::Array(arr) => {
267 for item in arr {
268 extract_extension_versions_from_value(item, versions, seen);
269 }
270 }
271 _ => {}
272 }
273}
274
275pub fn build_version_report(session_dir: Option<&Path>) -> Result<CopilotVersionReport> {
285 let installed = detect_installed_versions()?;
286
287 let active_version = installed
288 .iter()
289 .find(|i| i.is_active)
290 .map(|i| i.version.clone());
291
292 let mut session_versions: HashMap<String, usize> = HashMap::new();
293
294 if let Some(dir) = session_dir {
295 if dir.exists() {
296 for entry in std::fs::read_dir(dir)? {
297 let entry = entry?;
298 let path = entry.path();
299 if path.extension().is_some_and(|e| e == "jsonl") {
300 if let Ok(content) = std::fs::read_to_string(&path) {
301 for ver in extract_session_versions(&content) {
302 *session_versions.entry(ver).or_default() += 1;
303 }
304 }
305 }
306 }
307 }
308 }
309
310 let mut issues = Vec::new();
312
313 if installed.is_empty() {
315 issues.push(VersionIssue {
316 severity: "error",
317 message: "No Copilot Chat extension found in ~/.vscode/extensions/".to_string(),
318 });
319 }
320
321 if installed.len() > 1 {
323 let old_versions: Vec<String> = installed
324 .iter()
325 .skip(1)
326 .map(|i| i.version.to_string())
327 .collect();
328 issues.push(VersionIssue {
329 severity: "info",
330 message: format!(
331 "Multiple Copilot Chat versions installed: older {} may be stale",
332 old_versions.join(", ")
333 ),
334 });
335 }
336
337 if let Some(ref active) = active_version {
339 let active_str = active.to_string();
340 for (session_ver, count) in &session_versions {
341 if session_ver != &active_str {
342 let severity = if let Ok(sv) = Version::parse(session_ver) {
344 if sv.major != active.major || sv.minor != active.minor {
345 "warning"
347 } else {
348 "info"
349 }
350 } else {
351 "info"
352 };
353
354 issues.push(VersionIssue {
355 severity,
356 message: format!(
357 "{} session(s) created with extension v{} (installed: v{})",
358 count, session_ver, active_str
359 ),
360 });
361 }
362 }
363 }
364
365 if let Some(ref active) = active_version {
367 if active >= &Version::new(0, 37, 0) {
368 for (session_ver, count) in &session_versions {
369 if let Ok(sv) = Version::parse(session_ver) {
370 if sv < Version::new(0, 37, 0) {
371 issues.push(VersionIssue {
372 severity: "warning",
373 message: format!(
374 "{} session(s) from pre-JSONL era (v{}) — these use legacy JSON format. \
375 Current extension v{} uses JSONL. Consider upgrading with: chasm recover upgrade",
376 count, session_ver, active
377 ),
378 });
379 }
380 }
381 }
382 }
383 }
384
385 Ok(CopilotVersionReport {
386 installed,
387 active_version,
388 session_versions,
389 issues,
390 })
391}
392
393pub fn format_version_report(report: &CopilotVersionReport) -> String {
395 use std::fmt::Write;
396
397 let mut out = String::new();
398
399 writeln!(out, "[*] Copilot Chat Extension Analysis").unwrap();
400 writeln!(out).unwrap();
401
402 if report.installed.is_empty() {
403 writeln!(
404 out,
405 " [!] No Copilot Chat extension found in ~/.vscode/extensions/"
406 )
407 .unwrap();
408 } else {
409 writeln!(out, " Installed versions:").unwrap();
410 for install in &report.installed {
411 let active_marker = if install.is_active {
412 " (active)"
413 } else {
414 " (stale)"
415 };
416 writeln!(
417 out,
418 " v{}{} — requires VS Code {}",
419 install.version, active_marker, install.required_vscode_version,
420 )
421 .unwrap();
422 if let Some(ref node) = install.required_node_version {
423 writeln!(out, " Node.js: {}", node).unwrap();
424 }
425 writeln!(out, " Path: {}", install.extension_path.display()).unwrap();
426 }
427 }
428
429 if !report.session_versions.is_empty() {
430 writeln!(out).unwrap();
431 writeln!(out, " Session extension versions:").unwrap();
432 let mut sorted: Vec<_> = report.session_versions.iter().collect();
433 sorted.sort_by(|a, b| b.1.cmp(a.1));
434 for (ver, count) in sorted {
435 writeln!(out, " v{}: {} session(s)", ver, count).unwrap();
436 }
437 }
438
439 if !report.issues.is_empty() {
440 writeln!(out).unwrap();
441 writeln!(out, " Compatibility notes:").unwrap();
442 for issue in &report.issues {
443 let prefix = match issue.severity {
444 "error" => "[!]",
445 "warning" => "[?]",
446 _ => "[i]",
447 };
448 writeln!(out, " {} {}", prefix, issue.message).unwrap();
449 }
450 }
451
452 if let Some(ref active) = report.active_version {
454 writeln!(out).unwrap();
455 let expected_format = if active >= &Version::new(0, 37, 0) {
456 "JSONL (event-sourced, kind 0/1/2)"
457 } else {
458 "Legacy JSON (single object)"
459 };
460 writeln!(out, " Expected session format: {}", expected_format).unwrap();
461
462 let compat = known_compatibility();
464 for entry in &compat {
465 if let (Ok(min), Ok(max)) = (
466 Version::parse(entry.extension_min),
467 Version::parse(entry.extension_max),
468 ) {
469 if active >= &min && active <= &max {
470 writeln!(
471 out,
472 " Min VS Code for this extension: {}",
473 entry.vscode_min
474 )
475 .unwrap();
476 writeln!(out, " Notes: {}", entry.notes).unwrap();
477 break;
478 }
479 }
480 }
481 }
482
483 out
484}
485
486pub fn format_version_report_json(report: &CopilotVersionReport) -> Result<String> {
488 let installed_json: Vec<serde_json::Value> = report
489 .installed
490 .iter()
491 .map(|i| {
492 serde_json::json!({
493 "version": i.version.to_string(),
494 "required_vscode_version": i.required_vscode_version,
495 "required_node_version": i.required_node_version,
496 "extension_path": i.extension_path.display().to_string(),
497 "is_active": i.is_active,
498 })
499 })
500 .collect();
501
502 let issues_json: Vec<serde_json::Value> = report
503 .issues
504 .iter()
505 .map(|i| {
506 serde_json::json!({
507 "severity": i.severity,
508 "message": i.message,
509 })
510 })
511 .collect();
512
513 let result = serde_json::json!({
514 "installed": installed_json,
515 "active_version": report.active_version.as_ref().map(|v| v.to_string()),
516 "session_versions": report.session_versions,
517 "issues": issues_json,
518 "compatibility_table": known_compatibility().iter().map(|e| {
519 serde_json::json!({
520 "extension_range": format!("{} - {}", e.extension_min, e.extension_max),
521 "vscode_min": e.vscode_min,
522 "session_format": e.session_format,
523 "notes": e.notes,
524 })
525 }).collect::<Vec<_>>(),
526 });
527
528 Ok(serde_json::to_string_pretty(&result)?)
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
536 fn test_extract_session_versions_from_jsonl() {
537 let content =
538 r#"{"kind":0,"v":{"version":3,"requests":[{"agent":{"extensionVersion":"0.32.4"}}]}}"#;
539 let versions = extract_session_versions(content);
540 assert_eq!(versions, vec!["0.32.4"]);
541 }
542
543 #[test]
544 fn test_extract_multiple_versions() {
545 let content = r#"{"kind":0,"v":{"requests":[{"agent":{"extensionVersion":"0.32.3"}},{"agent":{"extensionVersion":"0.32.4"}}]}}
546{"kind":1,"v":{"agent":{"extensionVersion":"0.37.8"}}}"#;
547 let versions = extract_session_versions(content);
548 assert_eq!(versions.len(), 3);
549 assert!(versions.contains(&"0.32.3".to_string()));
550 assert!(versions.contains(&"0.32.4".to_string()));
551 assert!(versions.contains(&"0.37.8".to_string()));
552 }
553
554 #[test]
555 fn test_extract_no_versions() {
556 let content = r#"{"kind":0,"v":{"version":3,"requests":[{"message":{"text":"hello"}}]}}"#;
557 let versions = extract_session_versions(content);
558 assert!(versions.is_empty());
559 }
560
561 #[test]
562 fn test_known_compatibility_table() {
563 let compat = known_compatibility();
564 assert!(!compat.is_empty());
565 let last = compat.last().unwrap();
567 assert_eq!(last.session_format, "jsonl");
568 }
569}