1use crate::WorkContext;
33use std::fmt;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Explicit {
38 Org(String),
40 Context(WorkContext),
42 Personal,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum RepositoryOrg {
49 Undeclared,
51 Declared { org: String, declared_in: String },
53 Unreadable { location: String, error: String },
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Permitted {
61 Orgs(Vec<String>),
63 AnyHeld,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct AgentOrganizations {
72 pub agent: String,
74 pub permitted: Permitted,
75 pub default: Option<String>,
77 pub personal: bool,
79}
80
81impl AgentOrganizations {
82 pub fn personal_only(agent: impl Into<String>) -> Self {
85 Self {
86 agent: agent.into(),
87 permitted: Permitted::Orgs(Vec::new()),
88 default: None,
89 personal: true,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy)]
97pub struct Inputs<'a> {
98 pub inbound: Option<&'a WorkContext>,
102 pub explicit: Option<&'a Explicit>,
103 pub repository: &'a RepositoryOrg,
104 pub agent: &'a AgentOrganizations,
105 pub user_default: Option<&'a WorkContext>,
107 pub held: &'a [WorkContext],
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum Source {
114 Inbound,
115 Explicit,
116 Repository,
117 AgentDefault,
118 UserDefault,
119 Personal,
120}
121
122impl Source {
123 pub fn as_str(&self) -> &'static str {
125 match self {
126 Self::Inbound => "inbound",
127 Self::Explicit => "explicit",
128 Self::Repository => "repository",
129 Self::AgentDefault => "agent_default",
130 Self::UserDefault => "user_default",
131 Self::Personal => "personal",
132 }
133 }
134}
135
136impl fmt::Display for Source {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.write_str(match self {
139 Self::Inbound => "the inbound request",
140 Self::Explicit => "the explicit choice",
141 Self::Repository => "the repository",
142 Self::AgentDefault => "the agent's default",
143 Self::UserDefault => "your default org",
144 Self::Personal => "personal work",
145 })
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct Resolved {
152 pub context: Option<WorkContext>,
154 pub source: Source,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum Refusal {
160 RepositoryUnreadable {
161 location: String,
162 error: String,
163 },
164 RepositoryMismatch {
166 repository_org: String,
167 declared_in: String,
168 requested: Option<String>,
169 source: Source,
170 },
171 NotPermitted {
172 agent: String,
173 org: String,
174 source: Source,
175 },
176 PersonalNotAllowed {
177 agent: String,
178 },
179 NotHeld {
180 org: String,
181 source: Source,
182 },
183 Ambiguous {
184 org: String,
185 accounts: Vec<String>,
186 },
187 ContextNotHeld {
188 context: WorkContext,
189 },
190 DefaultUnavailable {
191 org: String,
192 },
193 Unresolved {
194 agent: String,
195 },
196}
197
198impl Refusal {
199 pub fn code(&self) -> &'static str {
202 match self {
203 Self::RepositoryUnreadable { .. } => "repository_unreadable",
204 Self::RepositoryMismatch { .. } => "repository_mismatch",
205 Self::NotPermitted { .. } => "not_permitted",
206 Self::PersonalNotAllowed { .. } => "personal_not_allowed",
207 Self::NotHeld { .. } => "not_held",
208 Self::Ambiguous { .. } => "ambiguous",
209 Self::ContextNotHeld { .. } => "context_not_held",
210 Self::DefaultUnavailable { .. } => "default_unavailable",
211 Self::Unresolved { .. } => "unresolved",
212 }
213 }
214}
215
216impl fmt::Display for Refusal {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 match self {
219 Self::RepositoryUnreadable { location, error } => write!(
220 f,
221 "cannot read the repository's organization from {location}: {error}. \
222 Fix the file before starting work here"
223 ),
224 Self::RepositoryMismatch {
225 repository_org,
226 declared_in,
227 requested,
228 source,
229 } => {
230 let requested = requested
231 .as_deref()
232 .map(|org| format!("org `{org}`"))
233 .unwrap_or_else(|| "personal work".to_string());
234 write!(
235 f,
236 "this repository belongs to org `{repository_org}` (declared in \
237 {declared_in}); a session here cannot bind {requested} from {source}. \
238 Work in `{repository_org}`, or start the session outside this repository"
239 )
240 }
241 Self::NotPermitted { agent, org, source } => write!(
242 f,
243 "agent `{agent}` is not permitted in org `{org}` (from {source}). \
244 Add `{org}` to the agent's permitted organizations, or choose another org"
245 ),
246 Self::PersonalNotAllowed { agent } => write!(
247 f,
248 "agent `{agent}` does not run personal work. Choose one of its organizations"
249 ),
250 Self::NotHeld { org, source } => write!(
251 f,
252 "no signed-in Parslee login belongs to org `{org}` (from {source}). \
253 Sign in to it with `car auth login --add`"
254 ),
255 Self::Ambiguous { org, accounts } => write!(
256 f,
257 "more than one signed-in login belongs to org `{org}` ({}). \
258 Name the account explicitly",
259 accounts.join(", ")
260 ),
261 Self::ContextNotHeld { context } => write!(
262 f,
263 "no signed-in login holds the requested context (account `{}`, org `{}`). \
264 Sign in to it with `car auth login --add`",
265 context.account_id,
266 context.organization_id.as_deref().unwrap_or("personal")
267 ),
268 Self::DefaultUnavailable { org } => write!(
269 f,
270 "your default org `{org}` is no longer held by any signed-in login. \
271 Choose a new one with `car auth default-org <org>`"
272 ),
273 Self::Unresolved { agent } => write!(
274 f,
275 "no organization applies to this session and agent `{agent}` does not run \
276 personal work. Name an org, or set a default with `car auth default-org <org>`"
277 ),
278 }
279 }
280}
281
282impl std::error::Error for Refusal {}
283
284enum Candidate<'a> {
286 Exact(&'a WorkContext),
287 Org(&'a str),
288 Personal,
289}
290
291impl Candidate<'_> {
292 fn org(&self) -> Option<&str> {
293 match self {
294 Self::Exact(context) => context.organization_id.as_deref(),
295 Self::Org(org) => Some(org),
296 Self::Personal => None,
297 }
298 }
299}
300
301pub fn resolve(inputs: Inputs<'_>) -> Result<Resolved, Refusal> {
303 if let RepositoryOrg::Unreadable { location, error } = inputs.repository {
304 return Err(Refusal::RepositoryUnreadable {
305 location: location.clone(),
306 error: error.clone(),
307 });
308 }
309
310 let (candidate, source) = pick(&inputs)?;
311
312 if let RepositoryOrg::Declared { org, declared_in } = inputs.repository {
313 if candidate.org() != Some(org.as_str()) {
314 return Err(Refusal::RepositoryMismatch {
315 repository_org: org.clone(),
316 declared_in: declared_in.clone(),
317 requested: candidate.org().map(str::to_string),
318 source,
319 });
320 }
321 }
322
323 let agent = inputs.agent;
324 if !permits(agent, candidate.org()) {
325 return Err(match candidate.org() {
326 None => Refusal::PersonalNotAllowed {
327 agent: agent.agent.clone(),
328 },
329 Some(org) => Refusal::NotPermitted {
330 agent: agent.agent.clone(),
331 org: org.to_string(),
332 source,
333 },
334 });
335 }
336
337 let context = match candidate {
338 Candidate::Personal => None,
339 Candidate::Exact(context) if context.organization_id.is_none() => None,
340 Candidate::Exact(context) => {
341 if !inputs.held.contains(context) {
342 return Err(if source == Source::UserDefault {
343 Refusal::DefaultUnavailable {
344 org: context.organization_id.clone().unwrap_or_default(),
345 }
346 } else {
347 Refusal::ContextNotHeld {
348 context: context.clone(),
349 }
350 });
351 }
352 Some(context.clone())
353 }
354 Candidate::Org(org) => Some(held_by_one(inputs.held, org, source)?.clone()),
355 };
356 Ok(Resolved { context, source })
357}
358
359fn pick<'a>(inputs: &Inputs<'a>) -> Result<(Candidate<'a>, Source), Refusal> {
360 if let Some(context) = inputs.inbound {
361 return Ok((Candidate::Exact(context), Source::Inbound));
362 }
363 if let Some(explicit) = inputs.explicit {
364 let candidate = match explicit {
365 Explicit::Org(org) => Candidate::Org(org),
366 Explicit::Context(context) => Candidate::Exact(context),
367 Explicit::Personal => Candidate::Personal,
368 };
369 return Ok((candidate, Source::Explicit));
370 }
371 if let RepositoryOrg::Declared { org, .. } = inputs.repository {
372 return Ok((Candidate::Org(org), Source::Repository));
373 }
374 if let Some(org) = inputs.agent.default.as_deref() {
375 return Ok((Candidate::Org(org), Source::AgentDefault));
376 }
377 if let Some(context) = inputs
378 .user_default
379 .filter(|context| permits(inputs.agent, context.organization_id.as_deref()))
380 {
381 return Ok((Candidate::Exact(context), Source::UserDefault));
382 }
383 if inputs.agent.personal {
384 return Ok((Candidate::Personal, Source::Personal));
385 }
386 Err(Refusal::Unresolved {
387 agent: inputs.agent.agent.clone(),
388 })
389}
390
391fn permits(agent: &AgentOrganizations, org: Option<&str>) -> bool {
393 match (org, &agent.permitted) {
394 (None, _) => agent.personal,
395 (Some(_), Permitted::AnyHeld) => true,
396 (Some(org), Permitted::Orgs(orgs)) => orgs.iter().any(|o| o == org),
397 }
398}
399
400fn held_by_one<'h>(
401 held: &'h [WorkContext],
402 org: &str,
403 source: Source,
404) -> Result<&'h WorkContext, Refusal> {
405 let mut matches = held
406 .iter()
407 .filter(|context| context.organization_id.as_deref() == Some(org));
408 let Some(first) = matches.next() else {
409 return Err(Refusal::NotHeld {
410 org: org.to_string(),
411 source,
412 });
413 };
414 let others: Vec<&WorkContext> = matches.filter(|context| *context != first).collect();
415 if others.is_empty() {
416 return Ok(first);
417 }
418 let mut accounts: Vec<String> = std::iter::once(first)
419 .chain(others)
420 .map(|context| context.account_id.clone())
421 .collect();
422 accounts.sort();
423 accounts.dedup();
424 Err(Refusal::Ambiguous {
425 org: org.to_string(),
426 accounts,
427 })
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 const BASE: &str = "https://api.parslee.ai";
435
436 fn ctx(account: &str, org: &str) -> WorkContext {
437 WorkContext::new(BASE, account, Some(org.into())).unwrap()
438 }
439
440 fn held() -> Vec<WorkContext> {
443 vec![
444 ctx("acc_parslee", "org_parslee"),
445 ctx("acc_parslee", "org_parslee_steelpath"),
446 ctx("acc_fly", "org_fly_exclusive"),
447 ctx("acc_volato", "org_volato"),
448 ]
449 }
450
451 fn agent(permitted: &[&str], default: Option<&str>, personal: bool) -> AgentOrganizations {
452 AgentOrganizations {
453 agent: "parslee-core".into(),
454 permitted: Permitted::Orgs(permitted.iter().map(|s| s.to_string()).collect()),
455 default: default.map(str::to_string),
456 personal,
457 }
458 }
459
460 fn declared(org: &str) -> RepositoryOrg {
461 RepositoryOrg::Declared {
462 org: org.into(),
463 declared_in: "/repo/.car/config.toml".into(),
464 }
465 }
466
467 struct Case<'a> {
468 inbound: Option<WorkContext>,
469 explicit: Option<Explicit>,
470 repository: RepositoryOrg,
471 agent: AgentOrganizations,
472 user_default: Option<WorkContext>,
473 held: &'a [WorkContext],
474 }
475
476 impl Case<'_> {
477 fn resolve(&self) -> Result<Resolved, Refusal> {
478 resolve(Inputs {
479 inbound: self.inbound.as_ref(),
480 explicit: self.explicit.as_ref(),
481 repository: &self.repository,
482 agent: &self.agent,
483 user_default: self.user_default.as_ref(),
484 held: self.held,
485 })
486 }
487 }
488
489 fn base(held: &[WorkContext]) -> Case<'_> {
490 Case {
491 inbound: None,
492 explicit: None,
493 repository: RepositoryOrg::Undeclared,
494 agent: agent(
495 &["org_parslee", "org_fly_exclusive", "org_volato"],
496 None,
497 true,
498 ),
499 user_default: Some(ctx("acc_parslee", "org_parslee")),
500 held,
501 }
502 }
503
504 fn bound(result: Result<Resolved, Refusal>) -> (Option<String>, Option<String>, Source) {
505 let resolved = result.expect("expected a binding");
506 let context = resolved.context;
507 (
508 context.as_ref().map(|c| c.account_id.clone()),
509 context.and_then(|c| c.organization_id),
510 resolved.source,
511 )
512 }
513
514 fn some(s: &str) -> Option<String> {
515 Some(s.to_string())
516 }
517
518 #[test]
519 fn the_user_default_fills_in_when_nothing_else_applies() {
520 let held = held();
521 assert_eq!(
522 bound(base(&held).resolve()),
523 (
524 some("acc_parslee"),
525 some("org_parslee"),
526 Source::UserDefault
527 )
528 );
529 }
530
531 #[test]
532 fn sources_apply_in_order() {
533 let held = held();
534 let mut case = base(&held);
536 case.agent.default = some("org_volato");
537 assert_eq!(
538 bound(case.resolve()),
539 (some("acc_volato"), some("org_volato"), Source::AgentDefault)
540 );
541 case.repository = declared("org_fly_exclusive");
543 assert_eq!(
544 bound(case.resolve()),
545 (
546 some("acc_fly"),
547 some("org_fly_exclusive"),
548 Source::Repository
549 )
550 );
551 case.explicit = Some(Explicit::Org("org_fly_exclusive".into()));
553 assert_eq!(bound(case.resolve()).2, Source::Explicit);
554 case.inbound = Some(ctx("acc_fly", "org_fly_exclusive"));
556 assert_eq!(bound(case.resolve()).2, Source::Inbound);
557 }
558
559 #[test]
560 fn the_org_supplies_the_account() {
561 let held = held();
562 let mut case = base(&held);
563 case.explicit = Some(Explicit::Org("org_parslee_steelpath".into()));
564 case.agent.permitted = Permitted::AnyHeld;
565 assert_eq!(
566 bound(case.resolve()),
567 (
568 some("acc_parslee"),
569 some("org_parslee_steelpath"),
570 Source::Explicit
571 )
572 );
573 }
574
575 #[test]
576 fn a_repository_org_constrains_every_source() {
577 let held = held();
578 let mismatch = |case: &Case<'_>, source: Source| match case.resolve() {
579 Err(Refusal::RepositoryMismatch {
580 repository_org,
581 source: got,
582 ..
583 }) => {
584 assert_eq!(repository_org, "org_fly_exclusive");
585 assert_eq!(got, source);
586 }
587 other => panic!("expected a repository mismatch, got {other:?}"),
588 };
589 let mut case = base(&held);
590 case.repository = declared("org_fly_exclusive");
591
592 case.explicit = Some(Explicit::Org("org_volato".into()));
593 mismatch(&case, Source::Explicit);
594
595 case.explicit = Some(Explicit::Personal);
596 mismatch(&case, Source::Explicit);
597
598 case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
599 mismatch(&case, Source::Explicit);
600
601 case.explicit = None;
604 case.inbound = Some(ctx("acc_volato", "org_volato"));
605 mismatch(&case, Source::Inbound);
606 }
607
608 #[test]
609 fn an_unreadable_repository_refuses_instead_of_falling_back() {
610 let held = held();
611 let mut case = base(&held);
612 case.repository = RepositoryOrg::Unreadable {
613 location: "/repo/.car/config.toml".into(),
614 error: "invalid TOML".into(),
615 };
616 case.explicit = Some(Explicit::Org("org_parslee".into()));
618 assert!(matches!(
619 case.resolve(),
620 Err(Refusal::RepositoryUnreadable { .. })
621 ));
622 }
623
624 #[test]
625 fn the_permitted_set_binds_every_source() {
626 let held = held();
627 let mut case = base(&held);
628 case.agent = agent(&["org_parslee"], None, true);
629
630 case.explicit = Some(Explicit::Org("org_volato".into()));
631 assert!(matches!(
632 case.resolve(),
633 Err(Refusal::NotPermitted {
634 source: Source::Explicit,
635 ..
636 })
637 ));
638
639 case.explicit = None;
641 case.repository = declared("org_volato");
642 assert!(matches!(
643 case.resolve(),
644 Err(Refusal::NotPermitted {
645 source: Source::Repository,
646 ..
647 })
648 ));
649
650 case.repository = RepositoryOrg::Undeclared;
652 case.inbound = Some(ctx("acc_volato", "org_volato"));
653 assert!(matches!(
654 case.resolve(),
655 Err(Refusal::NotPermitted {
656 source: Source::Inbound,
657 ..
658 })
659 ));
660 }
661
662 #[test]
663 fn the_user_default_applies_only_where_the_agent_is_permitted() {
664 let held = held();
665 let mut case = base(&held);
666 case.agent = agent(&["org_parslee"], None, true);
667 case.user_default = Some(ctx("acc_volato", "org_volato"));
668 assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
670
671 case.agent.personal = false;
672 assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
673 }
674
675 #[test]
676 fn an_explicit_refusal_never_falls_back_to_a_default() {
677 let held = held();
678 let mut case = base(&held);
679 case.agent = agent(&["org_parslee"], Some("org_parslee"), true);
680 case.explicit = Some(Explicit::Org("org_volato".into()));
681 assert!(matches!(
684 case.resolve(),
685 Err(Refusal::NotPermitted {
686 source: Source::Explicit,
687 ..
688 })
689 ));
690 }
691
692 #[test]
693 fn personal_work_needs_the_agent_to_allow_it() {
694 let held = held();
695 let mut case = base(&held);
696 case.user_default = None;
697 assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
698
699 case.agent.personal = false;
700 assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
701
702 case.explicit = Some(Explicit::Personal);
703 assert!(matches!(
704 case.resolve(),
705 Err(Refusal::PersonalNotAllowed { .. })
706 ));
707 }
708
709 #[test]
710 fn an_agent_with_no_org_settings_stays_personal() {
711 let held = held();
712 let mut case = base(&held);
713 case.agent = AgentOrganizations::personal_only("milo");
714 case.user_default = None;
717 assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
718 case.user_default = Some(ctx("acc_parslee", "org_parslee"));
719 assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
720 case.explicit = Some(Explicit::Org("org_parslee".into()));
722 assert!(matches!(case.resolve(), Err(Refusal::NotPermitted { .. })));
723 }
724
725 #[test]
726 fn an_org_no_login_holds_is_refused() {
727 let held = held();
728 let mut case = base(&held);
729 case.agent.permitted = Permitted::AnyHeld;
730 case.explicit = Some(Explicit::Org("org_acme".into()));
731 assert!(matches!(
732 case.resolve(),
733 Err(Refusal::NotHeld {
734 source: Source::Explicit,
735 ..
736 })
737 ));
738 }
739
740 #[test]
741 fn a_stale_user_default_is_refused_by_name() {
742 let held: Vec<WorkContext> = held()
744 .into_iter()
745 .filter(|c| c.account_id != "acc_volato")
746 .collect();
747 let mut case = base(&held);
748 case.user_default = Some(ctx("acc_volato", "org_volato"));
749 match case.resolve() {
750 Err(Refusal::DefaultUnavailable { org }) => assert_eq!(org, "org_volato"),
751 other => panic!("expected DefaultUnavailable, got {other:?}"),
752 }
753 }
754
755 #[test]
756 fn an_explicit_context_must_be_held_exactly() {
757 let held = held();
758 let mut case = base(&held);
759 case.explicit = Some(Explicit::Context(ctx("acc_parslee", "org_volato")));
761 assert!(matches!(
762 case.resolve(),
763 Err(Refusal::ContextNotHeld { .. })
764 ));
765 case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
766 assert_eq!(
767 bound(case.resolve()),
768 (some("acc_volato"), some("org_volato"), Source::Explicit)
769 );
770 }
771
772 #[test]
773 fn two_logins_in_one_org_is_ambiguous_not_a_guess() {
774 let mut held = held();
775 held.push(ctx("acc_parslee", "org_volato"));
776 let mut case = base(&held);
777 case.explicit = Some(Explicit::Org("org_volato".into()));
778 match case.resolve() {
779 Err(Refusal::Ambiguous { org, accounts }) => {
780 assert_eq!(org, "org_volato");
781 assert_eq!(accounts, vec!["acc_parslee", "acc_volato"]);
782 }
783 other => panic!("expected Ambiguous, got {other:?}"),
784 }
785 case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
787 assert_eq!(bound(case.resolve()).0, some("acc_volato"));
788 }
789
790 #[test]
791 fn duplicate_index_entries_are_not_ambiguous() {
792 let mut held = held();
793 held.push(ctx("acc_volato", "org_volato"));
794 let mut case = base(&held);
795 case.explicit = Some(Explicit::Org("org_volato".into()));
796 assert_eq!(bound(case.resolve()).0, some("acc_volato"));
797 }
798
799 #[test]
800 fn every_refusal_names_a_next_step() {
801 let refusals = [
802 Refusal::RepositoryUnreadable {
803 location: "x".into(),
804 error: "y".into(),
805 },
806 Refusal::RepositoryMismatch {
807 repository_org: "a".into(),
808 declared_in: "x".into(),
809 requested: None,
810 source: Source::Explicit,
811 },
812 Refusal::NotPermitted {
813 agent: "a".into(),
814 org: "o".into(),
815 source: Source::Explicit,
816 },
817 Refusal::PersonalNotAllowed { agent: "a".into() },
818 Refusal::NotHeld {
819 org: "o".into(),
820 source: Source::Explicit,
821 },
822 Refusal::Ambiguous {
823 org: "o".into(),
824 accounts: vec!["a".into(), "b".into()],
825 },
826 Refusal::ContextNotHeld {
827 context: ctx("a", "o"),
828 },
829 Refusal::DefaultUnavailable { org: "o".into() },
830 Refusal::Unresolved { agent: "a".into() },
831 ];
832 for refusal in refusals {
833 let message = refusal.to_string();
834 let actionable = ["Fix", "Work in", "Add", "Choose", "Sign in", "Name"]
835 .iter()
836 .any(|verb| message.contains(verb));
837 assert!(actionable, "refusal without a next step: {message}");
838 }
839 }
840}