aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--duet.el42
-rw-r--r--tests/test-duet-classify-path.el105
2 files changed, 147 insertions, 0 deletions
diff --git a/duet.el b/duet.el
index 364a7c4..9ab858b 100644
--- a/duet.el
+++ b/duet.el
@@ -33,11 +33,53 @@
;;; Code:
+(require 'tramp)
+
(defgroup duet nil
"Dual-pane file commander over dirvish/dired."
:group 'files
:prefix "duet-")
+;;; Path classification
+
+(defun duet--classify-path (path)
+ "Classify PATH into a plist describing its locality and components.
+
+The returned plist has these keys:
+
+ :locality `local' or `remote'
+ :method TRAMP method (e.g. \"ssh\"), or nil when local
+ :user remote user, or nil
+ :host remote host (the final hop for a multi-hop path), or nil
+ :port remote port as a string, or nil
+ :localname the path on the target filesystem
+ :hop the leading-hops string for a multi-hop path, or nil
+
+TRAMP performs the dissection, so any path `file-remote-p' recognizes is
+remote and everything else is local. A local PATH has its name expanded
+\(so a leading ~ resolves to the home directory); a remote localname is kept
+verbatim because a ~ there is the remote home, not this machine's.
+
+Classification is total: a TRAMP-looking string TRAMP does not accept as a
+remote name (an incomplete \"/ssh:host\") is treated as a local path rather
+than signaling. Validating raw TRAMP input is the connection reader's job."
+ (if (file-remote-p path)
+ (let ((v (tramp-dissect-file-name path)))
+ (list :locality 'remote
+ :method (tramp-file-name-method v)
+ :user (tramp-file-name-user v)
+ :host (tramp-file-name-host v)
+ :port (tramp-file-name-port v)
+ :localname (tramp-file-name-localname v)
+ :hop (tramp-file-name-hop v)))
+ (list :locality 'local
+ :method nil
+ :user nil
+ :host nil
+ :port nil
+ :localname (expand-file-name path)
+ :hop nil)))
+
;;;###autoload
(defun duet ()
"Launch the DUET dual-pane file commander."
diff --git a/tests/test-duet-classify-path.el b/tests/test-duet-classify-path.el
new file mode 100644
index 0000000..b9e8264
--- /dev/null
+++ b/tests/test-duet-classify-path.el
@@ -0,0 +1,105 @@
+;;; test-duet-classify-path.el --- Tests for duet--classify-path -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Normal/Boundary/Error coverage for `duet--classify-path', the pure path
+;; classifier that turns a path string into a `:locality'/`:method'/`:user'/
+;; `:host'/`:port'/`:localname'/`:hop' plist. TRAMP owns the dissection; these
+;; tests verify the locality decision, the extracted fields, and graceful
+;; handling of paths TRAMP does not recognize as remote.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; Normal cases
+
+(ert-deftest test-duet-classify-path-local-absolute ()
+ "An absolute local path classifies as local with no remote fields."
+ (let ((p (duet--classify-path "/home/cjennings/file")))
+ (should (eq 'local (plist-get p :locality)))
+ (should (null (plist-get p :method)))
+ (should (null (plist-get p :user)))
+ (should (null (plist-get p :host)))
+ (should (null (plist-get p :port)))
+ (should (null (plist-get p :hop)))
+ (should (equal "/home/cjennings/file" (plist-get p :localname)))))
+
+(ert-deftest test-duet-classify-path-ssh-host-only ()
+ "A remote ssh path with no user fills method/host/localname, user nil."
+ (let ((p (duet--classify-path "/ssh:host:/path")))
+ (should (eq 'remote (plist-get p :locality)))
+ (should (equal "ssh" (plist-get p :method)))
+ (should (null (plist-get p :user)))
+ (should (equal "host" (plist-get p :host)))
+ (should (null (plist-get p :port)))
+ (should (equal "/path" (plist-get p :localname)))))
+
+(ert-deftest test-duet-classify-path-ssh-user-host ()
+ "A user@host ssh path extracts the user."
+ (let ((p (duet--classify-path "/ssh:user@host:/path")))
+ (should (eq 'remote (plist-get p :locality)))
+ (should (equal "user" (plist-get p :user)))
+ (should (equal "host" (plist-get p :host)))
+ (should (equal "/path" (plist-get p :localname)))))
+
+;;; Boundary cases
+
+(ert-deftest test-duet-classify-path-tilde-expands ()
+ "A leading ~ in a local path expands to the home directory."
+ (let ((p (duet--classify-path "~/media")))
+ (should (eq 'local (plist-get p :locality)))
+ (should (equal (expand-file-name "~/media") (plist-get p :localname)))))
+
+(ert-deftest test-duet-classify-path-host-with-port ()
+ "A host#port path splits the port out into the :port field."
+ (let ((p (duet--classify-path "/ssh:user@host#2222:/path")))
+ (should (eq 'remote (plist-get p :locality)))
+ (should (equal "host" (plist-get p :host)))
+ (should (equal "2222" (plist-get p :port)))
+ (should (equal "/path" (plist-get p :localname)))))
+
+(ert-deftest test-duet-classify-path-sshx-and-scp-methods ()
+ "Methods other than ssh are preserved verbatim."
+ (should (equal "sshx" (plist-get (duet--classify-path "/sshx:u@h:/p") :method)))
+ (should (equal "scp" (plist-get (duet--classify-path "/scp:u@h:/p") :method))))
+
+(ert-deftest test-duet-classify-path-multi-hop ()
+ "A multi-hop path reports the final host and preserves the leading hops."
+ (let ((p (duet--classify-path "/ssh:host1|ssh:host2:/path")))
+ (should (eq 'remote (plist-get p :locality)))
+ (should (equal "host2" (plist-get p :host)))
+ (should (equal "/path" (plist-get p :localname)))
+ (should (stringp (plist-get p :hop)))
+ (should (string-match-p "host1" (plist-get p :hop)))))
+
+;;; Error / edge cases
+
+(ert-deftest test-duet-classify-path-malformed-is-local-not-error ()
+ "A TRAMP-looking string TRAMP does not accept as remote is treated as local.
+Validation of raw TRAMP entry belongs to the connection reader, not the
+classifier; classification stays total and never throws."
+ (dolist (bad '("/ssh:" "/ssh:host"))
+ (let ((p (duet--classify-path bad)))
+ (should (eq 'local (plist-get p :locality)))
+ (should (null (plist-get p :method))))))
+
+(provide 'test-duet-classify-path)
+;;; test-duet-classify-path.el ends here