1use 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#[derive(Debug)]
26#[non_exhaustive]
27pub struct AppContext {
28 pub context: Context,
29 pub found: Vec<ProviderId>,
33 pub search_path: Option<String>,
37 pub late: bool,
40 pub command_line_places: Vec<PathBuf>,
44}
45
46impl AppContext {
47 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 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 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
125fn guarded(dir: &Path, home: &Path) -> bool {
128 OS.guarded_folders()
129 .iter()
130 .any(|folder| dir.starts_with(home.join(folder)))
131}
132
133pub fn app_command_line(app: &Path) -> Option<PathBuf> {
136 OS.app_command_line(app)
137}
138
139pub fn can_run(path: &Path) -> bool {
144 host::proc::can_run(path)
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum CommandLine {
150 Bundled(PathBuf),
152 Another(PathBuf),
154 Nowhere,
156}
157
158pub 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
169pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199#[non_exhaustive]
200pub enum AppFile {
201 Preferences,
203 Told,
206}
207
208impl AppFile {
209 pub fn name(self) -> &'static str {
211 match self {
212 AppFile::Preferences => "app.json",
213 AppFile::Told => "told.json",
214 }
215 }
216}
217
218pub fn read_app_file(ctx: &Context, file: AppFile) -> std::io::Result<Option<String>> {
223 read_file(&home::dir(ctx).join(file.name()))
224}
225
226pub 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
237pub 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
253pub 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 #[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 #[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 Os::Windows => assert!(OS.package_bins().is_empty()),
401 }
402 }
403
404 #[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 #[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 #[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 #[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 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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}