1#![allow(
9 clippy::disallowed_methods,
10 reason = "the one module that reads this process's environment"
11)]
12
13use crate::host::{Host, Os};
14use crate::provider::ProviderId;
15use crate::provider::claude::api::{Anthropic, Api};
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
23const 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 "CLAUDE_CODE_FORCE_WINDOWS_CREDMAN",
50 "CLAUDE_CODE_GIT_BASH_PATH",
51 "WSL_DISTRO_NAME",
52 "CODEX_HOME",
53 "CODEX_INSTALL_DIR",
54 "NO_COLOR",
55 "XPC_SERVICE_NAME",
56 "SUDO_UID",
57 "USERPROFILE",
58 "USERNAME",
59 "APPDATA",
60 "LOCALAPPDATA",
61 "ProgramData",
62 "SystemRoot",
63 "PATHEXT",
64 "TEMP",
65 "TMP",
66 "OneDrive",
67 "OneDriveCommercial",
68 "OneDriveConsumer",
69];
70
71#[cfg(any(test, feature = "test-support"))]
73const READ_BY_BUILDS_FOR_TESTS: &[&str] = &[TEST_CLAUDE_DESKTOP];
74#[cfg(not(any(test, feature = "test-support")))]
75const READ_BY_BUILDS_FOR_TESTS: &[&str] = &[];
76
77#[cfg(any(test, feature = "test-support"))]
78const TEST_CLAUDE_DESKTOP: &str = "PITBOARD_TEST_CLAUDE_DESKTOP";
79
80pub(crate) fn variables() -> impl Iterator<Item = &'static str> {
82 READ.iter()
83 .chain(READ_BY_BUILDS_FOR_TESTS.iter())
84 .chain(crate::settings::OVERRIDING_ENV.iter())
85 .chain(crate::proxy::NAMING.iter())
86 .chain(crate::proxy::EXEMPTING.iter())
87 .copied()
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct Environment {
97 os: Os,
98 variables: HashMap<OsString, (OsString, OsString)>,
99}
100
101impl Default for Environment {
102 fn default() -> Environment {
103 Environment::on(crate::host::OS, std::iter::empty::<(OsString, OsString)>())
104 }
105}
106
107impl Environment {
108 pub fn of_this_process() -> Environment {
110 std::env::vars_os().collect()
111 }
112
113 pub(crate) fn on<K: Into<OsString>, V: Into<OsString>>(
114 os: Os,
115 pairs: impl IntoIterator<Item = (K, V)>,
116 ) -> Environment {
117 let mut variables = HashMap::new();
118 for (name, value) in pairs {
119 let (name, value) = (name.into(), value.into());
120 let key = os.variable_key(&name);
121 match os {
122 Os::MacOs | Os::Linux => {
123 variables.insert(key, (name, value));
124 }
125 Os::Windows => {
127 variables.entry(key).or_insert((name, value));
128 }
129 }
130 }
131 Environment { os, variables }
132 }
133
134 pub(crate) fn os(&self) -> Os {
135 self.os
136 }
137
138 pub(crate) fn path(&self, name: &str) -> Option<&OsStr> {
140 debug_assert!(
141 variables().any(|read| read == name),
142 "{name} is read but not listed in context::READ, so the tests would take it \
143 from whoever runs them"
144 );
145 self.variables
146 .get(&self.os.variable_key(OsStr::new(name)))
147 .map(|(_, value)| value.as_os_str())
148 }
149
150 pub(crate) fn text(&self, name: &str) -> Option<&str> {
153 self.path(name).and_then(OsStr::to_str)
154 }
155
156 pub(crate) fn spelling(&self, name: &str) -> Option<&OsStr> {
158 self.path(name)?;
159 self.variables
160 .get(&self.os.variable_key(OsStr::new(name)))
161 .map(|(spelled, _)| spelled.as_os_str())
162 }
163
164 pub(crate) fn set(&self, name: &str) -> bool {
166 self.text(name).is_some_and(|v| !v.is_empty())
167 }
168
169 pub(crate) fn named_folder(&self, name: &str) -> Option<PathBuf> {
171 self.path(name)
172 .filter(|folder| !folder.is_empty())
173 .map(PathBuf::from)
174 }
175
176 pub(crate) fn home(&self) -> PathBuf {
178 self.path(self.os.home_variable())
179 .map(PathBuf::from)
180 .or_else(crate::host::user::home)
181 .unwrap_or_default()
182 }
183
184 pub fn pitboard_home(&self) -> PathBuf {
186 self.path("PITBOARD_HOME")
187 .map(PathBuf::from)
188 .unwrap_or_else(|| self.os.default_pitboard_home(&self.home()))
189 }
190
191 #[cfg_attr(
193 windows,
194 allow(
195 dead_code,
196 reason = "what the login shell is given, and Windows has none (`host::login_path`)"
197 )
198 )]
199 pub(crate) fn iter(&self) -> impl Iterator<Item = (&OsStr, &OsStr)> {
200 self.variables
201 .values()
202 .map(|(name, value)| (name.as_os_str(), value.as_os_str()))
203 }
204}
205
206#[cfg(test)]
210fn unit_test_sentinel() -> PathBuf {
211 let thread = std::thread::current();
212 let test = thread.name().map_or_else(
213 || format!("{:?}", thread.id()),
214 |name| {
215 name.chars()
216 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
217 .collect()
218 },
219 );
220 std::env::temp_dir()
221 .join(format!("pitboard-unit-test-{}", std::process::id()))
222 .join(test)
223}
224
225impl<K: Into<OsString>, V: Into<OsString>> FromIterator<(K, V)> for Environment {
226 fn from_iter<I: IntoIterator<Item = (K, V)>>(pairs: I) -> Environment {
227 Environment::on(crate::host::OS, pairs)
228 }
229}
230
231#[derive(Debug, Clone)]
233pub struct ScratchClaude {
234 pub data: PathBuf,
235 pub app: PathBuf,
236}
237
238#[cfg(any(test, feature = "test-support"))]
240pub(crate) fn refuse_a_real_claude(path: &std::path::Path) {
241 use crate::host::user;
242 assert!(
243 !path
244 .components()
245 .any(|part| part == std::path::Component::ParentDir),
246 "a test named {} for Claude through `..`, which may lead anywhere once a folder is made: \
247 give it a folder of its own",
248 path.display()
249 );
250 #[cfg(test)]
251 let fake = testing::guarded_home();
252 #[cfg(not(test))]
253 let fake: Option<PathBuf> = None;
254 let real = match crate::host::OS {
255 Os::MacOs | Os::Linux => {
256 ["Library/Application Support", "Applications/Claude.app"]
257 .into_iter()
258 .any(|below| {
259 user::lies_in_the_accounts_own(path, below)
260 || fake
261 .as_ref()
262 .is_some_and(|home| crate::host::lies_in(path, &home.join(below)))
263 })
264 || crate::host::lies_in(path, std::path::Path::new("/Applications/Claude.app"))
265 }
266 Os::Windows => {
267 let (mut roaming, mut local) = user::app_data_folders(true);
268 if let Some(home) = &fake {
269 roaming.push(home.join("AppData").join("Roaming"));
270 local.push(home.join("AppData").join("Local"));
271 }
272 crate::host::claude_desktop_on_windows(&roaming, &local)
273 .iter()
274 .any(|place| crate::host::lies_in(path, place))
275 }
276 };
277 assert!(
278 !real,
279 "a test named {} for Claude, which is, holds or lies in where this account's own \
280 Claude Desktop keeps its data or its app: give it a folder of its own",
281 path.display()
282 );
283}
284
285#[cfg(test)]
286pub(crate) mod testing {
287 use std::cell::RefCell;
288 use std::path::{Path, PathBuf};
289
290 thread_local! {
291 static GUARDED: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
292 }
293
294 pub(crate) fn also_guarding(home: &Path) -> AlsoGuarding {
297 GUARDED.with(|guarded| *guarded.borrow_mut() = Some(home.to_path_buf()));
298 AlsoGuarding(())
299 }
300
301 pub(crate) struct AlsoGuarding(());
302
303 impl Drop for AlsoGuarding {
304 fn drop(&mut self) {
305 GUARDED.with(|guarded| guarded.borrow_mut().take());
306 }
307 }
308
309 pub(super) fn guarded_home() -> Option<PathBuf> {
310 GUARDED.with(|guarded| guarded.borrow().clone())
311 }
312}
313
314#[derive(Clone, Debug)]
315pub struct Context {
316 pub(crate) os: Os,
318 pub(crate) home: PathBuf,
319 pub(crate) pitboard_home: PathBuf,
320 pub(crate) claude_config_dir: Option<String>,
326 pub(crate) secure_storage_dir: Option<String>,
329 pub(crate) user: Option<String>,
331 pub(crate) custom_oauth: bool,
334 pub(crate) overriding_auth: Vec<String>,
338 pub(crate) argv_fallback: bool,
343 pub(crate) caller: String,
346 pub(crate) programs: std::collections::BTreeMap<ProviderId, PathBuf>,
348 pub(crate) api_base: Option<String>,
350 pub(crate) proxy: crate::proxy::Proxies,
353 pub(crate) agent: crate::api::Agents,
356 pub(crate) hover_rest: bool,
358 pub(crate) credential_manager_forced: bool,
361 pub(crate) codex_home: Option<String>,
363 pub(crate) profile: Option<PathBuf>,
365 pub(crate) local_app_data: Option<PathBuf>,
367 pub(crate) sid: Option<String>,
369 pub(crate) one_drive: Vec<PathBuf>,
371 pub(crate) schedule_program: Option<PathBuf>,
375 pub(crate) search_path: Option<OsString>,
380 pub(crate) installer_places: std::collections::BTreeMap<ProviderId, Vec<PathBuf>>,
383 pub(crate) path_ext: Option<OsString>,
385 pub(crate) git_bash: Option<PathBuf>,
387 pub(crate) wsl_distro: Option<String>,
389 pub(crate) scheduled_job: Option<String>,
392 pub(crate) marked_as_scheduled: bool,
396 pub(crate) sudo: bool,
400 pub(crate) clock: Arc<dyn Clock>,
403 pub(crate) locks: crate::lock::Locks,
406 pub(crate) host: Arc<dyn Host>,
409 pub(crate) api: Arc<dyn Api>,
411 pub(crate) openai: Arc<dyn OpenAi>,
413 #[cfg(any(test, feature = "test-support"))]
417 pub(crate) sign_in_script: Option<Arc<dyn crate::switch::SignInScript>>,
418 #[cfg(any(test, feature = "test-support"))]
419 scratch_claude: Option<ScratchClaude>,
420 #[cfg(any(test, feature = "test-support"))]
421 awaited_every: Option<std::time::Duration>,
422 #[cfg(test)]
423 unknown_machine: bool,
424 #[cfg(any(test, feature = "test-support"))]
428 pub(crate) warm_script: Option<Arc<dyn crate::duty::warm::run::WarmScript>>,
429}
430
431fn programs(
432 named: impl Fn(ProviderId, crate::provider::Cli) -> PathBuf,
433) -> std::collections::BTreeMap<ProviderId, PathBuf> {
434 ProviderId::ALL
435 .iter()
436 .filter_map(|&tool| Some((tool, named(tool, tool.cli()?))))
437 .collect()
438}
439
440impl Context {
441 pub(crate) fn now(&self) -> i64 {
443 self.clock.now()
444 }
445
446 pub(crate) fn now_millis(&self) -> i64 {
448 self.clock.now_millis()
449 }
450
451 pub(crate) fn zone(&self) -> jiff::tz::TimeZone {
453 self.clock.zone()
454 }
455
456 pub(crate) fn home(&self) -> &std::path::Path {
458 &self.home
459 }
460
461 pub(crate) fn default_pitboard_home(&self) -> PathBuf {
463 self.os.default_pitboard_home(&self.home)
464 }
465
466 pub(crate) fn host(&self) -> &dyn Host {
468 self.host.as_ref()
469 }
470
471 pub(crate) fn openai(&self) -> &dyn OpenAi {
473 self.openai.as_ref()
474 }
475
476 pub(crate) fn api(&self) -> &dyn Api {
477 self.api.as_ref()
478 }
479
480 #[cfg(any(test, feature = "test-support"))]
481 #[doc(hidden)]
482 pub fn new(home: PathBuf) -> Context {
483 Context {
484 os: crate::host::OS,
485 pitboard_home: crate::host::OS.default_pitboard_home(&home),
486 home,
487 claude_config_dir: None,
488 secure_storage_dir: None,
489 user: None,
490 custom_oauth: false,
491 argv_fallback: true,
492 overriding_auth: Vec::new(),
493 caller: "unknown".into(),
494 programs: programs(|tool, _| PathBuf::from(tool.program())),
495 api_base: None,
496 proxy: crate::proxy::Proxies::default(),
497 agent: crate::api::Agents::default(),
498 hover_rest: false,
499 credential_manager_forced: false,
500 codex_home: None,
501 profile: None,
502 local_app_data: None,
503 sid: None,
504 one_drive: Vec::new(),
505 schedule_program: None,
506 search_path: None,
507 installer_places: std::collections::BTreeMap::new(),
508 path_ext: None,
509 git_bash: None,
510 wsl_distro: None,
511 scheduled_job: None,
512 marked_as_scheduled: false,
513 sudo: false,
514 clock: Arc::new(SystemClock),
515 locks: crate::lock::Locks::CLAUDE_CODE,
516 host: crate::host::current(),
517 api: Arc::new(Anthropic),
518 openai: Arc::new(OpenAiNetwork),
519 #[cfg(any(test, feature = "test-support"))]
520 sign_in_script: None,
521 #[cfg(any(test, feature = "test-support"))]
522 scratch_claude: None,
523 #[cfg(any(test, feature = "test-support"))]
524 awaited_every: None,
525 #[cfg(test)]
526 unknown_machine: false,
527 #[cfg(any(test, feature = "test-support"))]
528 warm_script: None,
529 }
530 }
531
532 pub fn with_codex_home(mut self, dir: String) -> Context {
534 self.codex_home = Some(dir).filter(|d| !d.is_empty());
535 self
536 }
537
538 pub(crate) fn codex_home(&self) -> Option<&str> {
539 self.codex_home.as_deref()
540 }
541
542 #[cfg(test)]
543 pub(crate) fn with_profile(mut self, profile: PathBuf) -> Context {
544 self.profile = Some(profile);
545 self
546 }
547
548 pub fn with_pitboard_home(mut self, dir: PathBuf) -> Context {
549 self.pitboard_home = dir;
550 self
551 }
552
553 #[cfg(any(test, feature = "test-support"))]
558 #[doc(hidden)]
559 pub fn with_claude_config_dir(mut self, dir: String) -> Context {
560 self.claude_config_dir = Some(dir);
561 self
562 }
563
564 pub(crate) fn signed_in_at(&self, dir: &std::path::Path) -> Context {
567 let mut ctx = self.clone();
568 ctx.claude_config_dir = Some(dir.to_string_lossy().into_owned());
569 ctx
570 }
571
572 #[cfg(test)]
575 pub(crate) fn with_secure_storage_dir(mut self, dir: String) -> Context {
576 self.secure_storage_dir = Some(dir);
577 self
578 }
579
580 #[cfg(any(test, feature = "test-support"))]
582 #[doc(hidden)]
583 pub fn with_argv_fallback(mut self, allowed: bool) -> Context {
584 self.argv_fallback = allowed;
585 self
586 }
587
588 pub(crate) fn custom_oauth(&self) -> bool {
590 self.custom_oauth
591 }
592
593 pub fn claude_program(&self) -> &std::path::Path {
595 &self.programs[&ProviderId::Claude]
596 }
597
598 pub(crate) fn argv_fallback(&self) -> bool {
600 self.argv_fallback
601 }
602
603 pub(crate) fn overriding_auth(&self) -> &[String] {
605 &self.overriding_auth
606 }
607
608 #[cfg(any(test, feature = "test-support"))]
610 #[doc(hidden)]
611 pub fn with_caller(mut self, caller: String) -> Context {
612 self.caller = caller;
613 self
614 }
615
616 #[cfg(any(test, feature = "test-support"))]
617 #[doc(hidden)]
618 pub fn with_user(mut self, user: String) -> Context {
619 self.user = Some(user);
620 self
621 }
622
623 #[cfg(any(test, feature = "test-support"))]
625 #[doc(hidden)]
626 pub fn with_claude_program(mut self, program: PathBuf) -> Context {
627 self.programs.insert(ProviderId::Claude, program);
628 self
629 }
630
631 #[cfg(any(test, feature = "test-support"))]
633 #[doc(hidden)]
634 pub fn with_codex_program(mut self, program: PathBuf) -> Context {
635 self.programs.insert(ProviderId::Codex, program);
636 self
637 }
638
639 pub fn codex_program(&self) -> &std::path::Path {
641 &self.programs[&ProviderId::Codex]
642 }
643
644 #[cfg(any(test, feature = "test-support"))]
647 #[doc(hidden)]
648 pub fn with_schedule_program(mut self, program: PathBuf) -> Context {
649 self.schedule_program = Some(program);
650 self
651 }
652
653 pub fn schedule_program(&self) -> Option<&std::path::Path> {
655 self.schedule_program.as_deref()
656 }
657
658 #[cfg(any(test, feature = "test-support"))]
663 #[doc(hidden)]
664 pub fn with_search_path(mut self, path: String) -> Context {
665 self.search_path = Some(path.into());
666 self
667 }
668
669 pub(crate) fn lookup(&self) -> crate::host::program::Lookup {
671 crate::host::program::Lookup::new(self.os, self.path_ext.as_deref())
672 }
673
674 pub(crate) fn search_path(&self) -> OsString {
676 self.search_path
677 .clone()
678 .or_else(|| std::env::var_os("PATH"))
679 .unwrap_or_default()
680 }
681
682 pub(crate) fn scheduled_job(&self) -> Option<&str> {
685 self.scheduled_job.as_deref()
686 }
687
688 pub(crate) fn sudo(&self) -> bool {
690 self.sudo
691 }
692
693 #[cfg(test)]
695 pub(crate) fn with_scheduled_job(mut self, label: String) -> Context {
696 self.scheduled_job = Some(label);
697 self
698 }
699
700 pub fn started_by_the_schedule(mut self) -> Context {
706 self.marked_as_scheduled = true;
707 self
708 }
709
710 pub(crate) fn marked_as_scheduled(&self) -> bool {
712 self.marked_as_scheduled
713 }
714
715 pub(crate) fn with_proxy_variables_over(mut self, given: &[(String, String)]) -> Context {
717 let os = self.os;
718 let key = |name: &str| os.variable_key(OsStr::new(name));
719 let own = self
720 .proxy
721 .variables()
722 .iter()
723 .filter(|(name, _)| !given.iter().any(|(over, _)| key(over) == key(name)))
724 .map(|(name, value)| ((*name).to_string(), value.clone()));
725 let env = Environment::on(os, given.iter().cloned().chain(own));
726 self.proxy = crate::proxy::Proxies::read(&env);
727 self.agent = crate::api::Agents::default();
728 self
729 }
730
731 pub fn program_for(&self, tool: ProviderId) -> Option<&std::path::Path> {
733 self.programs.get(&tool).map(PathBuf::as_path)
734 }
735
736 pub(crate) fn scratch_claude(&self) -> Option<&ScratchClaude> {
738 #[cfg(any(test, feature = "test-support"))]
739 {
740 self.scratch_claude.as_ref()
741 }
742 #[cfg(not(any(test, feature = "test-support")))]
743 {
744 None
745 }
746 }
747
748 #[cfg(any(test, feature = "test-support"))]
750 #[doc(hidden)]
751 pub fn with_scratch_claude(mut self, folder: PathBuf) -> Context {
752 assert!(
753 folder.is_absolute(),
754 "a test named its Claude folder {} relative to wherever it runs: name it in full",
755 folder.display()
756 );
757 let scratch = ScratchClaude {
758 data: folder.join("data"),
759 app: folder.join("Claude.app"),
760 };
761 for named in [&folder, &scratch.data, &scratch.app] {
762 refuse_a_real_claude(named);
763 }
764 self.scratch_claude = Some(scratch);
765 self
766 }
767
768 pub(crate) fn awaited_every(&self) -> std::time::Duration {
770 #[cfg(any(test, feature = "test-support"))]
771 if let Some(every) = self.awaited_every {
772 return every;
773 }
774 std::time::Duration::from_secs(1)
775 }
776
777 #[cfg(any(test, feature = "test-support"))]
778 #[doc(hidden)]
779 pub fn with_awaited_every(mut self, every: std::time::Duration) -> Context {
780 self.awaited_every = Some(every);
781 self
782 }
783
784 pub(crate) fn this_machine(&self) -> String {
786 #[cfg(test)]
787 if self.unknown_machine {
788 return crate::state::UNKNOWN_MACHINE.into();
789 }
790 crate::state::machine_id()
791 }
792
793 #[cfg(test)]
794 pub(crate) fn with_unknown_machine(mut self) -> Context {
795 self.unknown_machine = true;
796 self
797 }
798
799 pub fn from_env() -> Context {
801 Context::for_command_line(&Environment::of_this_process())
802 }
803
804 pub fn for_command_line(env: &Environment) -> Context {
807 Context::read(env, "cli")
808 }
809
810 #[cfg(test)]
821 pub(crate) fn for_unit_test() -> Context {
822 let sentinel = unit_test_sentinel().into_os_string();
823 let homes = [
824 "HOME",
825 "USERPROFILE",
826 "PITBOARD_HOME",
827 "CLAUDE_CONFIG_DIR",
828 "CODEX_HOME",
829 ];
830 let kept: Environment = std::env::vars_os()
831 .filter(|(name, _)| !variables().any(|read| name == read))
832 .chain(homes.map(|home| (home.into(), sentinel.clone())))
833 .collect();
834 Context::for_command_line(&kept)
835 }
836
837 pub(crate) fn read(env: &Environment, caller: &str) -> Context {
845 let home = env.home();
846 let owned = |name: &str| env.text(name).map(str::to_owned);
847 let program = |tool: ProviderId, cli: crate::provider::Cli| {
848 env.path(cli.program_variable)
849 .filter(|named| !named.is_empty())
850 .map_or_else(|| PathBuf::from(tool.program()), PathBuf::from)
851 };
852 let read = Context {
853 os: env.os(),
854 pitboard_home: env.pitboard_home(),
855 home,
856 claude_config_dir: owned("CLAUDE_CONFIG_DIR"),
857 secure_storage_dir: owned("CLAUDE_SECURESTORAGE_CONFIG_DIR"),
858 user: owned(env.os().user_variable()),
859 custom_oauth: env.set("CLAUDE_CODE_CUSTOM_OAUTH_URL"),
860 argv_fallback: env.text("PITBOARD_NO_ARGV") != Some("1"),
861 overriding_auth: crate::settings::OVERRIDING_ENV
862 .iter()
863 .filter(|name| env.set(name))
864 .map(|name| (*name).to_string())
865 .collect(),
866 caller: caller.into(),
867 programs: programs(program),
868 api_base: owned("PITBOARD_API_BASE"),
869 proxy: crate::proxy::Proxies::read(env),
870 agent: crate::api::Agents::default(),
871 hover_rest: matches!(env.text("CLAUDE_CODE_HOVER_REST"), Some("1" | "true")),
872 credential_manager_forced: env.text("CLAUDE_CODE_FORCE_WINDOWS_CREDMAN") == Some("1"),
873 codex_home: owned("CODEX_HOME").filter(|v| !v.is_empty()),
874 profile: (env.os() == Os::Windows)
875 .then(crate::host::user::profile)
876 .flatten(),
877 local_app_data: env
878 .path("LOCALAPPDATA")
879 .filter(|_| env.os() == Os::Windows)
880 .map(PathBuf::from),
881 sid: (env.os() == Os::Windows)
882 .then(crate::host::user::sid)
883 .flatten(),
884 one_drive: ["OneDrive", "OneDriveCommercial", "OneDriveConsumer"]
885 .into_iter()
886 .filter(|_| env.os() == Os::Windows)
887 .filter_map(|name| env.path(name).filter(|folder| !folder.is_empty()))
888 .map(PathBuf::from)
889 .collect(),
890 schedule_program: None,
891 search_path: Some(env.path("PATH").unwrap_or_default().to_os_string()),
893 installer_places: std::collections::BTreeMap::new(),
894 path_ext: env.path("PATHEXT").map(OsStr::to_os_string),
895 git_bash: env
896 .path("CLAUDE_CODE_GIT_BASH_PATH")
897 .filter(|named| env.os() == Os::Windows && !named.is_empty())
898 .map(PathBuf::from),
899 wsl_distro: env
900 .path("WSL_DISTRO_NAME")
901 .filter(|_| env.os() == Os::Linux)
902 .map(|named| named.to_string_lossy().into_owned()),
903 scheduled_job: None,
904 marked_as_scheduled: false,
905 sudo: env.set("SUDO_UID"),
906 clock: Arc::new(SystemClock),
907 locks: crate::lock::Locks::CLAUDE_CODE,
908 host: crate::host::current(),
909 api: Arc::new(Anthropic),
910 openai: Arc::new(OpenAiNetwork),
911 #[cfg(any(test, feature = "test-support"))]
912 sign_in_script: None,
913 #[cfg(any(test, feature = "test-support"))]
914 scratch_claude: None,
915 #[cfg(any(test, feature = "test-support"))]
916 awaited_every: None,
917 #[cfg(test)]
918 unknown_machine: false,
919 #[cfg(any(test, feature = "test-support"))]
920 warm_script: None,
921 };
922 #[cfg(any(test, feature = "test-support"))]
923 if let Some(folder) = env.path(TEST_CLAUDE_DESKTOP).filter(|f| !f.is_empty()) {
924 return read.with_scratch_claude(PathBuf::from(folder));
925 }
926 read
927 }
928
929 #[cfg(any(test, feature = "test-support"))]
933 #[doc(hidden)]
934 pub fn with_scripted_api(mut self, api: Arc<crate::api::scripted::ScriptedApi>) -> Context {
935 self.api = api.clone();
936 self.openai = api;
937 self
938 }
939
940 #[cfg(any(test, feature = "test-support"))]
943 #[doc(hidden)]
944 pub fn with_memory_stores(mut self, memory: Arc<crate::host::memory::MemoryHost>) -> Context {
945 self.host = memory;
946 self
947 }
948
949 #[cfg(any(test, feature = "test-support"))]
952 #[doc(hidden)]
953 pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Context {
954 self.clock = clock;
955 self
956 }
957
958 #[cfg(any(test, feature = "test-support"))]
962 #[doc(hidden)]
963 pub fn with_lock_waiting(mut self, retries: u32, every: std::time::Duration) -> Context {
964 self.locks = crate::lock::Locks {
965 write: self.locks.write.asked(retries, every),
966 refresh: self.locks.refresh.asked(retries, every),
967 };
968 self
969 }
970
971 #[cfg(any(test, feature = "test-support"))]
976 #[doc(hidden)]
977 pub fn with_sign_in_script(
978 mut self,
979 script: Arc<dyn crate::switch::enroll::SignInScript>,
980 ) -> Context {
981 self.sign_in_script = Some(script);
982 self
983 }
984
985 #[cfg(any(test, feature = "test-support"))]
987 pub(crate) fn sign_in_script(&self) -> Option<&Arc<dyn crate::switch::enroll::SignInScript>> {
988 self.sign_in_script.as_ref()
989 }
990
991 #[cfg(any(test, feature = "test-support"))]
994 #[doc(hidden)]
995 pub fn with_warm_script(
996 mut self,
997 script: Arc<dyn crate::duty::warm::run::WarmScript>,
998 ) -> Context {
999 self.warm_script = Some(script);
1000 self
1001 }
1002
1003 #[cfg(any(test, feature = "test-support"))]
1005 pub(crate) fn warm_script(&self) -> Option<&Arc<dyn crate::duty::warm::run::WarmScript>> {
1006 self.warm_script.as_ref()
1007 }
1008
1009 #[cfg(any(test, feature = "test-support"))]
1015 #[doc(hidden)]
1016 pub fn reaches_only(
1017 &self,
1018 memory: &Arc<crate::host::memory::MemoryHost>,
1019 api: &Arc<crate::api::scripted::ScriptedApi>,
1020 ) -> bool {
1021 std::ptr::addr_eq(Arc::as_ptr(&self.host), Arc::as_ptr(memory))
1022 && std::ptr::addr_eq(Arc::as_ptr(&self.api), Arc::as_ptr(api))
1023 && std::ptr::addr_eq(Arc::as_ptr(&self.openai), Arc::as_ptr(api))
1024 }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029 use super::*;
1030 use std::path::Path;
1031
1032 #[test]
1033 fn the_test_variable_is_listed_only_in_a_build_for_tests() {
1034 assert!(variables().any(|name| name == "PITBOARD_TEST_CLAUDE_DESKTOP"));
1035 assert!(!READ.contains(&"PITBOARD_TEST_CLAUDE_DESKTOP"));
1036 assert!(Context::for_unit_test().scratch_claude().is_none());
1037 let folder = std::env::temp_dir().join("pitboard-scratch-claude");
1038 let named = folder.to_string_lossy().into_owned();
1039 let env: Environment = [
1040 ("HOME", "/home/x"),
1041 ("PITBOARD_TEST_CLAUDE_DESKTOP", named.as_str()),
1042 ]
1043 .into_iter()
1044 .collect();
1045 let read = Context::for_command_line(&env);
1046 let scratch = read.scratch_claude().expect("named");
1047 assert_eq!(scratch.data, folder.join("data"));
1048 assert_eq!(scratch.app, folder.join("Claude.app"));
1049 }
1050
1051 #[test]
1053 #[cfg_attr(
1054 windows,
1055 ignore = "Claude Desktop is macOS only: Windows names no Application Support"
1056 )]
1057 fn a_scratch_inside_the_real_application_support_panics() {
1058 let home = {
1059 let _reaching = crate::host::user::testing::reaching_the_real_home();
1060 crate::host::user::home().expect("this account has a home")
1061 };
1062 let support = home.join("Library/Application Support");
1063 let named = |folder: PathBuf| {
1064 std::panic::catch_unwind(move || {
1065 Context::new("/nowhere".into()).with_scratch_claude(folder)
1066 })
1067 .is_err()
1068 };
1069 assert!(named(support.join("pitboard-test-never-made")));
1070 assert!(named(support.clone()));
1071 assert!(named(home.join("Library")), "it holds it");
1072 let link = std::env::temp_dir().join(format!("pitboard-home-link-{}", std::process::id()));
1074 let _ = std::fs::remove_file(&link);
1075 crate::host::fs::testing::link_dir(&home, &link).expect("a link");
1076 let through = named(link.join("Library/Application Support/pitboard-test-never-made"));
1077 let _ = std::fs::remove_file(&link);
1078 assert!(through, "through a link");
1079 assert!(!named(
1080 std::env::temp_dir().join("pitboard-scratch-elsewhere")
1081 ));
1082 let _guarding = testing::also_guarding(&std::env::temp_dir().join("pitboard-fake-home"));
1083 assert!(
1084 named(support.join("pitboard-test-never-made")),
1085 "while a test guards a fake home too"
1086 );
1087 }
1088
1089 #[test]
1090 #[cfg(windows)]
1091 fn a_scratch_in_claude_desktops_windows_folders_panics() {
1092 let named = |folder: PathBuf| {
1093 std::panic::catch_unwind(move || {
1094 Context::new(PathBuf::from(r"C:\nowhere")).with_scratch_claude(folder)
1095 })
1096 .is_err()
1097 };
1098 let (roaming, local) = crate::host::user::app_data_folders(true);
1099 assert!(!roaming.is_empty() && !local.is_empty());
1100 for dir in &roaming {
1101 assert!(named(dir.join("Claude").join("pitboard-test-never-made")));
1102 assert!(named(dir.join("claude-3p")), "in any case");
1103 }
1104 for dir in &local {
1105 assert!(named(dir.join("AnthropicClaude")));
1106 }
1107 assert!(!named(
1108 std::env::temp_dir().join("pitboard-scratch-elsewhere")
1109 ));
1110 let fake = std::env::temp_dir().join("pitboard-fake-profile");
1111 let _guarding = testing::also_guarding(&fake);
1112 let roaming = fake.join("AppData").join("Roaming");
1113 assert!(named(roaming.join("Claude").join("x")), "a fake profile's");
1114 }
1115
1116 #[test]
1118 fn a_scratch_named_through_a_folder_not_made_yet_panics() {
1119 let home =
1120 std::env::temp_dir().join(format!("pitboard-scratch-climb-{}", std::process::id()));
1121 let _guarding = testing::also_guarding(&home);
1122 let climbed = std::panic::catch_unwind(|| {
1123 Context::new("/nowhere".into())
1124 .with_scratch_claude(home.join("never-made/../Library/Application Support/x"))
1125 });
1126 assert!(climbed.is_err());
1127 }
1128
1129 #[test]
1131 fn a_relative_name_lies_where_the_working_directory_puts_it() {
1132 let here = std::env::current_dir().expect("a working directory");
1133 let name = Path::new("pitboard-never-made");
1134 assert!(crate::host::lies_in(name, &here));
1135 assert!(!crate::host::lies_in(name, &here.join("elsewhere")));
1136 }
1137
1138 #[test]
1140 fn a_relative_scratch_panics() {
1141 let named = std::panic::catch_unwind(|| {
1142 Context::new("/nowhere".into()).with_scratch_claude("pitboard-scratch".into())
1143 });
1144 assert!(named.is_err());
1145 }
1146
1147 #[test]
1148 #[cfg_attr(
1149 windows,
1150 ignore = "Claude Desktop is macOS only: Windows makes no link"
1151 )]
1152 fn a_scratch_whose_data_or_app_links_to_a_real_place_panics() {
1153 let root =
1154 std::env::temp_dir().join(format!("pitboard-scratch-links-{}", std::process::id()));
1155 let _ = std::fs::remove_dir_all(&root);
1156 let home = root.join("home");
1157 let support = home.join("Library/Application Support/Claude");
1158 let app = home.join("Applications/Claude.app");
1159 let other = root.join("other");
1160 for made in [&support, &app, &other] {
1161 std::fs::create_dir_all(made).expect("a folder to link to");
1162 }
1163 let _guarding = testing::also_guarding(&home);
1164 let linked = |scratch: &str, name: &str, to: &Path| {
1165 let scratch = root.join(scratch);
1166 std::fs::create_dir_all(&scratch).expect("a scratch");
1167 crate::host::fs::testing::link_dir(to, &scratch.join(name)).expect("a link");
1168 std::panic::catch_unwind(|| {
1169 Context::new("/nowhere".into()).with_scratch_claude(scratch)
1170 })
1171 .is_err()
1172 };
1173 let data = linked("one", "data", &support);
1174 let claude = linked("two", "Claude.app", &app);
1175 let elsewhere = linked("three", "data", &other);
1176 let not_made = linked("four", "data", &support.join("never-made"));
1177 let not_made_relative = linked(
1178 "five",
1179 "data",
1180 Path::new("../home/Library/Application Support/Claude/never-made"),
1181 );
1182 std::fs::remove_dir_all(&root).expect("the scratch removed");
1183 assert!(data, "data linked into Application Support");
1184 assert!(claude, "Claude.app linked to ~/Applications/Claude.app");
1185 assert!(!elsewhere, "data linked to a folder of the test's own");
1186 assert!(not_made, "data linked to a folder not made yet there");
1187 assert!(not_made_relative, "the same, by a relative link");
1188 }
1189
1190 #[test]
1191 #[cfg_attr(
1192 windows,
1193 ignore = "Claude Desktop is macOS only: Windows has no `/Applications`"
1194 )]
1195 fn a_scratch_of_an_applications_folder_panics() {
1196 let home =
1197 std::env::temp_dir().join(format!("pitboard-scratch-apps-{}", std::process::id()));
1198 let _guarding = testing::also_guarding(&home);
1199 let named = |folder: PathBuf| {
1200 std::panic::catch_unwind(move || {
1201 Context::new("/nowhere".into()).with_scratch_claude(folder)
1202 })
1203 .is_err()
1204 };
1205 assert!(named("/Applications".into()));
1206 assert!(named(home.join("Applications")));
1207 assert!(!named(home.join("Applications/elsewhere")));
1208 }
1209
1210 #[test]
1212 #[cfg_attr(
1213 not(target_os = "macos"),
1214 ignore = "a firmlink to the Data volume is macOS's own"
1215 )]
1216 fn a_scratch_named_through_the_data_volume_panics() {
1217 let root =
1218 std::env::temp_dir().join(format!("pitboard-scratch-data-{}", std::process::id()));
1219 let _ = std::fs::remove_dir_all(&root);
1220 let support = root.join("home/Library/Application Support/Claude");
1221 let scratch = root.join("scratch");
1222 for made in [&support, &scratch] {
1223 std::fs::create_dir_all(made).expect("a folder of the test's own");
1224 }
1225 let through = |place: &Path| {
1226 let real = std::fs::canonicalize(place).expect("a folder of the test's own");
1227 Path::new("/System/Volumes/Data").join(real.strip_prefix("/").expect("from the root"))
1228 };
1229 crate::host::fs::testing::link_dir(&through(&support), &scratch.join("data"))
1230 .expect("a link");
1231 let _guarding = testing::also_guarding(&root.join("home"));
1232 let named = |folder: PathBuf| {
1233 std::panic::catch_unwind(move || {
1234 Context::new("/nowhere".into()).with_scratch_claude(folder)
1235 })
1236 .is_err()
1237 };
1238 let linked = named(scratch);
1239 let inside = named(through(&support).join("pitboard-test"));
1240 let own = named(through(&root).join("own"));
1241 let applications = named("/System/Volumes/Data/Applications".into());
1242 std::fs::remove_dir_all(&root).expect("the scratch removed");
1243 assert!(
1244 linked,
1245 "data linked into Application Support through the Data volume"
1246 );
1247 assert!(
1248 inside,
1249 "a scratch in Application Support named through the Data volume"
1250 );
1251 assert!(
1252 !own,
1253 "a folder of the test's own named through the Data volume"
1254 );
1255 assert!(applications, "/Applications named through the Data volume");
1256 }
1257
1258 #[test]
1263 fn an_explicit_context_reads_claude_codes_settings_the_way_the_environment_does() {
1264 let (home, named) = full_home();
1265 let ctx = Context::new(home.clone())
1266 .with_claude_config_dir(String::new())
1267 .with_secure_storage_dir(String::new());
1268 assert_eq!(
1269 ctx.pitboard_home,
1270 crate::host::OS.default_pitboard_home(&home)
1271 );
1272 assert_eq!(
1273 ctx.claude_config_dir.as_deref(),
1274 Some(""),
1275 "empty is the empty path for the config dir"
1276 );
1277 assert_eq!(
1278 ctx.secure_storage_dir.as_deref(),
1279 Some(""),
1280 "empty is set, and pins the default slot"
1281 );
1282 assert_eq!(ctx.claude_program(), Path::new("claude"));
1283 let env: Environment = [
1284 (crate::host::OS.home_variable(), named.as_str()),
1285 ("CLAUDE_CONFIG_DIR", ""),
1286 ]
1287 .into_iter()
1288 .collect();
1289 let read = Context::for_command_line(&env);
1290 assert_eq!(read.claude_config_dir, ctx.claude_config_dir);
1291 for ctx in [ctx, read] {
1292 assert!(
1293 matches!(
1294 crate::home::check_absolute(&ctx),
1295 Err(crate::error::Error::HomeNotAbsolute {
1296 variable: "CLAUDE_CONFIG_DIR",
1297 ..
1298 })
1299 ),
1300 "refused"
1301 );
1302 }
1303 }
1304
1305 #[test]
1309 fn a_context_says_whether_it_reaches_only_the_machine_and_services_it_was_given() {
1310 use crate::api::scripted::ScriptedApi;
1311 use crate::host::memory::MemoryHost;
1312 let (memory, api) = (MemoryHost::new(), ScriptedApi::new());
1313 let home = PathBuf::from("/home/x");
1314 let both = Context::new(home.clone())
1315 .with_memory_stores(Arc::clone(&memory))
1316 .with_scripted_api(Arc::clone(&api));
1317 assert!(both.reaches_only(&memory, &api));
1318 assert!(
1319 !Context::new(home.clone())
1320 .with_scripted_api(Arc::clone(&api))
1321 .reaches_only(&memory, &api),
1322 "this machine"
1323 );
1324 assert!(
1325 !Context::new(home)
1326 .with_memory_stores(Arc::clone(&memory))
1327 .reaches_only(&memory, &api),
1328 "the network"
1329 );
1330 assert!(
1331 !both.reaches_only(&MemoryHost::new(), &api),
1332 "another machine"
1333 );
1334 assert!(
1335 !both.reaches_only(&memory, &ScriptedApi::new()),
1336 "other services"
1337 );
1338 }
1339
1340 #[test]
1341 fn only_a_front_end_that_names_one_changes_what_the_schedule_runs() {
1342 assert_eq!(Context::for_unit_test().schedule_program(), None);
1343 let ctx = Context::new(PathBuf::from("/Users/x"));
1344 assert_eq!(ctx.schedule_program(), None);
1345 let bundled = PathBuf::from("/Applications/Pitboard.app/Contents/Helpers/pitboard");
1346 assert_eq!(
1347 ctx.with_schedule_program(bundled.clone())
1348 .schedule_program(),
1349 Some(bundled.as_path())
1350 );
1351 }
1352
1353 #[test]
1356 fn the_command_line_reads_the_environment_it_is_given() {
1357 let (home, named) = full_home();
1358 let env: Environment = [
1359 (crate::host::OS.home_variable(), named.as_str()),
1360 ("PITBOARD_CLAUDE", "/elsewhere/claude"),
1361 ("PITBOARD_CODEX", ""),
1362 ("CLAUDE_CODE_CUSTOM_OAUTH_URL", "https://oauth.example"),
1363 ("ANTHROPIC_API_KEY", "not-a-key"),
1364 ("ANTHROPIC_AUTH_TOKEN", ""),
1365 ("PATH", "/opt/tools/bin:/usr/bin"),
1366 ]
1367 .into_iter()
1368 .collect();
1369 let ctx = Context::for_command_line(&env);
1370 assert_eq!(ctx.claude_program(), Path::new("/elsewhere/claude"));
1371 assert_eq!(
1372 ctx.codex_program(),
1373 Path::new("codex"),
1374 "empty names nothing"
1375 );
1376 assert!(ctx.custom_oauth());
1377 assert_eq!(
1378 ctx.overriding_auth(),
1379 ["ANTHROPIC_API_KEY"],
1380 "empty is unset"
1381 );
1382 assert_eq!(ctx.search_path(), "/opt/tools/bin:/usr/bin");
1383 assert_eq!(
1384 ctx.pitboard_home,
1385 crate::host::OS.default_pitboard_home(&home)
1386 );
1387 assert_eq!(ctx.caller, "cli");
1388 let no_path: Environment = [(crate::host::OS.home_variable(), named.as_str())]
1389 .into_iter()
1390 .collect();
1391 assert_eq!(
1392 Context::for_command_line(&no_path).search_path(),
1393 "",
1394 "no PATH is nowhere, not this process's"
1395 );
1396 }
1397
1398 #[test]
1401 fn a_windows_environment_has_one_value_per_name_in_any_case_and_the_first_wins() {
1402 let search_path = |os: Os, pairs: &[(&str, &str)]| {
1403 let home = [("HOME", "/home/x"), ("USERPROFILE", "/home/x")];
1404 let env = Environment::on(os, home.iter().chain(pairs).copied());
1405 Context::read(&env, "cli").search_path()
1406 };
1407 let windows = |pairs: &[(&str, &str)]| search_path(Os::Windows, pairs);
1408 assert_eq!(
1409 windows(&[("Path", "C:\\a")]),
1410 "C:\\a",
1411 "as a program gets it"
1412 );
1413 assert_eq!(windows(&[("PATH", "C:\\b")]), "C:\\b");
1414 assert_eq!(windows(&[("Path", "C:\\a"), ("PATH", "C:\\b")]), "C:\\a");
1415 assert_eq!(windows(&[("PATH", "C:\\b"), ("Path", "C:\\a")]), "C:\\b");
1416 for os in [Os::MacOs, Os::Linux] {
1417 assert_eq!(search_path(os, &[("Path", "/a")]), "", "{os:?}");
1418 assert_eq!(search_path(os, &[("Path", "/a"), ("PATH", "/b")]), "/b");
1419 }
1420 let both = Environment::on(Os::Windows, [("Path", "C:\\a"), ("PATH", "C:\\b")]);
1421 let given: Vec<_> = both.iter().collect();
1422 assert_eq!(
1423 given,
1424 [(OsStr::new("Path"), OsStr::new("C:\\a"))],
1425 "a child gets one"
1426 );
1427 assert_eq!(both.text("PATH"), Some("C:\\a"));
1428 }
1429
1430 #[test]
1432 fn the_person_is_named_by_the_systems_own_variable() {
1433 let user = |os: Os| {
1434 let pairs = [
1435 ("HOME", "/home/x"),
1436 ("USERPROFILE", "/home/x"),
1437 ("USER", "posix"),
1438 ("username", "windows"),
1439 ];
1440 Context::read(&Environment::on(os, pairs), "cli").user
1441 };
1442 assert_eq!(user(Os::Windows).as_deref(), Some("windows"));
1443 assert_eq!(user(Os::MacOs).as_deref(), Some("posix"));
1444 assert_eq!(user(Os::Linux).as_deref(), Some("posix"));
1445 }
1446
1447 #[test]
1449 fn each_tool_finds_its_home_in_userprofile_on_windows_whatever_home_says() {
1450 let profile = std::env::temp_dir().join("pitboard-profile-tools");
1451 let named = profile.to_string_lossy().into_owned();
1452 let pairs = [("HOME", "/c/Users/x"), ("USERPROFILE", named.as_str())];
1453 let ctx = Context::read(&Environment::on(Os::Windows, pairs), "cli");
1454 assert_eq!(ctx.home, profile);
1455 assert_eq!(
1456 crate::provider::claude::paths::config_dir(&ctx),
1457 profile.join(".claude")
1458 );
1459 assert_eq!(
1460 crate::provider::claude::paths::config_file(&ctx),
1461 profile.join(".claude.json")
1462 );
1463 assert_eq!(
1464 crate::provider::codex::paths::home(&ctx),
1465 profile.join(".codex")
1466 );
1467 assert_eq!(
1468 ctx.pitboard_home,
1469 profile.join("AppData").join("Local").join("Pitboard")
1470 );
1471 let posix = Context::read(&Environment::on(Os::Linux, pairs), "cli");
1472 assert_eq!(posix.home, PathBuf::from("/c/Users/x"));
1473 }
1474
1475 fn full_home() -> (PathBuf, String) {
1477 let home = std::env::temp_dir().join("pitboard-never-made-home");
1478 let named = home.to_string_lossy().into_owned();
1479 (home, named)
1480 }
1481
1482 #[test]
1485 #[cfg(debug_assertions)]
1486 #[should_panic(expected = "PITBOARD_SOMETHING_NEW is read but not listed")]
1487 fn a_variable_read_without_being_listed_is_caught() {
1488 let _ = Environment::default().path("PITBOARD_SOMETHING_NEW");
1489 }
1490
1491 #[test]
1495 fn without_home_the_home_is_the_accounts_own() {
1496 let _real = crate::host::user::testing::reaching_the_real_home();
1497 let own = crate::host::user::home().expect("this account has a home");
1498 let ctx = Context::for_command_line(&Environment::default());
1499 assert_eq!(ctx.home, own);
1500 assert_eq!(
1501 ctx.pitboard_home,
1502 crate::host::OS.default_pitboard_home(&own)
1503 );
1504 let empty: Environment = [(crate::host::OS.home_variable(), "")]
1505 .into_iter()
1506 .collect();
1507 assert_eq!(
1508 Context::for_command_line(&empty).home,
1509 PathBuf::new(),
1510 "set, even empty, it is what the person said"
1511 );
1512 }
1513
1514 #[test]
1519 fn a_unit_tests_homes_are_a_folder_of_its_own_that_nothing_makes() {
1520 let ctx = Context::for_unit_test();
1521 let sentinel = ctx.home.clone();
1522 assert!(
1523 sentinel.starts_with(std::env::temp_dir()),
1524 "{}",
1525 sentinel.display()
1526 );
1527 assert!(!sentinel.exists(), "{}", sentinel.display());
1528 assert_eq!(ctx.pitboard_home, sentinel);
1529 assert_eq!(
1530 crate::provider::claude::paths::config_dir(&ctx),
1531 sentinel,
1532 "Claude Code's"
1533 );
1534 assert_eq!(
1535 crate::provider::codex::paths::home(&ctx),
1536 sentinel,
1537 "Codex's"
1538 );
1539 assert_eq!(Context::for_unit_test().home, sentinel, "the same test's");
1540 let another = std::thread::Builder::new()
1541 .name("another::test".into())
1542 .spawn(|| Context::for_unit_test().home)
1543 .expect("a thread")
1544 .join()
1545 .expect("its context");
1546 assert_ne!(another, sentinel, "another test's");
1547 assert!(another.ends_with("another--test"), "{}", another.display());
1548 }
1549
1550 #[test]
1554 fn the_homes_an_app_asks_for_are_the_contexts() {
1555 let _real = crate::host::user::testing::reaching_the_real_home();
1556 let cases: [&[(&str, &str)]; 5] = [
1557 &[],
1558 &[("HOME", "/Users/x")],
1559 &[("HOME", "/Users/x/")],
1560 &[
1561 ("HOME", "/Users/x"),
1562 ("PITBOARD_HOME", "/elsewhere/./pitboard/"),
1563 ],
1564 &[("PITBOARD_HOME", "")],
1565 ];
1566 for pairs in cases {
1567 let env: Environment = pairs.iter().copied().collect();
1568 let ctx = Context::for_command_line(&env);
1569 assert_eq!(env.home(), ctx.home, "{pairs:?}");
1570 assert_eq!(env.pitboard_home(), ctx.pitboard_home, "{pairs:?}");
1571 }
1572 let given: Environment = [("PITBOARD_HOME", "/elsewhere/./pitboard/")]
1573 .into_iter()
1574 .collect();
1575 assert_eq!(
1576 given.pitboard_home().as_os_str(),
1577 "/elsewhere/./pitboard/",
1578 "as the environment gives it"
1579 );
1580 }
1581}