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