use std::path::PathBuf;
use crate::error::{Error, Result};
use crate::instance::{
single_instance_with_options, InstanceGuard, InstanceScope, SingleInstanceOptions,
};
use crate::paths::{
ensure_local_app_data, ensure_roaming_app_data, local_app_data, roaming_app_data,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DesktopApp {
company_name: Option<String>,
app_name: String,
app_dir_name: String,
app_id: String,
instance_scope: InstanceScope,
}
impl DesktopApp {
pub fn new(app_name: impl Into<String>) -> Result<Self> {
let app_name = validate_identity_part("app_name", app_name.into())?;
Ok(Self {
company_name: None,
app_dir_name: app_name.clone(),
app_id: app_name.clone(),
app_name,
instance_scope: InstanceScope::CurrentSession,
})
}
pub fn with_company(
company_name: impl Into<String>,
app_name: impl Into<String>,
) -> Result<Self> {
let company_name = validate_identity_part("company_name", company_name.into())?;
let app_name = validate_identity_part("app_name", app_name.into())?;
Ok(Self {
app_dir_name: format!("{company_name}\\{app_name}"),
app_id: format!("{company_name}.{app_name}"),
company_name: Some(company_name),
app_name,
instance_scope: InstanceScope::CurrentSession,
})
}
pub fn instance_scope(mut self, scope: InstanceScope) -> Self {
self.instance_scope = scope;
self
}
pub fn company_name(&self) -> Option<&str> {
self.company_name.as_deref()
}
pub fn app_name(&self) -> &str {
&self.app_name
}
pub fn app_dir_name(&self) -> &str {
&self.app_dir_name
}
pub fn app_id(&self) -> &str {
&self.app_id
}
pub fn configured_instance_scope(&self) -> InstanceScope {
self.instance_scope
}
pub fn local_data_dir(&self) -> Result<PathBuf> {
local_app_data(&self.app_dir_name)
}
pub fn roaming_data_dir(&self) -> Result<PathBuf> {
roaming_app_data(&self.app_dir_name)
}
pub fn ensure_local_data_dir(&self) -> Result<PathBuf> {
ensure_local_app_data(&self.app_dir_name)
}
pub fn ensure_roaming_data_dir(&self) -> Result<PathBuf> {
ensure_roaming_app_data(&self.app_dir_name)
}
pub fn single_instance_options(&self) -> SingleInstanceOptions {
SingleInstanceOptions::new(self.app_id.clone()).scope(self.instance_scope)
}
pub fn single_instance(&self) -> Result<Option<InstanceGuard>> {
single_instance_with_options(&self.single_instance_options())
}
}
fn validate_identity_part(label: &'static str, value: String) -> Result<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(Error::InvalidInput(match label {
"company_name" => "company_name cannot be empty",
_ => "app_name cannot be empty",
}));
}
if trimmed.contains('\0') {
return Err(Error::InvalidInput(match label {
"company_name" => "company_name cannot contain NUL bytes",
_ => "app_name cannot contain NUL bytes",
}));
}
if trimmed
.chars()
.any(|ch| matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'))
{
return Err(Error::InvalidInput(match label {
"company_name" => "company_name contains invalid Windows file-name characters",
_ => "app_name contains invalid Windows file-name characters",
}));
}
Ok(trimmed.to_owned())
}
#[cfg(test)]
mod tests {
use super::{validate_identity_part, DesktopApp};
use crate::InstanceScope;
#[test]
fn desktop_app_uses_app_name_for_dir_and_id() {
let app = DesktopApp::new("Demo App").unwrap();
assert_eq!(app.company_name(), None);
assert_eq!(app.app_name(), "Demo App");
assert_eq!(app.app_dir_name(), "Demo App");
assert_eq!(app.app_id(), "Demo App");
assert_eq!(
app.configured_instance_scope(),
InstanceScope::CurrentSession
);
}
#[test]
fn desktop_app_with_company_uses_nested_dir_and_dotted_id() {
let app = DesktopApp::with_company("Demo Company", "Demo App")
.unwrap()
.instance_scope(InstanceScope::Global);
assert_eq!(app.company_name(), Some("Demo Company"));
assert_eq!(app.app_dir_name(), "Demo Company\\Demo App");
assert_eq!(app.app_id(), "Demo Company.Demo App");
assert_eq!(app.configured_instance_scope(), InstanceScope::Global);
}
#[test]
fn validate_identity_rejects_empty_string() {
let result = validate_identity_part("app_name", " ".to_owned());
assert!(matches!(
result,
Err(crate::Error::InvalidInput("app_name cannot be empty"))
));
}
#[test]
fn validate_identity_rejects_nul_bytes() {
let result = validate_identity_part("company_name", "Demo\0Company".to_owned());
assert!(matches!(
result,
Err(crate::Error::InvalidInput(
"company_name cannot contain NUL bytes"
))
));
}
#[test]
fn validate_identity_rejects_path_separators() {
let result = validate_identity_part("app_name", "Demo\\App".to_owned());
assert!(matches!(
result,
Err(crate::Error::InvalidInput(
"app_name contains invalid Windows file-name characters"
))
));
}
}