Skip to main content

pitboard_core/
context.rs

1//! Everything Pitboard takes from its environment, read in one place. Every front end builds
2//! its `Context` from the variables it was started with, by the same code: the command line
3//! from its own, once. An app passes the ones it was started with, which are not a shell's,
4//! so it also looks for each tool's program itself, in [`crate::app`].
5//!
6//! This module is where the process's environment is read. `clippy.toml` refuses
7//! `std::env::var` and its kind anywhere else, unless an `#[allow]` there says why.
8#![allow(
9    clippy::disallowed_methods,
10    reason = "the one module that reads this process's environment"
11)]
12
13use crate::api::{Anthropic, Api};
14use crate::host::Host;
15use crate::provider::ProviderId;
16use crate::provider::codex::api::{Network as OpenAiNetwork, OpenAi};
17use crate::time::{Clock, SystemClock};
18use std::collections::HashMap;
19use std::ffi::{OsStr, OsString};
20use std::path::PathBuf;
21use std::sync::Arc;
22
23/// Every variable Pitboard reads from the environment it was started with, besides
24/// [`crate::settings::OVERRIDING_ENV`] and the proxy variables, which [`crate::proxy`] lists
25/// in the order ureq tries them: what [`Context::read`] consults, the shell an app
26/// asks for its `PATH`, and the three read straight from the process: `PATH` where a context
27/// was given no search path, and where Linux's host finds the path Pitboard was started by,
28/// `NO_COLOR` by the command line's status line, and `XPC_SERVICE_NAME` by launchd's host.
29/// Each of those reads carries an `#[allow]` saying why it is not made through
30/// [`Environment`].
31///
32/// [`Environment`] refuses, in a build with debug assertions, to read a name missing here,
33/// so every test that reads a context fails until a new variable is added. The tests take
34/// nothing on this list from whoever runs them, except what they pass on by name.
35const READ: &[&str] = &[
36    "HOME",
37    "USER",
38    "PATH",
39    "SHELL",
40    "PITBOARD_HOME",
41    "PITBOARD_NO_ARGV",
42    "PITBOARD_API_BASE",
43    "PITBOARD_CLAUDE",
44    "PITBOARD_CODEX",
45    "CLAUDE_CONFIG_DIR",
46    "CLAUDE_SECURESTORAGE_CONFIG_DIR",
47    "CLAUDE_CODE_CUSTOM_OAUTH_URL",
48    "CLAUDE_CODE_HOVER_REST",
49    "CODEX_HOME",
50    "NO_COLOR",
51    "XPC_SERVICE_NAME",
52    "SUDO_UID",
53];
54
55/// Every variable Pitboard reads from its environment, by name.
56pub(crate) fn variables() -> impl Iterator<Item = &'static str> {
57    READ.iter()
58        .chain(crate::settings::OVERRIDING_ENV.iter())
59        .chain(crate::proxy::NAMING.iter())
60        .chain(crate::proxy::EXEMPTING.iter())
61        .copied()
62}
63
64/// The variables a process was started with, by name.
65///
66/// The command line has a shell's. An app the system started has launchd's, which name the
67/// home and the login and little else, unless somebody set more with `launchctl setenv`;
68/// whatever is there is read the way the command line reads it.
69#[derive(Clone, Debug, Default, PartialEq, Eq)]
70pub struct Environment(HashMap<OsString, OsString>);
71
72impl Environment {
73    /// This process's own.
74    pub fn of_this_process() -> Environment {
75        std::env::vars_os().collect()
76    }
77
78    /// `name`'s value, whatever bytes it holds, as a path may hold any.
79    pub(crate) fn path(&self, name: &str) -> Option<&OsStr> {
80        debug_assert!(
81            variables().any(|read| read == name),
82            "{name} is read but not listed in context::READ, so the tests would take it \
83             from whoever runs them"
84        );
85        self.0.get(OsStr::new(name)).map(OsString::as_os_str)
86    }
87
88    /// `name`'s value as text. One that is not UTF-8 reads as unset, as `std::env::var`
89    /// reads it.
90    pub(crate) fn text(&self, name: &str) -> Option<&str> {
91        self.path(name).and_then(OsStr::to_str)
92    }
93
94    /// Whether `name` holds anything: empty reads as unset.
95    pub(crate) fn set(&self, name: &str) -> bool {
96        self.text(name).is_some_and(|v| !v.is_empty())
97    }
98
99    /// The person's home: `HOME`, or, where it is unset, the account's own, as the passwd
100    /// database names it and Foundation finds it for an app. Set, even empty, it is what the
101    /// person said, and an empty or relative one is refused where it is used
102    /// ([`crate::home::check_absolute`]), never taken to be the folder Pitboard runs in.
103    pub fn home(&self) -> PathBuf {
104        self.path("HOME")
105            .map(PathBuf::from)
106            .or_else(crate::host::user::home)
107            .unwrap_or_default()
108    }
109
110    /// Pitboard's own directory: `PITBOARD_HOME`, or the system's default for
111    /// [`Environment::home`], `.pitboard` there on macOS and Linux. The path as the
112    /// environment gives it, with any `.`, `..` or trailing `/` it holds.
113    pub fn pitboard_home(&self) -> PathBuf {
114        self.path("PITBOARD_HOME")
115            .map(PathBuf::from)
116            .unwrap_or_else(|| crate::host::default_pitboard_home(&self.home()))
117    }
118
119    /// Every variable, as a program started in this environment is given them.
120    #[cfg_attr(
121        windows,
122        allow(
123            dead_code,
124            reason = "what the login shell is given, and Windows has none (`host::login_path`)"
125        )
126    )]
127    pub(crate) fn iter(&self) -> impl Iterator<Item = (&OsStr, &OsStr)> {
128        self.0
129            .iter()
130            .map(|(name, value)| (name.as_os_str(), value.as_os_str()))
131    }
132}
133
134/// The one folder every home of [`Context::for_unit_test`] names for the test running on
135/// this thread: one per test, since the test harness names each test's thread after the
136/// test, under the temporary directory, and never made by anything here.
137#[cfg(test)]
138fn unit_test_sentinel() -> PathBuf {
139    let thread = std::thread::current();
140    let test = thread.name().map_or_else(
141        || format!("{:?}", thread.id()),
142        |name| {
143            name.chars()
144                .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
145                .collect()
146        },
147    );
148    std::env::temp_dir()
149        .join(format!("pitboard-unit-test-{}", std::process::id()))
150        .join(test)
151}
152
153impl<K: Into<OsString>, V: Into<OsString>> FromIterator<(K, V)> for Environment {
154    fn from_iter<I: IntoIterator<Item = (K, V)>>(pairs: I) -> Environment {
155        Environment(
156            pairs
157                .into_iter()
158                .map(|(name, value)| (name.into(), value.into()))
159                .collect(),
160        )
161    }
162}
163
164#[derive(Clone, Debug)]
165pub struct Context {
166    pub(crate) home: PathBuf,
167    pub(crate) pitboard_home: PathBuf,
168    /// `CLAUDE_CONFIG_DIR`, held as it is set, empty included. Claude Code reads an empty
169    /// value as unset for its config file and its credential slot's name, but takes it as
170    /// the empty path for its config dir, which then is whatever folder it runs in, as the
171    /// register's `config_file_location` says. So an empty one is a home that is not a full
172    /// path, and refused as one ([`crate::home::check_absolute`]).
173    pub(crate) claude_config_dir: Option<String>,
174    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`, which Claude Code reads with `!== undefined`:
175    /// empty is set, and pins the default credential slot.
176    pub(crate) secure_storage_dir: Option<String>,
177    /// `$USER`, which names Claude Code's keychain account once screened by `slot`.
178    pub(crate) user: Option<String>,
179    /// `CLAUDE_CODE_CUSTOM_OAUTH_URL`. Set, it renames both the keychain item and the config
180    /// file Claude Code uses, so Pitboard would be reading and writing the wrong ones.
181    pub(crate) custom_oauth: bool,
182    /// Environment variables this process was started with that make Claude Code use
183    /// something other than the login Pitboard moves. Only half the answer: the rest is in
184    /// files, which [`crate::settings::overrides`] reads and an app can see too.
185    pub(crate) overriding_auth: Vec<String>,
186    /// Whether a login too large for `security -i` may be written the way Claude Code
187    /// writes it: as a command argument, where `ps` can see it for the length of the call.
188    /// On by default, because there is no third way and Claude Code writes the same
189    /// document that way itself on every token refresh.
190    pub(crate) argv_fallback: bool,
191    /// Which front end asked, for the audit log. A change made from the menu bar and one
192    /// typed at a prompt read the same otherwise.
193    pub(crate) caller: String,
194    /// The `claude` that runs a sign-in; a bare name is looked up on the search path.
195    pub(crate) claude_program: PathBuf,
196    /// Where Anthropic's endpoints are reached instead, for tests; `api` honours loopback only.
197    pub(crate) api_base: Option<String>,
198    /// The proxy the environment names for Pitboard's requests, read as ureq 3.4.2 reads one.
199    /// None in a context made without an environment.
200    pub(crate) proxy: crate::proxy::Proxies,
201    /// The agent this context's requests go out on, made with `proxy` for the first request
202    /// and shared by every copy of the context.
203    pub(crate) agent: crate::api::Agents,
204    /// `CLAUDE_CODE_HOVER_REST`, which switches on Claude Code's successor credential backend.
205    pub(crate) hover_rest: bool,
206    /// `CODEX_HOME`, which moves everything Codex keeps, including its keyring account.
207    pub(crate) codex_home: Option<String>,
208    /// The `codex` that runs a sign-in; a bare name is looked up on the search path.
209    pub(crate) codex_program: PathBuf,
210    /// The Pitboard the daily renewal schedule runs. `None` is this program, which is right
211    /// for the command line and wrong for an app: the schedule runs `pitboard renew`, so an
212    /// app names the command line it comes with.
213    pub(crate) schedule_program: Option<PathBuf>,
214    /// Where a tool's program is looked for, in `PATH`'s form, and what its sign-in is given
215    /// as `PATH`, behind the program's own directory where that is not on it. `None` is this
216    /// process's own `PATH`: an app opened from Finder has almost nothing on it, so it
217    /// passes the one the person's login shell would have.
218    pub(crate) search_path: Option<std::ffi::OsString>,
219    /// The scheduler's job this process runs as, where a test says. `None` is whatever the
220    /// scheduler said when it started this process.
221    pub(crate) scheduled_job: Option<String>,
222    /// Whether this process was started with the marker the schedule's job carries where
223    /// what the system says of the job it started is not to be relied on
224    /// ([`crate::schedule::SCHEDULED_RUN`]), as the front end it was started as read it.
225    pub(crate) marked_as_scheduled: bool,
226    /// Whether this process was started under sudo, which sets `SUDO_UID`. Part of what the
227    /// host answers when the one gate every change passes asks whether this process runs as
228    /// the person themselves.
229    pub(crate) sudo: bool,
230    /// Where the time comes from. The machine's clock in every real context; a test puts
231    /// its own here to reach the judgements that only happen at a particular moment.
232    pub(crate) clock: Arc<dyn Clock>,
233    /// The machine: its stores, its processes, its scheduler. This build's host in every
234    /// real context.
235    pub(crate) host: Arc<dyn Host>,
236    /// Who answers for Anthropic. The network in every real context.
237    pub(crate) api: Arc<dyn Api>,
238    /// Who answers for OpenAI. The network in every real context.
239    pub(crate) openai: Arc<dyn OpenAi>,
240    /// Who plays each tool's own sign-in in place of its program, where a test or a fixture
241    /// says. `None` in every real context, and in any build without `test-support` there is
242    /// nothing here at all.
243    #[cfg(any(test, feature = "test-support"))]
244    pub(crate) sign_in_script: Option<Arc<dyn crate::switch::SignInScript>>,
245    /// Who plays a warm-up's run in place of the tool's program, where a test or a fixture
246    /// says. `None` in every real context, and in any build without `test-support` there is
247    /// nothing here at all.
248    #[cfg(any(test, feature = "test-support"))]
249    pub(crate) warm_script: Option<Arc<dyn crate::warm::run::WarmScript>>,
250}
251
252impl Context {
253    /// Epoch seconds, from this context's clock.
254    pub(crate) fn now(&self) -> i64 {
255        self.clock.now()
256    }
257
258    /// Epoch milliseconds, from this context's clock.
259    pub(crate) fn now_millis(&self) -> i64 {
260        self.clock.now_millis()
261    }
262
263    /// The time zone of this context's clock: the machine's, unless a test says otherwise.
264    pub(crate) fn zone(&self) -> jiff::tz::TimeZone {
265        self.clock.zone()
266    }
267
268    /// The person's home directory, which is where a platform's own scheduler lives.
269    pub(crate) fn home(&self) -> &std::path::Path {
270        &self.home
271    }
272
273    /// The machine this context reaches.
274    pub(crate) fn host(&self) -> &dyn Host {
275        self.host.as_ref()
276    }
277
278    /// Who this context asks about a login.
279    pub(crate) fn openai(&self) -> &dyn OpenAi {
280        self.openai.as_ref()
281    }
282
283    pub(crate) fn api(&self) -> &dyn Api {
284        self.api.as_ref()
285    }
286
287    /// Claude Code's defaults for a person whose home is `home`: `~/.pitboard`, `~/.claude`,
288    /// the default credential slot, `claude` looked up on `PATH`, and no proxy. An app starts
289    /// here and sets only what differs. Pitboard's own directory is the system's default for
290    /// `home`, the home handed in and never this account's own.
291    pub fn new(home: PathBuf) -> Context {
292        Context {
293            pitboard_home: crate::host::default_pitboard_home(&home),
294            home,
295            claude_config_dir: None,
296            secure_storage_dir: None,
297            user: None,
298            custom_oauth: false,
299            argv_fallback: true,
300            overriding_auth: Vec::new(),
301            caller: "unknown".into(),
302            claude_program: PathBuf::from("claude"),
303            api_base: None,
304            proxy: crate::proxy::Proxies::default(),
305            agent: crate::api::Agents::default(),
306            hover_rest: false,
307            codex_home: None,
308            codex_program: PathBuf::from("codex"),
309            schedule_program: None,
310            search_path: None,
311            scheduled_job: None,
312            marked_as_scheduled: false,
313            sudo: false,
314            clock: Arc::new(SystemClock),
315            host: crate::host::current(),
316            api: Arc::new(Anthropic),
317            openai: Arc::new(OpenAiNetwork),
318            #[cfg(any(test, feature = "test-support"))]
319            sign_in_script: None,
320            #[cfg(any(test, feature = "test-support"))]
321            warm_script: None,
322        }
323    }
324
325    /// Where Codex keeps its login. Empty means unset, as Codex reads it.
326    pub fn with_codex_home(mut self, dir: String) -> Context {
327        self.codex_home = Some(dir).filter(|d| !d.is_empty());
328        self
329    }
330
331    pub(crate) fn codex_home(&self) -> Option<&str> {
332        self.codex_home.as_deref()
333    }
334
335    pub fn with_pitboard_home(mut self, dir: PathBuf) -> Context {
336        self.pitboard_home = dir;
337        self
338    }
339
340    /// Claude Code's config directory, as `CLAUDE_CONFIG_DIR` names it. Empty is kept, as
341    /// Claude Code keeps it: unset for its config file and its credential slot's name, but
342    /// the empty path for its config dir, as the register's `config_file_location` says,
343    /// which is refused as a home that is not a full path.
344    pub fn with_claude_config_dir(mut self, dir: String) -> Context {
345        self.claude_config_dir = Some(dir);
346        self
347    }
348
349    /// Empty is set, and pins the default slot, as Claude Code reads
350    /// `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
351    pub fn with_secure_storage_dir(mut self, dir: String) -> Context {
352        self.secure_storage_dir = Some(dir);
353        self
354    }
355
356    /// The login name whose keychain account Claude Code stores under.
357    /// Allows the argument-line write for a login too large for the stdin one.
358    pub fn with_argv_fallback(mut self, allowed: bool) -> Context {
359        self.argv_fallback = allowed;
360        self
361    }
362
363    /// Whether a custom OAuth endpoint is configured, which moves Claude Code's login.
364    pub fn custom_oauth(&self) -> bool {
365        self.custom_oauth
366    }
367
368    /// The `claude` Pitboard would run to sign someone in.
369    pub fn claude_program(&self) -> &std::path::Path {
370        &self.claude_program
371    }
372
373    /// Whether the argument-line write is allowed for an oversized login.
374    pub fn argv_fallback(&self) -> bool {
375        self.argv_fallback
376    }
377
378    /// Environment variables that authenticate Claude Code some other way, if any.
379    pub fn overriding_auth(&self) -> &[String] {
380        &self.overriding_auth
381    }
382
383    /// Names the front end in the audit log.
384    pub fn with_caller(mut self, caller: String) -> Context {
385        self.caller = caller;
386        self
387    }
388
389    pub fn with_user(mut self, user: String) -> Context {
390        self.user = Some(user);
391        self
392    }
393
394    /// An app started from Finder does not see the shell's `PATH`, so it names `claude` itself.
395    pub fn with_claude_program(mut self, program: PathBuf) -> Context {
396        self.claude_program = program;
397        self
398    }
399
400    /// The same for `codex`.
401    pub fn with_codex_program(mut self, program: PathBuf) -> Context {
402        self.codex_program = program;
403        self
404    }
405
406    /// The `codex` Pitboard would run to sign someone in.
407    pub fn codex_program(&self) -> &std::path::Path {
408        &self.codex_program
409    }
410
411    /// An app is not a command line, so it names the one it comes with for the schedule to
412    /// run.
413    pub fn with_schedule_program(mut self, program: PathBuf) -> Context {
414        self.schedule_program = Some(program);
415        self
416    }
417
418    /// The Pitboard the daily renewal schedule is written to run, where one was named.
419    pub fn schedule_program(&self) -> Option<&std::path::Path> {
420        self.schedule_program.as_deref()
421    }
422
423    /// Look for a tool's program on `path`, in `PATH`'s form, rather than on this process's
424    /// own `PATH`. An app opened from Finder has only the system's directories there, so a
425    /// tool installed through a version manager or an npm prefix is found only on the `PATH`
426    /// the person's shell has, and a script it runs finds its interpreter only there.
427    pub fn with_search_path(mut self, path: String) -> Context {
428        self.search_path = Some(path.into());
429        self
430    }
431
432    /// Where a tool's program is looked for: the path given, or this process's `PATH`.
433    pub(crate) fn search_path(&self) -> std::ffi::OsString {
434        self.search_path
435            .clone()
436            .or_else(|| std::env::var_os("PATH"))
437            .unwrap_or_default()
438    }
439
440    /// The scheduler's job this process runs as, where a test has said. The scheduler's own
441    /// word for it, where it has one, is its to read.
442    pub(crate) fn scheduled_job(&self) -> Option<&str> {
443        self.scheduled_job.as_deref()
444    }
445
446    /// Whether this process was started under sudo.
447    pub(crate) fn sudo(&self) -> bool {
448        self.sudo
449    }
450
451    /// Say this process runs as the scheduler's job `label`, which no test does.
452    #[cfg(test)]
453    pub(crate) fn with_scheduled_job(mut self, label: String) -> Context {
454        self.scheduled_job = Some(label);
455        self
456    }
457
458    /// Say this process is a run of the daily renewal schedule, as the marker its job is
459    /// started with says where what the system says of the job it started is not to be
460    /// relied on: the command line does this for `pitboard renew --scheduled`. Such a run
461    /// renews the default home whatever `PITBOARD_HOME` says
462    /// ([`crate::schedule::SCHEDULED_RUN`]).
463    pub fn started_by_the_schedule(mut self) -> Context {
464        self.marked_as_scheduled = true;
465        self
466    }
467
468    /// Whether this process was started with the schedule's marker.
469    pub(crate) fn marked_as_scheduled(&self) -> bool {
470        self.marked_as_scheduled
471    }
472
473    /// The program named for this tool, found or not.
474    pub fn program_for(&self, tool: ProviderId) -> &std::path::Path {
475        match tool {
476            ProviderId::Claude => &self.claude_program,
477            ProviderId::Codex => &self.codex_program,
478        }
479    }
480
481    /// Run `program` for this tool, where an app found it.
482    pub(crate) fn set_program(&mut self, tool: ProviderId, program: PathBuf) {
483        match tool {
484            ProviderId::Claude => self.claude_program = program,
485            ProviderId::Codex => self.codex_program = program,
486        }
487    }
488
489    /// The command line's context, from this process's own environment.
490    pub fn from_env() -> Context {
491        Context::for_command_line(&Environment::of_this_process())
492    }
493
494    /// The command line's context, from the environment it was started with: a shell's, so
495    /// each tool's program is looked for on its `PATH` when it runs.
496    pub fn for_command_line(env: &Environment) -> Context {
497        Context::read(env, "cli")
498    }
499
500    /// The command line's context for a unit test: this process's environment, less every
501    /// variable Pitboard reads, so what the person running the tests exported changes
502    /// nothing a test checks. `PITBOARD_NO_ARGV=1` would fail a keychain test, and
503    /// `PITBOARD_CLAUDE` would name the program a test runs.
504    ///
505    /// Every home is this test's sentinel ([`unit_test_sentinel`]): the person's home,
506    /// Pitboard's own directory, Claude Code's config directory and Codex's home. So a test
507    /// that forgets to make a scratch home of its own reads nothing real, and anything it
508    /// writes lands in a folder of its own. Taking the home from the environment, as this
509    /// did, gave every such test the real `~/.pitboard`, `~/.claude` and `~/.codex`.
510    #[cfg(test)]
511    pub(crate) fn for_unit_test() -> Context {
512        let sentinel = unit_test_sentinel().into_os_string();
513        let homes = ["HOME", "PITBOARD_HOME", "CLAUDE_CONFIG_DIR", "CODEX_HOME"];
514        let kept: Environment = std::env::vars_os()
515            .filter(|(name, _)| !variables().any(|read| name == read))
516            .chain(homes.map(|home| (home.into(), sentinel.clone())))
517            .collect();
518        Context::for_command_line(&kept)
519    }
520
521    /// Everything `env` says, read the same way for every front end; `caller` names the one
522    /// asking, for the audit log.
523    ///
524    /// Each tool's program is the one its own variable names, `PITBOARD_CLAUDE` or
525    /// `PITBOARD_CODEX`, or else its bare name, looked for on `PATH` when it runs. Where
526    /// to look is all a front end adds: an app has no shell's `PATH`, and finds the
527    /// programs before it runs anything.
528    pub(crate) fn read(env: &Environment, caller: &str) -> Context {
529        let home = env.home();
530        let owned = |name: &str| env.text(name).map(str::to_owned);
531        let program = |tool: ProviderId| {
532            env.path(tool.program_variable())
533                .filter(|named| !named.is_empty())
534                .map_or_else(|| PathBuf::from(tool.program()), PathBuf::from)
535        };
536        Context {
537            pitboard_home: env.pitboard_home(),
538            home,
539            claude_config_dir: owned("CLAUDE_CONFIG_DIR"),
540            secure_storage_dir: owned("CLAUDE_SECURESTORAGE_CONFIG_DIR"),
541            user: owned("USER"),
542            custom_oauth: env.set("CLAUDE_CODE_CUSTOM_OAUTH_URL"),
543            argv_fallback: env.text("PITBOARD_NO_ARGV") != Some("1"),
544            overriding_auth: crate::settings::OVERRIDING_ENV
545                .iter()
546                .filter(|name| env.set(name))
547                .map(|name| (*name).to_string())
548                .collect(),
549            caller: caller.into(),
550            claude_program: program(ProviderId::Claude),
551            api_base: owned("PITBOARD_API_BASE"),
552            proxy: crate::proxy::Proxies::read(env),
553            agent: crate::api::Agents::default(),
554            hover_rest: matches!(env.text("CLAUDE_CODE_HOVER_REST"), Some("1" | "true")),
555            codex_home: owned("CODEX_HOME").filter(|v| !v.is_empty()),
556            codex_program: program(ProviderId::Codex),
557            schedule_program: None,
558            // Unset is nowhere, as it is to the shell: nothing is found on an empty `PATH`.
559            search_path: Some(env.path("PATH").unwrap_or_default().to_os_string()),
560            scheduled_job: None,
561            marked_as_scheduled: false,
562            sudo: env.set("SUDO_UID"),
563            clock: Arc::new(SystemClock),
564            host: crate::host::current(),
565            api: Arc::new(Anthropic),
566            openai: Arc::new(OpenAiNetwork),
567            #[cfg(any(test, feature = "test-support"))]
568            sign_in_script: None,
569            #[cfg(any(test, feature = "test-support"))]
570            warm_script: None,
571        }
572    }
573
574    /// Answer for every service from a script, where a test can produce a 429 or a
575    /// refusal. One script for all of them, so a test that forgets to script a tool's
576    /// service gets a refusal rather than a request to the real one.
577    #[cfg(any(test, feature = "test-support"))]
578    #[doc(hidden)]
579    pub fn with_scripted_api(mut self, api: Arc<crate::api::scripted::ScriptedApi>) -> Context {
580        self.api = api.clone();
581        self.openai = api;
582        self
583    }
584
585    /// Put the credential stores in memory, where a test can make them fail. Only the
586    /// tests do this, which is why the trait behind it is not public.
587    #[cfg(any(test, feature = "test-support"))]
588    #[doc(hidden)]
589    pub fn with_memory_stores(mut self, memory: Arc<crate::host::memory::MemoryHost>) -> Context {
590        self.host = memory;
591        self
592    }
593
594    /// Read the time from somewhere else. Only the tests do this, which is why it is not
595    /// part of the builder a front end uses.
596    #[cfg(any(test, feature = "test-support"))]
597    #[doc(hidden)]
598    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Context {
599        self.clock = clock;
600        self
601    }
602
603    /// Play each tool's own sign-in with `script` rather than start its program: what the
604    /// tool says, what it reads typed back, and the login it stores where the tool would. A
605    /// test or a fixture that may start no program does this, and nothing else does, which is
606    /// why it is not part of the builder a front end uses.
607    #[cfg(any(test, feature = "test-support"))]
608    #[doc(hidden)]
609    pub fn with_sign_in_script(mut self, script: Arc<dyn crate::switch::SignInScript>) -> Context {
610        self.sign_in_script = Some(script);
611        self
612    }
613
614    /// Who plays each tool's own sign-in, where somebody said.
615    #[cfg(any(test, feature = "test-support"))]
616    pub(crate) fn sign_in_script(&self) -> Option<&Arc<dyn crate::switch::SignInScript>> {
617        self.sign_in_script.as_ref()
618    }
619
620    /// Play each warm-up's run with `script` rather than start the tool's program. A test or a
621    /// fixture that may start no program does this, and nothing else does.
622    #[cfg(any(test, feature = "test-support"))]
623    #[doc(hidden)]
624    pub fn with_warm_script(mut self, script: Arc<dyn crate::warm::run::WarmScript>) -> Context {
625        self.warm_script = Some(script);
626        self
627    }
628
629    /// Who plays a warm-up's run, where somebody said.
630    #[cfg(any(test, feature = "test-support"))]
631    pub(crate) fn warm_script(&self) -> Option<&Arc<dyn crate::warm::run::WarmScript>> {
632        self.warm_script.as_ref()
633    }
634
635    /// Whether this context reaches no machine but `memory` and no service but `api`. A
636    /// fixture asks it of the context it made before it puts a login anywhere, so that an
637    /// edit leaving either out of the builder fails there, rather than writing Claude Code's
638    /// login to this machine's keychain under the real one's service name, or asking
639    /// Anthropic.
640    #[cfg(any(test, feature = "test-support"))]
641    #[doc(hidden)]
642    pub fn reaches_only(
643        &self,
644        memory: &Arc<crate::host::memory::MemoryHost>,
645        api: &Arc<crate::api::scripted::ScriptedApi>,
646    ) -> bool {
647        std::ptr::addr_eq(Arc::as_ptr(&self.host), Arc::as_ptr(memory))
648            && std::ptr::addr_eq(Arc::as_ptr(&self.api), Arc::as_ptr(api))
649            && std::ptr::addr_eq(Arc::as_ptr(&self.openai), Arc::as_ptr(api))
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use std::path::Path;
657
658    /// An empty `CLAUDE_CONFIG_DIR` is kept, by the builder as by the environment, since
659    /// Claude Code takes it as the empty path for its config dir; the context is then
660    /// refused as one whose home is not a full path. It was read as unset, which pointed
661    /// Pitboard at `~/.claude`, where no Claude Code started with it keeps anything.
662    #[test]
663    #[cfg_attr(windows, ignore = "W14: Pitboard's own folder on Windows")]
664    fn an_explicit_context_reads_claude_codes_settings_the_way_the_environment_does() {
665        let ctx = Context::new(PathBuf::from("/home/x"))
666            .with_claude_config_dir(String::new())
667            .with_secure_storage_dir(String::new());
668        assert_eq!(ctx.pitboard_home, PathBuf::from("/home/x/.pitboard"));
669        assert_eq!(
670            ctx.claude_config_dir.as_deref(),
671            Some(""),
672            "empty is the empty path for the config dir"
673        );
674        assert_eq!(
675            ctx.secure_storage_dir.as_deref(),
676            Some(""),
677            "empty is set, and pins the default slot"
678        );
679        assert_eq!(ctx.claude_program, PathBuf::from("claude"));
680        let env: Environment = [("HOME", "/home/x"), ("CLAUDE_CONFIG_DIR", "")]
681            .into_iter()
682            .collect();
683        let read = Context::for_command_line(&env);
684        assert_eq!(read.claude_config_dir, ctx.claude_config_dir);
685        for ctx in [ctx, read] {
686            assert!(
687                matches!(
688                    crate::home::check_absolute(&ctx),
689                    Err(crate::error::Error::HomeNotAbsolute {
690                        variable: "CLAUDE_CONFIG_DIR",
691                        ..
692                    })
693                ),
694                "refused"
695            );
696        }
697    }
698
699    /// What a fixture asks of the context it made: whether it reaches nothing but the machine
700    /// in memory and the scripted services it was given, which leaving out either, or giving
701    /// another, makes false.
702    #[test]
703    fn a_context_says_whether_it_reaches_only_the_machine_and_services_it_was_given() {
704        use crate::api::scripted::ScriptedApi;
705        use crate::host::memory::MemoryHost;
706        let (memory, api) = (MemoryHost::new(), ScriptedApi::new());
707        let home = PathBuf::from("/home/x");
708        let both = Context::new(home.clone())
709            .with_memory_stores(Arc::clone(&memory))
710            .with_scripted_api(Arc::clone(&api));
711        assert!(both.reaches_only(&memory, &api));
712        assert!(
713            !Context::new(home.clone())
714                .with_scripted_api(Arc::clone(&api))
715                .reaches_only(&memory, &api),
716            "this machine"
717        );
718        assert!(
719            !Context::new(home)
720                .with_memory_stores(Arc::clone(&memory))
721                .reaches_only(&memory, &api),
722            "the network"
723        );
724        assert!(
725            !both.reaches_only(&MemoryHost::new(), &api),
726            "another machine"
727        );
728        assert!(
729            !both.reaches_only(&memory, &ScriptedApi::new()),
730            "other services"
731        );
732    }
733
734    #[test]
735    fn only_a_front_end_that_names_one_changes_what_the_schedule_runs() {
736        assert_eq!(Context::for_unit_test().schedule_program(), None);
737        let ctx = Context::new(PathBuf::from("/Users/x"));
738        assert_eq!(ctx.schedule_program(), None);
739        let bundled = PathBuf::from("/Applications/Pitboard.app/Contents/Helpers/pitboard");
740        assert_eq!(
741            ctx.with_schedule_program(bundled.clone())
742                .schedule_program(),
743            Some(bundled.as_path())
744        );
745    }
746
747    /// The command line reads the environment it is handed rather than this process's, so
748    /// every variable can be tried without changing what the tests themselves run in.
749    #[test]
750    #[cfg_attr(windows, ignore = "W14: Pitboard's own folder on Windows")]
751    fn the_command_line_reads_the_environment_it_is_given() {
752        let env: Environment = [
753            ("HOME", "/Users/x"),
754            ("PITBOARD_CLAUDE", "/elsewhere/claude"),
755            ("PITBOARD_CODEX", ""),
756            ("CLAUDE_CODE_CUSTOM_OAUTH_URL", "https://oauth.example"),
757            ("ANTHROPIC_API_KEY", "not-a-key"),
758            ("ANTHROPIC_AUTH_TOKEN", ""),
759            ("PATH", "/opt/tools/bin:/usr/bin"),
760        ]
761        .into_iter()
762        .collect();
763        let ctx = Context::for_command_line(&env);
764        assert_eq!(ctx.claude_program(), Path::new("/elsewhere/claude"));
765        assert_eq!(
766            ctx.codex_program(),
767            Path::new("codex"),
768            "empty names nothing"
769        );
770        assert!(ctx.custom_oauth());
771        assert_eq!(
772            ctx.overriding_auth(),
773            ["ANTHROPIC_API_KEY"],
774            "empty is unset"
775        );
776        assert_eq!(ctx.search_path(), "/opt/tools/bin:/usr/bin");
777        assert_eq!(ctx.pitboard_home, PathBuf::from("/Users/x/.pitboard"));
778        assert_eq!(ctx.caller, "cli");
779        let no_path: Environment = [("HOME", "/Users/x")].into_iter().collect();
780        assert_eq!(
781            Context::for_command_line(&no_path).search_path(),
782            "",
783            "no PATH is nowhere, not this process's"
784        );
785    }
786
787    /// A variable read without being listed is caught the first time a test reads it, so the
788    /// list the tests withhold from every command they run cannot fall behind what is read.
789    #[test]
790    #[cfg(debug_assertions)]
791    #[should_panic(expected = "PITBOARD_SOMETHING_NEW is read but not listed")]
792    fn a_variable_read_without_being_listed_is_caught() {
793        let _ = Environment::default().path("PITBOARD_SOMETHING_NEW");
794    }
795
796    /// With no `HOME`, the home is the account's own, as the passwd database names it, which
797    /// is where the app has always looked. The command line took an empty path instead, and
798    /// so kept its files in `.pitboard` wherever it was run from.
799    #[test]
800    #[cfg_attr(windows, ignore = "W14: the account's own home on Windows")]
801    fn without_home_the_home_is_the_accounts_own() {
802        let _real = crate::host::user::testing::reaching_the_real_home();
803        let own = crate::host::user::home().expect("this account has a home");
804        let ctx = Context::for_command_line(&Environment::default());
805        assert_eq!(ctx.home, own);
806        assert_eq!(ctx.pitboard_home, own.join(".pitboard"));
807        let empty: Environment = [("HOME", "")].into_iter().collect();
808        assert_eq!(
809            Context::for_command_line(&empty).home,
810            PathBuf::new(),
811            "set, even empty, it is what the person said"
812        );
813    }
814
815    /// A unit test's context names one folder for every home, its own, under the temporary
816    /// directory, and makes nothing there, so a test that forgets to give a scratch home of
817    /// its own reads none of the real ones and writes only there. It took the person's real
818    /// home from the passwd database, and every home under it.
819    #[test]
820    fn a_unit_tests_homes_are_a_folder_of_its_own_that_nothing_makes() {
821        let ctx = Context::for_unit_test();
822        let sentinel = ctx.home.clone();
823        assert!(
824            sentinel.starts_with(std::env::temp_dir()),
825            "{}",
826            sentinel.display()
827        );
828        assert!(!sentinel.exists(), "{}", sentinel.display());
829        assert_eq!(ctx.pitboard_home, sentinel);
830        assert_eq!(
831            crate::provider::claude::paths::config_dir(&ctx),
832            sentinel,
833            "Claude Code's"
834        );
835        assert_eq!(
836            crate::provider::codex::paths::home(&ctx),
837            sentinel,
838            "Codex's"
839        );
840        assert_eq!(Context::for_unit_test().home, sentinel, "the same test's");
841        let another = std::thread::Builder::new()
842            .name("another::test".into())
843            .spawn(|| Context::for_unit_test().home)
844            .expect("a thread")
845            .join()
846            .expect("its context");
847        assert_ne!(another, sentinel, "another test's");
848        assert!(another.ends_with("another--test"), "{}", another.display());
849    }
850
851    /// The home and Pitboard directory an app asks of the environment it was started with
852    /// are the ones the context it is given reads, path for path, so an app that keys records
853    /// of its own by Pitboard's directory keys them by the core's.
854    #[test]
855    fn the_homes_an_app_asks_for_are_the_contexts() {
856        let _real = crate::host::user::testing::reaching_the_real_home();
857        let cases: [&[(&str, &str)]; 5] = [
858            &[],
859            &[("HOME", "/Users/x")],
860            &[("HOME", "/Users/x/")],
861            &[
862                ("HOME", "/Users/x"),
863                ("PITBOARD_HOME", "/elsewhere/./pitboard/"),
864            ],
865            &[("PITBOARD_HOME", "")],
866        ];
867        for pairs in cases {
868            let env: Environment = pairs.iter().copied().collect();
869            let ctx = Context::for_command_line(&env);
870            assert_eq!(env.home(), ctx.home, "{pairs:?}");
871            assert_eq!(env.pitboard_home(), ctx.pitboard_home, "{pairs:?}");
872        }
873        let given: Environment = [("PITBOARD_HOME", "/elsewhere/./pitboard/")]
874            .into_iter()
875            .collect();
876        assert_eq!(
877            given.pitboard_home().as_os_str(),
878            "/elsewhere/./pitboard/",
879            "as the environment gives it"
880        );
881    }
882}