aboutsummaryrefslogtreecommitdiff
path: root/duet.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 10:58:46 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 10:58:46 -0500
commit3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac (patch)
tree9b6c1d5475c3074be59c0407e26470de34a50a08 /duet.el
parentfdc5f550d35d97272e3e5cac2f46ca7f892dda09 (diff)
downloadduet-3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac.tar.gz
duet-3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac.zip
feat: add transfer-spec, the endpoint matrix, and the conflict/move planners
duet--transfer-spec classifies both endpoints, selects a backend through the registry, determines the route, and delegates argv construction to the backend (Phase 3 in the design spec). It returns the plist the executor will run: sources, destination, backend, route, argv, and async flag. The route is decided independently of backend by duet--transfer-route: local, local-remote, remote-same-host, remote-direct, or remote-roundtrip. Different remote hosts default to the round-trip through this machine. Direct host-to-host runs only when a per-connection override asks for it, never automatically, because a direct route can silently fail where a round-trip always works. This phase also registers the two stage-1 backends through the same duet-register-backend seam a plugin uses: rsync for local and ssh-reachable endpoints, TRAMP as the universal fallback that costs more so rsync wins whenever it applies. rsync receives its source and destination as separate argv elements, so a filename with spaces or shell metacharacters stays inert. The two planners are pure and prompt-free, so the dangerous decisions are testable before a byte moves. duet--plan-conflicts resolves overwrite/skip/rename per file with an apply-to-all that stops asking, taking the existence check and the resolver as injected functions. duet--plan-move pairs each source's copy with a delete gated on that source's copy success, so a failed copy can never delete its source. Remote-to-remote execution (honoring the round-trip route as a two-step through local) and TRAMP's in-process copy land in Phase 6. Here transfer-spec records the route and the rsync path the executor will use.
Diffstat (limited to 'duet.el')
-rw-r--r--duet.el222
1 files changed, 222 insertions, 0 deletions
diff --git a/duet.el b/duet.el
index 66b9a8a..e8ae8c4 100644
--- a/duet.el
+++ b/duet.el
@@ -330,6 +330,228 @@ before DUET will trust it."
(list (unless (plist-get (duet-backend-capabilities backend) capability)
(format "capability %s is asserted but not declared" capability))))))
+;;; Endpoint matrix and routing
+
+(defconst duet--ssh-methods '("ssh" "sshx" "scp" "rsync")
+ "TRAMP methods rsync can transport over directly.")
+
+(defun duet--local-endpoint-p (ep)
+ "Return non-nil when classified endpoint EP is local."
+ (eq (plist-get ep :locality) 'local))
+
+(defun duet--remote-endpoint-p (ep)
+ "Return non-nil when classified endpoint EP is remote."
+ (eq (plist-get ep :locality) 'remote))
+
+(defun duet--ssh-endpoint-p (ep)
+ "Return non-nil when classified endpoint EP is reachable over ssh."
+ (and (duet--remote-endpoint-p ep)
+ (member (plist-get ep :method) duet--ssh-methods)))
+
+(defun duet--same-remote-host-p (a b)
+ "Return non-nil when classified endpoints A and B name the same remote host."
+ (and (duet--remote-endpoint-p a)
+ (duet--remote-endpoint-p b)
+ (equal (plist-get a :method) (plist-get b :method))
+ (equal (plist-get a :user) (plist-get b :user))
+ (equal (plist-get a :host) (plist-get b :host))
+ (equal (plist-get a :port) (plist-get b :port))))
+
+(defun duet--transfer-route (src dst opts)
+ "Determine the transfer route between classified SRC and DST given OPTS.
+
+ :local both endpoints local
+ :local-remote exactly one endpoint remote
+ :remote-same-host both on the same remote host (ssh in once, copy there)
+ :remote-direct different remote hosts, direct host-to-host (override only)
+ :remote-roundtrip different remote hosts, routed through this machine
+
+Different-host transfers default to the round-trip; direct mode is never
+automatic and requires `:direct-remote-to-remote' in OPTS."
+ (let ((s-local (duet--local-endpoint-p src))
+ (d-local (duet--local-endpoint-p dst)))
+ (cond
+ ((and s-local d-local) :local)
+ ((or s-local d-local) :local-remote)
+ ((duet--same-remote-host-p src dst) :remote-same-host)
+ ((plist-get opts :direct-remote-to-remote) :remote-direct)
+ (t :remote-roundtrip))))
+
+;;; Built-in backends: rsync (native) and TRAMP (fallback)
+
+(defun duet--rsync-handles (src dst)
+ "Score rsync for classified SRC and DST.
+rsync handles local and ssh-reachable endpoints. A local pair or a
+local/ssh pair scores 10; an ssh-to-ssh pair scores 20 (it needs routing).
+Anything not ssh-reachable scores nil so a fallback backend takes it."
+ (let ((s-ok (or (duet--local-endpoint-p src) (duet--ssh-endpoint-p src)))
+ (d-ok (or (duet--local-endpoint-p dst) (duet--ssh-endpoint-p dst))))
+ (when (and s-ok d-ok)
+ (if (and (duet--remote-endpoint-p src) (duet--remote-endpoint-p dst))
+ 20
+ 10))))
+
+(defun duet--rsync-endpoint-arg (ep)
+ "Return the rsync path argument for classified endpoint EP.
+A local endpoint uses its localname; a remote one uses rsync's native
+\[user@]host:path form (the port travels in the ssh transport flag)."
+ (if (duet--local-endpoint-p ep)
+ (plist-get ep :localname)
+ (concat (when (plist-get ep :user) (concat (plist-get ep :user) "@"))
+ (plist-get ep :host) ":" (plist-get ep :localname))))
+
+(defun duet--rsync-ssh-transport (src dst)
+ "Return rsync's -e ssh transport flags for SRC/DST, honoring a port."
+ (let ((port (or (plist-get src :port) (plist-get dst :port))))
+ (list "-e" (if port (format "ssh -p %s" port) "ssh"))))
+
+(defun duet--rsync-command (src dst opts)
+ "Build an rsync process spec for SRC to DST with OPTS.
+OPTS carries :sources (the source paths) and :destination (the destination
+directory). Sources and destination become rsync path arguments, and a
+remote endpoint adds an ssh transport. File names reach rsync as argv
+elements, never interpolated into a shell string."
+ (let* ((sources (plist-get opts :sources))
+ (remote (or (duet--ssh-endpoint-p src) (duet--ssh-endpoint-p dst)))
+ (src-args (mapcar (lambda (p)
+ (duet--rsync-endpoint-arg (duet--classify-path p)))
+ sources)))
+ (list :argv (append '("rsync" "-a" "--partial" "--info=progress2")
+ (when remote (duet--rsync-ssh-transport src dst))
+ src-args
+ (list (duet--rsync-endpoint-arg dst)))
+ :default-directory "/")))
+
+(defun duet--tramp-handles (_src _dst)
+ "Score TRAMP: the universal fallback, costlier than a native transport."
+ 100)
+
+(defun duet--tramp-command (_src _dst opts)
+ "Build the in-process TRAMP transfer spec from OPTS.
+TRAMP copies in process rather than spawning a CLI, so there is no argv; the
+:tramp marker tells the executor to use the in-process path."
+ (list :argv nil
+ :tramp t
+ :sources (plist-get opts :sources)
+ :destination (plist-get opts :destination)))
+
+(defconst duet--rsync-failure-patterns
+ '((:match "[Pp]ermission denied" :class permission-denied
+ :cause "The destination rejected the write (permission denied)."
+ :next-actions (fix-permissions) :safety "Source unchanged.")
+ (:match "No space left on device" :class destination-full
+ :cause "The destination filesystem is full."
+ :next-actions (free-space retry)
+ :safety "Source unchanged; the destination may hold a partial.")
+ (:match "\\(protocol version mismatch\\|unexpected tag\\|is your shell clean\\)"
+ :class rsync-protocol-mismatch
+ :cause "The remote shell printed text that corrupted rsync's protocol stream."
+ :next-actions (run-doctor) :safety "Source unchanged."))
+ "Known rsync stderr signatures mapped to DUET failure classes.")
+
+(defun duet--register-builtin-backends ()
+ "Register the stage-1 built-in backends: rsync, with TRAMP as the fallback.
+Idempotent: re-registering replaces the prior definitions."
+ (duet-register-backend
+ (duet-backend-create
+ :name 'tramp
+ :handles #'duet--tramp-handles
+ :command #'duet--tramp-command
+ :capabilities '()
+ :redaction '("\\(://[^:@/]+:\\)[^@]+@")
+ :cleanup :none))
+ (duet-register-backend
+ (duet-backend-create
+ :name 'rsync
+ :handles #'duet--rsync-handles
+ :command #'duet--rsync-command
+ :capabilities '(:async t :resume t :progress t)
+ :redaction :none
+ :cleanup :verifiable
+ :normalizer (duet-define-cli-failure-patterns duet--rsync-failure-patterns))))
+
+(duet--register-builtin-backends)
+
+;;; Transfer-spec assembly
+
+(defun duet--transfer-spec (sources destination-directory &optional opts)
+ "Build a transfer-spec plist to copy SOURCES into DESTINATION-DIRECTORY.
+SOURCES is a list of source paths; the first is representative for backend
+selection and routing. Classify both endpoints, select the lowest-cost
+backend, determine the route, and delegate argv construction to that
+backend's command builder. Return nil when no backend handles the pair."
+ (let* ((src (duet--classify-path (car sources)))
+ (dst (duet--classify-path destination-directory))
+ (backend (duet--select-backend src dst)))
+ (when backend
+ (let* ((bopts (append (list :sources sources
+ :destination destination-directory)
+ opts))
+ (cmd (funcall (duet-backend-command backend) src dst bopts)))
+ (list :sources sources
+ :destination-directory destination-directory
+ :destination-name (plist-get opts :destination-name)
+ :backend (duet-backend-name backend)
+ :route (duet--transfer-route src dst opts)
+ :argv (plist-get cmd :argv)
+ :default-directory (plist-get cmd :default-directory)
+ :process-environment (plist-get cmd :process-environment)
+ :async (if (plist-member opts :async) (plist-get opts :async) t))))))
+
+;;; Conflict and move planning (pure, prompt-free)
+
+(defun duet--unique-name (dest existing-p)
+ "Return a free basename near DEST, probing candidates with EXISTING-P.
+Append \" (N)\" before the extension, incrementing N until EXISTING-P reports
+the candidate path free. Return the basename only."
+ (let* ((dir (file-name-directory dest))
+ (base (file-name-base dest))
+ (ext (file-name-extension dest t))
+ (n 1)
+ (candidate (concat dir base " (" (number-to-string n) ")" ext)))
+ (while (funcall existing-p candidate)
+ (setq n (1+ n)
+ candidate (concat dir base " (" (number-to-string n) ")" ext)))
+ (file-name-nondirectory candidate)))
+
+(defun duet--conflict-entry (src dst action existing-p)
+ "Return a conflict-plan entry for SRC/DST resolved with ACTION.
+A `rename' action gets a computed free :new-name via EXISTING-P."
+ (if (eq action 'rename)
+ (list :source src :dest dst :action 'rename
+ :new-name (duet--unique-name dst existing-p))
+ (list :source src :dest dst :action action)))
+
+(defun duet--plan-conflicts (items existing-p resolver)
+ "Return a per-item action plan for ITEMS, prompt-free.
+ITEMS is a list of (SOURCE . DEST). EXISTING-P, called on a DEST, reports
+whether it already exists. RESOLVER, called on a colliding (SOURCE . DEST),
+returns an action (`overwrite', `skip', or `rename') or a cons (ACTION . all)
+to apply ACTION to every remaining collision without being called again.
+Non-colliding items plan a plain `copy'. Each entry is a plist
+\(:source :dest :action [:new-name]); no file is touched."
+ (let ((sticky nil) (plan nil))
+ (dolist (item items (nreverse plan))
+ (let ((src (car item)) (dst (cdr item)))
+ (if (not (funcall existing-p dst))
+ (push (list :source src :dest dst :action 'copy) plan)
+ (let ((action (or sticky (funcall resolver item))))
+ (when (and (consp action) (eq (cdr action) 'all))
+ (setq sticky (car action)
+ action (car action)))
+ (push (duet--conflict-entry src dst action existing-p) plan)))))))
+
+(defun duet--plan-move (sources)
+ "Return a move plan for SOURCES, prompt-free.
+Each source yields a copy step followed by a delete step gated on that
+source's copy success, so a source is never deleted before its copy is
+confirmed. Return a flat list of step plists in execution order."
+ (apply #'append
+ (mapcar (lambda (s)
+ (list (list :op 'copy :source s)
+ (list :op 'delete :source s :gate 'copy-success)))
+ sources)))
+
;;;###autoload
(defun duet ()
"Launch the DUET dual-pane file commander."