1use std::path::PathBuf;
11
12use url::Url;
13
14use onevcs::{
15 ArtifactId, ChangeChecks, ChangeId, ChangeRequest, ChangeSpec, ChangeStandings, Check,
16 CheckSource, CheckState, CommentId, Description, Error, HeadActivity, Hosting, MergeOutcome,
17 MergePolicy, Mergeability, PostedReply, ReadCost, ReadMarker, RemoteHost, RequiredChecks,
18 Result, ReviewRead, ReviewStandings, Sha, Visibility,
19};
20
21use crate::landings::{self, HeadAfter, PersonMerge};
22use crate::state::{ActivityAnswer, Described, HostCall, HostComment, HostState};
23use crate::store::{FileStore, MemoryStore, Store};
24use crate::{events, review};
25
26pub const DEFAULT_HOST: &str = "github.com";
29
30pub const DEFAULT_SLUG: &str = "onevcs/testing";
34
35#[derive(Debug)]
42pub struct Host<T> {
43 store: T,
44 slug: String,
51}
52
53pub type MemoryHost = Host<MemoryStore<HostState>>;
55
56pub type FileHost = Host<FileStore<HostState>>;
59
60impl MemoryHost {
61 pub fn new() -> Self {
63 Self::seeded(HostState::default())
64 }
65
66 pub fn seeded(state: HostState) -> Self {
68 Self {
69 store: MemoryStore::new(state),
70 slug: DEFAULT_SLUG.to_owned(),
71 }
72 }
73
74 pub fn state(&self) -> HostState {
76 self.store
77 .snapshot()
78 .expect("an in-memory store always answers")
79 }
80}
81
82impl Default for MemoryHost {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl FileHost {
89 pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
95 Ok(Self {
96 store: FileStore::attach(path, &HostState::default())?,
97 slug: DEFAULT_SLUG.to_owned(),
98 })
99 }
100
101 pub fn seeded(path: impl Into<PathBuf>, state: HostState) -> Result<Self> {
104 Ok(Self {
105 store: FileStore::replace(path, &state)?,
106 slug: DEFAULT_SLUG.to_owned(),
107 })
108 }
109
110 pub fn state(&self) -> Result<HostState> {
112 self.store.snapshot()
113 }
114}
115
116impl<T: Store<HostState>> Host<T> {
120 pub fn merge_by_person(
126 &self,
127 change: &ChangeId,
128 how: PersonMerge,
129 at: &str,
130 head: HeadAfter,
131 ) -> Result<Sha> {
132 self.store
133 .with(|state| landings::merge_by_person(state, change, how, at, head))
134 }
135
136 pub fn close_unmerged(&self, change: &ChangeId) -> Result<()> {
138 self.store.with(|state| {
139 if !state.changes.iter().any(|held| held.id == *change) {
140 return Err(Error::Invalid {
141 reason: format!("no change request {:?} was opened on this host", change.0),
142 });
143 }
144 state.closed.insert(change.clone());
145 Ok(())
146 })
147 }
148
149 pub fn retarget_by_person(&self, change: &ChangeId, base: &str) -> Result<()> {
151 crate::state::named_branch(base, "the base branch")?;
152 self.store.with(|state| {
153 if !state.changes.iter().any(|held| held.id == *change) {
154 return Err(Error::Invalid {
155 reason: format!("no change request {:?} was opened on this host", change.0),
156 });
157 }
158 landings::retarget_in(state, change, base);
159 Ok(())
160 })
161 }
162
163 pub fn set_head_checks(&self, head: &Sha, checks: Vec<Check>) -> Result<()> {
165 self.store.with(|state| {
166 state.head_checks.insert(head.clone(), checks);
167 Ok(())
168 })
169 }
170
171 pub fn ledger(&self) -> Result<Vec<HostCall>> {
173 Ok(self.store.snapshot()?.ledger)
174 }
175
176 fn called(&self, method: &str, changes: &[&ChangeId], charge: ReadCost) -> Result<()> {
178 let call = HostCall {
179 method: method.to_owned(),
180 changes: changes.iter().map(|id| (*id).clone()).collect(),
181 charge,
182 };
183 call.checked()?;
184 self.store.with(|state| {
185 state.ledger.push(call);
186 Ok(())
187 })
188 }
189}
190
191impl<T: Store<HostState>> Host<T> {
194 pub fn add_comment(&self, change: &ChangeId, comment: HostComment) -> Result<()> {
199 review::add(&self.store, change, comment)
200 }
201
202 pub fn edit_comment(&self, change: &ChangeId, comment: &CommentId, body: &str) -> Result<()> {
204 review::edit(&self.store, change, comment, body)
205 }
206
207 pub fn set_thread(
210 &self,
211 change: &ChangeId,
212 thread: &str,
213 resolved: bool,
214 outdated: bool,
215 ) -> Result<()> {
216 review::set_thread(&self.store, change, thread, resolved, outdated)
217 }
218}
219
220impl<T: Store<HostState> + Clone + std::fmt::Debug + Send + Sync + 'static> Hosting for Host<T> {
221 fn for_repo(&self, slug: &str) -> Result<Box<dyn RemoteHost>> {
222 Ok(Box::new(Host {
223 store: self.store.clone(),
224 slug: named_repository(slug)?,
225 }))
226 }
227}
228
229impl<T: Store<HostState>> RemoteHost for Host<T> {
230 fn authenticated_user(&self) -> Result<String> {
231 self.called("authenticated_user", &[], landings::rest(1))?;
232 let login = self.store.snapshot()?.authenticated_user;
233 if login.trim().is_empty() {
234 return Err(Error::Invalid {
235 reason: "the host reported no authenticated user".to_owned(),
236 });
237 }
238 Ok(login)
239 }
240
241 fn open_change(&self, req: ChangeSpec) -> Result<ChangeRequest> {
242 self.called("open_change", &[], landings::graphql(2))?;
244 addressable(&req.head, "the head branch")?;
245 addressable(&req.base, "the base branch")?;
246 crate::state::titled(&req.title)?;
249 if let Some(reason) = &req.draft {
255 reason.checked()?;
256 }
257 let slug = self.slug.clone();
258 self.store.with(|state| {
259 let id = ChangeId((state.changes.len() + 1).to_string());
262 let url = format!("https://{DEFAULT_HOST}/{slug}/pull/{}", id.0);
263 let change = ChangeRequest {
264 head_sha: Sha(events::stable_sha(&[&slug, &req.head, &id.0])),
265 url: Url::parse(&url).map_err(|e| Error::Invalid {
266 reason: format!("{url:?} is not a URL: {e}"),
267 })?,
268 base: req.base.clone(),
269 id: id.clone(),
270 };
271 state.heads.insert(id.clone(), req.head.clone());
272 state.titles.insert(id.clone(), req.title.clone());
273 if let Some(body) = req.body.clone() {
277 state.bodies.insert(id.clone(), body);
278 }
279 if let Some(reason) = req.draft.clone() {
284 state.drafts.insert(id, reason);
285 } else if req.draft_awaiting_checks {
286 state.awaiting_checks.insert(id);
289 }
290 state.changes.push(change.clone());
291 Ok(change)
292 })
293 }
294
295 fn find_changes(&self, head: &str, base: &str) -> Result<Vec<ChangeRequest>> {
296 self.called("find_changes", &[], landings::graphql(1))?;
297 addressable(head, "the head branch")?;
298 addressable(base, "the base branch")?;
299 let state = self.store.snapshot()?;
300 Ok(state
301 .changes
302 .iter()
303 .filter(|change| {
304 change.base == base
305 && state.heads.get(&change.id).is_some_and(|from| from == head)
306 && !matches!(state.merges.get(&change.id), Some(MergeOutcome::Merged(_)))
309 })
310 .cloned()
311 .collect())
312 }
313
314 fn change_checks(&self, cr: &ChangeRequest) -> Result<ChangeChecks> {
315 self.called("change_checks", &[&cr.id], landings::graphql(2))?;
317 let state = self.store.snapshot()?;
318 let sources = state.check_sources.clone().unwrap_or_else(complete_sources);
319 if sources.is_empty() {
324 return Err(Error::Invalid {
325 reason: format!(
326 "this host was seeded with no check source, so what the checks on {} say \
327 cannot be read rather than being empty",
328 cr.url
329 ),
330 });
331 }
332 Ok(ChangeChecks {
333 checks: current_checks(&state, &cr.id),
334 sources,
335 })
336 }
337
338 fn check_log(&self, cr: &ChangeRequest, check: &Check) -> Result<ArtifactId> {
339 self.called("check_log", &[&cr.id], landings::rest(1))?;
340 let log = self
341 .store
342 .snapshot()?
343 .check_logs
344 .get(&cr.id)
345 .and_then(|logs| logs.get(&check.name))
346 .cloned()
347 .unwrap_or_else(|| format!("the host log for check {}\n", check.name));
348 events::store_artifact(&artifact_id(&cr.id, &check.name), &log)
349 }
350
351 fn merge(&self, cr: &ChangeRequest, policy: MergePolicy) -> Result<MergeOutcome> {
352 let charge = match policy {
355 MergePolicy::ChangeAuto | MergePolicy::ChangeDirect => landings::graphql(2),
356 MergePolicy::LocalDirect | MergePolicy::ChangeOpen => ReadCost::default(),
357 };
358 self.called("merge", &[&cr.id], charge)?;
359 self.store.with(|state| {
360 if matches!(policy, MergePolicy::ChangeAuto | MergePolicy::ChangeDirect)
364 && drafted(state, &cr.id)
365 {
366 return Err(Error::Invalid {
367 reason: format!(
368 "{} is still a draft, and a draft can be neither merged nor armed to \
369 merge itself",
370 cr.url
371 ),
372 });
373 }
374 if state.armed.contains(&cr.id)
378 && matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
379 && required_checks_green(state, &cr.id)
380 {
381 return Ok(MergeOutcome::Merged(land(state, cr)));
382 }
383 if let Some(decided) = state.merges.get(&cr.id) {
387 return Ok(decided.clone());
388 }
389 let landed = |state: &mut HostState| MergeOutcome::Merged(land(state, cr));
390 Ok(match policy {
391 MergePolicy::LocalDirect | MergePolicy::ChangeOpen => MergeOutcome::Open,
394 MergePolicy::ChangeAuto => {
395 if required_checks_green(state, &cr.id) {
396 landed(state)
397 } else {
398 state.merges.insert(cr.id.clone(), MergeOutcome::Queued);
401 state.armed.insert(cr.id.clone());
402 MergeOutcome::Queued
403 }
404 }
405 MergePolicy::ChangeDirect => landed(state),
406 })
407 })
408 }
409
410 fn ready_for_review(&self, cr: &ChangeRequest) -> Result<()> {
419 self.called("ready_for_review", &[&cr.id], landings::graphql(1))?;
420 self.store.with(|state| {
421 state.made_ready.push(cr.id.clone());
422 Ok(())
423 })
424 }
425
426 fn is_draft(&self, cr: &ChangeRequest) -> Result<bool> {
433 self.called("is_draft", &[&cr.id], landings::graphql(1))?;
434 Ok(drafted(&self.store.snapshot()?, &cr.id))
435 }
436
437 fn required_checks_on(&self, _base: &str) -> Result<RequiredChecks> {
440 self.called("required_checks_on", &[], landings::rest(2))?;
442 let state = self.store.snapshot()?;
443 if let Some(seeded) = state.required_checks {
444 return Ok(seeded);
445 }
446 Ok(RequiredChecks {
447 checks: state
448 .checks
449 .values()
450 .chain(state.checks_after_lift.values())
451 .flatten()
452 .chain(
453 state
454 .check_readings
455 .values()
456 .chain(state.check_readings_after_lift.values())
457 .flatten()
458 .flatten(),
459 )
460 .filter(|check| check.required)
461 .map(|check| check.name.clone())
462 .collect(),
463 unconsulted: std::collections::BTreeMap::new(),
464 })
465 }
466
467 fn describe_change(&self, cr: &ChangeRequest, title: Option<&str>, body: &str) -> Result<()> {
477 self.called("describe_change", &[&cr.id], landings::graphql(1))?;
478 if let Some(title) = title {
479 crate::state::titled(title)?;
480 }
481 self.store.with(|state| {
482 if !state.changes.iter().any(|change| change.id == cr.id) {
483 return Err(Error::Invalid {
484 reason: format!(
485 "no change request {:?} was opened on this host, so there is nothing to \
486 describe",
487 cr.id.0
488 ),
489 });
490 }
491 if let Some(title) = title {
492 state.titles.insert(cr.id.clone(), title.to_owned());
493 }
494 state.bodies.insert(cr.id.clone(), body.to_owned());
495 state.described.push(Described {
496 id: cr.id.clone(),
497 title: title.map(str::to_owned),
498 body: body.to_owned(),
499 });
500 Ok(())
501 })
502 }
503
504 fn change_description(&self, cr: &ChangeRequest) -> Result<Description> {
508 self.called("change_description", &[&cr.id], landings::graphql(1))?;
509 let state = self.store.snapshot()?;
510 let title = state
511 .titles
512 .get(&cr.id)
513 .cloned()
514 .ok_or_else(|| Error::Invalid {
515 reason: format!(
516 "no change request {:?} was opened on this host, so it has no description",
517 cr.id.0
518 ),
519 })?;
520 Ok(Description {
521 title,
522 body: state.bodies.get(&cr.id).cloned().unwrap_or_default(),
523 })
524 }
525
526 fn review_comments(&self, change: &ChangeId, since: Option<&ReadMarker>) -> Result<ReviewRead> {
529 let read = review::read(&self.store, change, since);
530 let charge = read.as_ref().map_or(landings::graphql(1), |read| read.cost);
531 self.called("review_comments", &[change], charge)?;
532 read
533 }
534
535 fn reply_to_comment(
539 &self,
540 change: &ChangeId,
541 comment: &CommentId,
542 body: &str,
543 key: &str,
544 ) -> Result<PostedReply> {
545 self.called("reply_to_comment", &[change], landings::rest(1))?;
546 review::reply(&self.store, change, comment, body, key)
547 }
548
549 fn mergeability(&self, cr: &ChangeRequest) -> Result<Mergeability> {
553 self.called("mergeability", &[&cr.id], landings::graphql(1))?;
554 Err(Error::NotImplemented {
555 operation: "RemoteHost::mergeability",
556 })
557 }
558
559 fn head_activity(&self, cr: &ChangeRequest) -> Result<HeadActivity> {
564 self.called("head_activity", &[&cr.id], landings::rest(2))?;
565 let state = self.store.snapshot()?;
566 let call = asked(&state, "head_activity", &cr.id);
567 let scripted = state.head_activity.get(&cr.id).and_then(|answers| {
568 answers
569 .get(call.saturating_sub(1))
570 .or_else(|| answers.last())
571 });
572 match scripted {
573 None => Ok(HeadActivity {
574 head: cr.head_sha.clone(),
575 in_progress: Vec::new(),
576 complete: true,
577 }),
578 Some(ActivityAnswer::Head {
579 in_progress,
580 complete,
581 }) => Ok(HeadActivity {
582 head: cr.head_sha.clone(),
583 in_progress: in_progress.clone(),
584 complete: *complete,
585 }),
586 Some(ActivityAnswer::Other { head, in_progress }) => Ok(HeadActivity {
587 head: head.clone(),
588 in_progress: in_progress.clone(),
589 complete: true,
590 }),
591 Some(ActivityAnswer::Refused) => Err(Error::Invalid {
592 reason: format!(
593 "this host was scripted to refuse what is running on the head of {}",
594 cr.url
595 ),
596 }),
597 }
598 }
599
600 fn requires_up_to_date(&self, _base: &str) -> Result<bool> {
603 self.called("requires_up_to_date", &[], landings::rest(2))?;
604 Ok(self.store.snapshot()?.up_to_date_required)
605 }
606
607 fn allows_auto_merge(&self) -> Result<bool> {
610 self.called("allows_auto_merge", &[], landings::graphql(1))?;
611 Ok(self.store.snapshot()?.allow_auto_merge)
612 }
613
614 fn visibility(&self) -> Result<Visibility> {
618 self.called("visibility", &[], landings::rest(1))?;
619 Err(Error::NotImplemented {
620 operation: "RemoteHost::visibility",
621 })
622 }
623
624 fn change_standings(&self, root: &str, changes: &[ChangeId]) -> Result<ChangeStandings> {
627 let cost = landings::standings_charge(changes.len());
628 let named: Vec<&ChangeId> = changes.iter().collect();
629 self.called("change_standings", &named, cost)?;
630 let state = self.store.snapshot()?;
631 Ok(ChangeStandings {
632 standings: changes
633 .iter()
634 .map(|id| landings::standing(&state, root, id))
635 .collect::<Result<_>>()?,
636 cost,
637 })
638 }
639
640 fn review_standings(
643 &self,
644 root: &str,
645 changes: &[(ChangeId, Option<ReadMarker>)],
646 ) -> Result<ReviewStandings> {
647 let cost = landings::review_charge(changes.len());
648 let named: Vec<&ChangeId> = changes.iter().map(|(id, _)| id).collect();
649 self.called("review_standings", &named, cost)?;
650 self.store.with(|state| {
651 let mut standings = Vec::with_capacity(changes.len());
652 let mut reads = Vec::with_capacity(changes.len());
653 for (id, since) in changes {
654 standings.push(landings::standing(state, root, id)?);
655 reads.push(review::read_in(state, id, since.as_ref(), false)?);
656 }
657 Ok(ReviewStandings {
658 standings,
659 reads,
660 cost,
661 })
662 })
663 }
664
665 fn retarget_change(
669 &self,
670 change: &ChangeId,
671 base: &str,
672 body: Option<&str>,
673 ) -> Result<ReadCost> {
674 self.called("retarget_change", &[change], landings::rest(1))?;
675 addressable(base, "the base branch")?;
676 self.store.with(|state| {
677 let opened = state
678 .changes
679 .iter()
680 .find(|held| held.id == *change)
681 .cloned()
682 .ok_or_else(|| Error::Invalid {
683 reason: format!("no change request {:?} was opened on this host", change.0),
684 })?;
685 if matches!(state.merges.get(change), Some(MergeOutcome::Merged(_))) {
686 return Err(Error::Invalid {
687 reason: format!(
688 "Cannot change the base branch of a merged pull request: {}",
689 opened.url
690 ),
691 });
692 }
693 if state.closed.contains(change) {
694 return Err(Error::Invalid {
695 reason: format!(
696 "Cannot change the base branch of a closed pull request: {}",
697 opened.url
698 ),
699 });
700 }
701 if state.deleted_branches.contains(base) {
702 return Err(Error::Invalid {
703 reason: format!("Proposed base branch '{base}' was not found"),
704 });
705 }
706 landings::retarget_in(state, change, base);
707 if let Some(body) = body {
708 state.bodies.insert(change.clone(), body.to_owned());
709 }
710 Ok(landings::rest(1))
711 })
712 }
713
714 fn reopen_change(&self, change: &ChangeId) -> Result<ReadCost> {
718 self.called("reopen_change", &[change], landings::rest(1))?;
719 self.store.with(|state| {
720 let opened = state
721 .changes
722 .iter()
723 .find(|held| held.id == *change)
724 .cloned()
725 .ok_or_else(|| Error::Invalid {
726 reason: format!("no change request {:?} was opened on this host", change.0),
727 })?;
728 if !state.closed.contains(change) {
729 return Err(Error::Invalid {
730 reason: format!("{} is not closed, so its state cannot change", opened.url),
731 });
732 }
733 if state.deleted_branches.contains(&opened.base) {
734 return Err(Error::Invalid {
735 reason: format!(
736 "the base branch of {} was deleted, so it cannot be reopened",
737 opened.url
738 ),
739 });
740 }
741 state.closed.remove(change);
742 Ok(landings::rest(1))
743 })
744 }
745
746 fn merge_time(&self, cr: &ChangeRequest) -> Result<Option<String>> {
747 self.called("merge_time", &[&cr.id], landings::graphql(1))?;
748 let state = self.store.snapshot()?;
749 Ok(match state.merges.get(&cr.id) {
752 Some(MergeOutcome::Merged(_)) => state.merge_times.get(&cr.id).cloned(),
753 _ => None,
754 })
755 }
756
757 fn merged_at(&self, cr: &ChangeRequest) -> Result<Option<Sha>> {
758 self.called("merged_at", &[&cr.id], landings::graphql(1))?;
759 self.store.with(|state| {
760 if matches!(state.merges.get(&cr.id), Some(MergeOutcome::Queued))
767 && required_checks_green(state, &cr.id)
768 {
769 return Ok(Some(land(state, cr)));
770 }
771 Ok(match state.merges.get(&cr.id) {
772 Some(MergeOutcome::Merged(sha)) => Some(sha.clone()),
773 _ => None,
774 })
775 })
776 }
777}
778
779fn land(state: &mut HostState, cr: &ChangeRequest) -> Sha {
784 let sha = Sha(events::stable_sha(&["merge", &cr.id.0, cr.url.as_str()]));
785 state
786 .merges
787 .insert(cr.id.clone(), MergeOutcome::Merged(sha.clone()));
788 state.merge_times.insert(cr.id.clone(), events::timestamp());
789 state.armed.remove(&cr.id);
790 sha
791}
792
793fn complete_sources() -> std::collections::BTreeSet<CheckSource> {
797 [CheckSource::StatusChecks].into_iter().collect()
798}
799
800fn required_checks_green(state: &HostState, id: &ChangeId) -> bool {
810 let checks = current_checks(state, id);
811 let required: std::collections::BTreeSet<&str> = checks
812 .iter()
813 .filter(|check| check.required)
814 .map(|check| check.name.as_str())
815 .chain(
816 state
817 .required_checks
818 .iter()
819 .flat_map(|declared| declared.checks.iter().map(String::as_str)),
820 )
821 .collect();
822 !required.is_empty()
823 && required.iter().all(|name| {
824 let mut runs = checks.iter().filter(|check| check.name == *name).peekable();
825 runs.peek().is_some()
826 && runs
827 .all(|check| matches!(check.state(), CheckState::Passed | CheckState::Skipped))
828 })
829}
830
831pub(crate) fn drafted(state: &HostState, id: &ChangeId) -> bool {
834 (state.drafts.contains_key(id) || state.awaiting_checks.contains(id))
835 && !state.made_ready.contains(id)
836}
837
838pub(crate) fn current_checks(state: &HostState, id: &ChangeId) -> Vec<Check> {
841 let lifted = state
844 .ledger
845 .iter()
846 .rposition(|call| call.method == "ready_for_review" && call.changes.contains(id));
847 if let (Some(lifted), Some(readings)) = (lifted, state.check_readings_after_lift.get(id)) {
848 let read = state.ledger[lifted..]
849 .iter()
850 .filter(|call| call.method == "change_checks" && call.changes.contains(id))
851 .count();
852 if let Some(reading) = readings
853 .get(read.saturating_sub(1))
854 .or_else(|| readings.last())
855 {
856 return reading.clone();
857 }
858 }
859 if let Some(readings) = state.check_readings.get(id) {
860 let read = asked(state, "change_checks", id);
861 if let Some(reading) = readings
862 .get(read.saturating_sub(1))
863 .or_else(|| readings.last())
864 {
865 return reading.clone();
866 }
867 }
868 if state.made_ready.contains(id) {
869 if let Some(after) = state.checks_after_lift.get(id) {
870 return after.clone();
871 }
872 }
873 state.checks.get(id).cloned().unwrap_or_default()
874}
875
876fn asked(state: &HostState, method: &str, id: &ChangeId) -> usize {
879 state
880 .ledger
881 .iter()
882 .filter(|call| call.method == method && call.changes.contains(id))
883 .count()
884}
885
886fn artifact_id(change: &ChangeId, check: &str) -> String {
892 let safe: String = check
893 .chars()
894 .map(|c| {
895 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
896 c
897 } else {
898 '-'
899 }
900 })
901 .collect();
902 let number: String = change
903 .0
904 .chars()
905 .filter(|c| c.is_ascii_alphanumeric())
906 .collect();
907 format!("a-testing-{number}-{safe}")
908}
909
910fn addressable(value: &str, what: &str) -> Result<()> {
917 if value.is_empty() || value.starts_with('-') || value.contains(char::is_whitespace) {
918 return Err(Error::Invalid {
919 reason: format!(
920 "{what} {value:?} cannot address anything on the host: it must be non-empty, \
921 must not begin with '-', and must carry no whitespace"
922 ),
923 });
924 }
925 Ok(())
926}
927
928fn named_repository(slug: &str) -> Result<String> {
930 let mut parts = slug.split('/');
931 let named = matches!(
932 (parts.next(), parts.next(), parts.next()),
933 (Some(owner), Some(name), None)
934 if !owner.is_empty()
935 && !name.is_empty()
936 && !slug.starts_with('-')
937 && !slug.contains(char::is_whitespace)
938 );
939 if !named {
940 return Err(Error::Invalid {
941 reason: format!("{slug:?} does not name one repository as owner/name"),
942 });
943 }
944 Ok(slug.to_owned())
945}