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