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
430                    // Verify the update
431                    if let Ok(new_version) = self.get_installed_version() {
432                        println!("  New version: {}", new_version.cyan());
433                    }
434                    Ok(())
435                } else {
436                    let stderr = String::from_utf8_lossy(&output.stderr);
437
438                    // Check if it's a marketplace issue for VS Code forks
439                    if stderr.contains("not found") && matches!(self.code_command.as_str(), "cursor" | "windsurf") {
440                        println!();
441                        println!(
442                            "{}",
443                            "The extension is not available in your editor's default marketplace.".yellow()
444                        );
445                        println!();
446                        println!("To install from Open VSX:");
447                        println!("1. Open {} (Cmd+Shift+X)", "Extensions".cyan());
448                        println!("2. Search for {}", "'rumdl'".cyan());
449                        println!("3. Click {} on the rumdl extension", "Install".green());
450                        println!();
451                        println!("Or download the VSIX manually:");
452                        println!("1. Download from: {}", self.get_marketplace_url().cyan());
453                        println!(
454                            "2. Install with: {} --install-extension path/to/rumdl-{}.vsix",
455                            self.code_command.cyan(),
456                            latest_version.cyan()
457                        );
458
459                        Ok(()) // Don't treat as error, just provide instructions
460                    } else {
461                        Err(format!("Failed to update extension: {stderr}"))
462                    }
463                }
464            }
465            Err(e) => {
466                println!("{}", "⚠ Unable to check for updates".yellow());
467                println!("  {}", e.dimmed());
468                println!();
469                println!("You can try forcing a reinstall with:");
470                println!("  {}", "rumdl vscode --force".cyan());
471                Ok(())
472            }
473        }
474    }
475}
476
477pub fn handle_vscode_command(force: bool, update: bool, status: bool) -> Result<(), String> {
478    let vscode = VsCodeExtension::new()?;
479
480    if status {
481        vscode.show_status()
482    } else if update {
483        vscode.update()
484    } else {
485        vscode.install(force)
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn test_extension_constants() {
495        assert_eq!(EXTENSION_ID, "rvben.rumdl");
496        assert_eq!(EXTENSION_NAME, "rumdl - Markdown Linter");
497    }
498
499    #[test]
500    fn test_vscode_extension_with_command() {
501        // Test with a command that should not exist
502        let result = VsCodeExtension::with_command("nonexistent-command-xyz");
503        assert!(result.is_err());
504        assert!(result.unwrap_err().contains("not found or not working"));
505
506        // Test with a command that might exist (but we can't guarantee it in all environments)
507        // This test is more about testing the logic than actual command existence
508    }
509
510    #[test]
511    fn test_command_exists() {
512        // Test that command_exists returns false for non-existent commands
513        assert!(!VsCodeExtension::command_exists("nonexistent-command-xyz"));
514
515        // Test with commands that are likely to exist on most systems
516        // Note: We can't guarantee these exist in all test environments
517        // The actual behavior depends on the system
518    }
519
520    #[test]
521    fn test_command_exists_cross_platform() {
522        // Test that the function handles the direct execution approach
523        // This tests our fix for Windows PATH detection
524
525        // Test with a command that definitely doesn't exist
526        assert!(!VsCodeExtension::command_exists("definitely-nonexistent-command-12345"));
527
528        // Test that it tries the direct approach first
529        // We can't test positive cases reliably in CI, but we can verify
530        // the function doesn't panic and follows expected logic
531        let _result = VsCodeExtension::command_exists("code");
532        // Result depends on system, but should not panic
533    }
534
535    #[test]
536    fn test_find_all_editors() {
537        // This test verifies the function runs without panicking
538        // The actual results depend on what's installed on the system
539        let editors = VsCodeExtension::find_all_editors();
540
541        // Verify the result is a valid vector
542        assert!(editors.is_empty() || !editors.is_empty());
543
544        // If any editors are found, verify they have valid names
545        for (cmd, name) in &editors {
546            assert!(!cmd.is_empty());
547            assert!(!name.is_empty());
548            assert!(["code", "cursor", "windsurf", "codium", "vscodium"].contains(cmd));
549            assert!(["VS Code", "Cursor", "Windsurf", "VSCodium"].contains(name));
550        }
551    }
552
553    #[test]
554    fn test_current_editor_from_env() {
555        // Save current TERM_PROGRAM if it exists
556        let original_term = std::env::var("TERM_PROGRAM").ok();
557        let original_editor = std::env::var("EDITOR").ok();
558        let original_visual = std::env::var("VISUAL").ok();
559
560        unsafe {
561            // Clear all environment variables that could affect the test
562            std::env::remove_var("TERM_PROGRAM");
563            std::env::remove_var("EDITOR");
564            std::env::remove_var("VISUAL");
565
566            // Test with no TERM_PROGRAM set
567            assert!(VsCodeExtension::current_editor_from_env().is_none());
568
569            // Test with VS Code TERM_PROGRAM (but command might not exist)
570            std::env::set_var("TERM_PROGRAM", "vscode");
571            let _result = VsCodeExtension::current_editor_from_env();
572            // Result depends on whether 'code' command exists
573
574            // Test with cursor TERM_PROGRAM
575            std::env::set_var("TERM_PROGRAM", "cursor");
576            let _cursor_result = VsCodeExtension::current_editor_from_env();
577            // Result depends on whether 'cursor' command exists
578
579            // Test with windsurf TERM_PROGRAM
580            std::env::set_var("TERM_PROGRAM", "windsurf");
581            let _windsurf_result = VsCodeExtension::current_editor_from_env();
582            // Result depends on whether 'windsurf' command exists
583
584            // Test with unknown TERM_PROGRAM
585            std::env::set_var("TERM_PROGRAM", "unknown-editor");
586            assert!(VsCodeExtension::current_editor_from_env().is_none());
587
588            // Test with mixed case (should work due to to_lowercase)
589            std::env::set_var("TERM_PROGRAM", "VsCode");
590            let _mixed_case_result = VsCodeExtension::current_editor_from_env();
591            // Result should be same as lowercase version
592
593            // Restore original environment variables
594            if let Some(term) = original_term {
595                std::env::set_var("TERM_PROGRAM", term);
596            } else {
597                std::env::remove_var("TERM_PROGRAM");
598            }
599            if let Some(editor) = original_editor {
600                std::env::set_var("EDITOR", editor);
601            }
602            if let Some(visual) = original_visual {
603                std::env::set_var("VISUAL", visual);
604            }
605        }
606    }
607
608    #[test]
609    fn test_vscode_extension_struct() {
610        // Test that we can create the struct with a custom command
611        let ext = VsCodeExtension {
612            code_command: "test-command".to_string(),
613        };
614        assert_eq!(ext.code_command, "test-command");
615    }
616
617    #[test]
618    fn test_find_code_command_env_priority() {
619        // Save current TERM_PROGRAM if it exists
620        let original_term = std::env::var("TERM_PROGRAM").ok();
621
622        unsafe {
623            // The find_code_command method is private, but we can test it indirectly
624            // through VsCodeExtension::new() behavior
625
626            // Test that TERM_PROGRAM affects command selection
627            std::env::set_var("TERM_PROGRAM", "vscode");
628            // Creating new extension will use find_code_command internally
629            let _result = VsCodeExtension::new();
630            // Result depends on system configuration
631
632            // Restore original TERM_PROGRAM
633            if let Some(term) = original_term {
634                std::env::set_var("TERM_PROGRAM", term);
635            } else {
636                std::env::remove_var("TERM_PROGRAM");
637            }
638        }
639    }
640
641    #[test]
642    fn test_error_messages() {
643        // Test error message format when command doesn't exist
644        let result = VsCodeExtension::with_command("nonexistent");
645        assert!(result.is_err());
646        let err_msg = result.unwrap_err();
647        assert!(err_msg.contains("nonexistent"));
648        assert!(err_msg.contains("not found or not working"));
649    }
650
651    #[test]
652    fn test_handle_vscode_command_logic() {
653        // We can't fully test this without mocking Command execution,
654        // but we can verify it doesn't panic with invalid inputs
655
656        // This will fail to find a VS Code command in most test environments
657        let result = handle_vscode_command(false, false, true);
658        // Should return an error about VS Code not being found
659        assert!(result.is_err() || result.is_ok());
660    }
661}