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(
68 company_name: impl Into<String>,
69 app_name: impl Into<String>,
70 ) -> Result<Self> {
71 let company_name = validate_identity_part("company_name", company_name.into())?;
72 let app_name = validate_identity_part("app_name", app_name.into())?;
73
74 Ok(Self {
75 app_dir_name: format!("{company_name}\\{app_name}"),
76 app_id: format!("{company_name}.{app_name}"),
77 company_name: Some(company_name),
78 app_name,
79 instance_scope: InstanceScope::CurrentSession,
80 })
81 }
82
83 pub fn instance_scope(mut self, scope: InstanceScope) -> Self {
85 self.instance_scope = scope;
86 self
87 }
88
89 pub fn company_name(&self) -> Option<&str> {
91 self.company_name.as_deref()
92 }
93
94 pub fn app_name(&self) -> &str {
96 &self.app_name
97 }
98
99 pub fn app_dir_name(&self) -> &str {
101 &self.app_dir_name
102 }
103
104 pub fn app_id(&self) -> &str {
106 &self.app_id
107 }
108
109 pub fn configured_instance_scope(&self) -> InstanceScope {
111 self.instance_scope
112 }
113
114 pub fn local_data_dir(&self) -> Result<PathBuf> {
116 local_app_data(&self.app_dir_name)
117 }
118
119 pub fn roaming_data_dir(&self) -> Result<PathBuf> {
121 roaming_app_data(&self.app_dir_name)
122 }
123
124 pub fn ensure_local_data_dir(&self) -> Result<PathBuf> {
126 ensure_local_app_data(&self.app_dir_name)
127 }
128
129 pub fn ensure_roaming_data_dir(&self) -> Result<PathBuf> {
131 ensure_roaming_app_data(&self.app_dir_name)
132 }
133
134 pub fn single_instance_options(&self) -> SingleInstanceOptions {
136 SingleInstanceOptions::new(self.app_id.clone()).scope(self.instance_scope)
137 }
138
139 pub fn single_instance(&self) -> Result<Option<InstanceGuard>> {
141 single_instance_with_options(&self.single_instance_options())
142 }
143}
144
145fn validate_identity_part(label: &'static str, value: String) -> Result<String> {
146 let trimmed = value.trim();
147
148 if trimmed.is_empty() {
149 return Err(Error::InvalidInput(match label {
150 "company_name" => "company_name cannot be empty",
151 _ => "app_name cannot be empty",
152 }));
153 }
154
155 if trimmed.contains('\0') {
156 return Err(Error::InvalidInput(match label {
157 "company_name" => "company_name cannot contain NUL bytes",
158 _ => "app_name cannot contain NUL bytes",
159 }));
160 }
161
162 if trimmed
163 .chars()
164 .any(|ch| matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*'))
165 {
166 return Err(Error::InvalidInput(match label {
167 "company_name" => "company_name contains invalid Windows file-name characters",
168 _ => "app_name contains invalid Windows file-name characters",
169 }));
170 }
171
172 Ok(trimmed.to_owned())
173}
174
175#[cfg(test)]
176mod tests {
177 use super::{validate_identity_part, DesktopApp};
178 use crate::InstanceScope;
179
180 #[test]
181 fn desktop_app_uses_app_name_for_dir_and_id() {
182 let app = DesktopApp::new("Demo App").unwrap();
183 assert_eq!(app.company_name(), None);
184 assert_eq!(app.app_name(), "Demo App");
185 assert_eq!(app.app_dir_name(), "Demo App");
186 assert_eq!(app.app_id(), "Demo App");
187 assert_eq!(
188 app.configured_instance_scope(),
189 InstanceScope::CurrentSession
190 );
191 }
192
193 #[test]
194 fn desktop_app_with_company_uses_nested_dir_and_dotted_id() {
195 let app = DesktopApp::with_company("Demo Company", "Demo App")
196 .unwrap()
197 .instance_scope(InstanceScope::Global);
198
199 assert_eq!(app.company_name(), Some("Demo Company"));
200 assert_eq!(app.app_dir_name(), "Demo Company\\Demo App");
201 assert_eq!(app.app_id(), "Demo Company.Demo App");
202 assert_eq!(app.configured_instance_scope(), InstanceScope::Global);
203 }
204
205 #[test]
206 fn validate_identity_rejects_empty_string() {
207 let result = validate_identity_part("app_name", " ".to_owned());
208 assert!(matches!(
209 result,
210 Err(crate::Error::InvalidInput("app_name cannot be empty"))
211 ));
212 }
213
214 #[test]
215 fn validate_identity_rejects_nul_bytes() {
216 let result = validate_identity_part("company_name", "Demo\0Company".to_owned());
217 assert!(matches!(
218 result,
219 Err(crate::Error::InvalidInput(
220 "company_name cannot contain NUL bytes"
221 ))
222 ));
223 }
224
225 #[test]
226 fn validate_identity_rejects_path_separators() {
227 let result = validate_identity_part("app_name", "Demo\\App".to_owned());
228 assert!(matches!(
229 result,
230 Err(crate::Error::InvalidInput(
231 "app_name contains invalid Windows file-name characters"
232 ))
233 ));
234 }
235}