1use std::collections::BTreeMap;
11use std::fs::File;
12use std::path::PathBuf;
13
14use anyhow::Context;
15use serde::Serialize;
16
17use crate::boards::BoardRef;
18use crate::checkout::Repository;
19use crate::client::{
20 CreateGistRequest, CreateIssueRequest, GitHubClient, GitHubComment, GitHubGist, GitHubIssue,
21 GitHubProject, GitHubProjectItem, ProjectFieldWrite, UpdateGistRequest, UpdateIssueRequest,
22};
23use crate::mirror::{Document, Mirror, Scope, Standing};
24use crate::sync::{self, SyncReport};
25
26#[derive(Debug)]
32pub enum Failure {
33 Invalid(String),
35 NoToken(String),
37 GitHub(anyhow::Error),
39 Mirror(anyhow::Error),
41}
42
43impl Failure {
44 pub fn is_authorization(&self) -> bool {
47 match self {
48 Self::NoToken(_) => true,
49 Self::GitHub(error) => {
50 let message = format!("{error:#}");
51 message.contains("status 401") || message.contains("status 403")
52 }
53 _ => false,
54 }
55 }
56}
57
58impl std::fmt::Display for Failure {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Self::Invalid(message) | Self::NoToken(message) => f.write_str(message),
62 Self::GitHub(error) => write!(f, "{error:#}"),
63 Self::Mirror(error) => write!(f, "the GitHub mirror failed: {error:#}"),
64 }
65 }
66}
67
68impl std::error::Error for Failure {}
69
70pub type Outcome<T> = std::result::Result<T, Failure>;
71
72fn mirror_failure(error: anyhow::Error) -> Failure {
73 Failure::Mirror(error)
74}
75
76fn github_failure(error: anyhow::Error) -> Failure {
77 Failure::GitHub(error)
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum IssueState {
83 Open,
84 Closed,
85 All,
86}
87
88impl std::str::FromStr for IssueState {
89 type Err = String;
90 fn from_str(text: &str) -> Result<Self, String> {
91 match text {
92 "open" => Ok(Self::Open),
93 "closed" => Ok(Self::Closed),
94 "all" => Ok(Self::All),
95 other => Err(format!("unsupported issue state '{other}': open, closed or all")),
96 }
97 }
98}
99
100impl IssueState {
101 fn admits(self, issue: &serde_json::Value) -> bool {
102 let state = issue.get("state").and_then(|value| value.as_str()).unwrap_or("");
103 match self {
104 Self::Open => state == "open",
105 Self::Closed => state == "closed",
106 Self::All => true,
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct RepoFailure {
114 pub repo: String,
115 pub error: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize)]
120pub struct IssueList {
121 pub issues: Vec<serde_json::Value>,
123 pub failures: Vec<RepoFailure>,
124 pub sync: BTreeMap<String, Standing>,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize)]
130pub struct Listed {
131 pub items: Vec<serde_json::Value>,
132 pub sync: Standing,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize)]
138pub struct Single {
139 pub item: Option<serde_json::Value>,
140 pub sync: Standing,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
145pub struct RepoFacts {
146 pub open_issues: i64,
147 pub open_pulls: i64,
148 pub private: Option<bool>,
151 pub archived: Option<bool>,
152 pub stars: Option<i64>,
153 pub checks: Option<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Serialize)]
159pub struct BoardList {
160 pub boards: Vec<GitHubProject>,
161 pub problems: Vec<String>,
162 pub sync: BTreeMap<String, Standing>,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "snake_case")]
169pub enum Relation {
170 Account,
172 Organization,
174 Referenced,
176}
177
178#[derive(Debug, Clone, PartialEq, Serialize)]
180pub struct BoardOwner {
181 pub login: String,
182 pub relation: Relation,
183 pub sync: Standing,
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize)]
188pub struct AllBoards {
189 pub owners: Vec<BoardOwner>,
192 pub boards: Vec<GitHubProject>,
194 pub referenced: Vec<String>,
197 pub account: Standing,
199 pub problems: Vec<String>,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize)]
204pub struct Cards {
205 pub items: Vec<GitHubProjectItem>,
206 pub sync: Standing,
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize)]
211pub struct GistAnswer {
212 pub gist: serde_json::Value,
213 pub from_mirror: bool,
214}
215
216#[derive(Debug, Clone)]
218pub struct Service {
219 mirror: Mirror,
220}
221
222impl Service {
223 pub fn new(mirror: Mirror) -> Self {
224 Self { mirror }
225 }
226
227 pub fn mirror(&self) -> &Mirror {
228 &self.mirror
229 }
230
231 fn standing(&self, scope: &Scope) -> Outcome<Standing> {
234 Ok(self.mirror.state(scope).map_err(mirror_failure)?.standing())
235 }
236
237 pub fn issues(&self, repos: &[String], wanted: IssueState) -> Outcome<IssueList> {
239 let mut issues = Vec::new();
240 let mut failures = Vec::new();
241 let mut standing = BTreeMap::new();
242 for repo in repos {
243 let scope = Scope::Issues(repo.clone());
244 for mut issue in self.mirror.payloads(&scope).map_err(mirror_failure)? {
245 if !wanted.admits(&issue) {
246 continue;
247 }
248 if let Some(object) = issue.as_object_mut() {
249 object.insert("repo".to_string(), serde_json::json!(repo));
250 }
251 issues.push(issue);
252 }
253 let state = self.mirror.state(&scope).map_err(mirror_failure)?;
254 if let Some(error) = &state.last_error {
257 failures.push(RepoFailure {
258 repo: repo.clone(),
259 error: error.clone(),
260 });
261 }
262 standing.insert(repo.clone(), state.standing());
263 }
264 issues.sort_by(|a, b| updated_of(b).cmp(updated_of(a)));
265 Ok(IssueList {
266 issues,
267 failures,
268 sync: standing,
269 })
270 }
271
272 pub fn list(&self, scope: &Scope) -> Outcome<Listed> {
274 Ok(Listed {
275 items: self.mirror.payloads(scope).map_err(mirror_failure)?,
276 sync: self.standing(scope)?,
277 })
278 }
279
280 pub fn single(&self, scope: &Scope) -> Outcome<Single> {
282 Ok(Single {
283 item: self.mirror.only(scope).map_err(mirror_failure)?,
284 sync: self.standing(scope)?,
285 })
286 }
287
288 pub fn overview(&self, repositories: &[Repository]) -> Outcome<BTreeMap<String, RepoFacts>> {
290 let open_issues = self.mirror.open_counts("issues").map_err(mirror_failure)?;
291 let open_pulls = self.mirror.open_counts("pulls").map_err(mirror_failure)?;
292 let mut facts = BTreeMap::new();
293 for repository in repositories {
294 let repo = &repository.name;
295 let metadata = self
296 .mirror
297 .only(&Scope::Repository(repo.clone()))
298 .map_err(mirror_failure)?;
299 let checks = match &repository.head {
300 Some(commit) => self
301 .mirror
302 .only(&Scope::Checks {
303 repo: repo.clone(),
304 commit: commit.clone(),
305 })
306 .map_err(mirror_failure)?,
307 None => None,
308 };
309 facts.insert(
310 repo.clone(),
311 RepoFacts {
312 open_issues: open_issues.get(repo).copied().unwrap_or(0),
313 open_pulls: open_pulls.get(repo).copied().unwrap_or(0),
314 private: metadata.as_ref().and_then(|m| m.get("private")?.as_bool()),
315 archived: metadata.as_ref().and_then(|m| m.get("archived")?.as_bool()),
316 stars: metadata.as_ref().and_then(|m| m.get("stargazers_count")?.as_i64()),
317 checks: checks
318 .as_ref()
319 .and_then(|c| c.get("state")?.as_str().map(str::to_string)),
320 },
321 );
322 }
323 Ok(facts)
324 }
325
326 pub fn gists(&self) -> Outcome<Listed> {
328 self.list(&Scope::Gists)
329 }
330
331 pub async fn gist(&self, client: Option<&GitHubClient>, id: &str) -> Outcome<GistAnswer> {
338 if let Some(held) = self.mirror.document(&Scope::Gists, id).map_err(mirror_failure)? {
339 if held.complete {
340 return Ok(GistAnswer {
341 gist: held.payload,
342 from_mirror: true,
343 });
344 }
345 }
346 let client = client.ok_or_else(|| {
347 Failure::NoToken(format!(
348 "gist {id} is held only as a summary, and there is no GitHub token to read it with \
349 (log in with `gh auth login`)"
350 ))
351 })?;
352 let gist = client.get_gist(id).await.map_err(github_failure)?;
353 let payload = self.record_gist(&gist)?;
354 Ok(GistAnswer {
355 gist: payload,
356 from_mirror: false,
357 })
358 }
359
360 pub async fn comments(
366 &self,
367 client: Option<&GitHubClient>,
368 repo: &str,
369 number: i64,
370 ) -> Outcome<Listed> {
371 let scope = Scope::Comments {
372 repo: repo.to_string(),
373 number,
374 };
375 self.mirror.hold(&scope).map_err(mirror_failure)?;
376 let state = self.mirror.state(&scope).map_err(mirror_failure)?;
377 if !state.ever_synced() {
378 if let Some(client) = client {
379 let report = sync::sync_scopes(&self.mirror, client, &[scope.clone()], true).await;
380 if let Some((_, error)) = report.failed.first() {
381 return Err(Failure::GitHub(anyhow::anyhow!("{error}")));
382 }
383 }
384 }
385 self.list(&scope)
386 }
387
388 fn record_issue(&self, repo: &str, issue: &GitHubIssue) -> Outcome<()> {
391 let payload = serde_json::to_value(issue).map_err(|e| mirror_failure(e.into()))?;
392 self.mirror
393 .put_one(
394 &Scope::Issues(repo.to_string()),
395 Document::of(issue.number.to_string(), payload),
396 )
397 .map_err(mirror_failure)?;
398 Ok(())
399 }
400
401 fn record_gist(&self, gist: &GitHubGist) -> Outcome<serde_json::Value> {
402 let payload = serde_json::to_value(gist).map_err(|e| mirror_failure(e.into()))?;
403 self.mirror
406 .put_one(&Scope::Gists, Document::of(gist.id.clone(), payload.clone()))
407 .map_err(mirror_failure)?;
408 Ok(payload)
409 }
410
411 pub async fn create_issue(
412 &self,
413 client: &GitHubClient,
414 owner: &str,
415 repo: &str,
416 request: &CreateIssueRequest,
417 ) -> Outcome<GitHubIssue> {
418 if request.title.trim().is_empty() {
419 return Err(Failure::Invalid("an issue needs a title".to_string()));
420 }
421 let issue = client
422 .create_issue(owner, repo, request)
423 .await
424 .map_err(github_failure)?;
425 self.record_issue(&format!("{owner}/{repo}"), &issue)?;
428 Ok(issue)
429 }
430
431 pub async fn update_issue(
432 &self,
433 client: &GitHubClient,
434 owner: &str,
435 repo: &str,
436 number: i64,
437 request: &UpdateIssueRequest,
438 ) -> Outcome<GitHubIssue> {
439 let issue = client
440 .update_issue(owner, repo, number, request)
441 .await
442 .map_err(github_failure)?;
443 self.record_issue(&format!("{owner}/{repo}"), &issue)?;
444 Ok(issue)
445 }
446
447 pub async fn add_comment(
448 &self,
449 client: &GitHubClient,
450 owner: &str,
451 repo: &str,
452 number: i64,
453 body: &str,
454 ) -> Outcome<GitHubComment> {
455 if body.trim().is_empty() {
456 return Err(Failure::Invalid("a comment needs a body".to_string()));
457 }
458 let comment = client
459 .add_comment(owner, repo, number, body)
460 .await
461 .map_err(github_failure)?;
462 let scope = Scope::Comments {
463 repo: format!("{owner}/{repo}"),
464 number,
465 };
466 let payload = serde_json::to_value(&comment).map_err(|e| mirror_failure(e.into()))?;
467 self.mirror.hold(&scope).map_err(mirror_failure)?;
468 self.mirror
469 .put_one(&scope, Document::of(comment.id.to_string(), payload))
470 .map_err(mirror_failure)?;
471 Ok(comment)
472 }
473
474 pub async fn create_gist(
475 &self,
476 client: &GitHubClient,
477 request: &CreateGistRequest,
478 ) -> Outcome<serde_json::Value> {
479 if request.files.is_empty() {
480 return Err(Failure::Invalid("a gist needs at least one file".to_string()));
481 }
482 let gist = client.create_gist(request).await.map_err(github_failure)?;
483 self.record_gist(&gist)
484 }
485
486 pub async fn update_gist(
487 &self,
488 client: &GitHubClient,
489 id: &str,
490 request: &UpdateGistRequest,
491 ) -> Outcome<serde_json::Value> {
492 let gist = client.update_gist(id, request).await.map_err(github_failure)?;
493 self.record_gist(&gist)
494 }
495
496 pub fn boards(&self, references: &[BoardRef]) -> Outcome<BoardList> {
500 let wanted = references.iter().cloned().collect();
501 let (boards, problems) =
502 sync::resolve_boards(&self.mirror, &wanted).map_err(mirror_failure)?;
503 let mut ordered = Vec::new();
505 for reference in references {
506 if let Some(board) = boards
507 .iter()
508 .find(|board| board.owner == reference.owner && board.number == reference.number)
509 {
510 ordered.push(board.clone());
511 }
512 }
513 let mut standing = BTreeMap::new();
514 for reference in references {
515 let scope = Scope::Projects(reference.owner.clone());
516 standing.insert(scope.key(), self.standing(&scope)?);
517 }
518 Ok(BoardList {
519 boards: ordered,
520 problems,
521 sync: standing,
522 })
523 }
524
525 pub fn all_boards(&self, references: &[BoardRef]) -> Outcome<AllBoards> {
534 let account = self.standing(&Scope::Viewer)?;
535 let viewer = sync::viewer(&self.mirror).map_err(mirror_failure)?;
536 let mut problems = Vec::new();
537
538 let mut owners: Vec<(String, Relation)> = Vec::new();
539 match viewer {
540 Some(viewer) => {
541 owners.push((viewer.login, Relation::Account));
542 let mut organizations = viewer.organizations;
543 organizations.sort_by_key(|login| login.to_lowercase());
544 owners.extend(organizations.into_iter().map(|login| (login, Relation::Organization)));
545 }
546 None => problems.push(match &account.last_error {
547 Some(error) => format!("whose boards these are could not be read: {error}"),
548 None => "who the GitHub token belongs to has not been read yet, so only referenced \
549 boards are listed"
550 .to_string(),
551 }),
552 }
553 for reference in references {
554 if !owners.iter().any(|(login, _)| *login == reference.owner) {
555 owners.push((reference.owner.clone(), Relation::Referenced));
556 }
557 }
558
559 let mut listed = Vec::new();
560 let mut boards = Vec::new();
561 let mut unread = std::collections::BTreeSet::new();
562 for (login, relation) in owners {
563 let scope = Scope::Projects(login.clone());
564 match sync::owner_boards(&self.mirror, &login).map_err(mirror_failure)? {
565 Ok(mut held) => {
566 held.sort_by(|a, b| a.closed.cmp(&b.closed).then(b.updated_at.cmp(&a.updated_at)));
567 boards.extend(held);
568 }
569 Err(reason) => {
570 problems.push(reason);
571 unread.insert(login.clone());
572 }
573 }
574 listed.push(BoardOwner {
575 sync: self.standing(&scope)?,
576 login,
577 relation,
578 });
579 }
580
581 let mut referenced = Vec::new();
582 for reference in references {
583 match boards
584 .iter()
585 .find(|board| board.owner == reference.owner && board.number == reference.number)
586 {
587 Some(board) => referenced.push(board.id.clone()),
588 None if unread.contains(&reference.owner) => {}
590 None => problems.push(format!("{} has no board {}", reference.owner, reference.number)),
591 }
592 }
593
594 Ok(AllBoards {
595 owners: listed,
596 boards,
597 referenced,
598 account,
599 problems,
600 })
601 }
602
603 pub fn cards(&self, board: &str) -> Outcome<Cards> {
610 let scope = Scope::ProjectItems(board.to_string());
611 let items = self
612 .mirror
613 .payloads(&scope)
614 .map_err(mirror_failure)?
615 .into_iter()
616 .map(|payload| {
617 serde_json::from_value::<GitHubProjectItem>(payload).map_err(|e| {
618 Failure::Mirror(anyhow::anyhow!(
619 "a mirrored card of {board} cannot be read by this version ({e}); \
620 sync the board to read it again"
621 ))
622 })
623 })
624 .collect::<Outcome<Vec<_>>>()?;
625 Ok(Cards {
626 items,
627 sync: self.standing(&scope)?,
628 })
629 }
630
631 pub async fn refresh_boards(&self, client: &GitHubClient, references: &[BoardRef]) -> SyncReport {
634 let references = references.iter().cloned().collect();
635 sync::sync_boards(&self.mirror, client, &references, true).await
636 }
637
638 pub async fn refresh_board(
647 &self,
648 client: &GitHubClient,
649 board: &GitHubProject,
650 force: bool,
651 ) -> SyncReport {
652 sync::sync_scopes(&self.mirror, client, &[Scope::ProjectItems(board.id.clone())], force).await
653 }
654
655 fn record_card(&self, board: &str, item: &GitHubProjectItem) -> Outcome<()> {
656 let payload = serde_json::to_value(item).map_err(|e| mirror_failure(e.into()))?;
657 self.mirror
658 .put_one(
659 &Scope::ProjectItems(board.to_string()),
660 Document::of(item.id.clone(), payload),
661 )
662 .map_err(mirror_failure)?;
663 Ok(())
664 }
665
666 pub async fn set_field(
672 &self,
673 client: &GitHubClient,
674 board: &str,
675 item: &str,
676 field: &str,
677 value: Option<&ProjectFieldWrite>,
678 ) -> Outcome<GitHubProjectItem> {
679 let updated = match value {
680 Some(value) => client.set_project_field(board, item, field, value).await,
681 None => client.clear_project_field(board, item, field).await,
682 }
683 .map_err(github_failure)?;
684 self.record_card(board, &updated)?;
685 Ok(updated)
686 }
687
688 pub async fn move_card(
690 &self,
691 client: &GitHubClient,
692 board: &str,
693 item: &str,
694 after: Option<&str>,
695 ) -> Outcome<GitHubProjectItem> {
696 if after == Some(item) {
697 return Err(Failure::Invalid("a card cannot be moved behind itself".to_string()));
698 }
699 let updated = client
700 .move_project_item_after(board, item, after)
701 .await
702 .map_err(github_failure)?;
703 self.record_card(board, &updated)?;
704 Ok(updated)
705 }
706
707 pub async fn set_archived(
710 &self,
711 client: &GitHubClient,
712 board: &str,
713 item: &str,
714 archived: bool,
715 ) -> Outcome<GitHubProjectItem> {
716 let updated = client
717 .archive_project_item(board, item, archived)
718 .await
719 .map_err(github_failure)?;
720 self.record_card(board, &updated)?;
721 Ok(updated)
722 }
723
724 pub async fn edit_draft(
726 &self,
727 client: &GitHubClient,
728 board: &str,
729 item: &str,
730 content: &str,
731 title: &str,
732 body: &str,
733 ) -> Outcome<GitHubProjectItem> {
734 if title.trim().is_empty() {
735 return Err(Failure::Invalid(
736 "a draft card needs a title; an untitled card is one nobody can find".to_string(),
737 ));
738 }
739 let updated = client
740 .update_project_draft(item, content, title, body)
741 .await
742 .map_err(github_failure)?;
743 self.record_card(board, &updated)?;
744 Ok(updated)
745 }
746
747 pub async fn add_draft(
749 &self,
750 client: &GitHubClient,
751 board: &str,
752 title: &str,
753 body: &str,
754 ) -> Outcome<GitHubProjectItem> {
755 if title.trim().is_empty() {
756 return Err(Failure::Invalid("a draft card needs a title".to_string()));
757 }
758 let added = client
759 .add_project_draft(board, title, body)
760 .await
761 .map_err(github_failure)?;
762 self.record_card(board, &added)?;
763 Ok(added)
764 }
765
766 pub async fn add_existing(
768 &self,
769 client: &GitHubClient,
770 board: &str,
771 url: &str,
772 ) -> Outcome<GitHubProjectItem> {
773 let added = client
774 .add_project_item_by_url(board, url)
775 .await
776 .map_err(github_failure)?;
777 self.record_card(board, &added)?;
778 Ok(added)
779 }
780
781 pub async fn remove_card(&self, client: &GitHubClient, board: &str, item: &str) -> Outcome<()> {
784 client
785 .delete_project_item(board, item)
786 .await
787 .map_err(github_failure)?;
788 self.mirror
791 .remove_one(&Scope::ProjectItems(board.to_string()), item)
792 .map_err(mirror_failure)?;
793 Ok(())
794 }
795
796 pub async fn add_column(
798 &self,
799 client: &GitHubClient,
800 board: &str,
801 field: &str,
802 name: &str,
803 color: &str,
804 description: &str,
805 ) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
806 let _lock = self.column_lock().await?;
807 let updated = client
808 .add_project_column(board, field, name, color, description)
809 .await
810 .map_err(github_failure)?;
811 let items = client.project_items(board).await.map_err(github_failure)?;
812 self.record_board(&updated, &items)?;
813 Ok((updated, items))
814 }
815
816 pub async fn remove_column(
819 &self,
820 client: &GitHubClient,
821 board: &str,
822 field: &str,
823 option: &str,
824 ) -> Outcome<(GitHubProject, Vec<GitHubProjectItem>)> {
825 let _lock = self.column_lock().await?;
826 let updated = client
827 .remove_project_column(board, field, option)
828 .await
829 .map_err(github_failure)?;
830 let items = client.project_items(board).await.map_err(github_failure)?;
831 self.record_board(&updated, &items)?;
832 Ok((updated, items))
833 }
834
835 fn record_board(&self, board: &GitHubProject, items: &[GitHubProjectItem]) -> Outcome<()> {
838 let board_value = serde_json::to_value(board).map_err(|e| mirror_failure(e.into()))?;
839 let documents = items
840 .iter()
841 .map(|item| {
842 serde_json::to_value(item)
843 .map(|value| Document::of(item.id.clone(), value))
844 .map_err(|e| mirror_failure(e.into()))
845 })
846 .collect::<Outcome<Vec<_>>>()?;
847 self.mirror
848 .put_project_write(
849 &Scope::Projects(board.owner.clone()),
850 Document::of(board.id.clone(), board_value),
851 &Scope::ProjectItems(board.id.clone()),
852 &documents,
853 )
854 .map_err(mirror_failure)
855 }
856
857 async fn column_lock(&self) -> Outcome<ColumnLock> {
864 let path = self.mirror.path().with_extension("columns.lock");
865 tokio::task::spawn_blocking(move || ColumnLock::acquire(path))
866 .await
867 .map_err(|e| Failure::Mirror(anyhow::anyhow!("the column lock could not be taken: {e}")))?
868 .map_err(mirror_failure)
869 }
870}
871
872struct ColumnLock {
874 file: File,
875}
876
877impl ColumnLock {
878 fn acquire(path: PathBuf) -> anyhow::Result<Self> {
879 let file = std::fs::OpenOptions::new()
880 .create(true)
881 .truncate(false)
882 .write(true)
883 .open(&path)
884 .with_context(|| format!("cannot open {}", path.display()))?;
885 file.lock()
886 .with_context(|| format!("cannot lock {}", path.display()))?;
887 Ok(Self { file })
888 }
889}
890
891impl Drop for ColumnLock {
892 fn drop(&mut self) {
893 let _ = self.file.unlock();
896 }
897}
898
899fn updated_of(issue: &serde_json::Value) -> &str {
900 issue
901 .get("updated_at")
902 .and_then(|value| value.as_str())
903 .unwrap_or("")
904}
905
906#[cfg(test)]
907#[path = "service_tests.rs"]
908mod tests;