tauri-plugin-phyto 0.1.32

Tauri plugin that exposes an HTTP automation server for Phyto end-to-end testing
Documentation
//! Build-script helpers for consumers of `tauri-plugin-phyto`.
//!
//! Tauri capabilities live on disk and are filesystem-scanned at build time —
//! there's no `#[cfg]` equivalent. `tauri-build` rejects any capability that
//! references an unregistered plugin, so the moment a consumer gates
//! `tauri-plugin-phyto` behind a Cargo feature their build flips between two
//! states:
//!
//! - feature **on**: `phyto:default` permission must be granted by a
//!   capability file under `capabilities/`.
//! - feature **off**: the same file must *not* exist, or `tauri-build`
//!   fails.
//!
//! Hand-rolling this dance in every consumer's `build.rs` is the boilerplate
//! [`sync_capability`] removes.
//!
//! ## Usage
//!
//! In your `src-tauri/build.rs`:
//!
//! ```ignore
//! fn main() {
//!     tauri_plugin_phyto::build::sync_capability();
//!     tauri_build::build();
//! }
//! ```
//!
//! And in `src-tauri/Cargo.toml`:
//!
//! ```toml
//! [features]
//! e2e = ["tauri-plugin-phyto"]
//!
//! [dependencies]
//! tauri-plugin-phyto = { version = "...", optional = true }
//!
//! [build-dependencies]
//! tauri-plugin-phyto = { version = "...", default-features = false, features = ["build"] }
//! ```
//!
//! With the defaults this writes (or removes) `capabilities/phyto.json`
//! based on whether `CARGO_FEATURE_E2E` is set. Override via
//! [`SyncCapability`] for non-default feature names, multi-window apps, or
//! a custom file name.

use std::env;
use std::fs;
use std::io;
use std::path::PathBuf;

/// Default Cargo feature name the helper gates on.
const DEFAULT_FEATURE: &str = "e2e";
/// Default file written under `capabilities/`.
const DEFAULT_FILE_NAME: &str = "phyto.json";
/// Default window label included in the generated capability.
const DEFAULT_WINDOW: &str = "main";
/// Capability identifier (the `identifier` field in the generated JSON).
const CAPABILITY_IDENTIFIER: &str = "phyto-e2e";
/// Capability description (the `description` field in the generated JSON).
const CAPABILITY_DESCRIPTION: &str =
    "Phyto E2E test plugin — enabled only under the e2e feature.";

/// Sync the Phyto capability file with the consumer's `e2e` Cargo feature.
///
/// Convenience wrapper around [`SyncCapability::new().run()`] using all
/// defaults: feature `e2e`, file `capabilities/phyto.json`, windows `["main"]`.
///
/// Panics on any I/O failure — build scripts have no useful recovery path,
/// and a silent failure here would only surface much later as a confusing
/// `tauri-build` error.
pub fn sync_capability() {
    SyncCapability::new().run();
}

/// Builder for [`sync_capability`] with overridable defaults.
///
/// Use this when the consumer's feature isn't named `e2e`, the app has
/// multiple Tauri windows, or you want to rename the capability file.
///
/// ```no_run
/// tauri_plugin_phyto::build::SyncCapability::new()
///     .feature("e2e-test")
///     .windows(["main", "settings"])
///     .run();
/// ```
#[derive(Debug, Clone)]
pub struct SyncCapability {
    feature: String,
    windows: Vec<String>,
    file_name: String,
}

impl SyncCapability {
    /// Construct a new builder with the defaults: feature `e2e`,
    /// windows `["main"]`, file name `phyto.json`.
    pub fn new() -> Self {
        Self {
            feature: DEFAULT_FEATURE.to_string(),
            windows: vec![DEFAULT_WINDOW.to_string()],
            file_name: DEFAULT_FILE_NAME.to_string(),
        }
    }

    /// Set the Cargo feature name that gates the capability. The helper
    /// looks at `CARGO_FEATURE_<NAME>` (uppercased, `-` → `_`).
    pub fn feature(mut self, feature: impl Into<String>) -> Self {
        self.feature = feature.into();
        self
    }

    /// Set the window labels the capability applies to. Defaults to
    /// `["main"]`, which is Tauri's default window label.
    pub fn windows<I, S>(mut self, windows: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.windows = windows.into_iter().map(Into::into).collect();
        self
    }

    /// Set the file name (under `capabilities/`) the helper manages.
    /// Defaults to `phyto.json`.
    pub fn file_name(mut self, name: impl Into<String>) -> Self {
        self.file_name = name.into();
        self
    }

    /// Execute the sync. Reads `CARGO_FEATURE_<feature>`; writes the JSON
    /// when set, deletes the file when not.
    ///
    /// Emits `cargo:rerun-if-env-changed` / `cargo:rerun-if-changed`
    /// directives so toggling the feature re-runs the helper.
    pub fn run(self) {
        let cargo_feature_var = format!(
            "CARGO_FEATURE_{}",
            self.feature.to_uppercase().replace('-', "_")
        );

        // Re-trigger the build script when the feature flips, and when
        // the target file changes on disk (so a hand edit gets noticed).
        println!("cargo:rerun-if-env-changed={}", cargo_feature_var);

        let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect(
            "tauri-plugin-phyto::build::sync_capability: CARGO_MANIFEST_DIR not set — \
             this helper must be called from a build script",
        );

        let capabilities_dir = PathBuf::from(&manifest_dir).join("capabilities");
        let target = capabilities_dir.join(&self.file_name);

        println!("cargo:rerun-if-changed={}", target.display());

        let feature_active = env::var(&cargo_feature_var).is_ok();

        if feature_active {
            if let Err(e) = fs::create_dir_all(&capabilities_dir) {
                panic!(
                    "tauri-plugin-phyto::build::sync_capability: failed to create {}: {}",
                    capabilities_dir.display(),
                    e
                );
            }

            let json = render_capability(&self.windows);

            if let Err(e) = fs::write(&target, json) {
                panic!(
                    "tauri-plugin-phyto::build::sync_capability: failed to write {}: {}",
                    target.display(),
                    e
                );
            }
        } else {
            match fs::remove_file(&target) {
                Ok(()) => {}
                Err(e) if e.kind() == io::ErrorKind::NotFound => {}
                Err(e) => panic!(
                    "tauri-plugin-phyto::build::sync_capability: failed to remove {}: {}",
                    target.display(),
                    e
                ),
            }
        }
    }
}

impl Default for SyncCapability {
    fn default() -> Self {
        Self::new()
    }
}

/// Render the capability JSON. Hand-rolled formatter so we don't have to
/// pull `serde_json` into the `build`-feature dependency graph — the shape
/// is fixed (only `windows` varies) and an O(10-line) format string is
/// easier to audit than another dep.
fn render_capability(windows: &[String]) -> String {
    let mut windows_arr = String::new();
    for (i, w) in windows.iter().enumerate() {
        if i > 0 {
            windows_arr.push_str(", ");
        }
        windows_arr.push('"');
        windows_arr.push_str(&escape_json(w));
        windows_arr.push('"');
    }

    format!(
        "{{\n  \"identifier\": \"{ident}\",\n  \"description\": \"{desc}\",\n  \"windows\": [{windows}],\n  \"permissions\": [\"phyto:default\"]\n}}\n",
        ident = CAPABILITY_IDENTIFIER,
        desc = escape_json(CAPABILITY_DESCRIPTION),
        windows = windows_arr,
    )
}

/// Minimal JSON string escape. Only escapes the characters JSON requires;
/// passes non-ASCII printable chars through verbatim (valid in JSON, and
/// matches what `serde_json` does by default).
fn escape_json(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => {
                out.push_str(&format!("\\u{:04x}", c as u32));
            }
            c => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_default_capability() {
        let json = render_capability(&["main".to_string()]);
        assert!(json.contains("\"identifier\": \"phyto-e2e\""));
        assert!(json.contains("\"windows\": [\"main\"]"));
        assert!(json.contains("\"permissions\": [\"phyto:default\"]"));
    }

    #[test]
    fn renders_multi_window_capability() {
        let json = render_capability(&["main".to_string(), "settings".to_string()]);
        assert!(json.contains("\"windows\": [\"main\", \"settings\"]"));
    }

    #[test]
    fn escapes_special_chars() {
        assert_eq!(escape_json("he said \"hi\""), r#"he said \"hi\""#);
        assert_eq!(escape_json("back\\slash"), r"back\\slash");
        assert_eq!(escape_json("line1\nline2"), r"line1\nline2");
    }

    #[test]
    fn escape_passes_through_non_ascii() {
        // The default description contains an em-dash.
        assert_eq!(escape_json("a — b"), "a — b");
    }
}