Skip to main content

pitboard_core/
app.rs

1//! What an app has to find for itself that a command typed at a prompt is given.
2//!
3//! The command line is started from a shell, so its environment has the person's `PATH`, and
4//! each tool's program is looked for there when it runs. An app the system started has
5//! launchd's environment instead, whose `PATH` holds the system's directories and nothing a
6//! version manager or an npm prefix added. So an app asks the person's login shell for its
7//! `PATH`, which is the host's to ask, looks for each tool's program there and then where
8//! the tool's own installers put it, and hands the shell's `PATH` to every sign-in. The rest
9//! of its environment is read as the command line reads its own, by the same code.
10//!
11//! An app also says which `pitboard` a terminal would run, and whether it is the app's own,
12//! and keeps files of its own in Pitboard's directory, beside the core's, written as the core
13//! writes its own.
14
15use crate::context::{Context, Environment};
16use crate::error::{Error, Result};
17use crate::host::{self, LoginPath, OS};
18use crate::provider::ProviderId;
19use crate::service::Permit;
20use crate::{atomic, home};
21use std::ffi::OsStr;
22use std::path::{Path, PathBuf};
23
24/// An app's context, and what building it found.
25#[derive(Debug)]
26#[non_exhaustive]
27pub struct AppContext {
28    pub context: Context,
29    /// The tools whose program was named outright or found, in [`ProviderId::ALL`]'s order.
30    /// A tool missing here may still be on some `PATH`, so this narrows what an app offers
31    /// and never forbids anything.
32    pub found: Vec<ProviderId>,
33    /// The login shell's `PATH`, as far as it is looked in: where each tool's program is
34    /// looked for, and what a sign-in is given. `None` where the shell could not be asked,
35    /// and the app looks on its own `PATH`, as it always did.
36    pub search_path: Option<String>,
37    /// The login shell had not answered in time. What was found stands, and asking again a
38    /// while later may find more.
39    pub late: bool,
40    /// Where each way of installing Pitboard puts `pitboard`, as `command_line_places` gives
41    /// them for this app's home: under it, and where the system's package managers put
42    /// programs. Looked in after the login shell's `PATH` for the one a terminal would run.
43    pub command_line_places: Vec<PathBuf>,
44}
45
46impl AppContext {
47    /// The context of an app started with `env`, which is at `app`.
48    ///
49    /// Asks the person's login shell for its `PATH`, which can take seconds, so this is never
50    /// called on an app's main thread.
51    pub fn discover(env: &Environment, app: Option<&Path>) -> AppContext {
52        AppContext::read(env, app, host::login_path(env), host::proc::can_run)
53    }
54
55    /// `discover` with what the login shell said and whether a file can be run handed in, so
56    /// a test can say both without a shell or a program.
57    ///
58    /// Each tool's program is the one its variable names outright, or else the first found
59    /// on the login shell's `PATH`, where a version manager or an npm prefix puts it, and
60    /// then where the tool's own installers put it, which is all there is when the shell
61    /// could not be asked.
62    pub(crate) fn read(
63        env: &Environment,
64        app: Option<&Path>,
65        said: LoginPath,
66        runnable: impl Fn(&Path) -> bool,
67    ) -> AppContext {
68        let mut context = Context::read(env, "app");
69        let (shell, late) = match said {
70            LoginPath::Said(path) => (Some(path), false),
71            LoginPath::Unknown => (None, false),
72            LoginPath::Late => (None, true),
73        };
74        // A relative entry would be looked in wherever this app happens to be running, and
75        // looking inside a folder the system guards asks the person whether Pitboard may, for
76        // something it never needed to read.
77        let looked: Vec<PathBuf> = shell
78            .as_deref()
79            .map(|path| {
80                std::env::split_paths(path)
81                    .filter(|dir| dir.is_absolute() && !guarded(dir, &context.home))
82                    .collect()
83            })
84            .unwrap_or_default();
85        let mut found = Vec::new();
86        for &tool in ProviderId::ALL {
87            if env
88                .path(tool.program_variable())
89                .is_some_and(|named| !named.is_empty())
90            {
91                found.push(tool);
92                continue;
93            }
94            let places = looked
95                .iter()
96                .cloned()
97                .chain(tool.install_places(&context.home));
98            if let Some(program) =
99                host::program::find_among(Path::new(tool.program()), places, &runnable)
100            {
101                context.set_program(tool, program);
102                found.push(tool);
103            }
104        }
105        let search_path = shell.map(|_| {
106            std::env::join_paths(&looked)
107                .map(|joined| joined.to_string_lossy().into_owned())
108                .unwrap_or_default()
109        });
110        if let Some(path) = &search_path {
111            context.search_path = Some(path.into());
112        }
113        context.schedule_program = app.and_then(|app| OS.app_command_line(app));
114        let command_line_places = command_line_places(&context.home);
115        AppContext {
116            context,
117            found,
118            search_path,
119            late,
120            command_line_places,
121        }
122    }
123}
124
125/// Whether `dir` is inside a folder of `home` that the system asks the person about before
126/// an app may read it.
127fn guarded(dir: &Path, home: &Path) -> bool {
128    OS.guarded_folders()
129        .iter()
130        .any(|folder| dir.starts_with(home.join(folder)))
131}
132
133/// The command line an app at `app` comes with, which its renewal schedule runs. `None` for
134/// anything that is not an app, such as a test or a build directory.
135pub fn app_command_line(app: &Path) -> Option<PathBuf> {
136    OS.app_command_line(app)
137}
138
139/// Whether `path` is a program this user may run, as the core judges every program it finds
140/// and the system judges one it starts: a regular file, once every link is followed, that
141/// this user may execute. An app asks this of anything it would run or link to, so it has
142/// no rule of its own.
143pub fn can_run(path: &Path) -> bool {
144    host::proc::can_run(path)
145}
146
147/// The `pitboard` a terminal would run.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum CommandLine {
150    /// The app's own, at this path or linked to from it.
151    Bundled(PathBuf),
152    /// Another install, at this path.
153    Another(PathBuf),
154    /// None anywhere a terminal would look.
155    Nowhere,
156}
157
158/// Where each way of installing Pitboard puts `pitboard`, looked in after the login shell's
159/// `PATH`: `cargo install` and a copy from a release under `home`, then where the system's
160/// package managers put programs, which on a Mac is where Homebrew links the app's own and
161/// where the app links it itself, `/usr/local/bin`.
162pub fn command_line_places(home: &Path) -> Vec<PathBuf> {
163    [home.join(".cargo/bin"), home.join(".local/bin")]
164        .into_iter()
165        .chain(OS.package_bins().iter().map(PathBuf::from))
166        .collect()
167}
168
169/// The first `pitboard` a terminal would run, as a terminal finds it: the first file of
170/// that name that can be run on `search_path`, in `PATH`'s form, and then in `places`. It is
171/// the app's own when, every link on the way followed, it is the command line at `helper`.
172pub fn find_command_line(
173    search_path: Option<&OsStr>,
174    places: &[PathBuf],
175    helper: Option<&Path>,
176) -> CommandLine {
177    let dirs = search_path
178        .map(|path| std::env::split_paths(path).collect::<Vec<_>>())
179        .unwrap_or_default()
180        .into_iter()
181        .chain(places.iter().cloned());
182    let Some(found) = host::program::find_among(Path::new("pitboard"), dirs, host::proc::can_run)
183    else {
184        return CommandLine::Nowhere;
185    };
186    let own = helper.and_then(|helper| std::fs::canonicalize(helper).ok());
187    match own {
188        Some(own) if std::fs::canonicalize(&found).is_ok_and(|resolved| resolved == own) => {
189            CommandLine::Bundled(found)
190        }
191        _ => CommandLine::Another(found),
192    }
193}
194
195/// A file an app keeps of its own in Pitboard's directory: what it remembers between launches
196/// that is the app's and no command's. The core reads none of them for itself, and nothing in
197/// one is secret, but each is written private to its owner, as everything there is.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199#[non_exhaustive]
200pub enum AppFile {
201    /// The app's own preferences, such as which tools somebody keeps one account of.
202    Preferences,
203    /// What the app has told a person about, once for each reset of a limit, so a run-out
204    /// told before the app was quit is not told again after it is opened.
205    Told,
206}
207
208impl AppFile {
209    /// Its name in Pitboard's directory.
210    pub fn name(self) -> &'static str {
211        match self {
212            AppFile::Preferences => "app.json",
213            AppFile::Told => "told.json",
214        }
215    }
216}
217
218/// What the app keeps in `file`, or `None` where it keeps nothing there. A file that is there
219/// and cannot be read, because it is not this user's to read, a disk failed, another program
220/// holds it or it is not text, is an error and not nothing: an app that took it as nothing
221/// would write what it has over what it never read.
222pub fn read_app_file(ctx: &Context, file: AppFile) -> std::io::Result<Option<String>> {
223    read_file(&home::dir(ctx).join(file.name()))
224}
225
226/// What an app keeps in the file at `path`, wherever that is, or `None` where nothing is
227/// there, by the rule of `read_app_file`: a file that is there and cannot be read is an
228/// error, not nothing.
229pub fn read_file(path: &Path) -> std::io::Result<Option<String>> {
230    match std::fs::read_to_string(path) {
231        Ok(text) => Ok(Some(text)),
232        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
233        Err(e) => Err(e),
234    }
235}
236
237/// Keeps `body` in the file at `path`, outside Pitboard's directory, as the core writes its
238/// own: the directories it needs made private first where they are not there yet, and the
239/// file written whole or not at all, private to its owner. For what an app keeps of its own
240/// that is the app's whichever Pitboard directory it serves, such as the account windows'
241/// records, which belong with the app's web stores.
242///
243/// It takes the [`Permit`] only the one gate every change passes makes, which an app asks for
244/// with [`crate::service::Pitboard::permit`], so an app writes nothing of its own either where
245/// it runs as root or under sudo.
246pub fn write_file(permit: Permit, path: &Path, body: &str) -> std::io::Result<()> {
247    if let Some(dir) = path.parent() {
248        host::fs::create_private_dir(permit, dir)?;
249    }
250    atomic::write(permit, path, body.as_bytes(), atomic::Perms::Secret)
251}
252
253/// Keeps `body` in `file`, as the core writes its own: Pitboard's directory made private
254/// first where it is not there yet, and the file written whole or not at all, private to its
255/// owner.
256pub fn write_app_file(ctx: &Context, permit: Permit, file: AppFile, body: &str) -> Result<()> {
257    let path = home::dir(ctx).join(file.name());
258    let unwritable = |source| Error::HomeUnwritable {
259        path: path.clone(),
260        source,
261    };
262    home::ensure(ctx, permit).map_err(unwritable)?;
263    atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(unwritable)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::host::Os;
270    use crate::host::fs::testing;
271    use std::cell::RefCell;
272
273    fn env(pairs: &[(&str, &str)]) -> Environment {
274        pairs.iter().copied().collect()
275    }
276
277    fn said(path: &str) -> LoginPath {
278        LoginPath::Said(path.into())
279    }
280
281    /// A program is found where the variable naming it outright says, then on the login
282    /// shell's `PATH`, where a version manager or an npm prefix puts it, then where its
283    /// installer does.
284    #[test]
285    fn a_program_is_found_by_its_variable_then_the_login_shell_then_its_installer() {
286        let home = env(&[("HOME", "/Users/x")]);
287        let here: Vec<PathBuf> = [
288            "/Users/x/.nvm/versions/node/v22.1.0/bin/codex",
289            "/Users/x/.volta/bin/claude",
290            "/Users/x/.local/bin/codex",
291            "/Users/x/.local/bin/claude",
292        ]
293        .into_iter()
294        .map(PathBuf::from)
295        .collect();
296        let runnable = |path: &Path| here.iter().any(|there| there == path);
297        let login = "/usr/bin:bin:/Users/x/.volta/bin:/Users/x/.nvm/versions/node/v22.1.0/bin";
298
299        let shell = AppContext::read(&home, None, said(login), runnable);
300        assert_eq!(
301            shell.context.codex_program(),
302            Path::new("/Users/x/.nvm/versions/node/v22.1.0/bin/codex")
303        );
304        assert_eq!(
305            shell.context.claude_program(),
306            Path::new("/Users/x/.volta/bin/claude")
307        );
308        assert_eq!(shell.found, [ProviderId::Claude, ProviderId::Codex]);
309        assert_eq!(
310            shell.command_line_places,
311            command_line_places(Path::new("/Users/x")),
312            "a terminal's installs are looked for under the app's own home"
313        );
314        let looked = "/usr/bin:/Users/x/.volta/bin:/Users/x/.nvm/versions/node/v22.1.0/bin";
315        assert_eq!(
316            shell.search_path.as_deref(),
317            Some(looked),
318            "the core looks where the app looks"
319        );
320        assert_eq!(
321            shell.context.search_path(),
322            looked,
323            "and a sign-in is given it"
324        );
325
326        let named = AppContext::read(
327            &env(&[("HOME", "/Users/x"), ("PITBOARD_CODEX", "/elsewhere/codex")]),
328            None,
329            said(login),
330            runnable,
331        );
332        assert_eq!(named.context.codex_program(), Path::new("/elsewhere/codex"));
333        assert_eq!(
334            named.context.claude_program(),
335            Path::new("/Users/x/.volta/bin/claude")
336        );
337        assert_eq!(
338            named.found,
339            [ProviderId::Claude, ProviderId::Codex],
340            "a program named outright counts as found"
341        );
342
343        let empty = AppContext::read(
344            &env(&[("HOME", "/Users/x"), ("PITBOARD_CODEX", "")]),
345            None,
346            said("/opt/tools/bin"),
347            |path| path == Path::new("/opt/tools/bin/codex"),
348        );
349        assert_eq!(
350            empty.context.codex_program(),
351            Path::new("/opt/tools/bin/codex"),
352            "an empty name names nothing, and the app looks as it does without one"
353        );
354
355        let unknown = AppContext::read(&home, None, LoginPath::Unknown, runnable);
356        assert_eq!(
357            unknown.context.codex_program(),
358            Path::new("/Users/x/.local/bin/codex")
359        );
360        assert_eq!(
361            unknown.context.claude_program(),
362            Path::new("/Users/x/.local/bin/claude")
363        );
364        assert_eq!(
365            unknown.search_path, None,
366            "the app looks where it always did"
367        );
368        assert!(!unknown.late);
369
370        let nowhere = AppContext::read(&home, None, said(login), |_| false);
371        assert_eq!(nowhere.context.codex_program(), Path::new("codex"));
372        assert_eq!(nowhere.context.claude_program(), Path::new("claude"));
373        assert!(nowhere.found.is_empty());
374
375        let late = AppContext::read(&home, None, LoginPath::Late, |_| false);
376        assert!(late.late, "a late shell says so, for the app to ask again");
377        assert_eq!(late.search_path, None);
378    }
379
380    /// The installers' places come after the login shell's `PATH`, in the order each tool
381    /// says, and every tool's own installer goes first.
382    #[test]
383    fn each_tool_is_looked_for_where_its_installers_put_it() {
384        let home = Path::new("/Users/x");
385        for &tool in ProviderId::ALL {
386            let places = tool.install_places(home);
387            assert_eq!(places.first(), Some(&home.join(".local/bin")), "{tool}");
388            let after: Vec<&Path> = places[1..].iter().map(PathBuf::as_path).collect();
389            let expected: Vec<&Path> = OS.package_bins().iter().map(Path::new).collect();
390            assert_eq!(after, expected, "{tool}");
391        }
392        match OS {
393            Os::MacOs => assert_eq!(
394                OS.package_bins(),
395                ["/opt/homebrew/bin", "/usr/local/bin"],
396                "Homebrew on Apple silicon and on Intel"
397            ),
398            Os::Linux => assert!(OS.package_bins().is_empty()),
399            // Until W17 reads where winget, Scoop and npm put programs.
400            Os::Windows => assert!(OS.package_bins().is_empty()),
401        }
402    }
403
404    /// An app reads every variable the command line reads, the same way, so a custom OAuth
405    /// endpoint, a variable that signs Claude Code in some other way, the successor
406    /// credential backend and a test's own address for the services reach it too. It used to
407    /// read only the variables that move where things are kept.
408    #[test]
409    fn an_app_reads_its_environment_as_the_command_line_does() {
410        let given = env(&[
411            ("HOME", "/Users/x"),
412            ("PITBOARD_HOME", "/Users/x/elsewhere"),
413            ("CLAUDE_CONFIG_DIR", "/Users/x/claude"),
414            ("CLAUDE_SECURESTORAGE_CONFIG_DIR", ""),
415            ("USER", "x"),
416            ("CODEX_HOME", "/Users/x/codex"),
417            ("CLAUDE_CODE_CUSTOM_OAUTH_URL", "https://oauth.example"),
418            ("ANTHROPIC_API_KEY", "not-a-key"),
419            ("CLAUDE_CODE_USE_BEDROCK", "1"),
420            ("CLAUDE_CODE_HOVER_REST", "true"),
421            ("PITBOARD_API_BASE", "http://127.0.0.1:8080"),
422            ("PITBOARD_NO_ARGV", "1"),
423        ]);
424        let app = AppContext::read(&given, None, LoginPath::Unknown, |_| false).context;
425        let cli = Context::for_command_line(&given);
426        assert_eq!(app.home, cli.home);
427        assert_eq!(app.pitboard_home, cli.pitboard_home);
428        assert_eq!(app.claude_config_dir, cli.claude_config_dir);
429        assert_eq!(app.secure_storage_dir.as_deref(), Some(""));
430        assert_eq!(app.user, cli.user);
431        assert_eq!(app.codex_home, cli.codex_home);
432        assert!(app.custom_oauth() && cli.custom_oauth());
433        assert_eq!(
434            app.overriding_auth(),
435            ["ANTHROPIC_API_KEY", "CLAUDE_CODE_USE_BEDROCK"]
436        );
437        assert_eq!(app.overriding_auth(), cli.overriding_auth());
438        assert!(app.hover_rest && cli.hover_rest);
439        assert_eq!(app.api_base.as_deref(), Some("http://127.0.0.1:8080"));
440        assert_eq!(app.api_base, cli.api_base);
441        assert!(!app.argv_fallback() && !cli.argv_fallback());
442        assert_eq!((app.caller.as_str(), cli.caller.as_str()), ("app", "cli"));
443    }
444
445    /// `PITBOARD_NO_ARGV=1` in the app's environment refuses the argument line, as it does
446    /// for the command line, and anything else leaves it allowed.
447    #[test]
448    fn the_argument_line_is_refused_where_the_environment_says_so() {
449        let allowed = |value: Option<&str>| {
450            let mut pairs = vec![("HOME", "/Users/x")];
451            pairs.extend(value.map(|v| ("PITBOARD_NO_ARGV", v)));
452            AppContext::read(&env(&pairs), None, LoginPath::Unknown, |_| false)
453                .context
454                .argv_fallback()
455        };
456        assert!(!allowed(Some("1")));
457        assert!(allowed(None));
458        assert!(allowed(Some("0")));
459    }
460
461    /// A relative entry on the login shell's `PATH` names a directory relative to wherever
462    /// the shell was, which is not where this app is, so it is not looked in.
463    #[test]
464    fn a_relative_entry_is_not_looked_in() {
465        let looked = RefCell::new(Vec::new());
466        let _ = AppContext::read(
467            &env(&[("HOME", "/Users/x")]),
468            None,
469            said("bin:./node_modules/.bin:/usr/bin"),
470            |path| {
471                looked.borrow_mut().push(path.to_path_buf());
472                false
473            },
474        );
475        let looked = looked.into_inner();
476        assert!(looked.iter().all(|path| path.is_absolute()), "{looked:?}");
477        assert_eq!(looked.first(), Some(&PathBuf::from("/usr/bin/claude")));
478    }
479
480    /// A folder macOS guards, such as Documents or iCloud Drive, is not looked in, and is not
481    /// where the core looks or what a sign-in is given: looking there asks the person whether
482    /// Pitboard may, and a program started from there would have it asked on its behalf.
483    /// Linux guards none.
484    #[test]
485    fn a_guarded_folder_on_the_path_is_not_looked_in() {
486        let entries = [
487            "/Users/x/Documents/bin",
488            "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/bin",
489            "/Users/x/Desktop",
490            "/Users/x/Downloads/tools/bin",
491            "/Users/x/Library/CloudStorage/bin",
492            "/Users/x/Documentsbin",
493            "/usr/bin",
494        ];
495        let looked = RefCell::new(Vec::new());
496        let read = AppContext::read(
497            &env(&[("HOME", "/Users/x")]),
498            None,
499            said(&entries.join(":")),
500            |path| {
501                looked.borrow_mut().push(path.to_path_buf());
502                false
503            },
504        );
505        let looked = looked.into_inner();
506        let kept: &[&str] = match OS {
507            Os::MacOs => &["/Users/x/Documentsbin", "/usr/bin"],
508            // Neither passes over a folder: Linux asks nothing, and which folders Windows
509            // asks about, if any, W17 reads.
510            Os::Linux | Os::Windows => &entries,
511        };
512        let places = ProviderId::Claude.install_places(Path::new("/Users/x"));
513        let claude: Vec<PathBuf> = kept
514            .iter()
515            .map(PathBuf::from)
516            .chain(places)
517            .map(|dir| dir.join("claude"))
518            .collect();
519        assert_eq!(looked[..claude.len()], claude[..], "{looked:?}");
520        assert_eq!(
521            looked.len(),
522            2 * claude.len(),
523            "the same places for each tool: {looked:?}"
524        );
525        assert_eq!(read.search_path.as_deref(), Some(kept.join(":").as_str()));
526    }
527
528    /// The renewal schedule runs the command line inside the app, since the app has no
529    /// renewal of its own. Anything not running from an app bundle names none, which means it
530    /// cannot schedule renewal: the only other thing to schedule is the app itself.
531    #[test]
532    fn the_schedule_runs_the_command_line_inside_the_app() {
533        let scheduled = |app: Option<&str>| {
534            AppContext::read(
535                &env(&[("HOME", "/Users/x")]),
536                app.map(Path::new),
537                LoginPath::Unknown,
538                |_| false,
539            )
540            .context
541            .schedule_program()
542            .map(Path::to_path_buf)
543        };
544        let inside = |app: &str| match OS {
545            Os::MacOs => Some(PathBuf::from(format!("{app}/Contents/Helpers/pitboard"))),
546            // No app runs on Linux, and the Windows app does not say where its own is yet.
547            Os::Linux | Os::Windows => None,
548        };
549        for app in [
550            "/Applications/Pitboard.app",
551            "/Users/x/My Apps/Pitboard.app",
552        ] {
553            assert_eq!(scheduled(Some(app)), inside(app), "{app}");
554            assert_eq!(app_command_line(Path::new(app)), inside(app), "{app}");
555        }
556        assert_eq!(
557            scheduled(Some("/Users/x/pitboard/apps/macos/.build/debug")),
558            None
559        );
560        assert_eq!(scheduled(None), None);
561    }
562
563    /// A scratch directory with an app bundle carrying a command line, and whatever a test
564    /// puts beside it. Its name has a space and a quote in it, as a folder somebody made
565    /// might. Nothing in it is ever run.
566    struct Scratch(PathBuf);
567
568    impl Scratch {
569        fn new() -> Scratch {
570            let root = std::env::temp_dir().join(format!(
571                "pitboard's tool {}-{:?}",
572                std::process::id(),
573                std::thread::current().id()
574            ));
575            let _ = std::fs::remove_dir_all(&root);
576            let scratch = Scratch(root);
577            scratch.program(&scratch.helper(), true);
578            scratch
579        }
580
581        fn helper(&self) -> PathBuf {
582            self.0.join("Fake.app/Contents/Helpers/pitboard")
583        }
584
585        /// A directory under the root, made if it is not there.
586        fn dir(&self, name: &str) -> PathBuf {
587            let made = self.0.join(name);
588            std::fs::create_dir_all(&made).expect("a scratch directory");
589            made
590        }
591
592        fn program(&self, at: &Path, runnable: bool) {
593            std::fs::create_dir_all(at.parent().expect("its directory")).expect("a directory");
594            std::fs::write(at, "#!/bin/sh\n").expect("a program");
595            if runnable {
596                testing::make_runnable(at).expect("runnable");
597            }
598        }
599    }
600
601    impl Drop for Scratch {
602        fn drop(&mut self) {
603            let _ = std::fs::remove_dir_all(&self.0);
604        }
605    }
606
607    /// The first `pitboard` on the path is the one a terminal runs, and a link to the app's
608    /// own, the way Homebrew makes one, is the app's own however many links it takes to get
609    /// there. A directory, a file nobody can run and a link to nothing are not a `pitboard`.
610    #[test]
611    fn the_first_pitboard_found_says_whose_it_is() {
612        let scratch = Scratch::new();
613        let empty = scratch.dir("empty");
614        let folder = scratch.dir("folder");
615        scratch.dir("folder/pitboard");
616        let plain = scratch.dir("plain");
617        scratch.program(&plain.join("pitboard"), false);
618        let dangling = scratch.dir("dangling");
619        testing::link(&scratch.0.join("gone/pitboard"), &dangling.join("pitboard"))
620            .expect("a link to nothing");
621        let brew = scratch.dir("brew/bin");
622        testing::link(
623            Path::new("../../Fake.app/Contents/Helpers/pitboard"),
624            &brew.join("pitboard"),
625        )
626        .expect("a link as Homebrew makes one");
627        let cargo = scratch.dir("cargo/bin");
628        scratch.program(&cargo.join("pitboard"), true);
629        let helpers = scratch.0.join("helpers");
630        testing::link_dir(scratch.helper().parent().expect("Helpers"), &helpers)
631            .expect("a link to a directory");
632
633        let own = scratch.helper();
634        let find = |dirs: &[&PathBuf], helper: Option<&Path>| {
635            let places: Vec<PathBuf> = dirs.iter().map(|dir| (*dir).clone()).collect();
636            find_command_line(None, &places, helper)
637        };
638        assert_eq!(
639            find(
640                &[&empty, &folder, &plain, &dangling, &brew, &cargo],
641                Some(&own)
642            ),
643            CommandLine::Bundled(brew.join("pitboard"))
644        );
645        assert_eq!(
646            find(&[&helpers], Some(&own)),
647            CommandLine::Bundled(helpers.join("pitboard"))
648        );
649        assert_eq!(
650            find(&[&cargo, &brew], Some(&own)),
651            CommandLine::Another(cargo.join("pitboard"))
652        );
653        assert_eq!(
654            find(&[&empty, &folder, &plain, &dangling], Some(&own)),
655            CommandLine::Nowhere
656        );
657        assert_eq!(find(&[], Some(&own)), CommandLine::Nowhere);
658
659        let other = scratch.0.join("Other.app/Contents/Helpers/pitboard");
660        assert_eq!(
661            find(&[&brew], Some(&other)),
662            CommandLine::Another(brew.join("pitboard")),
663            "another copy's"
664        );
665        assert_eq!(
666            find(&[&brew], None),
667            CommandLine::Another(brew.join("pitboard")),
668            "not run from an app"
669        );
670
671        let path = std::env::join_paths([Path::new("/nowhere/bin"), Path::new("bin"), &brew])
672            .expect("a search path");
673        assert_eq!(
674            find_command_line(Some(&path), std::slice::from_ref(&cargo), Some(&own)),
675            CommandLine::Bundled(brew.join("pitboard")),
676            "the login shell's PATH first, ahead of the places installs go"
677        );
678    }
679
680    /// Pitboard's own installs go where cargo and a copy from a release put it under the
681    /// home, then where the system's package managers put programs.
682    #[test]
683    fn the_command_line_is_looked_for_where_each_way_of_installing_it_puts_it() {
684        let home = Path::new("/Users/x");
685        let places = command_line_places(home);
686        assert_eq!(
687            places[..2],
688            [home.join(".cargo/bin"), home.join(".local/bin")]
689        );
690        let after: Vec<&Path> = places[2..].iter().map(PathBuf::as_path).collect();
691        let expected: Vec<&Path> = OS.package_bins().iter().map(Path::new).collect();
692        assert_eq!(after, expected);
693    }
694
695    /// An app's file is kept in Pitboard's directory, which is made private where it is not
696    /// there yet, and the file private to its owner, whole: read back as it was written, and
697    /// in its place what was written last. Nothing kept reads as nothing.
698    #[test]
699    fn an_apps_file_is_kept_private_in_pitboards_directory() {
700        let root = std::env::temp_dir().join(format!("pitboard-app-file-{}", std::process::id()));
701        let _ = std::fs::remove_dir_all(&root);
702        let ctx = Context::new(root.clone()).with_pitboard_home(root.join(".pitboard"));
703        let read = |file| read_app_file(&ctx, file).expect("read");
704        assert_eq!(read(AppFile::Told), None);
705
706        write_app_file(
707            &ctx,
708            Permit::for_a_test(),
709            AppFile::Told,
710            r#"{"claude/work/session/":7200}"#,
711        )
712        .expect("kept");
713        write_app_file(&ctx, Permit::for_a_test(), AppFile::Preferences, "{}").expect("kept");
714        assert_eq!(
715            read(AppFile::Told).as_deref(),
716            Some(r#"{"claude/work/session/":7200}"#)
717        );
718        write_app_file(&ctx, Permit::for_a_test(), AppFile::Told, "{}").expect("kept again");
719        assert_eq!(read(AppFile::Told).as_deref(), Some("{}"));
720        assert_eq!(read(AppFile::Preferences).as_deref(), Some("{}"));
721        for kept in [
722            root.join(".pitboard"),
723            root.join(".pitboard").join("told.json"),
724            root.join(".pitboard").join("app.json"),
725        ] {
726            assert!(
727                testing::is_private(&kept).expect("there"),
728                "{}",
729                kept.display()
730            );
731        }
732        let _ = std::fs::remove_dir_all(&root);
733    }
734
735    /// A file that is there and cannot be read is an error, not nothing kept: here a
736    /// directory where the file goes, which no user can read as a file, and a file that is
737    /// not text. An app that took either as nothing would write over what it never read.
738    #[test]
739    fn an_apps_file_that_cannot_be_read_is_not_nothing() {
740        let root =
741            std::env::temp_dir().join(format!("pitboard-app-unreadable-{}", std::process::id()));
742        let _ = std::fs::remove_dir_all(&root);
743        let dir = root.join(".pitboard");
744        let ctx = Context::new(root.clone()).with_pitboard_home(dir.clone());
745        std::fs::create_dir_all(dir.join(AppFile::Preferences.name())).expect("a directory");
746        std::fs::write(dir.join(AppFile::Told.name()), [0xff, 0xfe, 0x00]).expect("a file");
747        assert!(read_app_file(&ctx, AppFile::Preferences).is_err());
748        assert!(read_app_file(&ctx, AppFile::Told).is_err());
749        let _ = std::fs::remove_dir_all(&root);
750    }
751
752    /// A file an app keeps outside Pitboard's directory is kept as one inside it: its
753    /// directories made private where they are not there, the file private and whole, and
754    /// nothing kept read as nothing, while a file that cannot be read is an error.
755    #[test]
756    fn a_file_kept_elsewhere_is_kept_as_pitboards_own() {
757        let root =
758            std::env::temp_dir().join(format!("pitboard-app-elsewhere-{}", std::process::id()));
759        let _ = std::fs::remove_dir_all(&root);
760        let file = root
761            .join("Application Support")
762            .join("app")
763            .join("windows.json");
764        assert_eq!(read_file(&file).expect("read"), None);
765        write_file(Permit::for_a_test(), &file, "{}").expect("kept");
766        write_file(Permit::for_a_test(), &file, r#"{"stores":{}}"#).expect("kept again");
767        assert_eq!(
768            read_file(&file).expect("read").as_deref(),
769            Some(r#"{"stores":{}}"#)
770        );
771        for kept in [file.parent().expect("its directory"), file.as_path()] {
772            assert!(
773                testing::is_private(kept).expect("there"),
774                "{}",
775                kept.display()
776            );
777        }
778        std::fs::write(&file, [0xff, 0xfe, 0x00]).expect("a file that is not text");
779        assert!(read_file(&file).is_err());
780        let _ = std::fs::remove_dir_all(&root);
781    }
782
783    /// Each file has a name of its own, apart from the core's.
784    #[test]
785    fn each_apps_file_has_a_name_of_its_own() {
786        assert_eq!(AppFile::Preferences.name(), "app.json");
787        assert_eq!(AppFile::Told.name(), "told.json");
788    }
789}