rumdl_lib/
vscode.rs

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    /// Create a VsCodeExtension with a specific command
19    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    /// Check if a command exists and works
30    fn command_exists(cmd: &str) -> bool {
31        // First, try to run the command directly with --version
32        // This is more reliable than using which/where
33        if let Ok(output) = Command::new(cmd).arg("--version").output()
34            && output.status.success()
35        {
36            return true;
37        }
38
39        // Fallback: use platform-appropriate command lookup
40        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        // First, check if we're in an integrated terminal
51        if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
52            let preferred_cmd = match term_program.to_lowercase().as_str() {
53                "vscode" => {
54                    // Check if we're actually in Cursor (which also sets TERM_PROGRAM=vscode)
55                    // by checking for Cursor-specific environment variables
56                    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                        // If only cursor exists, use it
60                        "cursor"
61                    } else {
62                        "code"
63                    }
64                }
65                "cursor" => "cursor",
66                "windsurf" => "windsurf",
67                _ => "",
68            };
69
70            // Verify the preferred command exists and works
71            if !preferred_cmd.is_empty() && Self::command_exists(preferred_cmd) {
72                return Ok(preferred_cmd.to_string());
73            }
74        }
75
76        // Fallback to finding the first available command
77        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    /// Find all available VS Code-compatible editors
92    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    /// Get the current editor from TERM_PROGRAM if available
108    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    /// Check if the editor uses Open VSX by default
140    fn uses_open_vsx(&self) -> bool {
141        // VSCodium and some other forks use Open VSX by default
142        matches!(self.code_command.as_str(), "codium" | "vscodium")
143    }
144
145    /// Get the marketplace URL for the current editor
146    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            // Get version information
160            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            // Try to check for updates
165            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                    // Don't show error if we can't check latest version
176                    // This is common for VS Code Marketplace
177                }
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        // For editors that use Open VSX, provide different instructions
190        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            // Try to get the installed version
214            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                // Provide marketplace-specific error message
223                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                // Extract version from format "rvben.rumdl@0.0.10"
273                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    /// Get the latest version from the marketplace
282    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            // Open VSX API - simple JSON endpoint
285            "https://open-vsx.org/api/rvben/rumdl".to_string()
286        } else {
287            // VS Code Marketplace API - requires POST request with specific query
288            // Using the official API endpoint
289            "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery".to_string()
290        };
291
292        let output = if api_url.contains("open-vsx.org") {
293            // Simple GET request for Open VSX
294            Command::new("curl")
295                .args(["-s", "-f", &api_url])
296                .output()
297                .map_err(|e| format!("Failed to query marketplace: {e}"))?
298        } else {
299            // POST request for VS Code Marketplace with query
300            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                // Parse Open VSX JSON response
332                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                // Parse VS Code Marketplace response
340                // Look for version in the complex JSON structure
341                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            // Try to check for updates
360            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                    // Don't show error if we can't check latest version
371                }
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    /// Update to the latest version
381    pub fn update(&self) -> Result<(), String> {
382        // Debug: show which command we're using
383        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        // Check for updates
394        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                // Install the update
405                println!();
406                println!("Updating to version {}...", latest_version.cyan());
407
408                // Try to install normally first, even for VS Code forks
409                // They might have Open VSX configured or other marketplace settings
410
411                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                    // Verify the update
420                    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                    // Check if it's a marketplace issue for VS Code forks
428                    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(()) // Don't treat as error, just provide instructions
449                    } 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        // Test with a command that should not exist
491        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        // Test with a command that might exist (but we can't guarantee it in all environments)
496        // This test is more about testing the logic than actual command existence
497    }
498
499    #[test]
500    fn test_command_exists() {
501        // Test that command_exists returns false for non-existent commands
502        assert!(!VsCodeExtension::command_exists("nonexistent-command-xyz"));
503
504        // Test with commands that are likely to exist on most systems
505        // Note: We can't guarantee these exist in all test environments
506        // The actual behavior depends on the system
507    }
508
509    #[test]
510    fn test_command_exists_cross_platform() {
511        // Test that the function handles the direct execution approach
512        // This tests our fix for Windows PATH detection
513
514        // Test with a command that definitely doesn't exist
515        assert!(!VsCodeExtension::command_exists("definitely-nonexistent-command-12345"));
516
517        // Test that it tries the direct approach first
518        // We can't test positive cases reliably in CI, but we can verify
519        // the function doesn't panic and follows expected logic
520        let _result = VsCodeExtension::command_exists("code");
521        // Result depends on system, but should not panic
522    }
523
524    #[test]
525    fn test_find_all_editors() {
526        // This test verifies the function runs without panicking
527        // The actual results depend on what's installed on the system
528        let editors = VsCodeExtension::find_all_editors();
529
530        // Verify the result is a valid vector
531        assert!(editors.is_empty() || !editors.is_empty());
532
533        // If any editors are found, verify they have valid names
534        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        // Save current TERM_PROGRAM if it exists
545        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            // Clear all environment variables that could affect the test
551            std::env::remove_var("TERM_PROGRAM");
552            std::env::remove_var("EDITOR");
553            std::env::remove_var("VISUAL");
554
555            // Test with no TERM_PROGRAM set
556            assert!(VsCodeExtension::current_editor_from_env().is_none());
557
558            // Test with VS Code TERM_PROGRAM (but command might not exist)
559            std::env::set_var("TERM_PROGRAM", "vscode");
560            let _result = VsCodeExtension::current_editor_from_env();
561            // Result depends on whether 'code' command exists
562
563            // Test with cursor TERM_PROGRAM
564            std::env::set_var("TERM_PROGRAM", "cursor");
565            let _cursor_result = VsCodeExtension::current_editor_from_env();
566            // Result depends on whether 'cursor' command exists
567
568            // Test with windsurf TERM_PROGRAM
569            std::env::set_var("TERM_PROGRAM", "windsurf");
570            let _windsurf_result = VsCodeExtension::current_editor_from_env();
571            // Result depends on whether 'windsurf' command exists
572
573            // Test with unknown TERM_PROGRAM
574            std::env::set_var("TERM_PROGRAM", "unknown-editor");
575            assert!(VsCodeExtension::current_editor_from_env().is_none());
576
577            // Test with mixed case (should work due to to_lowercase)
578            std::env::set_var("TERM_PROGRAM", "VsCode");
579            let _mixed_case_result = VsCodeExtension::current_editor_from_env();
580            // Result should be same as lowercase version
581
582            // Restore original environment variables
583            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        // Test that we can create the struct with a custom command
600        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        // Save current TERM_PROGRAM if it exists
609        let original_term = std::env::var("TERM_PROGRAM").ok();
610
611        unsafe {
612            // The find_code_command method is private, but we can test it indirectly
613            // through VsCodeExtension::new() behavior
614
615            // Test that TERM_PROGRAM affects command selection
616            std::env::set_var("TERM_PROGRAM", "vscode");
617            // Creating new extension will use find_code_command internally
618            let _result = VsCodeExtension::new();
619            // Result depends on system configuration
620
621            // Restore original TERM_PROGRAM
622            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        // Test error message format when command doesn't exist
633        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        // We can't fully test this without mocking Command execution,
643        // but we can verify it doesn't panic with invalid inputs
644
645        // This will fail to find a VS Code command in most test environments
646        let result = handle_vscode_command(false, false, true);
647        // Should return an error about VS Code not being found
648        assert!(result.is_err() || result.is_ok());
649    }
650}