├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── Cargo.lock ├── Cargo.toml ├── Formula └── dutis.rb ├── LICENSE ├── Readme.md ├── config └── groups.yaml └── src ├── config └── mod.rs ├── groups └── mod.rs ├── lib.rs ├── main.rs ├── mod.rs ├── uti ├── mod.rs └── names.rs └── utils ├── bimap.rs └── mod.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: macos-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Install Rust toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | 22 | - name: Rust Cache 23 | uses: Swatinem/rust-cache@v2 24 | 25 | - name: Run tests 26 | run: cargo test --verbose 27 | 28 | # clippy: 29 | # name: Clippy 30 | # runs-on: macos-latest 31 | # steps: 32 | # - uses: actions/checkout@v3 33 | 34 | # - name: Install Rust toolchain 35 | # uses: dtolnay/rust-toolchain@stable 36 | # with: 37 | # components: clippy 38 | 39 | # - name: Rust Cache 40 | # uses: Swatinem/rust-cache@v2 41 | 42 | # - name: Run clippy 43 | # run: cargo clippy -- -D warnings 44 | 45 | build: 46 | name: Build 47 | runs-on: macos-latest 48 | steps: 49 | - uses: actions/checkout@v3 50 | 51 | - name: Install Rust toolchain 52 | uses: dtolnay/rust-toolchain@stable 53 | 54 | - name: Rust Cache 55 | uses: Swatinem/rust-cache@v2 56 | 57 | - name: Build 58 | run: cargo build --verbose 59 | 60 | - name: Build release 61 | run: cargo build --release --verbose 62 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | build-and-release: 14 | name: Build and Release 15 | runs-on: macos-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Install Rust toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | 22 | - name: Build Release Binary 23 | run: cargo build --release 24 | 25 | - name: Create Release Archive 26 | run: | 27 | cd target/release 28 | tar -czf dutis-macos.tar.gz dutis 29 | shasum -a 256 dutis-macos.tar.gz > dutis-macos.tar.gz.sha256 30 | cd ../.. 31 | mv target/release/dutis-macos.tar.gz . 32 | mv target/release/dutis-macos.tar.gz.sha256 . 33 | 34 | - name: Create Release 35 | uses: softprops/action-gh-release@v1 36 | with: 37 | files: | 38 | dutis-macos.tar.gz 39 | dutis-macos.tar.gz.sha256 40 | draft: false 41 | prerelease: false 42 | generate_release_notes: true 43 | 44 | publish-crate: 45 | name: Publish to crates.io 46 | needs: build-and-release 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v3 50 | 51 | - name: Install Rust toolchain 52 | uses: dtolnay/rust-toolchain@stable 53 | 54 | - name: Publish to crates.io 55 | env: 56 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 57 | run: cargo publish 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | main 23 | 24 | /target/ 25 | **/*.rs.bk 26 | Cargo.lock 27 | .DS_Store 28 | .idea/ 29 | .vscode/ 30 | *.swp 31 | *.swo 32 | dist/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsonglew/dutis/cab5d61b84ab023e039f7d8998586c44ac6f2874/.gitmodules -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "bitflags" 7 | version = "2.6.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 10 | 11 | [[package]] 12 | name = "cfg-if" 13 | version = "1.0.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 16 | 17 | [[package]] 18 | name = "colored" 19 | version = "2.1.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" 22 | dependencies = [ 23 | "lazy_static", 24 | "windows-sys 0.48.0", 25 | ] 26 | 27 | [[package]] 28 | name = "console" 29 | version = "0.15.8" 30 | source = "registry+https://github.com/rust-lang/crates.io-index" 31 | checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" 32 | dependencies = [ 33 | "encode_unicode", 34 | "lazy_static", 35 | "libc", 36 | "unicode-width", 37 | "windows-sys 0.52.0", 38 | ] 39 | 40 | [[package]] 41 | name = "core-foundation" 42 | version = "0.9.4" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 45 | dependencies = [ 46 | "core-foundation-sys", 47 | "libc", 48 | ] 49 | 50 | [[package]] 51 | name = "core-foundation-sys" 52 | version = "0.8.7" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 55 | 56 | [[package]] 57 | name = "dialoguer" 58 | version = "0.10.4" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "59c6f2989294b9a498d3ad5491a79c6deb604617378e1cdc4bfc1c1361fe2f87" 61 | dependencies = [ 62 | "console", 63 | "shell-words", 64 | "tempfile", 65 | "zeroize", 66 | ] 67 | 68 | [[package]] 69 | name = "dutis" 70 | version = "0.1.0" 71 | dependencies = [ 72 | "colored", 73 | "console", 74 | "core-foundation", 75 | "core-foundation-sys", 76 | "dialoguer", 77 | "lazy_static", 78 | "libc", 79 | "serde", 80 | "serde_yaml", 81 | ] 82 | 83 | [[package]] 84 | name = "encode_unicode" 85 | version = "0.3.6" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" 88 | 89 | [[package]] 90 | name = "equivalent" 91 | version = "1.0.1" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 94 | 95 | [[package]] 96 | name = "errno" 97 | version = "0.3.10" 98 | source = "registry+https://github.com/rust-lang/crates.io-index" 99 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 100 | dependencies = [ 101 | "libc", 102 | "windows-sys 0.59.0", 103 | ] 104 | 105 | [[package]] 106 | name = "fastrand" 107 | version = "2.2.0" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" 110 | 111 | [[package]] 112 | name = "hashbrown" 113 | version = "0.15.2" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 116 | 117 | [[package]] 118 | name = "indexmap" 119 | version = "2.7.0" 120 | source = "registry+https://github.com/rust-lang/crates.io-index" 121 | checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" 122 | dependencies = [ 123 | "equivalent", 124 | "hashbrown", 125 | ] 126 | 127 | [[package]] 128 | name = "itoa" 129 | version = "1.0.14" 130 | source = "registry+https://github.com/rust-lang/crates.io-index" 131 | checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" 132 | 133 | [[package]] 134 | name = "lazy_static" 135 | version = "1.5.0" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 138 | 139 | [[package]] 140 | name = "libc" 141 | version = "0.2.167" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "09d6582e104315a817dff97f75133544b2e094ee22447d2acf4a74e189ba06fc" 144 | 145 | [[package]] 146 | name = "linux-raw-sys" 147 | version = "0.4.14" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 150 | 151 | [[package]] 152 | name = "once_cell" 153 | version = "1.20.2" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 156 | 157 | [[package]] 158 | name = "proc-macro2" 159 | version = "1.0.92" 160 | source = "registry+https://github.com/rust-lang/crates.io-index" 161 | checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" 162 | dependencies = [ 163 | "unicode-ident", 164 | ] 165 | 166 | [[package]] 167 | name = "quote" 168 | version = "1.0.37" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 171 | dependencies = [ 172 | "proc-macro2", 173 | ] 174 | 175 | [[package]] 176 | name = "rustix" 177 | version = "0.38.41" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" 180 | dependencies = [ 181 | "bitflags", 182 | "errno", 183 | "libc", 184 | "linux-raw-sys", 185 | "windows-sys 0.52.0", 186 | ] 187 | 188 | [[package]] 189 | name = "ryu" 190 | version = "1.0.18" 191 | source = "registry+https://github.com/rust-lang/crates.io-index" 192 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 193 | 194 | [[package]] 195 | name = "serde" 196 | version = "1.0.215" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" 199 | dependencies = [ 200 | "serde_derive", 201 | ] 202 | 203 | [[package]] 204 | name = "serde_derive" 205 | version = "1.0.215" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" 208 | dependencies = [ 209 | "proc-macro2", 210 | "quote", 211 | "syn", 212 | ] 213 | 214 | [[package]] 215 | name = "serde_yaml" 216 | version = "0.9.34+deprecated" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 219 | dependencies = [ 220 | "indexmap", 221 | "itoa", 222 | "ryu", 223 | "serde", 224 | "unsafe-libyaml", 225 | ] 226 | 227 | [[package]] 228 | name = "shell-words" 229 | version = "1.1.0" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" 232 | 233 | [[package]] 234 | name = "syn" 235 | version = "2.0.90" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" 238 | dependencies = [ 239 | "proc-macro2", 240 | "quote", 241 | "unicode-ident", 242 | ] 243 | 244 | [[package]] 245 | name = "tempfile" 246 | version = "3.14.0" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" 249 | dependencies = [ 250 | "cfg-if", 251 | "fastrand", 252 | "once_cell", 253 | "rustix", 254 | "windows-sys 0.59.0", 255 | ] 256 | 257 | [[package]] 258 | name = "unicode-ident" 259 | version = "1.0.14" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 262 | 263 | [[package]] 264 | name = "unicode-width" 265 | version = "0.1.14" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 268 | 269 | [[package]] 270 | name = "unsafe-libyaml" 271 | version = "0.2.11" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 274 | 275 | [[package]] 276 | name = "windows-sys" 277 | version = "0.48.0" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 280 | dependencies = [ 281 | "windows-targets 0.48.5", 282 | ] 283 | 284 | [[package]] 285 | name = "windows-sys" 286 | version = "0.52.0" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 289 | dependencies = [ 290 | "windows-targets 0.52.6", 291 | ] 292 | 293 | [[package]] 294 | name = "windows-sys" 295 | version = "0.59.0" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 298 | dependencies = [ 299 | "windows-targets 0.52.6", 300 | ] 301 | 302 | [[package]] 303 | name = "windows-targets" 304 | version = "0.48.5" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 307 | dependencies = [ 308 | "windows_aarch64_gnullvm 0.48.5", 309 | "windows_aarch64_msvc 0.48.5", 310 | "windows_i686_gnu 0.48.5", 311 | "windows_i686_msvc 0.48.5", 312 | "windows_x86_64_gnu 0.48.5", 313 | "windows_x86_64_gnullvm 0.48.5", 314 | "windows_x86_64_msvc 0.48.5", 315 | ] 316 | 317 | [[package]] 318 | name = "windows-targets" 319 | version = "0.52.6" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 322 | dependencies = [ 323 | "windows_aarch64_gnullvm 0.52.6", 324 | "windows_aarch64_msvc 0.52.6", 325 | "windows_i686_gnu 0.52.6", 326 | "windows_i686_gnullvm", 327 | "windows_i686_msvc 0.52.6", 328 | "windows_x86_64_gnu 0.52.6", 329 | "windows_x86_64_gnullvm 0.52.6", 330 | "windows_x86_64_msvc 0.52.6", 331 | ] 332 | 333 | [[package]] 334 | name = "windows_aarch64_gnullvm" 335 | version = "0.48.5" 336 | source = "registry+https://github.com/rust-lang/crates.io-index" 337 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 338 | 339 | [[package]] 340 | name = "windows_aarch64_gnullvm" 341 | version = "0.52.6" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 344 | 345 | [[package]] 346 | name = "windows_aarch64_msvc" 347 | version = "0.48.5" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 350 | 351 | [[package]] 352 | name = "windows_aarch64_msvc" 353 | version = "0.52.6" 354 | source = "registry+https://github.com/rust-lang/crates.io-index" 355 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 356 | 357 | [[package]] 358 | name = "windows_i686_gnu" 359 | version = "0.48.5" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 362 | 363 | [[package]] 364 | name = "windows_i686_gnu" 365 | version = "0.52.6" 366 | source = "registry+https://github.com/rust-lang/crates.io-index" 367 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 368 | 369 | [[package]] 370 | name = "windows_i686_gnullvm" 371 | version = "0.52.6" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 374 | 375 | [[package]] 376 | name = "windows_i686_msvc" 377 | version = "0.48.5" 378 | source = "registry+https://github.com/rust-lang/crates.io-index" 379 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 380 | 381 | [[package]] 382 | name = "windows_i686_msvc" 383 | version = "0.52.6" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 386 | 387 | [[package]] 388 | name = "windows_x86_64_gnu" 389 | version = "0.48.5" 390 | source = "registry+https://github.com/rust-lang/crates.io-index" 391 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 392 | 393 | [[package]] 394 | name = "windows_x86_64_gnu" 395 | version = "0.52.6" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 398 | 399 | [[package]] 400 | name = "windows_x86_64_gnullvm" 401 | version = "0.48.5" 402 | source = "registry+https://github.com/rust-lang/crates.io-index" 403 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 404 | 405 | [[package]] 406 | name = "windows_x86_64_gnullvm" 407 | version = "0.52.6" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 410 | 411 | [[package]] 412 | name = "windows_x86_64_msvc" 413 | version = "0.48.5" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 416 | 417 | [[package]] 418 | name = "windows_x86_64_msvc" 419 | version = "0.52.6" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 422 | 423 | [[package]] 424 | name = "zeroize" 425 | version = "1.8.1" 426 | source = "registry+https://github.com/rust-lang/crates.io-index" 427 | checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 428 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dutis" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | core-foundation = "0.9.3" 8 | core-foundation-sys = "0.8.3" 9 | libc = "0.2.167" 10 | dialoguer = "0.10.4" 11 | console = "0.15.7" 12 | colored = "2.0.4" 13 | lazy_static = "1.4.0" 14 | serde = { version = "1.0", features = ["derive"] } 15 | serde_yaml = "0.9" 16 | -------------------------------------------------------------------------------- /Formula/dutis.rb: -------------------------------------------------------------------------------- 1 | class Dutis < Formula 2 | desc "Command-line tool to manage default applications for file types on macOS" 3 | homepage "https://github.com/tsonglew/dutis" 4 | url "https://github.com/tsonglew/dutis/archive/refs/tags/v0.1.0.tar.gz" 5 | sha256 "REPLACE_WITH_ACTUAL_SHA256" 6 | license "MIT" 7 | head "https://github.com/tsonglew/dutis.git", branch: "main" 8 | 9 | depends_on "rust" => :build 10 | depends_on :macos 11 | 12 | def install 13 | system "cargo", "install", "--root", prefix, "--path", "." 14 | # Install shell completions 15 | generate_completions_from_executable(bin/"dutis", "--generate-shell-completion") 16 | end 17 | 18 | test do 19 | assert_match "dutis #{version}", shell_output("#{bin}/dutis --version") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tsonglew 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Dutis 2 | 3 | [![CI](https://github.com/tsonglew/dutis/actions/workflows/ci.yml/badge.svg)](https://github.com/tsonglew/dutis/actions/workflows/ci.yml) 4 | [![License](https://img.shields.io/github/license/tsonglew/dutis)](https://github.com/tsonglew/dutis/blob/master/LICENSE) 5 | [![Crates.io](https://img.shields.io/crates/v/dutis)](https://crates.io/crates/dutis) 6 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/tsonglew/dutis)](https://github.com/tsonglew/dutis/releases) 7 | 8 | A command-line tool to manage default applications for file types on macOS. It provides an interactive interface to set default applications for individual file extensions or groups of related file types (like video, audio, images, etc.). 9 | 10 | > ⚠️ **Note**: This tool is designed specifically for macOS and will not work on other operating systems. 11 | 12 | > ⚠️ **Warning**: This tool relies on deprecated macOS CoreServices APIs (deprecated since macOS 10.4–12.0). While it currently works, it may become unstable or stop working in future macOS versions. Apple has not provided direct replacements for these APIs, making this the only available approach for programmatically managing file type associations. 13 | 14 | ## Features 15 | 16 | - 🎯 Set default applications for individual file extensions 17 | - 👥 Batch set default applications for groups of file types (video, audio, image, etc.) 18 | - 🔍 Interactive selection of applications 19 | - 🎨 Color-coded output for better visibility 20 | - ⚡ Fast and efficient Rust implementation 21 | - 🔄 Supports common file type groups out of the box 22 | 23 | ## Installation 24 | 25 | ### Building from Source 26 | 27 | ```shell 28 | git clone https://github.com/tsonglew/dutis.git 29 | cd dutis 30 | cargo build --release 31 | ``` 32 | 33 | ## Usage 34 | 35 | ### Basic Usage 36 | 37 | ```shell 38 | # Set default application for a single file extension 39 | sudo dutis 40 | 41 | # Example: Set default application for .mp4 files 42 | sudo dutis mp4 43 | ``` 44 | 45 | ### Group Mode 46 | 47 | ```shell 48 | # Set default application for a group of file types 49 | sudo dutis --group 50 | 51 | # Example: Set default application for all video files 52 | sudo dutis --group video 53 | ``` 54 | 55 | > ⚠️ **Note**: `sudo` is required because changing default applications requires system-level permissions. 56 | 57 | ### Available Groups 58 | 59 | The following file type groups are supported: 60 | 61 | - `video`: Common video formats (mp4, avi, mkv, etc.) 62 | - `audio`: Audio formats (mp3, wav, aac, etc.) 63 | - `image`: Image formats (jpg, png, gif, etc.) 64 | - `code`: Programming and markup files (py, js, rs, etc.) 65 | - `archive`: Archive formats (zip, tar, gz, etc.) 66 | 67 | You can view the full list of supported extensions in the `config/groups.yaml` file. 68 | 69 | ## Configuration 70 | 71 | Dutis uses a YAML configuration file to define file type groups. The default configuration is located at `config/groups.yaml`. You can modify this file to add or remove file extensions from groups. 72 | 73 | Example group configuration: 74 | 75 | ```yaml 76 | groups: 77 | video: 78 | - mp4 79 | - avi 80 | - mkv 81 | # ... 82 | audio: 83 | - mp3 84 | - wav 85 | - aac 86 | # ... 87 | ``` 88 | 89 | ## Requirements 90 | 91 | - macOS operating system 92 | - Rust 1.56 or later (for building from source) 93 | 94 | ## Contributing 95 | 96 | Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. 97 | 98 | ## License 99 | 100 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 101 | 102 | ## Stargazers over time 103 | 104 | [![Stargazers over time](https://starchart.cc/tsonglew/dutis.svg?variant=adaptive)](https://starchart.cc/tsonglew/dutis) 105 | -------------------------------------------------------------------------------- /config/groups.yaml: -------------------------------------------------------------------------------- 1 | groups: 2 | video: 3 | - mp4 4 | - avi 5 | - mkv 6 | - mov 7 | - wmv 8 | - flv 9 | - webm 10 | - m4v 11 | - mpeg 12 | - mpg 13 | audio: 14 | - mp3 15 | - wav 16 | - aac 17 | - ogg 18 | - m4a 19 | - flac 20 | - wma 21 | - aiff 22 | image: 23 | - jpg 24 | - jpeg 25 | - png 26 | - gif 27 | - svg 28 | - webp 29 | - tiff 30 | - ico 31 | code: 32 | - py 33 | - js 34 | - go 35 | - rs 36 | - c 37 | - cpp 38 | - h 39 | - hpp 40 | - java 41 | - swift 42 | - rb 43 | - php 44 | - ts 45 | - cs 46 | - dart 47 | - lua 48 | - r 49 | - pl 50 | - pm 51 | - t 52 | - psgi 53 | - vb 54 | - fs 55 | - fsx 56 | - fsscript 57 | - fsi 58 | - rs 59 | archive: 60 | - zip 61 | - tar 62 | - gz 63 | - 7z 64 | - rar 65 | - bz2 66 | - xz 67 | - iso 68 | -------------------------------------------------------------------------------- /src/config/mod.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub struct Config { 3 | pub uti: String, 4 | pub suffix: String, 5 | } 6 | 7 | use std::fmt; 8 | 9 | impl fmt::Display for Config { 10 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 11 | write!(f, "{} ({})", self.uti, self.suffix) 12 | } 13 | } 14 | 15 | impl Config { 16 | pub fn new(uti: String, suffix: String) -> Config { 17 | Config { uti, suffix } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/groups/mod.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | use std::fs; 5 | 6 | #[derive(Debug, Serialize, Deserialize)] 7 | struct GroupConfig { 8 | groups: HashMap>, 9 | } 10 | 11 | lazy_static! { 12 | static ref GROUPS: GroupConfig = { 13 | let config_path = "config/groups.yaml"; 14 | let contents = 15 | fs::read_to_string(config_path).expect("Failed to read groups configuration file"); 16 | serde_yaml::from_str(&contents).expect("Failed to parse groups configuration") 17 | }; 18 | } 19 | 20 | pub fn get_suffix_group(group_name: &str) -> Option> { 21 | GROUPS 22 | .groups 23 | .get(group_name) 24 | .map(|v| v.iter().map(|s| s.as_str()).collect()) 25 | } 26 | 27 | pub fn list_available_groups() -> Vec<&'static str> { 28 | GROUPS.groups.keys().map(|s| s.as_str()).collect() 29 | } 30 | 31 | #[cfg(test)] 32 | mod tests { 33 | use super::*; 34 | 35 | #[test] 36 | fn test_get_suffix_group() { 37 | // Test valid group 38 | let video_group = get_suffix_group("video"); 39 | assert!(video_group.is_some()); 40 | assert!(video_group.unwrap().contains(&"mp4")); 41 | 42 | // Test invalid group 43 | let invalid_group = get_suffix_group("invalid"); 44 | assert!(invalid_group.is_none()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod groups; 3 | pub mod uti; 4 | pub mod utils; 5 | 6 | pub use config::Config; 7 | pub use groups::*; 8 | pub use uti::*; 9 | pub use utils::*; 10 | 11 | use colored::Colorize; 12 | use dialoguer::{theme::ColorfulTheme, Select}; 13 | use std::error::Error; 14 | 15 | pub fn run_interactive(config: &Config) -> Result<(), Box> { 16 | match crate::uti::get_role_handlers_from_uti(&config.uti) { 17 | Some(handlers) => { 18 | if handlers.is_empty() { 19 | eprintln!( 20 | "{} {}", 21 | "⚠️".yellow(), 22 | format!("No handlers found for content type '{}'", config.uti).yellow() 23 | ); 24 | return Ok(()); 25 | } 26 | 27 | println!( 28 | "{} {}", 29 | "🔍".blue(), 30 | format!("Select a handler for content type '{}':", config.uti).blue() 31 | ); 32 | 33 | let selection = Select::with_theme(&ColorfulTheme::default()) 34 | .items(&handlers) 35 | .default(0) 36 | .interact()?; 37 | 38 | let selected_handler = &handlers[selection]; 39 | println!( 40 | "\n{} {}", 41 | "✨".cyan(), 42 | format!("Selected handler: {}", selected_handler).cyan() 43 | ); 44 | 45 | if let Err(e) = crate::uti::set_default_app_for_suffix(config, selected_handler) { 46 | eprintln!( 47 | "{} {}", 48 | "❌".red(), 49 | format!("Failed to set default handler: {}", e).red() 50 | ) 51 | } 52 | Ok(()) 53 | } 54 | None => { 55 | eprintln!( 56 | "{} {}", 57 | "❌".red(), 58 | format!("No handlers found for content type '{}'", config).red() 59 | ); 60 | Ok(()) 61 | } 62 | } 63 | } 64 | 65 | pub fn run_interactive_group(uti2suf: BiMap) -> Result<(), Box> { 66 | if uti2suf.is_empty() { 67 | return Ok(()); 68 | } 69 | 70 | match crate::uti::get_common_role_handlers(&uti2suf) { 71 | Some(handlers) => { 72 | if handlers.is_empty() { 73 | eprintln!( 74 | "{} {}", 75 | "⚠️".yellow(), 76 | "No handlers found for the specified content types.".yellow() 77 | ); 78 | return Ok(()); 79 | } 80 | 81 | let selection = Select::with_theme(&ColorfulTheme::default()) 82 | .with_prompt("🔍 Select a handler:") 83 | .items(&handlers) 84 | .default(0) 85 | .interact()?; 86 | 87 | let handler = &handlers[selection]; 88 | for i in uti2suf.iter() { 89 | if let Err(e) = crate::uti::set_default_app_for_suffix( 90 | &Config::new(i.0.clone(), i.1.clone()), 91 | handler, 92 | ) { 93 | eprintln!("Error setting handler for {}: {}", i.0, e); 94 | } else { 95 | println!( 96 | "{} {}", 97 | "✅".green(), 98 | format!( 99 | "Successfully set '{}' as the default handler for '.{}' files", 100 | handler, i.1 101 | ) 102 | .green() 103 | ); 104 | } 105 | } 106 | } 107 | None => { 108 | eprintln!( 109 | "{} {}", 110 | "❌".red(), 111 | "No handlers found for the specified content types.".red() 112 | ); 113 | } 114 | } 115 | 116 | Ok(()) 117 | } 118 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use colored::Colorize; 2 | use dutis::{ 3 | groups, run_interactive, run_interactive_group, 4 | uti::{get_common_suffix, get_friendly_name, get_uti_from_suffix}, 5 | utils::BiMap, 6 | Config, 7 | }; 8 | use std::env; 9 | use std::process; 10 | 11 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 12 | 13 | fn check_os() { 14 | if !cfg!(target_os = "macos") { 15 | eprintln!( 16 | "{} {}", 17 | "⚠️ Warning:".yellow(), 18 | "Dutis is designed for macOS and may not work correctly on other operating systems.".yellow() 19 | ); 20 | process::exit(1); 21 | } 22 | } 23 | 24 | fn main() { 25 | check_os(); 26 | 27 | let args: Vec = env::args().collect(); 28 | 29 | if args.len() < 2 { 30 | print_usage(); 31 | process::exit(1); 32 | } 33 | 34 | match args[1].as_str() { 35 | "--version" => { 36 | println!("dutis {}", VERSION); 37 | process::exit(0); 38 | } 39 | "--generate-shell-completion" => { 40 | // TODO: Add shell completion generation 41 | process::exit(0); 42 | } 43 | "--group" => { 44 | if args.len() < 3 { 45 | eprintln!( 46 | "{} {}", 47 | "❌".red(), 48 | "Please specify a group name after --group".red() 49 | ); 50 | print_available_groups(); 51 | process::exit(1); 52 | } 53 | handle_group_mode(&args[2]); 54 | } 55 | suffix => handle_single_suffix(suffix), 56 | } 57 | } 58 | 59 | fn print_usage() { 60 | eprintln!( 61 | "{} {}", 62 | "❌".red(), 63 | "Usage: dutis OR dutis --group ".red() 64 | ); 65 | eprintln!("{} {}", "💡".yellow(), "Example: dutis html".yellow()); 66 | eprintln!( 67 | "{} {}", 68 | "💡".yellow(), 69 | "Example: dutis --group video".yellow() 70 | ); 71 | } 72 | 73 | fn print_available_groups() { 74 | eprintln!("{} {}", "ℹ️".blue(), "Available groups:".blue()); 75 | for group in groups::list_available_groups() { 76 | eprintln!(" • {}", group.cyan()); 77 | } 78 | } 79 | 80 | fn handle_group_mode(group_name: &str) { 81 | match groups::get_suffix_group(group_name) { 82 | Some(suffixes) => { 83 | println!( 84 | "{} {}", 85 | "🎯".cyan(), 86 | format!( 87 | "Processing group '{}' with {} suffixes", 88 | group_name, 89 | suffixes.len() 90 | ) 91 | .cyan() 92 | ); 93 | 94 | // Collect UTIs for all suffixes 95 | let mut uti2suf: BiMap = BiMap::new(); 96 | for suffix in &suffixes { 97 | match get_uti_from_suffix(suffix) { 98 | Some(uti) => { 99 | uti2suf.insert(uti.clone(), suffix.to_string()); 100 | println!( 101 | "{} {}", 102 | "📝".blue(), 103 | format!("Found UTI '{}' for '.{}'", uti, suffix).blue() 104 | ); 105 | } 106 | None => { 107 | eprintln!( 108 | "{} {}", 109 | "⚠️".yellow(), 110 | format!("Warning: No UTI found for suffix '{}', skipping.", suffix) 111 | .yellow() 112 | ); 113 | } 114 | } 115 | } 116 | 117 | if uti2suf.is_empty() { 118 | eprintln!( 119 | "{} {}", 120 | "❌".red(), 121 | "No valid UTIs found for any suffix in the group".red() 122 | ); 123 | process::exit(1); 124 | } 125 | 126 | if let Err(e) = run_interactive_group(uti2suf) { 127 | eprintln!( 128 | "{} {}", 129 | "❌".red(), 130 | format!("Application error: {}", e).red() 131 | ); 132 | process::exit(1); 133 | } 134 | } 135 | None => { 136 | eprintln!( 137 | "{} {}", 138 | "❌".red(), 139 | format!("Group '{}' not found", group_name).red() 140 | ); 141 | print_available_groups(); 142 | process::exit(1); 143 | } 144 | } 145 | } 146 | 147 | fn handle_single_suffix(suffix: &str) { 148 | let uti = match get_uti_from_suffix(suffix) { 149 | Some(uti) => { 150 | println!( 151 | "{} {}", 152 | "📝".blue(), 153 | format!( 154 | "Found UTI '{}' ({}) [{}] for '{}'", 155 | uti, 156 | get_friendly_name(&uti), 157 | get_common_suffix(&uti, suffix), 158 | suffix 159 | ) 160 | .blue() 161 | ); 162 | uti.to_string() 163 | } 164 | None => { 165 | eprintln!( 166 | "{} {}", 167 | "❌".red(), 168 | format!("Error: No UTI found for suffix '{}'", suffix).red() 169 | ); 170 | eprintln!( 171 | "{} {}", 172 | "💡".yellow(), 173 | "The suffix might not be recognized by the system".yellow() 174 | ); 175 | process::exit(1); 176 | } 177 | }; 178 | 179 | let config = Config::new(uti, suffix.to_string()); 180 | 181 | if let Err(e) = run_interactive(&config) { 182 | eprintln!( 183 | "{} {}", 184 | "❌".red(), 185 | format!("Application error: {}", e).red() 186 | ); 187 | process::exit(1); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/mod.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/uti/mod.rs: -------------------------------------------------------------------------------- 1 | use core_foundation::array::{CFArray, CFArrayRef}; 2 | use core_foundation::base::{CFType, TCFType}; 3 | use core_foundation::string::{CFString, CFStringRef}; 4 | use std::collections::HashSet; 5 | use std::ffi::c_void; 6 | use std::path::Path; 7 | use std::thread; 8 | use std::time::Duration; 9 | 10 | mod names; 11 | pub use names::{get_common_suffix, get_friendly_name}; 12 | 13 | use crate::{BiMap, Config}; 14 | 15 | #[link(name = "CoreServices", kind = "framework")] 16 | extern "C" { 17 | // Note: These CoreServices APIs are deprecated since macOS 10.4–12.0. 18 | // Apple has not provided direct replacements for these functionalities. 19 | // While they continue to work, they may become unstable in future macOS versions. 20 | 21 | fn UTTypeCreatePreferredIdentifierForTag( 22 | inTagClass: CFStringRef, 23 | inTag: CFStringRef, 24 | inConformingToUTI: CFStringRef, 25 | ) -> CFStringRef; 26 | 27 | fn LSSetDefaultRoleHandlerForContentType( 28 | inContentType: CFStringRef, 29 | inRole: CFStringRef, 30 | inHandlerBundleID: CFStringRef, 31 | ) -> i32; 32 | 33 | fn LSCopyAllRoleHandlersForContentType( 34 | inContentType: CFStringRef, 35 | inRole: CFStringRef, 36 | ) -> CFArrayRef; 37 | } 38 | 39 | pub fn get_uti_from_suffix(suffix: &str) -> Option { 40 | if suffix.is_empty() { 41 | return None; 42 | } 43 | 44 | unsafe { 45 | let cf_tag_class = CFString::new("public.filename-extension"); 46 | let cf_tag = CFString::new(suffix); 47 | let cf_conforming_to = CFString::new(""); 48 | 49 | let uti_ref = UTTypeCreatePreferredIdentifierForTag( 50 | cf_tag_class.as_concrete_TypeRef(), 51 | cf_tag.as_concrete_TypeRef(), 52 | cf_conforming_to.as_concrete_TypeRef(), 53 | ); 54 | 55 | if uti_ref.is_null() { 56 | None 57 | } else { 58 | let cf_uti = CFString::wrap_under_create_rule(uti_ref); 59 | Some(cf_uti.to_string()) 60 | } 61 | } 62 | } 63 | 64 | pub fn set_default_app_for_suffix(config: &Config, bundle_id: &str) -> Result<(), String> { 65 | if config.suffix.is_empty() { 66 | return Err("Suffix cannot be empty".to_string()); 67 | } 68 | 69 | if bundle_id.is_empty() { 70 | return Err("Bundle ID cannot be empty".to_string()); 71 | } 72 | 73 | unsafe { 74 | let cf_uti = CFString::new(&config.uti); 75 | let cf_role = CFString::new("all"); 76 | let cf_bundle_id = CFString::new(bundle_id); 77 | 78 | let attempts: i32 = 200; // Number of attempts to make the change stable 79 | 80 | let mut result: i32 = 0; 81 | for _ in 0..attempts { 82 | result = LSSetDefaultRoleHandlerForContentType( 83 | cf_uti.as_concrete_TypeRef(), 84 | cf_role.as_concrete_TypeRef(), 85 | cf_bundle_id.as_concrete_TypeRef(), 86 | ); 87 | thread::sleep(Duration::from_millis(10)); 88 | } 89 | 90 | if result == 0 { 91 | Ok(()) 92 | } else { 93 | Err(format!( 94 | "Failed to set default application. Error code: {}", 95 | result 96 | )) 97 | } 98 | } 99 | } 100 | 101 | pub fn get_role_handlers_from_uti(uti: &str) -> Option> { 102 | if uti.is_empty() { 103 | return None; 104 | } 105 | 106 | // Try multiple times to get a stable list of handlers 107 | let mut all_handlers = std::collections::HashSet::new(); 108 | let attempts: i32 = 200; // Number of attempts to get a stable list 109 | 110 | for _ in 0..attempts { 111 | unsafe { 112 | let cf_uti = CFString::new(uti); 113 | let cf_role = CFString::new("all"); 114 | 115 | let handlers_ref = LSCopyAllRoleHandlersForContentType( 116 | cf_uti.as_concrete_TypeRef(), 117 | cf_role.as_concrete_TypeRef(), 118 | ); 119 | 120 | if handlers_ref.is_null() { 121 | continue; // Try again if we got null 122 | } 123 | 124 | let handlers = CFArray::::wrap_under_create_rule(handlers_ref); 125 | let count = handlers.len(); 126 | 127 | for i in 0..count { 128 | if let Some(handler) = handlers.get(i) { 129 | all_handlers.insert(handler.to_string()); 130 | } 131 | } 132 | } 133 | 134 | // Small delay between attempts 135 | thread::sleep(Duration::from_millis(10)); 136 | } 137 | 138 | // Return None if no handlers were found after all attempts 139 | if all_handlers.is_empty() { 140 | None 141 | } else { 142 | // Convert HashSet to Vec and sort for stable output 143 | let mut result: Vec = all_handlers.into_iter().collect(); 144 | result.sort(); 145 | Some(result) 146 | } 147 | } 148 | 149 | pub fn get_common_role_handlers(uti2suf: &BiMap) -> Option> { 150 | let mut common_handlers: Option> = None; 151 | 152 | for (uti, _) in uti2suf.iter() { 153 | if let Some(handlers) = get_role_handlers_from_uti(uti) { 154 | match &mut common_handlers { 155 | None => common_handlers = Some(handlers), 156 | Some(common) => { 157 | common.retain(|h| handlers.contains(h)); 158 | if common.is_empty() { 159 | return None; 160 | } 161 | } 162 | } 163 | } else { 164 | return None; 165 | } 166 | } 167 | 168 | common_handlers 169 | } 170 | 171 | #[cfg(test)] 172 | mod tests { 173 | use super::*; 174 | 175 | #[test] 176 | fn test_get_uti_from_suffix() { 177 | // Test valid suffixes 178 | assert!(get_uti_from_suffix("html").is_some()); 179 | assert!(get_uti_from_suffix("txt").is_some()); 180 | assert!(get_uti_from_suffix("jpg").is_some()); 181 | assert!(get_uti_from_suffix("pdf").is_some()); 182 | assert!(get_uti_from_suffix("rs").is_some()); 183 | 184 | // Test empty suffix 185 | assert!(get_uti_from_suffix("").is_none()); 186 | 187 | // Test invalid suffix 188 | assert!(get_uti_from_suffix("nonexistentsuffix123456789").is_some()); // Even invalid suffixes return a UTI 189 | } 190 | 191 | #[test] 192 | fn test_set_default_app_for_suffix() { 193 | let config = Config { 194 | suffix: "txt".to_string(), 195 | uti: "public.plain-text".to_string(), 196 | }; 197 | 198 | // Test empty bundle_id 199 | let result = set_default_app_for_suffix(&config, ""); 200 | assert!(result.is_err()); 201 | assert_eq!(result.unwrap_err(), "Bundle ID cannot be empty"); 202 | 203 | // Test empty suffix in config 204 | let empty_config = Config { 205 | suffix: "".to_string(), 206 | uti: "public.plain-text".to_string(), 207 | }; 208 | let result = set_default_app_for_suffix(&empty_config, "com.apple.textedit"); 209 | assert!(result.is_err()); 210 | assert_eq!(result.unwrap_err(), "Suffix cannot be empty"); 211 | 212 | // Test valid case with TextEdit (this might fail if TextEdit is not installed) 213 | let result = set_default_app_for_suffix(&config, "com.apple.textedit"); 214 | assert!(result.is_ok()); 215 | } 216 | 217 | #[test] 218 | fn test_get_role_handlers_from_uti() { 219 | // Test empty UTI 220 | assert!(get_role_handlers_from_uti("").is_none()); 221 | 222 | // Test valid UTI that should have handlers (like text files) 223 | let handlers1 = get_role_handlers_from_uti("public.plain-text"); 224 | assert!(handlers1.is_some()); 225 | let handlers1 = handlers1.unwrap(); 226 | assert!(!handlers1.is_empty()); 227 | 228 | // Test stability - multiple calls should return the same handlers 229 | let handlers2 = get_role_handlers_from_uti("public.plain-text").unwrap(); 230 | assert_eq!( 231 | handlers1, handlers2, 232 | "Handler lists should be stable across calls" 233 | ); 234 | 235 | // Test UTI that might not have handlers 236 | let handlers = get_role_handlers_from_uti("dyn.ah62d4sv4ge81g5pe"); // A dynamic UTI 237 | assert!( 238 | handlers.is_none(), 239 | "Dynamic UTIs typically don't have handlers" 240 | ); 241 | 242 | // Test invalid UTI 243 | let handlers = get_role_handlers_from_uti("invalid.uti.identifier"); 244 | assert!(handlers.is_none(), "Invalid UTIs should return None"); 245 | } 246 | 247 | #[test] 248 | fn test_get_common_role_handlers() { 249 | // Create a test BiMap 250 | let mut uti2suf = BiMap::new(); 251 | uti2suf.insert("public.plain-text".to_string(), "txt".to_string()); 252 | uti2suf.insert("public.html".to_string(), "html".to_string()); 253 | 254 | // Test with valid UTIs 255 | let common_handlers = get_common_role_handlers(&uti2suf); 256 | assert!(common_handlers.is_some()); 257 | 258 | // Test with empty BiMap 259 | let empty_map = BiMap::new(); 260 | let common_handlers = get_common_role_handlers(&empty_map); 261 | assert!(common_handlers.is_none()); // Empty map should return None 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/uti/names.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use lazy_static::lazy_static; 3 | 4 | lazy_static! { 5 | pub static ref UTI_FRIENDLY_NAMES: HashMap<&'static str, &'static str> = { 6 | let mut m = HashMap::new(); 7 | // Video formats 8 | m.insert("public.mp4", "MP4 Video"); 9 | m.insert("public.mpeg", "MPEG Video"); 10 | m.insert("public.avi", "AVI Video"); 11 | m.insert("public.mov", "QuickTime Movie"); 12 | m.insert("com.apple.quicktime-movie", "QuickTime Movie"); 13 | m.insert("public.mpeg-4", "MPEG-4 Video"); 14 | 15 | // Audio formats 16 | m.insert("public.mp3", "MP3 Audio"); 17 | m.insert("public.wav", "WAV Audio"); 18 | m.insert("public.aiff", "AIFF Audio"); 19 | m.insert("public.m4a", "M4A Audio"); 20 | m.insert("com.apple.m4a-audio", "M4A Audio"); 21 | m.insert("public.audio", "Audio"); 22 | 23 | // Image formats 24 | m.insert("public.jpeg", "JPEG Image"); 25 | m.insert("public.png", "PNG Image"); 26 | m.insert("public.gif", "GIF Image"); 27 | m.insert("com.apple.pict", "PICT Image"); 28 | m.insert("public.svg-image", "SVG Image"); 29 | m.insert("public.tiff", "TIFF Image"); 30 | 31 | // Document formats 32 | m.insert("public.plain-text", "Plain Text"); 33 | m.insert("public.text", "Text"); 34 | m.insert("public.html", "HTML Document"); 35 | m.insert("public.xml", "XML Document"); 36 | m.insert("public.json", "JSON Document"); 37 | m.insert("com.adobe.pdf", "PDF Document"); 38 | m.insert("com.microsoft.word.doc", "Word Document"); 39 | m.insert("org.openxmlformats.wordprocessingml.document", "Word Document"); 40 | m.insert("public.rtf", "Rich Text Document"); 41 | m.insert("public.markdown", "Markdown Document"); 42 | 43 | // Programming languages 44 | m.insert("public.python-script", "Python Source"); 45 | m.insert("public.javascript-source", "JavaScript Source"); 46 | m.insert("public.ruby-script", "Ruby Source"); 47 | m.insert("public.go-source", "Go Source"); 48 | m.insert("public.rust-source", "Rust Source"); 49 | m.insert("public.c-source", "C Source"); 50 | m.insert("public.c-plus-plus-source", "C++ Source"); 51 | m.insert("public.swift-source", "Swift Source"); 52 | m.insert("public.java-source", "Java Source"); 53 | m.insert("public.shell-script", "Shell Script"); 54 | 55 | // Archive formats 56 | m.insert("public.zip-archive", "ZIP Archive"); 57 | m.insert("org.gnu.gnu-zip-archive", "GZIP Archive"); 58 | m.insert("public.tar-archive", "TAR Archive"); 59 | m.insert("org.7-zip.7-zip-archive", "7Z Archive"); 60 | m.insert("com.rarlab.rar-archive", "RAR Archive"); 61 | 62 | m 63 | }; 64 | 65 | pub static ref UTI_COMMON_SUFFIXES: HashMap<&'static str, &'static str> = { 66 | let mut m = HashMap::new(); 67 | // Video formats 68 | m.insert("public.mp4", "mp4"); 69 | m.insert("public.mpeg", "mpeg"); 70 | m.insert("public.avi", "avi"); 71 | m.insert("public.mov", "mov"); 72 | m.insert("com.apple.quicktime-movie", "mov"); 73 | m.insert("public.mpeg-4", "mp4"); 74 | 75 | // Audio formats 76 | m.insert("public.mp3", "mp3"); 77 | m.insert("public.wav", "wav"); 78 | m.insert("public.aiff", "aiff"); 79 | m.insert("public.m4a", "m4a"); 80 | m.insert("com.apple.m4a-audio", "m4a"); 81 | m.insert("public.audio", "audio"); 82 | 83 | // Image formats 84 | m.insert("public.jpeg", "jpg"); 85 | m.insert("public.png", "png"); 86 | m.insert("public.gif", "gif"); 87 | m.insert("com.apple.pict", "pict"); 88 | m.insert("public.svg-image", "svg"); 89 | m.insert("public.tiff", "tiff"); 90 | 91 | // Document formats 92 | m.insert("public.plain-text", "txt"); 93 | m.insert("public.text", "txt"); 94 | m.insert("public.html", "html"); 95 | m.insert("public.xml", "xml"); 96 | m.insert("public.json", "json"); 97 | m.insert("com.adobe.pdf", "pdf"); 98 | m.insert("com.microsoft.word.doc", "doc"); 99 | m.insert("org.openxmlformats.wordprocessingml.document", "docx"); 100 | m.insert("public.rtf", "rtf"); 101 | m.insert("public.markdown", "md"); 102 | 103 | // Programming languages 104 | m.insert("public.python-script", "py"); 105 | m.insert("public.javascript-source", "js"); 106 | m.insert("public.ruby-script", "rb"); 107 | m.insert("public.go-source", "go"); 108 | m.insert("public.rust-source", "rs"); 109 | m.insert("public.c-source", "c"); 110 | m.insert("public.c-plus-plus-source", "cpp"); 111 | m.insert("public.swift-source", "swift"); 112 | m.insert("public.java-source", "java"); 113 | m.insert("public.shell-script", "sh"); 114 | 115 | // Archive formats 116 | m.insert("public.zip-archive", "zip"); 117 | m.insert("org.gnu.gnu-zip-archive", "gz"); 118 | m.insert("public.tar-archive", "tar"); 119 | m.insert("org.7-zip.7-zip-archive", "7z"); 120 | m.insert("com.rarlab.rar-archive", "rar"); 121 | 122 | m 123 | }; 124 | } 125 | 126 | pub fn get_friendly_name(uti: &str) -> String { 127 | UTI_FRIENDLY_NAMES.get(uti).copied().unwrap_or(uti).to_string() 128 | } 129 | 130 | pub fn get_common_suffix(uti: &str, input_suffix: &str) -> String { 131 | format!(".{}", input_suffix) 132 | } 133 | -------------------------------------------------------------------------------- /src/utils/bimap.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | #[derive(Debug, Default)] 4 | pub struct BiMap { 5 | forward: HashMap, 6 | reverse: HashMap, 7 | } 8 | 9 | impl BiMap 10 | where 11 | K: Clone + Eq + std::hash::Hash, 12 | V: Clone + Eq + std::hash::Hash, 13 | { 14 | pub fn new() -> Self { 15 | BiMap { 16 | forward: HashMap::new(), 17 | reverse: HashMap::new(), 18 | } 19 | } 20 | 21 | pub fn insert(&mut self, key: K, value: V) -> Option<(K, V)> { 22 | // Remove any existing mappings 23 | let old_key = self.remove_by_value(&value); 24 | let old_value = self.remove_by_key(&key); 25 | 26 | self.forward.insert(key.clone(), value.clone()); 27 | self.reverse.insert(value, key); 28 | 29 | match (old_key, old_value) { 30 | (Some(k), Some(v)) => Some((k, v)), 31 | _ => None, 32 | } 33 | } 34 | 35 | pub fn remove_by_key(&mut self, key: &K) -> Option { 36 | if let Some(value) = self.forward.remove(key) { 37 | self.reverse.remove(&value); 38 | Some(value) 39 | } else { 40 | None 41 | } 42 | } 43 | 44 | pub fn remove_by_value(&mut self, value: &V) -> Option { 45 | if let Some(key) = self.reverse.remove(value) { 46 | self.forward.remove(&key); 47 | Some(key) 48 | } else { 49 | None 50 | } 51 | } 52 | 53 | pub fn get_by_key(&self, key: &K) -> Option<&V> { 54 | self.forward.get(key) 55 | } 56 | 57 | pub fn get_by_value(&self, value: &V) -> Option<&K> { 58 | self.reverse.get(value) 59 | } 60 | 61 | pub fn contains_key(&self, key: &K) -> bool { 62 | self.forward.contains_key(key) 63 | } 64 | 65 | pub fn contains_value(&self, value: &V) -> bool { 66 | self.reverse.contains_key(value) 67 | } 68 | 69 | pub fn len(&self) -> usize { 70 | self.forward.len() 71 | } 72 | 73 | pub fn is_empty(&self) -> bool { 74 | self.forward.is_empty() 75 | } 76 | 77 | pub fn clear(&mut self) { 78 | self.forward.clear(); 79 | self.reverse.clear(); 80 | } 81 | 82 | pub fn iter(&self) -> impl Iterator { 83 | self.forward.iter() 84 | } 85 | 86 | pub fn keys(&self) -> impl Iterator { 87 | self.forward.keys() 88 | } 89 | 90 | pub fn values(&self) -> impl Iterator { 91 | self.forward.values() 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | mod bimap; 2 | pub use bimap::BiMap; 3 | --------------------------------------------------------------------------------