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    /// Internal implementation that accepts a command checker for testing
59    #[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        // First, check if we're in an integrated terminal
65        if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
66            let preferred_cmd = match term_program.to_lowercase().as_str() {
67                "vscode" => {
68                    // Check if we're actually in Cursor (which also sets TERM_PROGRAM=vscode)
69                    // by checking for Cursor-specific environment variables
70                    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                        // If only cursor exists, use it
74                        "cursor"
75                    } else {
76                        "code"
77                    }
78                }
79                "cursor" => "cursor",
80                "windsurf" => "windsurf",
81                _ => "",
82            };
83
84            // Verify the preferred command exists
85            if !preferred_cmd.is_empty() && command_checker(preferred_cmd) {
86                return Ok(preferred_cmd.to_string());
87            }
88        }
89
90        // Fallback to finding the first available command
91        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    /// Internal implementation that accepts a command checker for testing
110    #[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    /// Find all available VS Code-compatible editors
127    pub fn find_all_editors() -> Vec<(&'static str, &'static str)> {
128        Self::find_all_editors_impl(Self::command_exists)
129    }
130
131    /// Get the current editor from TERM_PROGRAM if available
132    /// Internal implementation that accepts environment as parameters for testing
133    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    /// Check if the editor uses Open VSX by default
169    fn uses_open_vsx(&self) -> bool {
170        // VSCodium and some other forks use Open VSX by default
171        matches!(self.code_command.as_str(), "codium" | "vscodium")
172    }
173
174    /// Get the marketplace URL for the current editor
175    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            // Get version information
189            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            // Try to check for updates
194            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                    // Don't show error if we can't check latest version
205                    // This is common for VS Code Marketplace
206                }
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        // For editors that use Open VSX, provide different instructions
219        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            // Try to get the installed version
243            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                // Provide marketplace-specific error message
252                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                // Extract version from format "rvben.rumdl@0.0.10"
302                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    /// Get the latest version from the marketplace
311    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            // Open VSX API - simple JSON endpoint
314            "https://open-vsx.org/api/rvben/rumdl".to_string()
315        } else {
316            // VS Code Marketplace API - requires POST request with specific query
317            // Using the official API endpoint
318            "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery".to_string()
319        };
320
321        let output = if api_url.contains("open-vsx.org") {
322            // Simple GET request for Open VSX
323            Command::new("curl")
324                .args(["-s", "-f", &api_url])
325                .output()
326                .map_err(|e| format!("Failed to query marketplace: {e}"))?
327        } else {
328            // POST request for VS Code Marketplace with query
329            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                // Parse Open VSX JSON response
361                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                // Parse VS Code Marketplace response
369                // Look for version in the complex JSON structure
370                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            // Try to check for updates
389            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                    // Don't show error if we can't check latest version
400                }
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    /// Update to the latest version
410    pub fn update(&self) -> Result<(), String> {
411        // Debug: show which command we're using
412        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        // Check for updates
423        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                // Install the update
434                println!();
435                println!("Updating to version {}...", latest_version.cyan());
436
437                // Try to install normally first, even for VS Code forks
438                // They might have Open VSX configured or other marketplace settings
439
440                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                    // Verify the actual installed version after update
447                    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                            // Warn if the update didn't reach the latest version
453                            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                            // Update succeeded but we can't verify the version
470                            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                    // Check if it's a marketplace issue for VS Code forks
483                    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(()) // Don't treat as error, just provide instructions
504                    } 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        // Test with a command that should not exist
546        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        // Test with a command that might exist (but we can't guarantee it in all environments)
551        // This test is more about testing the logic than actual command existence
552    }
553
554    #[test]
555    fn test_command_exists() {
556        // Test that command_exists returns false for non-existent commands
557        assert!(!VsCodeExtension::command_exists("nonexistent-command-xyz"));
558
559        // Test with commands that are likely to exist on most systems
560        // Note: We can't guarantee these exist in all test environments
561        // The actual behavior depends on the system
562    }
563
564    #[test]
565    fn test_command_exists_cross_platform() {
566        // Test that the function handles the direct execution approach
567        // This tests our fix for Windows PATH detection
568
569        // Test with a command that definitely doesn't exist
570        assert!(!VsCodeExtension::command_exists("definitely-nonexistent-command-12345"));
571
572        // Test that it tries the direct approach first
573        // We can't test positive cases reliably in CI, but we can verify
574        // the function doesn't panic and follows expected logic
575        let _result = VsCodeExtension::command_exists("code");
576        // Result depends on system, but should not panic
577    }
578
579    #[test]
580    fn test_find_all_editors() {
581        // This test verifies the function runs without panicking
582        // The actual results depend on what's installed on the system
583        let editors = VsCodeExtension::find_all_editors();
584
585        // Verify the result is a valid vector
586        assert!(editors.is_empty() || !editors.is_empty());
587
588        // If any editors are found, verify they have valid names
589        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        // Test with no TERM_PROGRAM set
600        assert!(VsCodeExtension::current_editor_from_env_impl(None).is_none());
601
602        // Test with VS Code TERM_PROGRAM (but command might not exist)
603        let vscode_result = VsCodeExtension::current_editor_from_env_impl(Some("vscode"));
604        // Result depends on whether 'code' command exists
605        if let Some((cmd, name)) = vscode_result {
606            assert_eq!(cmd, "code");
607            assert_eq!(name, "VS Code");
608        }
609
610        // Test with cursor TERM_PROGRAM
611        let cursor_result = VsCodeExtension::current_editor_from_env_impl(Some("cursor"));
612        // Result depends on whether 'cursor' command exists
613        if let Some((cmd, name)) = cursor_result {
614            assert_eq!(cmd, "cursor");
615            assert_eq!(name, "Cursor");
616        }
617
618        // Test with windsurf TERM_PROGRAM
619        let windsurf_result = VsCodeExtension::current_editor_from_env_impl(Some("windsurf"));
620        // Result depends on whether 'windsurf' command exists
621        if let Some((cmd, name)) = windsurf_result {
622            assert_eq!(cmd, "windsurf");
623            assert_eq!(name, "Windsurf");
624        }
625
626        // Test with unknown TERM_PROGRAM - should always return None
627        assert!(VsCodeExtension::current_editor_from_env_impl(Some("unknown-editor")).is_none());
628
629        // Test with mixed case (should work due to to_lowercase)
630        let mixed_case_result = VsCodeExtension::current_editor_from_env_impl(Some("VsCode"));
631        // Should behave the same as lowercase version
632        assert_eq!(
633            mixed_case_result,
634            VsCodeExtension::current_editor_from_env_impl(Some("vscode"))
635        );
636
637        // Test edge cases
638        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        // Test that we can create the struct with a custom command
649        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        // Save current TERM_PROGRAM if it exists
658        let original_term = std::env::var("TERM_PROGRAM").ok();
659
660        unsafe {
661            // The find_code_command method is private, but we can test it indirectly
662            // through VsCodeExtension::new() behavior
663
664            // Test that TERM_PROGRAM affects command selection
665            std::env::set_var("TERM_PROGRAM", "vscode");
666            // Creating new extension will use find_code_command internally
667            let _result = VsCodeExtension::new();
668            // Result depends on system configuration
669
670            // Restore original TERM_PROGRAM
671            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        // Test error message format when command doesn't exist
682        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        // We can't fully test this without mocking Command execution,
692        // but we can verify it doesn't panic with invalid inputs
693
694        // This will fail to find a VS Code command in most test environments
695        let result = handle_vscode_command(false, false, true);
696        // Should return an error about VS Code not being found
697        assert!(result.is_err() || result.is_ok());
698    }
699}