ailloli_ui_core/
app_identity.rs

1//! Portable application identity shared by runtime and packaging.
2
3use crate::SvgSource;
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::fmt;
7
8/// Version of the runtime-to-packager metadata document.
9///
10/// # Examples
11///
12/// ```
13/// assert_eq!(ailloli_ui_core::APP_IDENTITY_METADATA_VERSION, 1);
14/// ```
15pub const APP_IDENTITY_METADATA_VERSION: u32 = 1;
16/// Source path resolved by [`app_icon!`](https://docs.rs/ailloli_ui/latest/ailloli_ui/macro.app_icon.html).
17///
18/// # Examples
19///
20/// ```
21/// assert_eq!(ailloli_ui_core::CONVENTIONAL_APP_ICON_PATH, "src/assets/icons/icon.svg");
22/// ```
23pub const CONVENTIONAL_APP_ICON_PATH: &str = "src/assets/icons/icon.svg";
24/// Environment variable used by `cargo-ailloli-ui` to request metadata without opening UI.
25///
26/// # Examples
27///
28/// ```
29/// assert_eq!(ailloli_ui_core::AILLOLI_UI_PACKAGE_METADATA_PATH_ENV, "AILLOLI_UI_PACKAGE_METADATA_PATH");
30/// ```
31pub const AILLOLI_UI_PACKAGE_METADATA_PATH_ENV: &str = "AILLOLI_UI_PACKAGE_METADATA_PATH";
32/// Legacy metadata-probe variable accepted when the Ailloli UI name is absent.
33///
34/// # Examples
35///
36/// ```
37/// assert_eq!(ailloli_ui_core::OCTAVUI_PACKAGE_METADATA_PATH_ENV, "OCTAVUI_PACKAGE_METADATA_PATH");
38/// ```
39#[doc(hidden)]
40pub const OCTAVUI_PACKAGE_METADATA_PATH_ENV: &str = "OCTAVUI_PACKAGE_METADATA_PATH";
41
42/// A validated, portable reverse-DNS application identifier.
43///
44/// # Examples
45///
46/// ```
47/// use ailloli_ui_core::ApplicationId;
48///
49/// let id = ApplicationId::parse("org.example.paint-app")?;
50/// assert_eq!(id.as_str(), "org.example.paint-app");
51/// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
52/// ```
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct ApplicationId(String);
56
57impl ApplicationId {
58    /// Parses an ASCII lowercase reverse-DNS identifier.
59    ///
60    /// A valid identifier contains at least three dot-separated components.
61    /// Every component starts with `a` through `z`, may then contain lowercase
62    /// ASCII letters, digits, or hyphens, and does not end in a hyphen. The
63    /// complete UTF-8 string is limited to 255 bytes.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`AppIdentityError::EmptyId`] for an empty string,
68    /// [`AppIdentityError::IdTooLong`] above 255 bytes, and
69    /// [`AppIdentityError::InvalidId`] for every other grammar violation.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use ailloli_ui_core::ApplicationId;
75    ///
76    /// let id = ApplicationId::parse("org.example.paint-app")?;
77    /// assert_eq!(id.as_str(), "org.example.paint-app");
78    /// assert!(ApplicationId::parse("Example_App").is_err());
79    /// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
80    /// ```
81    pub fn parse(value: impl Into<String>) -> Result<Self, AppIdentityError> {
82        let value = value.into();
83        validate_application_id(&value)?;
84        Ok(Self(value))
85    }
86
87    /// Returns the validated identifier exactly as supplied to [`Self::parse`].
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use ailloli_ui_core::ApplicationId;
93    /// let id = ApplicationId::parse("org.example.paint")?;
94    /// assert_eq!(id.as_str(), "org.example.paint");
95    /// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
96    /// ```
97    pub fn as_str(&self) -> &str {
98        &self.0
99    }
100}
101
102impl fmt::Display for ApplicationId {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(&self.0)
105    }
106}
107
108impl AsRef<str> for ApplicationId {
109    fn as_ref(&self) -> &str {
110        self.as_str()
111    }
112}
113
114/// Checks the byte limit and component grammar used by [`ApplicationId`].
115///
116/// # Errors
117///
118/// Returns [`AppIdentityError::EmptyId`] for empty input,
119/// [`AppIdentityError::IdTooLong`] above 255 bytes, or
120/// [`AppIdentityError::InvalidId`] for non-ASCII input, fewer than three dot
121/// components, or a component that violates the lowercase reverse-DNS grammar.
122fn validate_application_id(value: &str) -> Result<(), AppIdentityError> {
123    if value.is_empty() {
124        return Err(AppIdentityError::EmptyId);
125    }
126    if value.len() > 255 {
127        return Err(AppIdentityError::IdTooLong(value.len()));
128    }
129    if !value.is_ascii() {
130        return Err(AppIdentityError::InvalidId(value.to_owned()));
131    }
132    let parts: Vec<_> = value.split('.').collect();
133    if parts.len() < 3 {
134        return Err(AppIdentityError::InvalidId(value.to_owned()));
135    }
136    for part in parts {
137        let mut chars = part.chars();
138        if !matches!(chars.next(), Some('a'..='z'))
139            || !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
140            || part.ends_with('-')
141        {
142            return Err(AppIdentityError::InvalidId(value.to_owned()));
143        }
144    }
145    Ok(())
146}
147
148/// Embedded SVG used as an application or per-window icon.
149///
150/// # Examples
151///
152/// ```
153/// use ailloli_ui_core::AppIcon;
154///
155/// let icon = AppIcon::from_static_svg(b"<svg/>", "assets/icon.svg");
156/// assert_eq!(icon.bytes(), b"<svg/>");
157/// assert_eq!(icon.source_path(), "assets/icon.svg");
158/// ```
159#[derive(Debug, Clone)]
160pub struct AppIcon {
161    /// Owned or static SVG bytes retained for runtime decoding.
162    source: SvgSource,
163    /// Logical asset path reported in diagnostics and packaging metadata.
164    source_path: String,
165}
166
167impl AppIcon {
168    /// Creates an icon from compile-time embedded SVG bytes.
169    ///
170    /// `source_path` is diagnostic and packaging metadata; this function does
171    /// not read that path or validate the SVG document.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use ailloli_ui_core::AppIcon;
177    /// let icon = AppIcon::from_static_svg(b"<svg/>", "assets/icon.svg");
178    /// assert_eq!(icon.bytes(), b"<svg/>");
179    /// ```
180    pub fn from_static_svg(bytes: &'static [u8], source_path: impl Into<String>) -> Self {
181        Self {
182            source: SvgSource::Static(bytes),
183            source_path: source_path.into(),
184        }
185    }
186
187    /// Creates an icon from shared owned SVG bytes.
188    ///
189    /// This form is useful for per-window overrides and tooling that loads the
190    /// bytes dynamically. `source_path` remains descriptive: it is never read
191    /// and the SVG bytes are not validated here.
192    ///
193    /// # Examples
194    ///
195    /// ```
196    /// use ailloli_ui_core::AppIcon;
197    /// let icon = AppIcon::from_svg_bytes(Vec::from(&b"<svg/>"[..]), "generated.svg");
198    /// assert_eq!(icon.source_path(), "generated.svg");
199    /// ```
200    pub fn from_svg_bytes(
201        bytes: impl Into<std::sync::Arc<[u8]>>,
202        source_path: impl Into<String>,
203    ) -> Self {
204        Self {
205            source: SvgSource::Owned(bytes.into()),
206            source_path: source_path.into(),
207        }
208    }
209
210    /// Returns the borrowed SVG source consumed by widgets and rasterizers.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use ailloli_ui_core::{AppIcon, SvgSource};
216    /// let icon = AppIcon::from_static_svg(b"<svg/>", "icon.svg");
217    /// assert!(matches!(icon.source(), SvgSource::Static(_)));
218    /// ```
219    pub fn source(&self) -> &SvgSource {
220        &self.source
221    }
222
223    /// Returns the raw, unvalidated SVG bytes.
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use ailloli_ui_core::AppIcon;
229    /// assert_eq!(AppIcon::from_static_svg(b"abc", "icon.svg").bytes(), b"abc");
230    /// ```
231    pub fn bytes(&self) -> &[u8] {
232        self.source.as_bytes()
233    }
234
235    /// Returns the logical source path retained for diagnostics and packaging.
236    ///
237    /// # Examples
238    ///
239    /// ```
240    /// use ailloli_ui_core::AppIcon;
241    /// assert_eq!(AppIcon::from_static_svg(b"", "assets/icon.svg").source_path(), "assets/icon.svg");
242    /// ```
243    pub fn source_path(&self) -> &str {
244        &self.source_path
245    }
246
247    /// Computes the lowercase hexadecimal SHA-256 digest of the source bytes.
248    ///
249    /// The returned string always contains 64 ASCII hexadecimal characters and
250    /// does not incorporate [`Self::source_path`].
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use ailloli_ui_core::AppIcon;
256    /// assert_eq!(AppIcon::from_static_svg(b"", "icon.svg").sha256().len(), 64);
257    /// ```
258    pub fn sha256(&self) -> String {
259        let digest = Sha256::digest(self.bytes());
260        digest.iter().map(|byte| format!("{byte:02x}")).collect()
261    }
262}
263
264/// Builder-style application identity validated before a runtime is started.
265///
266/// An unset field is distinct from an explicitly empty string: both are
267/// rejected, but with different [`AppIdentityError`] variants. Builder calls
268/// replace the previous value.
269///
270/// # Examples
271///
272/// ```
273/// use ailloli_ui_core::{AppIcon, AppIdentity};
274///
275/// let identity = AppIdentity::new()
276///     .id("org.example.paint")
277///     .name("Paint")
278///     .icon(AppIcon::from_static_svg(b"<svg/>", "assets/icon.svg"));
279/// assert_eq!(identity.id_str(), Some("org.example.paint"));
280/// assert_eq!(identity.validate()?.name(), "Paint");
281/// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
282/// ```
283#[derive(Debug, Clone, Default)]
284pub struct AppIdentity {
285    /// Optional reverse-DNS identifier awaiting validation.
286    id: Option<String>,
287    /// Optional user-visible application name awaiting validation.
288    name: Option<String>,
289    /// Optional embedded SVG icon awaiting validation.
290    icon: Option<AppIcon>,
291}
292
293impl AppIdentity {
294    /// Creates an identity with all three required fields unset.
295    ///
296    /// # Examples
297    ///
298    /// ```
299    /// use ailloli_ui_core::AppIdentity;
300    /// assert_eq!(AppIdentity::new().id_str(), None);
301    /// ```
302    pub fn new() -> Self {
303        Self::default()
304    }
305
306    /// Sets the candidate reverse-DNS application identifier.
307    ///
308    /// Grammar validation is deferred to [`Self::validate`].
309    ///
310    /// # Examples
311    ///
312    /// ```
313    /// use ailloli_ui_core::AppIdentity;
314    /// assert_eq!(AppIdentity::new().id("org.example.paint").id_str(), Some("org.example.paint"));
315    /// ```
316    pub fn id(mut self, id: impl Into<String>) -> Self {
317        self.id = Some(id.into());
318        self
319    }
320
321    /// Sets the public application name, replacing any previous value.
322    ///
323    /// Surrounding whitespace is trimmed during [`Self::validate`].
324    ///
325    /// # Examples
326    ///
327    /// ```
328    /// use ailloli_ui_core::AppIdentity;
329    /// assert_eq!(AppIdentity::new().name(" Paint ").name_str(), Some(" Paint "));
330    /// ```
331    pub fn name(mut self, name: impl Into<String>) -> Self {
332        self.name = Some(name.into());
333        self
334    }
335
336    /// Sets the default application icon, replacing any previous value.
337    ///
338    /// # Examples
339    ///
340    /// ```
341    /// use ailloli_ui_core::{AppIcon, AppIdentity};
342    /// let identity = AppIdentity::new().icon(AppIcon::from_static_svg(b"<svg/>", "icon.svg"));
343    /// assert_eq!(identity.app_icon().unwrap().bytes(), b"<svg/>");
344    /// ```
345    pub fn icon(mut self, icon: AppIcon) -> Self {
346        self.icon = Some(icon);
347        self
348    }
349
350    /// Returns the unvalidated identifier, or `None` when [`Self::id`] was not called.
351    ///
352    /// # Examples
353    ///
354    /// ```
355    /// use ailloli_ui_core::AppIdentity;
356    /// assert_eq!(AppIdentity::new().id("org.example.paint").id_str(), Some("org.example.paint"));
357    /// ```
358    pub fn id_str(&self) -> Option<&str> {
359        self.id.as_deref()
360    }
361
362    /// Returns the unvalidated display name, including surrounding whitespace.
363    ///
364    /// # Examples
365    ///
366    /// ```
367    /// use ailloli_ui_core::AppIdentity;
368    /// assert_eq!(AppIdentity::new().name(" Paint ").name_str(), Some(" Paint "));
369    /// ```
370    pub fn name_str(&self) -> Option<&str> {
371        self.name.as_deref()
372    }
373
374    /// Returns the configured icon, or `None` when [`Self::icon`] was not called.
375    ///
376    /// # Examples
377    ///
378    /// ```
379    /// use ailloli_ui_core::AppIdentity;
380    /// assert!(AppIdentity::new().app_icon().is_none());
381    /// ```
382    pub fn app_icon(&self) -> Option<&AppIcon> {
383        self.icon.as_ref()
384    }
385
386    /// Validates all required fields and returns an independent owned identity.
387    ///
388    /// The display name is trimmed. The builder itself is only borrowed and
389    /// can be corrected or reused after a failed validation.
390    ///
391    /// # Errors
392    ///
393    /// Returns the corresponding `Missing*` variant for an unset field,
394    /// [`AppIdentityError::EmptyName`] for an all-whitespace name, or an
395    /// identifier validation error described by [`ApplicationId::parse`].
396    ///
397    /// # Examples
398    ///
399    /// ```
400    /// use ailloli_ui_core::{AppIcon, AppIdentity};
401    /// let identity = AppIdentity::new().id("org.example.paint").name(" Paint ")
402    ///     .icon(AppIcon::from_static_svg(b"<svg/>", "icon.svg"));
403    /// assert_eq!(identity.validate()?.name(), "Paint");
404    /// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
405    /// ```
406    pub fn validate(&self) -> Result<ValidatedAppIdentity, AppIdentityError> {
407        let id = self.id.clone().ok_or(AppIdentityError::MissingId)?;
408        let id = ApplicationId::parse(id)?;
409        let name = self
410            .name
411            .as_deref()
412            .ok_or(AppIdentityError::MissingName)?
413            .trim();
414        if name.is_empty() {
415            return Err(AppIdentityError::EmptyName);
416        }
417        let icon = self.icon.clone().ok_or(AppIdentityError::MissingIcon)?;
418        Ok(ValidatedAppIdentity {
419            id,
420            name: name.to_owned(),
421            icon,
422        })
423    }
424}
425
426/// Fully validated application identity used by runtime adapters.
427///
428/// # Examples
429///
430/// ```
431/// use ailloli_ui_core::{AppIcon, AppIdentity};
432///
433/// let validated = AppIdentity::new()
434///     .id("org.example.paint")
435///     .name("Paint")
436///     .icon(AppIcon::from_static_svg(b"<svg/>", "assets/icon.svg"))
437///     .validate()?;
438/// assert_eq!(validated.id().as_str(), "org.example.paint");
439/// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
440/// ```
441#[derive(Debug, Clone)]
442pub struct ValidatedAppIdentity {
443    /// Validated reverse-DNS identifier suitable for native adapters.
444    id: ApplicationId,
445    /// Non-empty user-visible application name.
446    name: String,
447    /// Validated embedded SVG icon and its diagnostic source path.
448    icon: AppIcon,
449}
450
451impl ValidatedAppIdentity {
452    /// Returns the validated reverse-DNS application identifier.
453    ///
454    /// # Examples
455    ///
456    /// ```
457    /// use ailloli_ui_core::{AppIcon, AppIdentity};
458    /// let identity = AppIdentity::new().id("org.example.paint").name("Paint")
459    ///     .icon(AppIcon::from_static_svg(b"<svg/>", "icon.svg")).validate()?;
460    /// assert_eq!(identity.id().as_str(), "org.example.paint");
461    /// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
462    /// ```
463    pub fn id(&self) -> &ApplicationId {
464        &self.id
465    }
466
467    /// Returns the trimmed, non-empty public display name.
468    ///
469    /// # Examples
470    ///
471    /// ```
472    /// use ailloli_ui_core::{AppIcon, AppIdentity};
473    /// let identity = AppIdentity::new().id("org.example.paint").name(" Paint ")
474    ///     .icon(AppIcon::from_static_svg(b"<svg/>", "icon.svg")).validate()?;
475    /// assert_eq!(identity.name(), "Paint");
476    /// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
477    /// ```
478    pub fn name(&self) -> &str {
479        &self.name
480    }
481
482    /// Returns the default icon and its original diagnostic source path.
483    ///
484    /// # Examples
485    ///
486    /// ```
487    /// use ailloli_ui_core::{AppIcon, AppIdentity};
488    /// let identity = AppIdentity::new().id("org.example.paint").name("Paint")
489    ///     .icon(AppIcon::from_static_svg(b"<svg/>", "icon.svg")).validate()?;
490    /// assert_eq!(identity.icon().source_path(), "icon.svg");
491    /// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
492    /// ```
493    pub fn icon(&self) -> &AppIcon {
494        &self.icon
495    }
496
497    /// Builds the versioned metadata contract written for `cargo-ailloli-ui`.
498    ///
499    /// The icon digest is computed on every call from its SVG bytes.
500    ///
501    /// # Examples
502    ///
503    /// ```
504    /// use ailloli_ui_core::{AppIcon, AppIdentity, APP_IDENTITY_METADATA_VERSION};
505    /// let identity = AppIdentity::new().id("org.example.paint").name("Paint")
506    ///     .icon(AppIcon::from_static_svg(b"<svg/>", "icon.svg")).validate()?;
507    /// assert_eq!(identity.metadata().schema_version, APP_IDENTITY_METADATA_VERSION);
508    /// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
509    /// ```
510    pub fn metadata(&self) -> AppIdentityMetadata {
511        AppIdentityMetadata {
512            schema_version: APP_IDENTITY_METADATA_VERSION,
513            application_id: self.id.clone(),
514            display_name: self.name.clone(),
515            icon: AppIconMetadata {
516                conventional_path: self.icon.source_path().to_owned(),
517                sha256: self.icon.sha256(),
518            },
519        }
520    }
521}
522
523/// Runtime metadata consumed by `cargo-ailloli-ui`.
524///
525/// # Examples
526///
527/// ```
528/// use ailloli_ui_core::{
529///     AppIconMetadata, AppIdentityMetadata, ApplicationId,
530///     APP_IDENTITY_METADATA_VERSION,
531/// };
532///
533/// let metadata = AppIdentityMetadata {
534///     schema_version: APP_IDENTITY_METADATA_VERSION,
535///     application_id: ApplicationId::parse("org.example.paint")?,
536///     display_name: "Paint".into(),
537///     icon: AppIconMetadata {
538///         conventional_path: "assets/icon.svg".into(),
539///         sha256: "0".repeat(64),
540///     },
541/// };
542/// assert_eq!(metadata.schema_version, 1);
543/// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
544/// ```
545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
546pub struct AppIdentityMetadata {
547    /// Metadata schema version; currently [`APP_IDENTITY_METADATA_VERSION`].
548    pub schema_version: u32,
549    /// Validated reverse-DNS application identifier.
550    pub application_id: ApplicationId,
551    /// Trimmed public application name.
552    pub display_name: String,
553    /// Conventional icon path and content digest.
554    pub icon: AppIconMetadata,
555}
556
557/// Icon identity embedded in [`AppIdentityMetadata`].
558///
559/// # Examples
560///
561/// ```
562/// use ailloli_ui_core::AppIconMetadata;
563///
564/// let icon = AppIconMetadata {
565///     conventional_path: "assets/icon.svg".into(),
566///     sha256: "a".repeat(64),
567/// };
568/// assert_eq!(icon.sha256.len(), 64);
569/// ```
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
571pub struct AppIconMetadata {
572    /// Consumer-relative logical path recorded for packaging diagnostics.
573    pub conventional_path: String,
574    /// Lowercase 64-character SHA-256 digest of the embedded SVG bytes.
575    pub sha256: String,
576}
577
578/// Validation errors for [`AppIdentity`] and [`ApplicationId`].
579///
580/// # Examples
581///
582/// ```
583/// use ailloli_ui_core::{AppIdentityError, ApplicationId};
584///
585/// assert_eq!(
586///     ApplicationId::parse("").unwrap_err(),
587///     AppIdentityError::EmptyId,
588/// );
589/// ```
590#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
591pub enum AppIdentityError {
592    /// No identifier was supplied with [`AppIdentity::id`].
593    #[error("application identity is missing `.id(...)`")]
594    MissingId,
595    /// No display name was supplied with [`AppIdentity::name`].
596    #[error("application identity is missing `.name(...)`")]
597    MissingName,
598    /// No icon was supplied with [`AppIdentity::icon`].
599    #[error("application identity is missing `.icon(...)`")]
600    MissingIcon,
601    /// The supplied identifier is an empty string.
602    #[error("application id must not be empty")]
603    EmptyId,
604    /// The identifier exceeds 255 bytes; the payload is its actual byte length.
605    #[error("application id is too long ({0} bytes, maximum 255)")]
606    IdTooLong(usize),
607    /// The payload does not satisfy the lowercase reverse-DNS grammar.
608    #[error("invalid application id `{0}`; expected lowercase reverse-DNS components")]
609    InvalidId(String),
610    /// The supplied display name is empty after trimming whitespace.
611    #[error("application name must not be empty")]
612    EmptyName,
613}
614
615#[cfg(test)]
616mod tests {
617    //! Validates identity grammar, required builder fields, and metadata round trips.
618
619    use super::*;
620
621    const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#;
622
623    #[test]
624    fn validates_complete_identity_and_metadata_v1() {
625        let identity = AppIdentity::new()
626            .id("org.example.sample-app")
627            .name("Sample App")
628            .icon(AppIcon::from_static_svg(SVG, CONVENTIONAL_APP_ICON_PATH))
629            .validate()
630            .unwrap();
631        let metadata = identity.metadata();
632        assert_eq!(metadata.schema_version, 1);
633        assert_eq!(metadata.application_id.as_str(), "org.example.sample-app");
634        assert_eq!(metadata.display_name, "Sample App");
635        assert_eq!(metadata.icon.sha256.len(), 64);
636        let roundtrip: AppIdentityMetadata =
637            serde_json::from_str(&serde_json::to_string(&metadata).unwrap()).unwrap();
638        assert_eq!(roundtrip, metadata);
639    }
640
641    #[test]
642    fn rejects_non_portable_ids() {
643        for id in [
644            "org.example",
645            "Org.example.app",
646            "org.example.my_app",
647            "org..app",
648            "org.example.-app",
649            "org.example.app-",
650            "org.exämple.app",
651            "../org.example.app",
652        ] {
653            assert!(ApplicationId::parse(id).is_err(), "accepted {id}");
654        }
655    }
656
657    #[test]
658    fn reports_each_incomplete_identity() {
659        assert_eq!(
660            AppIdentity::new().validate().unwrap_err(),
661            AppIdentityError::MissingId
662        );
663        assert_eq!(
664            AppIdentity::new()
665                .id("org.example.app")
666                .validate()
667                .unwrap_err(),
668            AppIdentityError::MissingName
669        );
670        assert_eq!(
671            AppIdentity::new()
672                .id("org.example.app")
673                .name("Example")
674                .validate()
675                .unwrap_err(),
676            AppIdentityError::MissingIcon
677        );
678    }
679}