use crate::context::Context;
use crate::error::{Error, Result};
use crate::service::Permit;
use std::io;
use std::path::{Path, PathBuf};
const SYNCED: [&str; 5] = [
"Dropbox",
"Google Drive",
"OneDrive",
"com~apple~CloudDocs",
"Sync",
];
fn a_sync_clients_folder(name: &str) -> bool {
let name = name.to_lowercase();
[
"onedrive",
"dropbox",
"google drive",
"com~apple~clouddocs",
"sync",
]
.contains(&name.as_str())
|| name.starts_with("onedrive - ")
|| name.starts_with("onedrive-")
|| (name.starts_with("dropbox (") && name.ends_with(')'))
|| name.starts_with("googledrive-")
}
const SYNCED_INSIDE: [[&str; 2]; 2] =
[["library", "cloudstorage"], ["library", "mobile documents"]];
fn synced_by(path: &Path) -> Option<String> {
let text = path.to_string_lossy();
if let Some(marker) = SYNCED.iter().find(|marker| text.contains(**marker)) {
return Some((*marker).to_string());
}
let names: Vec<String> = path
.components()
.map(|part| part.as_os_str().to_string_lossy().into_owned())
.collect();
names.iter().enumerate().find_map(|(at, name)| {
if a_sync_clients_folder(name) {
return Some(name.clone());
}
let parent = at.checked_sub(1).map(|before| names[before].as_str())?;
SYNCED_INSIDE
.iter()
.any(|[outer, inner]| parent.to_lowercase() == *outer && name.to_lowercase() == *inner)
.then(|| format!("{parent}/{name}"))
})
}
pub fn dir(ctx: &Context) -> PathBuf {
ctx.pitboard_home.clone()
}
pub fn ensure(ctx: &Context, permit: Permit) -> io::Result<PathBuf> {
let path = dir(ctx);
crate::host::fs::create_private_dir(permit, &path)?;
Ok(path)
}
pub fn check_absolute(ctx: &Context) -> Result<()> {
let homes: [(&'static str, Option<&Path>); 5] = [
("HOME", Some(ctx.home())),
("PITBOARD_HOME", Some(&ctx.pitboard_home)),
(
"CLAUDE_CONFIG_DIR",
ctx.claude_config_dir.as_deref().map(Path::new),
),
(
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
ctx.secure_storage_dir
.as_deref()
.filter(|dir| !dir.is_empty())
.map(Path::new),
),
("CODEX_HOME", ctx.codex_home().map(Path::new)),
];
match homes
.into_iter()
.find_map(|(variable, path)| path.filter(|p| !p.is_absolute()).map(|p| (variable, p)))
{
Some((variable, path)) => Err(Error::HomeNotAbsolute {
variable,
path: path.to_path_buf(),
}),
None => Ok(()),
}
}
pub fn check_location(path: &Path) -> Result<()> {
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
match synced_by(&resolved) {
Some(marker) => Err(Error::StateOnSyncedDrive {
path: resolved,
marker,
}),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::Environment;
fn refused_over(pairs: &[(&str, &str)]) -> Option<(&'static str, PathBuf)> {
let env: Environment = pairs.iter().copied().collect();
match check_absolute(&Context::for_command_line(&env)) {
Ok(()) => None,
Err(Error::HomeNotAbsolute { variable, path }) => Some((variable, path)),
Err(other) => panic!("{other}"),
}
}
#[test]
fn a_home_that_is_not_a_full_path_is_refused_by_the_variable_that_named_it() {
let home = ("HOME", "/Users/x");
for pairs in [
&[home][..],
&[home, ("PITBOARD_HOME", "/elsewhere/./pitboard/")],
&[home, ("CLAUDE_CONFIG_DIR", "/Users/x/claude")],
&[home, ("CLAUDE_SECURESTORAGE_CONFIG_DIR", "")],
&[home, ("CLAUDE_SECURESTORAGE_CONFIG_DIR", "/Users/x/slot")],
&[home, ("CODEX_HOME", "/Users/x/codex")],
&[home, ("CODEX_HOME", "")],
] {
assert_eq!(refused_over(pairs), None, "{pairs:?}");
}
for (pairs, variable, path) in [
(&[("HOME", "")][..], "HOME", ""),
(&[("HOME", "relative")], "HOME", "relative"),
(
&[("HOME", "x"), ("PITBOARD_HOME", "/Users/x/.pitboard")],
"HOME",
"x",
),
(&[home, ("PITBOARD_HOME", "")], "PITBOARD_HOME", ""),
(
&[home, ("PITBOARD_HOME", "pitboard")],
"PITBOARD_HOME",
"pitboard",
),
(
&[home, ("CLAUDE_CONFIG_DIR", "claude")],
"CLAUDE_CONFIG_DIR",
"claude",
),
(&[home, ("CLAUDE_CONFIG_DIR", "")], "CLAUDE_CONFIG_DIR", ""),
(
&[home, ("CLAUDE_SECURESTORAGE_CONFIG_DIR", "./slot")],
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
"./slot",
),
(&[home, ("CODEX_HOME", "codex")], "CODEX_HOME", "codex"),
] {
assert_eq!(
refused_over(pairs),
Some((variable, PathBuf::from(path))),
"{pairs:?}"
);
}
}
#[test]
fn a_home_that_is_not_a_full_path_is_said_with_the_way_out() {
let said = |pairs: &[(&str, &str)]| {
let env: Environment = pairs.iter().copied().collect();
let refused = check_absolute(&Context::for_command_line(&env)).expect_err("refused");
assert_eq!(refused.code(), "home_not_absolute");
refused.to_string()
};
assert_eq!(
said(&[("HOME", "")]),
"HOME is empty, so the folder it names would depend on where each program runs. \
Set it to a full path, or unset it; Pitboard reads and changes nothing until \
then."
);
assert!(
said(&[("HOME", "/Users/x"), ("CODEX_HOME", "codex")])
.starts_with("CODEX_HOME is `codex`, which is not a full path, so ")
);
}
fn refused_before(path: &str) -> bool {
SYNCED.iter().any(|marker| path.contains(marker))
}
fn synced_over(path: &str) -> Option<String> {
match check_location(Path::new(path)) {
Ok(()) => None,
Err(Error::StateOnSyncedDrive { marker, .. }) => Some(marker),
Err(other) => panic!("{other}"),
}
}
#[test]
fn a_sync_clients_folder_is_refused_by_its_name_in_any_case() {
for (path, marker) in [
("/home/x/OneDrive/pitboard", "OneDrive"),
("/home/x/onedrive/pitboard", "onedrive"),
("/home/x/OneDrive - Contoso/pitboard", "OneDrive"),
("/home/x/onedrive - contoso/pitboard", "onedrive - contoso"),
("/home/x/OneDrive-Personal/pitboard", "OneDrive"),
("/home/x/ONEDRIVE-PERSONAL/pitboard", "ONEDRIVE-PERSONAL"),
("/home/x/Dropbox/pitboard", "Dropbox"),
("/home/x/dropbox/pitboard", "dropbox"),
("/home/x/Dropbox (Personal)/pitboard", "Dropbox"),
("/home/x/Dropbox (Business)/pitboard", "Dropbox"),
("/home/x/dropbox (team)/pitboard", "dropbox (team)"),
(
"/home/x/GoogleDrive-a@b.example/pitboard",
"GoogleDrive-a@b.example",
),
(
"/home/x/googledrive-A@B.example/pitboard",
"googledrive-A@B.example",
),
("/home/x/Google Drive/pitboard", "Google Drive"),
("/home/x/google drive/pitboard", "google drive"),
(
"/Users/x/Library/CloudStorage/Box-Box/pitboard",
"Library/CloudStorage",
),
("/Users/x/library/cloudstorage/x", "library/cloudstorage"),
(
"/Users/x/Library/Mobile Documents/iCloud~md~obsidian/pitboard",
"Library/Mobile Documents",
),
(
"/Users/x/Library/Mobile Documents/com~apple~CloudDocs/pitboard",
"com~apple~CloudDocs",
),
(
"/Users/x/COM~APPLE~CLOUDDOCS/pitboard",
"COM~APPLE~CLOUDDOCS",
),
("/home/x/Sync/pitboard", "Sync"),
("/home/x/sync/pitboard", "sync"),
] {
assert_eq!(synced_over(path).as_deref(), Some(marker), "{path}");
}
}
#[test]
fn a_home_named_as_a_sync_client_names_its_folder_is_refused_too() {
for (path, marker) in [
("/home/sync/.pitboard", "sync"),
("/home/dropbox/.pitboard", "dropbox"),
("/home/OneDrive/.pitboard", "OneDrive"),
] {
assert_eq!(synced_over(path).as_deref(), Some(marker), "{path}");
}
assert_eq!(synced_over("/home/syncer/.pitboard"), None);
}
#[test]
fn every_folder_refused_before_is_refused_still() {
let before = [
"/home/x/Resilio Sync/pitboard",
"/home/x/Box Sync/pitboard",
"/home/x/Syncthing/pitboard",
"/home/x/SyncAdmin/pitboard",
"/home/x/OneDriveTools/pitboard",
"/home/x/MyDropboxBackup/pitboard",
"/home/x/Google Drive Stream/pitboard",
"/Users/Synchro/.pitboard",
"/home/x/Dropbox (Personal)/pitboard",
"/home/x/OneDrive - Contoso/pitboard",
];
for path in before {
assert!(refused_before(path), "{path}");
assert!(synced_over(path).is_some(), "{path}");
}
let neither = [
"/Users/x/.pitboard",
"/home/x/.pitboard",
"/home/x/projects/synchronous/.pitboard",
"/home/x/syncthing/pitboard",
"/home/x/dropboxes/pitboard",
"/home/x/onedrivetools/pitboard",
"/Users/x/Library/Application Support/Pitboard",
"/Users/x/Library/Caches/CloudStorage/pitboard",
"/Users/x/Documents/Library/Mobile/pitboard",
];
for path in neither {
assert!(!refused_before(path), "{path}");
assert_eq!(synced_over(path), None, "{path}");
}
}
}