1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow};
6use base64::Engine as _;
7use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
8
9const BEAR_GROUP_CONTAINER_SUFFIX: &str = ".net.shinyfrog.bear";
10const BEAR_DATABASE_SUFFIX: &str = "Application Data/database.sqlite";
11
12pub fn expand_tilde(path: &str) -> Result<PathBuf> {
13 if let Some(rest) = path.strip_prefix("~/") {
14 let home = env::var_os("HOME").ok_or_else(|| anyhow!("$HOME is not set"))?;
15 return Ok(PathBuf::from(home).join(rest));
16 }
17
18 Ok(PathBuf::from(path))
19}
20
21pub fn app_support_dir() -> Result<PathBuf> {
22 let home = env::var_os("HOME").ok_or_else(|| anyhow!("$HOME is not set"))?;
23 Ok(PathBuf::from(home)
24 .join("Library")
25 .join("Application Support")
26 .join("bear-cli"))
27}
28
29pub fn resolve_database_path(override_path: Option<&str>) -> Result<PathBuf> {
30 if let Some(path) = override_path {
31 return expand_tilde(path);
32 }
33
34 let home = env::var_os("HOME").ok_or_else(|| anyhow!("$HOME is not set"))?;
35 let group_containers = PathBuf::from(home).join("Library").join("Group Containers");
36
37 find_bear_database_in(&group_containers)
38}
39
40fn find_bear_database_in(group_containers: &Path) -> Result<PathBuf> {
41 let entries = fs::read_dir(group_containers).with_context(|| {
42 format!(
43 "failed to read Bear group containers from {}",
44 group_containers.display()
45 )
46 })?;
47
48 let mut matches = entries
49 .filter_map(|entry| entry.ok())
50 .map(|entry| entry.path())
51 .filter(|path| path.is_dir())
52 .filter(|path| {
53 path.file_name()
54 .and_then(|name| name.to_str())
55 .is_some_and(|name| name.ends_with(BEAR_GROUP_CONTAINER_SUFFIX))
56 })
57 .map(|path| path.join(BEAR_DATABASE_SUFFIX))
58 .filter(|path| path.is_file())
59 .collect::<Vec<_>>();
60
61 matches.sort();
62
63 matches.into_iter().next().ok_or_else(|| {
64 anyhow!(
65 "could not locate Bear database under {} matching *{}",
66 group_containers.display(),
67 BEAR_GROUP_CONTAINER_SUFFIX
68 )
69 })
70}
71
72pub fn token_path() -> Result<PathBuf> {
73 Ok(app_support_dir()?.join("token"))
74}
75
76pub fn save_token(token: &str) -> Result<()> {
77 let dir = app_support_dir()?;
78 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
79 fs::write(token_path()?, format!("{token}\n")).context("failed to write token file")?;
80 Ok(())
81}
82
83pub fn load_token() -> Result<Option<String>> {
84 let path = token_path()?;
85 match fs::read_to_string(&path) {
86 Ok(contents) => Ok(Some(contents.trim().to_string())),
87 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
88 Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
89 }
90}
91
92pub fn encode_file(path: &Path) -> Result<String> {
93 let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
94 Ok(BASE64_STANDARD.encode(bytes))
95}
96
97#[cfg(test)]
98mod tests {
99 use std::fs;
100
101 use super::{expand_tilde, find_bear_database_in};
102
103 #[test]
104 fn expands_tilde() {
105 let expanded = expand_tilde("~/tmp").expect("tilde should expand");
106 assert!(expanded.to_string_lossy().ends_with("/tmp"));
107 }
108
109 #[test]
110 fn finds_database_dynamically() {
111 let base =
112 std::env::temp_dir().join(format!("bear-cli-config-test-{}", std::process::id()));
113 let _ = fs::remove_dir_all(&base);
114 let group = base.join("ABC123.net.shinyfrog.bear");
115 let database = group.join("Application Data").join("database.sqlite");
116 fs::create_dir_all(database.parent().expect("database parent"))
117 .expect("test directories should be created");
118 fs::write(&database, b"").expect("database file should be created");
119
120 let found = find_bear_database_in(&base).expect("database should be discovered");
121 assert_eq!(found, database);
122
123 let _ = fs::remove_dir_all(&base);
124 }
125}