Skip to main content

car_context/
resolve.rs

1//! Which work context a new session binds.
2//!
3//! One CAR can hold logins for several organizations at once. Each session
4//! binds exactly one [`WorkContext`] (or none, for personal work) when it is
5//! created, and this module is the single rule that picks it.
6//!
7//! **Hard constraints** hold against every source, including an explicit choice
8//! and an attested inbound request:
9//!
10//! - a session inside a repository that declares an org binds that org;
11//! - the org is in the agent's permitted set (or the session is personal and
12//!   the agent allows personal work);
13//! - a signed-in login holds the org, and exactly one does.
14//!
15//! **Sources** fill in the context, first match wins: an attested inbound
16//! context, an explicit choice, the repository's org, the agent's default, the
17//! user's default org, then personal. The user's default is a preference among
18//! the orgs an agent may already work in: it applies only to an agent permitted
19//! there, so setting a default org never enrolls an agent that never joined
20//! one, and an agent with no organization settings keeps running personal work
21//! exactly as it did before.
22//!
23//! A refusal never falls back. The defaults fill in a context nobody named;
24//! they are never a way around a failed constraint, because the failure being
25//! prevented is work for one organization quietly running — and billing, and
26//! remembering — as another.
27//!
28//! The rule is pure: no clock, network, filesystem or credential store. The
29//! caller supplies what it read, and afterwards proves it can mint a token for
30//! the chosen context before the session starts.
31
32use crate::WorkContext;
33use std::fmt;
34
35/// What the person or host explicitly asked for when creating the session.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Explicit {
38    /// An org by id. The signed-in login that holds it supplies the account.
39    Org(String),
40    /// A complete context, for hosts that already hold one.
41    Context(WorkContext),
42    /// Personal, org-less work.
43    Personal,
44}
45
46/// The org a repository declares in its `.car/config.toml`.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum RepositoryOrg {
49    /// Not inside a repository, or the repository declares no org.
50    Undeclared,
51    /// `[organization] id = "<org>"`; `declared_in` names the file for messages.
52    Declared { org: String, declared_in: String },
53    /// The declaration exists but cannot be read. The constraint cannot be
54    /// checked, so every session there is refused until it is fixed.
55    Unreadable { location: String, error: String },
56}
57
58/// Which orgs an agent may work in.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Permitted {
61    /// Exactly these orgs.
62    Orgs(Vec<String>),
63    /// Any org a signed-in login holds. For callers with no agent definition to
64    /// consult, such as the CLI previewing a resolution. Agent definitions name
65    /// their orgs, so joining a new company never silently enrolls an agent.
66    AnyHeld,
67}
68
69/// The agent's organization settings, as its definition declares them.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct AgentOrganizations {
72    /// The agent's id, for messages.
73    pub agent: String,
74    pub permitted: Permitted,
75    /// The org a new session uses when nothing more specific applies.
76    pub default: Option<String>,
77    /// Whether the agent may run personal, org-less sessions.
78    pub personal: bool,
79}
80
81impl AgentOrganizations {
82    /// An agent with no organization settings: personal work only, which is
83    /// what every agent did before organizations existed.
84    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/// Everything the rule reads. Borrowed, so a caller can resolve repeatedly
95/// without cloning the held-login index.
96#[derive(Debug, Clone, Copy)]
97pub struct Inputs<'a> {
98    /// The context of an inbound request whose sender was attested as a member
99    /// of that org AND authorized to drive this agent. Attestation alone is not
100    /// enough; the caller must have checked both.
101    pub inbound: Option<&'a WorkContext>,
102    pub explicit: Option<&'a Explicit>,
103    pub repository: &'a RepositoryOrg,
104    pub agent: &'a AgentOrganizations,
105    /// The person's default org.
106    pub user_default: Option<&'a WorkContext>,
107    /// Every (login, org) context a signed-in login holds.
108    pub held: &'a [WorkContext],
109}
110
111/// Which source supplied the context.
112#[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    /// Stable wire name.
124    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/// The binding a session starts with.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct Resolved {
152    /// `None` is a personal, org-less session.
153    pub context: Option<WorkContext>,
154    pub source: Source,
155}
156
157/// Why a session cannot start. Each message names the fix.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum Refusal {
160    RepositoryUnreadable {
161        location: String,
162        error: String,
163    },
164    /// `requested` is an org id, or `None` for personal work.
165    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    /// Stable wire code, so a host can react to a refusal without parsing its
200    /// message.
201    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                 Choose one with `car auth default-org {org} --account <id>`",
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
284/// A candidate before the constraints are checked.
285enum 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
301/// Pick the context a new session binds, or say why none can be.
302pub 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(
355            held_by_one(inputs.held, org, source)
356                .or_else(|refusal| default_settles(refusal, &inputs, org))?
357                .clone(),
358        ),
359    };
360    Ok(Resolved { context, source })
361}
362
363fn pick<'a>(inputs: &Inputs<'a>) -> Result<(Candidate<'a>, Source), Refusal> {
364    if let Some(context) = inputs.inbound {
365        return Ok((Candidate::Exact(context), Source::Inbound));
366    }
367    if let Some(explicit) = inputs.explicit {
368        let candidate = match explicit {
369            Explicit::Org(org) => Candidate::Org(org),
370            Explicit::Context(context) => Candidate::Exact(context),
371            Explicit::Personal => Candidate::Personal,
372        };
373        return Ok((candidate, Source::Explicit));
374    }
375    if let RepositoryOrg::Declared { org, .. } = inputs.repository {
376        return Ok((Candidate::Org(org), Source::Repository));
377    }
378    if let Some(org) = inputs.agent.default.as_deref() {
379        return Ok((Candidate::Org(org), Source::AgentDefault));
380    }
381    if let Some(context) = inputs
382        .user_default
383        .filter(|context| permits(inputs.agent, context.organization_id.as_deref()))
384    {
385        return Ok((Candidate::Exact(context), Source::UserDefault));
386    }
387    if inputs.agent.personal {
388        return Ok((Candidate::Personal, Source::Personal));
389    }
390    Err(Refusal::Unresolved {
391        agent: inputs.agent.agent.clone(),
392    })
393}
394
395/// Whether the agent may work in `org` (`None` = personal).
396fn permits(agent: &AgentOrganizations, org: Option<&str>) -> bool {
397    match (org, &agent.permitted) {
398        (None, _) => agent.personal,
399        (Some(_), Permitted::AnyHeld) => true,
400        (Some(org), Permitted::Orgs(orgs)) => orgs.iter().any(|o| o == org),
401    }
402}
403
404/// car#4261: an org two signed-in logins hold resolves to the person's
405/// default context when that is one of them, so a repository that declares
406/// its org does not leave `car claude` with no way to choose: the default
407/// (`car auth default-org <org> --account <id>`) is consulted for the login,
408/// never for the org. Any other refusal stands.
409fn default_settles<'a>(
410    refusal: Refusal,
411    inputs: &Inputs<'a>,
412    org: &str,
413) -> Result<&'a WorkContext, Refusal> {
414    let Refusal::Ambiguous { .. } = refusal else {
415        return Err(refusal);
416    };
417    inputs
418        .user_default
419        .filter(|default| default.organization_id.as_deref() == Some(org))
420        .and_then(|default| inputs.held.iter().find(|held| *held == default))
421        .ok_or(refusal)
422}
423
424fn held_by_one<'h>(
425    held: &'h [WorkContext],
426    org: &str,
427    source: Source,
428) -> Result<&'h WorkContext, Refusal> {
429    let mut matches = held
430        .iter()
431        .filter(|context| context.organization_id.as_deref() == Some(org));
432    let Some(first) = matches.next() else {
433        return Err(Refusal::NotHeld {
434            org: org.to_string(),
435            source,
436        });
437    };
438    let others: Vec<&WorkContext> = matches.filter(|context| *context != first).collect();
439    if others.is_empty() {
440        return Ok(first);
441    }
442    let mut accounts: Vec<String> = std::iter::once(first)
443        .chain(others)
444        .map(|context| context.account_id.clone())
445        .collect();
446    accounts.sort();
447    accounts.dedup();
448    Err(Refusal::Ambiguous {
449        org: org.to_string(),
450        accounts,
451    })
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    const BASE: &str = "https://api.parslee.ai";
459
460    fn ctx(account: &str, org: &str) -> WorkContext {
461        WorkContext::new(BASE, account, Some(org.into())).unwrap()
462    }
463
464    /// The three logins this feature was built for, plus a demo org on the
465    /// Parslee login.
466    fn held() -> Vec<WorkContext> {
467        vec![
468            ctx("acc_parslee", "org_parslee"),
469            ctx("acc_parslee", "org_parslee_steelpath"),
470            ctx("acc_fly", "org_fly_exclusive"),
471            ctx("acc_volato", "org_volato"),
472        ]
473    }
474
475    fn agent(permitted: &[&str], default: Option<&str>, personal: bool) -> AgentOrganizations {
476        AgentOrganizations {
477            agent: "parslee-core".into(),
478            permitted: Permitted::Orgs(permitted.iter().map(|s| s.to_string()).collect()),
479            default: default.map(str::to_string),
480            personal,
481        }
482    }
483
484    fn declared(org: &str) -> RepositoryOrg {
485        RepositoryOrg::Declared {
486            org: org.into(),
487            declared_in: "/repo/.car/config.toml".into(),
488        }
489    }
490
491    struct Case<'a> {
492        inbound: Option<WorkContext>,
493        explicit: Option<Explicit>,
494        repository: RepositoryOrg,
495        agent: AgentOrganizations,
496        user_default: Option<WorkContext>,
497        held: &'a [WorkContext],
498    }
499
500    impl Case<'_> {
501        fn resolve(&self) -> Result<Resolved, Refusal> {
502            resolve(Inputs {
503                inbound: self.inbound.as_ref(),
504                explicit: self.explicit.as_ref(),
505                repository: &self.repository,
506                agent: &self.agent,
507                user_default: self.user_default.as_ref(),
508                held: self.held,
509            })
510        }
511    }
512
513    fn base(held: &[WorkContext]) -> Case<'_> {
514        Case {
515            inbound: None,
516            explicit: None,
517            repository: RepositoryOrg::Undeclared,
518            agent: agent(
519                &["org_parslee", "org_fly_exclusive", "org_volato"],
520                None,
521                true,
522            ),
523            user_default: Some(ctx("acc_parslee", "org_parslee")),
524            held,
525        }
526    }
527
528    fn bound(result: Result<Resolved, Refusal>) -> (Option<String>, Option<String>, Source) {
529        let resolved = result.expect("expected a binding");
530        let context = resolved.context;
531        (
532            context.as_ref().map(|c| c.account_id.clone()),
533            context.and_then(|c| c.organization_id),
534            resolved.source,
535        )
536    }
537
538    fn some(s: &str) -> Option<String> {
539        Some(s.to_string())
540    }
541
542    #[test]
543    fn the_user_default_fills_in_when_nothing_else_applies() {
544        let held = held();
545        assert_eq!(
546            bound(base(&held).resolve()),
547            (
548                some("acc_parslee"),
549                some("org_parslee"),
550                Source::UserDefault
551            )
552        );
553    }
554
555    #[test]
556    fn sources_apply_in_order() {
557        let held = held();
558        // Agent default beats the user default.
559        let mut case = base(&held);
560        case.agent.default = some("org_volato");
561        assert_eq!(
562            bound(case.resolve()),
563            (some("acc_volato"), some("org_volato"), Source::AgentDefault)
564        );
565        // Repository beats the agent default.
566        case.repository = declared("org_fly_exclusive");
567        assert_eq!(
568            bound(case.resolve()),
569            (
570                some("acc_fly"),
571                some("org_fly_exclusive"),
572                Source::Repository
573            )
574        );
575        // An explicit choice that agrees with the repository wins as Explicit.
576        case.explicit = Some(Explicit::Org("org_fly_exclusive".into()));
577        assert_eq!(bound(case.resolve()).2, Source::Explicit);
578        // Inbound beats everything, when it satisfies the constraints.
579        case.inbound = Some(ctx("acc_fly", "org_fly_exclusive"));
580        assert_eq!(bound(case.resolve()).2, Source::Inbound);
581    }
582
583    #[test]
584    fn the_org_supplies_the_account() {
585        let held = held();
586        let mut case = base(&held);
587        case.explicit = Some(Explicit::Org("org_parslee_steelpath".into()));
588        case.agent.permitted = Permitted::AnyHeld;
589        assert_eq!(
590            bound(case.resolve()),
591            (
592                some("acc_parslee"),
593                some("org_parslee_steelpath"),
594                Source::Explicit
595            )
596        );
597    }
598
599    #[test]
600    fn a_repository_org_constrains_every_source() {
601        let held = held();
602        let mismatch = |case: &Case<'_>, source: Source| match case.resolve() {
603            Err(Refusal::RepositoryMismatch {
604                repository_org,
605                source: got,
606                ..
607            }) => {
608                assert_eq!(repository_org, "org_fly_exclusive");
609                assert_eq!(got, source);
610            }
611            other => panic!("expected a repository mismatch, got {other:?}"),
612        };
613        let mut case = base(&held);
614        case.repository = declared("org_fly_exclusive");
615
616        case.explicit = Some(Explicit::Org("org_volato".into()));
617        mismatch(&case, Source::Explicit);
618
619        case.explicit = Some(Explicit::Personal);
620        mismatch(&case, Source::Explicit);
621
622        case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
623        mismatch(&case, Source::Explicit);
624
625        // An attested, authorized peer still cannot pull a flyExclusive
626        // repository into Volato.
627        case.explicit = None;
628        case.inbound = Some(ctx("acc_volato", "org_volato"));
629        mismatch(&case, Source::Inbound);
630    }
631
632    #[test]
633    fn an_unreadable_repository_refuses_instead_of_falling_back() {
634        let held = held();
635        let mut case = base(&held);
636        case.repository = RepositoryOrg::Unreadable {
637            location: "/repo/.car/config.toml".into(),
638            error: "invalid TOML".into(),
639        };
640        // Even an explicit choice: the constraint cannot be checked.
641        case.explicit = Some(Explicit::Org("org_parslee".into()));
642        assert!(matches!(
643            case.resolve(),
644            Err(Refusal::RepositoryUnreadable { .. })
645        ));
646    }
647
648    #[test]
649    fn the_permitted_set_binds_every_source() {
650        let held = held();
651        let mut case = base(&held);
652        case.agent = agent(&["org_parslee"], None, true);
653
654        case.explicit = Some(Explicit::Org("org_volato".into()));
655        assert!(matches!(
656            case.resolve(),
657            Err(Refusal::NotPermitted {
658                source: Source::Explicit,
659                ..
660            })
661        ));
662
663        // The repository's org is not a way in either.
664        case.explicit = None;
665        case.repository = declared("org_volato");
666        assert!(matches!(
667            case.resolve(),
668            Err(Refusal::NotPermitted {
669                source: Source::Repository,
670                ..
671            })
672        ));
673
674        // Nor an inbound request.
675        case.repository = RepositoryOrg::Undeclared;
676        case.inbound = Some(ctx("acc_volato", "org_volato"));
677        assert!(matches!(
678            case.resolve(),
679            Err(Refusal::NotPermitted {
680                source: Source::Inbound,
681                ..
682            })
683        ));
684    }
685
686    #[test]
687    fn the_user_default_applies_only_where_the_agent_is_permitted() {
688        let held = held();
689        let mut case = base(&held);
690        case.agent = agent(&["org_parslee"], None, true);
691        case.user_default = Some(ctx("acc_volato", "org_volato"));
692        // The default is a preference among the agent's orgs, not a grant.
693        assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
694
695        case.agent.personal = false;
696        assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
697    }
698
699    #[test]
700    fn an_explicit_refusal_never_falls_back_to_a_default() {
701        let held = held();
702        let mut case = base(&held);
703        case.agent = agent(&["org_parslee"], Some("org_parslee"), true);
704        case.explicit = Some(Explicit::Org("org_volato".into()));
705        // The agent default and the user default would both succeed; neither
706        // is consulted once an explicit choice was refused.
707        assert!(matches!(
708            case.resolve(),
709            Err(Refusal::NotPermitted {
710                source: Source::Explicit,
711                ..
712            })
713        ));
714    }
715
716    #[test]
717    fn personal_work_needs_the_agent_to_allow_it() {
718        let held = held();
719        let mut case = base(&held);
720        case.user_default = None;
721        assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
722
723        case.agent.personal = false;
724        assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
725
726        case.explicit = Some(Explicit::Personal);
727        assert!(matches!(
728            case.resolve(),
729            Err(Refusal::PersonalNotAllowed { .. })
730        ));
731    }
732
733    #[test]
734    fn an_agent_with_no_org_settings_stays_personal() {
735        let held = held();
736        let mut case = base(&held);
737        case.agent = AgentOrganizations::personal_only("milo");
738        // Today's behaviour is preserved, with or without a default org:
739        // setting one does not enroll an agent that never joined it.
740        case.user_default = None;
741        assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
742        case.user_default = Some(ctx("acc_parslee", "org_parslee"));
743        assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
744        // But an explicit org is refused, not silently run as personal.
745        case.explicit = Some(Explicit::Org("org_parslee".into()));
746        assert!(matches!(case.resolve(), Err(Refusal::NotPermitted { .. })));
747    }
748
749    #[test]
750    fn an_org_no_login_holds_is_refused() {
751        let held = held();
752        let mut case = base(&held);
753        case.agent.permitted = Permitted::AnyHeld;
754        case.explicit = Some(Explicit::Org("org_acme".into()));
755        assert!(matches!(
756            case.resolve(),
757            Err(Refusal::NotHeld {
758                source: Source::Explicit,
759                ..
760            })
761        ));
762    }
763
764    #[test]
765    fn a_stale_user_default_is_refused_by_name() {
766        // The Volato login was removed; the default still names it.
767        let held: Vec<WorkContext> = held()
768            .into_iter()
769            .filter(|c| c.account_id != "acc_volato")
770            .collect();
771        let mut case = base(&held);
772        case.user_default = Some(ctx("acc_volato", "org_volato"));
773        match case.resolve() {
774            Err(Refusal::DefaultUnavailable { org }) => assert_eq!(org, "org_volato"),
775            other => panic!("expected DefaultUnavailable, got {other:?}"),
776        }
777    }
778
779    #[test]
780    fn an_explicit_context_must_be_held_exactly() {
781        let held = held();
782        let mut case = base(&held);
783        // Right org, wrong account.
784        case.explicit = Some(Explicit::Context(ctx("acc_parslee", "org_volato")));
785        assert!(matches!(
786            case.resolve(),
787            Err(Refusal::ContextNotHeld { .. })
788        ));
789        case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
790        assert_eq!(
791            bound(case.resolve()),
792            (some("acc_volato"), some("org_volato"), Source::Explicit)
793        );
794    }
795
796    #[test]
797    fn two_logins_in_one_org_is_ambiguous_not_a_guess() {
798        let mut held = held();
799        held.push(ctx("acc_parslee", "org_volato"));
800        let mut case = base(&held);
801        case.explicit = Some(Explicit::Org("org_volato".into()));
802        match case.resolve() {
803            Err(Refusal::Ambiguous { org, accounts }) => {
804                assert_eq!(org, "org_volato");
805                assert_eq!(accounts, vec!["acc_parslee", "acc_volato"]);
806            }
807            other => panic!("expected Ambiguous, got {other:?}"),
808        }
809        // A full context removes the ambiguity.
810        case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
811        assert_eq!(bound(case.resolve()).0, some("acc_volato"));
812    }
813
814    /// car#4261: a repository-declared org two logins hold resolves to the
815    /// person's default login for that org; with no default for it, the
816    /// refusal names the command that sets one.
817    #[test]
818    fn a_repository_org_two_logins_hold_takes_the_default_login() {
819        let mut held = held();
820        held.push(ctx("acc_parslee", "org_volato"));
821        let mut case = base(&held);
822        case.repository = declared("org_volato");
823        case.user_default = Some(ctx("acc_parslee", "org_volato"));
824        assert_eq!(
825            bound(case.resolve()),
826            (some("acc_parslee"), some("org_volato"), Source::Repository)
827        );
828        case.user_default = Some(ctx("acc_parslee", "org_parslee"));
829        let refused = case.resolve().expect_err("no default for the org");
830        assert!(matches!(refused, Refusal::Ambiguous { .. }));
831        assert!(
832            refused
833                .to_string()
834                .contains("car auth default-org org_volato --account"),
835            "{refused}"
836        );
837    }
838
839    #[test]
840    fn duplicate_index_entries_are_not_ambiguous() {
841        let mut held = held();
842        held.push(ctx("acc_volato", "org_volato"));
843        let mut case = base(&held);
844        case.explicit = Some(Explicit::Org("org_volato".into()));
845        assert_eq!(bound(case.resolve()).0, some("acc_volato"));
846    }
847
848    #[test]
849    fn every_refusal_names_a_next_step() {
850        let refusals = [
851            Refusal::RepositoryUnreadable {
852                location: "x".into(),
853                error: "y".into(),
854            },
855            Refusal::RepositoryMismatch {
856                repository_org: "a".into(),
857                declared_in: "x".into(),
858                requested: None,
859                source: Source::Explicit,
860            },
861            Refusal::NotPermitted {
862                agent: "a".into(),
863                org: "o".into(),
864                source: Source::Explicit,
865            },
866            Refusal::PersonalNotAllowed { agent: "a".into() },
867            Refusal::NotHeld {
868                org: "o".into(),
869                source: Source::Explicit,
870            },
871            Refusal::Ambiguous {
872                org: "o".into(),
873                accounts: vec!["a".into(), "b".into()],
874            },
875            Refusal::ContextNotHeld {
876                context: ctx("a", "o"),
877            },
878            Refusal::DefaultUnavailable { org: "o".into() },
879            Refusal::Unresolved { agent: "a".into() },
880        ];
881        for refusal in refusals {
882            let message = refusal.to_string();
883            let actionable = ["Fix", "Work in", "Add", "Choose", "Sign in", "Name"]
884                .iter()
885                .any(|verb| message.contains(verb));
886            assert!(actionable, "refusal without a next step: {message}");
887        }
888    }
889}