Skip to main content

cranpose_capabilities/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{
4    collections::BTreeSet,
5    env,
6    ffi::OsString,
7    fmt::Write as _,
8    fs,
9    path::{Path, PathBuf},
10};
11
12/// Something an application uses that the device has to allow.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
14pub enum Service {
15    /// The camera.
16    Camera,
17    /// Reading the photo library.
18    PhotoLibrary,
19    /// Writing to the photo library.
20    PhotoLibraryAdd,
21    /// The microphone.
22    Microphone,
23    /// Where the device is.
24    Location,
25    /// Notifications the person sees.
26    Notifications,
27    /// Work that carries on with the application off screen.
28    Background,
29    /// Media playback that carries on with the application off screen.
30    Media,
31    /// Purchases through the platform store.
32    Billing,
33    /// A window drawn above other applications.
34    Overlay,
35    /// The vibrator.
36    Haptics,
37    /// Handing a downloaded package to the system installer.
38    Update,
39    /// The network, and whether the device is on one.
40    Network,
41    /// The wearer's heart rate, from the device's own sensor.
42    HeartRate,
43}
44
45impl Service {
46    /// The name this service carries in the build's own files.
47    pub const fn name(self) -> &'static str {
48        match self {
49            Service::Camera => "camera",
50            Service::PhotoLibrary => "photo-library",
51            Service::PhotoLibraryAdd => "photo-library-add",
52            Service::Microphone => "microphone",
53            Service::Location => "location",
54            Service::Notifications => "notifications",
55            Service::Background => "background",
56            Service::Media => "media",
57            Service::Billing => "billing",
58            Service::Overlay => "overlay",
59            Service::Haptics => "haptics",
60            Service::Update => "update",
61            Service::Network => "network",
62            Service::HeartRate => "heart-rate",
63        }
64    }
65
66    /// The Android permissions this service needs.
67    ///
68    /// Both photo library services are empty here: Android reads and writes
69    /// photos through the system picker, which asks the person for one file
70    /// and needs no permission from the application.
71    pub const fn android_permissions(self) -> &'static [&'static str] {
72        match self {
73            Service::Camera => &["android.permission.CAMERA"],
74            Service::PhotoLibrary => &[],
75            Service::PhotoLibraryAdd => &[],
76            Service::Microphone => &["android.permission.RECORD_AUDIO"],
77            Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
78            Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
79            Service::Background => &[
80                "android.permission.FOREGROUND_SERVICE",
81                "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
82            ],
83            Service::Media => &[
84                "android.permission.FOREGROUND_SERVICE",
85                "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
86            ],
87            Service::Billing => &["com.android.vending.BILLING"],
88            Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
89            Service::Haptics => &["android.permission.VIBRATE"],
90            Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
91            Service::Network => &[
92                "android.permission.INTERNET",
93                "android.permission.ACCESS_NETWORK_STATE",
94            ],
95            // Android 16 split the body sensors into the health
96            // permissions; older releases read the same sensor under
97            // BODY_SENSORS, which `android_permissions_up_to` carries.
98            Service::HeartRate => &["android.permission.health.READ_HEART_RATE"],
99        }
100    }
101
102    /// The Android permissions this service needs only up to an API level,
103    /// with that level: what a platform release replaced, declared with
104    /// `android:maxSdkVersion` so a newer device is never asked for it.
105    pub const fn android_permissions_up_to(self) -> &'static [(&'static str, u32)] {
106        match self {
107            Service::HeartRate => &[("android.permission.BODY_SENSORS", 35)],
108            _ => &[],
109        }
110    }
111
112    /// The key an Apple platform reads the sentence from.
113    pub const fn apple_key(self) -> Option<&'static str> {
114        match self {
115            Service::Camera => Some("NSCameraUsageDescription"),
116            Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
117            Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
118            Service::Microphone => Some("NSMicrophoneUsageDescription"),
119            Service::Location => Some("NSLocationWhenInUseUsageDescription"),
120            Service::HeartRate => Some("NSHealthShareUsageDescription"),
121            _ => None,
122        }
123    }
124
125    /// Whether this service takes a sentence to show the person.
126    pub const fn takes_reason(self) -> bool {
127        self.apple_key().is_some()
128    }
129}
130
131/// One service an application uses, with the sentence it shows if it needs one.
132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133pub struct Use {
134    service: Service,
135    reason: Option<&'static str>,
136}
137
138impl Use {
139    /// The camera, with what the person is told before it opens.
140    pub const fn camera(reason: &'static str) -> Self {
141        Self {
142            service: Service::Camera,
143            reason: Some(reason),
144        }
145    }
146
147    /// Reading the photo library, with what the person is told.
148    pub const fn photo_library(reason: &'static str) -> Self {
149        Self {
150            service: Service::PhotoLibrary,
151            reason: Some(reason),
152        }
153    }
154
155    /// Writing to the photo library, with what the person is told.
156    pub const fn photo_library_add(reason: &'static str) -> Self {
157        Self {
158            service: Service::PhotoLibraryAdd,
159            reason: Some(reason),
160        }
161    }
162
163    /// The microphone, with what the person is told.
164    pub const fn microphone(reason: &'static str) -> Self {
165        Self {
166            service: Service::Microphone,
167            reason: Some(reason),
168        }
169    }
170
171    /// Where the device is, with what the person is told.
172    pub const fn location(reason: &'static str) -> Self {
173        Self {
174            service: Service::Location,
175            reason: Some(reason),
176        }
177    }
178
179    /// Notifications the person sees.
180    pub const fn notifications() -> Self {
181        Self {
182            service: Service::Notifications,
183            reason: None,
184        }
185    }
186
187    /// Work that carries on with the application off screen.
188    pub const fn background() -> Self {
189        Self {
190            service: Service::Background,
191            reason: None,
192        }
193    }
194
195    /// Media playback that carries on with the application off screen.
196    pub const fn media() -> Self {
197        Self {
198            service: Service::Media,
199            reason: None,
200        }
201    }
202
203    /// Purchases through the platform store.
204    pub const fn billing() -> Self {
205        Self {
206            service: Service::Billing,
207            reason: None,
208        }
209    }
210
211    /// A window drawn above other applications.
212    pub const fn overlay() -> Self {
213        Self {
214            service: Service::Overlay,
215            reason: None,
216        }
217    }
218
219    /// The vibrator.
220    pub const fn haptics() -> Self {
221        Self {
222            service: Service::Haptics,
223            reason: None,
224        }
225    }
226
227    /// Handing a downloaded package to the system installer.
228    pub const fn update() -> Self {
229        Self {
230            service: Service::Update,
231            reason: None,
232        }
233    }
234
235    /// The network, and whether the device is on one.
236    pub const fn network() -> Self {
237        Self {
238            service: Service::Network,
239            reason: None,
240        }
241    }
242
243    /// The wearer's heart rate, with what the person is told before the
244    /// sensor is read. Declaring it is the only way an application's build
245    /// asks for the permission; reading it still takes an explicit request
246    /// at run time.
247    pub const fn heart_rate(reason: &'static str) -> Self {
248        Self {
249            service: Service::HeartRate,
250            reason: Some(reason),
251        }
252    }
253
254    /// Which service this is.
255    pub const fn service(self) -> Service {
256        self.service
257    }
258
259    /// The sentence the person is shown, for the services that have one.
260    pub const fn reason(self) -> Option<&'static str> {
261        self.reason
262    }
263}
264
265/// Hardware an application cannot run without.
266///
267/// Every other feature stays optional, so the application reaches devices
268/// that lack the hardware and asks the framework at run time.
269#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
270pub enum Demand {
271    /// A camera.
272    Camera,
273    /// A microphone.
274    Microphone,
275    /// A watch.
276    Watch,
277    /// Telephony.
278    Telephony,
279    /// Bluetooth.
280    Bluetooth,
281    /// Near-field communication.
282    Nfc,
283    /// Wi-Fi.
284    Wifi,
285    /// Location hardware.
286    Location,
287}
288
289impl Demand {
290    /// The Android feature name this demand becomes.
291    pub const fn android_feature(self) -> &'static str {
292        match self {
293            Demand::Camera => "android.hardware.camera",
294            Demand::Microphone => "android.hardware.microphone",
295            Demand::Watch => "android.hardware.type.watch",
296            Demand::Telephony => "android.hardware.telephony",
297            Demand::Bluetooth => "android.hardware.bluetooth",
298            Demand::Nfc => "android.hardware.nfc",
299            Demand::Wifi => "android.hardware.wifi",
300            Demand::Location => "android.hardware.location",
301        }
302    }
303
304    /// The name this demand carries in the build's own files.
305    pub const fn name(self) -> &'static str {
306        self.android_feature()
307    }
308}
309
310/// Everything one application asks of a device.
311#[derive(Clone, Copy, Debug, PartialEq, Eq)]
312pub struct Capabilities<'a> {
313    /// The services the application uses.
314    pub uses: &'a [Use],
315    /// The hardware the application cannot run without.
316    pub demands: &'a [Demand],
317    /// The media types of the files the application opens, such as
318    /// `"audio/*"` or `"application/pdf"`: the platform then offers it for
319    /// those files, in a share sheet and in "Open with", and hands what the
320    /// person picks to `cranpose_services::incoming_share`.
321    pub opens: &'a [&'a str],
322}
323
324impl Capabilities<'_> {
325    /// An application that asks for nothing.
326    pub const NONE: Capabilities<'static> = Capabilities {
327        uses: &[],
328        demands: &[],
329        opens: &[],
330    };
331
332    /// Whether the application declared this service.
333    pub fn has(&self, service: Service) -> bool {
334        self.uses.iter().any(|entry| entry.service == service)
335    }
336
337    /// The sentence declared for a service, if it has one.
338    pub fn reason_for(&self, service: Service) -> Option<&'static str> {
339        self.uses
340            .iter()
341            .find(|entry| entry.service == service)
342            .and_then(|entry| entry.reason())
343    }
344}
345
346/// What a build script declares, before [`Declaration::emit`] writes it out.
347#[must_use = "a declaration reaches the platform builds only through emit()"]
348#[derive(Clone, Copy, Debug)]
349pub struct Declaration<'a> {
350    capabilities: Capabilities<'a>,
351}
352
353/// Starts a declaration with the services an application uses.
354pub const fn declare(uses: &[Use]) -> Declaration<'_> {
355    Declaration {
356        capabilities: Capabilities {
357            uses,
358            demands: &[],
359            opens: &[],
360        },
361    }
362}
363
364impl<'a> Declaration<'a> {
365    /// Adds the hardware the application cannot run without.
366    pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
367        Declaration {
368            capabilities: Capabilities {
369                demands,
370                ..self.capabilities
371            },
372        }
373    }
374
375    /// Adds the media types of the files the application opens, such as
376    /// `"audio/*"`, so the platform offers it for them.
377    pub const fn opening(self, opens: &'a [&'a str]) -> Declaration<'a> {
378        Declaration {
379            capabilities: Capabilities {
380                opens,
381                ..self.capabilities
382            },
383        }
384    }
385
386    /// Writes the declaration where the application and every platform build
387    /// reads it.
388    ///
389    /// Call this from a build script. It panics when the files cannot be
390    /// written, which is what a build script does with a failure.
391    pub fn emit(self) {
392        let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
393        let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
394        write_file(
395            &out.join("cranpose_capabilities.rs"),
396            &rust_source(&self.capabilities),
397        );
398
399        let crate_dir = PathBuf::from(
400            env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
401        );
402        let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
403        fs::create_dir_all(&shared).expect("the shared capabilities directory");
404        let outputs = shared_outputs(&shared, &package);
405        let contents = [
406            json(&self.capabilities),
407            android_manifest(&self.capabilities),
408            apple_usage(&self.capabilities),
409        ];
410        for (path, text) in outputs.iter().zip(contents.iter()) {
411            write_file(path, text);
412        }
413        println!("cargo::rerun-if-changed=build.rs");
414        println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
415        for directive in rerun_directives(&outputs) {
416            println!("{directive}");
417        }
418    }
419}
420
421/// The files [`Declaration::emit`] writes outside `OUT_DIR`.
422///
423/// They go where every platform build reads them, which is also where anything
424/// that reclaims build artifacts can remove them.
425fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
426    [
427        shared.join(format!("{package}-capabilities.json")),
428        shared.join(format!("{package}-permissions.xml")),
429        shared.join(format!("{package}-usage.plist")),
430    ]
431}
432
433/// Tells cargo that these files are this build script's outputs.
434///
435/// Cargo does not know what a build script writes outside `OUT_DIR`, and a
436/// path named to `rerun-if-changed` counts as changed when it is missing. So
437/// naming them is what makes a deleted declaration come back: without it,
438/// cargo reads an unchanged `build.rs`, skips the script, and the tree keeps
439/// building without the permissions XML that carries SYSTEM_ALERT_WINDOW and
440/// VIBRATE into the merged Android manifest. The Android release APK then
441/// fails `cranposeReleaseManifestCheck` on every run in that workspace,
442/// because nothing will ever write the file again.
443fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
444    outputs
445        .iter()
446        .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
447        .collect()
448}
449
450fn write_file(path: &Path, text: &str) {
451    if fs::read(path).is_ok_and(|contents| contents == text.as_bytes()) {
452        return;
453    }
454    fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
455}
456
457/// Names the directory the declaration is written into.
458///
459/// A platform build sets it, because the build knows the tree it drives. A
460/// plain `cargo build` does not, and the declaration then goes to
461/// `<workspace>/target/cranpose`.
462const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
463
464/// Where the declaration goes: the directory the build named, or the one under
465/// the workspace this crate belongs to.
466fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
467    match named.filter(|value| !value.is_empty()) {
468        Some(value) => PathBuf::from(value),
469        None => workspace_root(crate_dir).join("target").join("cranpose"),
470    }
471}
472
473/// The workspace this crate belongs to, found from its own directory.
474///
475/// The platform builds read the declaration from `<workspace>/target/cranpose`,
476/// and they know the workspace because they already resolved this crate's
477/// source there. The directory Cargo happens to build into is not that place:
478/// it moves with `CARGO_TARGET_DIR`, and continuous integration sets it.
479fn workspace_root(crate_dir: &Path) -> PathBuf {
480    found_workspace(crate_dir, &|path| {
481        fs::read_to_string(path.join("Cargo.toml"))
482            .is_ok_and(|manifest| manifest.contains("[workspace]"))
483    })
484}
485
486fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
487    // The outermost workspace, so a crate inside a workspace that is itself
488    // vendored into another one still answers with the tree the build drives.
489    crate_dir
490        .ancestors()
491        .filter(|path| holds_workspace(path))
492        .last()
493        .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
494}
495
496/// The Rust the application includes, so it reads the same declaration the
497/// platform builds do.
498fn rust_source(capabilities: &Capabilities<'_>) -> String {
499    let mut text = String::from(
500        "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n    \
501         cranpose_capabilities::Capabilities {\n        uses: &[\n",
502    );
503    for entry in capabilities.uses {
504        let call = constructor(entry);
505        let _ = writeln!(text, "            cranpose_capabilities::Use::{call},");
506    }
507    text.push_str("        ],\n        demands: &[\n");
508    for demand in capabilities.demands {
509        let _ = writeln!(
510            text,
511            "            cranpose_capabilities::Demand::{demand:?},"
512        );
513    }
514    text.push_str("        ],\n        opens: &[\n");
515    for media_type in capabilities.opens {
516        let _ = writeln!(text, "            \"{}\",", escape(media_type));
517    }
518    text.push_str("        ],\n    };\n");
519    text
520}
521
522/// The call that rebuilds one entry, which is the service's own name with the
523/// dashes a Rust function cannot carry turned back into underscores.
524fn constructor(entry: &Use) -> String {
525    let name = entry.service.name().replace('-', "_");
526    match entry.reason {
527        Some(reason) => format!("{name}(\"{}\")", escape(reason)),
528        None => format!("{name}()"),
529    }
530}
531
532fn escape(text: &str) -> String {
533    text.replace('\\', "\\\\").replace('"', "\\\"")
534}
535
536/// The declaration as the platform builds read it.
537pub fn json(capabilities: &Capabilities<'_>) -> String {
538    let mut text = String::from("{\n  \"services\": [\n");
539    for (at, entry) in capabilities.uses.iter().enumerate() {
540        let comma = if at + 1 == capabilities.uses.len() {
541            ""
542        } else {
543            ","
544        };
545        let reason = match entry.reason {
546            Some(reason) => format!("\"{}\"", escape_json(reason)),
547            None => String::from("null"),
548        };
549        let _ = writeln!(
550            text,
551            "    {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
552            entry.service.name()
553        );
554    }
555    text.push_str("  ],\n  \"permissions\": [\n");
556    let permissions = android_permissions(capabilities);
557    for (at, permission) in permissions.iter().enumerate() {
558        let comma = if at + 1 == permissions.len() { "" } else { "," };
559        let _ = writeln!(text, "    \"{permission}\"{comma}");
560    }
561    text.push_str("  ],\n  \"permissionsUpTo\": [\n");
562    let capped = android_permissions_up_to(capabilities);
563    for (at, (permission, level)) in capped.iter().enumerate() {
564        let comma = if at + 1 == capped.len() { "" } else { "," };
565        let _ = writeln!(
566            text,
567            "    {{ \"name\": \"{permission}\", \"maxSdk\": {level} }}{comma}"
568        );
569    }
570    text.push_str("  ],\n  \"demands\": [\n");
571    for (at, demand) in capabilities.demands.iter().enumerate() {
572        let comma = if at + 1 == capabilities.demands.len() {
573            ""
574        } else {
575            ","
576        };
577        let _ = writeln!(text, "    \"{}\"{comma}", demand.android_feature());
578    }
579    text.push_str("  ],\n  \"opens\": [\n");
580    for (at, media_type) in capabilities.opens.iter().enumerate() {
581        let comma = if at + 1 == capabilities.opens.len() {
582            ""
583        } else {
584            ","
585        };
586        let _ = writeln!(text, "    \"{}\"{comma}", escape_json(media_type));
587    }
588    text.push_str("  ]\n}\n");
589    text
590}
591
592fn escape_json(text: &str) -> String {
593    escape(text).replace('\n', "\\n")
594}
595
596/// Every Android permission the declared services need, in order and without
597/// repeats.
598pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
599    let mut named = BTreeSet::new();
600    for entry in capabilities.uses {
601        for permission in entry.service.android_permissions() {
602            named.insert(*permission);
603        }
604    }
605    named.into_iter().collect()
606}
607
608/// The permissions the declaration needs only up to an API level, each with
609/// that level, without repeats and leaving out any the newer list already
610/// names.
611pub fn android_permissions_up_to(capabilities: &Capabilities<'_>) -> Vec<(&'static str, u32)> {
612    let plain = android_permissions(capabilities);
613    let mut named = std::collections::BTreeMap::new();
614    for entry in capabilities.uses {
615        for (permission, level) in entry.service.android_permissions_up_to() {
616            if !plain.contains(permission) {
617                named.insert(*permission, *level);
618            }
619        }
620    }
621    named.into_iter().collect()
622}
623
624/// The Android manifest fragment the declaration becomes.
625pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
626    let mut text = String::from(
627        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
628         <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
629    );
630    for permission in android_permissions(capabilities) {
631        let _ = writeln!(
632            text,
633            "    <uses-permission android:name=\"{permission}\" />"
634        );
635    }
636    for (permission, level) in android_permissions_up_to(capabilities) {
637        let _ = writeln!(
638            text,
639            "    <uses-permission android:name=\"{permission}\" android:maxSdkVersion=\"{level}\" />"
640        );
641    }
642    for demand in capabilities.demands {
643        let _ = writeln!(
644            text,
645            "    <uses-feature android:name=\"{}\" android:required=\"true\" />",
646            demand.android_feature()
647        );
648    }
649    if !capabilities.opens.is_empty() {
650        text.push_str(&android_opening_activity(capabilities.opens));
651    }
652    text.push_str("</manifest>\n");
653    text
654}
655
656/// The activity entry that offers the application for `opens`: sending a file
657/// to it and opening one with it.
658fn android_opening_activity(opens: &[&str]) -> String {
659    let mut text = String::from(
660        "    <application>\n        \
661         <activity android:name=\"dev.cranpose.android.CranposeActivity\">\n",
662    );
663    for actions in [
664        &[
665            "android.intent.action.SEND",
666            "android.intent.action.SEND_MULTIPLE",
667        ][..],
668        &["android.intent.action.VIEW"][..],
669    ] {
670        text.push_str("            <intent-filter>\n");
671        for action in actions {
672            let _ = writeln!(text, "                <action android:name=\"{action}\" />");
673        }
674        text.push_str(
675            "                <category android:name=\"android.intent.category.DEFAULT\" />\n",
676        );
677        for media_type in opens {
678            let _ = writeln!(
679                text,
680                "                <data android:mimeType=\"{}\" />",
681                escape_xml(media_type)
682            );
683        }
684        text.push_str("            </intent-filter>\n");
685    }
686    text.push_str("        </activity>\n    </application>\n");
687    text
688}
689
690/// The Apple usage descriptions the declaration becomes, as a property list
691/// to merge into an `Info.plist`.
692///
693/// It is a property list of its own, so an Apple build merges it with one
694/// line and no tool beyond the ones macOS ships:
695///
696/// ```text
697/// /usr/libexec/PlistBuddy -c "Merge target/cranpose/my-app-usage.plist" MyApp.app/Info.plist
698/// ```
699pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
700    let mut text = String::from(
701        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
702         <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
703         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
704         <plist version=\"1.0\">\n<dict>\n",
705    );
706    for entry in capabilities.uses {
707        let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
708            continue;
709        };
710        let _ = writeln!(text, "\t<key>{key}</key>");
711        let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
712    }
713    text.push_str("</dict>\n</plist>\n");
714    text
715}
716
717fn escape_xml(text: &str) -> String {
718    text.replace('&', "&amp;")
719        .replace('<', "&lt;")
720        .replace('>', "&gt;")
721}
722
723#[cfg(test)]
724#[path = "tests/capabilities_tests.rs"]
725mod tests;