1use std::path::PathBuf;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::Arc;
6
7use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
8use axum::extract::{Multipart, Path, Query, State};
9use axum::http::{header, StatusCode};
10use axum::response::{IntoResponse, Response};
11use axum::Json;
12use chrono::Utc;
13use serde::Deserialize;
14use serde_json::{json, Value};
15use tokio::sync::broadcast;
16
17use wipe_core::forum::{self, NewReply, NewThread, SearchQuery};
18use wipe_core::model::{Board, IdentityKind, Ticket};
19use wipe_core::ops::{self, NewTicket, TicketPatch};
20use wipe_core::{git, Store};
21
22#[derive(Clone)]
24pub struct AppState {
25 pub current: Option<PathBuf>,
30 pub tx: broadcast::Sender<String>,
32 pub clients: Arc<AtomicUsize>,
35}
36
37pub struct ApiError(anyhow::Error);
39
40impl<E: Into<anyhow::Error>> From<E> for ApiError {
41 fn from(e: E) -> Self {
42 ApiError(e.into())
43 }
44}
45
46impl IntoResponse for ApiError {
47 fn into_response(self) -> Response {
48 let body = Json(json!({ "ok": false, "error": self.0.to_string() }));
49 (StatusCode::BAD_REQUEST, body).into_response()
50 }
51}
52
53type ApiResult = Result<Json<Value>, ApiError>;
54
55#[derive(Debug, Deserialize)]
57pub struct ProjectQuery {
58 project: Option<String>,
59}
60
61fn store_for(state: &AppState, project: Option<String>) -> Result<Store, ApiError> {
65 let root = project
66 .map(PathBuf::from)
67 .or_else(|| state.current.clone())
68 .ok_or_else(|| ApiError(anyhow::anyhow!("no project selected")))?;
69 Ok(Store::open(root)?)
70}
71
72fn notify(state: &AppState) {
73 let _ = state.tx.send("changed".to_string());
74}
75
76fn resolve_actor(store: &Store, provided: Option<String>) -> String {
80 provided
81 .filter(|s| !s.trim().is_empty())
82 .or_else(|| git::config_identity(store.root()))
83 .unwrap_or_else(|| "someone".to_string())
84}
85
86fn board_json(board: &Board, view: &[(String, Vec<Ticket>)]) -> Value {
87 let lists: Vec<Value> = view
88 .iter()
89 .map(|(list_id, tickets)| {
90 let name = board
91 .list(list_id)
92 .map(|l| l.name.clone())
93 .unwrap_or_else(|| list_id.clone());
94 json!({ "list": list_id, "name": name, "tickets": tickets })
95 })
96 .collect();
97 json!({ "board": board.name, "lists": lists })
98}
99
100pub async fn health() -> Json<Value> {
104 Json(json!({ "ok": true, "service": "wipe-daemon", "version": env!("CARGO_PKG_VERSION") }))
105}
106
107pub async fn app_config() -> Json<Value> {
110 let g = wipe_core::GlobalConfig::load();
111 Json(json!({ "accent": g.ui_accent, "theme": g.ui_theme }))
112}
113
114pub async fn projects(State(state): State<AppState>) -> ApiResult {
120 let current = state
121 .current
122 .as_ref()
123 .filter(|root| Store::open(root).is_ok())
124 .map(|root| root.display().to_string());
125 if let Some(root) = &state.current {
126 crate::registry::register(root);
127 }
128 Ok(Json(
129 json!({ "projects": crate::registry::list(), "current": current }),
130 ))
131}
132
133pub async fn board(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
135 let store = store_for(&state, q.project)?;
136 let (board, view) = ops::board_view(&store)?;
137 Ok(Json(board_json(&board, &view)))
138}
139
140pub async fn history(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
142 let store = store_for(&state, q.project)?;
143 let commits = git::log(store.root(), Some(".wipe"), Some(200))?;
144 Ok(Json(json!({ "commits": commits })))
145}
146
147#[derive(Debug, Deserialize)]
149pub struct AtQuery {
150 project: Option<String>,
151 commit: String,
152}
153
154pub async fn board_at(State(state): State<AppState>, Query(q): Query<AtQuery>) -> ApiResult {
156 let store = store_for(&state, q.project)?;
157 let root = store.root();
158 let board_src = git::file_at_commit(root, &q.commit, ".wipe/board.json")?
159 .ok_or_else(|| ApiError(anyhow::anyhow!("no board at commit {}", q.commit)))?;
160 let board: Board = serde_json::from_str(&board_src)?;
161
162 let mut lists = Vec::with_capacity(board.lists.len());
163 for list in &board.lists {
164 let mut tickets = Vec::with_capacity(list.cards.len());
165 for id in &list.cards {
166 let rel = format!(".wipe/tickets/{id}.json");
167 if let Some(src) = git::file_at_commit(root, &q.commit, &rel)? {
168 if let Ok(t) = serde_json::from_str::<Ticket>(&src) {
169 tickets.push(t);
170 }
171 }
172 }
173 lists.push(json!({ "list": list.id, "name": list.name, "tickets": tickets }));
174 }
175 Ok(Json(
176 json!({ "board": board.name, "commit": q.commit, "lists": lists }),
177 ))
178}
179
180#[derive(Debug, Deserialize)]
184pub struct CreateTicketBody {
185 project: Option<String>,
186 title: String,
187 #[serde(default)]
188 body: Option<String>,
189 #[serde(default)]
190 priority: Option<String>,
191 #[serde(default)]
192 list: Option<String>,
193 #[serde(default)]
194 labels: Vec<String>,
195 #[serde(default)]
196 assignees: Vec<String>,
197 #[serde(default)]
198 actor: Option<String>,
199}
200
201pub async fn create_ticket(
203 State(state): State<AppState>,
204 Query(pq): Query<ProjectQuery>,
205 Json(b): Json<CreateTicketBody>,
206) -> ApiResult {
207 let store = store_for(&state, pq.project.or(b.project))?;
208 let actor = resolve_actor(&store, b.actor);
209 let spec = NewTicket {
210 title: b.title,
211 body: b.body,
212 priority: b.priority,
213 list: b.list,
214 labels: b.labels,
215 assignees: b.assignees,
216 };
217 let ticket = ops::create_ticket(&store, spec, &actor, Utc::now())?;
218 notify(&state);
219 Ok(Json(serde_json::to_value(ticket)?))
220}
221
222#[derive(Debug, Deserialize)]
224pub struct MoveBody {
225 project: Option<String>,
226 to: String,
227 #[serde(default)]
228 pos: Option<usize>,
229 #[serde(default)]
230 actor: Option<String>,
231}
232
233pub async fn move_ticket(
235 State(state): State<AppState>,
236 Path(id): Path<String>,
237 Query(pq): Query<ProjectQuery>,
238 Json(b): Json<MoveBody>,
239) -> ApiResult {
240 let store = store_for(&state, pq.project.or(b.project))?;
241 let actor = resolve_actor(&store, b.actor);
242 ops::move_ticket(&store, &id, &b.to, b.pos, &actor, Utc::now())?;
243 notify(&state);
244 Ok(Json(json!({ "ok": true, "id": id, "list": b.to })))
245}
246
247#[derive(Debug, Deserialize)]
249pub struct CommentBody {
250 project: Option<String>,
251 #[serde(default)]
252 author: Option<String>,
253 body: String,
254}
255
256pub async fn add_comment(
258 State(state): State<AppState>,
259 Path(id): Path<String>,
260 Query(pq): Query<ProjectQuery>,
261 Json(b): Json<CommentBody>,
262) -> ApiResult {
263 let store = store_for(&state, pq.project.or(b.project))?;
264 let author = b.author.unwrap_or_else(|| "ui".to_string());
265 let cid = ops::add_comment(&store, &id, &author, &b.body, Utc::now())?;
266 notify(&state);
267 Ok(Json(json!({ "ok": true, "ticket": id, "comment": cid })))
268}
269
270pub async fn definitions(
272 State(state): State<AppState>,
273 Query(q): Query<ProjectQuery>,
274) -> ApiResult {
275 let store = store_for(&state, q.project)?;
276 Ok(Json(serde_json::to_value(store.load_definitions()?)?))
277}
278
279#[derive(Debug, Deserialize)]
281pub struct LabelBody {
282 project: Option<String>,
283 name: String,
284 #[serde(default)]
285 color: Option<String>,
286 #[serde(default)]
287 description: Option<String>,
288}
289
290pub async fn create_label(
292 State(state): State<AppState>,
293 Query(pq): Query<ProjectQuery>,
294 Json(b): Json<LabelBody>,
295) -> ApiResult {
296 let store = store_for(&state, pq.project.or(b.project))?;
297 let label = ops::create_label(&store, &b.name, b.color, b.description)?;
298 notify(&state);
299 Ok(Json(serde_json::to_value(label)?))
300}
301
302#[derive(Debug, Deserialize)]
304pub struct LabelColorBody {
305 project: Option<String>,
306 color: String,
307}
308
309pub async fn recolor_label(
311 State(state): State<AppState>,
312 Path(name): Path<String>,
313 Query(pq): Query<ProjectQuery>,
314 Json(b): Json<LabelColorBody>,
315) -> ApiResult {
316 let store = store_for(&state, pq.project.or(b.project))?;
317 let label = ops::set_label_color(&store, &name, &b.color)?;
318 notify(&state);
319 Ok(Json(serde_json::to_value(label)?))
320}
321
322pub async fn delete_label(
324 State(state): State<AppState>,
325 Path(name): Path<String>,
326 Query(q): Query<ProjectQuery>,
327) -> ApiResult {
328 let store = store_for(&state, q.project)?;
329 ops::delete_label(&store, &name, Utc::now())?;
330 notify(&state);
331 Ok(Json(json!({ "ok": true })))
332}
333
334#[derive(Debug, Deserialize)]
337pub struct PatchBody {
338 project: Option<String>,
339 #[serde(default)]
340 title: Option<String>,
341 #[serde(default)]
342 body: Option<String>,
343 #[serde(default)]
344 priority: Option<Option<String>>,
345 #[serde(default)]
346 labels: Option<Vec<String>>,
347 #[serde(default)]
348 assignees: Option<Vec<String>>,
349 #[serde(default)]
350 actor: Option<String>,
351}
352
353pub async fn patch_ticket(
355 State(state): State<AppState>,
356 Path(id): Path<String>,
357 Query(pq): Query<ProjectQuery>,
358 Json(b): Json<PatchBody>,
359) -> ApiResult {
360 let store = store_for(&state, pq.project.or(b.project))?;
361 let actor = resolve_actor(&store, b.actor);
362 let patch = TicketPatch {
363 title: b.title,
364 body: b.body,
365 priority: b.priority,
366 labels: b.labels,
367 assignees: b.assignees,
368 };
369 let ticket = ops::update_ticket(&store, &id, patch, &actor, Utc::now())?;
370 notify(&state);
371 Ok(Json(serde_json::to_value(ticket)?))
372}
373
374pub async fn identities(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
376 let store = store_for(&state, q.project)?;
377 Ok(Json(json!({ "identities": ops::list_identities(&store)? })))
378}
379
380#[derive(Debug, Deserialize)]
382pub struct IdentityBody {
383 project: Option<String>,
384 display_name: String,
385 #[serde(default)]
386 kind: Option<String>,
387}
388
389pub async fn put_identity(
391 State(state): State<AppState>,
392 Path(id): Path<String>,
393 Query(pq): Query<ProjectQuery>,
394 Json(b): Json<IdentityBody>,
395) -> ApiResult {
396 let store = store_for(&state, pq.project.or(b.project))?;
397 let kind = match b.kind.as_deref() {
398 Some("agent") => Some(IdentityKind::Agent),
399 Some("human") => Some(IdentityKind::Human),
400 _ => None,
401 };
402 let ident = ops::upsert_identity(&store, &id, &b.display_name, kind)?;
403 notify(&state);
404 Ok(Json(serde_json::to_value(ident)?))
405}
406
407pub async fn delete_identity(
410 State(state): State<AppState>,
411 Path(id): Path<String>,
412 Query(q): Query<ProjectQuery>,
413) -> ApiResult {
414 let store = store_for(&state, q.project)?;
415 ops::delete_identity(&store, &id)?;
416 notify(&state);
417 Ok(Json(json!({ "ok": true, "id": id })))
418}
419
420pub async fn upload_attachment(
422 State(state): State<AppState>,
423 Path(id): Path<String>,
424 Query(q): Query<ProjectQuery>,
425 mut multipart: Multipart,
426) -> ApiResult {
427 let store = store_for(&state, q.project)?;
428 let actor = resolve_actor(&store, None);
429 let max = store.load_settings()?.max_attachment_mb * 1024 * 1024;
430
431 while let Some(field) = multipart
432 .next_field()
433 .await
434 .map_err(|e| ApiError(anyhow::anyhow!("bad upload: {e}")))?
435 {
436 if field.name() != Some("file") {
437 continue;
438 }
439 let name = field.file_name().unwrap_or("file").to_string();
440 let mime = field
441 .content_type()
442 .map(|s| s.to_string())
443 .unwrap_or_else(|| "application/octet-stream".to_string());
444 let bytes = field
445 .bytes()
446 .await
447 .map_err(|e| ApiError(anyhow::anyhow!("read upload: {e}")))?;
448 if bytes.len() as u64 > max {
449 return Err(ApiError(anyhow::anyhow!(
450 "attachment is {:.1} MB, over the {} MB limit",
451 bytes.len() as f64 / 1_048_576.0,
452 max / 1024 / 1024
453 )));
454 }
455 let att = ops::add_attachment(&store, &id, &name, &bytes, &mime, &actor, Utc::now())?;
456 notify(&state);
457 return Ok(Json(serde_json::to_value(att)?));
458 }
459 Err(ApiError(anyhow::anyhow!("no `file` field in upload")))
460}
461
462#[derive(Debug, Deserialize)]
464pub struct DetachBody {
465 project: Option<String>,
466 path: String,
467 #[serde(default)]
468 actor: Option<String>,
469}
470
471pub async fn delete_attachment(
473 State(state): State<AppState>,
474 Path(id): Path<String>,
475 Query(pq): Query<ProjectQuery>,
476 Json(b): Json<DetachBody>,
477) -> ApiResult {
478 let store = store_for(&state, pq.project.or(b.project))?;
479 let actor = resolve_actor(&store, b.actor);
480 ops::remove_attachment(&store, &id, &b.path, &actor, Utc::now())?;
481 notify(&state);
482 Ok(Json(json!({ "ok": true })))
483}
484
485pub async fn serve_media(
487 State(state): State<AppState>,
488 Query(q): Query<ProjectQuery>,
489 Path(path): Path<String>,
490) -> Response {
491 let store = match store_for(&state, q.project) {
492 Ok(s) => s,
493 Err(e) => return e.into_response(),
494 };
495 let rel = path.replace('\\', "/");
496 if rel.contains("..") {
497 return (StatusCode::BAD_REQUEST, "invalid path").into_response();
498 }
499 let full = store.root().join(&rel);
500 let within = std::fs::canonicalize(store.root())
502 .ok()
503 .zip(std::fs::canonicalize(&full).ok())
504 .map(|(root, target)| target.starts_with(root))
505 .unwrap_or(false);
506 if !within {
507 return (StatusCode::NOT_FOUND, "not found").into_response();
508 }
509 match std::fs::read(&full) {
510 Ok(bytes) => {
511 let mime = mime_guess::from_path(&full).first_or_octet_stream();
512 ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response()
513 }
514 Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
515 }
516}
517
518pub async fn graph(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
520 let store = store_for(&state, q.project)?;
521 let commits = git::graph(store.root(), Some(300))?;
522 Ok(Json(json!({ "commits": commits })))
523}
524
525#[derive(Debug, Deserialize)]
527pub struct AddListBody {
528 project: Option<String>,
529 name: String,
530}
531
532pub async fn add_list(
534 State(state): State<AppState>,
535 Query(pq): Query<ProjectQuery>,
536 Json(b): Json<AddListBody>,
537) -> ApiResult {
538 let store = store_for(&state, pq.project.or(b.project))?;
539 let id = ops::add_list(&store, &b.name, Utc::now())?;
540 notify(&state);
541 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
542}
543
544#[derive(Debug, Deserialize)]
546pub struct RenameListBody {
547 project: Option<String>,
548 name: String,
549}
550
551pub async fn rename_list(
553 State(state): State<AppState>,
554 Path(id): Path<String>,
555 Query(pq): Query<ProjectQuery>,
556 Json(b): Json<RenameListBody>,
557) -> ApiResult {
558 let store = store_for(&state, pq.project.or(b.project))?;
559 ops::rename_list(&store, &id, &b.name, Utc::now())?;
560 notify(&state);
561 Ok(Json(json!({ "ok": true, "id": id, "name": b.name })))
562}
563
564#[derive(Debug, Deserialize)]
566pub struct MoveListBody {
567 project: Option<String>,
568 index: usize,
569}
570
571pub async fn move_list(
573 State(state): State<AppState>,
574 Path(id): Path<String>,
575 Query(pq): Query<ProjectQuery>,
576 Json(b): Json<MoveListBody>,
577) -> ApiResult {
578 let store = store_for(&state, pq.project.or(b.project))?;
579 ops::move_list(&store, &id, b.index, Utc::now())?;
580 notify(&state);
581 Ok(Json(json!({ "ok": true, "id": id, "index": b.index })))
582}
583
584#[derive(Debug, Deserialize)]
586pub struct RemoveListQuery {
587 project: Option<String>,
588 #[serde(default)]
589 force: bool,
590}
591
592pub async fn remove_list(
594 State(state): State<AppState>,
595 Path(id): Path<String>,
596 Query(q): Query<RemoveListQuery>,
597) -> ApiResult {
598 let store = store_for(&state, q.project)?;
599 ops::remove_list(&store, &id, q.force, Utc::now())?;
600 notify(&state);
601 Ok(Json(json!({ "ok": true, "id": id })))
602}
603
604pub async fn forum_list(State(state): State<AppState>, Query(q): Query<ProjectQuery>) -> ApiResult {
608 let store = store_for(&state, q.project)?;
609 let all = forum::index(&store)?;
610 let threads: Vec<Value> = all
611 .iter()
612 .filter(|p| p.depth == 0)
613 .map(|r| {
614 let posts = all.iter().filter(|p| p.thread_id == r.thread_id).count();
615 json!({
616 "id": r.thread_id,
617 "title": r.thread_title,
618 "author": r.author,
619 "labels": r.labels,
620 "posts": posts,
621 "created": r.created,
622 })
623 })
624 .collect();
625 Ok(Json(json!({ "threads": threads })))
626}
627
628pub async fn forum_thread(
630 State(state): State<AppState>,
631 Path(id): Path<String>,
632 Query(q): Query<ProjectQuery>,
633) -> ApiResult {
634 let store = store_for(&state, q.project)?;
635 Ok(Json(serde_json::to_value(forum::get_thread(&store, &id)?)?))
636}
637
638#[derive(Debug, Deserialize)]
640pub struct ForumPostBody {
641 project: Option<String>,
642 title: String,
643 #[serde(default)]
644 body: String,
645 #[serde(default)]
646 labels: Vec<String>,
647 #[serde(default)]
648 refs: Vec<String>,
649 #[serde(default)]
650 actor: Option<String>,
651}
652
653pub async fn forum_create(
655 State(state): State<AppState>,
656 Query(pq): Query<ProjectQuery>,
657 Json(b): Json<ForumPostBody>,
658) -> ApiResult {
659 let store = store_for(&state, pq.project.or(b.project))?;
660 let actor = resolve_actor(&store, b.actor);
661 let t = forum::create_thread(
662 &store,
663 NewThread {
664 title: b.title,
665 body: b.body,
666 labels: b.labels,
667 refs: b.refs,
668 attachments: Vec::new(),
669 },
670 &actor,
671 Utc::now(),
672 )?;
673 notify(&state);
674 Ok(Json(serde_json::to_value(t)?))
675}
676
677#[derive(Debug, Deserialize)]
679pub struct ForumReplyBody {
680 project: Option<String>,
681 body: String,
682 #[serde(default)]
683 labels: Vec<String>,
684 #[serde(default)]
685 refs: Vec<String>,
686 #[serde(default)]
687 actor: Option<String>,
688}
689
690pub async fn forum_reply(
692 State(state): State<AppState>,
693 Path(id): Path<String>,
694 Query(pq): Query<ProjectQuery>,
695 Json(b): Json<ForumReplyBody>,
696) -> ApiResult {
697 let store = store_for(&state, pq.project.or(b.project))?;
698 let actor = resolve_actor(&store, b.actor);
699 let child = forum::reply(
700 &store,
701 &id,
702 NewReply {
703 body: b.body,
704 labels: b.labels,
705 refs: b.refs,
706 attachments: Vec::new(),
707 },
708 &actor,
709 Utc::now(),
710 )?;
711 notify(&state);
712 Ok(Json(json!({ "ok": true, "id": child, "parent": id })))
713}
714
715#[derive(Debug, Deserialize)]
717pub struct ForumEditBody {
718 project: Option<String>,
719 body: String,
720}
721
722pub async fn forum_edit(
724 State(state): State<AppState>,
725 Path(id): Path<String>,
726 Query(pq): Query<ProjectQuery>,
727 Json(b): Json<ForumEditBody>,
728) -> ApiResult {
729 let store = store_for(&state, pq.project.or(b.project))?;
730 forum::edit_post(&store, &id, &b.body, Utc::now())?;
731 notify(&state);
732 Ok(Json(json!({ "ok": true, "id": id })))
733}
734
735pub async fn forum_delete(
737 State(state): State<AppState>,
738 Path(id): Path<String>,
739 Query(q): Query<ProjectQuery>,
740) -> ApiResult {
741 let store = store_for(&state, q.project)?;
742 forum::delete_post(&store, &id, Utc::now())?;
743 notify(&state);
744 Ok(Json(json!({ "ok": true, "id": id })))
745}
746
747#[derive(Debug, Deserialize)]
749pub struct ForumSearchParams {
750 project: Option<String>,
751 #[serde(default)]
752 q: Option<String>,
753 #[serde(default)]
754 author: Option<String>,
755 #[serde(default)]
756 label: Option<String>,
757 #[serde(default)]
758 scope: Option<String>,
759 #[serde(default)]
760 titles: Option<bool>,
761 #[serde(default)]
762 depth: Option<usize>,
763 #[serde(default)]
764 limit: Option<usize>,
765}
766
767pub async fn forum_search(
769 State(state): State<AppState>,
770 Query(p): Query<ForumSearchParams>,
771) -> ApiResult {
772 let store = store_for(&state, p.project)?;
773 let pattern = match p.q.as_deref() {
774 Some(s) if !s.trim().is_empty() => Some(forum::compile_pattern(s, true)?),
775 _ => None,
776 };
777 let query = SearchQuery {
778 pattern,
779 author: p.author,
780 labels: p.label.into_iter().collect(),
781 scope: p.scope,
782 max_depth: p.depth,
783 titles_only: p.titles.unwrap_or(false),
784 limit: p.limit,
785 };
786 Ok(Json(json!({ "posts": forum::search(&store, &query)? })))
787}
788
789pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
793 ws.on_upgrade(move |socket| ws_loop(socket, state))
794}
795
796struct ClientGuard(Arc<AtomicUsize>);
798impl Drop for ClientGuard {
799 fn drop(&mut self) {
800 self.0.fetch_sub(1, Ordering::SeqCst);
801 }
802}
803
804async fn ws_loop(mut socket: WebSocket, state: AppState) {
805 let mut rx = state.tx.subscribe();
806 state.clients.fetch_add(1, Ordering::SeqCst);
809 let _guard = ClientGuard(state.clients.clone());
810 let _ = socket.send(Message::Text("connected".into())).await;
811 loop {
812 tokio::select! {
813 msg = rx.recv() => match msg {
814 Ok(m) => {
815 if socket.send(Message::Text(m.into())).await.is_err() {
816 break;
817 }
818 }
819 Err(broadcast::error::RecvError::Closed) => break,
820 Err(broadcast::error::RecvError::Lagged(_)) => {}
821 },
822 incoming = socket.recv() => match incoming {
823 Some(Ok(_)) => {}
824 _ => break,
825 },
826 }
827 }
828}