Commit 4d6ea462de

4d6ea462de24bddd1f0c2c1fdc7528ee7cf261db

parent: 76468e95d3

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 01:42 UTC

Edit your profile (!56)

`profile set` reaches the app: description, website, about text and links,
from an edit sheet on your own profile.

The flag semantics are unlike anything else this app sends, so the form
matches them exactly. `--link` replaces the whole set rather than
appending, so every link you keep is sent on every save and dropping one
from the form is how you delete it; an empty list sends `--link ""`,
which is how the set is cleared. An emptied field sends an empty value
rather than omitting the flag, because omitting means "leave it alone"
and empty means "remove it". A label with no URL, more than five links,
or a label containing `|` — which the server would read as the separator
and split into a broken link — are all refused before anything is sent.

The about text travels in stdin, not argv: it is long-form, and the
profile this was built against is org-mode with tables. The format picker
defaults to the format the text was stored with, since changing it
reinterprets prose that already exists.

Closes the `profile set` parity row. The web still has no form for this.

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr10-profile-set.md added +237
@@ -0,0 +1,237 @@
1# MR 10: Edit your profile Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close the `profile set` parity row — edit your description, website, about text and links from the app.
6
7**Architecture:** One write command behind an edit sheet on your own profile screen. The complication is entirely in `profile set`'s flag semantics, which are unlike anything else this app sends.
8
9**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
10
11## The command, and why it needs care
12
13```
14profile set [--description <d>] [--website <url>] [--about <text>|--file -]
15 [--about-format md|org] [--link <label|url>]... ('' clears)
16```
17
18Five things, all verified against `internal/control/profile.go`:
19
201. **`--link` replaces the whole set, it does not append.** `applyProfile` does `p.Links = *e.Links` (`:159-161`). So the editor sends **every link it wants to keep** on every save — dropping one from the form and saving is how you delete it.
212. **`--link ""` clears all links** (`:81-84`).
223. **`''` clears any field.** An empty `--description` is "remove my description", which is different from omitting the flag entirely.
234. **At least one flag is required.** With none, the command fails: *"nothing to set: pass --description, --website, --about and/or --link"* (`:356-358`). A save with no changes must not be sent.
245. **A link is `label|url`**, split on the first `|`. Without a separator the whole value is the URL (`:106-113`). Labels are trimmed and truncated at 32 characters; a link with no URL is an error. **At most 5 links** (`maxProfileLinks = 5`, `:31,100-102`).
25
26**About text goes over stdin.** It is long-form and contains newlines — the live profile on gitbay.org is org-mode with tables and links. Use `--file -` with the text in `stdin`, the same discipline the compose sheet already uses for issue and merge request bodies. Do not put it in argv.
27
28**`--about-format md|org`** is stored alongside the text, so changing it reinterprets prose that already exists. The editor must default to the format the profile was **stored** with, read from `about_format` on `profile show`.
29
30## Global Constraints
31
32- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
33- Swift Testing only — never XCTest.
34- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
35- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
36- The label model is `IssueLabel`; the notification model is `InboxNotification`.
37- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
38- Never commit to `main`.
39
40**Counting tests — `xcresulttool`, not grep:**
41
42```bash
43RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1)
44xcrun xcresulttool get test-results summary --path "$RES"
45```
46
47Baseline: **331 total, 330 passed, 1 skipped, 0 failed.**
48
49---
50
51## File Structure
52
53| File | Responsibility |
54|------|----------------|
55| `gitbay/Discovery/ProfileEdit.swift` (create) | `ProfileEdit` — the form's value type and its argv rendering |
56| `gitbay/Discovery/ProfileViewModel.swift` (modify) | `saveProfile(_:) async -> Bool` |
57| `gitbayTests/ProfileEditTests.swift` (create) | Every test in this plan |
58| `gitbay/Views/Discovery/ProfileEditSheet.swift` (create) | The edit form |
59| `gitbay/Views/Discovery/ProfileView.swift` (modify) | Edit button on your own profile |
60
61---
62
63### Task 1: The value type and its argv
64
65**Files:** Create `gitbay/Discovery/ProfileEdit.swift`; test in `gitbayTests/ProfileEditTests.swift`
66
67This task is pure argv construction — the same shape as MR 2's `IssueFilter`, and the same reason: a flag this command does not accept is a usage error the app cannot catch at compile time.
68
69**Interfaces produced:**
70- `nonisolated struct ProfileEdit: Equatable, Sendable` — `description: String`, `website: String`, `about: String`, `aboutFormat: String`, `links: [Link]`; nested `Link` with `label: String`, `url: String`
71- `init(from profile: ProfileViewModel.Profile)` — seeds the form from what the server reported
72- `var isEmpty: Bool` — true when nothing would be sent
73- `func flags() -> [String]` — everything except the about text
74- `var aboutStdin: String?` — the about text, when it is being set
75
76Rules, each pinned by a test:
771. **`--link` sends the entire set**, one flag per link, `label|url`. A link with an empty label sends just the URL (no leading `|`).
782. **An empty link list sends `--link ""`** — that is how the set is cleared. It must not simply omit the flag, which would leave the existing links untouched.
793. **A field cleared to empty sends `--flag ""`**, not omission. Empty means "remove this"; omitted means "leave it alone". Since the form is always seeded from the current profile, every field it holds is being set.
804. **The about text never appears in argv.** It goes to stdin via `--file -`.
815. **`--about-format` is sent whenever the about text is**, using the stored format unless the user changed it.
826. **More than 5 links is refused locally** before sending — the server caps at 5 and the form should not let it get there.
837. **A link with no URL is refused locally.**
84
85- [ ] **Step 1: Write the failing tests**
86
87```swift
88import Foundation
89import Testing
90@testable import gitbay
91
92struct ProfileEditFlagTests {
93
94 private func edit(
95 description: String = "", website: String = "",
96 about: String = "", aboutFormat: String = "md",
97 links: [ProfileEdit.Link] = []
98 ) -> ProfileEdit {
99 ProfileEdit(description: description, website: website,
100 about: about, aboutFormat: aboutFormat, links: links)
101 }
102
103 @Test func everyFieldSendsItsFlag() {
104 let e = edit(description: "hi", website: "https://x.test",
105 links: [.init(label: "blog", url: "https://b.test")])
106 let flags = e.flags()
107 #expect(flags.contains("--description"))
108 #expect(flags.contains("hi"))
109 #expect(flags.contains("--website"))
110 #expect(flags.contains("https://x.test"))
111 #expect(flags.contains("blog|https://b.test"))
112 }
113
114 /// An emptied field CLEARS it. Omitting the flag would leave the old
115 /// value in place, which is a different outcome.
116 @Test func anEmptiedFieldSendsAnEmptyValueNotNothing() {
117 let flags = edit(description: "", website: "https://x.test").flags()
118 let i = try! #require(flags.firstIndex(of: "--description"))
119 #expect(flags[i + 1] == "")
120 }
121
122 /// `--link ""` is how the whole set is cleared. Omitting it would
123 /// leave the existing links untouched.
124 @Test func noLinksSendsAnEmptyLinkFlag() {
125 let flags = edit(description: "hi").flags()
126 let i = try! #require(flags.firstIndex(of: "--link"))
127 #expect(flags[i + 1] == "")
128 }
129
130 /// The set replaces wholesale, so every kept link is sent every time.
131 @Test func everyLinkIsSentSoTheSetReplaces() {
132 let flags = edit(links: [
133 .init(label: "a", url: "https://a.test"),
134 .init(label: "b", url: "https://b.test"),
135 ]).flags()
136 #expect(flags.filter { $0 == "--link" }.count == 2)
137 #expect(flags.contains("a|https://a.test"))
138 #expect(flags.contains("b|https://b.test"))
139 }
140
141 /// Without a separator the whole value is the URL, so a label-less
142 /// link must not send a leading pipe.
143 @Test func aLabellessLinkSendsJustTheUrl() {
144 let flags = edit(links: [.init(label: "", url: "https://a.test")]).flags()
145 #expect(flags.contains("https://a.test"))
146 #expect(flags.contains("|https://a.test") == false)
147 }
148
149 /// Long-form text with newlines belongs in stdin, not argv.
150 @Test func theAboutTextGoesToStdinNotArgv() {
151 let e = edit(about: "line one\n\nline two", aboutFormat: "org")
152 #expect(e.flags().contains { $0.contains("line one") } == false)
153 #expect(e.flags().contains("--file"))
154 #expect(e.flags().contains("-"))
155 #expect(e.aboutStdin == "line one\n\nline two")
156 }
157
158 @Test func theAboutFormatAccompaniesTheText() {
159 let flags = edit(about: "x", aboutFormat: "org").flags()
160 let i = try! #require(flags.firstIndex(of: "--about-format"))
161 #expect(flags[i + 1] == "org")
162 }
163
164 @Test func moreThanFiveLinksIsInvalid() {
165 let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
166 #expect(edit(links: six).validationError != nil)
167 let five = (1...5).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
168 #expect(edit(links: five).validationError == nil)
169 }
170
171 @Test func aLinkWithoutAUrlIsInvalid() {
172 #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil)
173 }
174}
175```
176
177Add `var validationError: String?` to the interface list — the tests above use it.
178
179- [ ] **Step 2: Run to verify failure.**
180- [ ] **Step 3: Implement.**
181- [ ] **Step 4: Run the tests.**
182- [ ] **Step 5: Commit** — `git commit -m "Profile edit value type and its flags"`
183
184---
185
186### Task 2: Saving, and the edit sheet
187
188**Files:** Modify `gitbay/Discovery/ProfileViewModel.swift`; create `gitbay/Views/Discovery/ProfileEditSheet.swift`; modify `gitbay/Views/Discovery/ProfileView.swift`
189
190**Interfaces produced:** `ProfileViewModel.saveProfile(_ edit: ProfileEdit) async -> Bool`
191
192`saveProfile` sends `["profile", "set"] + edit.flags()` with `edit.aboutStdin` as stdin, then reloads so the screen shows what the server stored. It must refuse to send an edit whose `validationError` is non-nil, and refuse to send when nothing would change.
193
194**The sheet:** description and website as single-line fields; about as a `TextEditor` with a `md`/`org` picker; links as an editable list capped at five, each with a label and a URL.
195
196**The format picker defaults to the profile's stored `about_format`.** Changing it reinterprets prose that already exists — the same rule that governs issue and merge request bodies. Do not default to markdown when the stored value is org; the live profile on gitbay.org is org, with tables and links that would render as flat text under the wrong renderer.
197
198**The edit button appears only on your own profile** — `profile set` always writes the caller's own. The screen already computes this: MR 8 added an org-create button gated on `!profile.isOrg && session.current?.username == profile.name`. Reuse that condition rather than inventing a second one.
199
200Add tests for `saveProfile` in `gitbayTests/ProfileEditTests.swift`:
201- The full argv reaches the command, with the about text in stdin and not in argv.
202- A save reloads, so the screen shows what the server stored rather than what was typed.
203- A refusal surfaces and returns false.
204- An invalid edit (six links) sends nothing at all.
205
206**Provision stubs for every request each flow makes** — `saveProfile` writes *and* reloads. Three tests in this repo have already shipped under-provisioned, silently passing against bugs.
207
208- [ ] **Step 1: `saveProfile` and its tests**
209- [ ] **Step 2: The edit sheet**
210- [ ] **Step 3: The edit button on your own profile**
211- [ ] **Step 4: Build and run the full suite.**
212- [ ] **Step 5: Commit** — `git commit -m "Edit your profile from the app"`
213
214---
215
216### Task 3: Flip the parity row and open the merge request
217
218- [ ] **Step 1:** Set `profile set` to `yes` for iOS in the Accounts table. Touch no other row.
219
220The wiki note for that row currently says no surface has ever offered a form, and that the `no` is missing UI rather than a rule — **update that prose too**, since it stops being true. The web still has no form, so say that iOS has one and the web does not.
221
222Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. **Read the merge request number back from `mr create`'s JSON.**
223
224- [ ] **Step 2:** Run the full suite via `xcresulttool`; record the real numbers.
225- [ ] **Step 3:** Open the merge request.
226
227---
228
229## Notes for whoever executes this
230
231**`--link` replaces the set.** Every link you want to keep goes on every save, and `--link ""` clears them. This is the one flag semantic in the whole app that works this way, and getting it wrong silently loses or duplicates a user's links.
232
233**Empty is not omitted.** `--description ""` removes the description; omitting `--description` leaves it. The form always sends what it holds.
234
235**About text goes in stdin.** It is long-form with newlines. `--file -`, never argv.
236
237**Default the format to what was stored.** The live profile is org-mode; defaulting to markdown would reinterpret prose that already exists.
gitbay/Discovery/ProfileEdit.swift added +77
@@ -0,0 +1,77 @@
1import Foundation
2
3/// `profile set` — unlike every other filter or edit in the app, `--link`
4/// replaces the whole set rather than appending, so every link the form
5/// wants to keep is sent on every save. The form is always seeded from
6/// the current profile, so every other field is also sent every time,
7/// including empty ones: `--description ""` clears, omitting the flag
8/// would leave the old value alone.
9nonisolated struct ProfileEdit: Equatable, Sendable {
10
11 nonisolated struct Link: Equatable, Sendable, Identifiable {
12 var id = UUID()
13 var label: String
14 var url: String
15 }
16
17 var description: String
18 var website: String
19 var about: String
20 var aboutFormat: String
21 var links: [Link]
22
23 init(
24 description: String = "", website: String = "",
25 about: String = "", aboutFormat: String = "md",
26 links: [Link] = []
27 ) {
28 self.description = description
29 self.website = website
30 self.about = about
31 self.aboutFormat = aboutFormat
32 self.links = links
33 }
34
35 init(from profile: ProfileViewModel.Profile) {
36 description = profile.description ?? ""
37 website = profile.website ?? ""
38 about = profile.about ?? ""
39 aboutFormat = profile.aboutFormat ?? "md"
40 links = (profile.links ?? []).map { Link(label: $0.label ?? "", url: $0.url) }
41 }
42
43 /// At most 5 links and every link needs a URL — the server enforces
44 /// both, but the form should not let a save reach it that way. A
45 /// label may not contain `|` either: the server splits a link on its
46 /// first pipe, so a label carrying one would silently corrupt the URL
47 /// rather than be rejected.
48 var validationError: String? {
49 if links.count > 5 { return "At most 5 links." }
50 if links.contains(where: { $0.url.trimmingCharacters(in: .whitespaces).isEmpty }) {
51 return "A link needs a URL."
52 }
53 if links.contains(where: { $0.label.contains("|") }) {
54 return "A link label can't contain \"|\" — that's what separates it from the URL."
55 }
56 return nil
57 }
58
59 /// Everything except the about text, which travels over stdin.
60 func flags() -> [String] {
61 var flags = ["--description", description, "--website", website]
62 if links.isEmpty {
63 flags += ["--link", ""]
64 } else {
65 for link in links {
66 let label = link.label.trimmingCharacters(in: .whitespaces)
67 flags += ["--link", label.isEmpty ? link.url : "\(label)|\(link.url)"]
68 }
69 }
70 flags += ["--about-format", aboutFormat, "--file", "-"]
71 return flags
72 }
73
74 /// The about text, sent over stdin — never in argv, since it is
75 /// long-form and may hold newlines.
76 var aboutStdin: String { about }
77}
gitbay/Discovery/ProfileViewModel.swift +11 −2
@@ -123,12 +123,21 @@ final class ProfileViewModel {
123 return actionError == nil 123 return actionError == nil
124 } 124 }
125 125
126 private func perform(_ argv: [String]) async { 126 /// `profile set` — always the caller's own profile. The about text
127 /// travels over stdin, never argv, since it is long-form and may
128 /// carry newlines.
129 func saveProfile(_ edit: ProfileEdit) async -> Bool {
130 guard edit.validationError == nil else { return false }
131 await perform(["profile", "set"] + edit.flags(), stdin: edit.aboutStdin)
132 return actionError == nil
133 }
134
135 private func perform(_ argv: [String], stdin: String? = nil) async {
127 working = true 136 working = true
128 actionError = nil 137 actionError = nil
129 defer { working = false } 138 defer { working = false }
130 do { 139 do {
131 try await client.run(argv) 140 try await client.run(argv, stdin: stdin)
132 await load() 141 await load()
133 } catch let error as GitbayError { 142 } catch let error as GitbayError {
134 actionError = error.userFacingMessage 143 actionError = error.userFacingMessage
gitbay/Views/Discovery/ProfileEditSheet.swift added +118
@@ -0,0 +1,118 @@
1import SwiftUI
2
3/// `profile set` — always the caller's own. Description and website are
4/// single lines; about is long-form prose in whichever format the
5/// profile already carries, since switching formats reinterprets text
6/// that was already written; links are capped at five, matching the
7/// server.
8struct ProfileEditSheet: View {
9
10 let model: ProfileViewModel
11 let profile: ProfileViewModel.Profile
12
13 @Environment(\.dismiss) private var dismiss
14 @State private var edit: ProfileEdit
15
16 init(model: ProfileViewModel, profile: ProfileViewModel.Profile) {
17 self.model = model
18 self.profile = profile
19 _edit = State(initialValue: ProfileEdit(from: profile))
20 }
21
22 private var localError: String? { edit.validationError }
23
24 var body: some View {
25 NavigationStack {
26 Form {
27 Section("Description") {
28 TextField("Description", text: $edit.description)
29 .accessibilityIdentifier("profile-edit-description")
30 }
31 Section("Website") {
32 TextField("https://example.com", text: $edit.website)
33 .keyboardType(.URL)
34 .autocorrectionDisabled()
35 .textInputAutocapitalization(.never)
36 .accessibilityIdentifier("profile-edit-website")
37 }
38 Section {
39 Picker("Format", selection: $edit.aboutFormat) {
40 Text("Markdown").tag("md")
41 Text("Org").tag("org")
42 }
43 .pickerStyle(.segmented)
44 .accessibilityIdentifier("profile-edit-format")
45 TextEditor(text: $edit.about)
46 .font(.gbMono(.caption))
47 .frame(minHeight: 160)
48 .autocorrectionDisabled()
49 .accessibilityIdentifier("profile-edit-about")
50 } header: {
51 Text("About")
52 } footer: {
53 Text("Changing the format reinterprets text that is already written.")
54 }
55 linksSection
56 if let error = localError ?? model.actionError {
57 Section {
58 GBNotice(error, .gbWarn)
59 }
60 }
61 }
62 .navigationTitle("Edit Profile")
63 .navigationBarTitleDisplayMode(.inline)
64 .toolbar {
65 ToolbarItem(placement: .cancellationAction) {
66 Button("Cancel") { dismiss() }
67 }
68 ToolbarItem(placement: .confirmationAction) {
69 if model.working {
70 ProgressView()
71 } else {
72 Button("Save") { save() }
73 .disabled(localError != nil)
74 .accessibilityIdentifier("profile-edit-save")
75 }
76 }
77 }
78 .interactiveDismissDisabled(model.working)
79 }
80 }
81
82 private var linksSection: some View {
83 Section {
84 ForEach($edit.links) { $link in
85 let index = edit.links.firstIndex { $0.id == link.id } ?? 0
86 VStack(alignment: .leading, spacing: 4) {
87 TextField("Label", text: $link.label)
88 .autocorrectionDisabled()
89 .accessibilityIdentifier("profile-edit-link-label-\(index)")
90 TextField("https://example.com", text: $link.url)
91 .keyboardType(.URL)
92 .autocorrectionDisabled()
93 .textInputAutocapitalization(.never)
94 .accessibilityIdentifier("profile-edit-link-url-\(index)")
95 }
96 }
97 .onDelete { edit.links.remove(atOffsets: $0) }
98 if edit.links.count < 5 {
99 Button {
100 edit.links.append(.init(label: "", url: ""))
101 } label: {
102 Label("Add Link", systemImage: "plus.circle")
103 }
104 .accessibilityIdentifier("profile-edit-add-link")
105 }
106 } header: {
107 Text("Links")
108 } footer: {
109 Text("Up to 5 links.")
110 }
111 }
112
113 private func save() {
114 Task {
115 if await model.saveProfile(edit) { dismiss() }
116 }
117 }
118}
gitbay/Views/Discovery/ProfileView.swift +15
@@ -8,6 +8,7 @@ struct ProfileView: View {
8 @State private var model: ProfileViewModel 8 @State private var model: ProfileViewModel
9 @State private var creatingOrg = false 9 @State private var creatingOrg = false
10 @State private var newOrgName = "" 10 @State private var newOrgName = ""
11 @State private var editingProfile = false
11 12
12 /// Someone else's profile is titled with their name. Your own is 13 /// Someone else's profile is titled with their name. Your own is
13 /// reached from a tab and titled to match it. 14 /// reached from a tab and titled to match it.
@@ -46,6 +47,15 @@ struct ProfileView: View {
46 .toolbar { 47 .toolbar {
47 if let profile = model.state.value, 48 if let profile = model.state.value,
48 !profile.isOrg, session.current?.username == profile.name { 49 !profile.isOrg, session.current?.username == profile.name {
50 ToolbarItem(placement: .topBarTrailing) {
51 Button {
52 editingProfile = true
53 } label: {
54 Image(systemName: "pencil")
55 }
56 .disabled(model.working)
57 .accessibilityIdentifier("profile-edit-button")
58 }
49 ToolbarItem(placement: .topBarTrailing) { 59 ToolbarItem(placement: .topBarTrailing) {
50 Button { 60 Button {
51 newOrgName = "" 61 newOrgName = ""
@@ -60,6 +70,11 @@ struct ProfileView: View {
60 } 70 }
61 .task { await model.load() } 71 .task { await model.load() }
62 .refreshable { await model.load() } 72 .refreshable { await model.load() }
73 .sheet(isPresented: $editingProfile) {
74 if let profile = model.state.value {
75 ProfileEditSheet(model: model, profile: profile)
76 }
77 }
63 .alert("New Organization", isPresented: $creatingOrg) { 78 .alert("New Organization", isPresented: $creatingOrg) {
64 TextField("Name", text: $newOrgName) 79 TextField("Name", text: $newOrgName)
65 .autocorrectionDisabled() 80 .autocorrectionDisabled()
gitbayTests/ProfileEditTests.swift added +233
@@ -0,0 +1,233 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5struct ProfileEditFlagTests {
6
7 private func edit(
8 description: String = "", website: String = "",
9 about: String = "", aboutFormat: String = "md",
10 links: [ProfileEdit.Link] = []
11 ) -> ProfileEdit {
12 ProfileEdit(description: description, website: website,
13 about: about, aboutFormat: aboutFormat, links: links)
14 }
15
16 @Test func everyFieldSendsItsFlag() {
17 let e = edit(description: "hi", website: "https://x.test",
18 links: [.init(label: "blog", url: "https://b.test")])
19 let flags = e.flags()
20 #expect(flags.contains("--description"))
21 #expect(flags.contains("hi"))
22 #expect(flags.contains("--website"))
23 #expect(flags.contains("https://x.test"))
24 #expect(flags.contains("blog|https://b.test"))
25 }
26
27 /// An emptied field CLEARS it. Omitting the flag would leave the old
28 /// value in place, which is a different outcome.
29 @Test func anEmptiedFieldSendsAnEmptyValueNotNothing() {
30 let flags = edit(description: "", website: "https://x.test").flags()
31 let i = try! #require(flags.firstIndex(of: "--description"))
32 #expect(flags[i + 1] == "")
33 }
34
35 /// `--link ""` is how the whole set is cleared. Omitting it would
36 /// leave the existing links untouched.
37 @Test func noLinksSendsAnEmptyLinkFlag() {
38 let flags = edit(description: "hi").flags()
39 let i = try! #require(flags.firstIndex(of: "--link"))
40 #expect(flags[i + 1] == "")
41 }
42
43 /// The set replaces wholesale, so every kept link is sent every time.
44 @Test func everyLinkIsSentSoTheSetReplaces() {
45 let flags = edit(links: [
46 .init(label: "a", url: "https://a.test"),
47 .init(label: "b", url: "https://b.test"),
48 ]).flags()
49 #expect(flags.filter { $0 == "--link" }.count == 2)
50 #expect(flags.contains("a|https://a.test"))
51 #expect(flags.contains("b|https://b.test"))
52 }
53
54 /// Without a separator the whole value is the URL, so a label-less
55 /// link must not send a leading pipe.
56 @Test func aLabellessLinkSendsJustTheUrl() {
57 let flags = edit(links: [.init(label: "", url: "https://a.test")]).flags()
58 #expect(flags.contains("https://a.test"))
59 #expect(flags.contains("|https://a.test") == false)
60 }
61
62 /// Long-form text with newlines belongs in stdin, not argv.
63 @Test func theAboutTextGoesToStdinNotArgv() {
64 let e = edit(about: "line one\n\nline two", aboutFormat: "org")
65 #expect(e.flags().contains { $0.contains("line one") } == false)
66 #expect(e.flags().contains("--file"))
67 #expect(e.flags().contains("-"))
68 #expect(e.aboutStdin == "line one\n\nline two")
69 }
70
71 @Test func theAboutFormatAccompaniesTheText() {
72 let flags = edit(about: "x", aboutFormat: "org").flags()
73 let i = try! #require(flags.firstIndex(of: "--about-format"))
74 #expect(flags[i + 1] == "org")
75 }
76
77 @Test func moreThanFiveLinksIsInvalid() {
78 let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
79 #expect(edit(links: six).validationError != nil)
80 let five = (1...5).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
81 #expect(edit(links: five).validationError == nil)
82 }
83
84 @Test func aLinkWithoutAUrlIsInvalid() {
85 #expect(edit(links: [.init(label: "blog", url: " ")]).validationError != nil)
86 }
87
88 /// The server splits a link on its first `|`, so a label carrying one
89 /// would silently corrupt the URL rather than be rejected — caught
90 /// here, not just in the view.
91 @Test func aLabelContainingAPipeIsInvalid() {
92 #expect(edit(links: [.init(label: "a|b", url: "https://x.test")]).validationError != nil)
93 }
94}
95
96struct ProfileEditFromProfileTests {
97
98 private let orgProfileJSON = """
99 {"name":"cmc","kind":"user",\
100 "description":"about me","website":"https://cleberg.net",\
101 "about":"* heading\\n\\n| a | b |","about_format":"org",\
102 "links":[{"label":null,"url":"https://a.test"},{"label":"blog","url":"https://b.test"}],\
103 "repos":[],"activity_total":0}
104 """
105
106 /// The real profile on gitbay.org is org, with tables and links that
107 /// render as flat text under markdown — the stored format must round
108 /// trip, not default away from what the server holds.
109 @Test func seedsFromAnOrgProfile() throws {
110 let data = try #require(orgProfileJSON.data(using: .utf8))
111 let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data)
112
113 let edit = ProfileEdit(from: profile)
114
115 #expect(edit.aboutFormat == "org")
116 #expect(edit.description == "about me")
117 #expect(edit.website == "https://cleberg.net")
118 #expect(edit.about == "* heading\n\n| a | b |")
119 #expect(edit.links.map(\.url) == ["https://a.test", "https://b.test"])
120 #expect(edit.links.map(\.label) == ["", "blog"])
121 }
122
123 /// Absent `about_format`, seeding falls back to markdown.
124 @Test func seedingFallsBackToMarkdownWhenFormatIsAbsent() throws {
125 let json = """
126 {"name":"cmc","kind":"user",\
127 "description":"hi","repos":[],"activity_total":0}
128 """
129 let data = try #require(json.data(using: .utf8))
130 let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data)
131
132 let edit = ProfileEdit(from: profile)
133
134 #expect(edit.aboutFormat == "md")
135 }
136}
137
138private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
139 let box = StubProtocol.box()
140 let client = GitbayClient(
141 instance: try GitbayInstance(url: "https://gitbay.org"),
142 token: "test-token",
143 session: box.session()
144 )
145 return (client, box)
146}
147
148private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) {
149 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
150 return (try #require(body["argv"] as? [String]), body["stdin"] as? String)
151}
152
153private let okJSON = #"{"protocol_version":1,"exit_code":0}"#
154
155private let profileJSON = """
156 {"protocol_version":1,"data":{"name":"cmc","kind":"user",\
157 "description":"server value","website":"https://cleberg.net","repos":[],\
158 "activity_total":0},"exit_code":0}
159 """
160
161@MainActor
162struct ProfileSaveTests {
163
164 /// The write, then the reload `profile set` triggers — one POST, one
165 /// GET. Both need a stub or the flow ends up looking like a refusal
166 /// even when the write itself succeeded.
167 @Test func savesTheFullArgvWithAboutOnlyInStdin() async throws {
168 let (client, stub) = try makeClient()
169 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
170 stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show"))
171 let model = ProfileViewModel(client: client, name: "cmc")
172 let edit = ProfileEdit(
173 description: "hi", website: "https://cleberg.net",
174 about: "long form\n\nprose", aboutFormat: "org",
175 links: [.init(label: "blog", url: "https://b.test")]
176 )
177
178 let saved = await model.saveProfile(edit)
179
180 #expect(saved)
181 let write = try #require(stub.seen.first { $0.method == "POST" })
182 let (argv, stdin) = try argvOf(write)
183 #expect(argv == [
184 "profile", "set",
185 "--description", "hi", "--website", "https://cleberg.net",
186 "--link", "blog|https://b.test",
187 "--about-format", "org", "--file", "-",
188 ])
189 #expect(stdin == "long form\n\nprose")
190 #expect(argv.contains { $0.contains("long form") } == false)
191 }
192
193 /// The screen shows what the server stored, not what was typed — the
194 /// reload's payload, not the edit, is what the caller sees next.
195 @Test func aSuccessfulSaveReloadsFromTheServer() async throws {
196 let (client, stub) = try makeClient()
197 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
198 stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show"))
199 let model = ProfileViewModel(client: client, name: "cmc")
200
201 let saved = await model.saveProfile(ProfileEdit(description: "typed value", website: "https://cleberg.net"))
202
203 #expect(saved)
204 let profile = try #require(model.state.value)
205 #expect(profile.description == "server value")
206 }
207
208 @Test func aRefusedSaveReturnsFalseAndSurfaces() async throws {
209 let (client, stub) = try makeClient()
210 stub.enqueue(.init(status: 200, json: """
211 {"protocol_version":1,"error":"setting a profile needs write access","exit_code":4}
212 """))
213 let model = ProfileViewModel(client: client, name: "cmc")
214
215 let saved = await model.saveProfile(ProfileEdit(description: "hi"))
216
217 #expect(saved == false)
218 #expect(model.actionError?.isEmpty == false)
219 }
220
221 /// Six links fail `ProfileEdit.validationError`; the save must refuse
222 /// before any request goes out, not after the server rejects it.
223 @Test func anInvalidEditSendsNothingAtAll() async throws {
224 let (client, stub) = try makeClient()
225 let model = ProfileViewModel(client: client, name: "cmc")
226 let six = (1...6).map { ProfileEdit.Link(label: "l\($0)", url: "https://\($0).test") }
227
228 let saved = await model.saveProfile(ProfileEdit(links: six))
229
230 #expect(saved == false)
231 #expect(stub.seen.isEmpty)
232 }
233}