From 04f9eb281529965c4aff9ca9176b549fac4ae30f Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 6 Jun 2026 10:37:59 -0500 Subject: feat: add duet--classify-path duet--classify-path turns a path string into a plist describing where it lives and how to reach it: :locality (local or remote), :method, :user, :host, :port, :localname, and :hop for multi-hop paths. It's the pure foundation the transfer-spec and connection layers build on (Phase 1 in the design spec). TRAMP does the dissection, so any path file-remote-p recognizes is remote and everything else is local. A local path is 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 never signals: an incomplete string like /ssh:host that TRAMP rejects as a remote name falls back to local, since validating raw TRAMP input belongs to the connection reader, not here. I probed TRAMP's real contract before writing the tests (port comes back as a string, a multi-hop path reports the final host with the leading hops in :hop), so the Normal/Boundary/Error cases assert what TRAMP actually returns rather than what I'd have guessed. --- duet.el | 42 ++++++++++++++++ tests/test-duet-classify-path.el | 105 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/test-duet-classify-path.el 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 + +;; 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 . + +;;; 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 -- cgit v1.2.3