Skip to main content

cranpose_capabilities/
lib.rs

1//! What an application asks of the device it runs on, written once in Rust.
2//!
3//! An application declares this in its build script:
4//!
5//! ```no_run
6//! use cranpose_capabilities::{Use, declare};
7//!
8//! declare(&[
9//!     Use::camera("Reads a receipt with the camera. Nothing leaves this device."),
10//!     Use::notifications(),
11//! ])
12//! .emit();
13//! ```
14//!
15//! From that one list the build writes the Android permissions and feature
16//! declarations, the Apple usage descriptions, and a constant the application
17//! itself reads at run time. A service is a function, so a name cannot be
18//! misspelled, and a service Apple wants a sentence for takes that sentence as
19//! an argument, so it cannot be forgotten.
20
21use std::{
22    collections::BTreeSet,
23    env,
24    ffi::OsString,
25    fmt::Write as _,
26    fs,
27    path::{Path, PathBuf},
28};
29
30/// Something an application uses that the device has to allow.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
32pub enum Service {
33    /// The camera.
34    Camera,
35    /// Reading the photo library.
36    PhotoLibrary,
37    /// Writing to the photo library.
38    PhotoLibraryAdd,
39    /// The microphone.
40    Microphone,
41    /// Where the device is.
42    Location,
43    /// Notifications the person sees.
44    Notifications,
45    /// Work that carries on with the application off screen.
46    Background,
47    /// Media playback that carries on with the application off screen.
48    Media,
49    /// Purchases through the platform store.
50    Billing,
51    /// A window drawn above other applications.
52    Overlay,
53    /// The vibrator.
54    Haptics,
55    /// Handing a downloaded package to the system installer.
56    Update,
57    /// The network, and whether the device is on one.
58    Network,
59}
60
61impl Service {
62    /// The name this service carries in the build's own files.
63    pub const fn name(self) -> &'static str {
64        match self {
65            Service::Camera => "camera",
66            Service::PhotoLibrary => "photo-library",
67            Service::PhotoLibraryAdd => "photo-library-add",
68            Service::Microphone => "microphone",
69            Service::Location => "location",
70            Service::Notifications => "notifications",
71            Service::Background => "background",
72            Service::Media => "media",
73            Service::Billing => "billing",
74            Service::Overlay => "overlay",
75            Service::Haptics => "haptics",
76            Service::Update => "update",
77            Service::Network => "network",
78        }
79    }
80
81    /// The Android permissions this service needs.
82    ///
83    /// Both photo library services are empty here: Android reads and writes
84    /// photos through the system picker, which asks the person for one file
85    /// and needs no permission from the application.
86    pub const fn android_permissions(self) -> &'static [&'static str] {
87        match self {
88            Service::Camera => &["android.permission.CAMERA"],
89            Service::PhotoLibrary => &[],
90            Service::PhotoLibraryAdd => &[],
91            Service::Microphone => &["android.permission.RECORD_AUDIO"],
92            Service::Location => &["android.permission.ACCESS_COARSE_LOCATION"],
93            Service::Notifications => &["android.permission.POST_NOTIFICATIONS"],
94            Service::Background => &[
95                "android.permission.FOREGROUND_SERVICE",
96                "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
97            ],
98            Service::Media => &[
99                "android.permission.FOREGROUND_SERVICE",
100                "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
101            ],
102            Service::Billing => &["com.android.vending.BILLING"],
103            Service::Overlay => &["android.permission.SYSTEM_ALERT_WINDOW"],
104            Service::Haptics => &["android.permission.VIBRATE"],
105            Service::Update => &["android.permission.REQUEST_INSTALL_PACKAGES"],
106            Service::Network => &[
107                "android.permission.INTERNET",
108                "android.permission.ACCESS_NETWORK_STATE",
109            ],
110        }
111    }
112
113    /// The key an Apple platform reads the sentence from.
114    pub const fn apple_key(self) -> Option<&'static str> {
115        match self {
116            Service::Camera => Some("NSCameraUsageDescription"),
117            Service::PhotoLibrary => Some("NSPhotoLibraryUsageDescription"),
118            Service::PhotoLibraryAdd => Some("NSPhotoLibraryAddUsageDescription"),
119            Service::Microphone => Some("NSMicrophoneUsageDescription"),
120            Service::Location => Some("NSLocationWhenInUseUsageDescription"),
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    /// Which service this is.
244    pub const fn service(self) -> Service {
245        self.service
246    }
247
248    /// The sentence the person is shown, for the services that have one.
249    pub const fn reason(self) -> Option<&'static str> {
250        self.reason
251    }
252}
253
254/// Hardware an application cannot run without.
255///
256/// Every other feature stays optional, so the application reaches devices
257/// that lack the hardware and asks the framework at run time.
258#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
259pub enum Demand {
260    /// A camera.
261    Camera,
262    /// A microphone.
263    Microphone,
264    /// A watch.
265    Watch,
266    /// Telephony.
267    Telephony,
268    /// Bluetooth.
269    Bluetooth,
270    /// Near-field communication.
271    Nfc,
272    /// Wi-Fi.
273    Wifi,
274    /// Location hardware.
275    Location,
276}
277
278impl Demand {
279    /// The Android feature name this demand becomes.
280    pub const fn android_feature(self) -> &'static str {
281        match self {
282            Demand::Camera => "android.hardware.camera",
283            Demand::Microphone => "android.hardware.microphone",
284            Demand::Watch => "android.hardware.type.watch",
285            Demand::Telephony => "android.hardware.telephony",
286            Demand::Bluetooth => "android.hardware.bluetooth",
287            Demand::Nfc => "android.hardware.nfc",
288            Demand::Wifi => "android.hardware.wifi",
289            Demand::Location => "android.hardware.location",
290        }
291    }
292
293    /// The name this demand carries in the build's own files.
294    pub const fn name(self) -> &'static str {
295        self.android_feature()
296    }
297}
298
299/// Everything one application asks of a device.
300#[derive(Clone, Copy, Debug, PartialEq, Eq)]
301pub struct Capabilities<'a> {
302    /// The services the application uses.
303    pub uses: &'a [Use],
304    /// The hardware the application cannot run without.
305    pub demands: &'a [Demand],
306}
307
308impl Capabilities<'_> {
309    /// An application that asks for nothing.
310    pub const NONE: Capabilities<'static> = Capabilities {
311        uses: &[],
312        demands: &[],
313    };
314
315    /// Whether the application declared this service.
316    pub fn has(&self, service: Service) -> bool {
317        self.uses.iter().any(|entry| entry.service == service)
318    }
319
320    /// The sentence declared for a service, if it has one.
321    pub fn reason_for(&self, service: Service) -> Option<&'static str> {
322        self.uses
323            .iter()
324            .find(|entry| entry.service == service)
325            .and_then(|entry| entry.reason())
326    }
327}
328
329/// What a build script declares, before [`Declaration::emit`] writes it out.
330#[must_use = "a declaration reaches the platform builds only through emit()"]
331#[derive(Clone, Copy, Debug)]
332pub struct Declaration<'a> {
333    capabilities: Capabilities<'a>,
334}
335
336/// Starts a declaration with the services an application uses.
337pub const fn declare(uses: &[Use]) -> Declaration<'_> {
338    Declaration {
339        capabilities: Capabilities { uses, demands: &[] },
340    }
341}
342
343impl<'a> Declaration<'a> {
344    /// Adds the hardware the application cannot run without.
345    pub const fn demanding(self, demands: &'a [Demand]) -> Declaration<'a> {
346        Declaration {
347            capabilities: Capabilities {
348                uses: self.capabilities.uses,
349                demands,
350            },
351        }
352    }
353
354    /// Writes the declaration where the application and every platform build
355    /// reads it.
356    ///
357    /// Call this from a build script. It panics when the files cannot be
358    /// written, which is what a build script does with a failure.
359    pub fn emit(self) {
360        let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set for a build script"));
361        let package = env::var("CARGO_PKG_NAME").expect("CARGO_PKG_NAME is set for a build script");
362        write_file(
363            &out.join("cranpose_capabilities.rs"),
364            &rust_source(&self.capabilities),
365        );
366
367        let crate_dir = PathBuf::from(
368            env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set for a build script"),
369        );
370        let shared = shared_dir(&crate_dir, env::var_os(CAPABILITIES_DIR));
371        fs::create_dir_all(&shared).expect("the shared capabilities directory");
372        let outputs = shared_outputs(&shared, &package);
373        let contents = [
374            json(&self.capabilities),
375            android_manifest(&self.capabilities),
376            apple_usage(&self.capabilities),
377        ];
378        for (path, text) in outputs.iter().zip(contents.iter()) {
379            write_file(path, text);
380        }
381        println!("cargo::rerun-if-changed=build.rs");
382        println!("cargo::rerun-if-env-changed={CAPABILITIES_DIR}");
383        for directive in rerun_directives(&outputs) {
384            println!("{directive}");
385        }
386    }
387}
388
389/// The files [`Declaration::emit`] writes outside `OUT_DIR`.
390///
391/// They go where every platform build reads them, which is also where anything
392/// that reclaims build artifacts can remove them.
393fn shared_outputs(shared: &Path, package: &str) -> [PathBuf; 3] {
394    [
395        shared.join(format!("{package}-capabilities.json")),
396        shared.join(format!("{package}-permissions.xml")),
397        shared.join(format!("{package}-usage.plist")),
398    ]
399}
400
401/// Tells cargo that these files are this build script's outputs.
402///
403/// Cargo does not know what a build script writes outside `OUT_DIR`, and a
404/// path named to `rerun-if-changed` counts as changed when it is missing. So
405/// naming them is what makes a deleted declaration come back: without it,
406/// cargo reads an unchanged `build.rs`, skips the script, and the tree keeps
407/// building without the permissions XML that carries SYSTEM_ALERT_WINDOW and
408/// VIBRATE into the merged Android manifest. The Android release APK then
409/// fails `cranposeReleaseManifestCheck` on every run in that workspace,
410/// because nothing will ever write the file again.
411fn rerun_directives(outputs: &[PathBuf]) -> Vec<String> {
412    outputs
413        .iter()
414        .map(|path| format!("cargo::rerun-if-changed={}", path.display()))
415        .collect()
416}
417
418fn write_file(path: &Path, text: &str) {
419    fs::write(path, text).unwrap_or_else(|error| panic!("writing {}: {error}", path.display()));
420}
421
422/// Names the directory the declaration is written into.
423///
424/// A platform build sets it, because the build knows the tree it drives. A
425/// plain `cargo build` does not, and the declaration then goes to
426/// `<workspace>/target/cranpose`.
427const CAPABILITIES_DIR: &str = "CRANPOSE_CAPABILITIES_DIR";
428
429/// Where the declaration goes: the directory the build named, or the one under
430/// the workspace this crate belongs to.
431fn shared_dir(crate_dir: &Path, named: Option<OsString>) -> PathBuf {
432    match named.filter(|value| !value.is_empty()) {
433        Some(value) => PathBuf::from(value),
434        None => workspace_root(crate_dir).join("target").join("cranpose"),
435    }
436}
437
438/// The workspace this crate belongs to, found from its own directory.
439///
440/// The platform builds read the declaration from `<workspace>/target/cranpose`,
441/// and they know the workspace because they already resolved this crate's
442/// source there. The directory Cargo happens to build into is not that place:
443/// it moves with `CARGO_TARGET_DIR`, and continuous integration sets it.
444fn workspace_root(crate_dir: &Path) -> PathBuf {
445    found_workspace(crate_dir, &|path| {
446        fs::read_to_string(path.join("Cargo.toml"))
447            .is_ok_and(|manifest| manifest.contains("[workspace]"))
448    })
449}
450
451fn found_workspace(crate_dir: &Path, holds_workspace: &dyn Fn(&Path) -> bool) -> PathBuf {
452    // The outermost workspace, so a crate inside a workspace that is itself
453    // vendored into another one still answers with the tree the build drives.
454    crate_dir
455        .ancestors()
456        .filter(|path| holds_workspace(path))
457        .last()
458        .map_or_else(|| crate_dir.to_path_buf(), Path::to_path_buf)
459}
460
461/// The Rust the application includes, so it reads the same declaration the
462/// platform builds do.
463fn rust_source(capabilities: &Capabilities<'_>) -> String {
464    let mut text = String::from(
465        "pub const CAPABILITIES: cranpose_capabilities::Capabilities =\n    \
466         cranpose_capabilities::Capabilities {\n        uses: &[\n",
467    );
468    for entry in capabilities.uses {
469        let call = constructor(entry);
470        let _ = writeln!(text, "            cranpose_capabilities::Use::{call},");
471    }
472    text.push_str("        ],\n        demands: &[\n");
473    for demand in capabilities.demands {
474        let _ = writeln!(
475            text,
476            "            cranpose_capabilities::Demand::{demand:?},"
477        );
478    }
479    text.push_str("        ],\n    };\n");
480    text
481}
482
483/// The call that rebuilds one entry, which is the service's own name with the
484/// dashes a Rust function cannot carry turned back into underscores.
485fn constructor(entry: &Use) -> String {
486    let name = entry.service.name().replace('-', "_");
487    match entry.reason {
488        Some(reason) => format!("{name}(\"{}\")", escape(reason)),
489        None => format!("{name}()"),
490    }
491}
492
493fn escape(text: &str) -> String {
494    text.replace('\\', "\\\\").replace('"', "\\\"")
495}
496
497/// The declaration as the platform builds read it.
498pub fn json(capabilities: &Capabilities<'_>) -> String {
499    let mut text = String::from("{\n  \"services\": [\n");
500    for (at, entry) in capabilities.uses.iter().enumerate() {
501        let comma = if at + 1 == capabilities.uses.len() {
502            ""
503        } else {
504            ","
505        };
506        let reason = match entry.reason {
507            Some(reason) => format!("\"{}\"", escape_json(reason)),
508            None => String::from("null"),
509        };
510        let _ = writeln!(
511            text,
512            "    {{ \"name\": \"{}\", \"reason\": {reason} }}{comma}",
513            entry.service.name()
514        );
515    }
516    text.push_str("  ],\n  \"permissions\": [\n");
517    let permissions = android_permissions(capabilities);
518    for (at, permission) in permissions.iter().enumerate() {
519        let comma = if at + 1 == permissions.len() { "" } else { "," };
520        let _ = writeln!(text, "    \"{permission}\"{comma}");
521    }
522    text.push_str("  ],\n  \"demands\": [\n");
523    for (at, demand) in capabilities.demands.iter().enumerate() {
524        let comma = if at + 1 == capabilities.demands.len() {
525            ""
526        } else {
527            ","
528        };
529        let _ = writeln!(text, "    \"{}\"{comma}", demand.android_feature());
530    }
531    text.push_str("  ]\n}\n");
532    text
533}
534
535fn escape_json(text: &str) -> String {
536    escape(text).replace('\n', "\\n")
537}
538
539/// Every Android permission the declared services need, in order and without
540/// repeats.
541pub fn android_permissions(capabilities: &Capabilities<'_>) -> Vec<&'static str> {
542    let mut named = BTreeSet::new();
543    for entry in capabilities.uses {
544        for permission in entry.service.android_permissions() {
545            named.insert(*permission);
546        }
547    }
548    named.into_iter().collect()
549}
550
551/// The Android manifest fragment the declaration becomes.
552pub fn android_manifest(capabilities: &Capabilities<'_>) -> String {
553    let mut text = String::from(
554        "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
555         <manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
556    );
557    for permission in android_permissions(capabilities) {
558        let _ = writeln!(
559            text,
560            "    <uses-permission android:name=\"{permission}\" />"
561        );
562    }
563    for demand in capabilities.demands {
564        let _ = writeln!(
565            text,
566            "    <uses-feature android:name=\"{}\" android:required=\"true\" />",
567            demand.android_feature()
568        );
569    }
570    text.push_str("</manifest>\n");
571    text
572}
573
574/// The Apple usage descriptions the declaration becomes, as a property list
575/// to merge into an `Info.plist`.
576///
577/// It is a property list of its own, so an Apple build merges it with one
578/// line and no tool beyond the ones macOS ships:
579///
580/// ```text
581/// /usr/libexec/PlistBuddy -c "Merge target/cranpose/my-app-usage.plist" MyApp.app/Info.plist
582/// ```
583pub fn apple_usage(capabilities: &Capabilities<'_>) -> String {
584    let mut text = String::from(
585        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
586         <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
587         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
588         <plist version=\"1.0\">\n<dict>\n",
589    );
590    for entry in capabilities.uses {
591        let (Some(key), Some(reason)) = (entry.service.apple_key(), entry.reason) else {
592            continue;
593        };
594        let _ = writeln!(text, "\t<key>{key}</key>");
595        let _ = writeln!(text, "\t<string>{}</string>", escape_xml(reason));
596    }
597    text.push_str("</dict>\n</plist>\n");
598    text
599}
600
601fn escape_xml(text: &str) -> String {
602    text.replace('&', "&amp;")
603        .replace('<', "&lt;")
604        .replace('>', "&gt;")
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    const READS: &str = "Reads a receipt with the camera.";
612
613    const SCANNER: Capabilities<'static> = Capabilities {
614        uses: &[Use::camera(READS), Use::notifications(), Use::billing()],
615        demands: &[],
616    };
617
618    #[test]
619    fn a_service_with_a_sentence_carries_it() {
620        assert_eq!(SCANNER.reason_for(Service::Camera), Some(READS));
621        assert_eq!(SCANNER.reason_for(Service::Notifications), None);
622        assert!(SCANNER.has(Service::Billing));
623        assert!(!SCANNER.has(Service::Haptics));
624    }
625
626    const BOTH_SERVICES: [Use; 2] = [Use::background(), Use::media()];
627    const ONE_HAPTIC: [Use; 1] = [Use::haptics()];
628    const WATCH: [Demand; 1] = [Demand::Watch];
629    const ONE_CAMERA: [Use; 1] = [Use::camera(READS)];
630    const CAMERA_HARDWARE: [Demand; 1] = [Demand::Camera];
631
632    #[test]
633    fn android_permissions_come_from_the_services_without_repeats() {
634        let both = Capabilities {
635            uses: &BOTH_SERVICES,
636            demands: &[],
637        };
638        assert_eq!(
639            android_permissions(&both),
640            vec![
641                "android.permission.FOREGROUND_SERVICE",
642                "android.permission.FOREGROUND_SERVICE_DATA_SYNC",
643                "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
644            ]
645        );
646    }
647
648    #[test]
649    fn the_manifest_names_the_permissions_and_the_demanded_hardware() {
650        let watch = Capabilities {
651            uses: &ONE_HAPTIC,
652            demands: &WATCH,
653        };
654        let text = android_manifest(&watch);
655        assert!(text.contains("<uses-permission android:name=\"android.permission.VIBRATE\" />"));
656        assert!(text.contains(
657            "<uses-feature android:name=\"android.hardware.type.watch\" android:required=\"true\" />"
658        ));
659    }
660
661    #[test]
662    fn the_build_names_where_the_declaration_goes() {
663        let named = shared_dir(
664            Path::new("/checkout/crates/app"),
665            Some(OsString::from("/checkout/target/cranpose")),
666        );
667        assert_eq!(named, PathBuf::from("/checkout/target/cranpose"));
668    }
669
670    #[test]
671    fn an_empty_name_is_no_name() {
672        let crate_dir = Path::new("/checkout/crates/app");
673        assert_eq!(
674            shared_dir(crate_dir, Some(OsString::new())),
675            shared_dir(crate_dir, None)
676        );
677    }
678
679    #[test]
680    fn apple_takes_only_the_services_it_shows_a_sentence_for() {
681        let text = apple_usage(&SCANNER);
682        assert!(text.starts_with("<?xml"));
683        assert!(text.contains("<key>NSCameraUsageDescription</key>"));
684        assert!(text.contains(READS));
685        assert!(text.ends_with("</dict>\n</plist>\n"));
686        assert!(!text.contains("Notification"));
687    }
688
689    #[test]
690    fn the_json_lists_services_permissions_and_demands() {
691        let text = json(&Capabilities {
692            uses: &ONE_CAMERA,
693            demands: &CAMERA_HARDWARE,
694        });
695        assert!(text.contains("\"name\": \"camera\""));
696        assert!(text.contains("\"android.permission.CAMERA\""));
697        assert!(text.contains("\"android.hardware.camera\""));
698    }
699
700    #[test]
701    fn the_generated_rust_rebuilds_the_same_declaration() {
702        let text = rust_source(&SCANNER);
703        assert!(text.contains("Use::camera(\"Reads a receipt with the camera.\")"));
704        assert!(text.contains("Use::notifications()"));
705        assert!(text.contains("demands: &[\n        ],"));
706    }
707
708    #[test]
709    fn a_quote_in_a_sentence_survives_the_generated_rust() {
710        let text = constructor(&Use::camera("Reads a \"receipt\"."));
711        assert_eq!(text, "camera(\"Reads a \\\"receipt\\\".\")");
712    }
713
714    #[test]
715    fn the_workspace_is_the_tree_the_platform_builds_know() {
716        let crate_dir = Path::new("/w/app");
717        let holds = |path: &Path| path == Path::new("/w");
718        assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
719    }
720
721    #[test]
722    fn a_workspace_inside_a_workspace_answers_with_the_outer_one() {
723        let crate_dir = Path::new("/w/vendor/thing/crates/one");
724        let holds = |path: &Path| path == Path::new("/w") || path == Path::new("/w/vendor/thing");
725        assert_eq!(found_workspace(crate_dir, &holds), Path::new("/w"));
726    }
727
728    #[test]
729    fn a_crate_in_no_workspace_answers_with_itself() {
730        let crate_dir = Path::new("/w/single");
731        assert_eq!(
732            found_workspace(crate_dir, &|_| false),
733            Path::new("/w/single")
734        );
735    }
736
737    #[test]
738    fn the_declaration_is_three_files_beside_the_generated_source() {
739        let outputs = shared_outputs(Path::new("/w/target/cranpose"), "desktop-app-platform");
740        let names: Vec<String> = outputs
741            .iter()
742            .map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
743            .collect();
744        assert_eq!(
745            names,
746            vec![
747                "desktop-app-platform-capabilities.json",
748                "desktop-app-platform-permissions.xml",
749                "desktop-app-platform-usage.plist",
750            ]
751        );
752    }
753
754    #[test]
755    fn every_file_written_outside_out_dir_is_named_to_cargo() {
756        let outputs = shared_outputs(Path::new("/w/target/cranpose"), "example");
757        let directives = rerun_directives(&outputs);
758        assert_eq!(
759            directives.len(),
760            outputs.len(),
761            "a file cargo is not told about is a file nothing will rewrite once it is gone"
762        );
763        for path in &outputs {
764            let expected = format!("cargo::rerun-if-changed={}", path.display());
765            assert!(
766                directives.contains(&expected),
767                "{} is written but never named to cargo: {directives:?}",
768                path.display()
769            );
770        }
771    }
772
773    /// The coupling is the guard: a file written outside [`shared_outputs`]
774    /// would not appear in [`rerun_directives`] either, and deleting it would
775    /// break the Android manifest check permanently in that workspace.
776    #[test]
777    fn emit_writes_the_shared_files_only_through_the_named_list() {
778        let source = include_str!("lib.rs");
779        let emit = source
780            .split_once("pub fn emit(self)")
781            .expect("emit is still a function")
782            .1
783            .split_once("\n}")
784            .expect("emit still ends")
785            .0;
786        for name in ["-capabilities.json", "-permissions.xml", "-usage.plist"] {
787            assert!(
788                !emit.contains(name),
789                "emit names {name} directly instead of going through shared_outputs, \
790                 so cargo is never told the file exists"
791            );
792        }
793        assert!(
794            emit.contains("shared_outputs(&shared, &package)")
795                && emit.contains("rerun_directives(&outputs)"),
796            "emit must write and announce the same list of files"
797        );
798    }
799}