1use colored::Colorize;
2use std::process::Command;
3
4pub const EXTENSION_ID: &str = "rvben.rumdl";
5pub const EXTENSION_NAME: &str = "rumdl - Markdown Linter";
6
7#[derive(Debug)]
8pub struct VsCodeExtension {
9 code_command: String,
10}
11
12impl VsCodeExtension {
13 pub fn new() -> Result<Self, String> {
14 let code_command = Self::find_code_command()?;
15 Ok(Self { code_command })
16 }
17
18 pub fn with_command(command: &str) -> Result<Self, String> {
20 if Self::command_exists(command) {
21 Ok(Self {
22 code_command: command.to_string(),
23 })
24 } else {
25 Err(format!("Command '{command}' not found or not working"))
26 }
27 }
28
29 fn command_exists(cmd: &str) -> bool {
31 if let Ok(output) = Command::new(cmd).arg("--version").output()
34 && output.status.success()
35 {
36 return true;
37 }
38
39 let lookup_cmd = if cfg!(windows) { "where" } else { "which" };
41
42 Command::new(lookup_cmd)
43 .arg(cmd)
44 .output()
45 .map(|output| output.status.success())
46 .unwrap_or(false)
47 }
48
49 fn find_code_command() -> Result<String, String> {
50 if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
52 let preferred_cmd = match term_program.to_lowercase().as_str() {
53 "vscode" => {
54 if std::env::var("CURSOR_TRACE_ID").is_ok() || std::env::var("CURSOR_SETTINGS").is_ok() {
57 "cursor"
58 } else if Self::command_exists("cursor") && !Self::command_exists("code") {
59 "cursor"
61 } else {
62 "code"
63 }
64 }
65 "cursor" => "cursor",
66 "windsurf" => "windsurf",
67 _ => "",
68 };
69
70 if !preferred_cmd.is_empty() && Self::command_exists(preferred_cmd) {
72 return Ok(preferred_cmd.to_string());
73 }
74 }
75
76 let commands = ["code", "cursor", "windsurf", "codium", "vscodium"];
78
79 for cmd in &commands {
80 if Self::command_exists(cmd) {
81 return Ok(cmd.to_string());
82 }
83 }
84
85 Err(format!(
86 "VS Code (or compatible editor) not found. Please ensure one of the following commands is available: {}",
87 commands.join(", ")
88 ))
89 }
90
91 pub fn find_all_editors() -> Vec<(&'static str, &'static str)> {
93 let editors = [
94 ("code", "VS Code"),
95 ("cursor", "Cursor"),
96 ("windsurf", "Windsurf"),
97 ("codium", "VSCodium"),
98 ("vscodium", "VSCodium"),
99 ];
100
101 editors
102 .into_iter()
103 .filter(|(cmd, _)| Self::command_exists(cmd))
104 .collect()
105 }
106
107 pub fn current_editor_from_env() -> Option<(&'static str, &'static str)> {
109 if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
110 match term_program.to_lowercase().as_str() {
111 "vscode" => {
112 if Self::command_exists("code") {
113 Some(("code", "VS Code"))
114 } else {
115 None
116 }
117 }
118 "cursor" => {
119 if Self::command_exists("cursor") {
120 Some(("cursor", "Cursor"))
121 } else {
122 None
123 }
124 }
125 "windsurf" => {
126 if Self::command_exists("windsurf") {
127 Some(("windsurf", "Windsurf"))
128 } else {
129 None
130 }
131 }
132 _ => None,
133 }
134 } else {
135 None
136 }
137 }
138
139 fn uses_open_vsx(&self) -> bool {
141 matches!(self.code_command.as_str(), "codium" | "vscodium")
143 }
144
145 fn get_marketplace_url(&self) -> &str {
147 if self.uses_open_vsx() {
148 "https://open-vsx.org/extension/rvben/rumdl"
149 } else {
150 match self.code_command.as_str() {
151 "cursor" | "windsurf" => "https://open-vsx.org/extension/rvben/rumdl",
152 _ => "https://marketplace.visualstudio.com/items?itemName=rvben.rumdl",
153 }
154 }
155 }
156
157 pub fn install(&self, force: bool) -> Result<(), String> {
158 if !force && self.is_installed()? {
159 let current_version = self.get_installed_version().unwrap_or_else(|_| "unknown".to_string());
161 println!("{}", "✓ Rumdl VS Code extension is already installed".green());
162 println!(" Current version: {}", current_version.cyan());
163
164 match self.get_latest_version() {
166 Ok(latest_version) => {
167 println!(" Latest version: {}", latest_version.cyan());
168 if current_version != latest_version && current_version != "unknown" {
169 println!();
170 println!("{}", " ↑ Update available!".yellow());
171 println!(" Run {} to update", "rumdl vscode --update".cyan());
172 }
173 }
174 Err(_) => {
175 }
178 }
179
180 return Ok(());
181 }
182
183 if force {
184 println!("Force reinstalling {} extension...", EXTENSION_NAME.cyan());
185 } else {
186 println!("Installing {} extension...", EXTENSION_NAME.cyan());
187 }
188
189 if matches!(self.code_command.as_str(), "cursor" | "windsurf") {
191 println!(
192 "{}",
193 "ℹ Note: Cursor/Windsurf may default to VS Code Marketplace.".yellow()
194 );
195 println!(" If the extension is not found, please install from Open VSX:");
196 println!(" {}", self.get_marketplace_url().cyan());
197 println!();
198 }
199
200 let mut args = vec!["--install-extension", EXTENSION_ID];
201 if force {
202 args.push("--force");
203 }
204
205 let output = Command::new(&self.code_command)
206 .args(&args)
207 .output()
208 .map_err(|e| format!("Failed to run VS Code command: {e}"))?;
209
210 if output.status.success() {
211 println!("{}", "✓ Successfully installed Rumdl VS Code extension!".green());
212
213 if let Ok(version) = self.get_installed_version() {
215 println!(" Installed version: {}", version.cyan());
216 }
217
218 Ok(())
219 } else {
220 let stderr = String::from_utf8_lossy(&output.stderr);
221 if stderr.contains("not found") {
222 match self.code_command.as_str() {
224 "cursor" | "windsurf" => Err(format!(
225 "Extension not found in marketplace. Please install from Open VSX:\n\
226 {}\n\n\
227 Or download the VSIX directly and install with:\n\
228 {} --install-extension path/to/rumdl-*.vsix",
229 self.get_marketplace_url().cyan(),
230 self.code_command.cyan()
231 )),
232 "codium" | "vscodium" => Err(format!(
233 "Extension not found. VSCodium uses Open VSX by default.\n\
234 Please check: {}",
235 self.get_marketplace_url().cyan()
236 )),
237 _ => Err(format!(
238 "Extension not found in VS Code Marketplace.\n\
239 Please check: {}",
240 self.get_marketplace_url().cyan()
241 )),
242 }
243 } else {
244 Err(format!("Failed to install extension: {stderr}"))
245 }
246 }
247 }
248
249 pub fn is_installed(&self) -> Result<bool, String> {
250 let output = Command::new(&self.code_command)
251 .arg("--list-extensions")
252 .output()
253 .map_err(|e| format!("Failed to list extensions: {e}"))?;
254
255 if output.status.success() {
256 let extensions = String::from_utf8_lossy(&output.stdout);
257 Ok(extensions.lines().any(|line| line.trim() == EXTENSION_ID))
258 } else {
259 Err("Failed to check installed extensions".to_string())
260 }
261 }
262
263 fn get_installed_version(&self) -> Result<String, String> {
264 let output = Command::new(&self.code_command)
265 .args(["--list-extensions", "--show-versions"])
266 .output()
267 .map_err(|e| format!("Failed to list extensions: {e}"))?;
268
269 if output.status.success() {
270 let extensions = String::from_utf8_lossy(&output.stdout);
271 if let Some(line) = extensions.lines().find(|line| line.starts_with(EXTENSION_ID)) {
272 if let Some(version) = line.split('@').nth(1) {
274 return Ok(version.to_string());
275 }
276 }
277 }
278 Err("Could not determine installed version".to_string())
279 }
280
281 fn get_latest_version(&self) -> Result<String, String> {
283 let api_url = if self.uses_open_vsx() || matches!(self.code_command.as_str(), "cursor" | "windsurf") {
284 "https://open-vsx.org/api/rvben/rumdl".to_string()
286 } else {
287 "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery".to_string()
290 };
291
292 let output = if api_url.contains("open-vsx.org") {
293 Command::new("curl")
295 .args(["-s", "-f", &api_url])
296 .output()
297 .map_err(|e| format!("Failed to query marketplace: {e}"))?
298 } else {
299 let query = r#"{
301 "filters": [{
302 "criteria": [
303 {"filterType": 7, "value": "rvben.rumdl"}
304 ]
305 }],
306 "flags": 914
307 }"#;
308
309 Command::new("curl")
310 .args([
311 "-s",
312 "-f",
313 "-X",
314 "POST",
315 "-H",
316 "Content-Type: application/json",
317 "-H",
318 "Accept: application/json;api-version=3.0-preview.1",
319 "-d",
320 query,
321 &api_url,
322 ])
323 .output()
324 .map_err(|e| format!("Failed to query marketplace: {e}"))?
325 };
326
327 if output.status.success() {
328 let response = String::from_utf8_lossy(&output.stdout);
329
330 if api_url.contains("open-vsx.org") {
331 if let Some(version_start) = response.find("\"version\":\"") {
333 let start = version_start + 11;
334 if let Some(version_end) = response[start..].find('"') {
335 return Ok(response[start..start + version_end].to_string());
336 }
337 }
338 } else {
339 if let Some(version_start) = response.find("\"version\":\"") {
342 let start = version_start + 11;
343 if let Some(version_end) = response[start..].find('"') {
344 return Ok(response[start..start + version_end].to_string());
345 }
346 }
347 }
348 }
349
350 Err("Unable to check latest version from marketplace".to_string())
351 }
352
353 pub fn show_status(&self) -> Result<(), String> {
354 if self.is_installed()? {
355 let current_version = self.get_installed_version().unwrap_or_else(|_| "unknown".to_string());
356 println!("{}", "✓ Rumdl VS Code extension is installed".green());
357 println!(" Current version: {}", current_version.cyan());
358
359 match self.get_latest_version() {
361 Ok(latest_version) => {
362 println!(" Latest version: {}", latest_version.cyan());
363 if current_version != latest_version && current_version != "unknown" {
364 println!();
365 println!("{}", " ↑ Update available!".yellow());
366 println!(" Run {} to update", "rumdl vscode --update".cyan());
367 }
368 }
369 Err(_) => {
370 }
372 }
373 } else {
374 println!("{}", "✗ Rumdl VS Code extension is not installed".yellow());
375 println!(" Run {} to install it", "rumdl vscode".cyan());
376 }
377 Ok(())
378 }
379
380 pub fn update(&self) -> Result<(), String> {
382 log::debug!("Using command: {}", self.code_command);
384 if !self.is_installed()? {
385 println!("{}", "✗ Rumdl VS Code extension is not installed".yellow());
386 println!(" Run {} to install it", "rumdl vscode".cyan());
387 return Ok(());
388 }
389
390 let current_version = self.get_installed_version().unwrap_or_else(|_| "unknown".to_string());
391 println!("Current version: {}", current_version.cyan());
392
393 match self.get_latest_version() {
395 Ok(latest_version) => {
396 println!("Latest version: {}", latest_version.cyan());
397
398 if current_version == latest_version {
399 println!();
400 println!("{}", "✓ Already up to date!".green());
401 return Ok(());
402 }
403
404 println!();
406 println!("Updating to version {}...", latest_version.cyan());
407
408 let output = Command::new(&self.code_command)
412 .args(["--install-extension", EXTENSION_ID, "--force"])
413 .output()
414 .map_err(|e| format!("Failed to run VS Code command: {e}"))?;
415
416 if output.status.success() {
417 println!("{}", "✓ Successfully updated Rumdl VS Code extension!".green());
418
419 if let Ok(new_version) = self.get_installed_version() {
421 println!(" New version: {}", new_version.cyan());
422 }
423 Ok(())
424 } else {
425 let stderr = String::from_utf8_lossy(&output.stderr);
426
427 if stderr.contains("not found") && matches!(self.code_command.as_str(), "cursor" | "windsurf") {
429 println!();
430 println!(
431 "{}",
432 "The extension is not available in your editor's default marketplace.".yellow()
433 );
434 println!();
435 println!("To install from Open VSX:");
436 println!("1. Open {} (Cmd+Shift+X)", "Extensions".cyan());
437 println!("2. Search for {}", "'rumdl'".cyan());
438 println!("3. Click {} on the rumdl extension", "Install".green());
439 println!();
440 println!("Or download the VSIX manually:");
441 println!("1. Download from: {}", self.get_marketplace_url().cyan());
442 println!(
443 "2. Install with: {} --install-extension path/to/rumdl-{}.vsix",
444 self.code_command.cyan(),
445 latest_version.cyan()
446 );
447
448 Ok(()) } else {
450 Err(format!("Failed to update extension: {stderr}"))
451 }
452 }
453 }
454 Err(e) => {
455 println!("{}", "⚠ Unable to check for updates".yellow());
456 println!(" {}", e.dimmed());
457 println!();
458 println!("You can try forcing a reinstall with:");
459 println!(" {}", "rumdl vscode --force".cyan());
460 Ok(())
461 }
462 }
463 }
464}
465
466pub fn handle_vscode_command(force: bool, update: bool, status: bool) -> Result<(), String> {
467 let vscode = VsCodeExtension::new()?;
468
469 if status {
470 vscode.show_status()
471 } else if update {
472 vscode.update()
473 } else {
474 vscode.install(force)
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn test_extension_constants() {
484 assert_eq!(EXTENSION_ID, "rvben.rumdl");
485 assert_eq!(EXTENSION_NAME, "rumdl - Markdown Linter");
486 }
487
488 #[test]
489 fn test_vscode_extension_with_command() {
490 let result = VsCodeExtension::with_command("nonexistent-command-xyz");
492 assert!(result.is_err());
493 assert!(result.unwrap_err().contains("not found or not working"));
494
495 }
498
499 #[test]
500 fn test_command_exists() {
501 assert!(!VsCodeExtension::command_exists("nonexistent-command-xyz"));
503
504 }
508
509 #[test]
510 fn test_command_exists_cross_platform() {
511 assert!(!VsCodeExtension::command_exists("definitely-nonexistent-command-12345"));
516
517 let _result = VsCodeExtension::command_exists("code");
521 }
523
524 #[test]
525 fn test_find_all_editors() {
526 let editors = VsCodeExtension::find_all_editors();
529
530 assert!(editors.is_empty() || !editors.is_empty());
532
533 for (cmd, name) in &editors {
535 assert!(!cmd.is_empty());
536 assert!(!name.is_empty());
537 assert!(["code", "cursor", "windsurf", "codium", "vscodium"].contains(cmd));
538 assert!(["VS Code", "Cursor", "Windsurf", "VSCodium"].contains(name));
539 }
540 }
541
542 #[test]
543 fn test_current_editor_from_env() {
544 let original_term = std::env::var("TERM_PROGRAM").ok();
546 let original_editor = std::env::var("EDITOR").ok();
547 let original_visual = std::env::var("VISUAL").ok();
548
549 unsafe {
550 std::env::remove_var("TERM_PROGRAM");
552 std::env::remove_var("EDITOR");
553 std::env::remove_var("VISUAL");
554
555 assert!(VsCodeExtension::current_editor_from_env().is_none());
557
558 std::env::set_var("TERM_PROGRAM", "vscode");
560 let _result = VsCodeExtension::current_editor_from_env();
561 std::env::set_var("TERM_PROGRAM", "cursor");
565 let _cursor_result = VsCodeExtension::current_editor_from_env();
566 std::env::set_var("TERM_PROGRAM", "windsurf");
570 let _windsurf_result = VsCodeExtension::current_editor_from_env();
571 std::env::set_var("TERM_PROGRAM", "unknown-editor");
575 assert!(VsCodeExtension::current_editor_from_env().is_none());
576
577 std::env::set_var("TERM_PROGRAM", "VsCode");
579 let _mixed_case_result = VsCodeExtension::current_editor_from_env();
580 if let Some(term) = original_term {
584 std::env::set_var("TERM_PROGRAM", term);
585 } else {
586 std::env::remove_var("TERM_PROGRAM");
587 }
588 if let Some(editor) = original_editor {
589 std::env::set_var("EDITOR", editor);
590 }
591 if let Some(visual) = original_visual {
592 std::env::set_var("VISUAL", visual);
593 }
594 }
595 }
596
597 #[test]
598 fn test_vscode_extension_struct() {
599 let ext = VsCodeExtension {
601 code_command: "test-command".to_string(),
602 };
603 assert_eq!(ext.code_command, "test-command");
604 }
605
606 #[test]
607 fn test_find_code_command_env_priority() {
608 let original_term = std::env::var("TERM_PROGRAM").ok();
610
611 unsafe {
612 std::env::set_var("TERM_PROGRAM", "vscode");
617 let _result = VsCodeExtension::new();
619 if let Some(term) = original_term {
623 std::env::set_var("TERM_PROGRAM", term);
624 } else {
625 std::env::remove_var("TERM_PROGRAM");
626 }
627 }
628 }
629
630 #[test]
631 fn test_error_messages() {
632 let result = VsCodeExtension::with_command("nonexistent");
634 assert!(result.is_err());
635 let err_msg = result.unwrap_err();
636 assert!(err_msg.contains("nonexistent"));
637 assert!(err_msg.contains("not found or not working"));
638 }
639
640 #[test]
641 fn test_handle_vscode_command_logic() {
642 let result = handle_vscode_command(false, false, true);
647 assert!(result.is_err() || result.is_ok());
649 }
650}