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