Skip to main content

stakpak_shared/
remote_connection.rs

1use crate::utils::{DirectoryEntry, FileSystemProvider};
2use anyhow::{Result, anyhow};
3use async_trait::async_trait;
4use russh::client::{self, Handler};
5use russh_sftp::client::SftpSession;
6use serde::{Deserialize, Serialize};
7use std::{
8    collections::HashMap,
9    fmt::{self, Display},
10    fs,
11    path::PathBuf,
12    sync::Arc,
13    time::Duration,
14};
15use tokio::io::AsyncWriteExt;
16use tokio::sync::RwLock;
17use tracing::debug;
18use uuid;
19
20#[derive(Debug)]
21struct ParsedConnection {
22    username: String,
23    hostname: String,
24    port: u16,
25}
26
27pub struct CommandOptions {
28    pub timeout: Option<Duration>,
29    pub with_progress: bool,
30    pub simple: bool,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RemoteConnectionInfo {
35    pub connection_string: String, // format: user@host:port
36    pub password: Option<String>,
37    pub private_key_path: Option<String>,
38}
39
40impl RemoteConnectionInfo {
41    fn parse_connection_string(&self) -> Result<ParsedConnection> {
42        let (username, host_port) = self.connection_string.split_once('@').ok_or_else(|| {
43            anyhow!("Invalid connection string format. Expected: user@host or user@host:port")
44        })?;
45
46        let (hostname, port) = if let Some((host, port_str)) = host_port.split_once(':') {
47            let port = port_str
48                .parse::<u16>()
49                .map_err(|_| anyhow!("Invalid port number: {}", port_str))?;
50            (host, port)
51        } else {
52            (host_port, 22)
53        };
54
55        Ok(ParsedConnection {
56            username: username.to_string(),
57            hostname: hostname.to_string(),
58            port,
59        })
60    }
61}
62
63pub struct SSHClient;
64
65impl Handler for SSHClient {
66    type Error = russh::Error;
67
68    async fn check_server_key(
69        &mut self,
70        _server_public_key: &russh::keys::PublicKey,
71    ) -> Result<bool, Self::Error> {
72        // In production, you might want to verify the server key against known hosts
73        // For now, we accept all keys to avoid "Unknown server key" errors
74        Ok(true)
75    }
76}
77
78pub struct RemoteConnection {
79    sftp: SftpSession,
80    connection_info: RemoteConnectionInfo,
81}
82
83impl RemoteConnection {
84    fn map_ssh_error(error: russh::Error, context: &str) -> anyhow::Error {
85        anyhow!("SSH {}: {}", context, error)
86    }
87
88    fn map_auth_error(result: russh::client::AuthResult, method: &str) -> Result<()> {
89        match result {
90            russh::client::AuthResult::Success => Ok(()),
91            _ => Err(anyhow!("{} authentication failed", method)),
92        }
93    }
94
95    async fn create_authenticated_session_static(
96        connection_info: &RemoteConnectionInfo,
97    ) -> Result<client::Handle<SSHClient>> {
98        let parsed = connection_info.parse_connection_string()?;
99
100        debug!(
101            "Connecting to {}@{}:{}",
102            parsed.username, parsed.hostname, parsed.port
103        );
104
105        let config = client::Config::default();
106        let mut session = client::connect(
107            config.into(),
108            (parsed.hostname.as_str(), parsed.port),
109            SSHClient {},
110        )
111        .await
112        .map_err(|e| Self::map_ssh_error(e, "connection failed"))?;
113
114        Self::authenticate_session_static(&mut session, &parsed.username, connection_info).await?;
115        Ok(session)
116    }
117
118    async fn authenticate_session_static(
119        session: &mut client::Handle<SSHClient>,
120        username: &str,
121        connection_info: &RemoteConnectionInfo,
122    ) -> Result<()> {
123        if let Some(password) = &connection_info.password {
124            debug!("Authenticating with password");
125            let auth_result = session
126                .authenticate_password(username, password)
127                .await
128                .map_err(|e| Self::map_ssh_error(e, "password authentication"))?;
129            Self::map_auth_error(auth_result, "Password")?;
130        } else {
131            debug!("Authenticating with public key");
132            let private_key_path = if let Some(path) = &connection_info.private_key_path {
133                Self::canonicalize_key_path(path)?
134            } else {
135                Self::get_default_key_files()?.0
136            };
137
138            let keypair = russh::keys::load_secret_key(&private_key_path, None).map_err(|e| {
139                anyhow!(
140                    "Failed to load private key from {}: {}",
141                    private_key_path.display(),
142                    e
143                )
144            })?;
145
146            let auth_result = session
147                .authenticate_publickey(
148                    username,
149                    russh::keys::PrivateKeyWithHashAlg::new(
150                        Arc::new(keypair),
151                        Some(russh::keys::HashAlg::Sha256),
152                    ),
153                )
154                .await
155                .map_err(|e| Self::map_ssh_error(e, "public key authentication"))?;
156            Self::map_auth_error(auth_result, "Public key")?;
157        }
158        Ok(())
159    }
160
161    pub fn get_default_key_files() -> Result<(PathBuf, PathBuf)> {
162        let home_dir = dirs::home_dir().ok_or_else(|| anyhow!("Home directory not found"))?;
163        let ssh_dir = home_dir.join(".ssh");
164
165        if !ssh_dir.is_dir() {
166            return Err(anyhow!("SSH directory not found: {}", ssh_dir.display()));
167        }
168
169        // Try common key file names in order of preference
170        let key_names = ["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];
171
172        for key_name in &key_names {
173            let private_key = ssh_dir.join(key_name);
174            let public_key = ssh_dir.join(format!("{}.pub", key_name));
175
176            if private_key.is_file() {
177                return Ok((private_key, public_key));
178            }
179        }
180
181        Err(anyhow!("No SSH private key found in {}", ssh_dir.display()))
182    }
183
184    /// Canonicalize a key path, handling both absolute and relative paths
185    pub fn canonicalize_key_path(path: &str) -> Result<PathBuf> {
186        let path_buf = PathBuf::from(path);
187
188        // If it's already absolute, try to canonicalize directly
189        if path_buf.is_absolute() {
190            return fs::canonicalize(&path_buf)
191                .map_err(|e| anyhow!("Failed to access private key at {}: {}", path, e));
192        }
193
194        // For relative paths, try current directory first
195        if let Ok(canonical) = fs::canonicalize(&path_buf) {
196            return Ok(canonical);
197        }
198
199        // If that fails, try relative to ~/.ssh/
200        let home_dir = dirs::home_dir()
201            .ok_or_else(|| anyhow!("Home directory not found for relative key path"))?;
202        let ssh_relative_path = home_dir.join(".ssh").join(&path_buf);
203
204        if ssh_relative_path.exists() {
205            return fs::canonicalize(ssh_relative_path)
206                .map_err(|e| anyhow!("Failed to access private key at ~/.ssh/{}: {}", path, e));
207        }
208
209        // If still not found, try to expand ~ manually
210        if let Some(stripped) = path.strip_prefix("~/") {
211            let expanded_path = home_dir.join(stripped);
212            return fs::canonicalize(expanded_path)
213                .map_err(|e| anyhow!("Failed to access private key at {}: {}", path, e));
214        }
215
216        Err(anyhow!(
217            "Private key not found at {} (tried current directory and ~/.ssh/)",
218            path
219        ))
220    }
221
222    pub async fn new(connection_info: RemoteConnectionInfo) -> Result<Self> {
223        let session = Self::create_authenticated_session_static(&connection_info).await?;
224
225        // Open SFTP channel
226        let channel = session
227            .channel_open_session()
228            .await
229            .map_err(|e| Self::map_ssh_error(e, "failed to open SSH channel"))?;
230
231        channel
232            .request_subsystem(true, "sftp")
233            .await
234            .map_err(|e| Self::map_ssh_error(e, "failed to request SFTP subsystem"))?;
235
236        let sftp = SftpSession::new(channel.into_stream())
237            .await
238            .map_err(|e| anyhow!("Failed to create SFTP session: {}", e))?;
239
240        debug!("SFTP connection established successfully");
241
242        Ok(Self {
243            sftp,
244            connection_info,
245        })
246    }
247
248    pub async fn separator(&self) -> Result<char> {
249        // Try to determine the path separator by canonicalizing root
250        let canonical_path = self.sftp.canonicalize("/").await?;
251        Ok(if canonical_path.contains('\\') {
252            '\\'
253        } else {
254            '/'
255        })
256    }
257
258    pub async fn canonicalize(&self, path: &str) -> Result<String> {
259        self.sftp
260            .canonicalize(path)
261            .await
262            .map_err(|e| anyhow!("Failed to canonicalize path {}: {}", path, e))
263    }
264
265    /// Get the SSH connection string in the format user@host: or user@host#port:
266    /// Uses # as port separator to distinguish from path separators in SSH URLs
267    pub fn get_ssh_prefix(&self) -> Result<String> {
268        let parsed = self.connection_info.parse_connection_string()?;
269        if parsed.port == 22 {
270            Ok(format!("{}@{}:", parsed.username, parsed.hostname))
271        } else {
272            Ok(format!(
273                "{}@{}#{}:",
274                parsed.username, parsed.hostname, parsed.port
275            ))
276        }
277    }
278
279    pub async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
280        self.sftp
281            .read(path)
282            .await
283            .map_err(|e| anyhow!("Failed to read file {}: {}", path, e))
284    }
285
286    pub async fn read_file_to_string(&self, path: &str) -> Result<String> {
287        let content = self.read_file(path).await?;
288        String::from_utf8(content)
289            .map_err(|e| anyhow!("File {} contains invalid UTF-8: {}", path, e))
290    }
291
292    pub async fn write_file(&self, path: &str, data: &[u8]) -> Result<()> {
293        self.sftp
294            .write(path, data)
295            .await
296            .map_err(|e| anyhow!("Failed to write file {}: {}", path, e))
297    }
298
299    pub async fn create_file(&self, path: &str, data: &[u8]) -> Result<()> {
300        // Create the file and get a handle
301        let mut file_handle = self
302            .sftp
303            .create(path)
304            .await
305            .map_err(|e| anyhow!("Failed to create file {}: {}", path, e))?;
306
307        // Write data to the file handle
308        file_handle
309            .write_all(data)
310            .await
311            .map_err(|e| anyhow!("Failed to write data to file {}: {}", path, e))?;
312
313        // File handle is automatically closed when dropped
314        Ok(())
315    }
316
317    pub async fn create_directories(&self, path: &str) -> Result<()> {
318        let path_buf = PathBuf::from(path);
319        let mut current_path = PathBuf::new();
320
321        for component in path_buf.components() {
322            current_path.push(component);
323            let path_str = current_path.to_string_lossy().to_string();
324
325            if self.sftp.read_dir(&path_str).await.is_err() {
326                self.sftp
327                    .create_dir(&path_str)
328                    .await
329                    .map_err(|e| anyhow!("Failed to create directory {}: {}", path_str, e))?;
330            }
331        }
332
333        Ok(())
334    }
335
336    pub async fn list_directory(&self, path: &str) -> Result<Vec<String>> {
337        let entries = self
338            .sftp
339            .read_dir(path)
340            .await
341            .map_err(|e| anyhow!("Failed to read directory {}: {}", path, e))?;
342
343        let separator = self.separator().await?;
344        let mut result = Vec::new();
345
346        for entry in entries {
347            let entry_path = if path.ends_with(separator) {
348                format!("{}{}", path, entry.file_name())
349            } else {
350                format!("{}{}{}", path, separator, entry.file_name())
351            };
352            result.push(entry_path);
353        }
354
355        result.sort();
356        Ok(result)
357    }
358
359    /// List directory with file type information (more efficient for tree generation)
360    pub async fn list_directory_with_types(&self, path: &str) -> Result<Vec<(String, bool)>> {
361        let entries = self
362            .sftp
363            .read_dir(path)
364            .await
365            .map_err(|e| anyhow!("Failed to read directory {}: {}", path, e))?;
366
367        let separator = self.separator().await?;
368        let mut result = Vec::new();
369
370        for entry in entries {
371            let entry_path = if path.ends_with(separator) {
372                format!("{}{}", path, entry.file_name())
373            } else {
374                format!("{}{}{}", path, separator, entry.file_name())
375            };
376            let is_directory = entry.metadata().is_dir();
377            result.push((entry_path, is_directory));
378        }
379
380        result.sort_by(|a, b| a.0.cmp(&b.0));
381        Ok(result)
382    }
383
384    pub async fn is_file(&self, path: &str) -> bool {
385        self.sftp
386            .metadata(path)
387            .await
388            .map(|metadata| !metadata.is_dir())
389            .unwrap_or(false)
390    }
391
392    pub async fn is_directory(&self, path: &str) -> bool {
393        self.sftp
394            .metadata(path)
395            .await
396            .map(|metadata| metadata.is_dir())
397            .unwrap_or(false)
398    }
399
400    pub async fn exists(&self, path: &str) -> bool {
401        self.sftp.metadata(path).await.is_ok()
402    }
403
404    pub async fn file_size(&self, path: &str) -> Result<u64> {
405        let metadata = self
406            .sftp
407            .metadata(path)
408            .await
409            .map_err(|e| anyhow!("Failed to get metadata for {}: {}", path, e))?;
410
411        Ok(metadata.len())
412    }
413
414    pub async fn rename(&self, old_path: &str, new_path: &str) -> Result<()> {
415        self.sftp
416            .rename(old_path, new_path)
417            .await
418            .map_err(|e| anyhow!("Failed to rename '{}' to '{}': {}", old_path, new_path, e))
419    }
420
421    pub async fn execute_command_unified(
422        &self,
423        command: &str,
424        options: CommandOptions,
425        cancel_rx: &mut tokio::sync::oneshot::Receiver<()>,
426        progress_callback: Option<impl Fn(String) + Send + Sync + 'static>,
427        ctx: Option<&rmcp::service::RequestContext<rmcp::RoleServer>>,
428    ) -> Result<(String, i32)> {
429        use regex::Regex;
430
431        let session = Self::create_authenticated_session_static(&self.connection_info).await?;
432
433        // Execute command
434        let mut channel = session
435            .channel_open_session()
436            .await
437            .map_err(|e| Self::map_ssh_error(e, "failed to open channel"))?;
438
439        // Wrap the command to get the PID if we need it for cancellation (when not simple)
440        let wrapped_command = if options.simple {
441            command.to_string()
442        } else {
443            // Escape characters that have special meaning inside double quotes in bash:
444            // \ " $ ` ! need escaping, and | needs escaping to prevent pipe interpretation
445            let escaped_command = command
446                .replace('\\', "\\\\")
447                .replace('"', "\\\"")
448                .replace('$', "\\$")
449                .replace('`', "\\`")
450                .replace('!', "\\!");
451            format!(
452                "bash -c 'echo \"PID:$$\"; exec bash -c \"{}\"'",
453                escaped_command
454            )
455        };
456
457        channel
458            .exec(true, wrapped_command.as_str())
459            .await
460            .map_err(|e| Self::map_ssh_error(e, "failed to execute command"))?;
461
462        let mut output = String::new();
463        let mut exit_code = 0i32;
464        let mut remote_pid: Option<String> = None;
465        let progress_id = uuid::Uuid::new_v4();
466
467        // Compile regex for PID extraction if needed
468        let pid_regex = if !options.simple {
469            Some(Regex::new(r"PID:(\d+)").expect("Invalid PID regex"))
470        } else {
471            None
472        };
473
474        // Stream output with progress notifications
475        let command_execution = async {
476            while let Some(msg) = channel.wait().await {
477                match msg {
478                    russh::ChannelMsg::Data { data } => {
479                        let text = String::from_utf8_lossy(&data).to_string();
480
481                        // Extract PID from the output using regex for non-simple commands
482                        if let Some(ref regex) = pid_regex
483                            && remote_pid.is_none()
484                            && let Some(captures) = regex.captures(&text)
485                            && let Some(pid_match) = captures.get(1)
486                        {
487                            remote_pid = Some(pid_match.as_str().to_string());
488                            // Remove the PID line from output
489                            let cleaned_text = regex.replace_all(&text, "").to_string();
490                            if !cleaned_text.trim().is_empty() {
491                                output.push_str(&cleaned_text);
492                                if let Some(ref callback) = progress_callback {
493                                    callback(cleaned_text);
494                                }
495                            }
496                            continue;
497                        }
498
499                        // Normal output processing
500                        output.push_str(&text);
501                        if let Some(ref callback) = progress_callback {
502                            callback(text.clone());
503                        }
504
505                        // Send MCP progress notification if context is provided
506                        if let Some(ctx) = &ctx
507                            && options.with_progress
508                            && !text.trim().is_empty()
509                        {
510                            let _ = ctx.peer.notify_progress(rmcp::model::ProgressNotificationParam {
511                                    progress_token: rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(0)),
512                                    progress: 50.0,
513                                    total: Some(100.0),
514                                    message: Some(serde_json::to_string(&crate::models::integrations::openai::ToolCallResultProgress {
515                                        id: progress_id,
516                                        message: text,
517                                        progress_type: None,
518                                        task_updates: None,
519                                        progress: None,
520                                    }).unwrap_or_default()),
521                                }).await;
522                        }
523                    }
524                    russh::ChannelMsg::ExtendedData { data, ext: _ } => {
525                        let text = String::from_utf8_lossy(&data).to_string();
526                        output.push_str(&text);
527                        if let Some(ref callback) = progress_callback {
528                            callback(text.clone());
529                        }
530
531                        // Send MCP progress notification for stderr if context is provided
532                        if let Some(ctx) = &ctx
533                            && options.with_progress
534                            && !text.trim().is_empty()
535                        {
536                            let _ = ctx.peer.notify_progress(rmcp::model::ProgressNotificationParam {
537                                    progress_token: rmcp::model::ProgressToken(rmcp::model::NumberOrString::Number(0)),
538                                    progress: 50.0,
539                                    total: Some(100.0),
540                                    message: Some(serde_json::to_string(&crate::models::integrations::openai::ToolCallResultProgress {
541                                        id: progress_id,
542                                        message: text,
543                                        progress_type: None,
544                                        task_updates: None,
545                                        progress: None,
546                                    }).unwrap_or_default()),
547                                }).await;
548                        }
549                    }
550                    russh::ChannelMsg::ExitStatus { exit_status } => {
551                        exit_code = exit_status as i32;
552                    }
553                    russh::ChannelMsg::Eof => {
554                        break;
555                    }
556                    _ => {}
557                }
558            }
559        };
560
561        // Macro to handle cancellation cleanup - avoids lifetime issues
562        macro_rules! handle_cancellation {
563            ($error_msg:expr) => {{
564                // Kill the remote process before closing the channel if we have the PID
565                if let Some(pid) = &remote_pid {
566                    let kill_cmd = format!("kill -9 {}", pid);
567                    if let Ok(kill_channel) = session.channel_open_session().await {
568                        let _ = kill_channel.exec(true, kill_cmd.as_str()).await;
569                        let _ = kill_channel.close().await;
570                    }
571                }
572                let _ = channel.close().await;
573                Err(anyhow!($error_msg))
574            }};
575        }
576
577        // Execute with unified select handling timeout and cancellation
578        tokio::select! {
579            // Main command execution
580            _ = command_execution => Ok((output, exit_code)),
581
582            // Timeout handling (only if timeout is specified)
583            _ = async {
584                if let Some(timeout_duration) = options.timeout {
585                    tokio::time::sleep(timeout_duration).await;
586                } else {
587                    // If no timeout, wait forever
588                    std::future::pending::<()>().await;
589                }
590            } => {
591                handle_cancellation!(format!("Command timed out after {:?}", options.timeout))
592            },
593
594            // Context cancellation (only if ctx is provided)
595            _ = async {
596                if let Some(ctx) = &ctx {
597                    ctx.ct.cancelled().await;
598                } else {
599                    // If no context, wait forever
600                    std::future::pending::<()>().await;
601                }
602            } => {
603                handle_cancellation!("Command was cancelled")
604            },
605
606            // Manual cancellation via channel
607            _ = cancel_rx => {
608                handle_cancellation!("Command was cancelled")
609            }
610        }
611    }
612
613    pub async fn execute_command(
614        &self,
615        command: &str,
616        timeout: Option<Duration>,
617        ctx: Option<&rmcp::service::RequestContext<rmcp::RoleServer>>,
618    ) -> Result<(String, i32)> {
619        let options = CommandOptions {
620            timeout,
621            with_progress: true,
622            simple: false,
623        };
624
625        let (_cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel();
626
627        self.execute_command_unified(command, options, &mut cancel_rx, None::<fn(String)>, ctx)
628            .await
629    }
630
631    pub async fn execute_command_with_streaming<F>(
632        &self,
633        command: &str,
634        timeout: Option<Duration>,
635        cancel_rx: &mut tokio::sync::oneshot::Receiver<()>,
636        progress_callback: F,
637    ) -> Result<(String, i32)>
638    where
639        F: Fn(String) + Send + Sync + 'static,
640    {
641        let options = CommandOptions {
642            timeout,
643            with_progress: false,
644            simple: false,
645        };
646
647        self.execute_command_unified(command, options, cancel_rx, Some(progress_callback), None)
648            .await
649    }
650
651    pub fn connection_string(&self) -> &str {
652        &self.connection_info.connection_string
653    }
654}
655
656/// Remote file system provider implementation for tree generation
657pub struct RemoteFileSystemProvider {
658    connection: Arc<RemoteConnection>,
659}
660
661impl RemoteFileSystemProvider {
662    pub fn new(connection: Arc<RemoteConnection>) -> Self {
663        Self { connection }
664    }
665}
666
667#[async_trait]
668impl FileSystemProvider for RemoteFileSystemProvider {
669    type Error = String;
670
671    async fn list_directory(&self, path: &str) -> Result<Vec<DirectoryEntry>, Self::Error> {
672        // Reduce timeout for better responsiveness in tree operations
673        let timeout_duration = std::time::Duration::from_secs(10);
674
675        let entries = tokio::time::timeout(
676            timeout_duration,
677            self.connection.list_directory_with_types(path),
678        )
679        .await
680        .map_err(|_| format!("Timeout listing remote directory: {}", path))?
681        .map_err(|e| format!("Failed to list remote directory: {}", e))?;
682
683        let mut result = Vec::new();
684        for (entry_path, is_directory) in entries {
685            let name = entry_path
686                .split('/')
687                .next_back()
688                .unwrap_or(&entry_path)
689                .to_string();
690
691            result.push(DirectoryEntry {
692                name,
693                path: entry_path,
694                is_directory,
695            });
696        }
697
698        Ok(result)
699    }
700}
701
702impl Display for RemoteConnection {
703    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
704        write!(f, "SSH:{}", self.connection_info.connection_string)
705    }
706}
707
708// Global connection manager
709pub struct RemoteConnectionManager {
710    connections: RwLock<HashMap<String, Arc<RemoteConnection>>>,
711}
712
713impl RemoteConnectionManager {
714    pub fn new() -> Self {
715        Self {
716            connections: RwLock::new(HashMap::new()),
717        }
718    }
719
720    pub async fn get_connection(
721        &self,
722        connection_info: &RemoteConnectionInfo,
723    ) -> Result<Arc<RemoteConnection>> {
724        let key = connection_info.connection_string.clone();
725
726        // Check if connection already exists
727        {
728            let connections = self.connections.read().await;
729            if let Some(conn) = connections.get(&key) {
730                return Ok(conn.clone());
731            }
732        }
733
734        // Create new connection
735        let connection = RemoteConnection::new(connection_info.clone()).await?;
736        let arc_connection = Arc::new(connection);
737
738        // Store connection
739        {
740            let mut connections = self.connections.write().await;
741            connections.insert(key, arc_connection.clone());
742        }
743
744        Ok(arc_connection)
745    }
746
747    pub async fn remove_connection(&self, connection_string: &str) {
748        let mut connections = self.connections.write().await;
749        connections.remove(connection_string);
750    }
751
752    pub async fn list_connections(&self) -> Vec<String> {
753        let connections = self.connections.read().await;
754        connections.keys().cloned().collect()
755    }
756}
757
758impl Default for RemoteConnectionManager {
759    fn default() -> Self {
760        Self::new()
761    }
762}
763
764#[derive(Debug, Clone)]
765pub enum PathLocation {
766    Local(String),
767    Remote {
768        connection: RemoteConnectionInfo,
769        path: String,
770    },
771}
772
773impl PathLocation {
774    /// Parse a path that might be local or remote
775    /// Remote paths are in the format: ssh://user@host:port/path or user@host:/path
776    pub fn parse(path_str: &str) -> Result<Self> {
777        if let Some(without_scheme) = path_str.strip_prefix("ssh://") {
778            // Format: ssh://user@host:port/path
779
780            if let Some((connection_part, path_part)) = without_scheme.split_once('/') {
781                let connection_info = RemoteConnectionInfo {
782                    connection_string: connection_part.to_string(),
783                    password: None,
784                    private_key_path: None,
785                };
786
787                return Ok(PathLocation::Remote {
788                    connection: connection_info,
789                    path: format!("/{}", path_part),
790                });
791            }
792        } else if path_str.contains('@') && path_str.contains(':') {
793            // Format: user@host:/path (traditional SCP format)
794            if let Some((connection_part, path_part)) = path_str.split_once(':')
795                && path_part.starts_with('/')
796            {
797                let connection_info = RemoteConnectionInfo {
798                    connection_string: connection_part.to_string(),
799                    password: None,
800                    private_key_path: None,
801                };
802
803                return Ok(PathLocation::Remote {
804                    connection: connection_info,
805                    path: path_part.to_string(),
806                });
807            }
808        }
809
810        // Default to local path
811        Ok(PathLocation::Local(path_str.to_string()))
812    }
813
814    pub fn is_remote(&self) -> bool {
815        matches!(self, PathLocation::Remote { .. })
816    }
817
818    pub fn as_local_path(&self) -> Option<&str> {
819        match self {
820            PathLocation::Local(path) => Some(path),
821            PathLocation::Remote { .. } => None,
822        }
823    }
824
825    pub fn as_remote_info(&self) -> Option<(&RemoteConnectionInfo, &str)> {
826        match self {
827            PathLocation::Local(_) => None,
828            PathLocation::Remote { connection, path } => Some((connection, path)),
829        }
830    }
831}