1use std::path::PathBuf;
4
5use crate::error::{Error, Result};
6use crate::instance::{
7 single_instance_with_options, InstanceGuard, InstanceScope, SingleInstanceOptions,
8};
9use crate::paths::{
10 ensure_local_app_data, ensure_roaming_app_data, local_app_data, roaming_app_data,
11};
12
13#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct DesktopApp {
32 company_name: Option<String>,
33 app_name: String,
34 app_dir_name: String,
35 app_id: String,
36 instance_scope: InstanceScope,
37}
38
39impl DesktopApp {
40 pub fn new(app_name: impl Into<String>) -> Result<Self> {
47 let app_name = validate_identity_part("app_name", app_name.into())?;
48
49 Ok(Self {
50 company_name: None,
51 app_dir_name: app_name.clone(),
52 app_id: app_name.clone(),
53 app_name,
54 instance_scope: InstanceScope::CurrentSession,
55 })
56 }
57
58 pub fn with_company(
80 company_name: impl Into<String>,
81 app_name: impl Into<String>,
82 ) -> Result<Self> {
83 let company_name = validate_identity_part("company_name", company_name.into())?;
84 let app_name = validate_identity_part("app_name", app_name.into())?;
85
86 Ok(Self {
87 app_dir_name: format!("{company_name}\\{app_name}"),
88 app_id: format!("{company_name}.{app_name}"),
89 company_name: Some(company_name),
90 app_name,
91 instance_scope: InstanceScope::CurrentSession,
92 })
93 }
94
95 pub fn instance_scope(mut self, scope: InstanceScope) -> Self {
110 self.instance_scope = scope;
111 self
112 }
113
114 pub fn company_name(&self) -> Option<&str> {
116 self.company_name.as_deref()
117 }
118
119 pub fn app_name(&self) -> &str {
121 &self.app_name
122 }
123
124 pub fn app_dir_name(&self) -> &str {
126 &self.app_dir_name
127 }
128
129 pub fn app_id(&self) -> &str {
131 &self.app_id
132 }
133
134 pub fn configured_instance_scope(&self) -> InstanceScope {
136 self.instance_scope
137 }
138
139 pub fn local_data_dir(&self) -> Result<PathBuf> {
141 local_app_data(&self.app_dir_name)
142 }
143
144 pub fn roaming_data_dir(&self) -> Result<PathBuf> {
146 roaming_app_data(&self.app_dir_name)
147 }
148
149 pub fn ensure_local_data_dir(&self) -> Result<PathBuf> {
151 ensure_local_app_data(&self.app_dir_name)
152 }
153
154 pub fn ensure_roaming_data_dir(&self) -> Result<PathBuf> {
156 ensure_roaming_app_data(&self.app_dir_name)
157 }
158
159 pub fn single_instance_options(&self) -> SingleInstanceOptions {
175 SingleInstanceOptions::new(self.app_id.clone()).scope(self.instance_scope)
176 }
177
178 pub fn single_instance(&self) -> Result<Option<InstanceGuard>> {
180 single_instance_with_options(&self.single_instance_options())
181 }
182}
183
184fn validate_identity_part(label: &'static str, value: String) -> Result<String> {
185 let trimmed = value.trim();
186
187 if trimmed.is_empty() {
188 return Err(Error::InvalidInput(match label {
189 "company_name" => "company_name cannot be empty",
190 _ => "app_name cannot be empty",
191 }));
192 }
193
194 if trimmed.contains('\0') {
195 return Err(Error::InvalidInput(match label {
196 "company_name" => "company_name cannot contain NUL bytes",
197 _ => "app_name cannot contain NUL bytes",
198 }));
199 }
200
201 if trimmed
202 .chars()
203 .any(|ch| matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'))
204 {
205 return Err(Error::InvalidInput(match label {
206 "company_name" => "company_name contains invalid Windows file-name characters",
207 _ => "app_name contains invalid Windows file-name characters",
208 }));
209 }
210
211 Ok(trimmed.to_owned())
212}
213
214#[cfg(test)]
215mod tests {
216 use super::{validate_identity_part, DesktopApp};
217 use crate::InstanceScope;
218
219 #[test]
220 fn desktop_app_uses_app_name_for_dir_and_id() {
221 let app = DesktopApp::new("Demo App").unwrap();
222 assert_eq!(app.company_name(), None);
223 assert_eq!(app.app_name(), "Demo App");
224 assert_eq!(app.app_dir_name(), "Demo App");
225 assert_eq!(app.app_id(), "Demo App");
226 assert_eq!(
227 app.configured_instance_scope(),
228 InstanceScope::CurrentSession
229 );
230 }
231
232 #[test]
233 fn desktop_app_with_company_uses_nested_dir_and_dotted_id() {
234 let app = DesktopApp::with_company("Demo Company", "Demo App")
235 .unwrap()
236 .instance_scope(InstanceScope::Global);
237
238 assert_eq!(app.company_name(), Some("Demo Company"));
239 assert_eq!(app.app_dir_name(), "Demo Company\\Demo App");
240 assert_eq!(app.app_id(), "Demo Company.Demo App");
241 assert_eq!(app.configured_instance_scope(), InstanceScope::Global);
242 }
243
244 #[test]
245 fn validate_identity_rejects_empty_string() {
246 let result = validate_identity_part("app_name", " ".to_owned());
247 assert!(matches!(
248 result,
249 Err(crate::Error::InvalidInput("app_name cannot be empty"))
250 ));
251 }
252
253 #[test]
254 fn validate_identity_rejects_nul_bytes() {
255 let result = validate_identity_part("company_name", "Demo\0Company".to_owned());
256 assert!(matches!(
257 result,
258 Err(crate::Error::InvalidInput(
259 "company_name cannot contain NUL bytes"
260 ))
261 ));
262 }
263
264 #[test]
265 fn validate_identity_rejects_path_separators() {
266 let result = validate_identity_part("app_name", "Demo\\App".to_owned());
267 assert!(matches!(
268 result,
269 Err(crate::Error::InvalidInput(
270 "app_name contains invalid Windows file-name characters"
271 ))
272 ));
273 }
274}