use crate::dirs::BaseDirs;
use crate::error::{InstallerError, Result};
use camino::{Utf8Path, Utf8PathBuf};
pub const WHITAKER_REPO_URL: &str = "https://github.com/leynos/whitaker";
const WHITAKER_PACKAGE_NAME: &str = "whitaker";
pub fn is_whitaker_workspace(dir: &Utf8Path) -> bool {
let cargo_toml = dir.join("Cargo.toml");
if !cargo_toml.exists() {
return false;
}
let Ok(content) = std::fs::read_to_string(&cargo_toml) else {
return false;
};
let Ok(manifest) = content.parse::<toml::Table>() else {
return false;
};
manifest
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.is_some_and(|name| name == WHITAKER_PACKAGE_NAME)
}
pub fn clone_directory(dirs: &dyn BaseDirs) -> Option<Utf8PathBuf> {
dirs.whitaker_data_dir()
.and_then(|p| Utf8PathBuf::try_from(p).ok())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceAction {
UseCurrentDir(Utf8PathBuf),
CloneTo(Utf8PathBuf),
UpdateAt(Utf8PathBuf),
UseExisting(Utf8PathBuf),
}
pub fn decide_workspace_action(
cwd: &Utf8Path,
clone_dir: &Utf8Path,
update: bool,
) -> WorkspaceAction {
if is_whitaker_workspace(cwd) {
WorkspaceAction::UseCurrentDir(cwd.to_owned())
} else if clone_dir.exists() {
if update {
WorkspaceAction::UpdateAt(clone_dir.to_owned())
} else {
WorkspaceAction::UseExisting(clone_dir.to_owned())
}
} else {
WorkspaceAction::CloneTo(clone_dir.to_owned())
}
}
pub fn ensure_workspace(dirs: &dyn BaseDirs, update: bool) -> Result<Utf8PathBuf> {
let cwd = current_dir_utf8()?;
let clone_dir = clone_directory(dirs).ok_or_else(|| InstallerError::WorkspaceNotFound {
reason: "could not determine data directory for cloning".to_owned(),
})?;
match decide_workspace_action(&cwd, &clone_dir, update) {
WorkspaceAction::UseCurrentDir(dir) | WorkspaceAction::UseExisting(dir) => Ok(dir),
WorkspaceAction::CloneTo(dir) => {
crate::git::clone_repository(&dir)?;
Ok(dir)
}
WorkspaceAction::UpdateAt(dir) => {
crate::git::update_repository(&dir)?;
Ok(dir)
}
}
}
pub fn resolve_workspace_path(dirs: &dyn BaseDirs) -> Result<Utf8PathBuf> {
let cwd = current_dir_utf8()?;
if is_whitaker_workspace(&cwd) {
return Ok(cwd);
}
clone_directory(dirs).ok_or_else(|| InstallerError::WorkspaceNotFound {
reason: "could not determine data directory for cloning".to_owned(),
})
}
fn current_dir_utf8() -> Result<Utf8PathBuf> {
let cwd = std::env::current_dir()?;
Utf8PathBuf::try_from(cwd).map_err(|e| InstallerError::WorkspaceNotFound {
reason: format!("current directory is not valid UTF-8: {e}"),
})
}
pub fn find_workspace_root(start: &Utf8Path) -> Result<Utf8PathBuf> {
let mut current = start.to_owned();
loop {
let cargo_toml = current.join("Cargo.toml");
if cargo_toml.exists() && is_cargo_workspace_root(&cargo_toml)? {
return Ok(current);
}
match current.parent() {
Some(parent) => current = parent.to_owned(),
None => break,
}
}
Err(InstallerError::WorkspaceNotFound {
reason: "could not find Cargo.toml with [workspace] section".to_owned(),
})
}
fn is_cargo_workspace_root(cargo_toml: &Utf8Path) -> Result<bool> {
let contents = std::fs::read_to_string(cargo_toml)?;
let table = contents
.parse::<toml::Table>()
.map_err(|e| InstallerError::InvalidCargoToml {
path: cargo_toml.to_owned(),
reason: e.to_string(),
})?;
Ok(table.contains_key("workspace"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dirs::{MockBaseDirs, SystemBaseDirs};
use rstest::{fixture, rstest};
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
struct TempWorkspace {
_temp: TempDir,
path: Utf8PathBuf,
}
#[fixture]
fn temp_workspace() -> TempWorkspace {
let temp = TempDir::new().expect("failed to create temp dir");
let path = Utf8PathBuf::try_from(temp.path().to_owned()).expect("non-UTF8 temp path");
TempWorkspace { _temp: temp, path }
}
fn write_cargo_toml(dir: &Utf8Path, package_name: &str) {
let cargo_toml = dir.join("Cargo.toml");
fs::write(
cargo_toml,
format!("[package]\nname = \"{package_name}\"\nversion = \"0.1.0\"\n"),
)
.expect("failed to write Cargo.toml");
}
#[rstest]
#[case::whitaker_project(Some("whitaker"), true)]
#[case::other_project(Some("other-project"), false)]
#[case::empty_dir(None, false)]
fn is_whitaker_workspace_detection(
temp_workspace: TempWorkspace,
#[case] package_name: Option<&str>,
#[case] expected: bool,
) {
if let Some(name) = package_name {
write_cargo_toml(&temp_workspace.path, name);
}
assert_eq!(is_whitaker_workspace(&temp_workspace.path), expected);
}
#[test]
fn clone_directory_returns_some_on_supported_platforms() {
let dirs = SystemBaseDirs::new().expect("failed to create SystemBaseDirs");
let dir = clone_directory(&dirs);
assert!(dir.is_some(), "expected clone_directory to return Some");
assert!(
dir.as_ref()
.is_some_and(|p| p.as_str().contains("whitaker")),
"expected path to contain 'whitaker'"
);
}
#[rstest]
fn decide_workspace_action_uses_cwd_when_whitaker(temp_workspace: TempWorkspace) {
write_cargo_toml(&temp_workspace.path, "whitaker");
let clone_dir = Utf8PathBuf::from("/nonexistent/clone/dir");
let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true);
assert_eq!(action, WorkspaceAction::UseCurrentDir(temp_workspace.path));
}
#[rstest]
fn decide_workspace_action_clones_when_empty(temp_workspace: TempWorkspace) {
let clone_dir = temp_workspace.path.join("clone_target");
let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true);
assert_eq!(action, WorkspaceAction::CloneTo(clone_dir));
}
#[rstest]
fn decide_workspace_action_updates_when_clone_exists(temp_workspace: TempWorkspace) {
let clone_dir = temp_workspace.path.join("clone_target");
fs::create_dir(&clone_dir).expect("failed to create clone dir");
let action = decide_workspace_action(&temp_workspace.path, &clone_dir, true);
assert_eq!(action, WorkspaceAction::UpdateAt(clone_dir));
}
#[rstest]
fn decide_workspace_action_uses_existing_when_no_update(temp_workspace: TempWorkspace) {
let clone_dir = temp_workspace.path.join("clone_target");
fs::create_dir(&clone_dir).expect("failed to create clone dir");
let action = decide_workspace_action(&temp_workspace.path, &clone_dir, false);
assert_eq!(action, WorkspaceAction::UseExisting(clone_dir));
}
fn mock_dirs_returning(data_dir: Option<PathBuf>) -> MockBaseDirs {
let mut mock = MockBaseDirs::new();
mock.expect_whitaker_data_dir().return_const(data_dir);
mock
}
#[rstest]
fn resolve_workspace_path_returns_clone_dir_when_not_in_workspace(
temp_workspace: TempWorkspace,
) {
let expected_dir = temp_workspace.path.join("data").join("whitaker");
let mock = mock_dirs_returning(Some(expected_dir.clone().into_std_path_buf()));
let result = resolve_workspace_path(&mock);
assert!(result.is_ok());
assert_eq!(result.unwrap(), expected_dir);
}
#[rstest]
fn resolve_workspace_path_errors_when_data_dir_unavailable(temp_workspace: TempWorkspace) {
let _ = temp_workspace; let mock = mock_dirs_returning(None);
let result = resolve_workspace_path(&mock);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, InstallerError::WorkspaceNotFound { .. }),
"expected WorkspaceNotFound error, got: {err:?}"
);
}
#[test]
fn clone_directory_returns_none_when_data_dir_unavailable() {
let mock = mock_dirs_returning(None);
assert!(clone_directory(&mock).is_none());
}
#[rstest]
fn clone_directory_returns_path_from_mock(temp_workspace: TempWorkspace) {
let expected = temp_workspace.path.join("data").join("whitaker");
let mock = mock_dirs_returning(Some(expected.clone().into_std_path_buf()));
assert_eq!(clone_directory(&mock), Some(expected));
}
fn write_workspace_cargo_toml(dir: &Utf8Path) {
fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]\n",
)
.expect("failed to write workspace Cargo.toml");
}
#[rstest]
fn find_workspace_root_finds_workspace_in_current_dir(temp_workspace: TempWorkspace) {
write_workspace_cargo_toml(&temp_workspace.path);
assert_eq!(
find_workspace_root(&temp_workspace.path).unwrap(),
temp_workspace.path
);
}
#[rstest]
fn find_workspace_root_finds_workspace_in_parent_dir(temp_workspace: TempWorkspace) {
write_workspace_cargo_toml(&temp_workspace.path);
let subdir = temp_workspace.path.join("crates").join("my_crate");
fs::create_dir_all(&subdir).expect("failed to create subdirs");
assert_eq!(find_workspace_root(&subdir).unwrap(), temp_workspace.path);
}
#[rstest]
fn find_workspace_root_errors_when_no_workspace_found(temp_workspace: TempWorkspace) {
write_cargo_toml(&temp_workspace.path, "not_a_workspace");
let result = find_workspace_root(&temp_workspace.path);
assert!(matches!(
result.unwrap_err(),
InstallerError::WorkspaceNotFound { .. }
));
}
}