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, returning the working command name
30    fn find_working_command(cmd: &str) -> Option<String> {
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 Some(cmd.to_string());
37        }
38
39        // On Windows (including Git Bash), try with .cmd extension
40        // Git Bash requires the .cmd extension for batch files
41        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    /// Check if a command exists and works
54    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        // First, check if we're in an integrated terminal
60        if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
61            let preferred_cmd = match term_program.to_lowercase().as_str() {
62                "vscode" => {
63                    // Check if we're actually in Cursor (which also sets TERM_PROGRAM=vscode)
64                    // by checking for Cursor-specific environment variables
65                    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                        // If only cursor exists, use it
69                        "cursor"
70                    } else {
71                        "code"
72                    }
73                }
74                "cursor" => "cursor",
75                "windsurf" => "windsurf",
76                _ => "",
77            };
78
79            // Verify the preferred command exists and works, return the working version
80            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        // Fallback to finding the first available command
88        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    /// Find all available VS Code-compatible editors
103    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    /// Get the current editor from TERM_PROGRAM if available
119    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    /// Check if the editor uses Open VSX by default
151    fn uses_open_vsx(&self) -> bool {
152        // VSCodium and some other forks use Open VSX by default
153        matches!(self.code_command.as_str(), "codium" | "vscodium")
154    }
155
156    /// Get the marketplace URL for the current editor
157    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            // Get version information
171            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            // Try to check for updates
176            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                    // Don't show error if we can't check latest version
187                    // This is common for VS Code Marketplace
188                }
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        // For editors that use Open VSX, provide different instructions
201        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            // Try to get the installed version
225            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                // Provide marketplace-specific error message
234                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                // Extract version from format "rvben.rumdl@0.0.10"
284                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    /// Get the latest version from the marketplace
293    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            // Open VSX API - simple JSON endpoint
296            "https://open-vsx.org/api/rvben/rumdl".to_string()
297        } else {
298            // VS Code Marketplace API - requires POST request with specific query
299            // Using the official API endpoint
300            "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery".to_string()
301        };
302
303        let output = if api_url.contains("open-vsx.org") {
304            // Simple GET request for Open VSX
305            Command::new("curl")
306                .args(["-s", "-f", &api_url])
307                .output()
308                .map_err(|e| format!("Failed to query marketplace: {e}"))?
309        } else {
310            // POST request for VS Code Marketplace with query
311            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                // Parse Open VSX JSON response
343                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                // Parse VS Code Marketplace response
351                // Look for version in the complex JSON structure
352                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            // Try to check for updates
371            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                    // Don't show error if we can't check latest version
382                }
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    /// Update to the latest version
392    pub fn update(&self) -> Result<(), String> {
393        // Debug: show which command we're using
394        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        // Check for updates
405        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                // Install the update
416                println!();
417                println!("Updating to version {}...", latest_version.cyan());
418
419                // Try to install normally first, even for VS Code forks
420                // They might have Open VSX configured or other marketplace settings
421
422                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                    // Check if it's a marketplace issue for VS Code forks
435                    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(()) // Don't treat as error, just provide instructions
456                    } 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        // Test with a command that should not exist
498        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        // Test with a command that might exist (but we can't guarantee it in all environments)
503        // This test is more about testing the logic than actual command existence
504    }
505
506    #[test]
507    fn test_command_exists() {
508        // Test that command_exists returns false for non-existent commands
509        assert!(!VsCodeExtension::command_exists("nonexistent-command-xyz"));
510
511        // Test with commands that are likely to exist on most systems
512        // Note: We can't guarantee these exist in all test environments
513        // The actual behavior depends on the system
514    }
515
516    #[test]
517    fn test_command_exists_cross_platform() {
518        // Test that the function handles the direct execution approach
519        // This tests our fix for Windows PATH detection
520
521        // Test with a command that definitely doesn't exist
522        assert!(!VsCodeExtension::command_exists("definitely-nonexistent-command-12345"));
523
524        // Test that it tries the direct approach first
525        // We can't test positive cases reliably in CI, but we can verify
526        // the function doesn't panic and follows expected logic
527        let _result = VsCodeExtension::command_exists("code");
528        // Result depends on system, but should not panic
529    }
530
531    #[test]
532    fn test_find_all_editors() {
533        // This test verifies the function runs without panicking
534        // The actual results depend on what's installed on the system
535        let editors = VsCodeExtension::find_all_editors();
536
537        // Verify the result is a valid vector
538        assert!(editors.is_empty() || !editors.is_empty());
539
540        // If any editors are found, verify they have valid names
541        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        // Save current TERM_PROGRAM if it exists
552        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            // Clear all environment variables that could affect the test
558            std::env::remove_var("TERM_PROGRAM");
559            std::env::remove_var("EDITOR");
560            std::env::remove_var("VISUAL");
561
562            // Test with no TERM_PROGRAM set
563            assert!(VsCodeExtension::current_editor_from_env().is_none());
564
565            // Test with VS Code TERM_PROGRAM (but command might not exist)
566            std::env::set_var("TERM_PROGRAM", "vscode");
567            let _result = VsCodeExtension::current_editor_from_env();
568            // Result depends on whether 'code' command exists
569
570            // Test with cursor TERM_PROGRAM
571            std::env::set_var("TERM_PROGRAM", "cursor");
572            let _cursor_result = VsCodeExtension::current_editor_from_env();
573            // Result depends on whether 'cursor' command exists
574
575            // Test with windsurf TERM_PROGRAM
576            std::env::set_var("TERM_PROGRAM", "windsurf");
577            let _windsurf_result = VsCodeExtension::current_editor_from_env();
578            // Result depends on whether 'windsurf' command exists
579
580            // Test with unknown TERM_PROGRAM
581            std::env::set_var("TERM_PROGRAM", "unknown-editor");
582            assert!(VsCodeExtension::current_editor_from_env().is_none());
583
584            // Test with mixed case (should work due to to_lowercase)
585            std::env::set_var("TERM_PROGRAM", "VsCode");
586            let _mixed_case_result = VsCodeExtension::current_editor_from_env();
587            // Result should be same as lowercase version
588
589            // Restore original environment variables
590            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        // Test that we can create the struct with a custom command
607        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        // Save current TERM_PROGRAM if it exists
616        let original_term = std::env::var("TERM_PROGRAM").ok();
617
618        unsafe {
619            // The find_code_command method is private, but we can test it indirectly
620            // through VsCodeExtension::new() behavior
621
622            // Test that TERM_PROGRAM affects command selection
623            std::env::set_var("TERM_PROGRAM", "vscode");
624            // Creating new extension will use find_code_command internally
625            let _result = VsCodeExtension::new();
626            // Result depends on system configuration
627
628            // Restore original TERM_PROGRAM
629            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        // Test error message format when command doesn't exist
640        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        // We can't fully test this without mocking Command execution,
650        // but we can verify it doesn't panic with invalid inputs
651
652        // This will fail to find a VS Code command in most test environments
653        let result = handle_vscode_command(false, false, true);
654        // Should return an error about VS Code not being found
655        assert!(result.is_err() || result.is_ok());
656    }
657}