├── .github ├── dependabot.yml └── workflows │ └── rust.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE.md ├── NOTICE.md ├── README.md ├── rustfmt.toml ├── sapphire-cli ├── Cargo.toml └── src │ ├── cli.rs │ ├── cli │ ├── info.rs │ ├── install.rs │ ├── search.rs │ ├── uninstall.rs │ └── update.rs │ ├── main.rs │ └── ui.rs └── sapphire-core ├── Cargo.toml └── src ├── build ├── cask │ ├── artifacts │ │ ├── app.rs │ │ ├── audio_unit_plugin.rs │ │ ├── binary.rs │ │ ├── colorpicker.rs │ │ ├── dictionary.rs │ │ ├── font.rs │ │ ├── input_method.rs │ │ ├── installer.rs │ │ ├── internet_plugin.rs │ │ ├── keyboard_layout.rs │ │ ├── manpage.rs │ │ ├── mdimporter.rs │ │ ├── mod.rs │ │ ├── pkg.rs │ │ ├── preflight.rs │ │ ├── prefpane.rs │ │ ├── qlplugin.rs │ │ ├── screen_saver.rs │ │ ├── service.rs │ │ ├── suite.rs │ │ ├── uninstall.rs │ │ ├── vst3_plugin.rs │ │ ├── vst_plugin.rs │ │ └── zap.rs │ ├── dmg.rs │ └── mod.rs ├── devtools.rs ├── env.rs ├── extract.rs ├── formula │ ├── bottle.rs │ ├── link.rs │ ├── macho.rs │ ├── mod.rs │ └── source │ │ ├── cargo.rs │ │ ├── cmake.rs │ │ ├── go.rs │ │ ├── make.rs │ │ ├── meson.rs │ │ ├── mod.rs │ │ ├── perl.rs │ │ └── python.rs └── mod.rs ├── dependency ├── definition.rs ├── mod.rs ├── requirement.rs └── resolver.rs ├── fetch ├── api.rs ├── http.rs ├── mod.rs └── oci.rs ├── formulary.rs ├── keg.rs ├── lib.rs ├── model ├── cask.rs ├── formula.rs ├── mod.rs └── version.rs ├── tap ├── definition.rs └── mod.rs └── utils ├── cache.rs ├── config.rs ├── error.rs └── mod.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | groups: 8 | cargo: 9 | patterns: 10 | - "*" 11 | 12 | - package-ecosystem: github-actions 13 | directory: / 14 | schedule: 15 | interval: weekly 16 | groups: 17 | github-actions: 18 | patterns: 19 | - "*" 20 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | rust-macos-arm64: 16 | runs-on: macos-latest 17 | 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | 22 | - name: Install Rust toolchain (Nightly for fmt) 23 | uses: actions-rs/toolchain@v1 24 | with: 25 | toolchain: nightly 26 | components: rustfmt, clippy 27 | override: true 28 | 29 | - name: Set Stable as Default (MSRV 1.86.0) 30 | if: success() 31 | uses: actions-rs/toolchain@v1 32 | with: 33 | toolchain: 1.86.0 34 | components: clippy 35 | 36 | - name: Cache cargo registry & build artifacts 37 | uses: actions/cache@v4 38 | with: 39 | path: | 40 | ~/.cargo/registry 41 | ~/.cargo/git 42 | target 43 | key: ${{ runner.os }}-cargo-stable-${{ hashFiles('**/Cargo.lock') }} 44 | restore-keys: | 45 | ${{ runner.os }}-cargo-stable- 46 | ${{ runner.os }}-cargo- 47 | 48 | - name: Verify runner architecture 49 | run: 'echo "UNAME reports: $(uname -m)"' 50 | 51 | - name: Check formatting 52 | run: cargo +nightly fmt --all -- --check 53 | 54 | - name: Run linters 55 | run: cargo clippy -- -D warnings 56 | 57 | - name: Build 58 | run: cargo build --verbose 59 | 60 | - name: Run tests 61 | run: cargo test --verbose 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | .DS_STORE 4 | NOTES.md 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Sapphire 2 | 3 | > We love merge requests! This guide shows the fastest path from **idea** to **merged code**. Skip straight to the *Quick‑Start* if you just want to get going, or dive into the details below. 4 | 5 | --- 6 | 7 | ## ⏩ Quick‑Start 8 | 9 | ### 1. Fork, clone & branch 10 | ```bash 11 | git clone https://github.com//sapphire.git 12 | cd sapphire 13 | git checkout -b feat/ 14 | ``` 15 | 16 | ### 2. Install Nightly Toolchain (for formatting) 17 | ```bash 18 | rustup toolchain install nightly 19 | ``` 20 | 21 | ### 3. Compile fast (uses stable toolchain from rust-toolchain.toml) 22 | ```bash 23 | cargo check --workspace --all-targets 24 | ``` 25 | 26 | ### 4. Format (uses nightly toolchain) 27 | ```bash 28 | cargo +nightly fmt --all 29 | ``` 30 | 31 | ### 5. Lint (uses stable toolchain) 32 | ```bash 33 | cargo clippy --workspace --all-targets --all-features -- -D warnings 34 | ``` 35 | 36 | ### 6. Test (uses stable toolchain) 37 | ```bash 38 | cargo test --workspace 39 | ``` 40 | 41 | ### 7. Commit (Conventional + DCO) 42 | ```bash 43 | git commit -s -m "feat(core): add new fetcher" 44 | ``` 45 | 46 | ### 8. Push & open a Merge Request against `main` 47 | ```bash 48 | git push origin feat/ 49 | # then open a merge request on GitHub 50 | ``` 51 | 52 | ----- 53 | 54 | ## Project Layout 55 | 56 | | Crate | Role | 57 | | ------------------- | -------------------------------------------------------- | 58 | | **`sapphire-core`** | Library: dependency resolution, fetchers, install logic | 59 | | **`sapphire-cli`** | Binary: user‑facing `sapphire` command | 60 | 61 | All crates live in one Cargo **workspace**, so `cargo ` from the repo root affects everything. 62 | 63 | ----- 64 | 65 | ## Dev Environment 66 | 67 | * **Platform**: Development and execution require **macOS**. 68 | * **Rust (Build/Test)**: **Stable** toolchain, MSRV pinned in `rust-toolchain.toml` (currently *1.76.0*). Install via [rustup.rs][rustup.rs]. This is used by default for `cargo build`, `cargo check`, `cargo test`, etc. 69 | * **Rust (Format)**: **Nightly** toolchain is required *only* for formatting (`cargo fmt`) due to unstable options used in our `rustfmt.toml` configuration. 70 | * Install via: `rustup toolchain install nightly` 71 | * **Rust Components**: `rustfmt`, `clippy` – install via `rustup component add rustfmt clippy`. Make sure these components are available for *both* your default stable toolchain and the nightly toolchain. 72 | * **macOS System Tools**: Xcode Command Line Tools (provides C compiler, git, etc.). Install with `xcode-select --install`. You may also need `pkg-config` and `cmake` (e.g., install via [Homebrew][Homebrew]: `brew install pkg-config cmake`). 73 | 74 | ----- 75 | 76 | ## Coding Style 77 | 78 | * **Format** ‑ We use custom formatting rules (`rustfmt.toml`) which include unstable options (like `group_imports`, `imports_granularity`, `wrap_comments`, etc.). Applying these requires using the **nightly** toolchain. Format your code *before committing* using: 79 | ```bash 80 | cargo +nightly fmt --all 81 | ``` 82 | * Ensure the nightly toolchain is installed (`rustup toolchain install nightly`). 83 | * CI runs `cargo +nightly fmt --all --check`, so MRs with incorrect formatting will fail. 84 | * **Lint** ‑ `cargo clippy … -D warnings`; annotate false positives with `#[allow()]` + comment. (This uses the default stable toolchain). 85 | * **API** ‑ follow the [Rust API Guidelines][Rust API Guidelines]; document every public item; avoid `unwrap()`. 86 | * **Dependencies** ‑ discuss new crates in the MR; future policy will use `cargo deny`. 87 | 88 | ----- 89 | 90 | ## Testing 91 | 92 | * Unit tests in modules, integration tests in `tests/`. 93 | * Aim to cover new code; bug‑fix MRs **must** include a failing test that passes after the fix. 94 | * `cargo test --workspace` must pass (uses the default stable toolchain). 95 | 96 | ----- 97 | 98 | ## Git & Commits 99 | 100 | * **Fork** the repo on GitHub and add your remote if you haven’t already. 101 | * **Branches**: use feature branches like `feat/…`, `fix/…`, `docs/…`, `test/…`. 102 | * **Conventional Commits** preferred (`feat(core): add bottle caching`). 103 | * **DCO**: add `-s` flag (`git commit -s …`). 104 | * Keep commits atomic; squash fix‑ups before marking the MR ready. 105 | 106 | ----- 107 | 108 | ## Merge‑Request Flow 109 | 110 | 1. Sync with `main`; rebase preferred. 111 | 2. Ensure your code is formatted correctly with `cargo +nightly fmt --all`. 112 | 3. Ensure CI is green (build, fmt check, clippy, tests on macOS using appropriate toolchains). 113 | 4. Fill out the MR template; explain *why* + *how*. 114 | 5. Respond to review comments promptly – we’re friendly, promise! 115 | 6. Maintainers will *Squash & Merge* (unless history is already clean). 116 | 117 | ----- 118 | 119 | ## Reporting Issues 120 | 121 | * **Bug** – include repro steps, expected vs. actual, macOS version & architecture (Intel/ARM). 122 | * **Feature** – explain use‑case, alternatives, and willingness to implement. 123 | * **Security** – email maintainers privately; do **not** file a public issue. 124 | 125 | ----- 126 | 127 | ## License & DCO 128 | 129 | By submitting code you agree to the BSD‑3‑Clause license and certify the [Developer Certificate of Origin][Developer Certificate of Origin]. 130 | 131 | ----- 132 | 133 | ## Code of Conduct 134 | 135 | We follow the [Contributor Covenant][Contributor Covenant]; be kind and inclusive. Report misconduct privately to the core team. 136 | 137 | ----- 138 | 139 | Happy coding – and thanks for making Sapphire better! ✨ 140 | 141 | [rustup.rs]: https://rustup.rs/ 142 | [homebrew]: https://brew.sh/ 143 | [Rust API Guidelines]: https://rust-lang.github.io/api-guidelines/ 144 | [Developer Certificate of Origin]: https://developercertificate.org/ 145 | [Contributor Covenant]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/ 146 | 147 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "sapphire-cli", 5 | "sapphire-core", 6 | ] 7 | 8 | # Optional: Define shared dependencies or profiles here 9 | [workspace.dependencies] 10 | anyhow = "1.0" 11 | thiserror = "2.0.12" 12 | serde = { version = "1.0", features = ["derive"] } 13 | 14 | [profile.release] 15 | lto = true 16 | codegen-units = 1 17 | strip = true 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright 2025 Sapphire Contributors 4 | 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /NOTICE.md: -------------------------------------------------------------------------------- 1 | ## Homebrew 2 | 3 | ### Sources 4 | https://github.com/Homebrew/brew 5 | 6 | ### License 7 | BSD 2-Clause License 8 | 9 | Copyright (c) 2009-present, Homebrew contributors 10 | All rights reserved. 11 | 12 | Redistribution and use in source and binary forms, with or without 13 | modification, are permitted provided that the following conditions are met: 14 | 15 | * Redistributions of source code must retain the above copyright notice, this 16 | list of conditions and the following disclaimer. 17 | 18 | * Redistributions in binary form must reproduce the above copyright notice, 19 | this list of conditions and the following disclaimer in the documentation 20 | and/or other materials provided with the distribution. 21 | 22 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 23 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 24 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 25 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 26 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 27 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 28 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 29 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 30 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 31 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sapphire 2 | 3 | > [!WARNING] 4 | > **ALPHA SOFTWARE** 5 | > Sapphire is experimental, under heavy development, and may be unstable. Use at your own risk! 6 | > 7 | > Uninstalling a cask with brew then reinstalling it with Sapphire will have it installed with slightly different paths, your user settings etc. will not be migrated automatically. 8 | 9 | Sapphire is a next‑generation, Rust‑powered package manager inspired by Homebrew. It installs and manages: 10 | 11 | - **Formulae:** command‑line tools, libraries, and languages 12 | - **Casks:** desktop applications and related artifacts on macOS 13 | 14 | > _ARM only for now, might add x86 support eventually_ 15 | 16 | --- 17 | 18 | ## ⚙️ Project Structure 19 | 20 | - **sapphire‑core** Core library: fetching, dependency resolution, archive extraction, artifact handling (apps, binaries, pkg installers, fonts, plugins, zap/preflight/uninstall stanzas, etc.) 21 | 22 | - **sapphire‑cli** Command‑line interface: `sapphire` executable wrapping the core library. 23 | 24 | --- 25 | 26 | ## 🚧 Current Status 27 | 28 | - Bottle installation and uninstallation 29 | - Cask installation and uninstallation 30 | - Parallel downloads and installs for speed 31 | - Automatic dependency resolution and installation 32 | - Building Formulae from source (very early impl) 33 | 34 | --- 35 | 36 | ## 🚀 Roadmap 37 | 38 | 1. **Upgrade** command to update installed packages 39 | 2. **Cleanup** old downloads, versions, caches 40 | 3. **Reinstall** command for quick re‑pours 41 | 4. **Prefix isolation:** support `/opt/sapphire` as standalone layout 42 | 5. **`sapphire init`** helper to bootstrap your environment 43 | 6. **Ongoing** Bug fixes and stability improvements 44 | 45 | --- 46 | 47 | ## 📦 Usage 48 | 49 | ```sh 50 | # Print help 51 | sapphire --help 52 | 53 | # Update metadata 54 | sapphire update 55 | 56 | # Search for packages 57 | sapphire search 58 | 59 | # Get package info 60 | sapphire info 61 | 62 | # Install bottles or casks 63 | sapphire install 64 | 65 | # Build and install a formula from source 66 | sapphire install --build-from-source 67 | 68 | # Uninstall 69 | sapphire uninstall 70 | 71 | # (coming soon) 72 | sapphire upgrade [--all] 73 | sapphire cleanup 74 | sapphire init 75 | ```` 76 | 77 | ----- 78 | 79 | ## 🏗️ Building from Source 80 | 81 | **Prerequisites:** Rust toolchain (stable). 82 | 83 | ```sh 84 | git clone 85 | cd sapphire 86 | cargo build --release 87 | ``` 88 | 89 | The `sapphire` binary will be at `target/release/sapphire`. Add it to your `PATH`. 90 | 91 | ----- 92 | 93 | ## 🤝 Contributing 94 | 95 | Sapphire lives and grows by your feedback and code\! We’re particularly looking for: 96 | 97 | - Testing and bug reports for Cask & Bottle installation + `--build-from-source` 98 | - Test coverage for core and cask modules 99 | - CLI UI/UX improvements 100 | - See [CONTRIBUTING.md](CONTRIBUTING.md) 101 | 102 | Feel free to open issues or PRs. Every contribution helps\! 103 | 104 | ----- 105 | 106 | ## 📄 License 107 | 108 | - **Sapphire:** BSD‑3‑Clause - see [LICENSE.md](LICENSE.md) 109 | - Inspired by Homebrew BSD‑2‑Clause — see [NOTICE.md](NOTICE.md) 110 | 111 | ----- 112 | 113 | > *Alpha software. No guarantees. Use responsibly.* 114 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # configuration for https://rust-lang.github.io/rustfmt/ 2 | 3 | use_field_init_shorthand = true 4 | 5 | # unstable options. These require cargo +nightly fmt to use 6 | comment_width = 100 # generally more readable than 80 7 | format_code_in_doc_comments = true 8 | format_macro_matchers = true 9 | group_imports = "StdExternalCrate" 10 | imports_granularity = "Module" # generally leads to easier merges and shorter diffs 11 | normalize_doc_attributes = true # converts #[doc = "..."] to /// and #[doc(...)] to /// ... 12 | wrap_comments = true 13 | -------------------------------------------------------------------------------- /sapphire-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sapphire-cli" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Command-line interface for Sapphire" 6 | # repository = "..." 7 | # license = "..." 8 | 9 | [[bin]] 10 | name = "sapphire" 11 | path = "src/main.rs" 12 | 13 | [dependencies] 14 | sapphire-core = { path = "../sapphire-core" } 15 | 16 | # Inherit from workspace 17 | anyhow = { workspace = true } 18 | serde = { workspace = true } 19 | thiserror = { workspace = true } 20 | 21 | # CLI specific dependencies 22 | clap = { version = "4.3", features = ["derive"] } 23 | colored = "3.0.0" 24 | spinners = "4.1" 25 | dialoguer = "0.11.0" 26 | indicatif = "0.17" 27 | env_logger = "0.11.8" 28 | prettytable-rs = "0.10" 29 | serde_json = "1.0" 30 | walkdir = "2.3" 31 | reqwest = "0.12.15" 32 | tokio = { version = "1.44.2", features = ["full"] } 33 | futures = "0.3.31" 34 | terminal_size = "0.4.2" 35 | textwrap = "0.16" 36 | unicode-width = "0.2.0" 37 | tracing = "0.1.41" 38 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 39 | 40 | [build-dependencies] 41 | clap_complete = "4.3" 42 | -------------------------------------------------------------------------------- /sapphire-cli/src/cli.rs: -------------------------------------------------------------------------------- 1 | //! Defines the command-line argument structure using clap. 2 | use std::sync::Arc; 3 | 4 | use clap::{ArgAction, Parser, Subcommand}; 5 | use sapphire_core::utils::error::Result; 6 | use sapphire_core::utils::Cache; 7 | use sapphire_core::Config; 8 | 9 | use self::info::Info; 10 | use self::install::Install; 11 | use self::search::Search; 12 | use self::uninstall::Uninstall; 13 | use self::update::Update; 14 | 15 | pub mod info; 16 | pub mod install; 17 | pub mod search; 18 | pub mod uninstall; 19 | pub mod update; 20 | 21 | #[derive(Parser, Debug)] 22 | #[command(author, version, about, long_about = None)] 23 | #[command(propagate_version = true)] 24 | pub struct CliArgs { 25 | /// Increase verbosity (-v for debug output, -vv for trace) 26 | #[arg(short, long, action = ArgAction::Count, global = true)] 27 | pub verbose: u8, 28 | 29 | #[command(subcommand)] 30 | pub command: Command, 31 | } 32 | 33 | #[derive(Subcommand, Debug)] 34 | pub enum Command { 35 | /// Search for available formulas and casks 36 | Search(Search), 37 | 38 | /// Display information about a formula or cask 39 | Info(Info), 40 | 41 | /// Fetch the latest package list from the API 42 | Update(Update), 43 | 44 | /// Install a formula or cask 45 | Install(Install), 46 | 47 | /// Uninstall one or more formulas or casks 48 | Uninstall(Uninstall), 49 | } 50 | 51 | impl Command { 52 | pub async fn run(&self, config: &Config, cache: Arc) -> Result<()> { 53 | match self { 54 | Self::Search(command) => command.run(config, cache).await, 55 | Self::Info(command) => command.run(config, cache).await, 56 | Self::Update(command) => command.run(config, cache).await, 57 | Self::Install(command) => command.run(config, cache).await, 58 | Self::Uninstall(command) => command.run(config, cache).await, 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /sapphire-cli/src/cli/update.rs: -------------------------------------------------------------------------------- 1 | //! Contains the logic for the `update` command. 2 | use std::fs; 3 | use std::sync::Arc; 4 | 5 | use sapphire_core::fetch::api; 6 | use sapphire_core::utils::cache::Cache; 7 | use sapphire_core::utils::config::Config; 8 | use sapphire_core::utils::error::Result; 9 | 10 | use crate::ui; 11 | 12 | #[derive(clap::Args, Debug)] 13 | pub struct Update; 14 | 15 | impl Update { 16 | pub async fn run(&self, config: &Config, cache: Arc) -> Result<()> { 17 | tracing::debug!("Running manual update..."); // Log clearly it's the manual one 18 | 19 | // Use the ui utility function to create the spinner 20 | let pb = ui::create_spinner("Updating package lists"); // <-- CHANGED 21 | 22 | tracing::debug!("Using cache directory: {:?}", config.cache_dir); 23 | 24 | // Fetch and store raw formula data 25 | match api::fetch_all_formulas().await { 26 | Ok(raw_data) => { 27 | cache.store_raw("formula.json", &raw_data)?; 28 | tracing::debug!("✓ Successfully cached formulas data"); 29 | pb.set_message("Cached formulas data"); 30 | } 31 | Err(e) => { 32 | let err_msg = format!("Failed to fetch/store formulas from API: {e}"); 33 | tracing::error!("{}", err_msg); 34 | pb.finish_and_clear(); // Clear spinner on error 35 | return Err(e); 36 | } 37 | } 38 | 39 | // Fetch and store raw cask data 40 | match api::fetch_all_casks().await { 41 | Ok(raw_data) => { 42 | cache.store_raw("cask.json", &raw_data)?; 43 | tracing::debug!("✓ Successfully cached casks data"); 44 | pb.set_message("Cached casks data"); 45 | } 46 | Err(e) => { 47 | let err_msg = format!("Failed to fetch/store casks from API: {e}"); 48 | tracing::error!("{}", err_msg); 49 | pb.finish_and_clear(); // Clear spinner on error 50 | return Err(e); 51 | } 52 | } 53 | 54 | // Update timestamp file 55 | let timestamp_file = config.cache_dir.join(".sapphire_last_update_check"); 56 | tracing::debug!( 57 | "Manual update successful. Updating timestamp file: {}", 58 | timestamp_file.display() 59 | ); 60 | match fs::File::create(×tamp_file) { 61 | Ok(_) => { 62 | tracing::debug!("Updated timestamp file successfully."); 63 | } 64 | Err(e) => { 65 | tracing::warn!( 66 | "Failed to create or update timestamp file '{}': {}", 67 | timestamp_file.display(), 68 | e 69 | ); 70 | } 71 | } 72 | 73 | pb.finish_with_message("Update completed successfully!"); 74 | Ok(()) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /sapphire-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | use std::time::{Duration, SystemTime}; 3 | use std::{env, fs, process}; 4 | 5 | use anyhow::Result; 6 | use clap::Parser; 7 | use colored::Colorize; 8 | use sapphire_core::utils::cache::Cache; 9 | use sapphire_core::utils::config::Config; 10 | use sapphire_core::utils::error::Result as SapphireResult; // Alias to avoid clash 11 | 12 | mod cli; 13 | mod ui; 14 | 15 | use cli::{CliArgs, Command}; 16 | use tracing::level_filters::LevelFilter; 17 | use tracing_subscriber::EnvFilter; 18 | 19 | #[tokio::main] 20 | async fn main() -> Result<()> { 21 | let cli_args = CliArgs::parse(); 22 | 23 | // Initialize logger based on verbosity (default to info) 24 | let level_filter = match cli_args.verbose { 25 | 0 => LevelFilter::INFO, 26 | 1 => LevelFilter::DEBUG, 27 | _ => LevelFilter::TRACE, 28 | }; 29 | let env_filter = EnvFilter::builder() 30 | .with_default_directive(level_filter.into()) 31 | .with_env_var("SAPPHIRE_LOG") 32 | .from_env_lossy(); 33 | 34 | tracing_subscriber::fmt() 35 | .with_env_filter(env_filter) 36 | .with_writer(std::io::stderr) 37 | .with_ansi(true) 38 | .without_time() 39 | .init(); 40 | 41 | // Initialize config *before* auto-update check 42 | let config = Config::load().unwrap_or_else(|e| { 43 | eprintln!("{}: Could not load config: {}", "Error".red().bold(), e); 44 | process::exit(1); 45 | }); 46 | 47 | // Create Cache once and wrap in Arc 48 | let cache = Arc::new( 49 | Cache::new(&config.cache_dir) 50 | .map_err(|e| anyhow::anyhow!("Could not initialize cache: {}", e))?, 51 | ); 52 | 53 | let needs_update_check = matches!( 54 | cli_args.command, 55 | // Add Commands::Upgrade here when implemented. Note: Uninstall is intentionally excluded 56 | Command::Install(_) | Command::Search { .. } | Command::Info { .. } 57 | ); 58 | 59 | if needs_update_check { 60 | if let Err(e) = check_and_run_auto_update(&config, Arc::clone(&cache)).await { 61 | // Log the error from the check itself, but don't exit 62 | tracing::error!("Error during auto-update check: {}", e); 63 | } 64 | } else { 65 | tracing::debug!( 66 | "Skipping auto-update check for command: {:?}", 67 | cli_args.command 68 | ); 69 | } 70 | 71 | if let Err(e) = cli_args.command.run(&config, cache).await { 72 | eprintln!("{}: {:#}", "Error".red().bold(), e); 73 | process::exit(1); 74 | } 75 | 76 | Ok(()) 77 | } 78 | 79 | /// Checks if auto-update is needed and runs it. 80 | async fn check_and_run_auto_update(config: &Config, cache: Arc) -> SapphireResult<()> { 81 | // 1. Check if auto-update is disabled 82 | if env::var("SAPPHIRE_NO_AUTO_UPDATE").is_ok_and(|v| v == "1") { 83 | tracing::debug!("Auto-update disabled via SAPPHIRE_NO_AUTO_UPDATE=1."); 84 | return Ok(()); 85 | } 86 | 87 | // 2. Determine update interval 88 | let default_interval_secs: u64 = 86400; // 24 hours 89 | let update_interval_secs = env::var("SAPPHIRE_AUTO_UPDATE_SECS") 90 | .ok() 91 | .and_then(|s| s.parse::().ok()) 92 | .unwrap_or(default_interval_secs); 93 | let update_interval = Duration::from_secs(update_interval_secs); 94 | tracing::debug!("Auto-update interval: {:?}", update_interval); 95 | 96 | // 3. Check timestamp file 97 | let timestamp_file = config.cache_dir.join(".sapphire_last_update_check"); 98 | tracing::debug!("Checking timestamp file: {}", timestamp_file.display()); 99 | 100 | let mut needs_update = true; // Assume update needed unless file is recent 101 | if let Ok(metadata) = fs::metadata(×tamp_file) { 102 | if let Ok(modified_time) = metadata.modified() { 103 | match SystemTime::now().duration_since(modified_time) { 104 | Ok(age) => { 105 | tracing::debug!("Time since last update check: {:?}", age); 106 | if age < update_interval { 107 | needs_update = false; 108 | tracing::debug!("Auto-update interval not yet passed."); 109 | } else { 110 | tracing::debug!("Auto-update interval passed."); 111 | } 112 | } 113 | Err(e) => { 114 | tracing::warn!( 115 | "Could not get duration since last update check (system time error?): {}", 116 | e 117 | ); 118 | // Proceed with update if we can't determine age 119 | } 120 | } 121 | } else { 122 | tracing::warn!( 123 | "Could not read modification time for timestamp file: {}", 124 | timestamp_file.display() 125 | ); 126 | // Proceed with update if we can't read time 127 | } 128 | } else { 129 | tracing::debug!("Timestamp file not found or not accessible."); 130 | // Proceed with update if file doesn't exist 131 | } 132 | 133 | // 4. Run update if needed 134 | if needs_update { 135 | println!("Running auto-update..."); 136 | // Use the existing update command logic 137 | match cli::update::Update.run(config, cache).await { 138 | // Pass Arc::clone if needed, depends on run_update signature 139 | Ok(_) => { 140 | println!("Auto-update successful."); 141 | // 5. Update timestamp file on success 142 | match fs::File::create(×tamp_file) { 143 | Ok(_) => { 144 | tracing::debug!("Updated timestamp file: {}", timestamp_file.display()); 145 | } 146 | Err(e) => { 147 | tracing::warn!( 148 | "Failed to create or update timestamp file '{}': {}", 149 | timestamp_file.display(), 150 | e 151 | ); 152 | // Continue even if timestamp update fails, but log it 153 | } 154 | } 155 | } 156 | Err(e) => { 157 | // Log error but don't prevent the main command from running 158 | tracing::error!("Auto-update failed: {}", e); 159 | } 160 | } 161 | } else { 162 | tracing::debug!("Skipping auto-update."); 163 | } 164 | 165 | Ok(()) 166 | } 167 | -------------------------------------------------------------------------------- /sapphire-cli/src/ui.rs: -------------------------------------------------------------------------------- 1 | //! UI utility functions for creating common elements like spinners. 2 | 3 | use std::time::Duration; 4 | 5 | use indicatif::{ProgressBar, ProgressStyle}; 6 | 7 | /// Creates and configures a default spinner ProgressBar. 8 | /// 9 | /// # Arguments 10 | /// 11 | /// * `message` - The initial message to display next to the spinner. 12 | /// 13 | /// # Returns 14 | /// 15 | /// A configured `ProgressBar` instance ready to be used. 16 | pub fn create_spinner(message: &str) -> ProgressBar { 17 | let pb = ProgressBar::new_spinner(); 18 | pb.set_style(ProgressStyle::with_template("{spinner:.blue.bold} {msg}").unwrap()); 19 | pb.set_message(message.to_string()); 20 | pb.enable_steady_tick(Duration::from_millis(100)); // Standard tick rate 21 | pb 22 | } 23 | -------------------------------------------------------------------------------- /sapphire-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sapphire-core" 3 | version = "0.1.0" 4 | edition = "2021" 5 | description = "Core library for the Sapphire package manager" 6 | # repository = "..." # Add your repo URL 7 | # license = "..." # Add your license 8 | 9 | [dependencies] 10 | # Inherit from workspace where possible 11 | anyhow = { workspace = true } 12 | thiserror = { workspace = true } 13 | serde = { version = "1.0.219", features = ["derive"] } 14 | 15 | # Core-specific dependencies (add others identified from moved code) 16 | serde_json = "1.0.140" 17 | devtools = "0.3.3" 18 | toml = "0.8.20" 19 | env_logger = "0.11.8" 20 | which = "7.0.3" 21 | semver = "1.0.26" 22 | dirs = "6.0" 23 | walkdir = "2.5.0" 24 | fs_extra = "1.3" 25 | reqwest = { version = "0.12.15", features = ["json", "stream", "blocking"] } 26 | url = "2.5.4" 27 | git2 = "0.20.1" 28 | cmd_lib = "1.9.5" 29 | sha2 = "0.10.8" 30 | tempfile = "3.19.1" 31 | indicatif = "0.17.11" 32 | regex = "1.11.1" 33 | glob = "0.3.2" 34 | hex = "0.4.3" 35 | object = { version = "0.36.7", features = ["read_core", "write_core", "macho"] } 36 | tokio = { version = "1.44.2", features = ["full"] } 37 | futures = "0.3.31" 38 | flate2 = "1.1.1" 39 | bzip2 = "0.5.2" 40 | xz2 = "0.1.7" 41 | tar = "0.4.44" 42 | zip = "2.6.1" 43 | rand = "0.9.1" 44 | infer = "0.19.0" 45 | 46 | # Added from check errors 47 | chrono = { version = "0.4.40", features = ["serde"] } # Added serde feature for potential use 48 | num_cpus = "1.16.0" 49 | humantime = "2.2.0" 50 | bitflags = { version = "2.9.0", features = ["serde"] } 51 | async-recursion = "1.1.1" 52 | tracing = "0.1.41" 53 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/app.rs: -------------------------------------------------------------------------------- 1 | // In sapphire-core/src/build/cask/app.rs 2 | 3 | use std::fs; 4 | use std::path::Path; 5 | use std::process::Command; 6 | 7 | use tracing::{debug, error, info, warn}; // Added log imports 8 | 9 | use crate::build::cask::InstalledArtifact; 10 | use crate::model::cask::Cask; 11 | use crate::utils::config::Config; 12 | use crate::utils::error::{Result, SapphireError}; 13 | 14 | /// Installs an app bundle from a staged location to /Applications and creates a symlink in the 15 | /// caskroom. Returns a Vec containing the details of artifacts created. 16 | pub fn install_app_from_staged( 17 | _cask: &Cask, // Keep cask for potential future use (e.g., specific app flags) 18 | staged_app_path: &Path, 19 | cask_version_install_path: &Path, 20 | config: &Config, 21 | ) -> Result> { 22 | // <-- Return type changed 23 | 24 | if !staged_app_path.exists() || !staged_app_path.is_dir() { 25 | return Err(SapphireError::NotFound(format!( 26 | "Staged app bundle not found or is not a directory: {}", 27 | staged_app_path.display() 28 | ))); 29 | } 30 | 31 | let app_name = staged_app_path 32 | .file_name() 33 | .ok_or_else(|| { 34 | SapphireError::Generic(format!( 35 | "Invalid staged app path: {}", 36 | staged_app_path.display() 37 | )) 38 | })? 39 | .to_string_lossy(); 40 | 41 | let applications_dir = config.applications_dir(); 42 | let final_app_destination = applications_dir.join(app_name.as_ref()); 43 | 44 | debug!( 45 | "Moving app '{}' from stage to {}", 46 | app_name, 47 | applications_dir.display() 48 | ); 49 | 50 | // --- Remove Existing Destination --- 51 | if final_app_destination.exists() || final_app_destination.symlink_metadata().is_ok() { 52 | debug!( 53 | "Removing existing app at {}", 54 | final_app_destination.display() 55 | ); 56 | let remove_result = if final_app_destination.is_dir() { 57 | fs::remove_dir_all(&final_app_destination) 58 | } else { 59 | fs::remove_file(&final_app_destination) // Remove file or symlink 60 | }; 61 | 62 | if let Err(e) = remove_result { 63 | if e.kind() == std::io::ErrorKind::PermissionDenied 64 | || e.kind() == std::io::ErrorKind::DirectoryNotEmpty 65 | { 66 | warn!("Direct removal failed ({}). Trying with sudo rm -rf...", e); 67 | debug!("Executing: sudo rm -rf {}", final_app_destination.display()); 68 | let output = Command::new("sudo") 69 | .arg("rm") 70 | .arg("-rf") 71 | .arg(&final_app_destination) 72 | .output()?; 73 | if !output.status.success() { 74 | let stderr = String::from_utf8_lossy(&output.stderr); 75 | error!("sudo rm -rf failed ({}): {}", output.status, stderr); 76 | return Err(SapphireError::InstallError(format!( 77 | "Failed to remove existing app at {}: {}", 78 | final_app_destination.display(), 79 | stderr 80 | ))); 81 | } 82 | debug!("Successfully removed existing app with sudo."); 83 | } else { 84 | error!( 85 | "Failed to remove existing app at {}: {}", 86 | final_app_destination.display(), 87 | e 88 | ); 89 | return Err(SapphireError::Io(e)); 90 | } 91 | } else { 92 | debug!("Successfully removed existing app."); 93 | } 94 | } 95 | 96 | // --- Move/Copy from Stage --- 97 | debug!( 98 | "Moving staged app {} to {}", 99 | staged_app_path.display(), 100 | final_app_destination.display() 101 | ); 102 | let move_output = Command::new("mv") 103 | .arg(staged_app_path) // Source 104 | .arg(&final_app_destination) // Destination 105 | .output()?; 106 | 107 | if !move_output.status.success() { 108 | let stderr = String::from_utf8_lossy(&move_output.stderr).to_lowercase(); 109 | if stderr.contains("cross-device link") 110 | || stderr.contains("operation not permitted") 111 | || stderr.contains("permission denied") 112 | { 113 | warn!("Direct mv failed ({}), trying cp -R...", stderr); 114 | debug!( 115 | "Executing: cp -R {} {}", 116 | staged_app_path.display(), 117 | final_app_destination.display() 118 | ); 119 | let copy_output = Command::new("cp") 120 | .arg("-R") // Recursive copy 121 | .arg(staged_app_path) 122 | .arg(&final_app_destination) 123 | .output()?; 124 | if !copy_output.status.success() { 125 | let copy_stderr = String::from_utf8_lossy(©_output.stderr); 126 | error!("cp -R failed ({}): {}", copy_output.status, copy_stderr); 127 | return Err(SapphireError::InstallError(format!( 128 | "Failed to copy app from stage to {}: {}", 129 | final_app_destination.display(), 130 | copy_stderr 131 | ))); 132 | } 133 | debug!("Successfully copied app using cp -R."); 134 | } else { 135 | error!("mv command failed ({}): {}", move_output.status, stderr); 136 | return Err(SapphireError::InstallError(format!( 137 | "Failed to move app from stage to {}: {}", 138 | final_app_destination.display(), 139 | stderr 140 | ))); 141 | } 142 | } else { 143 | debug!("Successfully moved app using mv."); 144 | } 145 | 146 | // --- Record the main app artifact --- 147 | let mut created_artifacts = vec![InstalledArtifact::App { 148 | path: final_app_destination.clone(), 149 | }]; 150 | 151 | // --- Create Caskroom Symlink --- 152 | let caskroom_app_link_path = cask_version_install_path.join(app_name.as_ref()); 153 | debug!( 154 | "Linking {} -> {}", 155 | caskroom_app_link_path.display(), 156 | final_app_destination.display() 157 | ); 158 | 159 | if caskroom_app_link_path.exists() || caskroom_app_link_path.symlink_metadata().is_ok() { 160 | if let Err(e) = fs::remove_file(&caskroom_app_link_path) { 161 | warn!( 162 | "Failed to remove existing item at caskroom link path {}: {}", 163 | caskroom_app_link_path.display(), 164 | e 165 | ); 166 | } 167 | } 168 | 169 | #[cfg(unix)] 170 | { 171 | if let Err(e) = std::os::unix::fs::symlink(&final_app_destination, &caskroom_app_link_path) 172 | { 173 | warn!( 174 | "Failed to create symlink {} -> {}: {}", 175 | caskroom_app_link_path.display(), 176 | final_app_destination.display(), 177 | e 178 | ); 179 | // Decide if this should be a fatal error or just a warning 180 | // For now, let's just warn and continue. 181 | } else { 182 | debug!("Successfully created caskroom link."); 183 | // Record the link artifact if created successfully 184 | created_artifacts.push(InstalledArtifact::CaskroomLink { 185 | link_path: caskroom_app_link_path.clone(), 186 | target_path: final_app_destination.clone(), 187 | }); 188 | } 189 | } 190 | #[cfg(not(unix))] 191 | { 192 | warn!( 193 | "Symlink creation not supported on this platform. Skipping link for {}.", 194 | caskroom_app_link_path.display() 195 | ); 196 | } 197 | 198 | info!("Successfully installed app artifact: {}", app_name); 199 | Ok(created_artifacts) // <-- Return the collected artifacts 200 | } 201 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/audio_unit_plugin.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/audio_unit_plugin.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs `audio_unit_plugin` bundles from the staging area into 16 | /// `~/Library/Audio/Plug-Ins/Components`, then symlinks them into the Caskroom. 17 | /// 18 | /// Mirrors Homebrew’s `AudioUnitPlugin < Moved` pattern. 19 | pub fn install_audio_unit_plugin( 20 | cask: &Cask, 21 | stage_path: &Path, 22 | cask_version_install_path: &Path, 23 | config: &Config, 24 | ) -> Result> { 25 | let mut installed = Vec::new(); 26 | 27 | if let Some(artifacts_def) = &cask.artifacts { 28 | for art in artifacts_def { 29 | if let Some(obj) = art.as_object() { 30 | if let Some(entries) = obj.get("audio_unit_plugin").and_then(|v| v.as_array()) { 31 | // Target directory for Audio Unit components 32 | let dest_dir = config 33 | .home_dir() 34 | .join("Library") 35 | .join("Audio") 36 | .join("Plug-Ins") 37 | .join("Components"); 38 | fs::create_dir_all(&dest_dir)?; 39 | 40 | for entry in entries { 41 | if let Some(bundle_name) = entry.as_str() { 42 | let src = stage_path.join(bundle_name); 43 | if !src.exists() { 44 | warn!( 45 | "AudioUnit plugin '{}' not found in staging; skipping", 46 | bundle_name 47 | ); 48 | continue; 49 | } 50 | 51 | let dest = dest_dir.join(bundle_name); 52 | if dest.exists() { 53 | fs::remove_dir_all(&dest)?; 54 | } 55 | 56 | info!( 57 | "Installing AudioUnit plugin '{}' → '{}'", 58 | src.display(), 59 | dest.display() 60 | ); 61 | // Try move, fallback to copy 62 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 63 | if !status.success() { 64 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 65 | } 66 | 67 | // Record moved plugin 68 | installed.push(InstalledArtifact::App { path: dest.clone() }); 69 | 70 | // Symlink into Caskroom for reference 71 | let link = cask_version_install_path.join(bundle_name); 72 | let _ = fs::remove_file(&link); 73 | symlink(&dest, &link)?; 74 | installed.push(InstalledArtifact::CaskroomLink { 75 | link_path: link, 76 | target_path: dest, 77 | }); 78 | } 79 | } 80 | break; // one stanza only 81 | } 82 | } 83 | } 84 | } 85 | 86 | Ok(installed) 87 | } 88 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/binary.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/binary.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{debug, info}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::{Result, SapphireError}; 14 | 15 | /// Installs `binary` artifacts, which can be declared as: 16 | /// - a simple string: `"foo"` (source and target both `"foo"`) 17 | /// - a map: `{ "source": "path/in/stage", "target": "name", "chmod": "0755" }` 18 | /// - a map with just `"target"`: automatically generate a wrapper script 19 | /// 20 | /// Copies or symlinks executables into the prefix bin directory, 21 | /// and records both the link and caskroom reference. 22 | pub fn install_binary( 23 | cask: &Cask, 24 | stage_path: &Path, 25 | cask_version_install_path: &Path, 26 | config: &Config, 27 | ) -> Result> { 28 | let mut installed = Vec::new(); 29 | 30 | if let Some(artifacts_def) = &cask.artifacts { 31 | for art in artifacts_def { 32 | if let Some(obj) = art.as_object() { 33 | if let Some(entries) = obj.get("binary") { 34 | // Normalize into an array 35 | let arr = if let Some(arr) = entries.as_array() { 36 | arr.clone() 37 | } else { 38 | vec![entries.clone()] 39 | }; 40 | 41 | let bin_dir = config.bin_dir(); 42 | fs::create_dir_all(&bin_dir)?; 43 | 44 | for entry in arr { 45 | // Determine source, target, and optional chmod 46 | let (source_rel, target_name, chmod) = if let Some(tgt) = entry.as_str() { 47 | // simple form: "foo" 48 | (tgt.to_string(), tgt.to_string(), None) 49 | } else if let Some(m) = entry.as_object() { 50 | let target = m 51 | .get("target") 52 | .and_then(|v| v.as_str()) 53 | .map(String::from) 54 | .ok_or_else(|| { 55 | SapphireError::InstallError(format!( 56 | "Binary artifact missing 'target': {m:?}" 57 | )) 58 | })?; 59 | 60 | let chmod = m.get("chmod").and_then(|v| v.as_str()).map(String::from); 61 | 62 | // If `source` is provided, use it; otherwise generate wrapper 63 | let source = if let Some(src) = m.get("source").and_then(|v| v.as_str()) 64 | { 65 | src.to_string() 66 | } else { 67 | // generate wrapper script in caskroom 68 | let wrapper_name = format!("{target}.wrapper.sh"); 69 | let wrapper_path = cask_version_install_path.join(&wrapper_name); 70 | 71 | // assume the real executable lives inside the .app bundle 72 | let app_name = format!("{}.app", cask.display_name()); 73 | let exe_path = 74 | format!("/Applications/{app_name}/Contents/MacOS/{target}"); 75 | 76 | let script = 77 | format!("#!/usr/bin/env bash\nexec \"{exe_path}\" \"$@\"\n"); 78 | fs::write(&wrapper_path, script)?; 79 | Command::new("chmod") 80 | .arg("+x") 81 | .arg(&wrapper_path) 82 | .status()?; 83 | 84 | wrapper_name 85 | }; 86 | 87 | (source, target, chmod) 88 | } else { 89 | debug!("Invalid binary artifact entry: {:?}", entry); 90 | continue; 91 | }; 92 | 93 | let src_path = stage_path.join(&source_rel); 94 | if !src_path.exists() { 95 | debug!("Binary source '{}' not found, skipping", src_path.display()); 96 | continue; 97 | } 98 | 99 | // Link into bin_dir 100 | let link_path = bin_dir.join(&target_name); 101 | let _ = fs::remove_file(&link_path); 102 | info!( 103 | "Linking binary '{}' → '{}'", 104 | src_path.display(), 105 | link_path.display() 106 | ); 107 | symlink(&src_path, &link_path)?; 108 | 109 | // Apply chmod if specified 110 | if let Some(mode) = chmod.as_deref() { 111 | let _ = Command::new("chmod").arg(mode).arg(&link_path).status(); 112 | } 113 | 114 | installed.push(InstalledArtifact::BinaryLink { 115 | link_path: link_path.clone(), 116 | target_path: src_path.clone(), 117 | }); 118 | 119 | // Also create a Caskroom symlink for reference 120 | let caskroom_link = cask_version_install_path.join(&target_name); 121 | let _ = fs::remove_file(&caskroom_link); 122 | symlink(&link_path, &caskroom_link)?; 123 | installed.push(InstalledArtifact::CaskroomLink { 124 | link_path: caskroom_link, 125 | target_path: link_path, 126 | }); 127 | } 128 | 129 | // Only one binary stanza per cask 130 | break; 131 | } 132 | } 133 | } 134 | } 135 | 136 | Ok(installed) 137 | } 138 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/colorpicker.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/colorpicker.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs any `colorpicker` stanzas from the Cask definition. 16 | /// 17 | /// Homebrew’s `Colorpicker` artifact simply subclasses `Moved` with 18 | /// `dirmethod :colorpickerdir` → `~/Library/ColorPickers` :contentReference[oaicite:3]{index=3}. 19 | pub fn install_colorpicker( 20 | cask: &Cask, 21 | stage_path: &Path, 22 | cask_version_install_path: &Path, 23 | config: &Config, 24 | ) -> Result> { 25 | let mut installed = Vec::new(); 26 | 27 | if let Some(artifacts_def) = &cask.artifacts { 28 | for art in artifacts_def { 29 | if let Some(obj) = art.as_object() { 30 | if let Some(entries) = obj.get("colorpicker").and_then(|v| v.as_array()) { 31 | // For each declared bundle name: 32 | for entry in entries { 33 | if let Some(bundle_name) = entry.as_str() { 34 | let src = stage_path.join(bundle_name); 35 | if !src.exists() { 36 | warn!( 37 | "Colorpicker bundle '{}' not found in stage; skipping", 38 | bundle_name 39 | ); 40 | continue; 41 | } 42 | 43 | // Ensure ~/Library/ColorPickers exists 44 | // :contentReference[oaicite:4]{index=4} 45 | let dest_dir = config 46 | .home_dir() // e.g. /Users/alxknt 47 | .join("Library") 48 | .join("ColorPickers"); 49 | fs::create_dir_all(&dest_dir)?; 50 | 51 | let dest = dest_dir.join(bundle_name); 52 | // Remove any previous copy 53 | if dest.exists() { 54 | fs::remove_dir_all(&dest)?; 55 | } 56 | 57 | info!( 58 | "Moving colorpicker '{}' → '{}'", 59 | src.display(), 60 | dest.display() 61 | ); 62 | // mv, fallback to cp -R if necessary (cross‑device) 63 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 64 | if !status.success() { 65 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 66 | } 67 | 68 | // Record as a moved artifact (bundle installed) 69 | installed.push(InstalledArtifact::App { path: dest.clone() }); 70 | 71 | // Symlink back into Caskroom for reference 72 | // :contentReference[oaicite:5]{index=5} 73 | let link = cask_version_install_path.join(bundle_name); 74 | let _ = fs::remove_file(&link); 75 | symlink(&dest, &link)?; 76 | installed.push(InstalledArtifact::CaskroomLink { 77 | link_path: link, 78 | target_path: dest, 79 | }); 80 | } 81 | } 82 | break; // only one `colorpicker` stanza per cask 83 | } 84 | } 85 | } 86 | } 87 | 88 | Ok(installed) 89 | } 90 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/dictionary.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/dictionary.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Implements the `dictionary` stanza by moving each declared 16 | /// `.dictionary` bundle from the staging area into `~/Library/Dictionaries`, 17 | /// then symlinking it in the Caskroom. 18 | /// 19 | /// Homebrew’s Ruby definition is simply: 20 | /// ```ruby 21 | /// class Dictionary < Moved; end 22 | /// ``` 23 | /// :contentReference[oaicite:2]{index=2} 24 | pub fn install_dictionary( 25 | cask: &Cask, 26 | stage_path: &Path, 27 | cask_version_install_path: &Path, 28 | config: &Config, 29 | ) -> Result> { 30 | let mut installed = Vec::new(); 31 | 32 | // Find any `dictionary` arrays in the raw JSON artifacts 33 | if let Some(artifacts_def) = &cask.artifacts { 34 | for art in artifacts_def { 35 | if let Some(obj) = art.as_object() { 36 | if let Some(entries) = obj.get("dictionary").and_then(|v| v.as_array()) { 37 | for entry in entries { 38 | if let Some(bundle_name) = entry.as_str() { 39 | let src = stage_path.join(bundle_name); 40 | if !src.exists() { 41 | warn!( 42 | "Dictionary bundle '{}' not found in staging; skipping", 43 | bundle_name 44 | ); 45 | continue; 46 | } 47 | 48 | // Standard user dictionary directory: ~/Library/Dictionaries 49 | // :contentReference[oaicite:3]{index=3} 50 | let dest_dir = config 51 | .home_dir() // e.g. /Users/alxknt 52 | .join("Library") 53 | .join("Dictionaries"); 54 | fs::create_dir_all(&dest_dir)?; 55 | 56 | let dest = dest_dir.join(bundle_name); 57 | // Remove any previous install 58 | if dest.exists() { 59 | fs::remove_dir_all(&dest)?; 60 | } 61 | 62 | info!( 63 | "Moving dictionary '{}' → '{}'", 64 | src.display(), 65 | dest.display() 66 | ); 67 | // Try a direct move; fall back to recursive copy 68 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 69 | if !status.success() { 70 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 71 | } 72 | 73 | // Record the moved bundle 74 | installed.push(InstalledArtifact::App { path: dest.clone() }); 75 | 76 | // Symlink back into Caskroom for reference 77 | let link = cask_version_install_path.join(bundle_name); 78 | let _ = fs::remove_file(&link); 79 | symlink(&dest, &link)?; 80 | installed.push(InstalledArtifact::CaskroomLink { 81 | link_path: link, 82 | target_path: dest, 83 | }); 84 | } 85 | } 86 | break; // Only one `dictionary` stanza per cask 87 | } 88 | } 89 | } 90 | } 91 | 92 | Ok(installed) 93 | } 94 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/font.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/font.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Implements the `font` stanza by moving each declared 16 | /// font file or directory from the staging area into 17 | /// `~/Library/Fonts`, then symlinking it in the Caskroom. 18 | /// 19 | /// Mirrors Homebrew’s `Dictionary < Moved` and `Colorpicker < Moved` pattern. 20 | pub fn install_font( 21 | cask: &Cask, 22 | stage_path: &Path, 23 | cask_version_install_path: &Path, 24 | config: &Config, 25 | ) -> Result> { 26 | let mut installed = Vec::new(); 27 | 28 | // Look for "font" entries in the JSON artifacts 29 | if let Some(artifacts_def) = &cask.artifacts { 30 | for art in artifacts_def { 31 | if let Some(obj) = art.as_object() { 32 | if let Some(entries) = obj.get("font").and_then(|v| v.as_array()) { 33 | // Target directory for user fonts 34 | let dest_dir = config.home_dir().join("Library").join("Fonts"); 35 | fs::create_dir_all(&dest_dir)?; 36 | 37 | for entry in entries { 38 | if let Some(name) = entry.as_str() { 39 | let src = stage_path.join(name); 40 | if !src.exists() { 41 | warn!("Font '{}' not found in staging; skipping", name); 42 | continue; 43 | } 44 | 45 | let dest = dest_dir.join(name); 46 | if dest.exists() { 47 | fs::remove_file(&dest)?; 48 | } 49 | 50 | info!("Installing font '{}' → '{}'", src.display(), dest.display()); 51 | // Try move, fallback to copy 52 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 53 | if !status.success() { 54 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 55 | } 56 | 57 | // Record moved font 58 | installed.push(InstalledArtifact::App { path: dest.clone() }); 59 | 60 | // Symlink into Caskroom for reference 61 | let link = cask_version_install_path.join(name); 62 | let _ = fs::remove_file(&link); 63 | symlink(&dest, &link)?; 64 | installed.push(InstalledArtifact::CaskroomLink { 65 | link_path: link, 66 | target_path: dest, 67 | }); 68 | } 69 | } 70 | break; // single font stanza per cask 71 | } 72 | } 73 | } 74 | } 75 | 76 | Ok(installed) 77 | } 78 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/input_method.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/input_method.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs as unix_fs; 5 | use std::path::Path; 6 | 7 | use crate::build::cask::{write_cask_manifest, InstalledArtifact}; 8 | use crate::model::cask::Cask; 9 | use crate::utils::config::Config; 10 | use crate::utils::error::Result; 11 | 12 | /// Install `input_method` artifacts from the staged directory into 13 | /// `~/Library/Input Methods` and record installed artifacts. 14 | pub fn install_input_method( 15 | cask: &Cask, 16 | stage_path: &Path, 17 | cask_version_install_path: &Path, 18 | config: &Config, 19 | ) -> Result> { 20 | let mut installed = Vec::new(); 21 | 22 | // Ensure we have an array of input_method names 23 | if let Some(artifacts_def) = &cask.artifacts { 24 | for artifact_value in artifacts_def { 25 | if let Some(obj) = artifact_value.as_object() { 26 | if let Some(names) = obj.get("input_method").and_then(|v| v.as_array()) { 27 | for name_val in names { 28 | if let Some(name) = name_val.as_str() { 29 | let source = stage_path.join(name); 30 | if source.exists() { 31 | // Target directory: ~/Library/Input Methods 32 | let target_dir = 33 | config.home_dir().join("Library").join("Input Methods"); 34 | if !target_dir.exists() { 35 | fs::create_dir_all(&target_dir)?; 36 | } 37 | let target = target_dir.join(name); 38 | 39 | // Remove existing input method if present 40 | if target.exists() { 41 | fs::remove_dir_all(&target)?; 42 | } 43 | 44 | // Move (or rename) the staged bundle 45 | fs::rename(&source, &target) 46 | .or_else(|_| unix_fs::symlink(&source, &target))?; 47 | 48 | // Record the main artifact 49 | installed.push(InstalledArtifact::App { 50 | path: target.clone(), 51 | }); 52 | 53 | // Create a caskroom symlink for uninstallation 54 | let link_path = cask_version_install_path.join(name); 55 | if link_path.exists() { 56 | fs::remove_file(&link_path)?; 57 | } 58 | #[cfg(unix)] 59 | std::os::unix::fs::symlink(&target, &link_path)?; 60 | 61 | installed.push(InstalledArtifact::CaskroomLink { 62 | link_path, 63 | target_path: target, 64 | }); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | } 72 | 73 | // Write manifest for these artifacts 74 | write_cask_manifest(cask, cask_version_install_path, installed.clone())?; 75 | Ok(installed) 76 | } 77 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/installer.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/installer.rs ===== 2 | 3 | use std::path::Path; 4 | use std::process::{Command, Stdio}; 5 | 6 | use tracing::{info, warn}; 7 | 8 | use crate::build::cask::InstalledArtifact; 9 | use crate::model::cask::Cask; 10 | use crate::utils::config::Config; 11 | use crate::utils::error::{Result, SapphireError}; 12 | 13 | /// Implements the `installer` stanza: 14 | /// - `manual`: prints instructions to open the staged path. 15 | /// - `script`: runs the given executable with args, under sudo if requested. 16 | /// 17 | /// Mirrors Homebrew’s `Cask::Artifact::Installer` behavior :contentReference[oaicite:1]{index=1}. 18 | pub fn run_installer( 19 | cask: &Cask, 20 | stage_path: &Path, 21 | _cask_version_install_path: &Path, 22 | _config: &Config, 23 | ) -> Result> { 24 | let mut installed = Vec::new(); 25 | 26 | // Find the `installer` definitions in the raw JSON artifacts 27 | if let Some(artifacts_def) = &cask.artifacts { 28 | for art in artifacts_def { 29 | if let Some(obj) = art.as_object() { 30 | if let Some(insts) = obj.get("installer").and_then(|v| v.as_array()) { 31 | for inst in insts { 32 | if let Some(inst_obj) = inst.as_object() { 33 | // Manual installer: user must open the path themselves 34 | if let Some(man) = inst_obj.get("manual").and_then(|v| v.as_str()) { 35 | warn!( 36 | "Cask {} requires manual install. To finish:\n open {}", 37 | cask.token, 38 | stage_path.join(man).display() 39 | ); 40 | // Nothing to record in InstalledArtifact for manual 41 | continue; 42 | } 43 | 44 | // Script installer 45 | let exe_key = if inst_obj.contains_key("script") { 46 | "script" 47 | } else { 48 | "executable" 49 | }; 50 | let executable = inst_obj 51 | .get(exe_key) 52 | .and_then(|v| v.as_str()) 53 | .ok_or_else(|| { 54 | SapphireError::Generic(format!( 55 | "installer stanza missing '{exe_key}' field" 56 | )) 57 | })?; 58 | let args: Vec = inst_obj 59 | .get("args") 60 | .and_then(|v| v.as_array()) 61 | .map(|arr| { 62 | arr.iter() 63 | .filter_map(|a| a.as_str().map(String::from)) 64 | .collect() 65 | }) 66 | .unwrap_or_default(); 67 | let use_sudo = inst_obj 68 | .get("sudo") 69 | .and_then(|v| v.as_bool()) 70 | .unwrap_or(false); 71 | 72 | let script_path = stage_path.join(executable); 73 | if !script_path.exists() { 74 | return Err(SapphireError::NotFound(format!( 75 | "Installer script not found: {}", 76 | script_path.display() 77 | ))); 78 | } 79 | 80 | info!( 81 | "Running installer script '{}' for cask {}", 82 | script_path.display(), 83 | cask.token 84 | ); 85 | // Build the command 86 | let mut cmd = if use_sudo { 87 | let mut c = Command::new("sudo"); 88 | c.arg(script_path.clone()); 89 | c 90 | } else { 91 | Command::new(script_path.clone()) 92 | }; 93 | cmd.args(&args); 94 | // Inherit stdout/stderr so user sees progress 95 | cmd.stdin(Stdio::null()) 96 | .stdout(Stdio::inherit()) 97 | .stderr(Stdio::inherit()); 98 | 99 | // Execute 100 | let status = cmd.status().map_err(|e| { 101 | SapphireError::Generic(format!( 102 | "Failed to spawn installer script: {e}" 103 | )) 104 | })?; 105 | if !status.success() { 106 | return Err(SapphireError::InstallError(format!( 107 | "Installer script exited with {status}" 108 | ))); 109 | } 110 | 111 | // No specific files to record here, but we can note the script ran 112 | installed 113 | .push(InstalledArtifact::CaskroomReference { path: script_path }); 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | 121 | Ok(installed) 122 | } 123 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/internet_plugin.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/internet_plugin.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Implements the `internet_plugin` stanza by moving each declared 16 | /// internet plugin bundle from the staging area into 17 | /// `~/Library/Internet Plug-Ins`, then symlinking it in the Caskroom. 18 | /// 19 | /// Mirrors Homebrew’s `InternetPlugin < Moved` pattern. 20 | pub fn install_internet_plugin( 21 | cask: &Cask, 22 | stage_path: &Path, 23 | cask_version_install_path: &Path, 24 | config: &Config, 25 | ) -> Result> { 26 | let mut installed = Vec::new(); 27 | 28 | // Look for "internet_plugin" entries in the JSON artifacts 29 | if let Some(artifacts_def) = &cask.artifacts { 30 | for art in artifacts_def { 31 | if let Some(obj) = art.as_object() { 32 | if let Some(entries) = obj.get("internet_plugin").and_then(|v| v.as_array()) { 33 | // Target directory for user internet plugins 34 | let dest_dir = config.home_dir().join("Library").join("Internet Plug-Ins"); 35 | fs::create_dir_all(&dest_dir)?; 36 | 37 | for entry in entries { 38 | if let Some(name) = entry.as_str() { 39 | let src = stage_path.join(name); 40 | if !src.exists() { 41 | warn!("Internet plugin '{}' not found in staging; skipping", name); 42 | continue; 43 | } 44 | 45 | let dest = dest_dir.join(name); 46 | if dest.exists() { 47 | fs::remove_dir_all(&dest)?; 48 | } 49 | 50 | info!( 51 | "Installing internet plugin '{}' → '{}'", 52 | src.display(), 53 | dest.display() 54 | ); 55 | // Try move, fallback to copy 56 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 57 | if !status.success() { 58 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 59 | } 60 | 61 | // Record moved plugin 62 | installed.push(InstalledArtifact::App { path: dest.clone() }); 63 | 64 | // Symlink into Caskroom for reference 65 | let link = cask_version_install_path.join(name); 66 | let _ = fs::remove_file(&link); 67 | symlink(&dest, &link)?; 68 | installed.push(InstalledArtifact::CaskroomLink { 69 | link_path: link, 70 | target_path: dest, 71 | }); 72 | } 73 | } 74 | break; // single stanza 75 | } 76 | } 77 | } 78 | } 79 | 80 | Ok(installed) 81 | } 82 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/keyboard_layout.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/keyboard_layout.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs `keyboard_layout` bundles from the staging area into 16 | /// `~/Library/Keyboard Layouts`, then symlinks them into the Caskroom. 17 | /// 18 | /// Mirrors Homebrew’s `KeyboardLayout < Moved` behavior. 19 | pub fn install_keyboard_layout( 20 | cask: &Cask, 21 | stage_path: &Path, 22 | cask_version_install_path: &Path, 23 | config: &Config, 24 | ) -> Result> { 25 | let mut installed = Vec::new(); 26 | 27 | if let Some(artifacts_def) = &cask.artifacts { 28 | for art in artifacts_def { 29 | if let Some(obj) = art.as_object() { 30 | if let Some(entries) = obj.get("keyboard_layout").and_then(|v| v.as_array()) { 31 | // Target directory for user keyboard layouts 32 | let dest_dir = config.home_dir().join("Library").join("Keyboard Layouts"); 33 | fs::create_dir_all(&dest_dir)?; 34 | 35 | for entry in entries { 36 | if let Some(bundle_name) = entry.as_str() { 37 | let src = stage_path.join(bundle_name); 38 | if !src.exists() { 39 | warn!( 40 | "Keyboard layout '{}' not found in staging; skipping", 41 | bundle_name 42 | ); 43 | continue; 44 | } 45 | 46 | let dest = dest_dir.join(bundle_name); 47 | if dest.exists() { 48 | fs::remove_dir_all(&dest)?; 49 | } 50 | 51 | info!( 52 | "Installing keyboard layout '{}' → '{}'", 53 | src.display(), 54 | dest.display() 55 | ); 56 | // Try move, fallback to copy 57 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 58 | if !status.success() { 59 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 60 | } 61 | 62 | // Record moved bundle 63 | installed.push(InstalledArtifact::App { path: dest.clone() }); 64 | 65 | // Symlink into Caskroom 66 | let link = cask_version_install_path.join(bundle_name); 67 | let _ = fs::remove_file(&link); 68 | symlink(&dest, &link)?; 69 | installed.push(InstalledArtifact::CaskroomLink { 70 | link_path: link, 71 | target_path: dest, 72 | }); 73 | } 74 | } 75 | break; // one stanza only 76 | } 77 | } 78 | } 79 | } 80 | 81 | Ok(installed) 82 | } 83 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/manpage.rs: -------------------------------------------------------------------------------- 1 | // ===== src/build/cask/artifacts/manpage.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::sync::LazyLock; 7 | 8 | use regex::Regex; 9 | use tracing::{info, warn}; 10 | 11 | use crate::build::cask::InstalledArtifact; 12 | use crate::model::cask::Cask; 13 | use crate::utils::config::Config; 14 | use crate::utils::error::Result; 15 | 16 | // --- Moved Regex Creation Outside --- 17 | static MANPAGE_RE: LazyLock = 18 | LazyLock::new(|| Regex::new(r"\.([1-8nl])(?:\.gz)?$").unwrap()); 19 | 20 | /// Install any `manpage` stanzas from the Cask definition. 21 | /// Mirrors Homebrew’s `Cask::Artifact::Manpage < Symlinked` behavior 22 | /// :contentReference[oaicite:3]{index=3}. 23 | pub fn install_manpage( 24 | cask: &Cask, 25 | stage_path: &Path, 26 | _cask_version_install_path: &Path, // Not needed for symlinking manpages 27 | config: &Config, 28 | ) -> Result> { 29 | let mut installed = Vec::new(); 30 | 31 | // Look up the "manpage" array in the raw artifacts JSON :contentReference[oaicite:4]{index=4} 32 | if let Some(artifacts_def) = &cask.artifacts { 33 | for art in artifacts_def { 34 | if let Some(obj) = art.as_object() { 35 | if let Some(entries) = obj.get("manpage").and_then(|v| v.as_array()) { 36 | for entry in entries { 37 | if let Some(man_file) = entry.as_str() { 38 | let src = stage_path.join(man_file); 39 | if !src.exists() { 40 | warn!("Manpage '{}' not found in staging area, skipping", man_file); 41 | continue; 42 | } 43 | 44 | // Use the static regex 45 | let section = if let Some(caps) = MANPAGE_RE.captures(man_file) { 46 | caps.get(1).unwrap().as_str() 47 | } else { 48 | warn!( 49 | "Filename '{}' does not look like a manpage, skipping", 50 | man_file 51 | ); 52 | continue; 53 | }; 54 | 55 | // Build the target directory: e.g. /usr/local/share/man/man1 56 | // :contentReference[oaicite:6]{index=6} 57 | let man_dir = config.manpagedir().join(format!("man{section}")); 58 | fs::create_dir_all(&man_dir)?; 59 | 60 | // Determine the target path 61 | let file_name = Path::new(man_file).file_name().ok_or_else(|| { 62 | crate::utils::error::SapphireError::Generic(format!( 63 | "Invalid manpage filename: {man_file}" 64 | )) 65 | })?; // Handle potential None 66 | let dest = man_dir.join(file_name); 67 | 68 | // Remove any existing file or symlink 69 | // :contentReference[oaicite:7]{index=7} 70 | if dest.exists() || dest.symlink_metadata().is_ok() { 71 | fs::remove_file(&dest)?; 72 | } 73 | 74 | info!("Linking manpage '{}' → '{}'", src.display(), dest.display()); 75 | // Create the symlink 76 | symlink(&src, &dest)?; 77 | 78 | // Record it in our manifest 79 | installed.push(InstalledArtifact::CaskroomLink { 80 | link_path: dest.clone(), 81 | target_path: src.clone(), 82 | }); 83 | } 84 | } 85 | // Assume only one "manpage" stanza per Cask based on Homebrew structure 86 | break; 87 | } 88 | } 89 | } 90 | } 91 | 92 | Ok(installed) 93 | } 94 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/mdimporter.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/mdimporter.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs `mdimporter` bundles from the staging area into 16 | /// `~/Library/Spotlight`, then symlinks them into the Caskroom, 17 | /// and reloads them via `mdimport -r` so Spotlight picks them up. 18 | /// 19 | /// Mirrors Homebrew’s `Mdimporter < Moved` behavior. 20 | pub fn install_mdimporter( 21 | cask: &Cask, 22 | stage_path: &Path, 23 | cask_version_install_path: &Path, 24 | config: &Config, 25 | ) -> Result> { 26 | let mut installed = Vec::new(); 27 | 28 | if let Some(artifacts_def) = &cask.artifacts { 29 | for art in artifacts_def { 30 | if let Some(obj) = art.as_object() { 31 | if let Some(entries) = obj.get("mdimporter").and_then(|v| v.as_array()) { 32 | // Target directory for user Spotlight importers 33 | let dest_dir = config.home_dir().join("Library").join("Spotlight"); 34 | fs::create_dir_all(&dest_dir)?; 35 | 36 | for entry in entries { 37 | if let Some(bundle_name) = entry.as_str() { 38 | let src = stage_path.join(bundle_name); 39 | if !src.exists() { 40 | warn!( 41 | "Mdimporter bundle '{}' not found in staging; skipping", 42 | bundle_name 43 | ); 44 | continue; 45 | } 46 | 47 | let dest = dest_dir.join(bundle_name); 48 | if dest.exists() { 49 | fs::remove_dir_all(&dest)?; 50 | } 51 | 52 | info!( 53 | "Installing mdimporter '{}' → '{}',", 54 | src.display(), 55 | dest.display() 56 | ); 57 | // Try move, fallback to copy 58 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 59 | if !status.success() { 60 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 61 | } 62 | 63 | // Record moved importer 64 | installed.push(InstalledArtifact::App { path: dest.clone() }); 65 | 66 | // Symlink for reference 67 | let link = cask_version_install_path.join(bundle_name); 68 | let _ = fs::remove_file(&link); 69 | symlink(&dest, &link)?; 70 | installed.push(InstalledArtifact::CaskroomLink { 71 | link_path: link, 72 | target_path: dest.clone(), 73 | }); 74 | 75 | // Reload Spotlight importer so it's picked up immediately 76 | info!("Reloading Spotlight importer: {}", dest.display()); 77 | let _ = Command::new("/usr/bin/mdimport") 78 | .arg("-r") 79 | .arg(&dest) 80 | .status(); 81 | } 82 | } 83 | break; // one stanza only 84 | } 85 | } 86 | } 87 | } 88 | 89 | Ok(installed) 90 | } 91 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod audio_unit_plugin; 3 | pub mod binary; 4 | pub mod colorpicker; 5 | pub mod dictionary; 6 | pub mod font; 7 | pub mod input_method; 8 | pub mod installer; 9 | pub mod internet_plugin; 10 | pub mod keyboard_layout; 11 | pub mod manpage; 12 | pub mod mdimporter; 13 | pub mod pkg; 14 | pub mod preflight; 15 | pub mod prefpane; 16 | pub mod qlplugin; 17 | pub mod screen_saver; 18 | pub mod service; 19 | pub mod suite; 20 | pub mod uninstall; 21 | pub mod vst3_plugin; 22 | pub mod vst_plugin; 23 | pub mod zap; 24 | 25 | // Re‑export a single enum if you like: 26 | pub use self::app::install_app_from_staged; 27 | pub use self::audio_unit_plugin::install_audio_unit_plugin; 28 | pub use self::binary::install_binary; 29 | pub use self::colorpicker::install_colorpicker; 30 | pub use self::dictionary::install_dictionary; 31 | pub use self::font::install_font; 32 | pub use self::input_method::install_input_method; 33 | pub use self::installer::run_installer; 34 | pub use self::internet_plugin::install_internet_plugin; 35 | pub use self::keyboard_layout::install_keyboard_layout; 36 | pub use self::manpage::install_manpage; 37 | pub use self::mdimporter::install_mdimporter; 38 | pub use self::pkg::install_pkg_from_path; 39 | pub use self::preflight::run_preflight; 40 | pub use self::prefpane::install_prefpane; 41 | pub use self::qlplugin::install_qlplugin; 42 | pub use self::screen_saver::install_screen_saver; 43 | pub use self::service::install_service; 44 | pub use self::suite::install_suite; 45 | pub use self::uninstall::record_uninstall; 46 | pub use self::vst3_plugin::install_vst3_plugin; 47 | pub use self::vst_plugin::install_vst_plugin; 48 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/pkg.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::Path; 3 | use std::process::Command; 4 | 5 | use tracing::{debug, error, info}; 6 | 7 | use crate::build::cask::InstalledArtifact; 8 | use crate::model::cask::Cask; // Artifact type alias is just Value 9 | use crate::utils::config::Config; 10 | use crate::utils::error::{Result, SapphireError}; 11 | 12 | /// Installs a PKG file and returns details of artifacts created/managed. 13 | pub fn install_pkg_from_path( 14 | cask: &Cask, 15 | pkg_path: &Path, 16 | cask_version_install_path: &Path, // e.g., /opt/homebrew/Caskroom/foo/1.2.3 17 | _config: &Config, // Keep for potential future use 18 | ) -> Result> { 19 | // <-- Return type changed 20 | info!("Installing pkg file: {}", pkg_path.display()); 21 | 22 | if !pkg_path.exists() || !pkg_path.is_file() { 23 | return Err(SapphireError::NotFound(format!( 24 | "Package file not found or is not a file: {}", 25 | pkg_path.display() 26 | ))); 27 | } 28 | 29 | let pkg_name = pkg_path.file_name().ok_or_else(|| { 30 | SapphireError::Generic(format!("Invalid pkg path: {}", pkg_path.display())) 31 | })?; 32 | 33 | // --- Prepare list for artifacts --- 34 | let mut installed_artifacts: Vec = Vec::new(); 35 | 36 | // --- Copy PKG to Caskroom for Reference --- 37 | let caskroom_pkg_path = cask_version_install_path.join(pkg_name); 38 | debug!( 39 | "Copying pkg to caskroom for reference: {}", 40 | caskroom_pkg_path.display() 41 | ); 42 | if let Some(parent) = caskroom_pkg_path.parent() { 43 | fs::create_dir_all(parent).map_err(|e| { 44 | SapphireError::Io(std::io::Error::new( 45 | e.kind(), 46 | format!("Failed create parent dir {}: {}", parent.display(), e), 47 | )) 48 | })?; 49 | } 50 | if let Err(e) = fs::copy(pkg_path, &caskroom_pkg_path) { 51 | error!( 52 | "Failed to copy PKG {} to {}: {}", 53 | pkg_path.display(), 54 | caskroom_pkg_path.display(), 55 | e 56 | ); 57 | return Err(SapphireError::Io(std::io::Error::new( 58 | e.kind(), 59 | format!("Failed copy PKG to caskroom: {e}"), 60 | ))); 61 | } else { 62 | // Record the reference copy artifact 63 | installed_artifacts.push(InstalledArtifact::CaskroomReference { 64 | path: caskroom_pkg_path.clone(), 65 | }); 66 | } 67 | 68 | // --- Run Installer --- 69 | debug!("Running installer (this may require sudo)"); 70 | debug!( 71 | "Executing: sudo installer -pkg {} -target /", 72 | pkg_path.display() 73 | ); 74 | let output = Command::new("sudo") 75 | .arg("installer") 76 | .arg("-pkg") 77 | .arg(pkg_path) 78 | .arg("-target") 79 | .arg("/") 80 | .output() 81 | .map_err(|e| { 82 | SapphireError::Io(std::io::Error::new( 83 | e.kind(), 84 | format!("Failed to execute sudo installer: {e}"), 85 | )) 86 | })?; 87 | 88 | if !output.status.success() { 89 | let stderr = String::from_utf8_lossy(&output.stderr); 90 | error!("sudo installer failed ({}): {}", output.status, stderr); 91 | // Don't clean up the reference copy here, let the main process handle directory removal on 92 | // failure 93 | return Err(SapphireError::InstallError(format!( 94 | "Package installation failed for {}: {}", 95 | pkg_path.display(), 96 | stderr 97 | ))); 98 | } 99 | debug!("Successfully ran installer command."); 100 | let stdout = String::from_utf8_lossy(&output.stdout); 101 | if !stdout.trim().is_empty() { 102 | debug!("Installer stdout:\n{}", stdout); 103 | } 104 | 105 | // --- Record PkgUtil Receipts (based on cask definition) --- 106 | if let Some(artifacts) = &cask.artifacts { 107 | // artifacts is Option> 108 | for artifact_value in artifacts.iter() { 109 | if let Some(uninstall_array) = 110 | artifact_value.get("uninstall").and_then(|v| v.as_array()) 111 | { 112 | for stanza_value in uninstall_array { 113 | if let Some(stanza_obj) = stanza_value.as_object() { 114 | if let Some(pkgutil_id) = stanza_obj.get("pkgutil").and_then(|v| v.as_str()) 115 | { 116 | debug!("Found pkgutil ID to record: {}", pkgutil_id); 117 | // Check for duplicates before adding 118 | let new_artifact = InstalledArtifact::PkgUtilReceipt { 119 | id: pkgutil_id.to_string(), 120 | }; 121 | if !installed_artifacts.contains(&new_artifact) { 122 | // Need PartialEq for InstalledArtifact 123 | installed_artifacts.push(new_artifact); 124 | } 125 | } 126 | // Consider other uninstall keys like launchctl, delete? 127 | } 128 | } 129 | } 130 | // Optionally check "zap" stanzas too 131 | } 132 | } 133 | info!("Successfully installed pkg: {}", pkg_path.display()); 134 | Ok(installed_artifacts) // <-- Return collected artifacts 135 | } 136 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/preflight.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::process::Command; 3 | 4 | use tracing::debug; 5 | 6 | use crate::build::cask::InstalledArtifact; 7 | use crate::model::cask::Cask; 8 | use crate::utils::config::Config; 9 | use crate::utils::error::{Result, SapphireError}; 10 | 11 | /// Execute any `preflight` commands listed in the Cask’s JSON artifact stanza. 12 | /// Returns an empty Vec since preflight does not produce install artifacts. 13 | pub fn run_preflight( 14 | cask: &Cask, 15 | stage_path: &Path, 16 | _config: &Config, 17 | ) -> Result> { 18 | // Iterate over artifacts, look for "preflight" keys 19 | if let Some(entries) = &cask.artifacts { 20 | for entry in entries.iter().filter_map(|v| v.as_object()) { 21 | if let Some(cmds) = entry.get("preflight").and_then(|v| v.as_array()) { 22 | for cmd_val in cmds.iter().filter_map(|v| v.as_str()) { 23 | // Substitute $STAGEDIR placeholder 24 | let cmd_str = cmd_val.replace("$STAGEDIR", stage_path.to_str().unwrap()); 25 | debug!("Running preflight: {}", cmd_str); 26 | let status = Command::new("sh").arg("-c").arg(&cmd_str).status()?; 27 | if !status.success() { 28 | return Err(SapphireError::InstallError(format!( 29 | "preflight failed: {cmd_str}" 30 | ))); 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | // No install artifacts to return 38 | Ok(Vec::new()) 39 | } 40 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/prefpane.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/prefpane.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Implements the `prefpane` stanza by moving each declared 16 | /// preference pane bundle from the staging area into 17 | /// `~/Library/PreferencePanes`, then symlinking it in the Caskroom. 18 | /// 19 | /// Mirrors Homebrew’s `Prefpane < Moved` pattern. 20 | pub fn install_prefpane( 21 | cask: &Cask, 22 | stage_path: &Path, 23 | cask_version_install_path: &Path, 24 | config: &Config, 25 | ) -> Result> { 26 | let mut installed = Vec::new(); 27 | 28 | // Look for "prefpane" entries in the JSON artifacts 29 | if let Some(artifacts_def) = &cask.artifacts { 30 | for art in artifacts_def { 31 | if let Some(obj) = art.as_object() { 32 | if let Some(entries) = obj.get("prefpane").and_then(|v| v.as_array()) { 33 | // Target directory for user preference panes 34 | let dest_dir = config.home_dir().join("Library").join("PreferencePanes"); 35 | fs::create_dir_all(&dest_dir)?; 36 | 37 | for entry in entries { 38 | if let Some(bundle_name) = entry.as_str() { 39 | let src = stage_path.join(bundle_name); 40 | if !src.exists() { 41 | warn!( 42 | "Preference pane '{}' not found in staging; skipping", 43 | bundle_name 44 | ); 45 | continue; 46 | } 47 | 48 | let dest = dest_dir.join(bundle_name); 49 | if dest.exists() { 50 | fs::remove_dir_all(&dest)?; 51 | } 52 | 53 | info!( 54 | "Installing prefpane '{}' → '{}'", 55 | src.display(), 56 | dest.display() 57 | ); 58 | // Try move, fallback to copy 59 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 60 | if !status.success() { 61 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 62 | } 63 | 64 | // Record moved bundle 65 | installed.push(InstalledArtifact::App { path: dest.clone() }); 66 | 67 | // Symlink into Caskroom for reference 68 | let link = cask_version_install_path.join(bundle_name); 69 | let _ = fs::remove_file(&link); 70 | symlink(&dest, &link)?; 71 | installed.push(InstalledArtifact::CaskroomLink { 72 | link_path: link, 73 | target_path: dest, 74 | }); 75 | } 76 | } 77 | break; // single stanza 78 | } 79 | } 80 | } 81 | } 82 | 83 | Ok(installed) 84 | } 85 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/qlplugin.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/qlplugin.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs `qlplugin` bundles from the staging area into 16 | /// `~/Library/QuickLook`, then symlinks them into the Caskroom. 17 | /// 18 | /// Mirrors Homebrew’s `QuickLook < Moved` pattern for QuickLook plugins. 19 | pub fn install_qlplugin( 20 | cask: &Cask, 21 | stage_path: &Path, 22 | cask_version_install_path: &Path, 23 | config: &Config, 24 | ) -> Result> { 25 | let mut installed = Vec::new(); 26 | 27 | // Look for "qlplugin" entries in the JSON artifacts 28 | if let Some(artifacts_def) = &cask.artifacts { 29 | for art in artifacts_def { 30 | if let Some(obj) = art.as_object() { 31 | if let Some(entries) = obj.get("qlplugin").and_then(|v| v.as_array()) { 32 | // Target directory for QuickLook plugins 33 | let dest_dir = config.home_dir().join("Library").join("QuickLook"); 34 | fs::create_dir_all(&dest_dir)?; 35 | 36 | for entry in entries { 37 | if let Some(bundle_name) = entry.as_str() { 38 | let src = stage_path.join(bundle_name); 39 | if !src.exists() { 40 | warn!( 41 | "QuickLook plugin '{}' not found in staging; skipping", 42 | bundle_name 43 | ); 44 | continue; 45 | } 46 | 47 | let dest = dest_dir.join(bundle_name); 48 | if dest.exists() { 49 | fs::remove_dir_all(&dest)?; 50 | } 51 | 52 | info!( 53 | "Installing QuickLook plugin '{}' → '{}',", 54 | src.display(), 55 | dest.display() 56 | ); 57 | // Try move, fallback to copy 58 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 59 | if !status.success() { 60 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 61 | } 62 | 63 | // Record moved plugin 64 | installed.push(InstalledArtifact::App { path: dest.clone() }); 65 | 66 | // Symlink into Caskroom for reference 67 | let link = cask_version_install_path.join(bundle_name); 68 | let _ = fs::remove_file(&link); 69 | symlink(&dest, &link)?; 70 | installed.push(InstalledArtifact::CaskroomLink { 71 | link_path: link, 72 | target_path: dest, 73 | }); 74 | } 75 | } 76 | break; // single stanza 77 | } 78 | } 79 | } 80 | } 81 | 82 | Ok(installed) 83 | } 84 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/screen_saver.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/screen_saver.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs `screen_saver` bundles from the staging area into 16 | /// `~/Library/Screen Savers`, then symlinks them into the Caskroom. 17 | /// 18 | /// Mirrors Homebrew’s `ScreenSaver < Moved` pattern. 19 | pub fn install_screen_saver( 20 | cask: &Cask, 21 | stage_path: &Path, 22 | cask_version_install_path: &Path, 23 | config: &Config, 24 | ) -> Result> { 25 | let mut installed = Vec::new(); 26 | 27 | if let Some(artifacts_def) = &cask.artifacts { 28 | for art in artifacts_def { 29 | if let Some(obj) = art.as_object() { 30 | if let Some(entries) = obj.get("screen_saver").and_then(|v| v.as_array()) { 31 | // Target directory for user screen savers 32 | let dest_dir = config.home_dir().join("Library").join("Screen Savers"); 33 | fs::create_dir_all(&dest_dir)?; 34 | 35 | for entry in entries { 36 | if let Some(bundle_name) = entry.as_str() { 37 | let src = stage_path.join(bundle_name); 38 | if !src.exists() { 39 | warn!( 40 | "Screen saver '{}' not found in staging; skipping", 41 | bundle_name 42 | ); 43 | continue; 44 | } 45 | 46 | let dest = dest_dir.join(bundle_name); 47 | if dest.exists() { 48 | fs::remove_dir_all(&dest)?; 49 | } 50 | 51 | info!( 52 | "Installing screen saver '{}' → '{}',", 53 | src.display(), 54 | dest.display() 55 | ); 56 | // Try move, fallback to copy 57 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 58 | if !status.success() { 59 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 60 | } 61 | 62 | // Record moved screen saver 63 | installed.push(InstalledArtifact::App { path: dest.clone() }); 64 | 65 | // Symlink into Caskroom for reference 66 | let link = cask_version_install_path.join(bundle_name); 67 | let _ = fs::remove_file(&link); 68 | symlink(&dest, &link)?; 69 | installed.push(InstalledArtifact::CaskroomLink { 70 | link_path: link, 71 | target_path: dest, 72 | }); 73 | } 74 | } 75 | break; // single stanza 76 | } 77 | } 78 | } 79 | } 80 | 81 | Ok(installed) 82 | } 83 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/service.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/service.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs `service` artifacts by moving each declared 16 | /// Automator workflow or service bundle from the staging area into 17 | /// `~/Library/Services`, then symlinking it in the Caskroom. 18 | /// 19 | /// Mirrors Homebrew’s `Service < Moved` behavior. 20 | pub fn install_service( 21 | cask: &Cask, 22 | stage_path: &Path, 23 | cask_version_install_path: &Path, 24 | config: &Config, 25 | ) -> Result> { 26 | let mut installed = Vec::new(); 27 | 28 | if let Some(artifacts_def) = &cask.artifacts { 29 | for art in artifacts_def { 30 | if let Some(obj) = art.as_object() { 31 | if let Some(entries) = obj.get("service").and_then(|v| v.as_array()) { 32 | // Target directory for user Services 33 | let dest_dir = config.home_dir().join("Library").join("Services"); 34 | fs::create_dir_all(&dest_dir)?; 35 | 36 | for entry in entries { 37 | if let Some(bundle_name) = entry.as_str() { 38 | let src = stage_path.join(bundle_name); 39 | if !src.exists() { 40 | warn!( 41 | "Service bundle '{}' not found in staging; skipping", 42 | bundle_name 43 | ); 44 | continue; 45 | } 46 | 47 | let dest = dest_dir.join(bundle_name); 48 | if dest.exists() { 49 | fs::remove_dir_all(&dest)?; 50 | } 51 | 52 | info!( 53 | "Installing service '{}' → '{}'", 54 | src.display(), 55 | dest.display() 56 | ); 57 | // Try move, fallback to copy 58 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 59 | if !status.success() { 60 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 61 | } 62 | 63 | // Record moved service 64 | installed.push(InstalledArtifact::App { path: dest.clone() }); 65 | 66 | // Symlink into Caskroom for reference 67 | let link = cask_version_install_path.join(bundle_name); 68 | let _ = fs::remove_file(&link); 69 | symlink(&dest, &link)?; 70 | installed.push(InstalledArtifact::CaskroomLink { 71 | link_path: link, 72 | target_path: dest, 73 | }); 74 | } 75 | } 76 | break; // one stanza only 77 | } 78 | } 79 | } 80 | } 81 | 82 | Ok(installed) 83 | } 84 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/suite.rs: -------------------------------------------------------------------------------- 1 | // src/build/cask/artifacts/suite.rs 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Implements the `suite` stanza by moving each named directory from 16 | /// the staging area into `/Applications`, then symlinking it in the Caskroom. 17 | /// 18 | /// Mirrors Homebrew’s Suite < Moved behavior (dirmethod :appdir) 19 | /// :contentReference[oaicite:3]{index=3} 20 | pub fn install_suite( 21 | cask: &Cask, 22 | stage_path: &Path, 23 | cask_version_install_path: &Path, 24 | config: &Config, 25 | ) -> Result> { 26 | let mut installed = Vec::new(); 27 | 28 | // Find the `suite` definition in the raw JSON artifacts 29 | if let Some(artifacts_def) = &cask.artifacts { 30 | for art in artifacts_def.iter() { 31 | if let Some(obj) = art.as_object() { 32 | if let Some(entries) = obj.get("suite").and_then(|v| v.as_array()) { 33 | for entry in entries { 34 | if let Some(dir_name) = entry.as_str() { 35 | let src = stage_path.join(dir_name); 36 | if !src.exists() { 37 | warn!( 38 | "Suite directory '{}' not found in staging, skipping", 39 | dir_name 40 | ); 41 | continue; 42 | } 43 | 44 | let dest_dir = config.applications_dir(); // e.g. /Applications 45 | let dest = dest_dir.join(dir_name); // e.g. /Applications/Foobar Suite 46 | if dest.exists() { 47 | fs::remove_dir_all(&dest)?; // remove old 48 | } 49 | 50 | info!("Moving suite '{}' → '{}'", src.display(), dest.display()); 51 | // Try a rename (mv); fall back to recursive copy if cross‑filesystem 52 | let mv_status = Command::new("mv").arg(&src).arg(&dest).status()?; 53 | if !mv_status.success() { 54 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 55 | } 56 | 57 | // Record as an App artifact (a directory moved into /Applications) 58 | installed.push(InstalledArtifact::App { path: dest.clone() }); 59 | 60 | // Then symlink it under Caskroom for reference 61 | let link = cask_version_install_path.join(dir_name); 62 | let _ = fs::remove_file(&link); 63 | symlink(&dest, &link)?; 64 | installed.push(InstalledArtifact::CaskroomLink { 65 | link_path: link, 66 | target_path: dest, 67 | }); 68 | } 69 | } 70 | break; // only one "suite" stanza per cask 71 | } 72 | } 73 | } 74 | } 75 | 76 | Ok(installed) 77 | } 78 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/uninstall.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::build::cask::InstalledArtifact; 4 | use crate::model::cask::Cask; 5 | use crate::utils::error::Result; 6 | 7 | /// At install time, scan the `uninstall` stanza and turn each directive 8 | /// into an InstalledArtifact variant, so it can later be torn down. 9 | pub fn record_uninstall(cask: &Cask) -> Result> { 10 | let mut artifacts = Vec::new(); 11 | 12 | if let Some(entries) = &cask.artifacts { 13 | for entry in entries.iter().filter_map(|v| v.as_object()) { 14 | if let Some(steps) = entry.get("uninstall").and_then(|v| v.as_array()) { 15 | for step in steps.iter().filter_map(|v| v.as_object()) { 16 | for (key, val) in step { 17 | match key.as_str() { 18 | "pkgutil" => { 19 | if let Some(id) = val.as_str() { 20 | artifacts.push(InstalledArtifact::PkgUtilReceipt { 21 | id: id.to_string(), 22 | }); 23 | } 24 | } 25 | "delete" => { 26 | if let Some(arr) = val.as_array() { 27 | for p in arr.iter().filter_map(|v| v.as_str()) { 28 | artifacts.push(InstalledArtifact::App { 29 | path: PathBuf::from(p), 30 | }); 31 | } 32 | } 33 | } 34 | "rmdir" => { 35 | if let Some(arr) = val.as_array() { 36 | for p in arr.iter().filter_map(|v| v.as_str()) { 37 | artifacts.push(InstalledArtifact::App { 38 | path: PathBuf::from(p), 39 | }); 40 | } 41 | } 42 | } 43 | "launchctl" => { 44 | if let Some(arr) = val.as_array() { 45 | for lbl in arr.iter().filter_map(|v| v.as_str()) { 46 | artifacts.push(InstalledArtifact::Launchd { 47 | label: lbl.to_string(), 48 | path: None, 49 | }); 50 | } 51 | } 52 | } 53 | // Add other uninstall keys similarly... 54 | _ => {} 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | Ok(artifacts) 63 | } 64 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/vst3_plugin.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/vst3_plugin.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs `vst3_plugin` bundles from the staging area into 16 | /// `~/Library/Audio/Plug-Ins/VST3`, then symlinks them into the Caskroom. 17 | /// 18 | /// Mirrors Homebrew’s `Vst3Plugin < Moved` pattern. 19 | pub fn install_vst3_plugin( 20 | cask: &Cask, 21 | stage_path: &Path, 22 | cask_version_install_path: &Path, 23 | config: &Config, 24 | ) -> Result> { 25 | let mut installed = Vec::new(); 26 | 27 | if let Some(artifacts_def) = &cask.artifacts { 28 | for art in artifacts_def { 29 | if let Some(obj) = art.as_object() { 30 | if let Some(entries) = obj.get("vst3_plugin").and_then(|v| v.as_array()) { 31 | // Target directory for VST3 plugins 32 | let dest_dir = config 33 | .home_dir() 34 | .join("Library") 35 | .join("Audio") 36 | .join("Plug-Ins") 37 | .join("VST3"); 38 | fs::create_dir_all(&dest_dir)?; 39 | 40 | for entry in entries { 41 | if let Some(bundle_name) = entry.as_str() { 42 | let src = stage_path.join(bundle_name); 43 | if !src.exists() { 44 | warn!( 45 | "VST3 plugin '{}' not found in staging; skipping", 46 | bundle_name 47 | ); 48 | continue; 49 | } 50 | 51 | let dest = dest_dir.join(bundle_name); 52 | if dest.exists() { 53 | fs::remove_dir_all(&dest)?; 54 | } 55 | 56 | info!( 57 | "Installing VST3 plugin '{}' → '{}'", 58 | src.display(), 59 | dest.display() 60 | ); 61 | // Try move, fallback to copy 62 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 63 | if !status.success() { 64 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 65 | } 66 | 67 | // Record moved plugin 68 | installed.push(InstalledArtifact::App { path: dest.clone() }); 69 | 70 | // Symlink into Caskroom for reference 71 | let link = cask_version_install_path.join(bundle_name); 72 | let _ = fs::remove_file(&link); 73 | symlink(&dest, &link)?; 74 | installed.push(InstalledArtifact::CaskroomLink { 75 | link_path: link, 76 | target_path: dest, 77 | }); 78 | } 79 | } 80 | break; // single stanza 81 | } 82 | } 83 | } 84 | } 85 | 86 | Ok(installed) 87 | } 88 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/artifacts/vst_plugin.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/cask/artifacts/vst_plugin.rs ===== 2 | 3 | use std::fs; 4 | use std::os::unix::fs::symlink; 5 | use std::path::Path; 6 | use std::process::Command; 7 | 8 | use tracing::{info, warn}; 9 | 10 | use crate::build::cask::InstalledArtifact; 11 | use crate::model::cask::Cask; 12 | use crate::utils::config::Config; 13 | use crate::utils::error::Result; 14 | 15 | /// Installs `vst_plugin` bundles from the staging area into 16 | /// `~/Library/Audio/Plug-Ins/VST`, then symlinks them into the Caskroom. 17 | /// 18 | /// Mirrors Homebrew’s `VstPlugin < Moved` pattern. 19 | pub fn install_vst_plugin( 20 | cask: &Cask, 21 | stage_path: &Path, 22 | cask_version_install_path: &Path, 23 | config: &Config, 24 | ) -> Result> { 25 | let mut installed = Vec::new(); 26 | 27 | if let Some(artifacts_def) = &cask.artifacts { 28 | for art in artifacts_def { 29 | if let Some(obj) = art.as_object() { 30 | if let Some(entries) = obj.get("vst_plugin").and_then(|v| v.as_array()) { 31 | // Target directory for VST plugins 32 | let dest_dir = config 33 | .home_dir() 34 | .join("Library") 35 | .join("Audio") 36 | .join("Plug-Ins") 37 | .join("VST"); 38 | fs::create_dir_all(&dest_dir)?; 39 | 40 | for entry in entries { 41 | if let Some(bundle_name) = entry.as_str() { 42 | let src = stage_path.join(bundle_name); 43 | if !src.exists() { 44 | warn!( 45 | "VST plugin '{}' not found in staging; skipping", 46 | bundle_name 47 | ); 48 | continue; 49 | } 50 | 51 | let dest = dest_dir.join(bundle_name); 52 | if dest.exists() { 53 | fs::remove_dir_all(&dest)?; 54 | } 55 | 56 | info!( 57 | "Installing VST plugin '{}' → '{}'", 58 | src.display(), 59 | dest.display() 60 | ); 61 | // Try move, fallback to copy 62 | let status = Command::new("mv").arg(&src).arg(&dest).status()?; 63 | if !status.success() { 64 | Command::new("cp").arg("-R").arg(&src).arg(&dest).status()?; 65 | } 66 | 67 | // Record moved plugin 68 | installed.push(InstalledArtifact::App { path: dest.clone() }); 69 | 70 | // Symlink into Caskroom for reference 71 | let link = cask_version_install_path.join(bundle_name); 72 | let _ = fs::remove_file(&link); 73 | symlink(&dest, &link)?; 74 | installed.push(InstalledArtifact::CaskroomLink { 75 | link_path: link, 76 | target_path: dest, 77 | }); 78 | } 79 | } 80 | break; // single stanza 81 | } 82 | } 83 | } 84 | } 85 | 86 | Ok(installed) 87 | } 88 | -------------------------------------------------------------------------------- /sapphire-core/src/build/cask/dmg.rs: -------------------------------------------------------------------------------- 1 | // In sapphire-core/src/build/cask/dmg.rs 2 | 3 | use std::fs; 4 | use std::io::{BufRead, BufReader}; 5 | use std::path::{Path, PathBuf}; 6 | use std::process::Command; 7 | 8 | use tracing::{debug, error, warn}; 9 | 10 | use crate::utils::error::{Result, SapphireError}; // Added log imports 11 | 12 | // --- Keep Existing Helpers --- 13 | pub fn mount_dmg(dmg_path: &Path) -> Result { 14 | debug!("Mounting DMG: {}", dmg_path.display()); 15 | let output = Command::new("hdiutil") 16 | .arg("attach") 17 | .arg("-plist") 18 | .arg("-nobrowse") 19 | .arg("-readonly") 20 | .arg("-mountrandom") 21 | .arg("/tmp") // Consider making mount location configurable or more robust 22 | .arg(dmg_path) 23 | .output()?; 24 | 25 | if !output.status.success() { 26 | let stderr = String::from_utf8_lossy(&output.stderr); 27 | error!( 28 | "hdiutil attach failed for {}: {}", 29 | dmg_path.display(), 30 | stderr 31 | ); 32 | return Err(SapphireError::Generic(format!( 33 | "Failed to mount DMG '{}': {}", 34 | dmg_path.display(), 35 | stderr 36 | ))); 37 | } 38 | 39 | let mount_point = parse_mount_point(&output.stdout)?; 40 | debug!("DMG mounted at: {}", mount_point.display()); 41 | Ok(mount_point) 42 | } 43 | 44 | pub fn unmount_dmg(mount_point: &Path) -> Result<()> { 45 | debug!("Unmounting DMG from: {}", mount_point.display()); 46 | // Add logging for commands 47 | debug!("Executing: hdiutil detach -force {}", mount_point.display()); 48 | let output = Command::new("hdiutil") 49 | .arg("detach") 50 | .arg("-force") 51 | .arg(mount_point) 52 | .output()?; 53 | 54 | if !output.status.success() { 55 | let stderr = String::from_utf8_lossy(&output.stderr); 56 | warn!( 57 | "hdiutil detach failed ({}): {}. Trying diskutil...", 58 | output.status, stderr 59 | ); 60 | // Add logging for fallback 61 | debug!( 62 | "Executing: diskutil unmount force {}", 63 | mount_point.display() 64 | ); 65 | let diskutil_output = Command::new("diskutil") 66 | .arg("unmount") 67 | .arg("force") 68 | .arg(mount_point) 69 | .output()?; 70 | 71 | if !diskutil_output.status.success() { 72 | let diskutil_stderr = String::from_utf8_lossy(&diskutil_output.stderr); 73 | error!( 74 | "diskutil unmount force failed ({}): {}", 75 | diskutil_output.status, diskutil_stderr 76 | ); 77 | // Consider returning error only if both fail? Or always error on diskutil fail? 78 | return Err(SapphireError::Generic(format!( 79 | "Failed to unmount DMG '{}' using hdiutil and diskutil: {}", 80 | mount_point.display(), 81 | diskutil_stderr 82 | ))); 83 | } 84 | } 85 | debug!("DMG successfully unmounted"); 86 | Ok(()) 87 | } 88 | 89 | fn parse_mount_point(output: &[u8]) -> Result { 90 | // ... (existing implementation) ... 91 | // Use plist crate for more robust parsing if possible in the future 92 | let cursor = std::io::Cursor::new(output); 93 | let reader = BufReader::new(cursor); 94 | let mut in_sys_entities = false; 95 | let mut in_mount_point = false; 96 | let mut mount_path_str: Option = None; 97 | 98 | for line_res in reader.lines() { 99 | let line = line_res?; 100 | let trimmed = line.trim(); 101 | 102 | if trimmed == "system-entities" { 103 | in_sys_entities = true; 104 | continue; 105 | } 106 | if !in_sys_entities { 107 | continue; 108 | } 109 | 110 | if trimmed == "mount-point" { 111 | in_mount_point = true; 112 | continue; 113 | } 114 | 115 | if in_mount_point && trimmed.starts_with("") && trimmed.ends_with("") { 116 | mount_path_str = Some( 117 | trimmed 118 | .trim_start_matches("") 119 | .trim_end_matches("") 120 | .to_string(), 121 | ); 122 | break; // Found the first mount point, assume it's the main one 123 | } 124 | 125 | // Reset flags if we encounter closing tags for structures containing mount-point 126 | if trimmed == "" { 127 | in_mount_point = false; 128 | } 129 | if trimmed == "" && in_sys_entities { 130 | // End of system-entities 131 | // break; // Stop searching if we leave the system-entities array 132 | in_sys_entities = false; // Reset this flag too 133 | } 134 | } 135 | 136 | match mount_path_str { 137 | Some(path_str) if !path_str.is_empty() => { 138 | debug!("Parsed mount point from plist: {}", path_str); 139 | Ok(PathBuf::from(path_str)) 140 | } 141 | _ => { 142 | error!("Failed to parse mount point from hdiutil plist output."); 143 | // Optionally log the raw output for debugging 144 | // error!("Raw hdiutil output:\n{}", String::from_utf8_lossy(output)); 145 | Err(SapphireError::Generic( 146 | "Failed to determine mount point from hdiutil output".to_string(), 147 | )) 148 | } 149 | } 150 | } 151 | 152 | // --- NEW Function --- 153 | /// Extracts the contents of a mounted DMG to a staging directory using `ditto`. 154 | pub fn extract_dmg_to_stage(dmg_path: &Path, stage_dir: &Path) -> Result<()> { 155 | let mount_point = mount_dmg(dmg_path)?; 156 | 157 | // Ensure the stage directory exists (though TempDir should handle it) 158 | if !stage_dir.exists() { 159 | fs::create_dir_all(stage_dir).map_err(SapphireError::Io)?; 160 | } 161 | 162 | debug!( 163 | "Copying contents from DMG mount {} to stage {} using ditto...", 164 | mount_point.display(), 165 | stage_dir.display() 166 | ); 167 | // Use ditto for robust copying, preserving metadata 168 | // ditto 169 | debug!( 170 | "Executing: ditto {} {}", 171 | mount_point.display(), 172 | stage_dir.display() 173 | ); 174 | let ditto_output = Command::new("ditto") 175 | .arg(&mount_point) // Source first 176 | .arg(stage_dir) // Then destination 177 | .output()?; 178 | 179 | let unmount_result = unmount_dmg(&mount_point); // Unmount regardless of ditto success 180 | 181 | if !ditto_output.status.success() { 182 | let stderr = String::from_utf8_lossy(&ditto_output.stderr); 183 | error!("ditto command failed ({}): {}", ditto_output.status, stderr); 184 | // Also log stdout which might contain info on specific file errors 185 | let stdout = String::from_utf8_lossy(&ditto_output.stdout); 186 | if !stdout.trim().is_empty() { 187 | error!("ditto stdout: {}", stdout); 188 | } 189 | unmount_result?; // Ensure we still return unmount error if it happened 190 | return Err(SapphireError::Generic(format!( 191 | "Failed to copy DMG contents using ditto: {stderr}" 192 | ))); 193 | } 194 | 195 | unmount_result // Return the result of unmounting 196 | } 197 | -------------------------------------------------------------------------------- /sapphire-core/src/build/devtools.rs: -------------------------------------------------------------------------------- 1 | // **File:** sapphire-core/src/build/devtools.rs (New file) 2 | use std::env; 3 | use std::path::PathBuf; 4 | use std::process::{Command, Stdio}; 5 | 6 | use which; 7 | 8 | use crate::utils::error::{Result, SapphireError}; 9 | /// Finds the path to the specified compiler executable (e.g., "cc", "c++"). 10 | /// 11 | /// Tries environment variables (e.g., `CC`, `CXX`) first, then `xcrun` on macOS, 12 | /// then falls back to searching the system `PATH`. 13 | pub fn find_compiler(name: &str) -> Result { 14 | // 1. Check environment variables (CC for "cc", CXX for "c++") 15 | let env_var_name = match name { 16 | "cc" => "CC", 17 | "c++" | "cxx" => "CXX", 18 | _ => "", // Only handle common cases for now 19 | }; 20 | if !env_var_name.is_empty() { 21 | if let Ok(compiler_path) = env::var(env_var_name) { 22 | let path = PathBuf::from(compiler_path); 23 | if path.is_file() { 24 | println!( 25 | "Using compiler from env var {}: {}", 26 | env_var_name, 27 | path.display() 28 | ); 29 | return Ok(path); 30 | } else { 31 | println!( 32 | "Env var {} points to non-existent file: {}", 33 | env_var_name, 34 | path.display() 35 | ); 36 | } 37 | } 38 | } 39 | 40 | // 2. Use xcrun on macOS (if available) 41 | if cfg!(target_os = "macos") { 42 | println!("Attempting to find '{name}' using xcrun"); 43 | let output = Command::new("xcrun") 44 | .arg("--find") 45 | .arg(name) 46 | .stderr(Stdio::piped()) // Capture stderr for better error messages 47 | .output(); 48 | 49 | match output { 50 | Ok(out) if out.status.success() => { 51 | let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string(); 52 | if !path_str.is_empty() { 53 | let path = PathBuf::from(path_str); 54 | if path.is_file() { 55 | println!("Found compiler via xcrun: {}", path.display()); 56 | return Ok(path); 57 | } else { 58 | println!( 59 | "xcrun found '{}' but path doesn't exist or isn't a file: {}", 60 | name, 61 | path.display() 62 | ); 63 | } 64 | } else { 65 | println!("xcrun found '{name}' but returned empty path."); 66 | } 67 | } 68 | Ok(out) => { 69 | // xcrun ran but failed 70 | let stderr = String::from_utf8_lossy(&out.stderr); 71 | // Don't treat xcrun failure as fatal, just means it couldn't find it this way 72 | println!("xcrun failed to find '{}': {}", name, stderr.trim()); 73 | } 74 | Err(e) => { 75 | // xcrun command itself failed to execute (likely not installed or not in PATH) 76 | println!("Failed to execute xcrun: {e}. Falling back to PATH search."); 77 | } 78 | } 79 | } 80 | 81 | // 3. Fallback to searching PATH 82 | println!("Falling back to searching PATH for '{name}'"); 83 | which::which(name).map_err(|e| { 84 | SapphireError::BuildEnvError(format!("Failed to find compiler '{name}' on PATH: {e}")) 85 | }) 86 | } 87 | 88 | /// Finds the path to the active macOS SDK. 89 | /// Returns "/" on non-macOS platforms or if detection fails. 90 | pub fn find_sdk_path() -> Result { 91 | if cfg!(target_os = "macos") { 92 | println!("Attempting to find macOS SDK path using xcrun"); 93 | let output = Command::new("xcrun") 94 | .arg("--show-sdk-path") 95 | .stderr(Stdio::piped()) 96 | .output(); 97 | 98 | match output { 99 | Ok(out) if out.status.success() => { 100 | let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string(); 101 | if path_str.is_empty() || path_str == "/" { 102 | println!("xcrun returned empty or invalid SDK path ('{path_str}'). Check Xcode/CLT installation."); 103 | // Fallback or error? Homebrew errors here. Let's error. 104 | return Err(SapphireError::BuildEnvError( 105 | "xcrun returned empty or invalid SDK path. Is Xcode or Command Line Tools installed correctly?".to_string() 106 | )); 107 | } 108 | let sdk_path = PathBuf::from(path_str); 109 | if !sdk_path.exists() { 110 | return Err(SapphireError::BuildEnvError(format!( 111 | "SDK path reported by xcrun does not exist: {}", 112 | sdk_path.display() 113 | ))); 114 | } 115 | println!("Found SDK path: {}", sdk_path.display()); 116 | Ok(sdk_path) 117 | } 118 | Ok(out) => { 119 | // xcrun ran but failed 120 | let stderr = String::from_utf8_lossy(&out.stderr); 121 | Err(SapphireError::BuildEnvError(format!( 122 | "xcrun failed to find SDK path: {}", 123 | stderr.trim() 124 | ))) 125 | } 126 | Err(e) => { 127 | // xcrun command itself failed to execute 128 | Err(SapphireError::BuildEnvError(format!( 129 | "Failed to execute 'xcrun --show-sdk-path': {e}. Is Xcode or Command Line Tools installed?" 130 | ))) 131 | } 132 | } 133 | } else { 134 | // No SDK concept in this way on Linux/other platforms usually 135 | println!("Not on macOS, returning '/' as SDK path placeholder"); 136 | Ok(PathBuf::from("/")) 137 | } 138 | } 139 | 140 | /// Gets the macOS product version string (e.g., "14.4"). 141 | /// Returns "0.0" on non-macOS platforms. 142 | pub fn get_macos_version() -> Result { 143 | if cfg!(target_os = "macos") { 144 | println!("Attempting to get macOS version using sw_vers"); 145 | let output = Command::new("sw_vers") 146 | .arg("-productVersion") 147 | .stderr(Stdio::piped()) 148 | .output(); 149 | 150 | match output { 151 | Ok(out) if out.status.success() => { 152 | let version_full = String::from_utf8_lossy(&out.stdout).trim().to_string(); 153 | // Homebrew often uses major.minor, let's try to replicate that 154 | let version_parts: Vec<&str> = version_full.split('.').collect(); 155 | let version_short = if version_parts.len() >= 2 { 156 | format!("{}.{}", version_parts[0], version_parts[1]) 157 | } else { 158 | version_full.clone() // Fallback if format is unexpected 159 | }; 160 | println!("Found macOS version: {version_full} (short: {version_short})"); 161 | Ok(version_short) 162 | } 163 | Ok(out) => { 164 | // sw_vers ran but failed 165 | let stderr = String::from_utf8_lossy(&out.stderr); 166 | Err(SapphireError::BuildEnvError(format!( 167 | "sw_vers failed to get product version: {}", 168 | stderr.trim() 169 | ))) 170 | } 171 | Err(e) => { 172 | // sw_vers command itself failed to execute 173 | Err(SapphireError::BuildEnvError(format!( 174 | "Failed to execute 'sw_vers -productVersion': {e}" 175 | ))) 176 | } 177 | } 178 | } else { 179 | println!("Not on macOS, returning '0.0' as version placeholder"); 180 | Ok(String::from("0.0")) // Not applicable 181 | } 182 | } 183 | 184 | /// Gets the appropriate architecture flag (e.g., "-arch arm64") for the current build target. 185 | pub fn get_arch_flag() -> String { 186 | if cfg!(target_os = "macos") { 187 | // On macOS, we explicitly use -arch flags 188 | if cfg!(target_arch = "x86_64") { 189 | println!("Detected target arch: x86_64"); 190 | "-arch x86_64".to_string() 191 | } else if cfg!(target_arch = "aarch64") { 192 | println!("Detected target arch: aarch64 (arm64)"); 193 | "-arch arm64".to_string() 194 | } else { 195 | let arch = env::consts::ARCH; 196 | println!("Unknown target architecture on macOS: {arch}, cannot determine -arch flag. Build might fail."); 197 | // Provide no flag in this unknown case? Or default to native? 198 | // Homebrew might error or try native. Let's return empty for safety. 199 | String::new() 200 | } 201 | } else { 202 | // On Linux/other, -march=native is common but less portable for distribution. 203 | // Compilers usually target the host architecture by default without specific flags. 204 | // Let's return an empty string for non-macOS for now. Flags can be added later if needed. 205 | println!("Not on macOS, returning empty arch flag."); 206 | String::new() 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /sapphire-core/src/build/formula/source/cargo.rs: -------------------------------------------------------------------------------- 1 | // FILE: sapphire-core/src/build/formula/source/cargo.rs 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use tracing::{debug, info}; 7 | 8 | use crate::build::env::BuildEnvironment; 9 | use crate::build::formula::source::run_command_in_dir; 10 | use crate::utils::error::{Result, SapphireError}; 11 | 12 | pub fn cargo_build( 13 | source_dir: &Path, 14 | install_dir: &Path, 15 | build_env: &BuildEnvironment, 16 | ) -> Result<()> { 17 | info!("==> Building with Cargo in {}", source_dir.display()); 18 | let cargo_exe = 19 | which::which_in("cargo", build_env.get_path_string(), source_dir).map_err(|_| { 20 | SapphireError::BuildEnvError( 21 | "cargo command not found in build environment PATH.".to_string(), 22 | ) 23 | })?; 24 | 25 | info!( 26 | "==> Running cargo install --path . --root {}", 27 | install_dir.display() 28 | ); 29 | let mut cmd = Command::new(cargo_exe); 30 | cmd.arg("install") 31 | .arg("--path") 32 | .arg(".") // Build path is relative to the CWD where command runs 33 | .arg("--root") 34 | .arg(install_dir); 35 | 36 | run_command_in_dir(&mut cmd, source_dir, build_env, "cargo install")?; 37 | debug!("Cargo install completed successfully."); 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /sapphire-core/src/build/formula/source/cmake.rs: -------------------------------------------------------------------------------- 1 | // FILE: sapphire-core/src/build/formula/source/cmake.rs 2 | 3 | use std::fs; 4 | use std::path::Path; 5 | use std::process::Command; 6 | 7 | use tracing::{debug, info}; 8 | 9 | use crate::build::env::BuildEnvironment; 10 | use crate::build::formula::source::run_command_in_dir; 11 | use crate::utils::error::{Result, SapphireError}; 12 | 13 | pub fn cmake_build( 14 | source_subdir: &Path, 15 | build_dir: &Path, 16 | install_dir: &Path, 17 | build_env: &BuildEnvironment, 18 | ) -> Result<()> { 19 | info!("==> Building with CMake in {}", build_dir.display()); 20 | let cmake_build_subdir_name = "sapphire-cmake-build"; 21 | let cmake_build_dir = build_dir.join(cmake_build_subdir_name); 22 | fs::create_dir_all(&cmake_build_dir).map_err(SapphireError::Io)?; 23 | 24 | let cmake_exe = 25 | which::which_in("cmake", build_env.get_path_string(), build_dir).map_err(|_| { 26 | SapphireError::BuildEnvError( 27 | "cmake command not found in build environment PATH.".to_string(), 28 | ) 29 | })?; 30 | 31 | info!( 32 | "==> Running cmake configuration (source: {}, build: {})", 33 | build_dir.join(source_subdir).display(), 34 | cmake_build_dir.display() 35 | ); 36 | 37 | let mut cmd_configure = Command::new(cmake_exe); 38 | cmd_configure 39 | .arg(build_dir.join(source_subdir)) 40 | .arg(format!("-DCMAKE_INSTALL_PREFIX={}", install_dir.display())) 41 | .arg("-DCMAKE_POLICY_VERSION_MINIMUM=3.5") 42 | .arg("-DCMAKE_BUILD_TYPE=Release") 43 | .args([ 44 | "-G", 45 | "Ninja", 46 | "-DCMAKE_FIND_FRAMEWORK=LAST", 47 | "-DCMAKE_VERBOSE_MAKEFILE=ON", 48 | "-Wno-dev", 49 | ]); 50 | 51 | let configure_output = run_command_in_dir( 52 | &mut cmd_configure, 53 | &cmake_build_dir, 54 | build_env, 55 | "cmake configure", 56 | )?; 57 | debug!( 58 | "CMake configure stdout:\n{}", 59 | String::from_utf8_lossy(&configure_output.stdout) 60 | ); 61 | debug!( 62 | "CMake configure stderr:\n{}", 63 | String::from_utf8_lossy(&configure_output.stderr) 64 | ); 65 | 66 | info!("==> Running ninja install in {}", cmake_build_dir.display()); 67 | let ninja_exe = which::which_in("ninja", build_env.get_path_string(), &cmake_build_dir) 68 | .map_err(|_| { 69 | SapphireError::BuildEnvError( 70 | "ninja command not found in build environment PATH.".to_string(), 71 | ) 72 | })?; 73 | 74 | let mut cmd_install = Command::new(ninja_exe); 75 | cmd_install.arg("install"); 76 | 77 | run_command_in_dir( 78 | &mut cmd_install, 79 | &cmake_build_dir, 80 | build_env, 81 | "ninja install", 82 | )?; 83 | debug!("Ninja install completed successfully."); 84 | 85 | Ok(()) 86 | } 87 | -------------------------------------------------------------------------------- /sapphire-core/src/build/formula/source/go.rs: -------------------------------------------------------------------------------- 1 | // FILE: sapphire-core/src/build/formula/source/go.rs 2 | 3 | use std::fs; 4 | use std::path::{Path, PathBuf}; 5 | use std::process::Command; 6 | 7 | use tracing::{debug, info}; 8 | 9 | use crate::build::env::BuildEnvironment; 10 | use crate::build::formula::source::run_command_in_dir; 11 | use crate::utils::error::{Result, SapphireError}; 12 | 13 | pub fn go_build( 14 | source_dir: &Path, 15 | install_dir: &Path, 16 | build_env: &BuildEnvironment, 17 | _all_installed_paths: &[PathBuf], // Keep if needed later, currently unused 18 | ) -> Result<()> { 19 | info!("==> Building Go module in {}", source_dir.display()); 20 | 21 | let go_exe = which::which_in("go", build_env.get_path_string(), source_dir).map_err(|_| { 22 | SapphireError::BuildEnvError("go command not found in build environment PATH.".to_string()) 23 | })?; 24 | 25 | let formula_name = install_dir 26 | .parent() 27 | .and_then(|p| p.file_name()) 28 | .and_then(|n| n.to_str()) 29 | .ok_or_else(|| { 30 | SapphireError::BuildEnvError(format!( 31 | "Could not infer formula name from install path: {}", 32 | install_dir.display() 33 | )) 34 | })?; 35 | 36 | let cmd_pkg_path = source_dir.join("cmd").join(formula_name); 37 | let package_to_build = if cmd_pkg_path.is_dir() { 38 | debug!( 39 | "Found potential command package path: {}", 40 | cmd_pkg_path.display() 41 | ); 42 | format!("./cmd/{formula_name}") // Relative to source_dir 43 | } else { 44 | debug!("Command package path not found, building '.'"); 45 | ".".to_string() 46 | }; 47 | 48 | let target_bin_dir = install_dir.join("bin"); 49 | fs::create_dir_all(&target_bin_dir).map_err(|e| { 50 | SapphireError::Io(std::io::Error::new( 51 | e.kind(), 52 | format!("Failed create target bin dir: {e}"), 53 | )) 54 | })?; 55 | let output_binary_path = target_bin_dir.join(formula_name); 56 | 57 | info!( 58 | "==> Running: go build -o {} -ldflags \"-s -w\" {}", 59 | output_binary_path.display(), 60 | package_to_build 61 | ); 62 | 63 | let mut cmd = Command::new(go_exe); 64 | #[allow(clippy::suspicious_command_arg_space)] 65 | cmd.arg("build") 66 | .arg("-o") 67 | .arg(&output_binary_path) 68 | .arg("-ldflags") 69 | .arg("-s -w") 70 | .arg(&package_to_build); 71 | 72 | let build_output = run_command_in_dir(&mut cmd, source_dir, build_env, "go build")?; 73 | 74 | debug!( 75 | "Go build stdout:\n{}", 76 | String::from_utf8_lossy(&build_output.stdout) 77 | ); 78 | debug!( 79 | "Go build stderr:\n{}", 80 | String::from_utf8_lossy(&build_output.stderr) 81 | ); 82 | info!( 83 | "Go build successful, binary placed at: {}", 84 | output_binary_path.display() 85 | ); 86 | 87 | Ok(()) 88 | } 89 | -------------------------------------------------------------------------------- /sapphire-core/src/build/formula/source/make.rs: -------------------------------------------------------------------------------- 1 | // FILE: sapphire-core/src/build/formula/source/make.rs 2 | 3 | use std::fs; 4 | use std::io::Read; 5 | use std::os::unix::fs::PermissionsExt; 6 | use std::path::Path; 7 | use std::process::Command; 8 | 9 | use tracing::{debug, error, info, warn}; 10 | 11 | use crate::build::env::BuildEnvironment; 12 | use crate::build::formula::source::run_command_in_dir; 13 | use crate::utils::error::{Result, SapphireError}; 14 | 15 | fn is_gnu_autotools_configure(script_path: &Path) -> bool { 16 | const READ_BUFFER_SIZE: usize = 4096; 17 | const AUTOCONF_MARKERS: &[&str] = &[ 18 | "Generated by GNU Autoconf", 19 | "generated by autoconf", 20 | "config.status:", 21 | ]; 22 | 23 | let mut buffer = String::with_capacity(READ_BUFFER_SIZE); 24 | match fs::File::open(script_path).and_then(|mut file| file.read_to_string(&mut buffer)) { 25 | Ok(_) => { 26 | for marker in AUTOCONF_MARKERS { 27 | if buffer.contains(marker) { 28 | debug!( 29 | "Detected Autotools marker ('{}') in {}", 30 | marker, 31 | script_path.display() 32 | ); 33 | return true; 34 | } 35 | } 36 | debug!( 37 | "No specific Autotools markers found in {}", 38 | script_path.display() 39 | ); 40 | false 41 | } 42 | Err(e) => { 43 | warn!( 44 | "Could not read {} to check for Autotools: {}.", 45 | script_path.display(), 46 | e 47 | ); 48 | false 49 | } 50 | } 51 | } 52 | 53 | pub fn configure_and_make( 54 | source_dir: &Path, 55 | install_dir: &Path, 56 | build_env: &BuildEnvironment, 57 | ) -> Result<()> { 58 | info!("==> Configuring and Making in {}", source_dir.display()); 59 | let configure_script_path = source_dir.join("configure"); 60 | 61 | if !configure_script_path.exists() { 62 | return Err(SapphireError::BuildEnvError(format!( 63 | "./configure script not found in {}", 64 | source_dir.display() 65 | ))); 66 | } 67 | 68 | let is_autotools = is_gnu_autotools_configure(&configure_script_path); 69 | 70 | info!("==> Running ./configure --prefix={}", install_dir.display()); 71 | if is_autotools { 72 | info!(" (Detected Autotools flags)"); 73 | } 74 | 75 | let mut cmd_configure = Command::new("./configure"); 76 | cmd_configure.arg(format!("--prefix={}", install_dir.display())); 77 | if is_autotools { 78 | cmd_configure.args(["--disable-dependency-tracking", "--disable-silent-rules"]); 79 | } 80 | 81 | let configure_output = 82 | run_command_in_dir(&mut cmd_configure, source_dir, build_env, "configure")?; 83 | debug!( 84 | "Configure stdout:\n{}", 85 | String::from_utf8_lossy(&configure_output.stdout) 86 | ); 87 | debug!( 88 | "Configure stderr:\n{}", 89 | String::from_utf8_lossy(&configure_output.stderr) 90 | ); 91 | 92 | let make_exe = which::which_in("make", build_env.get_path_string(), source_dir) 93 | .or_else(|_| which::which("make")) 94 | .map_err(|_| SapphireError::BuildEnvError("make command not found.".to_string()))?; 95 | 96 | info!("==> Running make"); 97 | let mut cmd_make = Command::new(make_exe.clone()); 98 | run_command_in_dir(&mut cmd_make, source_dir, build_env, "make")?; 99 | debug!("Make completed successfully."); 100 | 101 | info!("==> Running make install"); 102 | let mut cmd_install = Command::new(make_exe); 103 | cmd_install.arg("install"); 104 | run_command_in_dir(&mut cmd_install, source_dir, build_env, "make install")?; 105 | debug!("Make install completed successfully."); 106 | 107 | Ok(()) 108 | } 109 | 110 | pub fn simple_make( 111 | source_dir: &Path, 112 | install_dir: &Path, 113 | build_env: &BuildEnvironment, 114 | ) -> Result<()> { 115 | let make_exe = which::which_in("make", build_env.get_path_string(), source_dir) 116 | .or_else(|_| which::which("make")) 117 | .map_err(|_| SapphireError::BuildEnvError("make command not found.".to_string()))?; 118 | 119 | info!("==> Running make"); 120 | let mut cmd_make = Command::new(make_exe.clone()); 121 | let make_output = run_command_in_dir(&mut cmd_make, source_dir, build_env, "make")?; 122 | info!("Make completed successfully."); 123 | debug!( 124 | "Make stdout:\n{}", 125 | String::from_utf8_lossy(&make_output.stdout) 126 | ); 127 | debug!( 128 | "Make stderr:\n{}", 129 | String::from_utf8_lossy(&make_output.stderr) 130 | ); 131 | 132 | info!("==> Running make install PREFIX={}", install_dir.display()); 133 | let mut cmd_install = Command::new(make_exe); 134 | cmd_install.arg("install"); 135 | cmd_install.arg(format!("PREFIX={}", install_dir.display())); 136 | 137 | let install_output_result = run_command_in_dir( 138 | &mut cmd_install, 139 | source_dir, 140 | build_env, 141 | "make install (simple)", 142 | ); 143 | let make_install_succeeded = install_output_result.is_ok(); 144 | 145 | if !make_install_succeeded { 146 | warn!("'make install' failed. Will check for manually installable artifacts."); 147 | // Corrected line: Collapsed the nested if let 148 | if let Err(SapphireError::CommandExecError(msg)) = &install_output_result { 149 | // Use '&' to borrow 150 | if let Some(output) = extract_output_from_exec_error(msg) { 151 | debug!( 152 | "Make install stdout:\n{}", 153 | String::from_utf8_lossy(&output.stdout) 154 | ); 155 | debug!( 156 | "Make install stderr:\n{}", 157 | String::from_utf8_lossy(&output.stderr) 158 | ); 159 | } 160 | } 161 | } else { 162 | info!("Make install completed successfully."); 163 | if let Ok(ref output) = install_output_result { 164 | debug!( 165 | "Make install stdout:\n{}", 166 | String::from_utf8_lossy(&output.stdout) 167 | ); 168 | debug!( 169 | "Make install stderr:\n{}", 170 | String::from_utf8_lossy(&output.stderr) 171 | ); 172 | } 173 | } 174 | 175 | let bin_dir = install_dir.join("bin"); 176 | let bin_populated = 177 | bin_dir.exists() && bin_dir.read_dir().is_ok_and(|mut d| d.next().is_some()); 178 | 179 | if !bin_populated { 180 | warn!( 181 | "Installation directory '{}' is empty after 'make install'. Attempting manual artifact installation.", 182 | bin_dir.display() 183 | ); 184 | 185 | let formula_name = install_dir 186 | .parent() 187 | .and_then(|p| p.file_name()) 188 | .and_then(|n| n.to_str()) 189 | .unwrap_or(""); 190 | let potential_binary_path = source_dir.join(formula_name); 191 | let mut found_and_installed_manually = false; 192 | 193 | if !formula_name.is_empty() && potential_binary_path.is_file() { 194 | info!( 195 | "Found potential binary '{}'. Manually installing...", 196 | potential_binary_path.display() 197 | ); 198 | fs::create_dir_all(&bin_dir)?; 199 | 200 | let target_path = bin_dir.join(formula_name); 201 | fs::copy(&potential_binary_path, &target_path).map_err(|e| { 202 | SapphireError::Io(std::io::Error::new( 203 | e.kind(), 204 | format!("Failed copy binary: {e}"), 205 | )) 206 | })?; 207 | 208 | #[cfg(unix)] 209 | { 210 | let mut perms = fs::metadata(&target_path)?.permissions(); 211 | perms.set_mode(0o755); 212 | fs::set_permissions(&target_path, perms)?; 213 | info!("Set executable permissions on {}", target_path.display()); 214 | } 215 | 216 | found_and_installed_manually = true; 217 | } else { 218 | warn!( 219 | "Could not find executable named '{}' in build directory for manual installation.", 220 | formula_name 221 | ); 222 | } 223 | 224 | if !make_install_succeeded && !found_and_installed_manually { 225 | error!("make install failed and could not find/install artifacts manually."); 226 | // Return the original error by propagating it 227 | return install_output_result.map(|_| ()); 228 | } else if !found_and_installed_manually && make_install_succeeded { 229 | warn!("make install reported success, but '{}' was not populated and no executable found manually.", bin_dir.display()); 230 | } 231 | } else { 232 | info!( 233 | "Installation directory '{}' appears populated.", 234 | bin_dir.display() 235 | ); 236 | } 237 | 238 | Ok(()) 239 | } 240 | 241 | fn extract_output_from_exec_error(msg: &str) -> Option { 242 | // This is a basic heuristic, might need refinement 243 | // We assume the `run_command_in_dir` error format includes Status/Stdout/Stderr strings 244 | // It's better if `run_command_in_dir` returns the Output struct on error. 245 | // For now, we return None as we can't reliably parse it back. 246 | let _ = msg; // Avoid unused warning 247 | None 248 | } 249 | -------------------------------------------------------------------------------- /sapphire-core/src/build/formula/source/meson.rs: -------------------------------------------------------------------------------- 1 | // FILE: sapphire-core/src/build/formula/source/meson.rs 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use tracing::{debug, info}; 7 | 8 | use crate::build::env::BuildEnvironment; 9 | use crate::build::formula::source::run_command_in_dir; 10 | use crate::utils::error::{Result, SapphireError}; 11 | 12 | pub fn meson_build( 13 | source_subdir: &Path, 14 | build_dir: &Path, 15 | install_dir: &Path, 16 | build_env: &BuildEnvironment, 17 | ) -> Result<()> { 18 | info!("==> Building with Meson in {}", build_dir.display()); 19 | let meson_build_subdir_name = "sapphire-meson-build"; 20 | let meson_build_dir = build_dir.join(meson_build_subdir_name); 21 | let source_root_abs = build_dir.join(source_subdir); 22 | 23 | let meson_exe = 24 | which::which_in("meson", build_env.get_path_string(), build_dir).map_err(|_| { 25 | SapphireError::BuildEnvError( 26 | "meson command not found in build environment PATH.".to_string(), 27 | ) 28 | })?; 29 | 30 | info!( 31 | "==> Running meson setup (source: {}, build: {})", 32 | source_root_abs.display(), 33 | meson_build_dir.display() 34 | ); 35 | 36 | let mut cmd_setup = Command::new(&meson_exe); 37 | cmd_setup 38 | .arg("setup") 39 | .arg(format!("--prefix={}", install_dir.display())) 40 | .arg("--buildtype=release") 41 | .arg("--libdir=lib") 42 | .arg(&meson_build_dir) 43 | .arg("."); // Source directory is CWD for the command 44 | 45 | let setup_output = 46 | run_command_in_dir(&mut cmd_setup, &source_root_abs, build_env, "meson setup")?; 47 | debug!( 48 | "Meson setup stdout:\n{}", 49 | String::from_utf8_lossy(&setup_output.stdout) 50 | ); 51 | debug!( 52 | "Meson setup stderr:\n{}", 53 | String::from_utf8_lossy(&setup_output.stderr) 54 | ); 55 | 56 | info!("==> Running meson install -C {}", meson_build_dir.display()); 57 | let _ninja_exe = which::which_in("ninja", build_env.get_path_string(), build_dir) 58 | .map_err(|_| SapphireError::BuildEnvError("ninja command not found.".to_string()))?; 59 | 60 | let mut cmd_install = Command::new(&meson_exe); 61 | cmd_install.arg("install").arg("-C").arg(&meson_build_dir); 62 | 63 | run_command_in_dir( 64 | &mut cmd_install, 65 | &source_root_abs, 66 | build_env, 67 | "meson install", 68 | )?; 69 | debug!("Meson install completed successfully."); 70 | 71 | Ok(()) 72 | } 73 | -------------------------------------------------------------------------------- /sapphire-core/src/build/formula/source/perl.rs: -------------------------------------------------------------------------------- 1 | // FILE: sapphire-core/src/build/formula/source/perl.rs 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use tracing::{debug, info}; 7 | 8 | use crate::build::env::BuildEnvironment; 9 | use crate::build::formula::source::run_command_in_dir; 10 | use crate::utils::error::{Result, SapphireError}; 11 | 12 | pub fn perl_build( 13 | source_dir: &Path, 14 | install_dir: &Path, 15 | build_env: &BuildEnvironment, 16 | ) -> Result<()> { 17 | let configure_script = source_dir.join("Configure"); 18 | let makefile_pl = source_dir.join("Makefile.PL"); 19 | 20 | if configure_script.is_file() { 21 | info!( 22 | "==> Building with Perl Configure script in {}", 23 | source_dir.display() 24 | ); 25 | let sh_exe = which::which_in("sh", build_env.get_path_string(), source_dir) 26 | .map_err(|_| SapphireError::BuildEnvError("sh command not found.".to_string()))?; 27 | 28 | let mut cmd_configure = Command::new(sh_exe); 29 | cmd_configure 30 | .arg("./Configure") // Relative to CWD where command runs 31 | .arg("-des") 32 | .arg(format!("-Dprefix={}", install_dir.display())); 33 | 34 | let configure_output = 35 | run_command_in_dir(&mut cmd_configure, source_dir, build_env, "Perl Configure")?; 36 | debug!( 37 | "Perl Configure stdout:\n{}", 38 | String::from_utf8_lossy(&configure_output.stdout) 39 | ); 40 | debug!( 41 | "Perl Configure stderr:\n{}", 42 | String::from_utf8_lossy(&configure_output.stderr) 43 | ); 44 | } else if makefile_pl.is_file() { 45 | info!( 46 | "==> Building with Perl Makefile.PL in {}", 47 | source_dir.display() 48 | ); 49 | let perl_exe = which::which_in("perl", build_env.get_path_string(), source_dir) 50 | .map_err(|_| SapphireError::BuildEnvError("perl command not found.".to_string()))?; 51 | 52 | let mut cmd_makefile = Command::new(perl_exe); 53 | cmd_makefile 54 | .arg("Makefile.PL") 55 | .arg(format!("PREFIX={}", install_dir.display())); 56 | 57 | let makefile_output = 58 | run_command_in_dir(&mut cmd_makefile, source_dir, build_env, "perl Makefile.PL")?; 59 | debug!( 60 | "perl Makefile.PL stdout:\n{}", 61 | String::from_utf8_lossy(&makefile_output.stdout) 62 | ); 63 | debug!( 64 | "perl Makefile.PL stderr:\n{}", 65 | String::from_utf8_lossy(&makefile_output.stderr) 66 | ); 67 | } else { 68 | return Err(SapphireError::BuildEnvError( 69 | "Neither Perl Configure nor Makefile.PL script found.".to_string(), 70 | )); 71 | } 72 | 73 | let make_exe = which::which_in("make", build_env.get_path_string(), source_dir) 74 | .map_err(|_| SapphireError::BuildEnvError("make command not found.".to_string()))?; 75 | 76 | info!("==> Running make for Perl"); 77 | let mut cmd_make = Command::new(make_exe.clone()); 78 | run_command_in_dir(&mut cmd_make, source_dir, build_env, "make (perl)")?; 79 | info!("Perl make completed successfully."); 80 | 81 | info!("==> Running make install for Perl"); 82 | let mut cmd_install = Command::new(make_exe); 83 | cmd_install.arg("install"); 84 | run_command_in_dir( 85 | &mut cmd_install, 86 | source_dir, 87 | build_env, 88 | "make install (perl)", 89 | )?; 90 | info!("Perl make install completed successfully."); 91 | 92 | Ok(()) 93 | } 94 | -------------------------------------------------------------------------------- /sapphire-core/src/build/formula/source/python.rs: -------------------------------------------------------------------------------- 1 | // FILE: sapphire-core/src/build/formula/source/python.rs 2 | 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | use tracing::{debug, info}; 7 | 8 | use crate::build::env::BuildEnvironment; 9 | use crate::build::formula::source::run_command_in_dir; // Ensure helper is imported if used 10 | use crate::utils::error::{Result, SapphireError}; 11 | 12 | // Corrected signature: Added source_dir argument 13 | pub fn python_build( 14 | source_dir: &Path, 15 | install_dir: &Path, 16 | build_env: &BuildEnvironment, 17 | ) -> Result<()> { 18 | info!( 19 | "==> Building with Python setup.py in {}", 20 | source_dir.display() 21 | ); 22 | let python_exe = which::which_in("python3", build_env.get_path_string(), source_dir) 23 | .or_else(|_| which::which_in("python", build_env.get_path_string(), source_dir)) 24 | .map_err(|_| { 25 | SapphireError::BuildEnvError("python3 or python command not found.".to_string()) 26 | })?; 27 | 28 | info!( 29 | "==> Running {} setup.py install --prefix={}", 30 | python_exe.display(), 31 | install_dir.display() 32 | ); 33 | let mut cmd = Command::new(python_exe); 34 | cmd.arg("setup.py") 35 | .arg("install") 36 | .arg(format!("--prefix={}", install_dir.display())); 37 | 38 | // Use the helper function to run the command in the correct directory 39 | run_command_in_dir(&mut cmd, source_dir, build_env, "python setup.py install")?; 40 | debug!("Python install completed successfully."); 41 | 42 | Ok(()) 43 | } 44 | -------------------------------------------------------------------------------- /sapphire-core/src/build/mod.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/build/mod.rs ===== 2 | // Main module for build functionality 3 | // Removed deprecated functions and re-exports. 4 | 5 | use std::path::PathBuf; 6 | 7 | use crate::model::formula::Formula; 8 | use crate::utils::config::Config; 9 | 10 | // --- Submodules --- 11 | pub mod cask; 12 | pub mod devtools; 13 | pub mod env; 14 | pub mod extract; 15 | pub mod formula; // <-- Declare the extract module 16 | 17 | // --- Re-exports --- 18 | pub use extract::extract_archive; // <-- Re-export the main function from extract.rs 19 | // Re-export relevant functions from formula submodule 20 | pub use formula::{get_formula_cellar_path, write_receipt}; 21 | 22 | // --- Path helpers using Config --- 23 | pub fn get_formula_opt_path(formula: &Formula, config: &Config) -> PathBuf { 24 | // Use Config method 25 | config.formula_opt_link_path(formula.name()) 26 | } 27 | 28 | // --- DEPRECATED EXTRACTION FUNCTIONS REMOVED --- 29 | -------------------------------------------------------------------------------- /sapphire-core/src/dependency/definition.rs: -------------------------------------------------------------------------------- 1 | // **File:** sapphire-core/src/dependency/dependency.rs // Should be in the model module 2 | use std::fmt; 3 | 4 | use bitflags::bitflags; 5 | use serde::{Deserialize, Serialize}; // For derive macros and attributes 6 | 7 | bitflags! { 8 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 9 | /// Tags associated with a dependency, mirroring Homebrew's concepts. 10 | pub struct DependencyTag: u8 { 11 | /// Standard runtime dependency, needed for the formula to function. 12 | const RUNTIME = 0b00000001; 13 | /// Needed only at build time. 14 | const BUILD = 0b00000010; 15 | /// Needed for running tests (`brew test`). 16 | const TEST = 0b00000100; 17 | /// Optional dependency, installable via user flag (e.g., `--with-foo`). 18 | const OPTIONAL = 0b00001000; 19 | /// Recommended dependency, installed by default but can be skipped (e.g., `--without-bar`). 20 | const RECOMMENDED = 0b00010000; 21 | // Add other tags as needed (e.g., :implicit) 22 | } 23 | } 24 | 25 | impl Default for DependencyTag { 26 | // By default, a dependency is considered runtime unless specified otherwise. 27 | fn default() -> Self { 28 | Self::RUNTIME 29 | } 30 | } 31 | 32 | impl fmt::Display for DependencyTag { 33 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 34 | write!(f, "{self:?}") // Simple debug format for now 35 | } 36 | } 37 | 38 | /// Represents a dependency declared by a Formula. 39 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 40 | pub struct Dependency { 41 | /// The name of the formula dependency. 42 | pub name: String, 43 | /// Tags associated with this dependency (e.g., build, optional). 44 | #[serde(default)] // Use default tags (RUNTIME) if missing in serialization 45 | pub tags: DependencyTag, 46 | // We could add requirements here later: 47 | // pub requirements: Vec, 48 | } 49 | 50 | impl Dependency { 51 | /// Creates a new runtime dependency. 52 | pub fn new_runtime(name: impl Into) -> Self { 53 | Self { 54 | name: name.into(), 55 | tags: DependencyTag::RUNTIME, 56 | } 57 | } 58 | 59 | /// Creates a new dependency with specific tags. 60 | pub fn new_with_tags(name: impl Into, tags: DependencyTag) -> Self { 61 | Self { 62 | name: name.into(), 63 | tags, 64 | } 65 | } 66 | } 67 | 68 | /// Extension trait for Vec for easier filtering. 69 | pub trait DependencyExt { 70 | /// Filters dependencies based on included tags and excluded tags. 71 | /// For example, to get runtime dependencies that are *not* optional: 72 | /// `filter_by_tags(DependencyTag::RUNTIME, DependencyTag::OPTIONAL)` 73 | fn filter_by_tags(&self, include: DependencyTag, exclude: DependencyTag) -> Vec<&Dependency>; 74 | 75 | /// Get only runtime dependencies (excluding build, test). 76 | fn runtime(&self) -> Vec<&Dependency>; 77 | 78 | /// Get only build-time dependencies (includes :build, excludes others unless also :build). 79 | fn build_time(&self) -> Vec<&Dependency>; 80 | } 81 | 82 | impl DependencyExt for Vec { 83 | fn filter_by_tags(&self, include: DependencyTag, exclude: DependencyTag) -> Vec<&Dependency> { 84 | self.iter() 85 | .filter(|dep| dep.tags.contains(include) && !dep.tags.intersects(exclude)) 86 | .collect() 87 | } 88 | 89 | fn runtime(&self) -> Vec<&Dependency> { 90 | // Runtime deps are those *not* exclusively build or test 91 | // (A dep could be both runtime and build, e.g., a compiler needed at runtime too) 92 | self.iter() 93 | .filter(|dep| { 94 | !dep.tags 95 | .contains(DependencyTag::BUILD | DependencyTag::TEST) 96 | || dep.tags.contains(DependencyTag::RUNTIME) 97 | }) 98 | // Alternatively, be more explicit: include RUNTIME | RECOMMENDED | OPTIONAL 99 | // .filter(|dep| dep.tags.intersects(DependencyTag::RUNTIME | DependencyTag::RECOMMENDED 100 | // | DependencyTag::OPTIONAL)) 101 | .collect() 102 | } 103 | 104 | fn build_time(&self) -> Vec<&Dependency> { 105 | self.filter_by_tags(DependencyTag::BUILD, DependencyTag::empty()) 106 | } 107 | } 108 | 109 | // Required for bitflags! 110 | -------------------------------------------------------------------------------- /sapphire-core/src/dependency/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod definition; // Renamed from 'dependency' 2 | pub mod requirement; 3 | pub mod resolver; 4 | 5 | // Re-export key types for easier access 6 | pub use definition::{Dependency, DependencyExt, DependencyTag}; // Updated source module 7 | pub use requirement::Requirement; 8 | pub use resolver::{ 9 | DependencyResolver, ResolutionContext, ResolutionStatus, ResolvedDependency, ResolvedGraph, 10 | }; 11 | -------------------------------------------------------------------------------- /sapphire-core/src/dependency/requirement.rs: -------------------------------------------------------------------------------- 1 | // **File:** sapphire-core/src/dependency/requirement.rs (New file) 2 | use std::fmt; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /// Represents a requirement beyond a simple formula dependency. 7 | /// Placeholder - This needs significant expansion based on Homebrew's Requirement system. 8 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 9 | pub enum Requirement { 10 | /// Minimum macOS version required. 11 | MacOS(String), // e.g., "12.0" 12 | /// Minimum Xcode version required. 13 | Xcode(String), // e.g., "14.1" 14 | // Add others: Arch, specific libraries, environment variables, etc. 15 | /// Placeholder for unparsed or complex requirements. 16 | Other(String), 17 | } 18 | 19 | impl fmt::Display for Requirement { 20 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 21 | match self { 22 | Self::MacOS(v) => write!(f, "macOS >= {v}"), 23 | Self::Xcode(v) => write!(f, "Xcode >= {v}"), 24 | Self::Other(s) => write!(f, "Requirement: {s}"), 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /sapphire-core/src/fetch/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod http; 3 | pub mod oci; 4 | 5 | // Re-export 6 | pub use api::*; 7 | pub use oci::*; 8 | -------------------------------------------------------------------------------- /sapphire-core/src/formulary.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; // For caching parsed formulas 2 | use std::sync::Arc; 3 | 4 | // Removed: use std::fs; 5 | // Removed: use std::path::PathBuf; 6 | // Removed: const DEFAULT_CORE_TAP: &str = "homebrew/core"; 7 | use tracing::debug; 8 | 9 | use crate::model::formula::Formula; 10 | use crate::utils::cache::Cache; 11 | use crate::utils::config::Config; 12 | use crate::utils::error::{Result, SapphireError}; // Import the Cache struct // Import Arc for thread-safe shared ownership 13 | 14 | /// Responsible for finding and loading Formula definitions from the API cache. 15 | #[derive()] 16 | pub struct Formulary { 17 | // config: Config, // Keep config if needed for cache path, etc. 18 | cache: Cache, 19 | // Optional: Add a cache for *parsed* formulas to avoid repeated parsing of the large JSON 20 | parsed_cache: std::sync::Mutex>>, /* Using Arc for thread-safety */ 21 | } 22 | 23 | impl Formulary { 24 | pub fn new(config: Config) -> Self { 25 | // Initialize the cache helper using the directory from config 26 | let cache = Cache::new(&config.cache_dir).unwrap_or_else(|e| { 27 | // Handle error appropriately - maybe panic or return Result? 28 | // Using expect here for simplicity, but Result is better. 29 | panic!("Failed to initialize cache in Formulary: {e}"); 30 | }); 31 | Self { 32 | // config, 33 | cache, 34 | parsed_cache: std::sync::Mutex::new(HashMap::new()), 35 | } 36 | } 37 | 38 | // Removed: resolve_formula_path 39 | // Removed: parse_qualified_name 40 | 41 | /// Loads a formula definition by name from the API cache. 42 | pub fn load_formula(&self, name: &str) -> Result { 43 | // 1. Check parsed cache first 44 | let mut parsed_cache_guard = self.parsed_cache.lock().unwrap(); 45 | if let Some(formula_arc) = parsed_cache_guard.get(name) { 46 | debug!("Loaded formula '{}' from parsed cache.", name); 47 | return Ok(Arc::clone(formula_arc).as_ref().clone()); 48 | } 49 | // Release lock early if not found 50 | drop(parsed_cache_guard); 51 | 52 | // 2. Load the raw formula list from the main cache file 53 | debug!("Loading raw formula data from cache file 'formula.json'..."); 54 | let raw_data = self.cache.load_raw("formula.json")?; // Assumes update stored it here 55 | 56 | // 3. Parse the entire JSON array 57 | // This could be expensive, hence the parsed_cache above. 58 | debug!("Parsing full formula list..."); 59 | let all_formulas: Vec = serde_json::from_str(&raw_data).map_err(|e| { 60 | SapphireError::Cache(format!("Failed to parse cached formula data: {e}")) 61 | })?; 62 | debug!("Parsed {} formulas.", all_formulas.len()); 63 | 64 | // 4. Find the requested formula and populate the parsed cache 65 | let mut found_formula: Option = None; 66 | // Lock again to update the parsed cache 67 | parsed_cache_guard = self.parsed_cache.lock().unwrap(); 68 | // Use entry API to avoid redundant lookups if another thread populated it 69 | for formula in all_formulas { 70 | let formula_name = formula.name.clone(); // Clone name for insertion 71 | let formula_arc = std::sync::Arc::new(formula); // Create Arc once 72 | 73 | // If this is the formula we're looking for, store it for return value 74 | if formula_name == name { 75 | found_formula = Some(Arc::clone(&formula_arc).as_ref().clone()); 76 | // Clone Formula out 77 | } 78 | 79 | // Insert into parsed cache using entry API 80 | parsed_cache_guard 81 | .entry(formula_name) 82 | .or_insert(formula_arc); 83 | } 84 | 85 | // 5. Return the found formula or an error 86 | match found_formula { 87 | Some(f) => { 88 | debug!( 89 | "Successfully loaded formula '{}' version {}", 90 | f.name, 91 | f.version_str_full() 92 | ); 93 | Ok(f) 94 | } 95 | None => { 96 | debug!( 97 | "Formula '{}' not found within the cached formula data.", 98 | name 99 | ); 100 | Err(SapphireError::Generic(format!( 101 | "Formula '{name}' not found in cache." 102 | ))) 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /sapphire-core/src/keg.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::{Path, PathBuf}; 3 | 4 | use semver::Version; // Changed from crate::model::version::Version 5 | 6 | use crate::utils::config::Config; 7 | use crate::utils::error::{Result, SapphireError}; 8 | 9 | /// Represents information about an installed package (Keg). 10 | #[derive(Debug, Clone, PartialEq, Eq)] 11 | pub struct InstalledKeg { 12 | pub name: String, 13 | pub version: Version, // Use semver::Version 14 | pub path: PathBuf, // Path to the versioned installation directory (e.g., Cellar/foo/1.2.3) 15 | pub revision: u32, // Store revision separately 16 | } 17 | 18 | /// Manages querying installed packages in the Cellar. 19 | #[derive(Debug)] 20 | pub struct KegRegistry { 21 | config: Config, // Holds paths like cellar and prefix 22 | } 23 | 24 | impl KegRegistry { 25 | pub fn new(config: Config) -> Self { 26 | Self { config } 27 | } 28 | 29 | /// Gets the path to the directory containing all versions for a formula. 30 | fn formula_cellar_path(&self, name: &str) -> PathBuf { 31 | self.config.cellar.join(name) 32 | } 33 | 34 | /// Calculates the conventional 'opt' path for a formula (e.g., /opt/homebrew/opt/foo). 35 | /// This path typically points to the currently linked/active version. 36 | pub fn get_opt_path(&self, name: &str) -> PathBuf { 37 | self.config.prefix.join("opt").join(name) 38 | } 39 | 40 | /// Checks if a formula is installed and returns its Keg info if it is. 41 | /// If multiple versions are installed, returns the latest version (considering revisions). 42 | pub fn get_installed_keg(&self, name: &str) -> Result> { 43 | let formula_dir = self.formula_cellar_path(name); 44 | 45 | if !formula_dir.is_dir() { 46 | return Ok(None); 47 | } 48 | 49 | let mut latest_keg: Option = None; 50 | 51 | for entry_result in fs::read_dir(&formula_dir).map_err(SapphireError::Io)? { 52 | let entry = entry_result.map_err(SapphireError::Io)?; 53 | let path = entry.path(); 54 | 55 | if path.is_dir() { 56 | if let Some(version_str_full) = path.file_name().and_then(|n| n.to_str()) { 57 | // Separate version and revision 58 | let mut parts = version_str_full.splitn(2, '_'); 59 | let version_part = parts.next().unwrap_or(version_str_full); 60 | let revision = parts 61 | .next() 62 | .and_then(|s| s.parse::().ok()) 63 | .unwrap_or(0); 64 | 65 | // Attempt to parse the version part (pad if necessary) 66 | let version_str_padded = if version_part.split('.').count() < 3 { 67 | let v_parts: Vec<&str> = version_part.split('.').collect(); 68 | match v_parts.len() { 69 | 1 => format!("{}.0.0", v_parts[0]), 70 | 2 => format!("{}.{}.0", v_parts[0], v_parts[1]), 71 | _ => version_part.to_string(), 72 | } 73 | } else { 74 | version_part.to_string() 75 | }; 76 | 77 | if let Ok(version) = Version::parse(&version_str_padded) { 78 | let current_keg = InstalledKeg { 79 | name: name.to_string(), 80 | version: version.clone(), 81 | revision, 82 | path: path.clone(), 83 | }; 84 | 85 | // Compare with the latest found so far 86 | match latest_keg { 87 | Some(ref latest) => { 88 | if version > latest.version 89 | || (version == latest.version && revision > latest.revision) 90 | { 91 | latest_keg = Some(current_keg); 92 | } 93 | } 94 | None => { 95 | latest_keg = Some(current_keg); 96 | } 97 | } 98 | } 99 | // else: Ignore directories that don't parse as versions 100 | } 101 | } 102 | } 103 | 104 | Ok(latest_keg) 105 | } 106 | 107 | /// Lists all installed kegs. 108 | /// Reads the cellar directory and parses all valid keg structures found. 109 | // Ensure this method is public 110 | pub fn list_installed_kegs(&self) -> Result> { 111 | let mut installed_kegs = Vec::new(); 112 | let cellar_dir = self.cellar_path(); 113 | 114 | if !cellar_dir.is_dir() { 115 | return Ok(installed_kegs); // Cellar doesn't exist 116 | } 117 | 118 | // Iterate over formula name directories 119 | for formula_entry_res in fs::read_dir(cellar_dir).map_err(SapphireError::Io)? { 120 | let formula_entry = formula_entry_res.map_err(SapphireError::Io)?; 121 | let formula_path = formula_entry.path(); 122 | 123 | if formula_path.is_dir() { 124 | if let Some(formula_name) = formula_path.file_name().and_then(|n| n.to_str()) { 125 | // Iterate over version directories within the formula dir 126 | for version_entry_res in 127 | fs::read_dir(&formula_path).map_err(SapphireError::Io)? 128 | { 129 | let version_entry = version_entry_res.map_err(SapphireError::Io)?; 130 | let version_path = version_entry.path(); 131 | 132 | if version_path.is_dir() { 133 | if let Some(version_str_full) = 134 | version_path.file_name().and_then(|n| n.to_str()) 135 | { 136 | // Parse version and revision 137 | let mut parts = version_str_full.splitn(2, '_'); 138 | let version_part = parts.next().unwrap_or(version_str_full); 139 | let revision = parts 140 | .next() 141 | .and_then(|s| s.parse::().ok()) 142 | .unwrap_or(0); 143 | let version_str_padded = if version_part.split('.').count() < 3 { 144 | let v_parts: Vec<&str> = version_part.split('.').collect(); 145 | match v_parts.len() { 146 | 1 => format!("{}.0.0", v_parts[0]), 147 | 2 => format!("{}.{}.0", v_parts[0], v_parts[1]), 148 | _ => version_part.to_string(), 149 | } 150 | } else { 151 | version_part.to_string() 152 | }; 153 | 154 | if let Ok(version) = Version::parse(&version_str_padded) { 155 | installed_kegs.push(InstalledKeg { 156 | name: formula_name.to_string(), 157 | version, 158 | revision, 159 | path: version_path.clone(), 160 | }); 161 | } 162 | } 163 | } 164 | } 165 | } 166 | } 167 | } 168 | 169 | Ok(installed_kegs) 170 | } 171 | 172 | /// Returns the root path of the Cellar. 173 | pub fn cellar_path(&self) -> &Path { 174 | &self.config.cellar 175 | } 176 | 177 | /// Returns the path for a *specific* versioned keg (whether installed or not). 178 | /// Includes revision in the path name if revision > 0. 179 | pub fn get_keg_path(&self, name: &str, version: &Version, revision: u32) -> PathBuf { 180 | let version_string = if revision > 0 { 181 | format!("{version}_{revision}") 182 | } else { 183 | version.to_string() 184 | }; 185 | self.formula_cellar_path(name).join(version_string) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /sapphire-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | // sapphire-core/src/lib.rs 2 | // This is the main library file for the sapphire-core crate. 3 | // It declares and re-exports the public modules and types. 4 | 5 | // Declare the top-level modules within the library crate 6 | // These are directories with their own mod.rs files 7 | pub mod build; 8 | pub mod dependency; 9 | pub mod fetch; 10 | pub mod formulary; 11 | pub mod keg; 12 | pub mod model; 13 | pub mod tap; 14 | pub mod utils; 15 | 16 | // Re-export key types for easier use by the CLI crate 17 | pub use model::cask::Cask; 18 | pub use model::formula::Formula; 19 | pub use utils::config::Config; 20 | pub use utils::error::{Result, SapphireError}; 21 | 22 | // No need to redefine the Error type since we're re-exporting the existing one 23 | -------------------------------------------------------------------------------- /sapphire-core/src/model/cask.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/model/cask.rs ===== 2 | use std::collections::HashMap; 3 | use std::fs; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::utils::config::Config; // <-- Added import 8 | 9 | pub type Artifact = serde_json::Value; 10 | 11 | /// Represents the `url` field, which can be a simple string or a map with specs 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | #[serde(untagged)] 14 | pub enum UrlField { 15 | Simple(String), 16 | WithSpec { 17 | url: String, 18 | #[serde(default)] 19 | verified: Option, 20 | #[serde(flatten)] 21 | other: HashMap, 22 | }, 23 | } 24 | 25 | /// Represents the `sha256` field: hex, no_check, or per-architecture 26 | #[derive(Debug, Clone, Serialize, Deserialize)] 27 | #[serde(untagged)] 28 | pub enum Sha256Field { 29 | Hex(String), 30 | #[serde(rename_all = "snake_case")] 31 | NoCheck { 32 | no_check: bool, 33 | }, 34 | PerArch(HashMap), 35 | } 36 | 37 | /// Appcast metadata 38 | #[derive(Debug, Clone, Serialize, Deserialize)] 39 | pub struct Appcast { 40 | pub url: String, 41 | pub checkpoint: Option, 42 | } 43 | 44 | /// Represents conflicts with other casks or formulae 45 | #[derive(Debug, Clone, Serialize, Deserialize)] 46 | pub struct ConflictsWith { 47 | #[serde(default)] 48 | pub cask: Vec, 49 | #[serde(default)] 50 | pub formula: Vec, 51 | #[serde(flatten)] 52 | pub extra: HashMap, 53 | } 54 | 55 | /// Helper for architecture requirements: single string, list of strings, or list of spec objects 56 | #[derive(Debug, Clone, Serialize, Deserialize)] 57 | #[serde(untagged)] 58 | pub enum ArchReq { 59 | One(String), // e.g., "arm64" 60 | Many(Vec), // e.g., ["arm64", "x86_64"] 61 | Specs(Vec), // Add this variant to handle [{"type": "arm", "bits": 64}] 62 | } 63 | 64 | /// Helper for macOS requirements: symbol, list, comparison, or map 65 | #[derive(Debug, Clone, Serialize, Deserialize)] 66 | #[serde(untagged)] 67 | pub enum MacOSReq { 68 | Symbol(String), // ":big_sur" 69 | Symbols(Vec), // [":catalina", ":big_sur"] 70 | Comparison(String), // ">= :big_sur" 71 | Map(HashMap>), 72 | } 73 | 74 | /// Helper to coerce string-or-list into Vec 75 | #[derive(Debug, Clone, Serialize, Deserialize)] 76 | #[serde(untagged)] 77 | pub enum StringList { 78 | One(String), 79 | Many(Vec), 80 | } 81 | 82 | impl From for Vec { 83 | fn from(item: StringList) -> Self { 84 | match item { 85 | StringList::One(s) => vec![s], 86 | StringList::Many(v) => v, 87 | } 88 | } 89 | } 90 | 91 | /// Represents the specific architecture details found in some cask definitions 92 | #[derive(Debug, Clone, Serialize, Deserialize)] 93 | pub struct ArchSpec { 94 | #[serde(rename = "type")] // Map the JSON "type" field 95 | pub type_name: String, // e.g., "arm" 96 | pub bits: u32, // e.g., 64 97 | } 98 | 99 | /// Represents `depends_on` block with multiple possible keys 100 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 101 | pub struct DependsOn { 102 | #[serde(default)] 103 | pub cask: Vec, 104 | #[serde(default)] 105 | pub formula: Vec, 106 | #[serde(default)] 107 | pub arch: Option, 108 | #[serde(default)] 109 | pub macos: Option, 110 | #[serde(flatten)] 111 | pub extra: HashMap, 112 | } 113 | 114 | /// The main Cask model matching Homebrew JSON v2 115 | #[derive(Debug, Clone, Serialize, Deserialize)] 116 | pub struct Cask { 117 | pub token: String, 118 | 119 | #[serde(default)] 120 | pub name: Option>, 121 | pub version: Option, 122 | pub desc: Option, 123 | pub homepage: Option, 124 | 125 | #[serde(default)] 126 | pub artifacts: Option>, 127 | 128 | #[serde(default)] 129 | pub url: Option, 130 | #[serde(default)] 131 | pub url_specs: Option>, 132 | 133 | #[serde(default)] 134 | pub sha256: Option, 135 | 136 | pub appcast: Option, 137 | pub auto_updates: Option, 138 | 139 | #[serde(default)] 140 | pub depends_on: Option, 141 | 142 | #[serde(default)] 143 | pub conflicts_with: Option, 144 | 145 | pub caveats: Option, 146 | pub stage_only: Option, 147 | 148 | #[serde(default)] 149 | pub uninstall: Option>, 150 | #[serde(default)] 151 | pub zap: Option>, 152 | } 153 | 154 | #[derive(Debug, Clone, Serialize, Deserialize)] 155 | pub struct CaskList { 156 | pub casks: Vec, 157 | } 158 | 159 | impl Cask { 160 | /// Check if this cask is installed by looking for a manifest file 161 | /// in any versioned directory within the Caskroom. 162 | pub fn is_installed(&self, config: &Config) -> bool { 163 | let cask_dir = config.cask_dir(&self.token); // e.g., /opt/homebrew/Caskroom/firefox 164 | if !cask_dir.exists() || !cask_dir.is_dir() { 165 | return false; 166 | } 167 | 168 | // Iterate through entries (version dirs) inside the cask_dir 169 | match fs::read_dir(&cask_dir) { 170 | Ok(entries) => { 171 | // Clippy fix: Use flatten() to handle Result entries directly 172 | for entry in entries.flatten() { 173 | // <-- Use flatten() here 174 | let version_path = entry.path(); 175 | // Check if it's a directory (representing a version) 176 | if version_path.is_dir() { 177 | // Check for the existence of the manifest file 178 | let manifest_path = version_path.join("CASK_INSTALL_MANIFEST.json"); // <-- Correct filename 179 | if manifest_path.is_file() { 180 | // Found a manifest in at least one version directory, consider it 181 | // installed 182 | return true; 183 | } 184 | } 185 | } 186 | // If loop completes without finding a manifest in any version dir 187 | false 188 | } 189 | Err(e) => { 190 | // Log error if reading the directory fails, but assume not installed 191 | tracing::warn!( 192 | "Failed to read cask directory {} to check for installed versions: {}", 193 | cask_dir.display(), 194 | e 195 | ); 196 | false 197 | } 198 | } 199 | } 200 | 201 | /// Get the installed version of this cask by reading the directory names 202 | /// in the Caskroom. Returns the first version found (use cautiously if multiple 203 | /// versions could exist, though current install logic prevents this). 204 | pub fn installed_version(&self, config: &Config) -> Option { 205 | let cask_dir = config.cask_dir(&self.token); // 206 | if !cask_dir.exists() { 207 | return None; 208 | } 209 | // Iterate through entries and return the first directory name found 210 | match fs::read_dir(&cask_dir) { 211 | Ok(entries) => { 212 | // Clippy fix: Use flatten() 213 | for entry in entries.flatten() { 214 | // <-- Use flatten() here 215 | let path = entry.path(); 216 | // Check if it's a directory (representing a version) 217 | if path.is_dir() { 218 | if let Some(version_str) = path.file_name().and_then(|name| name.to_str()) { 219 | // Return the first version directory name found 220 | return Some(version_str.to_string()); 221 | } 222 | } 223 | } 224 | // No version directories found 225 | None 226 | } 227 | Err(_) => None, // Error reading directory 228 | } 229 | } 230 | 231 | /// Get a friendly name for display purposes 232 | pub fn display_name(&self) -> String { 233 | self.name 234 | .as_ref() 235 | .and_then(|names| names.first().cloned()) 236 | .unwrap_or_else(|| self.token.clone()) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /sapphire-core/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | // src/model/mod.rs 2 | // Declares the modules within the model directory. 3 | 4 | pub mod cask; 5 | pub mod formula; 6 | pub mod version; 7 | 8 | // Re-export 9 | pub use cask::Cask; 10 | pub use formula::Formula; 11 | -------------------------------------------------------------------------------- /sapphire-core/src/model/version.rs: -------------------------------------------------------------------------------- 1 | // **File:** sapphire-core/src/model/version.rs (New file) 2 | use std::fmt; 3 | use std::str::FromStr; 4 | 5 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 6 | 7 | use crate::utils::error::{Result, SapphireError}; 8 | 9 | /// Wrapper around semver::Version for formula versions. 10 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] 11 | pub struct Version(semver::Version); 12 | 13 | impl Version { 14 | pub fn parse(s: &str) -> Result { 15 | // Attempt standard semver parse first 16 | semver::Version::parse(s).map(Version).or_else(|_| { 17 | // Homebrew often uses versions like "1.2.3_1" (revision) or just "123" 18 | // Try to handle these by stripping suffixes or padding 19 | // This is a simplified handling, Homebrew's PkgVersion is complex 20 | let cleaned = s.split('_').next().unwrap_or(s); // Take part before _ 21 | let parts: Vec<&str> = cleaned.split('.').collect(); 22 | let padded = match parts.len() { 23 | 1 => format!("{}.0.0", parts[0]), 24 | 2 => format!("{}.{}.0", parts[0], parts[1]), 25 | _ => cleaned.to_string(), // Use original if 3+ parts 26 | }; 27 | semver::Version::parse(&padded).map(Version).map_err(|e| { 28 | SapphireError::VersionError(format!( 29 | "Failed to parse version '{s}' (tried '{padded}'): {e}" 30 | )) 31 | }) 32 | }) 33 | } 34 | } 35 | 36 | impl FromStr for Version { 37 | type Err = SapphireError; 38 | fn from_str(s: &str) -> std::result::Result { 39 | Self::parse(s) 40 | } 41 | } 42 | 43 | impl fmt::Display for Version { 44 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 45 | // TODO: Preserve original format if possible? PkgVersion complexity. 46 | // For now, display the parsed semver representation. 47 | write!(f, "{}", self.0) 48 | } 49 | } 50 | 51 | // Manual Serialize/Deserialize to handle the Version<->String conversion 52 | impl Serialize for Version { 53 | fn serialize(&self, serializer: S) -> std::result::Result 54 | where 55 | S: Serializer, 56 | { 57 | serializer.serialize_str(&self.to_string()) 58 | } 59 | } 60 | 61 | impl AsRef for Version { 62 | fn as_ref(&self) -> &Self { 63 | self 64 | } 65 | } 66 | 67 | // Removed redundant ToString implementation as it conflicts with the blanket implementation in std. 68 | 69 | impl From for semver::Version { 70 | fn from(version: Version) -> Self { 71 | version.0 72 | } 73 | } 74 | 75 | impl<'de> Deserialize<'de> for Version { 76 | fn deserialize(deserializer: D) -> std::result::Result 77 | where 78 | D: Deserializer<'de>, 79 | { 80 | let s = String::deserialize(deserializer)?; 81 | Self::from_str(&s).map_err(serde::de::Error::custom) 82 | } 83 | } 84 | 85 | // Add to sapphire-core/src/utils/error.rs: 86 | // #[error("Version error: {0}")] 87 | // VersionError(String), 88 | 89 | // Add to sapphire-core/Cargo.toml: 90 | // [dependencies] 91 | // semver = "1.0" 92 | -------------------------------------------------------------------------------- /sapphire-core/src/tap/definition.rs: -------------------------------------------------------------------------------- 1 | // tap/tap.rs - Basic tap functionality // Should probably be in model module 2 | 3 | use std::path::PathBuf; 4 | 5 | use crate::utils::error::{Result, SapphireError}; 6 | 7 | /// Represents a source of packages (formulas and casks) 8 | pub struct Tap { 9 | /// The user part of the tap name (e.g., "homebrew" in "homebrew/core") 10 | pub user: String, 11 | 12 | /// The repository part of the tap name (e.g., "core" in "homebrew/core") 13 | pub repo: String, 14 | 15 | /// The full path to the tap directory 16 | pub path: PathBuf, 17 | } 18 | 19 | impl Tap { 20 | /// Create a new tap from user/repo format 21 | pub fn new(name: &str) -> Result { 22 | let parts: Vec<&str> = name.split('/').collect(); 23 | if parts.len() != 2 { 24 | return Err(SapphireError::Generic(format!("Invalid tap name: {name}"))); 25 | } 26 | let user = parts[0].to_string(); 27 | let repo = parts[1].to_string(); 28 | let prefix = if cfg!(target_arch = "aarch64") { 29 | PathBuf::from("/opt/homebrew") 30 | } else { 31 | PathBuf::from("/usr/local") 32 | }; 33 | let path = prefix 34 | .join("Library/Taps") 35 | .join(&user) 36 | .join(format!("homebrew-{repo}")); 37 | Ok(Self { user, repo, path }) 38 | } 39 | 40 | /// Update this tap by pulling latest changes 41 | pub fn update(&self) -> Result<()> { 42 | use git2::{FetchOptions, Repository}; 43 | 44 | let repo = Repository::open(&self.path) 45 | .map_err(|e| SapphireError::Generic(format!("Failed to open tap repository: {e}")))?; 46 | 47 | // Fetch updates from origin 48 | let mut remote = repo 49 | .find_remote("origin") 50 | .map_err(|e| SapphireError::Generic(format!("Failed to find remote 'origin': {e}")))?; 51 | 52 | let mut fetch_options = FetchOptions::new(); 53 | remote 54 | .fetch( 55 | &["refs/heads/*:refs/heads/*"], 56 | Some(&mut fetch_options), 57 | None, 58 | ) 59 | .map_err(|e| SapphireError::Generic(format!("Failed to fetch updates: {e}")))?; 60 | 61 | // Merge changes 62 | let fetch_head = repo 63 | .find_reference("FETCH_HEAD") 64 | .map_err(|e| SapphireError::Generic(format!("Failed to find FETCH_HEAD: {e}")))?; 65 | 66 | let fetch_commit = repo 67 | .reference_to_annotated_commit(&fetch_head) 68 | .map_err(|e| { 69 | SapphireError::Generic(format!("Failed to get commit from FETCH_HEAD: {e}")) 70 | })?; 71 | 72 | let analysis = repo 73 | .merge_analysis(&[&fetch_commit]) 74 | .map_err(|e| SapphireError::Generic(format!("Failed to analyze merge: {e}")))?; 75 | 76 | if analysis.0.is_up_to_date() { 77 | println!("Already up-to-date"); 78 | return Ok(()); 79 | } 80 | 81 | if analysis.0.is_fast_forward() { 82 | let mut reference = repo.find_reference("refs/heads/master").map_err(|e| { 83 | SapphireError::Generic(format!("Failed to find master branch: {e}")) 84 | })?; 85 | reference 86 | .set_target(fetch_commit.id(), "Fast-forward") 87 | .map_err(|e| SapphireError::Generic(format!("Failed to fast-forward: {e}")))?; 88 | repo.set_head("refs/heads/master") 89 | .map_err(|e| SapphireError::Generic(format!("Failed to set HEAD: {e}")))?; 90 | repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force())) 91 | .map_err(|e| SapphireError::Generic(format!("Failed to checkout: {e}")))?; 92 | } else { 93 | return Err(SapphireError::Generic( 94 | "Tap requires merge but automatic merging is not implemented".to_string(), 95 | )); 96 | } 97 | 98 | Ok(()) 99 | } 100 | 101 | /// Remove this tap by deleting its local repository 102 | pub fn remove(&self) -> Result<()> { 103 | if !self.path.exists() { 104 | return Err(SapphireError::NotFound(format!( 105 | "Tap {} is not installed", 106 | self.full_name() 107 | ))); 108 | } 109 | println!("Removing tap {}", self.full_name()); 110 | std::fs::remove_dir_all(&self.path).map_err(|e| { 111 | SapphireError::Generic(format!("Failed to remove tap {}: {}", self.full_name(), e)) 112 | }) 113 | } 114 | 115 | /// Get the full name of the tap (user/repo) 116 | pub fn full_name(&self) -> String { 117 | format!("{}/{}", self.user, self.repo) 118 | } 119 | 120 | /// Check if this tap is installed locally 121 | pub fn is_installed(&self) -> bool { 122 | self.path.exists() 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /sapphire-core/src/tap/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod definition; // Renamed from 'tap' 2 | 3 | // Re-export 4 | pub use definition::*; 5 | -------------------------------------------------------------------------------- /sapphire-core/src/utils/cache.rs: -------------------------------------------------------------------------------- 1 | // src/utils/cache.rs 2 | // Handles caching of formula data and downloads 3 | 4 | use std::fs; 5 | use std::path::{Path, PathBuf}; 6 | use std::time::{Duration, SystemTime}; 7 | 8 | use serde::de::DeserializeOwned; 9 | use serde::Serialize; 10 | 11 | use crate::utils::error::{Result, SapphireError}; 12 | 13 | // TODO: Define cache directory structure (e.g., ~/.cache/brew-rs-client) 14 | // TODO: Implement functions for storing, retrieving, and clearing cached data. 15 | 16 | const CACHE_SUBDIR: &str = "brew-rs-client"; 17 | // Define how long cache entries are considered valid 18 | const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours 19 | 20 | /// Cache struct to manage cache operations 21 | pub struct Cache { 22 | cache_dir: PathBuf, 23 | } 24 | 25 | impl Cache { 26 | pub fn new(cache_dir: &Path) -> Result { 27 | if !cache_dir.exists() { 28 | fs::create_dir_all(cache_dir).map_err(SapphireError::Io)?; // Replaced closure 29 | } 30 | 31 | Ok(Self { 32 | cache_dir: cache_dir.to_path_buf(), 33 | }) 34 | } 35 | 36 | /// Gets the cache directory path 37 | pub fn get_dir(&self) -> &Path { 38 | &self.cache_dir 39 | } 40 | 41 | /// Stores raw string data in the cache 42 | pub fn store_raw(&self, filename: &str, data: &str) -> Result<()> { 43 | let path = self.cache_dir.join(filename); 44 | tracing::debug!("Saving raw data to cache file: {:?}", path); 45 | fs::write(&path, data).map_err(SapphireError::Io)?; 46 | Ok(()) 47 | } 48 | 49 | /// Loads raw string data from the cache 50 | pub fn load_raw(&self, filename: &str) -> Result { 51 | let path = self.cache_dir.join(filename); 52 | tracing::debug!("Loading raw data from cache file: {:?}", path); 53 | 54 | if !path.exists() { 55 | return Err(SapphireError::Cache(format!( 56 | "Cache file {filename} does not exist" 57 | ))); 58 | } 59 | 60 | fs::read_to_string(&path).map_err(SapphireError::Io) 61 | } 62 | 63 | /// Checks if a cache file exists and is valid (within TTL) 64 | pub fn is_cache_valid(&self, filename: &str) -> Result { 65 | let path = self.cache_dir.join(filename); 66 | if !path.exists() { 67 | return Ok(false); 68 | } 69 | 70 | let metadata = fs::metadata(&path)?; 71 | let modified_time = metadata.modified()?; 72 | let age = SystemTime::now() 73 | .duration_since(modified_time) 74 | .map_err(|e| SapphireError::Cache(format!("System time error: {e}")))?; 75 | 76 | Ok(age <= CACHE_TTL) 77 | } 78 | 79 | /// Clears a specific cache file 80 | pub fn clear_file(&self, filename: &str) -> Result<()> { 81 | let path = self.cache_dir.join(filename); 82 | if path.exists() { 83 | fs::remove_file(&path).map_err(SapphireError::Io)?; 84 | } 85 | Ok(()) 86 | } 87 | 88 | /// Clears all cache files 89 | pub fn clear_all(&self) -> Result<()> { 90 | if self.cache_dir.exists() { 91 | fs::remove_dir_all(&self.cache_dir).map_err(SapphireError::Io)?; 92 | fs::create_dir_all(&self.cache_dir).map_err(SapphireError::Io)?; 93 | } 94 | Ok(()) 95 | } 96 | } 97 | 98 | /// Gets the path to the application's cache directory, creating it if necessary. 99 | /// Uses dirs::cache_dir() to find the appropriate system cache location. 100 | pub fn get_cache_dir() -> Result { 101 | let base_cache_dir = dirs::cache_dir().ok_or_else(|| { 102 | SapphireError::Cache("Could not determine system cache directory".to_string()) 103 | })?; 104 | let app_cache_dir = base_cache_dir.join(CACHE_SUBDIR); 105 | 106 | if !app_cache_dir.exists() { 107 | tracing::debug!("Creating cache directory at {:?}", app_cache_dir); 108 | fs::create_dir_all(&app_cache_dir).map_err(|e| { 109 | SapphireError::Io(e) 110 | // Consider a specific Cache error variant: Cache(format!("Failed to create cache dir: 111 | // {}", e)) 112 | })?; 113 | } 114 | Ok(app_cache_dir) 115 | } 116 | 117 | /// Constructs the full path for a given cache filename. 118 | fn get_cache_path(filename: &str) -> Result { 119 | Ok(get_cache_dir()?.join(filename)) 120 | } 121 | 122 | /// Saves serializable data to a file in the cache directory. 123 | /// The data is serialized as JSON. 124 | pub fn save_to_cache(filename: &str, data: &T) -> Result<()> { 125 | let path = get_cache_path(filename)?; 126 | tracing::debug!("Saving data to cache file: {:?}", path); 127 | let file = fs::File::create(&path)?; 128 | // Use serde_json::to_writer_pretty for readable cache files (optional) 129 | serde_json::to_writer_pretty(file, data)?; 130 | Ok(()) 131 | } 132 | 133 | /// Loads and deserializes data from a file in the cache directory. 134 | /// Checks if the cache file exists and is within the TTL (Time To Live). 135 | pub fn load_from_cache(filename: &str) -> Result { 136 | let path = get_cache_path(filename)?; 137 | tracing::debug!("Attempting to load from cache file: {:?}", path); 138 | 139 | if !path.exists() { 140 | tracing::debug!("Cache file not found."); 141 | return Err(SapphireError::Cache( 142 | "Cache file does not exist".to_string(), 143 | )); 144 | } 145 | 146 | // Check cache file age 147 | let metadata = fs::metadata(&path)?; 148 | let modified_time = metadata.modified()?; 149 | let age = SystemTime::now() 150 | .duration_since(modified_time) 151 | .map_err(|e| SapphireError::Cache(format!("System time error: {e}")))?; 152 | 153 | if age > CACHE_TTL { 154 | tracing::debug!("Cache file expired (age: {:?}, TTL: {:?}).", age, CACHE_TTL); 155 | return Err(SapphireError::Cache(format!( 156 | "Cache file expired ({} > {})", 157 | humantime::format_duration(age), 158 | humantime::format_duration(CACHE_TTL) 159 | ))); 160 | } 161 | 162 | tracing::debug!("Cache file is valid. Loading..."); 163 | let file = fs::File::open(&path)?; 164 | let data: T = serde_json::from_reader(file)?; 165 | Ok(data) 166 | } 167 | 168 | /// Clears the entire application cache directory. 169 | pub fn clear_cache() -> Result<()> { 170 | let path = get_cache_dir()?; 171 | tracing::debug!("Clearing cache directory: {:?}", path); 172 | if path.exists() { 173 | fs::remove_dir_all(&path)?; 174 | } 175 | Ok(()) 176 | } 177 | 178 | /// Checks if a specific cache file exists and is valid (within TTL). 179 | pub fn is_cache_valid(filename: &str) -> Result { 180 | let path = get_cache_path(filename)?; 181 | if !path.exists() { 182 | return Ok(false); 183 | } 184 | let metadata = fs::metadata(&path)?; 185 | let modified_time = metadata.modified()?; 186 | let age = SystemTime::now() 187 | .duration_since(modified_time) 188 | .map_err(|e| SapphireError::Cache(format!("System time error: {e}")))?; 189 | Ok(age <= CACHE_TTL) 190 | } 191 | -------------------------------------------------------------------------------- /sapphire-core/src/utils/config.rs: -------------------------------------------------------------------------------- 1 | // ===== sapphire-core/src/utils/config.rs ===== 2 | use std::env; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use dirs; 6 | use tracing::debug; 7 | 8 | use crate::utils::cache; 9 | use crate::utils::error::Result; // for home directory lookup 10 | 11 | /// Default installation prefixes 12 | const DEFAULT_LINUX_PREFIX: &str = "/home/linuxbrew/.linuxbrew"; 13 | const DEFAULT_MACOS_INTEL_PREFIX: &str = "/usr/local"; 14 | const DEFAULT_MACOS_ARM_PREFIX: &str = "/opt/homebrew"; 15 | 16 | /// Determines the active prefix for installation. 17 | /// Checks SAPPHIRE_PREFIX/HOMEBREW_PREFIX env vars, then OS-specific defaults. 18 | fn determine_prefix() -> PathBuf { 19 | if let Ok(prefix) = env::var("SAPPHIRE_PREFIX").or_else(|_| env::var("HOMEBREW_PREFIX")) { 20 | debug!("Using prefix from environment variable: {}", prefix); 21 | return PathBuf::from(prefix); 22 | } 23 | 24 | let default_prefix = if cfg!(target_os = "linux") { 25 | DEFAULT_LINUX_PREFIX 26 | } else if cfg!(target_os = "macos") { 27 | if cfg!(target_arch = "aarch64") { 28 | DEFAULT_MACOS_ARM_PREFIX 29 | } else { 30 | DEFAULT_MACOS_INTEL_PREFIX 31 | } 32 | } else { 33 | // Fallback for unsupported OS 34 | "/usr/local/sapphire" 35 | }; 36 | debug!("Using default prefix for OS/Arch: {}", default_prefix); 37 | PathBuf::from(default_prefix) 38 | } 39 | 40 | #[derive(Debug, Clone)] 41 | pub struct Config { 42 | pub prefix: PathBuf, 43 | pub cellar: PathBuf, 44 | pub taps_dir: PathBuf, 45 | pub cache_dir: PathBuf, 46 | pub api_base_url: String, 47 | pub artifact_domain: Option, 48 | pub docker_registry_token: Option, 49 | pub docker_registry_basic_auth: Option, 50 | pub github_api_token: Option, 51 | } 52 | 53 | impl Config { 54 | pub fn load() -> Result { 55 | debug!("Loading Sapphire configuration..."); 56 | let prefix = determine_prefix(); 57 | let cellar = prefix.join("Cellar"); 58 | let taps_dir = prefix.join("Library/Taps"); 59 | let cache_dir = cache::get_cache_dir()?; 60 | let api_base_url = "https://formulae.brew.sh/api".to_string(); 61 | 62 | let artifact_domain = env::var("HOMEBREW_ARTIFACT_DOMAIN").ok(); 63 | let docker_registry_token = env::var("HOMEBREW_DOCKER_REGISTRY_TOKEN").ok(); 64 | let docker_registry_basic_auth = env::var("HOMEBREW_DOCKER_REGISTRY_BASIC_AUTH_TOKEN").ok(); 65 | let github_api_token = env::var("HOMEBREW_GITHUB_API_TOKEN").ok(); 66 | 67 | if artifact_domain.is_some() { 68 | debug!("Loaded HOMEBREW_ARTIFACT_DOMAIN"); 69 | } 70 | if docker_registry_token.is_some() { 71 | debug!("Loaded HOMEBREW_DOCKER_REGISTRY_TOKEN"); 72 | } 73 | if docker_registry_basic_auth.is_some() { 74 | debug!("Loaded HOMEBREW_DOCKER_REGISTRY_BASIC_AUTH_TOKEN"); 75 | } 76 | if github_api_token.is_some() { 77 | debug!("Loaded HOMEBREW_GITHUB_API_TOKEN"); 78 | } 79 | 80 | debug!("Configuration loaded successfully."); 81 | Ok(Self { 82 | prefix, 83 | cellar, 84 | taps_dir, 85 | cache_dir, 86 | api_base_url, 87 | artifact_domain, 88 | docker_registry_token, 89 | docker_registry_basic_auth, 90 | github_api_token, 91 | }) 92 | } 93 | 94 | // --- Start: New Path Methods --- 95 | 96 | pub fn prefix(&self) -> &Path { 97 | &self.prefix 98 | } 99 | 100 | pub fn cellar_path(&self) -> &Path { 101 | &self.cellar 102 | } 103 | 104 | pub fn caskroom_dir(&self) -> PathBuf { 105 | self.prefix.join("Caskroom") 106 | } 107 | 108 | pub fn opt_dir(&self) -> PathBuf { 109 | self.prefix.join("opt") 110 | } 111 | 112 | pub fn bin_dir(&self) -> PathBuf { 113 | self.prefix.join("bin") 114 | } 115 | 116 | pub fn applications_dir(&self) -> PathBuf { 117 | if cfg!(target_os = "macos") { 118 | PathBuf::from("/Applications") 119 | } else { 120 | self.prefix.join("Applications") 121 | } 122 | } 123 | 124 | pub fn formula_cellar_dir(&self, formula_name: &str) -> PathBuf { 125 | self.cellar_path().join(formula_name) 126 | } 127 | 128 | pub fn formula_keg_path(&self, formula_name: &str, version_str: &str) -> PathBuf { 129 | self.formula_cellar_dir(formula_name).join(version_str) 130 | } 131 | 132 | pub fn formula_opt_link_path(&self, formula_name: &str) -> PathBuf { 133 | self.opt_dir().join(formula_name) 134 | } 135 | 136 | pub fn cask_dir(&self, cask_token: &str) -> PathBuf { 137 | self.caskroom_dir().join(cask_token) 138 | } 139 | 140 | pub fn cask_version_path(&self, cask_token: &str, version_str: &str) -> PathBuf { 141 | self.cask_dir(cask_token).join(version_str) 142 | } 143 | 144 | /// Returns the path to the current user's home directory. 145 | pub fn home_dir(&self) -> PathBuf { 146 | dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")) 147 | } 148 | 149 | /// Returns the base manpage directory (e.g., /usr/local/share/man). 150 | pub fn manpagedir(&self) -> PathBuf { 151 | self.prefix.join("share").join("man") 152 | } 153 | 154 | // --- End: New Path Methods --- 155 | 156 | pub fn get_tap_path(&self, name: &str) -> Option { 157 | let parts: Vec<&str> = name.split('/').collect(); 158 | if parts.len() == 2 { 159 | Some( 160 | self.taps_dir 161 | .join(parts[0]) 162 | .join(format!("homebrew-{}", parts[1])), 163 | ) 164 | } else { 165 | None 166 | } 167 | } 168 | 169 | pub fn get_formula_path_from_tap(&self, tap_name: &str, formula_name: &str) -> Option { 170 | self.get_tap_path(tap_name).and_then(|tap_path| { 171 | let json_path = tap_path 172 | .join("Formula") 173 | .join(format!("{formula_name}.json")); 174 | if json_path.exists() { 175 | return Some(json_path); 176 | } 177 | let rb_path = tap_path.join("Formula").join(format!("{formula_name}.rb")); 178 | if rb_path.exists() { 179 | return Some(rb_path); 180 | } 181 | None 182 | }) 183 | } 184 | } 185 | 186 | impl Default for Config { 187 | fn default() -> Self { 188 | Self::load().expect("Failed to load default configuration") 189 | } 190 | } 191 | 192 | pub fn load_config() -> Result { 193 | Config::load() 194 | } 195 | -------------------------------------------------------------------------------- /sapphire-core/src/utils/error.rs: -------------------------------------------------------------------------------- 1 | // sapphire-core/src/utils/error.rs 2 | // *** Added MachO related error variants *** [cite: 142] 3 | 4 | use thiserror::Error; 5 | 6 | // Define a top-level error enum for the application using thiserror 7 | #[derive(Error, Debug)] 8 | pub enum SapphireError { 9 | #[error("I/O Error: {0}")] 10 | Io(#[from] std::io::Error), 11 | 12 | #[error("HTTP Request Error: {0}")] 13 | Http(#[from] reqwest::Error), 14 | 15 | #[error("JSON Parsing Error: {0}")] 16 | Json(#[from] serde_json::Error), 17 | 18 | #[error("Configuration Error: {0}")] 19 | Config(String), 20 | 21 | #[error("API Error: {0}")] 22 | Api(String), 23 | 24 | #[error("API Request Error: {0}")] 25 | ApiRequestError(String), 26 | 27 | #[error("Semantic Versioning Error: {0}")] 28 | SemVer(#[from] semver::Error), 29 | 30 | // Updated DownloadError to match previous structure if needed, or keep simple 31 | #[error("DownloadError: Failed to download '{0}' from '{1}': {2}")] 32 | DownloadError(String, String, String), // name, url, reason 33 | 34 | #[error("Cache Error: {0}")] 35 | Cache(String), 36 | 37 | #[error("Resource Not Found: {0}")] 38 | NotFound(String), 39 | 40 | #[error("Installation Error: {0}")] 41 | InstallError(String), 42 | 43 | #[error("Generic Error: {0}")] 44 | Generic(String), 45 | 46 | // Keep HttpError if distinct from Http(reqwest::Error) is needed 47 | #[error("HttpError: {0}")] 48 | HttpError(String), 49 | 50 | #[error("Checksum Mismatch: {0}")] 51 | ChecksumMismatch(String), // Keep if used distinctly from ChecksumError 52 | 53 | #[error("Checksum Error: {0}")] 54 | ChecksumError(String), 55 | 56 | #[error("Parsing Error in {0}: {1}")] 57 | ParseError(&'static str, String), 58 | 59 | #[error("Version error: {0}")] 60 | VersionError(String), 61 | 62 | #[error("Dependency Error: {0}")] 63 | DependencyError(String), 64 | 65 | #[error("Build environment setup failed: {0}")] 66 | BuildEnvError(String), 67 | 68 | // Kept IoError if distinct from Io(std::io::Error) is useful 69 | #[error("IoError: {0}")] 70 | IoError(String), 71 | 72 | #[error("Failed to execute command: {0}")] 73 | CommandExecError(String), 74 | 75 | // --- Added Mach-O Relocation Errors (Based on Plan) --- [cite: 142] 76 | #[error("Mach-O Error: {0}")] 77 | MachOError(String), // General Mach-O processing error 78 | 79 | #[error("Mach-O Modification Error: {0}")] 80 | MachOModificationError(String), // Specific error during modification step 81 | 82 | #[error("Mach-O Relocation Error: Path too long - {0}")] 83 | PathTooLongError(String), /* Specifically for path length issues during patching [cite: 84 | * 115, 142] */ 85 | 86 | #[error("Codesign Error: {0}")] 87 | CodesignError(String), // For errors during re-signing on Apple Silicon [cite: 138, 142] 88 | 89 | // --- Added object crate error integration --- [cite: 142] 90 | #[error("Object File Error: {0}")] 91 | Object(#[from] object::read::Error), // Error from object crate parsing 92 | } 93 | 94 | // Define a convenience Result type alias using our custom error 95 | pub type Result = std::result::Result; 96 | -------------------------------------------------------------------------------- /sapphire-core/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | // src/utils/mod.rs 2 | // Utility modules and functions. 3 | 4 | // Example: pub mod path_utils; 5 | // Example: pub mod display_utils; 6 | 7 | pub mod cache; 8 | pub mod config; 9 | pub mod error; 10 | 11 | // Re-export 12 | pub use self::cache::*; 13 | pub use self::config::*; 14 | pub use self::error::*; 15 | --------------------------------------------------------------------------------