Commit b475734cc0

b475734cc076e8abc98aadce653bcb71b8e22d95

parent: 5cbcadb314

Verified · cmc

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

Profile edit value type and its flags

Layout: unified · split

gitbay/Discovery/ProfileEdit.swift added +74
@@ -0,0 +1,74 @@
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 {
12 var label: String
13 var url: String
14 }
15
16 var description: String
17 var website: String
18 var about: String
19 var aboutFormat: String
20 var links: [Link]
21
22 init(
23 description: String = "", website: String = "",
24 about: String = "", aboutFormat: String = "md",
25 links: [Link] = []
26 ) {
27 self.description = description
28 self.website = website
29 self.about = about
30 self.aboutFormat = aboutFormat
31 self.links = links
32 }
33
34 init(from profile: ProfileViewModel.Profile) {
35 description = profile.description ?? ""
36 website = profile.website ?? ""
37 about = profile.about ?? ""
38 aboutFormat = profile.aboutFormat ?? "md"
39 links = (profile.links ?? []).map { Link(label: $0.label ?? "", url: $0.url) }
40 }
41
42 var isEmpty: Bool {
43 description.isEmpty && website.isEmpty && about.isEmpty && links.isEmpty
44 }
45
46 /// At most 5 links, and every link needs a URL — the server enforces
47 /// both, but the form should not let a save reach it that way.
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 return nil
54 }
55
56 /// Everything except the about text, which travels over stdin.
57 func flags() -> [String] {
58 var flags = ["--description", description, "--website", website]
59 if links.isEmpty {
60 flags += ["--link", ""]
61 } else {
62 for link in links {
63 let label = link.label.trimmingCharacters(in: .whitespaces)
64 flags += ["--link", label.isEmpty ? link.url : "\(label)|\(link.url)"]
65 }
66 }
67 flags += ["--about-format", aboutFormat, "--file", "-"]
68 return flags
69 }
70
71 /// The about text, sent over stdin — never in argv, since it is
72 /// long-form and may hold newlines.
73 var aboutStdin: String? { about }
74}
gitbayTests/ProfileEditTests.swift added +87
@@ -0,0 +1,87 @@
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}