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 #[doc(hidden)]
60 pub fn find_code_command_impl<F>(command_checker: F) -> Result<String, String>
61 where
62 F: Fn(&str) -> bool,
63 {
64 if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
66 let preferred_cmd = match term_program.to_lowercase().as_str() {
67 "vscode" => {
68 if std::env::var("CURSOR_TRACE_ID").is_ok() || std::env::var("CURSOR_SETTINGS").is_ok() {
71 "cursor"
72 } else if command_checker("cursor") && !command_checker("code") {
73 "cursor"
75 } else {
76 "code"
77 }
78 }
79 "cursor" => "cursor",
80 "windsurf" => "windsurf",
81 _ => "",
82 };
83
84 if !preferred_cmd.is_empty() && command_checker(preferred_cmd) {
86 return Ok(preferred_cmd.to_string());
87 }
88 }
89
90 let commands = ["code", "cursor", "windsurf", "codium", "vscodium"];
92
93 for cmd in &commands {
94 if command_checker(cmd) {
95 return Ok(cmd.to_string());
96 }
97 }
98
99 Err(format!(
100 "VS Code (or compatible editor) not found. Please ensure one of the following commands is available: {}",
101 commands.join(", ")
102 ))
103 }
104
105 fn find_code_command() -> Result<String, String> {
106 Self::find_code_command_impl(Self::command_exists)
107 }
108
109 #[doc(hidden)]
111 pub fn find_all_editors_impl<F>(command_checker: F) -> Vec<(&'static str, &'static str)>
112 where
113 F: Fn(&str) -> bool,
114 {
115 let editors = [
116 ("code", "VS Code"),
117 ("cursor", "Cursor"),
118 ("windsurf", "Windsurf"),
119 ("codium", "VSCodium"),
120 ("vscodium", "VSCodium"),
121 ];
122
123 editors.into_iter().filter(|(cmd, _)| command_checker(cmd)).collect()
124 }
125
126 pub fn find_all_editors() -> Vec<(&'static str, &'static str)> {
128 Self::find_all_editors_impl(Self::command_exists)
129 }
130
131 fn current_editor_from_env_impl(term_program: Option<&str>) -> Option<(&'static str, &'static str)> {
134 if let Some(term) = term_program {
135 match term.to_lowercase().as_str() {
136 "vscode" => {
137 if Self::command_exists("code") {
138 Some(("code", "VS Code"))
139 } else {
140 None
141 }
142 }
143 "cursor" => {
144 if Self::command_exists("cursor") {
145 Some(("cursor", "Cursor"))
146 } else {
147 None
148 }
149 }
150 "windsurf" => {
151 if Self::command_exists("windsurf") {
152 Some(("windsurf", "Windsurf"))
153 } else {
154 None
155 }
156 }
157 _ => None,
158 }
159 } else {
160 None
161 }
162 }
163
164 pub fn current_editor_from_env() -> Option<(&'static str, &'static str)> {
165 Self::current_editor_from_env_impl(std::env::var("TERM_PROGRAM").ok().as_deref())
166 }
167
168 fn uses_open_vsx(&self) -> bool {
170 matches!(self.code_command.as_str(), "codium" | "vscodium")
172 }
173
174 fn get_marketplace_url(&self) -> &str {
176 if self.uses_open_vsx() {
177 "https://open-vsx.org/extension/rvben/rumdl"
178 } else {
179 match self.code_command.as_str() {
180 "cursor" | "windsurf" => "https://open-vsx.org/extension/rvben/rumdl",
181 _ => "https://marketplace.visualstudio.com/items?itemName=rvben.rumdl",
182 }
183 }
184 }
185
186 pub fn install(&self, force: bool) -> Result<(), String> {
187 if !force && self.is_installed()? {
188 let current_version = self.get_installed_version().unwrap_or_else(|_| "unknown".to_string());
190 println!("{}", "✓ Rumdl VS Code extension is already installed".green());
191 println!(" Current version: {}", current_version.cyan());
192
193 match self.get_latest_version() {
195 Ok(latest_version) => {
196 println!(" Latest version: {}", latest_version.cyan());
197 if current_version != latest_version && current_version != "unknown" {
198 println!();
199 println!("{}", " ↑ Update available!".yellow());
200 println!(" Run {} to update", "rumdl vscode --update".cyan());
201 }
202 }
203 Err(_) => {
204 }
207 }
208
209 return Ok(());
210 }
211
212 if force {
213 println!("Force reinstalling {} extension...", EXTENSION_NAME.cyan());
214 } else {
215 println!("Installing {} extension...", EXTENSION_NAME.cyan());
216 }
217
218 if matches!(self.code_command.as_str(), "cursor" | "windsurf") {
220 println!(
221 "{}",
222 "ℹ Note: Cursor/Windsurf may default to VS Code Marketplace.".yellow()
223 );
224 println!(" If the extension is not found, please install from Open VSX:");
225 println!(" {}", self.get_marketplace_url().cyan());
226 println!();
227 }
228
229 let mut args = vec!["--install-extension", EXTENSION_ID];
230 if force {
231 args.push("--force");
232 }
233
234 let output = Command::new(&self.code_command)
235 .args(&args)
236 .output()
237 .map_err(|e| format!("Failed to run VS Code command: {e}"))?;
238
239 if output.status.success() {
240 println!("{}", "✓ Successfully installed Rumdl VS Code extension!".green());
241
242 if let Ok(version) = self.get_installed_version() {
244 println!(" Installed version: {}", version.cyan());
245 }
246
247 Ok(())
248 } else {
249 let stderr = String::from_utf8_lossy(&output.stderr);
250 if stderr.contains("not found") {
251 match self.code_command.as_str() {
253 "cursor" | "windsurf" => Err(format!(
254 "Extension not found in marketplace. Please install from Open VSX:\n\
255 {}\n\n\
256 Or download the VSIX directly and install with:\n\
257 {} --install-extension path/to/rumdl-*.vsix",
258 self.get_marketplace_url().cyan(),
259 self.code_command.cyan()
260 )),
261 "codium" | "vscodium" => Err(format!(
262 "Extension not found. VSCodium uses Open VSX by default.\n\
263 Please check: {}",
264 self.get_marketplace_url().cyan()
265 )),
266 _ => Err(format!(
267 "Extension not found in VS Code Marketplace.\n\
268 Please check: {}",
269 self.get_marketplace_url().cyan()
270 )),
271 }
272 } else {
273 Err(format!("Failed to install extension: {stderr}"))
274 }
275 }
276 }
277
278 pub fn is_installed(&self) -> Result<bool, String> {
279 let output = Command::new(&self.code_command)
280 .arg("--list-extensions")
281 .output()
282 .map_err(|e| format!("Failed to list extensions: {e}"))?;
283
284 if output.status.success() {
285 let extensions = String::from_utf8_lossy(&output.stdout);
286 Ok(extensions.lines().any(|line| line.trim() == EXTENSION_ID))
287 } else {
288 Err("Failed to check installed extensions".to_string())
289 }
290 }
291
292 fn get_installed_version(&self) -> Result<String, String> {
293 let output = Command::new(&self.code_command)
294 .args(["--list-extensions", "--show-versions"])
295 .output()
296 .map_err(|e| format!("Failed to list extensions: {e}"))?;
297
298 if output.status.success() {
299 let extensions = String::from_utf8_lossy(&output.stdout);
300 if let Some(line) = extensions.lines().find(|line| line.starts_with(EXTENSION_ID)) {
301 if let Some(version) = line.split('@').nth(1) {
303 return Ok(version.to_string());
304 }
305 }
306 }
307 Err("Could not determine installed version".to_string())
308 }
309
310 fn get_latest_version(&self) -> Result<String, String> {
312 let api_url = if self.uses_open_vsx() || matches!(self.code_command.as_str(), "cursor" | "windsurf") {
313 "https://open-vsx.org/api/rvben/rumdl".to_string()
315 } else {
316 "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery".to_string()
319 };
320
321 let output = if api_url.contains("open-vsx.org") {
322 Command::new("curl")
324 .args(["-s", "-f", &api_url])
325 .output()
326 .map_err(|e| format!("Failed to query marketplace: {e}"))?
327 } else {
328 let query = r#"{
330 "filters": [{
331 "criteria": [
332 {"filterType": 7, "value": "rvben.rumdl"}
333 ]
334 }],
335 "flags": 914
336 }"#;
337
338 Command::new("curl")
339 .args([
340 "-s",
341 "-f",
342 "-X",
343 "POST",
344 "-H",
345 "Content-Type: application/json",
346 "-H",
347 "Accept: application/json;api-version=3.0-preview.1",
348 "-d",
349 query,
350 &api_url,
351 ])
352 .output()
353 .map_err(|e| format!("Failed to query marketplace: {e}"))?
354 };
355
356 if output.status.success() {
357 let response = String::from_utf8_lossy(&output.stdout);
358
359 if api_url.contains("open-vsx.org") {
360 if let Some(version_start) = response.find("\"version\":\"") {
362 let start = version_start + 11;
363 if let Some(version_end) = response[start..].find('"') {
364 return Ok(response[start..start + version_end].to_string());
365 }
366 }
367 } else {
368 if let Some(version_start) = response.find("\"version\":\"") {
371 let start = version_start + 11;
372 if let Some(version_end) = response[start..].find('"') {
373 return Ok(response[start..start + version_end].to_string());
374 }
375 }
376 }
377 }
378
379 Err("Unable to check latest version from marketplace".to_string())
380 }
381
382 pub fn show_status(&self) -> Result<(), String> {
383 if self.is_installed()? {
384 let current_version = self.get_installed_version().unwrap_or_else(|_| "unknown".to_string());
385 println!("{}", "✓ Rumdl VS Code extension is installed".green());
386 println!(" Current version: {}", current_version.cyan());
387
388 match self.get_latest_version() {
390 Ok(latest_version) => {
391 println!(" Latest version: {}", latest_version.cyan());
392 if current_version != latest_version && current_version != "unknown" {
393 println!();
394 println!("{}", " ↑ Update available!".yellow());
395 println!(" Run {} to update", "rumdl vscode --update".cyan());
396 }
397 }
398 Err(_) => {
399 }
401 }
402 } else {
403 println!("{}", "✗ Rumdl VS Code extension is not installed".yellow());
404 println!(" Run {} to install it", "rumdl vscode".cyan());
405 }
406 Ok(())
407 }
408
409 pub fn update(&self) -> Result<(), String> {
411 log::debug!("Using command: {}", self.code_command);
413 if !self.is_installed()? {
414 println!("{}", "✗ Rumdl VS Code extension is not installed".yellow());
415 println!(" Run {} to install it", "rumdl vscode".cyan());
416 return Ok(());
417 }
418
419 let current_version = self.get_installed_version().unwrap_or_else(|_| "unknown".to_string());
420 println!("Current version: {}", current_version.cyan());
421
422 match self.get_latest_version() {
424 Ok(latest_version) => {
425 println!("Latest version: {}", latest_version.cyan());
426
427 if current_version == latest_version {
428 println!();
429 println!("{}", "✓ Already up to date!".green());
430 return Ok(());
431 }
432
433 println!();
435 println!("Updating to version {}...", latest_version.cyan());
436
437 let output = Command::new(&self.code_command)
441 .args(["--install-extension", EXTENSION_ID, "--force"])
442 .output()
443 .map_err(|e| format!("Failed to run VS Code command: {e}"))?;
444
445 if output.status.success() {
446 match self.get_installed_version() {
448 Ok(new_version) => {
449 println!("{}", "✓ Successfully updated Rumdl VS Code extension!".green());
450 println!(" New version: {}", new_version.cyan());
451
452 if new_version != latest_version {
454 println!();
455 println!(
456 "{}",
457 format!("⚠ Expected version {latest_version}, but {new_version} is installed")
458 .yellow()
459 );
460 println!(" This might indicate a caching issue or delayed marketplace propagation.");
461 println!(
462 " Try restarting your editor or running {} again later",
463 "rumdl vscode --update".cyan()
464 );
465 }
466 Ok(())
467 }
468 Err(e) => {
469 println!("{}", "✓ Successfully updated Rumdl VS Code extension!".green());
471 println!(
472 " {} {}",
473 "Note:".dimmed(),
474 format!("Could not verify version: {e}").dimmed()
475 );
476 Ok(())
477 }
478 }
479 } else {
480 let stderr = String::from_utf8_lossy(&output.stderr);
481
482 if stderr.contains("not found") && matches!(self.code_command.as_str(), "cursor" | "windsurf") {
484 println!();
485 println!(
486 "{}",
487 "The extension is not available in your editor's default marketplace.".yellow()
488 );
489 println!();
490 println!("To install from Open VSX:");
491 println!("1. Open {} (Cmd+Shift+X)", "Extensions".cyan());
492 println!("2. Search for {}", "'rumdl'".cyan());
493 println!("3. Click {} on the rumdl extension", "Install".green());
494 println!();
495 println!("Or download the VSIX manually:");
496 println!("1. Download from: {}", self.get_marketplace_url().cyan());
497 println!(
498 "2. Install with: {} --install-extension path/to/rumdl-{}.vsix",
499 self.code_command.cyan(),
500 latest_version.cyan()
501 );
502
503 Ok(()) } else {
505 Err(format!("Failed to update extension: {stderr}"))
506 }
507 }
508 }
509 Err(e) => {
510 println!("{}", "⚠ Unable to check for updates".yellow());
511 println!(" {}", e.dimmed());
512 println!();
513 println!("You can try forcing a reinstall with:");
514 println!(" {}", "rumdl vscode --force".cyan());
515 Ok(())
516 }
517 }
518 }
519}
520
521pub fn handle_vscode_command(force: bool, update: bool, status: bool) -> Result<(), String> {
522 let vscode = VsCodeExtension::new()?;
523
524 if status {
525 vscode.show_status()
526 } else if update {
527 vscode.update()
528 } else {
529 vscode.install(force)
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn test_extension_constants() {
539 assert_eq!(EXTENSION_ID, "rvben.rumdl");
540 assert_eq!(EXTENSION_NAME, "rumdl - Markdown Linter");
541 }
542
543 #[test]
544 fn test_vscode_extension_with_command() {
545 let result = VsCodeExtension::with_command("nonexistent-command-xyz");
547 assert!(result.is_err());
548 assert!(result.unwrap_err().contains("not found or not working"));
549
550 }
553
554 #[test]
555 fn test_command_exists() {
556 assert!(!VsCodeExtension::command_exists("nonexistent-command-xyz"));
558
559 }
563
564 #[test]
565 fn test_command_exists_cross_platform() {
566 assert!(!VsCodeExtension::command_exists("definitely-nonexistent-command-12345"));
571
572 let _result = VsCodeExtension::command_exists("code");
576 }
578
579 #[test]
580 fn test_find_all_editors() {
581 let editors = VsCodeExtension::find_all_editors();
584
585 assert!(editors.is_empty() || !editors.is_empty());
587
588 for (cmd, name) in &editors {
590 assert!(!cmd.is_empty());
591 assert!(!name.is_empty());
592 assert!(["code", "cursor", "windsurf", "codium", "vscodium"].contains(cmd));
593 assert!(["VS Code", "Cursor", "Windsurf", "VSCodium"].contains(name));
594 }
595 }
596
597 #[test]
598 fn test_current_editor_from_env() {
599 assert!(VsCodeExtension::current_editor_from_env_impl(None).is_none());
601
602 let vscode_result = VsCodeExtension::current_editor_from_env_impl(Some("vscode"));
604 if let Some((cmd, name)) = vscode_result {
606 assert_eq!(cmd, "code");
607 assert_eq!(name, "VS Code");
608 }
609
610 let cursor_result = VsCodeExtension::current_editor_from_env_impl(Some("cursor"));
612 if let Some((cmd, name)) = cursor_result {
614 assert_eq!(cmd, "cursor");
615 assert_eq!(name, "Cursor");
616 }
617
618 let windsurf_result = VsCodeExtension::current_editor_from_env_impl(Some("windsurf"));
620 if let Some((cmd, name)) = windsurf_result {
622 assert_eq!(cmd, "windsurf");
623 assert_eq!(name, "Windsurf");
624 }
625
626 assert!(VsCodeExtension::current_editor_from_env_impl(Some("unknown-editor")).is_none());
628
629 let mixed_case_result = VsCodeExtension::current_editor_from_env_impl(Some("VsCode"));
631 assert_eq!(
633 mixed_case_result,
634 VsCodeExtension::current_editor_from_env_impl(Some("vscode"))
635 );
636
637 assert!(VsCodeExtension::current_editor_from_env_impl(Some("")).is_none());
639 assert!(VsCodeExtension::current_editor_from_env_impl(Some(" ")).is_none());
640 assert!(
641 VsCodeExtension::current_editor_from_env_impl(Some("VSCODE")).is_some()
642 || !VsCodeExtension::command_exists("code")
643 );
644 }
645
646 #[test]
647 fn test_vscode_extension_struct() {
648 let ext = VsCodeExtension {
650 code_command: "test-command".to_string(),
651 };
652 assert_eq!(ext.code_command, "test-command");
653 }
654
655 #[test]
656 fn test_find_code_command_env_priority() {
657 let original_term = std::env::var("TERM_PROGRAM").ok();
659
660 unsafe {
661 std::env::set_var("TERM_PROGRAM", "vscode");
666 let _result = VsCodeExtension::new();
668 if let Some(term) = original_term {
672 std::env::set_var("TERM_PROGRAM", term);
673 } else {
674 std::env::remove_var("TERM_PROGRAM");
675 }
676 }
677 }
678
679 #[test]
680 fn test_error_messages() {
681 let result = VsCodeExtension::with_command("nonexistent");
683 assert!(result.is_err());
684 let err_msg = result.unwrap_err();
685 assert!(err_msg.contains("nonexistent"));
686 assert!(err_msg.contains("not found or not working"));
687 }
688
689 #[test]
690 fn test_handle_vscode_command_logic() {
691 let result = handle_vscode_command(false, false, true);
696 assert!(result.is_err() || result.is_ok());
698 }
699}