1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "os"
10 "sort"
11 "text/tabwriter"
12
13 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/platform"
16 "github.com/bnema/vev/internal/ports"
17)
18
19const (
20 hostActionAdd = "add"
21 hostActionRm = "rm"
22 hostActionList = "list"
23)
24
25// remoteHostDeps holds injectable seams for host management and remote listing.
26type remoteHostDeps struct {
27 stateDir func() string
28 store ports.RemoteHostStore
29 catalog ports.RemoteCatalogClient
30 localList func(context.Context) ([]ports.SessionInfo, error)
31 stdout io.Writer
32}
33
34func defaultRemoteHostDeps() remoteHostDeps {
35 return remoteHostDeps{
36 stateDir: platform.StateDir,
37 catalog: remoteadapter.NewCatalogClient(),
38 localList: listLocalSessions,
39 stdout: os.Stdout,
40 }
41}
42
43func (d remoteHostDeps) withDefaults() remoteHostDeps {
44 if d.stateDir == nil {
45 d.stateDir = platform.StateDir
46 }
47 if d.store == nil {
48 d.store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(d.stateDir()))
49 }
50 if d.catalog == nil {
51 d.catalog = remoteadapter.NewCatalogClient()
52 }
53 if d.localList == nil {
54 d.localList = listLocalSessions
55 }
56 if d.stdout == nil {
57 d.stdout = os.Stdout
58 }
59 return d
60}
61
62func (d remoteHostDeps) hostStore() ports.RemoteHostStore {
63 return d.store
64}
65
66func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {
67 _ = ctx
68 deps = deps.withDefaults()
69
70 switch cmd.hostAction {
71 case hostActionAdd:
72 return hostAdd(deps, cmd.hostTarget)
73 case hostActionRm:
74 return hostRm(deps, cmd.hostTarget)
75 case hostActionList:
76 return hostList(deps)
77 default:
78 return usagef("unknown host action %q", cmd.hostAction)
79 }
80}
81
82func hostAdd(deps remoteHostDeps, target string) error {
83 if err := domain.ValidateRemoteHostTarget(target); err != nil {
84 return err
85 }
86 if err := deps.hostStore().AddPinned(target); err != nil {
87 return fmt.Errorf("vev: adding pinned host %q: %w", target, err)
88 }
89 return nil
90}
91
92func hostRm(deps remoteHostDeps, target string) error {
93 if err := domain.ValidateRemoteHostTarget(target); err != nil {
94 return err
95 }
96 deleted, err := deps.hostStore().Remove(target)
97 if err != nil {
98 return fmt.Errorf("vev: removing host %q: %w", target, err)
99 }
100 if !deleted {
101 return fmt.Errorf("vev: unknown host %q", target)
102 }
103 return nil
104}
105
106func hostList(deps remoteHostDeps) error {
107 hosts, err := mergeKnownHosts(deps)
108 if err != nil {
109 return err
110 }
111 if len(hosts) == 0 {
112 _, _ = fmt.Fprintln(deps.stdout, "no hosts")
113 return nil
114 }
115 tw := tabwriter.NewWriter(deps.stdout, 0, 4, 2, ' ', 0)
116 _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")
117 for _, host := range hosts {
118 _, _ = fmt.Fprintf(tw, "%s\t%s\n", host.Target, remoteHostSource(host))
119 }
120 return tw.Flush()
121}
122
123func remoteHostSource(host domain.RemoteHost) string {
124 switch {
125 case host.Pinned && host.Learned:
126 return "pinned,learned"
127 case host.Pinned:
128 return "pinned"
129 case host.Learned:
130 return "learned"
131 default:
132 return ""
133 }
134}
135
136func runRemoteList(ctx context.Context, cmd command, deps remoteHostDeps) error {
137 deps = deps.withDefaults()
138
139 hosts, err := mergeKnownHosts(deps)
140 if err != nil {
141 return err
142 }
143 if cmd.listHost != "" {
144 return listOneRemoteHost(ctx, deps, hosts, cmd.listHost)
145 }
146 return listAllSessions(ctx, deps, hosts)
147}
148
149func listOneRemoteHost(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost, target string) error {
150 known := false
151 for _, host := range hosts {
152 if host.Target == target {
153 known = true
154 break
155 }
156 }
157 if !known {
158 return fmt.Errorf("vev: unknown host %q", target)
159 }
160 catalog, err := deps.catalog.List(ctx, target)
161 if err != nil {
162 return fmt.Errorf("vev: listing sessions on %q: %w", target, err)
163 }
164 printSessions(deps.stdout, catalogSessionsAsInfo(target, catalog.Sessions))
165 return nil
166}
167
168// listAllSessions queries hosts sequentially for deterministic output ordering.
169func listAllSessions(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost) error {
170 local, err := deps.localList(ctx)
171 if err != nil {
172 if local != nil {
173 printSessions(deps.stdout, local)
174 }
175 return err
176 }
177 all := append([]ports.SessionInfo(nil), local...)
178 var hostErrs []error
179 for _, host := range hosts {
180 if err := ctx.Err(); err != nil {
181 printSessions(deps.stdout, all)
182 return err
183 }
184 catalog, listErr := deps.catalog.List(ctx, host.Target)
185 if listErr != nil {
186 hostErrs = append(hostErrs, fmt.Errorf("host %s: %w", host.Target, listErr))
187 continue
188 }
189 all = append(all, catalogSessionsAsInfo(host.Target, catalog.Sessions)...)
190 }
191 printSessions(deps.stdout, all)
192 if len(hostErrs) > 0 {
193 return errors.Join(hostErrs...)
194 }
195 return nil
196}
197
198func catalogSessionsAsInfo(host string, sessions []ports.RemoteCatalogSession) []ports.SessionInfo {
199 out := make([]ports.SessionInfo, 0, len(sessions))
200 for _, session := range sessions {
201 info := ports.SessionInfo{
202 Name: session.Name + "@" + host,
203 Ephemeral: session.Ephemeral,
204 Tabs: ports.SaturateUint16(ports.CatalogTabCount(session)),
205 Attached: session.Attached,
206 }
207 switch session.State {
208 case "up":
209 info.State = ports.SessionUp
210 case "down":
211 info.State = ports.SessionDown
212 case "broken":
213 info.State = ports.SessionBroken
214 default:
215 slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)
216 info.State = ports.SessionBroken
217 }
218 out = append(out, info)
219 }
220 return out
221}
222
223func mergeKnownHosts(deps remoteHostDeps) ([]domain.RemoteHost, error) {
224 pinned, learned, err := deps.hostStore().Hosts()
225 if err != nil {
226 return nil, fmt.Errorf("vev: reading hosts: %w", err)
227 }
228 return mergeRemoteHosts(pinned, learned), nil
229}
230
231// mergeRemoteHosts returns pinned hosts in stored order, then learned-only
232// hosts in lexical order. Duplicates keep first occurrence and mark both sources.
233func mergeRemoteHosts(pinned, learned []string) []domain.RemoteHost {
234 pinned = domain.UniqueRemoteHostTargets(pinned)
235 learnedSet := make(map[string]struct{}, len(learned))
236 for _, target := range learned {
237 learnedSet[target] = struct{}{}
238 }
239 pinnedSet := make(map[string]struct{}, len(pinned))
240 out := make([]domain.RemoteHost, 0, len(pinned)+len(learned))
241 for _, target := range pinned {
242 pinnedSet[target] = struct{}{}
243 _, isLearned := learnedSet[target]
244 out = append(out, domain.RemoteHost{Target: target, Pinned: true, Learned: isLearned})
245 }
246 learnedOnly := make([]string, 0, len(learned))
247 for _, target := range learned {
248 if _, ok := pinnedSet[target]; !ok {
249 learnedOnly = append(learnedOnly, target)
250 }
251 }
252 learnedOnly = domain.UniqueRemoteHostTargets(learnedOnly)
253 sort.Strings(learnedOnly)
254 for _, target := range learnedOnly {
255 out = append(out, domain.RemoteHost{Target: target, Learned: true})
256 }
257 return out
258}
259
260type remoteHostLearner struct {
261 store ports.RemoteHostStore
262 target string
263}
264
265func (l remoteHostLearner) RememberRemoteHost() error {
266 return l.store.Remember(l.target)
267}
268
269func attachRememberLearner(deps runAttachDeps, remoteTarget string, _ *slog.Logger) ports.RemoteHostLearner {
270 if remoteTarget == "" {
271 return nil
272 }
273 store := deps.hostStore
274 if store == nil {
275 stateDir := deps.stateDir
276 if stateDir == nil {
277 stateDir = platform.StateDir
278 }
279 store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir()))
280 }
281 return remoteHostLearner{store: store, target: remoteTarget}
282}