Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ dist-ssr
/openvcs.plugins.local.json
/.sisyphus
/.omo
/Frontend/coverage
2 changes: 1 addition & 1 deletion Backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ default = []

[dependencies]
dotenvy = "0.15"
tauri = { version = "2.11", features = [] }
tauri = { version = "2.11", features = ["test"] }
tauri-plugin-opener = "2.5"
serde = { version = "1", features = ["derive"] }
notify = "8"
Expand Down
112 changes: 108 additions & 4 deletions Backend/src/app_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,99 @@
//! Channel-aware desktop identity and persistence paths.

use directories::ProjectDirs;
use std::path::{Path, PathBuf};

/// Holds the resolved project directory paths used by OpenVCS.
///
/// This avoids depending on the `directories::ProjectDirs` type in public
/// signatures, allowing test code to inject temporary paths for isolation.
#[derive(Clone, Debug)]
pub struct AppDirs {
config_dir: PathBuf,
data_dir: PathBuf,
}

impl AppDirs {
/// Returns the configuration directory path.
///
/// # Returns
/// - The config directory path.
pub fn config_dir(&self) -> &Path {
&self.config_dir
}

/// Returns the application data directory path.
///
/// # Returns
/// - The data directory path.
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
}

#[cfg(test)]
impl AppDirs {
pub fn new(config_dir: PathBuf, data_dir: PathBuf) -> Self {
Self {
config_dir,
data_dir,
}
}
}

// Thread-local per-test overrides; parallel tests do not conflict.
#[cfg(test)]
std::thread_local! {
static TEST_APP_DIRS: std::cell::RefCell<Option<AppDirs>> = const { std::cell::RefCell::new(None) };
}

/// Sets the test override for project directories.
///
/// # Parameters
/// - `dirs`: The `AppDirs` to return from [`project_dirs()`] in test builds.
#[cfg(test)]
pub(crate) fn set_test_app_dirs(dirs: AppDirs) {
TEST_APP_DIRS.with(|tls| *tls.borrow_mut() = Some(dirs));
}

/// Clears the test override for project directories.
///
/// After calling this, [`project_dirs()`] returns real paths again.
#[cfg(test)]
pub(crate) fn clear_test_app_dirs() {
TEST_APP_DIRS.with(|tls| *tls.borrow_mut() = None);
}

/// Guards against accidental test writes to real config/recents.
///
/// Called from `AppConfig::save()` and `save_recents_to_disk()` in
/// test builds. Panics with actionable guidance when no test override
/// is active so new tests cannot silently corrupt user data.
#[cfg(test)]
pub(crate) fn assert_test_isolation() {
let has_override = TEST_APP_DIRS.with(|tls| tls.borrow().is_some());
assert!(
has_override,
"test must use AppDirsGuard before writing to config/recents paths;\n\
add `let _guard = AppDirsGuard::new();` at the start of this test"
);
}

/// Installs temp directories as the app dirs override (leaked intentionally).
///
/// Call at the top of any `build_app*` helper whose `AppState` may
/// eventually trigger `set_config()` or `set_current_repo()`.
#[cfg(test)]
pub(crate) fn setup_test_isolation() {
let dir = tempfile::tempdir().expect("temp dir for test isolation");
let cfg_dir = dir.path().join("config");
let data_dir = dir.path().join("data");
// Drop dir immediately so no temp dir leaks. save() and
// save_recents_to_disk() both call create_dir_all before writing,
// so they recreate the paths on first use.
drop(dir);
set_test_app_dirs(AppDirs::new(cfg_dir, data_dir));
}

/// Returns the filesystem app name used for persistence.
///
Expand All @@ -21,11 +114,22 @@ pub fn persistence_name() -> &'static str {
/// All desktop channels preserve the legacy `OpenVCS` application name so
/// existing users keep the same config and data roots.
///
/// In test builds, returns the injected test paths when
/// [`set_test_app_dirs`] has been called.
///
/// # Returns
/// - `Some(ProjectDirs)` when the platform exposes standard app directories.
/// - `None` when no platform-specific directories are available.
pub fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("dev", "OpenVCS", persistence_name())
/// - `Some(AppDirs)` when the platform exposes standard app directories (or a test override is set).
/// - `None` when no platform-specific directories are available and no override is configured.
pub fn project_dirs() -> Option<AppDirs> {
#[cfg(test)]
if let Some(dirs) = TEST_APP_DIRS.with(|tls| tls.borrow().clone()) {
return Some(dirs);
}

ProjectDirs::from("dev", "OpenVCS", persistence_name()).map(|pd| AppDirs {
config_dir: pd.config_dir().to_path_buf(),
data_dir: pd.data_dir().to_path_buf(),
})
}

#[cfg(test)]
Expand Down
67 changes: 53 additions & 14 deletions Backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,43 @@ mod utilities;
mod validate;
mod workarounds;

/// Builds the development `.env` path relative to the backend crate manifest.
fn local_dotenv_path() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env")
}

/// Resolves the preferred backend from a configured default and available backend ids.
fn resolve_preferred_backend_id(
configured_default: &str,
available_backend_ids: &[BackendId],
) -> Option<BackendId> {
let desired = configured_default.trim();
if !desired.is_empty() {
let desired_backend = BackendId::from(desired.to_string());
if available_backend_ids
.iter()
.any(|backend| backend.as_ref() == desired_backend.as_ref())
{
return Some(desired_backend);
}
}

let mut backends = available_backend_ids.to_vec();
backends.sort_by(|left, right| left.as_ref().cmp(right.as_ref()));
backends.into_iter().next()
}

/// Returns the first recent repository path that still exists on disk.
fn first_existing_recent_repo(paths: &[std::path::PathBuf]) -> Option<std::path::PathBuf> {
paths.iter().find(|path| path.exists()).cloned()
}

/// Loads `Client/.env` for local development without overwriting existing env vars.
///
/// Missing .env file is silently ignored. Malformed or unreadable .env files
/// are reported with context for debugging before structured logging is ready.
fn load_local_dotenv() {
let dotenv_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env");
let dotenv_path = local_dotenv_path();

match dotenvy::from_path(&dotenv_path) {
Ok(_) => {}
Expand All @@ -67,20 +98,23 @@ fn load_local_dotenv() {
/// - `Some(BackendId)` when a backend is available.
/// - `None` otherwise.
fn preferred_vcs_backend_id(_cfg: &settings::AppConfig) -> Option<BackendId> {
let desired = _cfg.general.default_backend.trim().to_string();
if !desired.is_empty() {
let desired = BackendId::from(desired);
if crate::plugin_vcs_backends::has_plugin_vcs_backend(&desired) {
return Some(desired);
}
}

crate::plugin_vcs_backends::list_plugin_vcs_backends()
let available_backend_ids = crate::plugin_vcs_backends::list_plugin_vcs_backends()
.ok()
.and_then(|mut backends| {
backends.sort_by(|a, b| a.backend_id.as_ref().cmp(b.backend_id.as_ref()));
backends.into_iter().next().map(|b| b.backend_id)
.map(|backends| {
backends
.into_iter()
.map(|backend| backend.backend_id)
.collect::<Vec<_>>()
})
.unwrap_or_default();

let resolved =
resolve_preferred_backend_id(&_cfg.general.default_backend, &available_backend_ids)?;
if crate::plugin_vcs_backends::has_plugin_vcs_backend(&resolved) {
Some(resolved)
} else {
None
}
}

/// Attempt to reopen the most recent repository at startup if the
Expand All @@ -102,7 +136,7 @@ fn try_reopen_last_repo<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) {
}

let recents = state.recents();
if let Some(path) = recents.into_iter().find(|p| p.exists()) {
if let Some(path) = first_existing_recent_repo(&recents) {
let Some(backend) = preferred_vcs_backend_id(&app_config) else {
log::warn!("startup reopen: no VCS backend available");
return;
Expand Down Expand Up @@ -452,3 +486,8 @@ fn build_invoke_handler<R: tauri::Runtime>()
tauri_commands::check_for_updates,
]
}

#[cfg(test)]
mod tests {
include!("../tests/modules/lib.rs");
}
10 changes: 10 additions & 0 deletions Backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,13 @@
fn main() {
openvcs_lib::run()
}

#[cfg(test)]
mod tests {
/// Smoke test ensuring the binary entrypoint compiles and links.
#[test]
fn main_function_exists() {
// Verify the function signature is correct by referencing it
let _ = super::main;
}
}
2 changes: 1 addition & 1 deletion Backend/src/output_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use serde::{Deserialize, Serialize};

/// Severity level associated with an output log entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputLevel {
Info,
Expand Down
27 changes: 22 additions & 5 deletions Backend/src/plugin_runtime/host_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,29 @@
//! Minimal host-side plugin runtime APIs.

use parking_lot::RwLock;
use std::sync::OnceLock;
use std::sync::{Arc, OnceLock};

/// Callback type for status text updates from plugins to frontend.
type StatusEventEmitter = Box<dyn Fn(&str) + Send + Sync + 'static>;
type StatusEventEmitter = Arc<dyn Fn(&str) + Send + Sync + 'static>;

/// Global status emitter callback used by backend->frontend bridge.
static STATUS_EVENT_EMITTER: OnceLock<StatusEventEmitter> = OnceLock::new();
static STATUS_EVENT_EMITTER: OnceLock<RwLock<Option<StatusEventEmitter>>> = OnceLock::new();
/// Shared in-memory status text for plugin updates.
static STATUS_TEXT: OnceLock<RwLock<String>> = OnceLock::new();

/// Returns global status emitter storage.
fn status_event_emitter_store() -> &'static RwLock<Option<StatusEventEmitter>> {
STATUS_EVENT_EMITTER.get_or_init(|| RwLock::new(None))
}

/// Returns global status storage singleton.
fn status_text_store() -> &'static RwLock<String> {
STATUS_TEXT.get_or_init(|| RwLock::new(String::new()))
}

/// Emits a status text event through the configured backend emitter.
fn emit_status_event(message: &str) {
if let Some(emitter) = STATUS_EVENT_EMITTER.get() {
if let Some(emitter) = status_event_emitter_store().read().clone() {
emitter(message);
}
}
Expand All @@ -36,7 +41,14 @@ pub fn set_status_event_emitter<F>(emitter: F)
where
F: Fn(&str) + Send + Sync + 'static,
{
let _ = STATUS_EVENT_EMITTER.set(Box::new(emitter));
*status_event_emitter_store().write() = Some(Arc::new(emitter));
}

/// Resets host API state between tests.
#[cfg(test)]
pub fn reset_host_api_state_for_tests() {
*status_event_emitter_store().write() = None;
status_text_store().write().clear();
}

/// Sets status text without permission checks.
Expand All @@ -57,3 +69,8 @@ pub fn set_status_text_unchecked(message: &str) {
*status_text_store().write() = trimmed.to_string();
emit_status_event(trimmed);
}

#[cfg(test)]
mod tests {
include!("../../tests/plugin_runtime/host_api.rs");
}
38 changes: 38 additions & 0 deletions Backend/src/plugin_runtime/node_instance/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ use self::rpc::NodeRpcProcess;
const DEFAULT_RPC_TIMEOUT_SECS: u64 = 30;
const VCS_OPERATION_TIMEOUT_SECS: u64 = 60;

/// Test-only mock RPC handler type.
#[cfg(test)]
type MockRpcHandler = Box<dyn Fn(&str, Value) -> Result<Value, String> + Send>;

/// Parsed plugin initialize response payload.
#[derive(Debug, Deserialize)]
struct InitializeResponse {
Expand All @@ -49,6 +53,9 @@ pub struct NodePluginRuntimeInstance {
vcs_session_id: Mutex<Option<String>>,
/// Optional sink for VCS progress events.
event_sink: RwLock<Option<OnEvent>>,
/// Test-only RPC mock handler injected instead of a real process.
#[cfg(test)]
mock_rpc_handler: Mutex<Option<MockRpcHandler>>,
}

impl NodePluginRuntimeInstance {
Expand All @@ -65,9 +72,29 @@ impl NodePluginRuntimeInstance {
process: Mutex::new(None),
vcs_session_id: Mutex::new(None),
event_sink: RwLock::new(None),
#[cfg(test)]
mock_rpc_handler: Mutex::new(None),
}
}

/// Sets the VCS session id for testing without a real plugin process.
#[cfg(test)]
pub(crate) fn set_session_id(&self, id: Option<String>) {
*self.vcs_session_id.lock() = id;
}

/// Injects a pre-built process for testing the real RPC call path.
#[cfg(test)]
pub(crate) fn set_process(&self, process: NodeRpcProcess) {
*self.process.lock() = Some(process);
}

/// Installs a mock RPC handler for testing, bypassing the real process.
#[cfg(test)]
pub(crate) fn set_mock_handler(&self, handler: MockRpcHandler) {
*self.mock_rpc_handler.lock() = Some(handler);
}

/// Resolves the bundled Node executable path used to launch plugins.
///
/// # Returns
Expand Down Expand Up @@ -267,6 +294,12 @@ impl NodePluginRuntimeInstance {
where
T: DeserializeOwned,
{
#[cfg(test)]
if let Some(handler) = self.mock_rpc_handler.lock().as_ref() {
let result = handler(method, params)?;
return serde_json::from_value(result).map_err(|e| format!("mock rpc decode: {e}"));
}

let timeout = timeout_secs.or_else(|| {
if method.starts_with("vcs.") {
Some(VCS_OPERATION_TIMEOUT_SECS)
Expand Down Expand Up @@ -541,3 +574,8 @@ impl Drop for NodePluginRuntimeInstance {
}
}
}

#[cfg(test)]
mod tests {
include!("../../../tests/plugin_runtime/node_instance/mod.rs");
}
Loading
Loading