diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5713001 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "command": "npm run tauri dev", + "name": "Launch debug instance", + "request": "launch", + "type": "node-terminal" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index f4412a2..e283749 100644 --- a/README.md +++ b/README.md @@ -68,14 +68,30 @@ gitmun completions bash `gitmun ` is the shorthand for opening a repository. `clone` accepts an optional repository URL or SSH path, an optional destination, and `--start` to begin cloning after the window opens. Use `--to` to set only the destination. `init` defaults to the current directory when no path is supplied. Use `--help` for the full command reference. +### Experimental Local Copy + +Local Copy is disabled by default. Enable **Local Copy** under **Settings > Application > Experimental**, or set `enableLocalCopy = true` in `config.toml`. The command remains available for testing but is omitted from normal help and generated completions: + +```bash +gitmun copy /path/to/source /path/to/destination +gitmun copy /path/to/source --to /path/to/destination --mode files-only --start +gitmun copy https://github.com/owner/repo.git /path/to/destination --mode complete-repository --start +``` + +`copy` opens the Local Copy tab and accepts optional source and destination values. Use `--mode files-only` or `--mode complete-repository`; `--start` requires both paths and an explicit mode. Files only copies every hidden, ignored, untracked, and tracked file physically present while excluding every `.git` entry. It preserves symbolic links and materialises checked-out submodules. An existing usable destination `.git` is preserved; otherwise Gitmun initialises a fresh repository. `--delete-existing` removes only the destination working tree. Complete repository recursively clones the source and requires a destination that does not exist. + Linux-only helper setup (if needed): ```bash npm run linux:setup ``` +### Experimental AI extension + +Gitmun includes an opt-in AI extension for commit-message previews and conflict-resolution proposals. It is disabled by default and supports hosted, local and OpenAI-compatible providers. See [AI extension configuration](docs/ai-extension.md) for provider profiles, privacy controls and launch-time environment overrides. + ## Notes - Gitmun uses your system Git authentication setup (SSH agent, credential helpers, HTTPS tokens). -- Settings are stored in a JSON config file; the path is shown in the Settings window. +- Settings are stored in a TOML config file; the path is shown in the Settings window. - macOS bundles are built in CI but are currently untested because I do not have access to macOS hardware. Any help testing macOS releases is greatly appreciated. diff --git a/docs/ai-extension.md b/docs/ai-extension.md new file mode 100644 index 0000000..168cb43 --- /dev/null +++ b/docs/ai-extension.md @@ -0,0 +1,65 @@ +# AI extension + +Gitmun ships AI support as a bundled, experimental extension. It is disabled by default on new installations. The frontend, Tauri commands and provider runtime are compiled into the main application; the extension is not currently a separate or on-demand download. Enabling or disabling it does not remove profiles or credentials. Gitmun does not currently load third-party extension code. + +## Providers + +The provider list contains OpenAI, Anthropic Claude, Mistral, Google Gemini, OpenRouter, Azure OpenAI, Ollama, LM Studio and an advanced OpenAI-compatible option. Mistral and Gemini use their OpenAI-compatible APIs through Gitmun's shared transport. OpenRouter uses the same transport with additional privacy, routing, catalogue, pricing and usage handling. + +Profiles keep the provider, canonical endpoint, protocol, model and provider-specific settings together. Credentials are stored separately in the operating-system credential store and are scoped to the profile, provider and endpoint authority. Changing a destination does not reuse the previous destination's credential. Environment-provided secrets are never returned to the UI, written to the configuration file or copied to the credential store. + +Remote providers must use HTTPS. Plain HTTP is accepted only for loopback addresses such as `127.0.0.1`, `localhost` and `::1`. URLs containing credentials are rejected, redirects are not followed and responses are size-limited. Generation requests are not retried after transport failures or ordinary provider errors, but Gitmun can make bounded follow-up requests when automatic reasoning effort or structured output is rejected as unsupported. Large commit contexts can also require separate summarisation requests, so one user action can make more than one provider request. + +## Environment overrides + +Gitmun reads AI overrides once at launch. Precedence is explicit `GITMUN_AI_*` values, standard variables for the explicitly selected provider, the selected stored profile, then provider defaults. Invalid values fail closed and errors identify the variable name without including its value. Environment-managed controls are read-only in Settings. + +The supported variables are: + +```text +GITMUN_AI_ENABLED +GITMUN_AI_PROVIDER +GITMUN_AI_ENDPOINT +GITMUN_AI_MODEL +GITMUN_AI_API_KEY +GITMUN_AI_REASONING +GITMUN_AI_API_STYLE +GITMUN_AI_REQUEST_PATH +GITMUN_AI_MODELS_PATH +GITMUN_AI_AUTH_MODE +GITMUN_AI_AUTH_HEADER +GITMUN_AI_MAX_TOKENS_FIELD +GITMUN_AI_EXTRA_HEADERS_JSON +GITMUN_AI_AZURE_DEPLOYMENT +GITMUN_AI_AZURE_API_VERSION +GITMUN_AI_OPENROUTER_PRIVACY +GITMUN_AI_OPENROUTER_ALLOW_FALLBACKS +GITMUN_AI_OPENROUTER_REQUIRE_PARAMETERS +GITMUN_AI_OPENROUTER_MAX_PROMPT_PRICE +GITMUN_AI_OPENROUTER_MAX_COMPLETION_PRICE +GITMUN_AI_COMMIT_CONTEXT_LIMIT_KIB +GITMUN_AI_CONFLICT_CONTEXT_LIMIT_KIB +GITMUN_AI_COMMIT_MAX_TOKENS +GITMUN_AI_CONFLICT_MAX_TOKENS +GITMUN_AI_COMMIT_PROMPT_FILE +GITMUN_AI_CONFLICT_PROMPT_FILE +GITMUN_AI_INCLUDE_COMMIT_HISTORY +``` + +When a provider is explicitly selected, Gitmun also recognises its standard secret variable: `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `MISTRAL_API_KEY`, `GEMINI_API_KEY` (then `GOOGLE_API_KEY`), `OPENROUTER_API_KEY` or `AZURE_OPENAI_API_KEY`. Established endpoint variables including `OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL` and `AZURE_OPENAI_ENDPOINT` are supported in the same provider-scoped way. + +## Privacy and workflows + +Before any workflow sends repository content to a provider authority for the first time, Gitmun shows an outbound-context preview and requires consent. Commit-message and AI-writing contexts honour global and per-repository path exclusions, can omit recent commit history and apply best-effort sensitive-path detection. Conflict-resolution context never includes recent commit history, but it does not currently apply configured path exclusions or sensitive-path detection. Conflict input is restricted to a repository-contained, unmerged UTF-8 file within the configured context limit. These guards are not a substitute for reviewing the preview. + +The commit editor's primary AI action generates one candidate using the repository defaults. Its adjacent menu opens the full composer for one to three candidates, issue keys and one-off instructions. Composer defaults can retain the mode, language and optional Conventional Commit type and scope without retaining issue keys or additional instructions. Existing text requires confirmation before a quick request, generated text has one-step undo, and the full composer writes nothing until a candidate is accepted. Quick generation is cancelled if its repository, workflow or editor contents change, and the backend rejects results when the staged snapshot changes. Normal, amend, merge, rebase, cherry-pick and revert workflows pass their workflow and existing message as context. + +Conflict resolution produces structured, per-region proposals without writing files. Gitmun can generate proposals for one conflict file or queue every eligible conflict file, using one generation operation per file and processing multi-file queues sequentially. Before a multi-file queue starts, Gitmun shows the number of prepared prompts and warns about potential paid provider requests. Multi-file results are grouped by file for review, while applying selected regions still revalidates and writes one file at a time. Applying proposals preserves line endings and permissions and writes atomically. Applying every region also stages the file as resolved. Undo restores the original file and unmerged index while the in-memory proposal session remains available, for up to one hour. + +The AI writing tools provide preview-only staged-change reviews, branch summaries, pull request descriptions and release notes. They never publish content or write repository files. Branch and pull request tasks use the configured base reference or the current branch's upstream; release notes use the configured base reference or latest tag. Repository policies control history inclusion and exclusions for commit generation and AI writing. Commit generation can also use saved style and language defaults and a repository-relative commit prompt file. Conflict generation can use a repository-relative conflict prompt file; launch-environment prompt overrides take precedence. + +OpenRouter defaults to denying provider data collection, allows normal provider fallback and requires requested parameters. Strict ZDR and account-default privacy modes are available. Requests include OpenRouter [app attribution](https://openrouter.ai/docs/app-attribution) for Gitmun using its public project URL, application title and `programming-app` category; attribution never includes repository or user data. Model discovery uses the authenticated `/models/user` catalogue and enriches it with the ZDR endpoint list. Selecting a model also loads its available providers, quantisation, limits, pricing and recent performance metadata. Gitmun applies its model search, filters, sorting and UI pagination locally after fetching the user-aware catalogue. Failure of user-aware discovery is shown instead of falling back to the unrestricted public catalogue. Gitmun does not invoke OpenRouter tools, web search, shell execution, BYOK management, activity or credit-management APIs. + +OpenRouter profiles can authenticate through [OpenRouter OAuth PKCE](https://openrouter.ai/docs/guides/overview/auth/oauth). Gitmun opens the system browser, receives the one-use authorisation code on an arbitrary loopback port, exchanges it using an S256 verifier and stores only the resulting profile-scoped API key in the operating system credential store. The verifier and returned key never enter the frontend. Clearing the credential in Gitmun does not revoke the user-controlled key in OpenRouter; revoke it from the OpenRouter account when remote invalidation is required. OAuth is available only for the official `https://openrouter.ai` service and is disabled when the credential is supplied by the launch environment. + +Local usage history records provider, profile, model, task, duration, token counts, returned cost, request identifiers and status. When a new record is added, entries older than 30 days are removed and the history is capped at 1,000 records. It never stores prompts or repository content and can be cleared in Settings. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 52cd944..9de6a0a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -103,6 +114,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -314,6 +336,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -336,6 +370,112 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-sigv4" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + [[package]] name = "base64" version = "0.21.7" @@ -348,6 +488,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -387,6 +537,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -468,6 +636,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "bytesize" version = "2.3.1" @@ -541,6 +719,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.61" @@ -604,6 +791,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -671,6 +868,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -696,6 +899,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.1" @@ -755,6 +964,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -789,6 +1007,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -828,6 +1055,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.23.0" @@ -935,8 +1171,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -957,7 +1206,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1087,6 +1336,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + [[package]] name = "embed-resource" version = "3.0.9" @@ -1167,7 +1422,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1602,12 +1857,16 @@ dependencies = [ name = "gitmun" version = "0.1.0" dependencies = [ + "aws-credential-types", + "aws-sigv4", "base64 0.22.1", "clap", "clap_complete", + "getrandom 0.3.4", "gix", "gtk", "infer", + "keyring", "linux-terminal-launch", "md5", "mime_guess", @@ -1616,6 +1875,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2 0.10.9", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -1625,6 +1885,8 @@ dependencies = [ "tauri-plugin-updater", "tauri-utils", "tempfile", + "tokio", + "tokio-util", "toml 0.8.23", "toml_edit 0.22.27", "url", @@ -2694,7 +2956,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.14.0", "slab", "tokio", @@ -2783,6 +3045,33 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2793,6 +3082,17 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -2803,6 +3103,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2810,7 +3121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -2821,8 +3132,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2838,6 +3149,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaec953f16e5bcf6b8a3cb3aa959b17e5577dbd2693e94554c462c08be22624b" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2849,8 +3169,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -2865,7 +3185,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -2884,8 +3204,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -3098,6 +3418,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "io-close" version = "0.3.7" @@ -3180,7 +3510,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3338,6 +3668,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -3586,7 +3937,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3664,12 +4015,75 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3978,7 +4392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3995,6 +4409,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "pango" version = "0.18.3" @@ -4131,6 +4551,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -4552,8 +4978,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -4569,6 +4995,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4646,7 +5073,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4704,7 +5131,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4731,6 +5158,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -4812,6 +5245,25 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4959,6 +5411,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.19.0" @@ -5028,8 +5492,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -5038,7 +5502,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" dependencies = [ - "digest", + "digest 0.10.7", "sha1", ] @@ -5049,8 +5513,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5164,7 +5639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5421,7 +5896,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 1.4.0", "jni 0.21.1", "libc", "log", @@ -5494,7 +5969,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", @@ -5647,7 +6122,7 @@ dependencies = [ "dirs", "flate2", "futures-util", - "http", + "http 1.4.0", "infer", "log", "minisign-verify", @@ -5679,7 +6154,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http", + "http 1.4.0", "jni 0.21.1", "objc2", "objc2-ui-kit", @@ -5702,7 +6177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3989df2ae1c476404fe0a2e8ffc4cfbde97e51efd613c2bb5355fbc9ab52cf0" dependencies = [ "gtk", - "http", + "http 1.4.0", "jni 0.21.1", "log", "objc2", @@ -5734,7 +6209,7 @@ dependencies = [ "dom_query", "dunce", "glob", - "http", + "http 1.4.0", "infer", "json-patch", "log", @@ -5780,7 +6255,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5901,10 +6376,22 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6090,8 +6577,8 @@ dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", "tower", "tower-layer", @@ -6161,7 +6648,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6190,7 +6677,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6361,6 +6848,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" @@ -6729,7 +7222,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6891,6 +7384,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -7358,7 +7864,7 @@ dependencies = [ "dunce", "gdkx11", "gtk", - "http", + "http 1.4.0", "javascriptcore-rs", "jni 0.21.1", "libc", @@ -7372,7 +7878,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", @@ -7476,6 +7982,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.15.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 51be5bb..c119ea6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,10 +23,15 @@ tauri-plugin-opener = "2.5" tauri-plugin-os = "2.3" tauri-plugin-shell = "2.3" tauri-plugin-updater = "2.10" -reqwest = { version = "0.13", features = ["blocking", "rustls"], default-features = false } +reqwest = { version = "0.13", features = ["blocking", "json", "query", "rustls"], default-features = false } +keyring = "4.1" url = "2" md5 = "0.8" base64 = "0.22" +getrandom = "0.3" +sha2 = "0.10" +aws-credential-types = "1.3.0" +aws-sigv4 = { version = "1.5.1", default-features = false, features = ["sign-http", "http1"] } clap = { version = "4.5", features = ["derive"] } clap_complete = "4.5" notify = "8" @@ -35,9 +40,9 @@ infer = "0.19" tauri-utils = "2.8.3" toml = "0.8" toml_edit = "0.22" - -[dev-dependencies] tempfile = "3" +tokio-util = "0.7" +tokio = { version = "1", features = ["macros"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = { version = "0.18", features = ["v3_24"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index c3f253b..be0a460 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -20,11 +20,11 @@ fn main() { std::env::var("GITHUB_SHA") .ok() .filter(|v| !v.trim().is_empty()) - .map(|sha| sha[..8.min(sha.len())].to_string()) + .map(|sha| sha[..7.min(sha.len())].to_string()) }) .or_else(|| { std::process::Command::new("git") - .args(["rev-parse", "--short", "HEAD"]) + .args(["rev-parse", "--short=7", "HEAD"]) .output() .ok() .filter(|o| o.status.success()) diff --git a/src-tauri/config.example.toml b/src-tauri/config.example.toml index afe2b9a..56468b6 100644 --- a/src-tauri/config.example.toml +++ b/src-tauri/config.example.toml @@ -23,6 +23,9 @@ wrapDiffLines = false # Experimental: show the commit graph toolbar button. showCommitGraphButton = false +# Experimental: enable Local Copy in the Clone window and CLI. +enableLocalCopy = false + # Whether error messages stay open until dismissed. persistentErrorToasts = false @@ -101,3 +104,44 @@ gitExecutablePath = "" # Whether Gitmun may fetch missing GPG public keys through your GnuPG keyserver configuration. gpgKeyserverVerificationEnabled = false + +# Bundled experimental AI extension. New installations start with it disabled. +[extensions.ai] +enabled = false +selectedProfileId = "" +profiles = [] + +# Maximum context sent in each AI commit-message request, in KiB. +# Larger staged diffs use bounded chunks and summaries, up to 1024 KiB total. +# Values are limited to the range 8-1024. +commitContextLimitKib = 24 + +# Maximum conflict context sent for AI conflict resolution, in KiB. +# Values are limited to the range 8-1024. +conflictContextLimitKib = 48 + +# Maximum provider output for AI commit messages, in tokens. +# Values are limited to the range 1-65536. +commitMessageMaxTokens = 512 + +# Maximum provider output for AI conflict resolution, in tokens. +# Values are limited to the range 1-65536. +conflictResolutionMaxTokens = 4096 + +# Instructions used to generate commit messages. Gitmun adds the active subject limit separately. +commitMessagePrompt = "Write a concise Git commit message in the style of the supplied recent commits. Return only the commit message as plain text. Put the subject first, then an optional blank line and body. Summarise the staged changes accurately. Do not use Markdown headings, lists, fences, emoji, or commentary." + +# Instructions used to resolve conflicts. Gitmun keeps the structured response schema fixed. +conflictResolutionPrompt = "Resolve the supplied Git conflict regions. Preserve intended behaviour and surrounding style. Return only the requested structured JSON with one replacement for every supplied region ID." + +# Include recent commit messages when generating commit-message suggestions. +includeCommitHistory = true + +# Repository-relative glob patterns that are never included in AI context. +globalExclusions = [] + +# Provider destinations approved after Gitmun shows its first-use privacy notice. +consentedDestinations = [] + +# Per-repository AI policies are added here after they are configured in Gitmun. +repositoryPolicies = {} diff --git a/src-tauri/src/ai/api/aws_sigv4.rs b/src-tauri/src/ai/api/aws_sigv4.rs new file mode 100644 index 0000000..db4d1f7 --- /dev/null +++ b/src-tauri/src/ai/api/aws_sigv4.rs @@ -0,0 +1,203 @@ +//! AWS Signature Version 4 support for Amazon Bedrock requests. + +use std::time::SystemTime; + +use aws_credential_types::Credentials; +use aws_sigv4::{ + http_request::{SignableBody, SignableRequest, SigningSettings, sign}, + sign::v4, +}; +use reqwest::header::{HeaderName, HeaderValue}; +use serde::Deserialize; + +use super::super::AiError; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct IamCredentials { + access_key_id: String, + secret_access_key: String, + #[serde(default)] + session_token: Option, +} + +pub(crate) fn is_iam_credentials_json(value: &str) -> bool { + parse_iam_credentials(value).is_ok() +} + +pub(crate) fn sign_bedrock_request( + request: &mut reqwest::Request, + credential_value: &str, + body: &[u8], +) -> Result<(), AiError> { + sign_bedrock_request_at(request, credential_value, body, SystemTime::now()) +} + +fn parse_iam_credentials(credential_value: &str) -> Result { + let credentials: IamCredentials = + serde_json::from_str(credential_value).map_err(|_| AiError::new("apiKeyInvalid"))?; + if credentials.access_key_id.trim().is_empty() + || credentials.secret_access_key.trim().is_empty() + { + return Err(AiError::new("apiKeyInvalid")); + } + Ok(IamCredentials { + access_key_id: credentials.access_key_id, + secret_access_key: credentials.secret_access_key, + session_token: credentials + .session_token + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()), + }) +} + +fn sign_bedrock_request_at( + request: &mut reqwest::Request, + credential_value: &str, + body: &[u8], + signing_time: SystemTime, +) -> Result<(), AiError> { + let credentials = parse_iam_credentials(credential_value)?; + let region = bedrock_region(request.url()).ok_or_else(|| AiError::new("endpointInvalid"))?; + let credentials = Credentials::new( + credentials.access_key_id, + credentials.secret_access_key, + credentials.session_token, + None, + "gitmun-user-supplied", + ); + let identity = credentials.into(); + let signing_params = v4::SigningParams::builder() + .identity(&identity) + .region(®ion) + .name("bedrock") + .time(signing_time) + .settings(SigningSettings::default()) + .build() + .map_err(|_| AiError::new("authentication"))? + .into(); + if request + .headers() + .values() + .any(|value| value.to_str().is_err()) + { + return Err(AiError::new("authHeaderInvalid")); + } + let signable = SignableRequest::new( + request.method().as_str(), + request.url().as_str(), + request + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.to_str().unwrap_or_default())), + SignableBody::Bytes(body), + ) + .map_err(|_| AiError::new("authentication"))?; + let (instructions, _) = sign(signable, &signing_params) + .map_err(|_| AiError::new("authentication"))? + .into_parts(); + let (headers, query_parameters) = instructions.into_parts(); + if !query_parameters.is_empty() { + return Err(AiError::new("authentication")); + } + for header in headers { + let name = HeaderName::from_bytes(header.name().as_bytes()) + .map_err(|_| AiError::new("authentication"))?; + let mut value = HeaderValue::from_bytes(header.value().as_bytes()) + .map_err(|_| AiError::new("authentication"))?; + value.set_sensitive(header.sensitive()); + request.headers_mut().insert(name, value); + } + Ok(()) +} + +fn bedrock_region(url: &url::Url) -> Option { + let host = url.host_str()?; + let suffix = ".amazonaws.com"; + let host = host.strip_suffix(suffix)?; + host.strip_prefix("bedrock-runtime.") + .or_else(|| host.strip_prefix("bedrock-runtime-fips.")) + .or_else(|| host.strip_prefix("bedrock.")) + .filter(|region| !region.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, UNIX_EPOCH}; + + use reqwest::header::CONTENT_TYPE; + + use super::*; + + #[test] + fn signs_runtime_requests_with_bedrock_scope() { + let client = reqwest::Client::new(); + let mut request = client + .post("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/converse") + .header(CONTENT_TYPE, "application/json") + .body(b"{}".to_vec()) + .build() + .unwrap(); + sign_bedrock_request_at( + &mut request, + r#"{"accessKeyId":"AKIDEXAMPLE","secretAccessKey":"secret","sessionToken":"session"}"#, + b"{}", + UNIX_EPOCH + Duration::from_secs(1_440_938_160), + ) + .unwrap(); + let authorisation = request + .headers() + .get("authorization") + .unwrap() + .to_str() + .unwrap(); + assert!(authorisation.contains("/us-east-1/bedrock/aws4_request")); + assert!(request.headers().contains_key("x-amz-security-token")); + } + + #[test] + fn derives_regions_from_runtime_and_control_plane_hosts() { + assert_eq!( + bedrock_region( + &url::Url::parse("https://bedrock-runtime.eu-west-1.amazonaws.com").unwrap() + ), + Some("eu-west-1".to_string()) + ); + assert_eq!( + bedrock_region(&url::Url::parse("https://bedrock.us-east-1.amazonaws.com").unwrap()), + Some("us-east-1".to_string()) + ); + assert_eq!( + bedrock_region( + &url::Url::parse("https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com") + .unwrap() + ), + Some("us-gov-west-1".to_string()) + ); + } + + #[test] + fn distinguishes_iam_json_from_bearer_tokens() { + assert!(is_iam_credentials_json( + r#"{"accessKeyId":"AKIDEXAMPLE","secretAccessKey":"secret"}"# + )); + assert!(!is_iam_credentials_json("bedrock-api-key-token")); + assert!(!is_iam_credentials_json("")); + assert!(!is_iam_credentials_json( + r#"{"accessKeyId":"","secretAccessKey":"secret"}"# + )); + } + + #[test] + fn rejects_bearer_tokens_for_sigv4_signing() { + let client = reqwest::Client::new(); + let mut request = client + .post("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/converse") + .body(b"{}".to_vec()) + .build() + .unwrap(); + let error = sign_bedrock_request(&mut request, "bedrock-api-key-token", b"{}").unwrap_err(); + assert_eq!(error.code, "apiKeyInvalid"); + } +} diff --git a/src-tauri/src/ai/api/bedrock.rs b/src-tauri/src/ai/api/bedrock.rs new file mode 100644 index 0000000..f183c38 --- /dev/null +++ b/src-tauri/src/ai/api/bedrock.rs @@ -0,0 +1,163 @@ +//! Amazon Bedrock Converse protocol adapter. + +use serde_json::{Value, json}; + +use super::super::AiError; +use super::super::configuration::EffectiveAiConfiguration; +use super::{ + AiOutputContract, AiStructuredOutputMode, AiUsage, OpenAiCompatibleExtension, ProtocolAdapter, + ProviderResult, response_text, +}; + +pub(crate) struct BedrockAdapter; + +impl ProtocolAdapter for BedrockAdapter { + fn request_body( + &self, + _configuration: &EffectiveAiConfiguration, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + _effort: Option<&str>, + output_contract: &AiOutputContract, + structured_output_mode: Option, + _extension: Option<&dyn OpenAiCompatibleExtension>, + ) -> Value { + let mut body = json!({ + "system": [{"text": system_prompt}], + "messages": [{"role": "user", "content": [{"text": user_prompt}]}], + "inferenceConfig": {"maxTokens": max_tokens}, + }); + if let ( + AiOutputContract::JsonSchema { name, schema }, + Some(AiStructuredOutputMode::JsonSchema), + ) = (output_contract, structured_output_mode) + { + body["outputConfig"] = json!({ + "textFormat": { + "type": "json_schema", + "structure": { + "jsonSchema": { + "name": name, + "schema": serde_json::to_string(schema).unwrap_or_default(), + } + } + } + }); + } + body + } + + fn parse_response( + &self, + value: Value, + request_id: Option, + _provider_extension: Option<&dyn OpenAiCompatibleExtension>, + ) -> Result { + let text = value + .pointer("/output/message/content") + .and_then(response_text) + .ok_or_else(|| AiError::new("invalidResponse"))?; + let finish_reason = value + .get("stopReason") + .and_then(Value::as_str) + .map(str::to_string); + Ok(ProviderResult { + text, + usage: AiUsage { + input_tokens: value.pointer("/usage/inputTokens").and_then(Value::as_u64), + output_tokens: value.pointer("/usage/outputTokens").and_then(Value::as_u64), + reasoning_tokens: None, + cached_tokens: value + .pointer("/usage/cacheReadInputTokens") + .and_then(Value::as_u64), + cost: None, + byok: None, + }, + request_id, + generation_id: None, + routed_provider: None, + routed_model: value + .pointer("/trace/promptRouter/invokedModelId") + .and_then(Value::as_str) + .map(str::to_string), + output_truncated: finish_reason.as_deref() == Some("max_tokens"), + finish_reason, + response_bytes: 0, + }) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::ai::api::{AiOutputContract, endpoint_with_path}; + + #[test] + fn converse_request_uses_content_blocks() { + let body = BedrockAdapter.request_body( + &crate::ai::providers::test_helpers::configuration(crate::ai::AiProvider::Bedrock), + "system", + "user", + 128, + None, + &AiOutputContract::Text, + None, + None, + ); + assert_eq!(body["system"][0]["text"], "system"); + assert_eq!(body["messages"][0]["content"][0]["text"], "user"); + assert_eq!(body["inferenceConfig"]["maxTokens"], 128); + } + + #[test] + fn parses_converse_response() { + let result = BedrockAdapter + .parse_response( + json!({ + "output": {"message": {"content": [{"text": "OK"}]}}, + "stopReason": "max_tokens", + "usage": {"inputTokens": 2, "outputTokens": 1} + }), + Some("request".to_string()), + None, + ) + .unwrap(); + assert_eq!(result.text, "OK"); + assert_eq!(result.usage.input_tokens, Some(2)); + assert!(result.output_truncated); + } + + #[test] + fn model_id_is_encoded_as_one_path_segment() { + let mut configuration = + crate::ai::providers::test_helpers::configuration(crate::ai::AiProvider::Bedrock); + configuration.request_path = "/model/{model}/converse".to_string(); + configuration.model = + "arn:aws:bedrock:us-east-1:123456789012:inference-profile/example".to_string(); + + let endpoint = endpoint_with_path(&configuration, &configuration.request_path).unwrap(); + + assert!(endpoint.as_str().contains("inference-profile%2Fexample")); + } + + #[test] + fn bearer_authentication_rejects_iam_json_credentials() { + let mut configuration = + crate::ai::providers::test_helpers::configuration(crate::ai::AiProvider::Bedrock); + configuration.auth_mode = crate::ai::AiAuthMode::Bearer; + let runtime = crate::ai::api::AiRuntime::new().unwrap(); + let request = runtime + .client + .get("https://bedrock.us-east-1.amazonaws.com/foundation-models"); + let error = crate::ai::api::authenticate( + request, + &configuration, + r#"{"accessKeyId":"AKIDEXAMPLE","secretAccessKey":"secret"}"#, + ) + .unwrap_err(); + assert_eq!(error.code, "apiKeyInvalid"); + } +} diff --git a/src-tauri/src/ai/api/claude.rs b/src-tauri/src/ai/api/claude.rs new file mode 100644 index 0000000..ec72ed2 --- /dev/null +++ b/src-tauri/src/ai/api/claude.rs @@ -0,0 +1,189 @@ +//! Claude Messages protocol adapter, model normalisation, and effort discovery. + +use reqwest::RequestBuilder; +use serde_json::{Map, Value, json}; + +use super::super::AiError; +use super::super::configuration::EffectiveAiConfiguration; +use super::super::types::{AiEffortCapability, AiProvider, AiReasoningPreference}; +use super::{ + AiModelInfo, AiOutputContract, AiStructuredOutputMode, AiUsage, MAX_RESPONSE_BYTES, + OpenAiCompatibleExtension, ProtocolAdapter, ProviderResult, REQUEST_TIMEOUT, authenticate, + endpoint_with_path, read_response, response_text, +}; + +use super::AiRuntime; + +pub(crate) struct ClaudeAdapter; + +impl ProtocolAdapter for ClaudeAdapter { + fn request_body( + &self, + configuration: &EffectiveAiConfiguration, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + effort: Option<&str>, + output_contract: &AiOutputContract, + structured_output_mode: Option, + _extension: Option<&dyn OpenAiCompatibleExtension>, + ) -> Value { + let mut body = json!({ + "model": configuration.model, + "system": system_prompt, + "messages": [{"role": "user", "content": user_prompt}], + "max_tokens": max_tokens + }); + let mut output_config = Map::new(); + if let Some(effort) = effort { + output_config.insert("effort".to_string(), json!(effort)); + } + if let ( + AiOutputContract::JsonSchema { schema, .. }, + Some(AiStructuredOutputMode::JsonSchema), + ) = (output_contract, structured_output_mode) + { + output_config.insert( + "format".to_string(), + json!({"type": "json_schema", "schema": schema}), + ); + } + if !output_config.is_empty() { + body["output_config"] = Value::Object(output_config); + } + body + } + + fn parse_response( + &self, + value: Value, + request_id: Option, + _provider_extension: Option<&dyn OpenAiCompatibleExtension>, + ) -> Result { + let text = value + .get("content") + .and_then(response_text) + .ok_or_else(|| AiError::new("invalidResponse"))?; + Ok(ProviderResult { + text, + usage: AiUsage { + input_tokens: value.pointer("/usage/input_tokens").and_then(Value::as_u64), + output_tokens: value + .pointer("/usage/output_tokens") + .and_then(Value::as_u64), + reasoning_tokens: None, + cached_tokens: value + .pointer("/usage/cache_read_input_tokens") + .and_then(Value::as_u64), + cost: None, + byok: None, + }, + request_id, + generation_id: value.get("id").and_then(Value::as_str).map(str::to_string), + routed_provider: None, + routed_model: value + .get("model") + .and_then(Value::as_str) + .map(str::to_string), + output_truncated: value.get("stop_reason").and_then(Value::as_str) + == Some("max_tokens"), + finish_reason: value + .get("stop_reason") + .and_then(Value::as_str) + .map(str::to_string), + response_bytes: 0, + }) + } + + fn add_protocol_headers(&self, request: RequestBuilder) -> RequestBuilder { + request.header("anthropic-version", "2023-06-01") + } +} + +/// Claude model normalisation. +pub(crate) fn normalise_claude_model(value: &Value) -> Option { + let id = value.get("id")?.as_str()?.to_string(); + let name = value + .get("display_name") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(); + let capabilities = value.get("capabilities"); + let image_input = capabilities + .and_then(|c| c.get("image_input")) + .and_then(Value::as_bool) + .unwrap_or(false); + Some(AiModelInfo { + name, + id, + created: value.get("created_at").and_then(Value::as_u64), + context_length: value.get("max_input_tokens").and_then(Value::as_u64), + maximum_completion_tokens: value.get("max_tokens").and_then(Value::as_u64), + structured_output: capabilities + .and_then(|c| c.get("structured_outputs")) + .and_then(Value::as_bool) + .unwrap_or(false), + reasoning: capabilities + .and_then(|c| c.get("thinking")) + .is_some_and(|v| !v.is_null()), + input_modalities: if image_input { + vec!["text".to_string(), "image".to_string()] + } else { + vec!["text".to_string()] + }, + ..AiModelInfo::default() + }) +} + +/// Discover effort capability from Claude's per-model endpoint. +pub(crate) async fn discover_effort( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, +) -> Option { + if configuration.provider != AiProvider::Claude || configuration.models_path.is_empty() { + return None; + } + let path = format!( + "{}/{}", + configuration.models_path.trim_end_matches('/'), + configuration.model + ); + let endpoint = endpoint_with_path(configuration, &path).ok()?; + let request = runtime.client.get(endpoint).timeout(REQUEST_TIMEOUT); + let request = ClaudeAdapter.add_protocol_headers(request); + let response = authenticate(request, configuration, api_key) + .ok()? + .send() + .await + .ok()?; + let (status, _, bytes) = read_response(response, MAX_RESPONSE_BYTES).await.ok()?; + if !status.is_success() { + return None; + } + let value: Value = serde_json::from_slice(&bytes).ok()?; + claude_effort_capability(&value) +} + +pub(crate) fn claude_effort_capability(value: &Value) -> Option { + let effort = value.pointer("/capabilities/effort")?; + let levels = [ + ("low", AiReasoningPreference::Low), + ("medium", AiReasoningPreference::Medium), + ("high", AiReasoningPreference::High), + ] + .into_iter() + .filter_map(|(name, level)| { + effort + .pointer(&format!("/{name}/supported")) + .and_then(Value::as_bool) + .unwrap_or(false) + .then_some(level) + }) + .collect::>(); + if levels.is_empty() { + Some(AiEffortCapability::Unsupported) + } else { + Some(AiEffortCapability::Supported(levels)) + } +} diff --git a/src-tauri/src/ai/api/mod.rs b/src-tauri/src/ai/api/mod.rs new file mode 100644 index 0000000..475185c --- /dev/null +++ b/src-tauri/src/ai/api/mod.rs @@ -0,0 +1,1012 @@ +//! Shared HTTP engine primitives for AI provider requests. + +pub(crate) mod aws_sigv4; +pub(crate) mod bedrock; +pub(crate) mod claude; +pub(crate) mod openai; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use reqwest::{Client, RequestBuilder, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use tokio_util::sync::CancellationToken; +use url::Url; + +pub(crate) use super::AiUsage; +use super::configuration::{EffectiveAiConfiguration, validate_endpoint}; +use super::types::{AiAuthMode, AiEffortCapability, AiProvider, AiReasoningPreference}; +use super::{AiError, AiProviderResponseMetadata}; + +pub(crate) const CONNECTION_TEST_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(120); +pub(crate) const CONFLICT_RESOLUTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(5 * 60); +pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_OAUTH_RESPONSE_BYTES: usize = 64 * 1024; +pub(crate) const MAX_MODELS_RESPONSE_BYTES: usize = 4 * 1024 * 1024; +pub(crate) const MODEL_DISCOVERY_ATTEMPTS: usize = 3; +pub(crate) const MAX_AI_OPERATION_REQUESTS: usize = 64; +pub(crate) const MAX_AI_OPERATION_OUTBOUND_BYTES: usize = 2 * 1024 * 1024; + +#[derive(Clone, Copy)] +pub(crate) enum AiTask { + ConnectionTest, + CommitMessage, + ConflictResolution, +} + +impl AiTask { + pub(crate) fn request_timeout(self) -> Duration { + match self { + Self::ConnectionTest => CONNECTION_TEST_TIMEOUT, + Self::CommitMessage => REQUEST_TIMEOUT, + Self::ConflictResolution => CONFLICT_RESOLUTION_REQUEST_TIMEOUT, + } + } +} + +#[derive(Clone)] +pub(crate) struct AiRuntime { + pub(crate) client: Client, + structured_output_modes: Arc>>, +} + +impl AiRuntime { + pub fn new() -> Result { + let client = Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("gitmun-ai") + .build() + .map_err(|_| AiError::new("network"))?; + Ok(Self { + client, + structured_output_modes: Arc::new(Mutex::new(HashMap::new())), + }) + } + + pub(crate) fn load_structured_output_modes(&self, modes: &HashMap) { + let mut cache = self + .structured_output_modes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache.extend( + modes + .iter() + .filter_map(|(key, mode)| Some((key.clone(), parse_structured_output_mode(mode)?))), + ); + } + + pub(crate) fn forget_structured_output_mode(&self, key: &str) { + self.structured_output_modes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(key); + } + + pub(crate) fn structured_output_modes(&self) -> HashMap { + self.structured_output_modes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .map(|(key, mode)| (key.clone(), structured_output_mode_name(*mode).to_string())) + .collect() + } + + pub(crate) fn structured_output_mode( + &self, + configuration: &EffectiveAiConfiguration, + ) -> Option { + let key = structured_output_cache_key(configuration).ok()?; + self.structured_output_modes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(&key) + .copied() + } + + pub(crate) fn remember_structured_output_mode( + &self, + configuration: &EffectiveAiConfiguration, + mode: AiStructuredOutputMode, + ) { + let Ok(key) = structured_output_cache_key(configuration) else { + return; + }; + self.structured_output_modes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(key, mode); + } +} + +pub(crate) fn parse_structured_output_mode(value: &str) -> Option { + match value { + "jsonSchema" => Some(AiStructuredOutputMode::JsonSchema), + "jsonObject" => Some(AiStructuredOutputMode::JsonObject), + "promptOnly" => Some(AiStructuredOutputMode::PromptOnly), + _ => None, + } +} + +pub(crate) fn structured_output_mode_name(mode: AiStructuredOutputMode) -> &'static str { + match mode { + AiStructuredOutputMode::JsonSchema => "jsonSchema", + AiStructuredOutputMode::JsonObject => "jsonObject", + AiStructuredOutputMode::PromptOnly => "promptOnly", + } +} + +#[derive(Debug, Clone)] +pub(crate) enum AiOutputContract { + Text, + JsonSchema { name: &'static str, schema: Value }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AiStructuredOutputMode { + JsonSchema, + JsonObject, + PromptOnly, +} + +impl AiStructuredOutputMode { + pub(crate) fn fallback(self, adapter: &dyn ProtocolAdapter) -> Option { + match self { + Self::JsonSchema if adapter.supports_json_object() => Some(Self::JsonObject), + Self::JsonSchema | Self::JsonObject => Some(Self::PromptOnly), + Self::PromptOnly => None, + } + } +} + +pub(crate) struct AiRequestBudget { + pub(crate) requests: usize, + pub(crate) outbound_bytes: usize, +} + +impl AiRequestBudget { + pub(crate) fn new() -> Self { + Self { + requests: 0, + outbound_bytes: 0, + } + } + + fn charge(&mut self, body: &Value) -> Result<(), AiError> { + self.requests += 1; + self.outbound_bytes = self + .outbound_bytes + .saturating_add(serde_json::to_vec(body).map_or(usize::MAX, |body| body.len())); + if self.requests > MAX_AI_OPERATION_REQUESTS + || self.outbound_bytes > MAX_AI_OPERATION_OUTBOUND_BYTES + { + return Err(AiError::new("operationBudgetExceeded")); + } + Ok(()) + } +} + +pub(crate) struct ProviderResult { + pub(crate) text: String, + pub(crate) usage: AiUsage, + pub(crate) request_id: Option, + pub(crate) generation_id: Option, + pub(crate) routed_provider: Option, + pub(crate) routed_model: Option, + pub(crate) output_truncated: bool, + pub(crate) finish_reason: Option, + pub(crate) response_bytes: usize, +} + +#[allow(dead_code)] +impl ProviderResult { + pub(crate) fn metadata(&self) -> AiProviderResponseMetadata { + AiProviderResponseMetadata { + usage: self.usage.clone(), + request_id: self.request_id.clone(), + generation_id: self.generation_id.clone(), + routed_provider: self.routed_provider.clone(), + routed_model: self.routed_model.clone(), + finish_reason: self.finish_reason.clone(), + response_bytes: self.response_bytes, + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AiModelQuery { + pub search: String, + pub page: u32, + pub page_size: u32, + pub programming_only: bool, + pub author: String, + pub hosting_provider: String, + pub minimum_context_length: Option, + pub maximum_prompt_price: Option, + pub maximum_completion_price: Option, + pub zdr_only: bool, + pub sort: AiModelSort, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize)] +pub enum AiModelSort { + #[default] + Popularity, + PromptPrice, + CompletionPrice, + Context, + Latency, + Throughput, + CodingScore, + Newest, +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiModelInfo { + pub id: String, + pub canonical_slug: Option, + pub name: String, + pub description: Option, + pub context_length: Option, + pub maximum_completion_tokens: Option, + pub input_modalities: Vec, + pub output_modalities: Vec, + pub supported_parameters: Vec, + pub prompt_price: Option, + pub completion_price: Option, + pub request_price: Option, + pub cache_read_price: Option, + pub cache_write_price: Option, + pub reasoning: bool, + pub structured_output: bool, + pub available_providers: Vec, + pub quantisations: Vec, + pub latency: Option, + pub throughput: Option, + pub uptime: Option, + pub coding_score: Option, + pub zero_data_retention: Option, + pub created: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiModelPage { + pub models: Vec, + pub page: u32, + pub page_size: u32, + pub has_more: bool, +} + +// ---- Traits ---- + +pub(crate) trait ProtocolAdapter: Send + Sync { + fn request_body( + &self, + configuration: &EffectiveAiConfiguration, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + effort: Option<&str>, + output_contract: &AiOutputContract, + structured_output_mode: Option, + extension: Option<&dyn OpenAiCompatibleExtension>, + ) -> Value; + + fn parse_response( + &self, + value: Value, + request_id: Option, + provider_extension: Option<&dyn OpenAiCompatibleExtension>, + ) -> Result; + + fn add_protocol_headers(&self, request: RequestBuilder) -> RequestBuilder { + request + } + + fn supports_json_object(&self) -> bool { + false + } +} + +/// Extension trait for OpenAI-compatible providers with additional behaviour +/// (e.g. OpenRouter custom headers, routing, privacy fields). +pub(crate) trait OpenAiCompatibleExtension: Send + Sync { + fn add_headers(&self, request: RequestBuilder) -> RequestBuilder; + fn extend_request( + &self, + configuration: &EffectiveAiConfiguration, + body: &mut Map, + ); + fn extend_result(&self, value: &Value, result: &mut ProviderResult); + fn normalise_model(&self, value: &Value) -> Option; +} + +// ---- Shared helpers ---- + +pub(crate) fn response_text(value: &Value) -> Option { + match value { + Value::String(text) => Some(text.clone()), + Value::Array(items) => { + let text = items + .iter() + .filter_map(|item| { + item.get("text") + .and_then(Value::as_str) + .or_else(|| item.get("content").and_then(Value::as_str)) + }) + .collect::>() + .join(""); + (!text.is_empty()).then_some(text) + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => None, + } +} + +pub(crate) fn responses_output_text(value: &Value) -> Option { + let text = value + .get("output")? + .as_array()? + .iter() + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("output_text")) + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + (!text.is_empty()).then_some(text) +} + +pub(crate) fn string_array(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() +} + +pub(crate) fn string_number(value: Option<&Value>) -> Option { + value.and_then(|value| match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }) +} + +pub(crate) fn first_f64(value: &Value, pointers: &[&str]) -> Option { + pointers.iter().find_map(|pointer| { + value + .pointer(pointer) + .and_then(|value| value.as_f64().or_else(|| value.as_str()?.parse().ok())) + }) +} + +pub(crate) fn endpoint_with_path( + configuration: &EffectiveAiConfiguration, + path: &str, +) -> Result { + let mut endpoint = validate_endpoint(&configuration.endpoint)?; + let path = path.replace("{deployment}", &configuration.azure_deployment); + let unresolved_path = path.replace("{model}", ""); + if unresolved_path.contains('{') || unresolved_path.contains('}') { + return Err(AiError::new("deploymentRequired")); + } + let mut segments = endpoint + .path_segments_mut() + .map_err(|_| AiError::new("endpointInvalid"))?; + segments.pop_if_empty(); + for segment in path.trim_matches('/').split('/') { + if segment == "{model}" { + if configuration.model.trim().is_empty() { + return Err(AiError::new("modelRequired")); + } + segments.push(&configuration.model); + } else if !segment.is_empty() { + segments.push(segment); + } + } + drop(segments); + if configuration.provider == AiProvider::AzureOpenAi { + if configuration.azure_deployment.trim().is_empty() { + return Err(AiError::new("deploymentRequired")); + } + endpoint + .query_pairs_mut() + .append_pair("api-version", &configuration.azure_api_version); + } + Ok(endpoint) +} + +pub(crate) fn structured_output_cache_key( + configuration: &EffectiveAiConfiguration, +) -> Result { + Ok(format!( + "{:?}\0{}\0{:?}\0{}", + configuration.provider, + endpoint_with_path(configuration, &configuration.request_path)?, + configuration.api_style, + configuration.model, + )) +} + +pub(crate) fn api_key_optional(configuration: &EffectiveAiConfiguration) -> bool { + configuration + .provider + .api_key_optional(configuration.endpoint_is_loopback()) + || configuration.auth_mode == AiAuthMode::None +} + +pub(crate) fn authenticate( + request: RequestBuilder, + configuration: &EffectiveAiConfiguration, + api_key: &str, +) -> Result { + let mut request = match configuration.auth_mode { + AiAuthMode::Bearer if !api_key.is_empty() => { + if configuration.provider == AiProvider::Bedrock + && aws_sigv4::is_iam_credentials_json(api_key) + { + return Err(AiError::new("apiKeyInvalid")); + } + request.bearer_auth(api_key) + } + AiAuthMode::Header if !api_key.is_empty() => { + let name = HeaderName::from_bytes(configuration.auth_header.as_bytes()) + .map_err(|_| AiError::new("authHeaderInvalid"))?; + let value = + HeaderValue::from_str(api_key).map_err(|_| AiError::new("apiKeyInvalid"))?; + request.header(name, value) + } + AiAuthMode::Bearer | AiAuthMode::Header | AiAuthMode::None => request, + AiAuthMode::AwsSigV4 => request, + }; + for (name, value) in &configuration.extra_headers { + request = request.header(name, value); + } + Ok(request) +} + +pub(crate) fn effort_for( + configuration: &EffectiveAiConfiguration, + task: AiTask, +) -> Option<&'static str> { + let effort = match configuration.reasoning_preference { + AiReasoningPreference::ProviderDefault => None, + AiReasoningPreference::Low => Some("low"), + AiReasoningPreference::Medium => Some("medium"), + AiReasoningPreference::High => Some("high"), + AiReasoningPreference::Automatic => Some(match task { + AiTask::ConflictResolution => "medium", + AiTask::ConnectionTest | AiTask::CommitMessage => "low", + }), + }; + if configuration.reasoning_preference != AiReasoningPreference::Automatic { + return effort; + } + match &configuration.effort_capability { + AiEffortCapability::Unsupported => None, + AiEffortCapability::Supported(levels) => effort.filter(|effort| { + levels.iter().any(|level| { + matches!( + (level, *effort), + (&AiReasoningPreference::Low, "low") + | (&AiReasoningPreference::Medium, "medium") + | (&AiReasoningPreference::High, "high") + ) + }) + }), + AiEffortCapability::Unknown | AiEffortCapability::Accepted => effort, + } +} + +pub(crate) async fn read_response( + response: Response, + maximum_bytes: usize, +) -> Result<(StatusCode, HeaderMap, Vec), AiError> { + let status = response.status(); + let headers = response.headers().clone(); + if response + .content_length() + .is_some_and(|length| length > maximum_bytes as u64) + { + return Err(AiError::new("responseTooLarge")); + } + let mut response = response; + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(network_error)? { + if body.len() + chunk.len() > maximum_bytes { + return Err(AiError::new("responseTooLarge")); + } + body.extend_from_slice(&chunk); + } + Ok((status, headers, body)) +} + +pub(crate) fn network_error(error: reqwest::Error) -> AiError { + if error.is_timeout() { + AiError::new("timeout") + } else if error.is_redirect() { + AiError::new("unsafeRedirect") + } else { + AiError::new("network") + } +} + +pub(crate) fn rejected_effort(status: StatusCode, body: &[u8]) -> bool { + status == StatusCode::BAD_REQUEST + && String::from_utf8_lossy(body) + .to_ascii_lowercase() + .contains("effort") +} + +pub(crate) fn rejected_structured_output( + status: StatusCode, + body: &[u8], + mode: Option, +) -> bool { + if !matches!(status.as_u16(), 400 | 422) + || !matches!( + mode, + Some(AiStructuredOutputMode::JsonSchema | AiStructuredOutputMode::JsonObject) + ) + { + return false; + } + + let body_str = String::from_utf8_lossy(body); + let lower = body_str.to_ascii_lowercase(); + + // Guard against false positives from deprecation or sunset notices + if lower.contains("deprecated") + && lower.contains("response_format") + && !lower.contains("not") + && !lower.contains("invalid") + && !lower.contains("unrecogni") + { + return false; + } + + // Structured JSON detection: many providers return typed error objects + if let Ok(value) = serde_json::from_slice::(body) { + if let Some(error) = value.get("error") { + // OpenAI / OpenAI-compatible: response_format param set -> clear signal + if let Some(param) = error.get("param").and_then(|v| v.as_str()) { + if matches!( + param.to_ascii_lowercase().as_str(), + "response_format" | "output_config" + ) { + return true; + } + } + // Check message for combined format + rejection keywords + if let Some(msg) = error.get("message").and_then(|v| v.as_str()) { + let msg_lower = msg.to_ascii_lowercase(); + // Deprecation warnings are not active rejections + if msg_lower.contains("deprecated") || msg_lower.contains("sunset") { + return false; + } + let type_lower = error + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if matches!( + type_lower.as_str(), + "invalid_request_error" | "validation_error" + ) { + let has_format = msg_lower.contains("response_format") + || msg_lower.contains("json_schema") + || msg_lower.contains("structured_output") + || msg_lower.contains("structured output") + || msg_lower.contains("text.format") + || msg_lower.contains("output_config.format") + || msg_lower.contains("output_config"); + let has_rejection = msg_lower.contains("unsupported") + || msg_lower.contains("not supported") + || msg_lower.contains("unknown parameter") + || msg_lower.contains("unrecogni") + || msg_lower.contains("invalid") + || msg_lower.contains("must be") + || msg_lower.contains("not allowed") + || msg_lower.contains("not permitted") + || msg_lower.contains("extra inputs") + || msg_lower.contains("not valid") + || msg_lower.contains("bad value") + || msg_lower.contains("wrong type"); + if has_format && has_rejection { + return true; + } + } + // If we have structured JSON with a message but no match, fall through + } + } + } + + // Heuristic fallback: keyword matching across the full body + let identifies_format = [ + "response_format", + "json_schema", + "structured_output", + "structured output", + "text.format", + "output_config.format", + "output_config", + "format.", + "format type", + ] + .iter() + .any(|field| lower.contains(field)); + let identifies_rejection = [ + "unsupported", + "not supported", + "unknown parameter", + "unknown", + "unrecognized", + "unrecognised", + "invalid parameter", + "invalid value", + "must be", + "not allowed", + "not permitted", + "extra inputs", + "not valid", + "bad value", + "wrong type", + ] + .iter() + .any(|reason| lower.contains(reason)); + identifies_format && (identifies_rejection || status == StatusCode::UNPROCESSABLE_ENTITY) +} + +pub(crate) fn response_error(status: StatusCode) -> AiError { + match status.as_u16() { + 401 | 403 => AiError::new("authentication"), + 408 | 429 => AiError::with_detail("providerUnavailable", status.as_u16().to_string()), + 300..=399 => AiError::new("unsafeRedirect"), + 400..=499 => AiError::with_detail("requestRejected", status.as_u16().to_string()), + _ => AiError::with_detail("providerUnavailable", status.as_u16().to_string()), + } +} + +pub(crate) fn provider_response_error( + configuration: &EffectiveAiConfiguration, + status: StatusCode, + headers: &HeaderMap, + body: &[u8], +) -> AiError { + let mut error = response_error(status); + if configuration.provider != AiProvider::OpenRouter || !configuration.open_router.diagnostics { + return error; + } + let value = serde_json::from_slice::(body).unwrap_or(Value::Null); + let mut diagnostics = vec![format!("status={}", status.as_u16())]; + for (label, value) in [ + ( + "request", + headers + .get("x-request-id") + .or_else(|| headers.get("request-id")) + .and_then(|value| value.to_str().ok()), + ), + ( + "generation", + value + .pointer("/error/metadata/generation_id") + .and_then(Value::as_str), + ), + ( + "provider", + value + .pointer("/error/metadata/provider_name") + .or_else(|| value.pointer("/error/metadata/provider")) + .and_then(Value::as_str), + ), + ] { + if let Some(value) = value.and_then(redacted_diagnostic_value) { + diagnostics.push(format!("{label}={value}")); + } + } + if let Some(attempts) = value + .pointer("/error/metadata/provider_attempts") + .and_then(Value::as_array) + { + let attempts = attempts + .iter() + .take(8) + .filter_map(|attempt| { + attempt + .get("provider") + .or_else(|| attempt.get("provider_name")) + .and_then(Value::as_str) + .and_then(redacted_diagnostic_value) + }) + .collect::>(); + if !attempts.is_empty() { + diagnostics.push(format!("attempts={}", attempts.join(","))); + } + } + error.detail = Some(diagnostics.join("; ")); + error +} + +pub(crate) fn redacted_diagnostic_value(value: &str) -> Option { + (!value.is_empty() + && value.len() <= 128 + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '/' | ':') + })) + .then(|| value.to_string()) +} + +// ---- Request orchestration ---- + +pub(crate) enum ProviderAttempt { + Completed(ProviderResult), + EffortRejected, + StructuredOutputRejected, +} + +pub(crate) async fn send_provider_request( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + effort: Option<&str>, + task: AiTask, + output_contract: &AiOutputContract, + structured_output_mode: Option, + budget: &mut AiRequestBudget, + cancellation: Option, + adapter: &dyn ProtocolAdapter, + extension: Option<&dyn OpenAiCompatibleExtension>, +) -> Result { + let endpoint = endpoint_with_path(configuration, &configuration.request_path)?; + let body = adapter.request_body( + configuration, + system_prompt, + user_prompt, + max_tokens, + effort, + output_contract, + structured_output_mode, + extension, + ); + budget.charge(&body)?; + let request = runtime + .client + .post(endpoint) + .timeout(task.request_timeout()) + .header(reqwest::header::CONTENT_TYPE, "application/json"); + let request = adapter.add_protocol_headers(request); + let request = if let Some(extension) = extension { + extension.add_headers(request) + } else { + request + }; + let body_bytes = serde_json::to_vec(&body).map_err(|_| AiError::new("invalidResponse"))?; + let request = async { + if configuration.auth_mode == AiAuthMode::AwsSigV4 { + let request = configuration + .extra_headers + .iter() + .fold(request, |request, (name, value)| { + request.header(name, value) + }); + let mut request = request + .body(body_bytes.clone()) + .build() + .map_err(network_error)?; + aws_sigv4::sign_bedrock_request(&mut request, api_key, &body_bytes)?; + runtime.client.execute(request).await.map_err(network_error) + } else { + authenticate(request, configuration, api_key)? + .body(body_bytes) + .send() + .await + .map_err(network_error) + } + }; + let response = if let Some(cancellation) = &cancellation { + tokio::select! { + result = request => result?, + _ = cancellation.cancelled() => return Err(AiError::new("operationCancelled")), + } + } else { + request.await? + }; + let (status, headers, bytes) = if let Some(cancellation) = &cancellation { + tokio::select! { + result = read_response(response, MAX_RESPONSE_BYTES) => result?, + _ = cancellation.cancelled() => return Err(AiError::new("operationCancelled")), + } + } else { + read_response(response, MAX_RESPONSE_BYTES).await? + }; + if !status.is_success() { + if rejected_effort(status, &bytes) { + return Ok(ProviderAttempt::EffortRejected); + } + if rejected_structured_output(status, &bytes, structured_output_mode) { + return Ok(ProviderAttempt::StructuredOutputRejected); + } + return Err(provider_response_error( + configuration, + status, + &headers, + &bytes, + )); + } + let response_bytes = bytes.len(); + let value: Value = serde_json::from_slice(&bytes).map_err(|_| { + AiError::new("invalidResponse").with_provider_response(AiProviderResponseMetadata { + usage: AiUsage::default(), + request_id: None, + generation_id: None, + routed_provider: None, + routed_model: None, + finish_reason: None, + response_bytes, + }) + })?; + let request_id = headers + .get("x-request-id") + .or_else(|| headers.get("request-id")) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let mut result = adapter + .parse_response(value, request_id, extension) + .map_err(|error| { + error.with_provider_response(AiProviderResponseMetadata { + usage: AiUsage::default(), + request_id: None, + generation_id: None, + routed_provider: None, + routed_model: None, + finish_reason: None, + response_bytes, + }) + })?; + result.response_bytes = response_bytes; + Ok(ProviderAttempt::Completed(result)) +} + +pub(crate) async fn run_provider( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + task: AiTask, + budget: &mut AiRequestBudget, + cancellation: Option, + adapter: &dyn ProtocolAdapter, + extension: Option<&dyn OpenAiCompatibleExtension>, +) -> Result<(ProviderResult, AiEffortCapability), AiError> { + let result = run_provider_with_output( + runtime, + configuration, + api_key, + system_prompt, + user_prompt, + max_tokens, + task, + budget, + &AiOutputContract::Text, + cancellation, + adapter, + extension, + ) + .await?; + Ok((result.0, result.1)) +} + +pub(crate) async fn run_provider_with_output( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + task: AiTask, + budget: &mut AiRequestBudget, + output_contract: &AiOutputContract, + cancellation: Option, + adapter: &dyn ProtocolAdapter, + extension: Option<&dyn OpenAiCompatibleExtension>, +) -> Result< + ( + ProviderResult, + AiEffortCapability, + Option, + ), + AiError, +> { + if matches!( + configuration.reasoning_preference, + AiReasoningPreference::Low | AiReasoningPreference::Medium | AiReasoningPreference::High + ) { + let supported = match &configuration.effort_capability { + AiEffortCapability::Unsupported => false, + AiEffortCapability::Supported(levels) => { + levels.contains(&configuration.reasoning_preference) + } + AiEffortCapability::Unknown | AiEffortCapability::Accepted => true, + }; + if !supported { + return Err(AiError::new("reasoningUnsupported")); + } + } + let mut effort = effort_for(configuration, task); + let mut effort_capability = configuration.effort_capability.clone(); + let mut structured_output_mode = match output_contract { + AiOutputContract::Text => None, + AiOutputContract::JsonSchema { .. } => Some( + runtime + .structured_output_mode(configuration) + .unwrap_or(AiStructuredOutputMode::JsonSchema), + ), + }; + loop { + match send_provider_request( + runtime, + configuration, + api_key, + system_prompt, + user_prompt, + max_tokens, + effort, + task, + output_contract, + structured_output_mode, + budget, + cancellation.clone(), + adapter, + extension, + ) + .await? + { + ProviderAttempt::Completed(result) => { + if effort.is_some() { + effort_capability = AiEffortCapability::Accepted; + } + if let Some(mode) = structured_output_mode { + runtime.remember_structured_output_mode(configuration, mode); + } + return Ok((result, effort_capability, structured_output_mode)); + } + ProviderAttempt::EffortRejected => { + if effort.is_none() { + return Err(AiError::new("requestRejected")); + } + if configuration.reasoning_preference != AiReasoningPreference::Automatic + && !matches!(task, AiTask::ConnectionTest) + { + return Err(AiError::new("reasoningUnsupported")); + } + effort = None; + effort_capability = AiEffortCapability::Unsupported; + } + ProviderAttempt::StructuredOutputRejected => { + let Some(fallback) = structured_output_mode.and_then(|mode| mode.fallback(adapter)) + else { + return Err(AiError::new("requestRejected")); + }; + structured_output_mode = Some(fallback); + runtime.remember_structured_output_mode(configuration, fallback); + } + } + } +} diff --git a/src-tauri/src/ai/api/openai.rs b/src-tauri/src/ai/api/openai.rs new file mode 100644 index 0000000..6e7767f --- /dev/null +++ b/src-tauri/src/ai/api/openai.rs @@ -0,0 +1,385 @@ +//! OpenAI-compatible protocol adapter (chat completions + responses API). + +use serde_json::{Value, json}; + +use super::super::AiError; +use super::super::configuration::EffectiveAiConfiguration; +use super::super::types::AiApiStyle; +use super::{ + AiModelInfo, AiOutputContract, AiStructuredOutputMode, AiUsage, OpenAiCompatibleExtension, + ProtocolAdapter, ProviderResult, response_text, responses_output_text, +}; + +pub(crate) struct OpenAiAdapter; + +impl ProtocolAdapter for OpenAiAdapter { + fn request_body( + &self, + configuration: &EffectiveAiConfiguration, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + effort: Option<&str>, + output_contract: &AiOutputContract, + structured_output_mode: Option, + extension: Option<&dyn OpenAiCompatibleExtension>, + ) -> Value { + let mut body = match configuration.api_style { + AiApiStyle::ChatCompletions => json!({ + "model": configuration.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + }), + AiApiStyle::Responses => json!({ + "model": configuration.model, + "instructions": system_prompt, + "input": user_prompt + }), + }; + body[&configuration.max_tokens_field] = json!(max_tokens); + if let Some(effort) = effort { + body["reasoning_effort"] = json!(effort); + } + if let (AiOutputContract::JsonSchema { name, schema }, Some(structured_output_mode)) = + (output_contract, structured_output_mode) + { + let format = match structured_output_mode { + AiStructuredOutputMode::JsonSchema => Some(json!({ + "type": "json_schema", + "name": name, + "strict": true, + "schema": schema, + })), + AiStructuredOutputMode::JsonObject => Some(json!({"type": "json_object"})), + AiStructuredOutputMode::PromptOnly => None, + }; + if let Some(format) = format { + match configuration.api_style { + AiApiStyle::ChatCompletions => { + body["response_format"] = + if structured_output_mode == AiStructuredOutputMode::JsonSchema { + json!({ + "type": "json_schema", + "json_schema": { + "name": name, + "strict": true, + "schema": schema, + } + }) + } else { + format + }; + } + AiApiStyle::Responses => body["text"] = json!({"format": format}), + } + } + } + if let (Some(extension), Some(object)) = (extension, body.as_object_mut()) { + extension.extend_request(configuration, object); + } + body + } + + fn parse_response( + &self, + value: Value, + request_id: Option, + provider_extension: Option<&dyn OpenAiCompatibleExtension>, + ) -> Result { + let finish_reason = value + .pointer("/choices/0/finish_reason") + .and_then(Value::as_str); + let text = if value.get("choices").is_some() { + value + .pointer("/choices/0/message/content") + .and_then(response_text) + } else { + value + .get("output_text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| responses_output_text(&value)) + } + .ok_or_else(|| AiError::new("invalidResponse"))?; + let mut result = ProviderResult { + text, + usage: AiUsage { + input_tokens: value + .pointer("/usage/prompt_tokens") + .or_else(|| value.pointer("/usage/input_tokens")) + .and_then(Value::as_u64), + output_tokens: value + .pointer("/usage/completion_tokens") + .or_else(|| value.pointer("/usage/output_tokens")) + .and_then(Value::as_u64), + reasoning_tokens: value + .pointer("/usage/completion_tokens_details/reasoning_tokens") + .or_else(|| value.pointer("/usage/output_tokens_details/reasoning_tokens")) + .and_then(Value::as_u64), + cached_tokens: value + .pointer("/usage/prompt_tokens_details/cached_tokens") + .or_else(|| value.pointer("/usage/input_tokens_details/cached_tokens")) + .and_then(Value::as_u64), + cost: None, + byok: None, + }, + request_id, + generation_id: value.get("id").and_then(Value::as_str).map(str::to_string), + routed_provider: None, + routed_model: None, + output_truncated: matches!(finish_reason, Some("length")) + || value.get("status").and_then(Value::as_str) == Some("incomplete"), + finish_reason: finish_reason + .or_else(|| value.get("status").and_then(Value::as_str)) + .map(str::to_string), + response_bytes: 0, + }; + if let Some(extension) = provider_extension { + extension.extend_result(&value, &mut result); + } + Ok(result) + } + + fn supports_json_object(&self) -> bool { + true + } +} + +/// Default model normalisation for non-OpenRouter, non-Claude providers. +pub(crate) fn normalise_openai_compatible_model(value: &Value) -> Option { + let id = value.get("id")?.as_str()?.to_string(); + Some(AiModelInfo { + name: value + .get("display_name") + .or_else(|| value.get("name")) + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(), + id, + created: value.get("created").and_then(Value::as_u64), + ..AiModelInfo::default() + }) +} + +#[cfg(test)] +mod tests { + use super::super::super::providers::test_helpers; + use super::super::claude::ClaudeAdapter; + use super::super::*; + use super::OpenAiAdapter; + use crate::ai::types::{AiApiStyle, AiProvider}; + use serde_json::json; + use std::time::Duration; + + #[test] + fn mistral_and_gemini_use_the_openai_request_shape() { + for provider in [AiProvider::Mistral, AiProvider::GoogleGemini] { + let configuration = test_helpers::configuration(provider); + let body = OpenAiAdapter.request_body( + &configuration, + "system", + "user", + 128, + None, + &AiOutputContract::Text, + None, + None, + ); + + assert_eq!(body.pointer("/messages/0/role"), Some(&json!("system"))); + assert_eq!(body.pointer("/messages/1/role"), Some(&json!("user"))); + } + } + + #[test] + fn openai_compatible_providers_share_structured_chat_requests() { + for provider in [ + AiProvider::OpenAi, + AiProvider::Mistral, + AiProvider::GoogleGemini, + AiProvider::OpenRouter, + AiProvider::AzureOpenAi, + AiProvider::Ollama, + AiProvider::LmStudio, + AiProvider::OpenAiCompatible, + ] { + let configuration = test_helpers::configuration(provider); + let body = OpenAiAdapter.request_body( + &configuration, + "system", + "user", + 128, + None, + &test_helpers::conflict_contract(), + Some(AiStructuredOutputMode::JsonSchema), + None, + ); + + assert_eq!( + body.pointer("/response_format/type"), + Some(&json!("json_schema")), + "{provider:?}" + ); + assert_eq!( + body.pointer("/response_format/json_schema/strict"), + Some(&json!(true)), + "{provider:?}" + ); + } + } + + #[test] + fn responses_and_claude_use_their_structured_output_shapes() { + let mut responses = test_helpers::configuration(AiProvider::OpenAi); + responses.api_style = AiApiStyle::Responses; + responses.request_path = "/responses".to_string(); + let responses_body = OpenAiAdapter.request_body( + &responses, + "system", + "user", + 128, + None, + &test_helpers::conflict_contract(), + Some(AiStructuredOutputMode::JsonSchema), + None, + ); + assert_eq!( + responses_body.pointer("/text/format/type"), + Some(&json!("json_schema")) + ); + assert_eq!( + responses_body.pointer("/text/format/name"), + Some(&json!("gitmun_conflict_resolution")) + ); + + let claude = test_helpers::configuration(AiProvider::Claude); + let claude_body = ClaudeAdapter.request_body( + &claude, + "system", + "user", + 128, + Some("medium"), + &test_helpers::conflict_contract(), + Some(AiStructuredOutputMode::JsonSchema), + None, + ); + assert_eq!( + claude_body.pointer("/output_config/format/type"), + Some(&json!("json_schema")) + ); + assert_eq!( + claude_body.pointer("/output_config/effort"), + Some(&json!("medium")) + ); + } + + #[test] + fn json_object_and_prompt_only_modes_remove_strict_schema_fields() { + let configuration = test_helpers::configuration(AiProvider::OpenAi); + let json_object = OpenAiAdapter.request_body( + &configuration, + "system", + "user", + 128, + None, + &test_helpers::conflict_contract(), + Some(AiStructuredOutputMode::JsonObject), + None, + ); + let prompt_only = OpenAiAdapter.request_body( + &configuration, + "system", + "user", + 128, + None, + &test_helpers::conflict_contract(), + Some(AiStructuredOutputMode::PromptOnly), + None, + ); + + assert_eq!( + json_object.pointer("/response_format/type"), + Some(&json!("json_object")) + ); + assert!( + json_object + .pointer("/response_format/json_schema") + .is_none() + ); + assert!(prompt_only.get("response_format").is_none()); + assert_eq!( + AiStructuredOutputMode::JsonSchema.fallback(&ClaudeAdapter), + Some(AiStructuredOutputMode::PromptOnly) + ); + } + + #[test] + fn conflict_resolution_allows_slow_reasoning_responses() { + assert_eq!( + AiTask::ConflictResolution.request_timeout(), + Duration::from_secs(5 * 60) + ); + assert_eq!( + AiTask::CommitMessage.request_timeout(), + Duration::from_secs(120) + ); + } + + #[test] + fn typed_openai_content_is_combined() { + let value = json!({ + "choices": [{ + "message": {"content": [ + {"type": "text", "text": "first"}, + {"type": "text", "text": " second"} + ]}, + "finish_reason": "stop" + }] + }); + + let result = OpenAiAdapter.parse_response(value, None, None).unwrap(); + + assert_eq!(result.text, "first second"); + } + + #[test] + fn openrouter_exact_cost_and_routing_are_parsed() { + let value = json!({ + "id": "generation-1", + "model": "author/model", + "provider": "Example", + "choices": [{"message": {"content": "done"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "cost": 0.0003, "is_byok": true} + }); + + let result = OpenAiAdapter.parse_response(value, None, None).unwrap(); + + // Without the OpenRouter extension, cost/routing aren't extracted + assert_eq!(result.text, "done"); + assert!(result.routed_provider.is_none()); + } + + #[test] + fn openrouter_cost_and_routing_with_extension_are_parsed() { + use crate::ai::providers::openrouter::OpenRouterExtension; + + let value = json!({ + "id": "generation-1", + "model": "author/model", + "provider": "Example", + "choices": [{"message": {"content": "done"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "cost": 0.0003, "is_byok": true} + }); + + let extension = OpenRouterExtension; + let result = OpenAiAdapter + .parse_response(value, None, Some(&extension)) + .unwrap(); + + assert_eq!(result.usage.cost, Some(0.0003)); + assert_eq!(result.usage.byok, Some(true)); + assert_eq!(result.routed_provider.as_deref(), Some("Example")); + } +} diff --git a/src-tauri/src/ai/commands.rs b/src-tauri/src/ai/commands.rs new file mode 100644 index 0000000..728b646 --- /dev/null +++ b/src-tauri/src/ai/commands.rs @@ -0,0 +1,4909 @@ +//! Tauri command surface owned by the bundled AI extension. + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, atomic::Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::Emitter; +use tokio_util::sync::CancellationToken; +use url::Url; + +use crate::AppState; +use crate::ai::api::structured_output_cache_key; +use crate::ai::{ + AiApiStyle, AiAuthMode, AiCommitMessageMode, AiConfigurationSource, AiError, + AiExtensionSettings, AiModelInfo, AiModelPage, AiModelQuery, AiOutputContract, AiProfile, + AiRepositoryPolicy, AiRequestBudget, AiStructuredOutputMode, AiTask, AiUsage, + EffectiveAiConfiguration, OpenRouterSettings, ProviderResult, api_key_optional, + discover_effort, discover_models, discover_openrouter_model_details, run_provider, + run_provider_with_output, +}; +use crate::git::types::{AiEffortCapability, AiProvider, AiReasoningPreference}; + +const MAX_COMMIT_TOTAL_CONTEXT_BYTES: usize = 1024 * 1024; +const COMMIT_SUMMARY_MAX_BYTES: usize = 1024; +const COMMIT_SUMMARY_MAX_TOKENS: u32 = 1024; +const COMMIT_PATH_LIST_MAX_BYTES: usize = 1536; +const COMMIT_STYLE_EXAMPLES_MAX_BYTES: usize = 2048; +const MAX_GIT_PATH_OUTPUT_BYTES: usize = 256 * 1024; +const MAX_GIT_METADATA_OUTPUT_BYTES: usize = 64 * 1024; +const AI_OPERATION_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const COMMIT_SUMMARY_PROMPT: &str = "Summarise this staged Git diff chunk for a later commit-message writer in at most 120 words. State only concrete changes and their purpose. Do not review the code, give advice, or write a commit message. Return concise plain text."; +const COMMIT_SUMMARY_REDUCTION_PROMPT: &str = "Consolidate these staged-change summaries for a later commit-message writer in at most 120 words. Preserve concrete changes and their purpose. Do not review the code, give advice, or write a commit message. Return concise plain text."; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveAiConfigurationRequest { + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub profile_name: Option, + pub provider: AiProvider, + pub endpoint: String, + pub model: String, + pub reasoning_preference: AiReasoningPreference, + #[serde(default)] + pub api_style: Option, + #[serde(default)] + pub request_path: Option, + #[serde(default)] + pub models_path: Option, + #[serde(default)] + pub auth_mode: Option, + #[serde(default)] + pub auth_header: Option, + #[serde(default)] + pub max_tokens_field: Option, + #[serde(default)] + pub azure_deployment: Option, + #[serde(default)] + pub azure_api_version: Option, + #[serde(default)] + pub open_router: Option, + #[serde(default)] + pub api_key: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiConfigurationView { + pub enabled: bool, + pub selected_profile_id: String, + pub profiles: Vec, + pub provider: AiProvider, + pub endpoint: String, + pub model: String, + pub reasoning_preference: AiReasoningPreference, + pub effort_capability: AiEffortCapability, + pub commit_context_limit_kib: u32, + pub conflict_context_limit_kib: u32, + pub commit_message_max_tokens: u32, + pub conflict_resolution_max_tokens: u32, + pub commit_message_prompt: String, + pub conflict_resolution_prompt: String, + pub include_commit_history: bool, + pub has_api_key: bool, + pub credential_managed_by_environment: bool, + pub configured: bool, + pub insecure_transport: bool, + pub sources: std::collections::BTreeMap, + pub environment_fields: Vec, + pub consent_required: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiContextPreview { + pub provider: AiProvider, + pub destination_authority: String, + pub task: &'static str, + pub files: Vec, + pub context_size_kib: usize, + pub context_limit_kib: u32, + pub includes_commit_history: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetAiRepositoryPolicyRequest { + pub repo_path: String, + pub policy: AiRepositoryPolicy, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetAiPrivacySettingsRequest { + pub include_commit_history: bool, + pub global_exclusions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoverAiModelDetailsRequest { + pub configuration: SaveAiConfigurationRequest, + pub model_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiConnectionTestResult { + pub effort_capability: AiEffortCapability, + pub usage: AiUsage, + pub request_id: Option, + pub generation_id: Option, + pub routed_provider: Option, + pub routed_model: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiCommitMessageResult { + pub message: String, + pub usage: AiUsage, + pub request_id: Option, + pub generation_id: Option, + pub routed_provider: Option, + pub routed_model: Option, +} + +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +pub enum AiCommitWorkflow { + #[default] + Normal, + Amend, + Merge, + Rebase, + CherryPick, + Revert, +} + +impl AiCommitWorkflow { + fn label(self) -> &'static str { + match self { + Self::Normal => "normal commit", + Self::Amend => "amended commit", + Self::Merge => "merge commit", + Self::Rebase => "rebase commit", + Self::CherryPick => "cherry-pick commit", + Self::Revert => "revert commit", + } + } + + fn expected_repository_operation(self) -> Option<&'static str> { + match self { + Self::Normal | Self::Amend => None, + Self::Merge => Some("merge"), + Self::Rebase => Some("rebase"), + Self::CherryPick => Some("cherry-pick"), + Self::Revert => Some("revert"), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerateAiCommitMessagesRequest { + pub repo_path: String, + pub subject_limit: u32, + #[serde(default)] + pub operation_id: String, + #[serde(default = "default_candidate_count")] + pub candidate_count: u8, + #[serde(default)] + pub mode: AiCommitMessageMode, + #[serde(default)] + pub commit_type: String, + #[serde(default)] + pub scope: String, + #[serde(default)] + pub language: String, + #[serde(default)] + pub issue_key: String, + #[serde(default)] + pub additional_instruction: String, + #[serde(default)] + pub workflow: AiCommitWorkflow, + #[serde(default)] + pub existing_message: String, +} + +fn default_candidate_count() -> u8 { + 1 +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiCommitCandidatesResult { + pub candidates: Vec, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +pub enum AiWritingTask { + StagedReview, + BranchSummary, + PullRequestDescription, + ReleaseNotes, +} + +impl AiWritingTask { + fn identifier(self) -> &'static str { + match self { + Self::StagedReview => "stagedReview", + Self::BranchSummary => "branchSummary", + Self::PullRequestDescription => "pullRequestDescription", + Self::ReleaseNotes => "releaseNotes", + } + } + + fn system_prompt(self) -> &'static str { + match self { + Self::StagedReview => { + "Review the supplied staged changes without modifying them. Return concise Markdown findings ordered by severity. Cite relevant paths, explain concrete correctness, security or testing risks, and omit speculative or cosmetic comments. State clearly when no actionable findings are present." + } + Self::BranchSummary => { + "Summarise the supplied branch changes as concise Markdown. Explain the purpose, main implementation changes, tests and material risks. Do not invent work that is not present in the supplied context." + } + Self::PullRequestDescription => { + "Write a pull request title and concise Markdown description from the supplied branch changes. Include summary, testing and material risk sections. Do not claim tests were run unless the context says so." + } + Self::ReleaseNotes => { + "Write concise user-facing Markdown release notes from the supplied commits and changes. Group related changes, prioritise user impact, and omit implementation detail that is not useful to users. Do not invent changes." + } + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GenerateAiWritingRequest { + pub repo_path: String, + pub task: AiWritingTask, + #[serde(default)] + pub base_reference: String, + #[serde(default)] + pub additional_instruction: String, + #[serde(default)] + pub operation_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiWritingContextPreviewRequest { + pub repo_path: String, + pub task: AiWritingTask, + #[serde(default)] + pub base_reference: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiWritingResult { + pub content: String, + pub usage: AiUsage, + pub request_id: Option, + pub generation_id: Option, + pub routed_provider: Option, + pub routed_model: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AiOperationProgress { + operation_id: String, + task: &'static str, + stage: &'static str, +} + +fn emit_ai_progress( + app: &tauri::AppHandle, + operation_id: &str, + task: &'static str, + stage: &'static str, +) { + drop(app.emit( + "ai-operation-progress", + AiOperationProgress { + operation_id: operation_id.to_string(), + task, + stage, + }, + )); +} + +fn record_ai_usage( + state: &AppState, + task: &str, + started_at: Instant, + usage: Option<&AiUsage>, + request_id: Option<&str>, + generation_id: Option<&str>, + routed_provider: Option<&str>, + routed_model: Option<&str>, + diagnostic: Option<&str>, + status: &str, +) { + let settings = state.git_service.get_settings(); + let Ok(configuration) = state.ai_extension.environment.resolve(&settings) else { + return; + }; + let usage = usage.cloned().unwrap_or_default(); + state.git_service.record_ai_usage(crate::ai::AiUsageRecord { + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + provider: configuration.provider, + profile_id: configuration.profile_id, + model: configuration.model, + task: task.to_string(), + duration_ms: started_at.elapsed().as_millis().min(u64::MAX as u128) as u64, + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + reasoning_tokens: usage.reasoning_tokens, + cached_tokens: usage.cached_tokens, + cost: usage.cost, + byok: usage.byok, + request_id: request_id.map(str::to_string), + generation_id: generation_id.map(str::to_string), + routed_provider: routed_provider.map(str::to_string), + routed_model: routed_model.map(str::to_string), + diagnostic: diagnostic.map(str::to_string), + status: status.to_string(), + }); +} + +fn record_conflict_usage( + state: &AppState, + task: &'static str, + started_at: Instant, + result: &Result, +) { + match result { + Ok(result) => record_ai_usage( + state, + task, + started_at, + Some(&result.usage), + result.request_id.as_deref(), + result.generation_id.as_deref(), + result.routed_provider.as_deref(), + result.routed_model.as_deref(), + None, + "completed", + ), + Err(error) => { + let provider_response = error.provider_response.as_ref(); + let mut diagnostics = error.detail.iter().cloned().collect::>(); + if let Some(finish_reason) = provider_response + .and_then(|response| response.finish_reason.as_deref()) + .filter(|finish_reason| { + finish_reason.len() <= 64 + && finish_reason.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '-' | '_' | '.' | ' ') + }) + }) + { + diagnostics.push(format!("finish={finish_reason}")); + } + if let Some(response) = provider_response { + diagnostics.push(format!("responseBytes={}", response.response_bytes)); + } + let diagnostic = (!diagnostics.is_empty()).then(|| diagnostics.join("; ")); + record_ai_usage( + state, + task, + started_at, + provider_response.map(|response| &response.usage), + provider_response.and_then(|response| response.request_id.as_deref()), + provider_response.and_then(|response| response.generation_id.as_deref()), + provider_response.and_then(|response| response.routed_provider.as_deref()), + provider_response.and_then(|response| response.routed_model.as_deref()), + diagnostic.as_deref(), + error.code, + ); + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveConflictWithAiRequest { + pub repo_path: String, + pub file_path: String, + #[serde(default)] + pub operation_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiConflictResolutionResult { + pub file_path: String, + pub resolved_regions: usize, + pub marked_resolved: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiConflictRegionProposal { + pub id: String, + pub original: String, + pub ours: String, + pub theirs: String, + pub ancestor: Option, + pub proposed: String, + pub explanation: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiConflictProposalResult { + pub proposal_id: String, + pub file_path: String, + pub regions: Vec, + pub usage: AiUsage, + pub request_id: Option, + pub generation_id: Option, + pub routed_provider: Option, + pub routed_model: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyAiConflictProposalRequest { + pub proposal_id: String, + pub region_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegenerateAiConflictRegionsRequest { + pub proposal_id: String, + pub region_ids: Vec, + #[serde(default)] + pub operation_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiConflictEligibility { + pub eligible: bool, + pub reason: Option<&'static str>, +} + +#[derive(Debug, Clone)] +struct CommitContext { + branch: String, + workflow: AiCommitWorkflow, + existing_message: String, + subject_limit: u32, + path_list: String, + recent_messages: String, + diff: String, + staged_snapshot: md5::Digest, +} + +#[derive(Debug, Clone)] +struct WritingContext { + files: Vec, + content: String, + snapshot: md5::Digest, +} + +#[derive(Debug, Clone)] +struct ConflictRegion { + id: String, + start: usize, + end: usize, + prompt: String, + original: String, + ours: String, + theirs: String, + ancestor: Option, +} + +struct PreparedConflict { + repository: PathBuf, + original_bytes: Vec, + operation: &'static str, + unmerged_index: Vec, + regions: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelConflictResponse { + regions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelConflictReplacement { + id: String, + replacement: String, + explanation: String, +} + +async fn configuration_view(state: &AppState) -> Result { + let settings = state.git_service.get_settings(); + configuration_view_for_settings(state, settings).await +} + +async fn configuration_view_for_settings( + state: &AppState, + settings: crate::git::types::Settings, +) -> Result { + let configuration = state.ai_extension.environment.resolve(&settings)?; + let has_api_key = if configuration.provider == AiProvider::Disabled { + false + } else { + read_api_key(state, &configuration, true).await?.is_some() + }; + let insecure_transport = + Url::parse(&configuration.endpoint).is_ok_and(|url| url.scheme() == "http"); + let configured = validate_effective_configuration(&configuration, true).is_ok() + && (has_api_key || api_key_optional(&configuration)); + let consent_required = configuration + .consent_key() + .is_ok_and(|key| !settings.extensions.ai.consented_destinations.contains(&key)); + Ok(AiConfigurationView { + enabled: configuration.enabled, + selected_profile_id: settings.extensions.ai.selected_profile_id, + profiles: settings.extensions.ai.profiles, + provider: configuration.provider, + endpoint: configuration.endpoint, + model: configuration.model, + reasoning_preference: configuration.reasoning_preference, + effort_capability: configuration.effort_capability, + commit_context_limit_kib: configuration.commit_context_limit_kib, + conflict_context_limit_kib: configuration.conflict_context_limit_kib, + commit_message_max_tokens: configuration.commit_message_max_tokens, + conflict_resolution_max_tokens: configuration.conflict_resolution_max_tokens, + commit_message_prompt: configuration.commit_message_prompt, + conflict_resolution_prompt: configuration.conflict_resolution_prompt, + include_commit_history: configuration.include_commit_history, + has_api_key, + credential_managed_by_environment: configuration.environment_api_key, + configured, + insecure_transport, + sources: configuration.sources, + environment_fields: configuration.environment_fields, + consent_required, + }) +} + +fn require_consent( + state: &AppState, + configuration: &EffectiveAiConfiguration, +) -> Result<(), AiError> { + let key = configuration.consent_key()?; + if state + .git_service + .get_settings() + .extensions + .ai + .consented_destinations + .contains(&key) + { + Ok(()) + } else { + Err(AiError::with_detail( + "consentRequired", + configuration.destination_authority()?, + )) + } +} + +fn repository_context_options( + state: &AppState, + repo_path: &str, + configuration: &EffectiveAiConfiguration, +) -> (bool, Vec) { + let settings = state.git_service.get_settings(); + let repository_key = Path::new(repo_path) + .canonicalize() + .ok() + .map(|path| path.to_string_lossy().to_string()); + let policy = repository_key + .as_ref() + .and_then(|key| settings.extensions.ai.repository_policies.get(key)) + .or_else(|| settings.extensions.ai.repository_policies.get(repo_path)); + let include_history = policy + .and_then(|policy| policy.include_commit_history) + .unwrap_or(configuration.include_commit_history); + let mut exclusions = configuration.global_exclusions.clone(); + if let Some(policy) = policy { + exclusions.extend(policy.exclusions.clone()); + } + (include_history, exclusions) +} + +fn repository_policy(state: &AppState, repo_path: &str) -> AiRepositoryPolicy { + let settings = state.git_service.get_settings(); + let repository_key = Path::new(repo_path) + .canonicalize() + .ok() + .map(|path| path.to_string_lossy().to_string()); + repository_key + .as_ref() + .and_then(|key| settings.extensions.ai.repository_policies.get(key)) + .or_else(|| settings.extensions.ai.repository_policies.get(repo_path)) + .cloned() + .unwrap_or_default() +} + +fn read_repository_prompt(repo_path: &str, prompt_path: &str) -> Result, AiError> { + if prompt_path.is_empty() { + return Ok(None); + } + let path = safe_repository_file(repo_path, prompt_path)?; + let file = std::fs::File::open(path).map_err(|_| AiError::new("promptFileUnavailable"))?; + let mut bytes = Vec::new(); + file.take(MAX_GIT_METADATA_OUTPUT_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| AiError::new("promptFileUnavailable"))?; + if bytes.len() > MAX_GIT_METADATA_OUTPUT_BYTES { + return Err(AiError::new("promptFileTooLarge")); + } + let prompt = String::from_utf8(bytes).map_err(|_| AiError::new("promptFileUnavailable"))?; + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err(AiError::new("promptFileUnavailable")); + } + Ok(Some(prompt.to_string())) +} + +async fn apply_repository_prompts( + state: &AppState, + repo_path: &str, + configuration: &mut EffectiveAiConfiguration, + task: AiTask, +) -> Result<(), AiError> { + let policy = repository_policy(state, repo_path); + match task { + AiTask::CommitMessage => { + if !configuration + .environment_fields + .iter() + .any(|field| field == "commitMessagePrompt") + { + let repository = repo_path.to_string(); + let prompt_path = policy.commit_prompt_file; + let prompt = tauri::async_runtime::spawn_blocking(move || { + read_repository_prompt(&repository, &prompt_path) + }) + .await + .map_err(|_| AiError::new("promptFileUnavailable"))??; + if let Some(prompt) = prompt { + configuration.commit_message_prompt = prompt; + } + } + } + AiTask::ConflictResolution => { + if !configuration + .environment_fields + .iter() + .any(|field| field == "conflictResolutionPrompt") + { + let repository = repo_path.to_string(); + let prompt_path = policy.conflict_prompt_file; + let prompt = tauri::async_runtime::spawn_blocking(move || { + read_repository_prompt(&repository, &prompt_path) + }) + .await + .map_err(|_| AiError::new("promptFileUnavailable"))??; + if let Some(prompt) = prompt { + configuration.conflict_resolution_prompt = prompt; + } + } + } + AiTask::ConnectionTest => {} + } + Ok(()) +} + +fn validate_configuration(request: &SaveAiConfigurationRequest) -> Result<(), AiError> { + let endpoint = request.endpoint.trim().to_string(); + if request.provider == AiProvider::Disabled { + return Ok(()); + } + if endpoint.is_empty() && request.provider.default_endpoint().is_empty() { + return Err(AiError::new("endpointRequired")); + } + if !endpoint.is_empty() { + crate::ai::validate_endpoint(&endpoint)?; + } + for path in [ + request.request_path.as_deref(), + request.models_path.as_deref(), + ] + .into_iter() + .flatten() + { + if !path.is_empty() && (!path.starts_with('/') || path.contains(['?', '#'])) { + return Err(AiError::new("routeInvalid")); + } + } + if request.provider == AiProvider::OpenRouter { + let settings = request.open_router.as_ref().cloned().unwrap_or_default(); + for value in [&settings.max_prompt_price, &settings.max_completion_price] { + if !value.is_empty() + && value + .parse::() + .ok() + .is_none_or(|price| !price.is_finite() || price < 0.0) + { + return Err(AiError::new("routeInvalid")); + } + } + for value in [ + &settings.preferred_max_latency, + &settings.preferred_min_throughput, + ] { + if !value.is_empty() + && value + .parse::() + .ok() + .is_none_or(|preference| !preference.is_finite() || preference <= 0.0) + { + return Err(AiError::new("routeInvalid")); + } + } + for provider in settings + .preferred_providers + .iter() + .chain(&settings.allowed_providers) + .chain(&settings.ignored_providers) + { + if provider.is_empty() || provider.len() > 128 || provider.chars().any(char::is_control) + { + return Err(AiError::new("routeInvalid")); + } + } + } + Ok(()) +} + +fn validate_effective_configuration( + configuration: &EffectiveAiConfiguration, + require_model: bool, +) -> Result<(), AiError> { + if !configuration.enabled || configuration.provider == AiProvider::Disabled { + return Err(AiError::new("notConfigured")); + } + configuration.endpoint_url()?; + if require_model && configuration.model.trim().is_empty() { + return Err(AiError::new("modelRequired")); + } + if configuration.provider == AiProvider::AzureOpenAi + && configuration.azure_deployment.trim().is_empty() + { + return Err(AiError::new("deploymentRequired")); + } + Ok(()) +} + +fn profile_id(request: &SaveAiConfigurationRequest, current: Option<&AiProfile>) -> String { + request + .profile_id + .as_deref() + .map(str::trim) + .filter(|id| { + !id.is_empty() + && id + .chars() + .all(|character| character == '-' || character.is_ascii_alphanumeric()) + }) + .map(str::to_string) + .or_else(|| current.map(|profile| profile.id.clone())) + .unwrap_or_else(|| { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!("profile-{timestamp:x}") + }) +} + +fn profile_from_request( + request: &SaveAiConfigurationRequest, + current: Option<&AiProfile>, +) -> AiProfile { + let mut profile = current.cloned().unwrap_or_default(); + profile.id = profile_id(request, current); + if let Some(name) = &request.profile_name { + profile.name = name.trim().to_string(); + } + let destination_changed = profile.provider != request.provider + || profile.endpoint.trim() != request.endpoint.trim() + || profile.model.trim() != request.model.trim(); + profile.provider = request.provider; + profile.endpoint = request.endpoint.trim().to_string(); + profile.model = request.model.trim().to_string(); + profile.reasoning_preference = request.reasoning_preference; + if destination_changed { + profile.effort_capability = AiEffortCapability::Unknown; + } + if let Some(value) = request.api_style { + profile.api_style = value; + } + if let Some(value) = &request.request_path { + profile.request_path = value.trim().to_string(); + } + if let Some(value) = &request.models_path { + profile.models_path = value.trim().to_string(); + } + if let Some(value) = request.auth_mode { + profile.auth_mode = value; + } + if let Some(value) = &request.auth_header { + profile.auth_header = value.trim().to_string(); + } + if let Some(value) = &request.max_tokens_field { + profile.max_tokens_field = value.trim().to_string(); + } + if let Some(value) = &request.azure_deployment { + profile.azure_deployment = value.trim().to_string(); + } + if let Some(value) = &request.azure_api_version { + profile.azure_api_version = value.trim().to_string(); + } + if let Some(value) = &request.open_router { + profile.open_router = value.clone(); + } + profile +} + +fn requested_profile<'a>( + request: &SaveAiConfigurationRequest, + settings: &'a AiExtensionSettings, +) -> Option<&'a AiProfile> { + request + .profile_id + .as_deref() + .and_then(|id| settings.profiles.iter().find(|profile| profile.id == id)) +} + +async fn read_api_key( + state: &AppState, + configuration: &EffectiveAiConfiguration, + migrate_legacy: bool, +) -> Result, AiError> { + if let Some(api_key) = state + .ai_extension + .environment + .api_key(configuration.provider, configuration.auth_mode) + { + return Ok(Some(api_key)); + } + let scope = configuration.credential_scope()?; + let is_migrated_profile = configuration.profile_id == "migrated-default"; + let credentials = Arc::clone(&state.ai_extension.credentials); + tauri::async_runtime::spawn_blocking(move || { + if let Some(api_key) = credentials.read_api_key(&scope)? { + return Ok(Some(api_key)); + } + if migrate_legacy && is_migrated_profile { + if let Some(api_key) = credentials.read_legacy_api_key()? { + credentials.set_api_key(&scope, &api_key)?; + credentials.clear_legacy_api_key()?; + return Ok(Some(api_key)); + } + } + Ok(None) + }) + .await + .map_err(|_| AiError::new("credentialStoreUnavailable"))? +} + +async fn write_api_key( + state: &AppState, + configuration: &EffectiveAiConfiguration, + api_key: String, +) -> Result<(), AiError> { + if configuration.environment_api_key { + return Err(AiError::new("environmentManaged")); + } + let scope = configuration.credential_scope()?; + let credentials = Arc::clone(&state.ai_extension.credentials); + tauri::async_runtime::spawn_blocking(move || credentials.set_api_key(&scope, api_key.trim())) + .await + .map_err(|_| AiError::new("credentialStoreUnavailable"))? +} + +async fn clear_api_key_for( + state: &AppState, + configuration: &EffectiveAiConfiguration, +) -> Result<(), AiError> { + if configuration.environment_api_key { + return Err(AiError::new("environmentManaged")); + } + let scope = configuration.credential_scope()?; + let credentials = Arc::clone(&state.ai_extension.credentials); + tauri::async_runtime::spawn_blocking(move || credentials.clear_api_key(&scope)) + .await + .map_err(|_| AiError::new("credentialStoreUnavailable"))? +} + +async fn read_stored_api_key_for( + state: &AppState, + configuration: &EffectiveAiConfiguration, +) -> Result, AiError> { + let scope = configuration.credential_scope()?; + let credentials = Arc::clone(&state.ai_extension.credentials); + tauri::async_runtime::spawn_blocking(move || credentials.read_api_key(&scope)) + .await + .map_err(|_| AiError::new("credentialStoreUnavailable"))? +} + +async fn write_stored_api_key_for( + state: &AppState, + configuration: &EffectiveAiConfiguration, + api_key: String, +) -> Result<(), AiError> { + let scope = configuration.credential_scope()?; + let credentials = Arc::clone(&state.ai_extension.credentials); + tauri::async_runtime::spawn_blocking(move || credentials.set_api_key(&scope, &api_key)) + .await + .map_err(|_| AiError::new("credentialStoreUnavailable"))? +} + +async fn clear_stored_api_key_for( + state: &AppState, + configuration: &EffectiveAiConfiguration, +) -> Result<(), AiError> { + let scope = configuration.credential_scope()?; + let credentials = Arc::clone(&state.ai_extension.credentials); + tauri::async_runtime::spawn_blocking(move || credentials.clear_api_key(&scope)) + .await + .map_err(|_| AiError::new("credentialStoreUnavailable"))? +} + +fn emit_configuration_updated(app: &tauri::AppHandle) { + drop(app.emit("ai-configuration-updated", ())); + crate::instance_coordinator::broadcast_settings_updated(); +} + +#[tauri::command] +pub async fn get_ai_configuration( + profile_id: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let mut settings = state.git_service.get_settings(); + if let Some(profile_id) = profile_id { + if !settings + .extensions + .ai + .profiles + .iter() + .any(|profile| profile.id == profile_id) + { + return Err(AiError::new("profileNotFound")); + } + settings.extensions.ai.selected_profile_id = profile_id; + } + configuration_view_for_settings(&state, settings).await +} + +#[tauri::command] +pub async fn save_ai_configuration( + request: SaveAiConfigurationRequest, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + validate_configuration(&request)?; + if request + .api_key + .as_deref() + .is_some_and(|api_key| api_key.trim().is_empty()) + { + return Err(AiError::new("apiKeyRequired")); + } + let current = state.git_service.get_settings(); + let current_profile = requested_profile(&request, ¤t.extensions.ai); + let profile = profile_from_request(&request, current_profile); + let enabled = request.enabled.unwrap_or(current.extensions.ai.enabled); + let mut proposed = current.clone(); + proposed.extensions.ai.enabled = enabled; + proposed.extensions.ai.selected_profile_id = profile.id.clone(); + if let Some(existing) = proposed + .extensions + .ai + .profiles + .iter_mut() + .find(|existing| existing.id == profile.id) + { + *existing = profile.clone(); + } else { + proposed.extensions.ai.profiles.push(profile.clone()); + } + let proposed_configuration = state.ai_extension.environment.resolve(&proposed)?; + if proposed_configuration + .environment_fields + .iter() + .any(|field| { + matches!( + field.as_str(), + "enabled" | "provider" | "endpoint" | "model" | "reasoningPreference" + ) + }) + { + return Err(AiError::new("environmentManaged")); + } + let current_configuration = current_profile.and_then(|profile| { + let mut current_profile_settings = current.clone(); + current_profile_settings.extensions.ai.selected_profile_id = profile.id.clone(); + state + .ai_extension + .environment + .resolve(¤t_profile_settings) + .ok() + }); + let destination_changed = current_configuration.as_ref().is_some_and(|current| { + current.credential_scope().ok() != proposed_configuration.credential_scope().ok() + }); + let previous_destination_key = if destination_changed { + let current_configuration = current_configuration.as_ref().unwrap(); + read_stored_api_key_for(&state, current_configuration).await? + } else { + None + }; + let previous_api_key = if request.api_key.is_some() { + read_stored_api_key_for(&state, &proposed_configuration).await? + } else { + None + }; + if destination_changed { + clear_stored_api_key_for(&state, current_configuration.as_ref().unwrap()).await?; + } + if let Some(api_key) = request.api_key.as_deref() { + if let Err(error) = + write_api_key(&state, &proposed_configuration, api_key.to_string()).await + { + if let (Some(current_configuration), Some(previous_destination_key)) = ( + current_configuration.as_ref(), + previous_destination_key.as_ref(), + ) { + write_stored_api_key_for( + &state, + current_configuration, + previous_destination_key.clone(), + ) + .await?; + } + return Err(error); + } + } + let save_result = state.git_service.save_ai_profile(enabled, profile); + if save_result.is_err() { + if request.api_key.is_some() { + if let Some(previous_api_key) = previous_api_key { + write_stored_api_key_for(&state, &proposed_configuration, previous_api_key).await?; + } else { + clear_stored_api_key_for(&state, &proposed_configuration).await?; + } + } + if let (Some(current_configuration), Some(previous_destination_key)) = ( + current_configuration.as_ref(), + previous_destination_key.as_ref(), + ) { + write_stored_api_key_for( + &state, + current_configuration, + previous_destination_key.clone(), + ) + .await?; + } + } + save_result.map_err(|_| AiError::new("configurationWriteFailed"))?; + emit_configuration_updated(&app); + configuration_view(&state).await +} + +#[tauri::command] +pub async fn set_ai_api_key( + api_key: String, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let key = api_key.trim(); + if key.is_empty() { + return Err(AiError::new("apiKeyRequired")); + } + let settings = state.git_service.get_settings(); + let configuration = state.ai_extension.environment.resolve(&settings)?; + write_api_key(&state, &configuration, key.to_string()).await?; + emit_configuration_updated(&app); + configuration_view(&state).await +} + +#[tauri::command] +pub async fn clear_ai_api_key( + profile_id: Option, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let mut settings = state.git_service.get_settings(); + if let Some(profile_id) = profile_id { + if !settings + .extensions + .ai + .profiles + .iter() + .any(|profile| profile.id == profile_id) + { + return Err(AiError::new("profileNotFound")); + } + settings.extensions.ai.selected_profile_id = profile_id; + } + let configuration = state.ai_extension.environment.resolve(&settings)?; + clear_api_key_for(&state, &configuration).await?; + emit_configuration_updated(&app); + configuration_view_for_settings(&state, settings).await +} + +#[tauri::command] +pub async fn connect_openrouter( + callback_message: String, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + if state + .ai_extension + .openrouter_oauth_active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(AiError::new("openRouterOAuthInProgress")); + } + let result = connect_openrouter_inner(callback_message, &state, &app).await; + state + .ai_extension + .openrouter_oauth_active + .store(false, Ordering::Release); + result +} + +async fn connect_openrouter_inner( + callback_message: String, + state: &AppState, + app: &tauri::AppHandle, +) -> Result { + let settings = state.git_service.get_settings(); + let configuration = state.ai_extension.environment.resolve(&settings)?; + if configuration.provider != AiProvider::OpenRouter { + return Err(AiError::new("openRouterOAuthProviderRequired")); + } + if configuration.environment_api_key { + return Err(AiError::new("environmentManaged")); + } + let endpoint = configuration.endpoint_url()?; + if !openrouter_oauth_endpoint_allowed(&endpoint) { + return Err(AiError::new("openRouterOAuthOfficialEndpointRequired")); + } + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + let api_key = crate::ai::openrouter_oauth::authorise(runtime, app, callback_message).await?; + write_api_key(state, &configuration, api_key).await?; + emit_configuration_updated(app); + configuration_view(state).await +} + +fn openrouter_oauth_endpoint_allowed(endpoint: &Url) -> bool { + endpoint.origin().ascii_serialization() == "https://openrouter.ai" +} + +#[tauri::command] +pub async fn grant_ai_consent( + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let settings = state.git_service.get_settings(); + let configuration = state.ai_extension.environment.resolve(&settings)?; + validate_effective_configuration(&configuration, false)?; + state + .git_service + .grant_ai_destination_consent(configuration.consent_key()?) + .map_err(|_| AiError::new("configurationWriteFailed"))?; + emit_configuration_updated(&app); + configuration_view(&state).await +} + +fn normalise_repository_policy(policy: &mut AiRepositoryPolicy) -> Result<(), AiError> { + if policy.exclusions.len() > 100 { + return Err(AiError::new("invalidRepositoryPolicy")); + } + policy.exclusions = std::mem::take(&mut policy.exclusions) + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + if policy + .exclusions + .iter() + .any(|value| value.len() > 256 || value.chars().any(char::is_control)) + || !valid_optional_repository_path(&policy.commit_prompt_file) + || !valid_optional_repository_path(&policy.conflict_prompt_file) + { + return Err(AiError::new("invalidRepositoryPolicy")); + } + policy.default_commit_type = policy.default_commit_type.trim().to_string(); + policy.default_commit_scope = policy.default_commit_scope.trim().to_string(); + policy.default_language = policy.default_language.trim().to_string(); + if validate_commit_control(&policy.default_commit_type, 32).is_err() + || validate_commit_control(&policy.default_commit_scope, 64).is_err() + || validate_commit_control(&policy.default_language, 64).is_err() + { + return Err(AiError::new("invalidRepositoryPolicy")); + } + policy.commit_prompt_file = policy.commit_prompt_file.trim().to_string(); + policy.conflict_prompt_file = policy.conflict_prompt_file.trim().to_string(); + if let Some(mode) = policy.commit_message_mode { + policy.conventional_commits = mode == AiCommitMessageMode::ConventionalCommits; + } + Ok(()) +} + +#[tauri::command] +pub async fn set_ai_repository_policy( + mut request: SetAiRepositoryPolicyRequest, + state: tauri::State<'_, AppState>, +) -> Result<(), AiError> { + normalise_repository_policy(&mut request.policy)?; + let repository = tauri::async_runtime::spawn_blocking(move || { + Path::new(&request.repo_path) + .canonicalize() + .map(|path| (path.to_string_lossy().to_string(), request.policy)) + .map_err(|_| AiError::new("invalidRepository")) + }) + .await + .map_err(|_| AiError::new("invalidRepository"))??; + state + .git_service + .set_ai_repository_policy(repository.0, repository.1) + .map_err(|_| AiError::new("configurationWriteFailed"))?; + Ok(()) +} + +fn valid_optional_repository_path(value: &str) -> bool { + let value = value.trim(); + value.is_empty() + || (value.len() <= 512 + && !value.chars().any(char::is_control) + && !Path::new(value).is_absolute() + && Path::new(value) + .components() + .all(|part| matches!(part, Component::Normal(_)))) +} + +#[tauri::command] +pub async fn get_ai_repository_policy( + repo_path: String, + state: tauri::State<'_, AppState>, +) -> Result { + let repository = tauri::async_runtime::spawn_blocking(move || { + Path::new(&repo_path) + .canonicalize() + .map(|path| path.to_string_lossy().to_string()) + .map_err(|_| AiError::new("invalidRepository")) + }) + .await + .map_err(|_| AiError::new("invalidRepository"))??; + Ok(state + .git_service + .get_settings() + .extensions + .ai + .repository_policies + .get(&repository) + .cloned() + .unwrap_or_default()) +} + +#[tauri::command] +pub fn set_ai_privacy_settings( + mut request: SetAiPrivacySettingsRequest, + state: tauri::State<'_, AppState>, +) -> Result<(), AiError> { + let settings = state.git_service.get_settings(); + let configuration = state.ai_extension.environment.resolve(&settings)?; + if configuration + .environment_fields + .iter() + .any(|field| field == "includeCommitHistory") + { + request.include_commit_history = settings.extensions.ai.include_commit_history; + } + if request.global_exclusions.len() > 100 { + return Err(AiError::new("invalidExclusion")); + } + let mut exclusions = Vec::with_capacity(request.global_exclusions.len()); + for exclusion in request.global_exclusions { + let exclusion = exclusion.trim(); + if exclusion.is_empty() { + continue; + } + if exclusion.len() > 256 || exclusion.chars().any(char::is_control) { + return Err(AiError::new("invalidExclusion")); + } + if !exclusions.iter().any(|existing| existing == exclusion) { + exclusions.push(exclusion.to_string()); + } + } + state + .git_service + .set_ai_privacy_settings(request.include_commit_history, exclusions) + .map_err(|_| AiError::new("configurationWriteFailed"))?; + Ok(()) +} + +async fn configured_settings( + state: &AppState, + require_model: bool, +) -> Result<(EffectiveAiConfiguration, String), AiError> { + let settings = state.git_service.get_settings(); + let configuration = state.ai_extension.environment.resolve(&settings)?; + validate_effective_configuration(&configuration, require_model)?; + let key = if api_key_optional(&configuration) { + String::new() + } else { + read_api_key(state, &configuration, true) + .await? + .ok_or_else(|| AiError::new("notConfigured"))? + }; + Ok((configuration, key)) +} + +#[tauri::command] +pub async fn test_ai_connection( + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let (configuration, api_key) = configured_settings(&state, true).await?; + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + require_consent(&state, &configuration)?; + let discovered = discover_effort(runtime, &configuration, &api_key).await; + let mut budget = AiRequestBudget::new(); + let (result, tested_capability) = run_provider( + runtime, + &configuration, + &api_key, + "Return only the requested text.", + "Reply with OK only.", + 64, + AiTask::ConnectionTest, + &mut budget, + None, + ) + .await?; + if result.output_truncated { + return Err(AiError::new("outputTruncated")); + } + if result.text.trim().is_empty() { + return Err(AiError::new("invalidResponse")); + } + let capability = discovered.unwrap_or(tested_capability); + state + .git_service + .set_ai_effort_capability(&configuration.profile_id, capability.clone()); + emit_configuration_updated(&app); + Ok(AiConnectionTestResult { + effort_capability: capability, + usage: result.usage, + request_id: result.request_id, + generation_id: result.generation_id, + routed_provider: result.routed_provider, + routed_model: result.routed_model, + }) +} + +#[tauri::command] +pub async fn test_ai_connection_draft( + request: SaveAiConfigurationRequest, + state: tauri::State<'_, AppState>, +) -> Result { + let (configuration, api_key) = draft_configuration(&request, &state, true).await?; + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + let discovered = discover_effort(runtime, &configuration, &api_key).await; + let mut budget = AiRequestBudget::new(); + let (result, tested_capability) = run_provider( + runtime, + &configuration, + &api_key, + "Return only the requested text.", + "Reply with OK only.", + 64, + AiTask::ConnectionTest, + &mut budget, + None, + ) + .await?; + if result.output_truncated || result.text.trim().is_empty() { + return Err(AiError::new("invalidResponse")); + } + Ok(AiConnectionTestResult { + effort_capability: discovered.unwrap_or(tested_capability), + usage: result.usage, + request_id: result.request_id, + generation_id: result.generation_id, + routed_provider: result.routed_provider, + routed_model: result.routed_model, + }) +} + +async fn draft_configuration( + request: &SaveAiConfigurationRequest, + state: &AppState, + require_model: bool, +) -> Result<(EffectiveAiConfiguration, String), AiError> { + validate_configuration(&request)?; + let current = state.git_service.get_settings(); + let current_profile = requested_profile(request, ¤t.extensions.ai); + let profile = profile_from_request(&request, current_profile); + let mut proposed = current; + proposed.extensions.ai.enabled = request.enabled.unwrap_or(true); + proposed.extensions.ai.selected_profile_id = profile.id.clone(); + if let Some(existing) = proposed + .extensions + .ai + .profiles + .iter_mut() + .find(|existing| existing.id == profile.id) + { + *existing = profile; + } else { + proposed.extensions.ai.profiles.push(profile); + } + let configuration = state.ai_extension.environment.resolve(&proposed)?; + validate_effective_configuration(&configuration, require_model)?; + let api_key = if configuration.environment_api_key { + state + .ai_extension + .environment + .api_key(configuration.provider, configuration.auth_mode) + .unwrap_or_default() + } else if let Some(api_key) = request + .api_key + .as_deref() + .filter(|key| !key.trim().is_empty()) + { + api_key.to_string() + } else { + read_api_key(&state, &configuration, false) + .await? + .unwrap_or_default() + }; + if api_key.is_empty() && !api_key_optional(&configuration) { + return Err(AiError::new("apiKeyRequired")); + } + Ok((configuration, api_key)) +} + +#[tauri::command] +pub async fn discover_ai_models_draft( + request: SaveAiConfigurationRequest, + query: AiModelQuery, + state: tauri::State<'_, AppState>, +) -> Result { + let (configuration, api_key) = draft_configuration(&request, &state, false).await?; + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + discover_models(runtime, &configuration, &api_key, &query).await +} + +#[tauri::command] +pub async fn discover_ai_model_details_draft( + request: DiscoverAiModelDetailsRequest, + state: tauri::State<'_, AppState>, +) -> Result { + let (configuration, api_key) = + draft_configuration(&request.configuration, &state, false).await?; + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + let result = + discover_openrouter_model_details(runtime, &configuration, &api_key, &request.model_id) + .await; + if result.is_ok() { + let _ = state + .git_service + .update_structured_output_modes(runtime.structured_output_modes()); + } + result +} + +#[tauri::command] +pub async fn discover_ai_models( + query: AiModelQuery, + state: tauri::State<'_, AppState>, +) -> Result { + let (configuration, api_key) = configured_settings(&state, false).await?; + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + discover_models(runtime, &configuration, &api_key, &query).await +} + +#[tauri::command] +pub async fn delete_ai_profile( + profile_id: String, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let settings = state.git_service.get_settings(); + let profile = settings + .extensions + .ai + .profiles + .iter() + .find(|profile| profile.id == profile_id) + .cloned() + .ok_or_else(|| AiError::new("profileNotFound"))?; + let mut profile_settings = settings.clone(); + profile_settings.extensions.ai.selected_profile_id = profile.id.clone(); + let configuration = state.ai_extension.environment.resolve(&profile_settings)?; + let previous_key = read_stored_api_key_for(&state, &configuration).await?; + clear_stored_api_key_for(&state, &configuration).await?; + let result = state + .git_service + .delete_ai_profile(&profile_id) + .map_err(|_| AiError::new("configurationWriteFailed")); + if result.is_err() { + if let Some(previous_key) = previous_key { + write_stored_api_key_for(&state, &configuration, previous_key).await?; + } + } + result?; + if let Ok(key) = structured_output_cache_key(&configuration) { + if let Some(runtime) = &state.ai_extension.runtime { + runtime.forget_structured_output_mode(&key); + let _ = state.git_service.remove_structured_output_mode(&key); + } + } + emit_configuration_updated(&app); + configuration_view(&state).await +} + +fn git_output( + repo_path: &str, + arguments: &[&str], + maximum_bytes: usize, +) -> Result, AiError> { + let mut command = crate::git_command(); + command + .arg("-C") + .arg(repo_path) + .args(arguments) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = command + .spawn() + .map_err(|_| AiError::new("gitUnavailable"))?; + let mut output = Vec::new(); + child + .stdout + .take() + .ok_or_else(|| AiError::new("gitFailed"))? + .take(maximum_bytes as u64 + 1) + .read_to_end(&mut output) + .map_err(|_| AiError::new("gitFailed"))?; + if output.len() > maximum_bytes { + drop(child.kill()); + drop(child.wait()); + return Err(AiError::new("contextTooLarge")); + } + let status = child.wait().map_err(|_| AiError::new("gitFailed"))?; + if !status.success() { + return Err(AiError::new("gitFailed")); + } + Ok(output) +} + +fn repository_operation(repo_path: &str) -> Result, AiError> { + let git_dir = String::from_utf8(git_output( + repo_path, + &["rev-parse", "--absolute-git-dir"], + MAX_GIT_METADATA_OUTPUT_BYTES, + )?) + .map_err(|_| AiError::new("gitFailed"))?; + let git_dir = PathBuf::from(git_dir.trim()); + if git_dir.join("MERGE_HEAD").exists() { + Ok(Some("merge")) + } else if git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() { + Ok(Some("rebase")) + } else if git_dir.join("CHERRY_PICK_HEAD").exists() { + Ok(Some("cherry-pick")) + } else if git_dir.join("REVERT_HEAD").exists() { + Ok(Some("revert")) + } else { + Ok(None) + } +} + +fn is_sensitive_path(path: &str) -> bool { + let normalised = path.replace('\\', "/"); + let lower = normalised.to_ascii_lowercase(); + let components = lower.split('/').collect::>(); + let file_name = components.last().copied().unwrap_or_default(); + file_name == ".env" + || file_name.starts_with(".env.") + || matches!( + file_name, + ".npmrc" + | ".pypirc" + | ".netrc" + | ".dockercfg" + | ".git-credentials" + | "git-credentials" + | ".htpasswd" + | ".k5login" + | ".s3cfg" + | ".pgpass" + | "pg_service.conf" + | ".my.cnf" + | ".azurerc" + | ".boto" + | ".token" + | ".terraform.lock.hcl" + | ".kubeconfig" + | "kubeconfig" + | "credentials" + | "credentials.toml" + | "credentials.json" + | "secrets.yml" + | "secrets.yaml" + | "secrets.json" + ) + || components + .iter() + .any(|part| matches!(*part, ".ssh" | ".aws" | ".gnupg" | ".kube" | ".docker")) + || file_name.starts_with("id_rsa") + || file_name.starts_with("id_ed25519") + || file_name.starts_with("connectionstrings.") + || [ + ".pem", + ".key", + ".p12", + ".pfx", + ".keytab", + ".age", + ".pgp", + ".gpg", + ".jks", + ".keystore", + ".truststore", + ".tfvars", + ".tfvars.json", + ".tfstate", + ".tfstate.backup", + ".token", + ] + .iter() + .any(|suffix| file_name.ends_with(suffix)) +} + +fn parse_name_status(output: &[u8]) -> Result, AiError> { + let fields = output + .split(|byte| *byte == 0) + .filter(|field| !field.is_empty()) + .collect::>(); + let mut result = Vec::new(); + let mut index = 0; + while index < fields.len() { + let status = String::from_utf8_lossy(fields[index]).to_string(); + index += 1; + if index >= fields.len() { + return Err(AiError::new("gitFailed")); + } + if status.starts_with('R') || status.starts_with('C') { + if index + 1 >= fields.len() { + return Err(AiError::new("gitFailed")); + } + let old_path = String::from_utf8_lossy(fields[index]).to_string(); + let new_path = String::from_utf8_lossy(fields[index + 1]).to_string(); + result.push((status, format!("{old_path} -> {new_path}"))); + index += 2; + } else { + result.push((status, String::from_utf8_lossy(fields[index]).to_string())); + index += 1; + } + } + Ok(result) +} + +fn staged_paths(repo_path: &str) -> Result, AiError> { + let output = git_output( + repo_path, + &["diff", "--cached", "--name-status", "-z"], + MAX_GIT_PATH_OUTPUT_BYTES, + )?; + parse_name_status(&output) +} + +fn recent_commit_messages(repo_path: &str) -> String { + let Ok(output) = git_output( + repo_path, + &["log", "-n", "20", "--no-merges", "--format=%B%x00"], + MAX_GIT_METADATA_OUTPUT_BYTES, + ) else { + return "None available.".to_string(); + }; + let messages = String::from_utf8_lossy(&output) + .split('\0') + .map(str::trim) + .filter(|message| !message.is_empty()) + .map(str::to_string) + .collect::>(); + if messages.is_empty() { + "None available.".to_string() + } else { + messages.join("\n---\n") + } +} + +fn validate_context_size(context_bytes: usize, context_limit_kib: u32) -> Result<(), AiError> { + if context_bytes > context_limit_kib as usize * 1024 { + return Err(AiError::context_too_large(context_bytes, context_limit_kib)); + } + Ok(()) +} + +fn final_commit_instruction(subject_limit: u32) -> String { + if subject_limit == 0 { + "Now return only the commit message. Do not review or explain the changes.".to_string() + } else { + format!( + "Now return only the commit message. Keep its subject to no more than {subject_limit} characters. Do not review or explain the changes." + ) + } +} + +fn render_commit_context(context: &CommitContext) -> String { + let subject_limit = if context.subject_limit == 0 { + "Disabled".to_string() + } else { + context.subject_limit.to_string() + }; + format!( + "Branch: {}\nWorkflow: {}\nSubject limit: {subject_limit}\nExisting Git-provided or user message:\n{}\n\nStaged files:\n{}\n\nRecent commit messages for style:\n{}\n\nStaged diff:\n{}\n\n{}", + context.branch, + context.workflow.label(), + if context.existing_message.is_empty() { + "None." + } else { + &context.existing_message + }, + context.path_list, + context.recent_messages, + context.diff, + final_commit_instruction(context.subject_limit) + ) +} + +fn wildcard_matches(pattern: &str, value: &str) -> bool { + let pattern = pattern.as_bytes(); + let value = value.as_bytes(); + let (mut pattern_index, mut value_index, mut star, mut star_value) = (0, 0, None, 0); + while value_index < value.len() { + if pattern_index < pattern.len() + && (pattern[pattern_index] == b'?' || pattern[pattern_index] == value[value_index]) + { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + star = Some(pattern_index); + pattern_index += 1; + star_value = value_index; + } else if let Some(star_index) = star { + pattern_index = star_index + 1; + star_value += 1; + value_index = star_value; + } else { + return false; + } + } + while pattern_index < pattern.len() && pattern[pattern_index] == b'*' { + pattern_index += 1; + } + pattern_index == pattern.len() +} + +fn excluded_path(path: &str, exclusions: &[String]) -> bool { + exclusions + .iter() + .map(|pattern| pattern.trim().replace('\\', "/")) + .filter(|pattern| !pattern.is_empty()) + .any(|pattern| wildcard_matches(&pattern, &path.replace('\\', "/"))) +} + +fn staged_diff(repo_path: &str) -> Result { + String::from_utf8(git_output( + repo_path, + &[ + "diff", + "--cached", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--unified=1", + ], + MAX_COMMIT_TOTAL_CONTEXT_BYTES, + )?) + .map_err(|_| AiError::new("gitFailed")) +} + +fn staged_snapshot(repo_path: &str) -> Result { + let path_list = staged_paths(repo_path)? + .iter() + .map(|(status, path)| format!("{status}\t{path}")) + .collect::>() + .join("\n"); + let diff = staged_diff(repo_path)?; + let head = git_output( + repo_path, + &["rev-parse", "--verify", "HEAD"], + MAX_GIT_METADATA_OUTPUT_BYTES, + ) + .ok() + .and_then(|value| String::from_utf8(value).ok()) + .unwrap_or_else(|| "unborn".to_string()); + let operation = repository_operation(repo_path)?.unwrap_or("none"); + Ok(md5::compute(format!( + "{}\0{operation}\0{path_list}\0{diff}", + head.trim() + ))) +} + +fn build_commit_context( + repo_path: &str, + subject_limit: u32, + include_commit_history: bool, + exclusions: &[String], + workflow: AiCommitWorkflow, + existing_message: &str, +) -> Result { + if repository_operation(repo_path)? != workflow.expected_repository_operation() { + return Err(AiError::new("operationInProgress")); + } + validate_commit_control(existing_message, 4096)?; + let paths = staged_paths(repo_path)?; + if paths.is_empty() { + return Err(AiError::new("noStagedChanges")); + } + if paths.iter().any(|(_, path)| { + path.split(" -> ") + .any(|path| is_sensitive_path(path) || excluded_path(path, exclusions)) + }) { + return Err(AiError::new("sensitivePath")); + } + let branch = git_output( + repo_path, + &["symbolic-ref", "--short", "-q", "HEAD"], + MAX_GIT_METADATA_OUTPUT_BYTES, + ) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .map(|branch| branch.trim().to_string()) + .filter(|branch| !branch.is_empty()) + .unwrap_or_else(|| "detached HEAD".to_string()); + let diff = staged_diff(repo_path)?; + let path_list = paths + .iter() + .map(|(status, path)| format!("{status}\t{path}")) + .collect::>() + .join("\n"); + let recent_messages = if include_commit_history { + recent_commit_messages(repo_path) + } else { + "Not included.".to_string() + }; + let staged_snapshot = staged_snapshot(repo_path)?; + let context = CommitContext { + branch, + workflow, + existing_message: existing_message.trim().to_string(), + subject_limit, + path_list, + recent_messages, + diff, + staged_snapshot, + }; + validate_context_size( + render_commit_context(&context).len(), + (MAX_COMMIT_TOTAL_CONTEXT_BYTES / 1024) as u32, + )?; + Ok(context) +} + +fn default_writing_base_reference(repo_path: &str, task: AiWritingTask) -> Result { + let arguments: &[&str] = match task { + AiWritingTask::BranchSummary | AiWritingTask::PullRequestDescription => &[ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ], + AiWritingTask::ReleaseNotes => &["describe", "--tags", "--abbrev=0"], + AiWritingTask::StagedReview => return Ok(String::new()), + }; + let value = git_output(repo_path, arguments, MAX_GIT_METADATA_OUTPUT_BYTES) + .ok() + .and_then(|value| String::from_utf8(value).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AiError::new("baseReferenceRequired"))?; + Ok(value) +} + +fn validate_base_reference(repo_path: &str, reference: &str) -> Result { + validate_commit_control(reference, 256)?; + if reference.trim().is_empty() || reference.starts_with('-') { + return Err(AiError::new("baseReferenceInvalid")); + } + let revision = format!("{}^{{commit}}", reference.trim()); + let value = git_output( + repo_path, + &["rev-parse", "--verify", &revision], + MAX_GIT_METADATA_OUTPUT_BYTES, + ) + .map_err(|_| AiError::new("baseReferenceInvalid"))?; + String::from_utf8(value) + .map(|value| value.trim().to_string()) + .map_err(|_| AiError::new("baseReferenceInvalid")) +} + +fn writing_context( + repo_path: &str, + task: AiWritingTask, + base_reference: &str, + include_commit_history: bool, + exclusions: &[String], +) -> Result { + if matches!(task, AiWritingTask::StagedReview) { + let context = build_commit_context( + repo_path, + 0, + include_commit_history, + exclusions, + AiCommitWorkflow::Normal, + "", + )?; + let content = format!( + "Branch: {}\nStaged files:\n{}\n\nRecent commit messages:\n{}\n\nStaged diff:\n{}", + context.branch, context.path_list, context.recent_messages, context.diff + ); + return Ok(WritingContext { + files: context + .path_list + .lines() + .filter_map(|line| line.split_once('\t').map(|(_, path)| path.to_string())) + .collect(), + content, + snapshot: context.staged_snapshot, + }); + } + if repository_operation(repo_path)?.is_some() { + return Err(AiError::new("operationInProgress")); + } + let base_reference = if base_reference.trim().is_empty() { + default_writing_base_reference(repo_path, task)? + } else { + base_reference.trim().to_string() + }; + let base_commit = validate_base_reference(repo_path, &base_reference)?; + let separator = match task { + AiWritingTask::BranchSummary | AiWritingTask::PullRequestDescription => "...", + AiWritingTask::ReleaseNotes => "..", + AiWritingTask::StagedReview => unreachable!(), + }; + let range = format!("{base_commit}{separator}HEAD"); + let path_output = git_output( + repo_path, + &["diff", "--name-status", "-z", &range], + MAX_GIT_PATH_OUTPUT_BYTES, + )?; + let paths = parse_name_status(&path_output)?; + if paths.is_empty() { + return Err(AiError::new("noChanges")); + } + if paths.iter().any(|(_, path)| { + path.split(" -> ") + .any(|path| is_sensitive_path(path) || excluded_path(path, exclusions)) + }) { + return Err(AiError::new("sensitivePath")); + } + let diff = String::from_utf8(git_output( + repo_path, + &[ + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--unified=1", + &range, + ], + MAX_COMMIT_TOTAL_CONTEXT_BYTES, + )?) + .map_err(|_| AiError::new("gitFailed"))?; + let commit_range = format!("{base_commit}..HEAD"); + let commits = if include_commit_history { + String::from_utf8(git_output( + repo_path, + &["log", "--abbrev=7", "--format=%h %s", &commit_range], + MAX_GIT_METADATA_OUTPUT_BYTES, + )?) + .map_err(|_| AiError::new("gitFailed"))? + } else { + "Not included.\n".to_string() + }; + let branch = git_output( + repo_path, + &["symbolic-ref", "--short", "-q", "HEAD"], + MAX_GIT_METADATA_OUTPUT_BYTES, + ) + .ok() + .and_then(|value| String::from_utf8(value).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "detached HEAD".to_string()); + let path_list = paths + .iter() + .map(|(status, path)| format!("{status}\t{path}")) + .collect::>() + .join("\n"); + let content = format!( + "Branch: {branch}\nBase reference: {base_reference}\nChanged files:\n{path_list}\n\nCommits:\n{}\nChanges:\n{diff}", + commits.trim() + ); + let head = String::from_utf8(git_output( + repo_path, + &["rev-parse", "--verify", "HEAD"], + MAX_GIT_METADATA_OUTPUT_BYTES, + )?) + .map_err(|_| AiError::new("gitFailed"))?; + Ok(WritingContext { + files: paths.into_iter().map(|(_, path)| path).collect(), + snapshot: md5::compute(format!("{}\0{base_commit}\0{content}", head.trim())), + content, + }) +} + +fn truncate_text(text: &str, maximum_bytes: usize) -> String { + if text.len() <= maximum_bytes { + return text.to_string(); + } + const SUFFIX: &str = "\n[truncated]"; + let mut end = maximum_bytes.saturating_sub(SUFFIX.len()); + while !text.is_char_boundary(end) { + end -= 1; + } + format!("{}{SUFFIX}", &text[..end]) +} + +fn split_text(text: &str, maximum_bytes: usize) -> Vec { + let mut chunks = Vec::new(); + let mut remaining = text; + while !remaining.is_empty() { + let mut end = remaining.len().min(maximum_bytes); + while !remaining.is_char_boundary(end) { + end -= 1; + } + if end < remaining.len() { + if let Some(line_end) = remaining[..end].rfind('\n') { + end = line_end + 1; + } + } + chunks.push(remaining[..end].to_string()); + remaining = &remaining[end..]; + } + chunks +} + +fn commit_request_context_bytes(configuration: &EffectiveAiConfiguration) -> usize { + configuration.commit_context_limit_kib as usize * 1024 +} + +fn commit_summary_diff_bytes(configuration: &EffectiveAiConfiguration) -> usize { + commit_request_context_bytes(configuration) * 5 / 8 +} + +fn commit_summary_max_tokens(configuration: &EffectiveAiConfiguration) -> u32 { + configuration + .commit_message_max_tokens + .min(COMMIT_SUMMARY_MAX_TOKENS) +} + +fn render_summary_context(context: &CommitContext, summaries: &[String]) -> String { + let subject_limit = if context.subject_limit == 0 { + "Disabled".to_string() + } else { + context.subject_limit.to_string() + }; + format!( + "Branch: {}\nWorkflow: {}\nSubject limit: {subject_limit}\nExisting Git-provided or user message:\n{}\n\nStaged files:\n{}\n\nRecent commit messages for style:\n{}\n\nStaged change summaries:\n{}\n\n{}", + context.branch, + context.workflow.label(), + if context.existing_message.is_empty() { + "None." + } else { + &context.existing_message + }, + truncate_text(&context.path_list, COMMIT_PATH_LIST_MAX_BYTES), + truncate_text(&context.recent_messages, COMMIT_STYLE_EXAMPLES_MAX_BYTES), + summaries.join("\n\n"), + final_commit_instruction(context.subject_limit) + ) +} + +fn combined_usage(left: AiUsage, right: AiUsage) -> AiUsage { + fn add(left: Option, right: Option) -> Option { + match (left, right) { + (None, None) => None, + (left, right) => Some( + left.unwrap_or_default() + .saturating_add(right.unwrap_or_default()), + ), + } + } + + AiUsage { + input_tokens: add(left.input_tokens, right.input_tokens), + output_tokens: add(left.output_tokens, right.output_tokens), + reasoning_tokens: add(left.reasoning_tokens, right.reasoning_tokens), + cached_tokens: add(left.cached_tokens, right.cached_tokens), + cost: match (left.cost, right.cost) { + (None, None) => None, + (left, right) => Some(left.unwrap_or_default() + right.unwrap_or_default()), + }, + byok: left.byok.or(right.byok), + } +} + +async fn run_commit_provider( + runtime: &crate::ai::AiRuntime, + configuration: &mut EffectiveAiConfiguration, + budget: &mut AiRequestBudget, + api_key: &str, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + cancellation: Option, +) -> Result { + validate_context_size( + system_prompt.len().saturating_add(user_prompt.len()), + configuration.commit_context_limit_kib, + )?; + let (result, capability) = run_provider( + runtime, + configuration, + api_key, + system_prompt, + user_prompt, + max_tokens, + AiTask::CommitMessage, + budget, + cancellation, + ) + .await?; + if capability == AiEffortCapability::Unsupported { + configuration.effort_capability = capability; + } + Ok(result) +} + +async fn summarise_commit_diff( + runtime: &crate::ai::AiRuntime, + configuration: &mut EffectiveAiConfiguration, + budget: &mut AiRequestBudget, + api_key: &str, + diff: &str, + cancellation: Option, +) -> Result<(Vec, AiUsage), AiError> { + let chunks = split_text(diff, commit_summary_diff_bytes(configuration)); + let chunk_count = chunks.len(); + let mut summaries = Vec::with_capacity(chunk_count); + let mut usage = AiUsage::default(); + for (index, chunk) in chunks.into_iter().enumerate() { + let prompt = format!( + "Diff chunk {} of {chunk_count}:\n{chunk}\n\nSummarise this chunk now.", + index + 1 + ); + validate_context_size(prompt.len(), configuration.commit_context_limit_kib)?; + let max_tokens = commit_summary_max_tokens(configuration); + let result = run_commit_provider( + runtime, + configuration, + budget, + api_key, + COMMIT_SUMMARY_PROMPT, + &prompt, + max_tokens, + cancellation.clone(), + ) + .await?; + if result.output_truncated { + return Err(AiError::new("outputTruncated")); + } + let summary = result.text.trim(); + if summary.is_empty() { + return Err(AiError::new("invalidResponse")); + } + summaries.push(truncate_text(summary, COMMIT_SUMMARY_MAX_BYTES)); + usage = combined_usage(usage, result.usage); + } + Ok((summaries, usage)) +} + +async fn reduce_commit_summaries( + runtime: &crate::ai::AiRuntime, + configuration: &mut EffectiveAiConfiguration, + budget: &mut AiRequestBudget, + api_key: &str, + summaries: Vec, + cancellation: Option, +) -> Result<(Vec, AiUsage), AiError> { + let joined = summaries.join("\n\n"); + let chunks = split_text(&joined, commit_summary_diff_bytes(configuration)); + if chunks.len() >= summaries.len() { + return Err(AiError::new("contextTooLarge")); + } + let chunk_count = chunks.len(); + let mut reduced = Vec::with_capacity(chunk_count); + let mut usage = AiUsage::default(); + for (index, chunk) in chunks.into_iter().enumerate() { + let prompt = format!( + "Summary group {} of {chunk_count}:\n{chunk}\n\nConsolidate this group now.", + index + 1 + ); + let max_tokens = commit_summary_max_tokens(configuration); + let result = run_commit_provider( + runtime, + configuration, + budget, + api_key, + COMMIT_SUMMARY_REDUCTION_PROMPT, + &prompt, + max_tokens, + cancellation.clone(), + ) + .await?; + if result.output_truncated { + return Err(AiError::new("outputTruncated")); + } + let summary = result.text.trim(); + if summary.is_empty() { + return Err(AiError::new("invalidResponse")); + } + reduced.push(truncate_text(summary, COMMIT_SUMMARY_MAX_BYTES)); + usage = combined_usage(usage, result.usage); + } + Ok((reduced, usage)) +} + +fn validate_commit_message(message: String, subject_limit: u32) -> Result { + let message = message.trim().to_string(); + let subject = message.lines().next().unwrap_or_default(); + if message.is_empty() + || message.len() > 4096 + || message.contains("```") + || message + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + || (subject_limit > 0 && subject.chars().count() > subject_limit as usize) + || subject.trim().is_empty() + { + return Err(AiError::new("invalidResponse")); + } + Ok(message) +} + +async fn generate_commit_message_from_context( + runtime: &crate::ai::AiRuntime, + mut configuration: EffectiveAiConfiguration, + api_key: &str, + context: CommitContext, + system_prompt: &str, + budget: &mut AiRequestBudget, + cancellation: Option, +) -> Result { + let full_context = render_commit_context(&context); + let request_context_bytes = commit_request_context_bytes(&configuration); + let available_user_context_bytes = request_context_bytes.saturating_sub(system_prompt.len()); + if available_user_context_bytes == 0 { + return Err(AiError::context_too_large( + system_prompt.len(), + configuration.commit_context_limit_kib, + )); + } + let mut usage = AiUsage::default(); + let user_prompt = if full_context.len() <= available_user_context_bytes { + full_context + } else { + let (mut summaries, summary_usage) = summarise_commit_diff( + runtime, + &mut configuration, + budget, + api_key, + &context.diff, + cancellation.clone(), + ) + .await?; + usage = combined_usage(usage, summary_usage); + let mut summary_context = render_summary_context(&context, &summaries); + while summary_context.len() > available_user_context_bytes { + let (reduced, reduction_usage) = reduce_commit_summaries( + runtime, + &mut configuration, + budget, + api_key, + summaries, + cancellation.clone(), + ) + .await?; + summaries = reduced; + usage = combined_usage(usage, reduction_usage); + summary_context = render_summary_context(&context, &summaries); + } + summary_context + }; + let max_tokens = configuration.commit_message_max_tokens; + let result = run_commit_provider( + runtime, + &mut configuration, + budget, + api_key, + system_prompt, + &user_prompt, + max_tokens, + cancellation, + ) + .await?; + if result.output_truncated { + return Err(AiError::new("outputTruncated")); + } + usage = combined_usage(usage, result.usage); + Ok(AiCommitMessageResult { + message: validate_commit_message(result.text, context.subject_limit)?, + usage, + request_id: result.request_id, + generation_id: result.generation_id, + routed_provider: result.routed_provider, + routed_model: result.routed_model, + }) +} + +#[tauri::command] +pub async fn generate_ai_commit_message( + repo_path: String, + subject_limit: u32, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let result = generate_ai_commit_messages( + GenerateAiCommitMessagesRequest { + repo_path, + subject_limit, + operation_id: format!("commit-{}", operation_nonce()), + candidate_count: 1, + mode: AiCommitMessageMode::RepositoryStyle, + commit_type: String::new(), + scope: String::new(), + language: String::new(), + issue_key: String::new(), + additional_instruction: String::new(), + workflow: AiCommitWorkflow::Normal, + existing_message: String::new(), + }, + state, + app, + ) + .await?; + result + .candidates + .into_iter() + .next() + .ok_or_else(|| AiError::new("invalidResponse")) +} + +fn validate_commit_control(value: &str, maximum_length: usize) -> Result<(), AiError> { + if value.len() > maximum_length + || value + .chars() + .any(|character| character.is_control() && character != '\t') + { + return Err(AiError::new("invalidCommitControl")); + } + Ok(()) +} + +fn commit_system_prompt( + configuration: &EffectiveAiConfiguration, + request: &GenerateAiCommitMessagesRequest, +) -> Result { + validate_commit_control(&request.commit_type, 32)?; + validate_commit_control(&request.scope, 64)?; + validate_commit_control(&request.language, 64)?; + validate_commit_control(&request.issue_key, 64)?; + validate_commit_control(&request.additional_instruction, 1000)?; + validate_commit_control(&request.existing_message, 4096)?; + let mut prompt = configuration.commit_message_prompt.clone(); + if request.subject_limit > 0 { + prompt.push_str(&format!( + "\n\nThe commit subject must not exceed {} characters.", + request.subject_limit + )); + } + match request.mode { + AiCommitMessageMode::RepositoryStyle => { + prompt + .push_str("\nFollow the supplied repository commit style where it is consistent."); + } + AiCommitMessageMode::ConventionalCommits => { + prompt.push_str("\nUse Conventional Commits format for the subject."); + } + AiCommitMessageMode::FreeForm => {} + } + if !request.commit_type.trim().is_empty() { + prompt.push_str(&format!( + "\nUse commit type: {}.", + request.commit_type.trim() + )); + } + if !request.scope.trim().is_empty() { + prompt.push_str(&format!("\nUse commit scope: {}.", request.scope.trim())); + } + if !request.language.trim().is_empty() { + prompt.push_str(&format!("\nWrite in {}.", request.language.trim())); + } + if !request.issue_key.trim().is_empty() { + prompt.push_str(&format!( + "\nInclude issue key: {}.", + request.issue_key.trim() + )); + } + if !request.additional_instruction.trim().is_empty() { + prompt.push_str(&format!( + "\nAdditional user instruction: {}", + request.additional_instruction.trim() + )); + } + if request.workflow != AiCommitWorkflow::Normal { + prompt.push_str(&format!( + "\nThis is a {}. Treat the supplied existing message as context, but return a complete replacement candidate.", + request.workflow.label() + )); + } + Ok(prompt) +} + +async fn generate_ai_commit_messages_inner( + request: GenerateAiCommitMessagesRequest, + state: &AppState, + app: &tauri::AppHandle, + cancellation: Option, +) -> Result { + if !(1..=3).contains(&request.candidate_count) { + return Err(AiError::new("invalidCandidateCount")); + } + emit_ai_progress( + app, + &request.operation_id, + "commitMessage", + "collectingContext", + ); + let (mut configuration, api_key) = configured_settings(state, true).await?; + apply_repository_prompts( + state, + &request.repo_path, + &mut configuration, + AiTask::CommitMessage, + ) + .await?; + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + require_consent(state, &configuration)?; + let (include_commit_history, exclusions) = + repository_context_options(state, &request.repo_path, &configuration); + let context_repo_path = request.repo_path.clone(); + let subject_limit = request.subject_limit; + let workflow = request.workflow; + let existing_message = request.existing_message.clone(); + let context = tauri::async_runtime::spawn_blocking(move || { + build_commit_context( + &context_repo_path, + subject_limit, + include_commit_history, + &exclusions, + workflow, + &existing_message, + ) + }) + .await + .map_err(|_| AiError::new("gitFailed"))??; + let expected_snapshot = context.staged_snapshot; + let system_prompt = commit_system_prompt(&configuration, &request)?; + emit_ai_progress( + app, + &request.operation_id, + "commitMessage", + "contactingProvider", + ); + let mut budget = AiRequestBudget::new(); + let mut candidates = Vec::with_capacity(request.candidate_count as usize); + for _ in 0..request.candidate_count { + candidates.push( + generate_commit_message_from_context( + runtime, + configuration.clone(), + &api_key, + context.clone(), + &system_prompt, + &mut budget, + cancellation.clone(), + ) + .await?, + ); + } + let current_snapshot = + tauri::async_runtime::spawn_blocking(move || staged_snapshot(&request.repo_path)) + .await + .map_err(|_| AiError::new("gitFailed"))??; + if current_snapshot != expected_snapshot { + return Err(AiError::new("stagedChangesChanged")); + } + emit_ai_progress(app, &request.operation_id, "commitMessage", "complete"); + Ok(AiCommitCandidatesResult { candidates }) +} + +#[tauri::command] +pub async fn generate_ai_commit_messages( + mut request: GenerateAiCommitMessagesRequest, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let started_at = Instant::now(); + if request.operation_id.is_empty() { + request.operation_id = format!("commit-{}", operation_nonce()); + } + let operation_id = request.operation_id.clone(); + let cancellation = state.ai_extension.operations.begin(&operation_id)?; + let result = tokio::select! { + _ = cancellation.cancelled() => Err(AiError::new("operationCancelled")), + result = tokio::time::timeout(AI_OPERATION_TIMEOUT, generate_ai_commit_messages_inner(request, &state, &app, Some(cancellation.clone()))) => { + result.map_err(|_| AiError::new("timeout")).and_then(|result| result) + }, + }; + state.ai_extension.operations.finish(&operation_id); + match &result { + Ok(result) => { + let usage = result + .candidates + .iter() + .fold(AiUsage::default(), |total, candidate| { + combined_usage(total, candidate.usage.clone()) + }); + record_ai_usage( + &state, + "commitMessage", + started_at, + Some(&usage), + result + .candidates + .first() + .and_then(|candidate| candidate.request_id.as_deref()), + result + .candidates + .first() + .and_then(|candidate| candidate.generation_id.as_deref()), + result + .candidates + .first() + .and_then(|candidate| candidate.routed_provider.as_deref()), + result + .candidates + .first() + .and_then(|candidate| candidate.routed_model.as_deref()), + None, + "completed", + ); + } + Err(error) => record_ai_usage( + &state, + "commitMessage", + started_at, + None, + None, + None, + None, + None, + error.detail.as_deref(), + error.code, + ), + } + result +} + +fn operation_nonce() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + +#[tauri::command] +pub fn cancel_ai_operation( + operation_id: String, + state: tauri::State<'_, AppState>, +) -> Result<(), AiError> { + state.ai_extension.operations.cancel(&operation_id) +} + +#[tauri::command] +pub async fn get_ai_commit_context_preview( + repo_path: String, + subject_limit: u32, + workflow: Option, + existing_message: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let settings = state.git_service.get_settings(); + let mut configuration = state.ai_extension.environment.resolve(&settings)?; + apply_repository_prompts( + &state, + &repo_path, + &mut configuration, + AiTask::CommitMessage, + ) + .await?; + validate_effective_configuration(&configuration, true)?; + let (include_commit_history, exclusions) = + repository_context_options(&state, &repo_path, &configuration); + let workflow = workflow.unwrap_or_default(); + let existing_message = existing_message.unwrap_or_default(); + let context = tauri::async_runtime::spawn_blocking(move || { + build_commit_context( + &repo_path, + subject_limit, + include_commit_history, + &exclusions, + workflow, + &existing_message, + ) + }) + .await + .map_err(|_| AiError::new("gitFailed"))??; + let files = context + .path_list + .lines() + .filter_map(|line| line.split_once('\t').map(|(_, path)| path.to_string())) + .collect(); + let context_bytes = render_commit_context(&context) + .len() + .saturating_add(configuration.commit_message_prompt.len()); + Ok(AiContextPreview { + provider: configuration.provider, + destination_authority: configuration.destination_authority()?, + task: "commitMessage", + files, + context_size_kib: context_bytes.div_ceil(1024), + context_limit_kib: configuration.commit_context_limit_kib, + includes_commit_history: include_commit_history, + }) +} + +async fn prepare_writing_context( + state: &AppState, + configuration: &EffectiveAiConfiguration, + repo_path: String, + task: AiWritingTask, + base_reference: String, +) -> Result<(WritingContext, bool), AiError> { + let (include_commit_history, exclusions) = + repository_context_options(state, &repo_path, configuration); + let context_exclusions = exclusions.clone(); + let context = tauri::async_runtime::spawn_blocking(move || { + writing_context( + &repo_path, + task, + &base_reference, + include_commit_history, + &context_exclusions, + ) + }) + .await + .map_err(|_| AiError::new("gitFailed"))??; + Ok((context, include_commit_history)) +} + +#[tauri::command] +pub async fn get_ai_writing_context_preview( + request: AiWritingContextPreviewRequest, + state: tauri::State<'_, AppState>, +) -> Result { + let settings = state.git_service.get_settings(); + let configuration = state.ai_extension.environment.resolve(&settings)?; + validate_effective_configuration(&configuration, true)?; + let (context, include_commit_history) = prepare_writing_context( + &state, + &configuration, + request.repo_path, + request.task, + request.base_reference, + ) + .await?; + let context_bytes = context + .content + .len() + .saturating_add(request.task.system_prompt().len()); + validate_context_size(context_bytes, configuration.commit_context_limit_kib)?; + Ok(AiContextPreview { + provider: configuration.provider, + destination_authority: configuration.destination_authority()?, + task: request.task.identifier(), + files: context.files, + context_size_kib: context_bytes.div_ceil(1024), + context_limit_kib: configuration.commit_context_limit_kib, + includes_commit_history: include_commit_history, + }) +} + +async fn generate_ai_writing_inner( + request: &GenerateAiWritingRequest, + state: &AppState, + app: &tauri::AppHandle, + cancellation: Option, +) -> Result { + validate_commit_control(&request.additional_instruction, 1000)?; + emit_ai_progress( + app, + &request.operation_id, + request.task.identifier(), + "collectingContext", + ); + let (configuration, api_key) = configured_settings(state, true).await?; + require_consent(state, &configuration)?; + let (context, _) = prepare_writing_context( + state, + &configuration, + request.repo_path.clone(), + request.task, + request.base_reference.clone(), + ) + .await?; + let mut system_prompt = request.task.system_prompt().to_string(); + if !request.additional_instruction.trim().is_empty() { + system_prompt.push_str(&format!( + "\n\nAdditional user instruction: {}", + request.additional_instruction.trim() + )); + } + validate_context_size( + system_prompt.len().saturating_add(context.content.len()), + configuration.commit_context_limit_kib, + )?; + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + let mut budget = AiRequestBudget::new(); + emit_ai_progress( + app, + &request.operation_id, + request.task.identifier(), + "contactingProvider", + ); + let (result, _) = run_provider( + runtime, + &configuration, + &api_key, + &system_prompt, + &context.content, + configuration.commit_message_max_tokens, + AiTask::CommitMessage, + &mut budget, + cancellation, + ) + .await?; + if result.output_truncated { + return Err(AiError::new("outputTruncated")); + } + let content = result.text.trim().to_string(); + if content.is_empty() + || content.len() > 64 * 1024 + || content + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(AiError::new("invalidResponse")); + } + let current = prepare_writing_context( + state, + &configuration, + request.repo_path.clone(), + request.task, + request.base_reference.clone(), + ) + .await? + .0; + if current.snapshot != context.snapshot { + return Err(AiError::new("repositoryChanged")); + } + emit_ai_progress( + app, + &request.operation_id, + request.task.identifier(), + "complete", + ); + Ok(AiWritingResult { + content, + usage: result.usage, + request_id: result.request_id, + generation_id: result.generation_id, + routed_provider: result.routed_provider, + routed_model: result.routed_model, + }) +} + +#[tauri::command] +pub async fn generate_ai_writing( + mut request: GenerateAiWritingRequest, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let started_at = Instant::now(); + if request.operation_id.is_empty() { + request.operation_id = format!("writing-{}", operation_nonce()); + } + let operation_id = request.operation_id.clone(); + let task = request.task.identifier(); + let cancellation = state.ai_extension.operations.begin(&operation_id)?; + let result = tokio::select! { + _ = cancellation.cancelled() => Err(AiError::new("operationCancelled")), + result = tokio::time::timeout(AI_OPERATION_TIMEOUT, generate_ai_writing_inner(&request, &state, &app, Some(cancellation.clone()))) => { + result.map_err(|_| AiError::new("timeout")).and_then(|result| result) + }, + }; + state.ai_extension.operations.finish(&operation_id); + match &result { + Ok(result) => record_ai_usage( + &state, + task, + started_at, + Some(&result.usage), + result.request_id.as_deref(), + result.generation_id.as_deref(), + result.routed_provider.as_deref(), + result.routed_model.as_deref(), + None, + "completed", + ), + Err(error) => record_ai_usage( + &state, + task, + started_at, + None, + None, + None, + None, + None, + error.detail.as_deref(), + error.code, + ), + } + result +} + +fn safe_repository_file(repo_path: &str, file_path: &str) -> Result { + if file_path.is_empty() || is_sensitive_path(file_path) { + return Err(AiError::new(if file_path.is_empty() { + "invalidPath" + } else { + "sensitivePath" + })); + } + let relative = Path::new(file_path); + if relative.is_absolute() + || relative + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(AiError::new("invalidPath")); + } + let repository = Path::new(repo_path) + .canonicalize() + .map_err(|_| AiError::new("invalidRepository"))?; + let candidate = repository.join(relative); + let metadata = candidate + .symlink_metadata() + .map_err(|_| AiError::new("fileUnavailable"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(AiError::new("unsupportedFile")); + } + let canonical = candidate + .canonicalize() + .map_err(|_| AiError::new("fileUnavailable"))?; + if !canonical.starts_with(&repository) { + return Err(AiError::new("invalidPath")); + } + Ok(canonical) +} + +fn line_ranges(text: &str) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut start = 0; + for (index, byte) in text.bytes().enumerate() { + if byte == b'\n' { + ranges.push((start, index + 1)); + start = index + 1; + } + } + if start < text.len() { + ranges.push((start, text.len())); + } + ranges +} + +fn trimmed_line<'a>(text: &'a str, range: (usize, usize)) -> &'a str { + text[range.0..range.1].trim_end_matches(['\r', '\n']) +} + +fn marker_size(line: &str, marker: char) -> Option { + let count = line + .chars() + .take_while(|character| *character == marker) + .count(); + (count >= 7).then_some(count) +} + +fn marker_line(line: &str, marker: char, size: usize, allows_label: bool) -> bool { + let marker_text = marker.to_string().repeat(size); + let Some(remainder) = line.strip_prefix(&marker_text) else { + return false; + }; + if allows_label { + remainder.is_empty() || remainder.starts_with(char::is_whitespace) + } else { + remainder.is_empty() + } +} + +fn parse_conflict_regions(text: &str) -> Result, AiError> { + let lines = line_ranges(text); + let mut regions = Vec::new(); + let mut index = 0; + while index < lines.len() { + let outside_line = trimmed_line(text, lines[index]); + let Some(size) = marker_size(outside_line, '<') else { + if marker_size(outside_line, '=').is_some() + || marker_size(outside_line, '>').is_some() + || marker_size(outside_line, '|').is_some() + { + return Err(AiError::new("malformedConflict")); + } + index += 1; + continue; + }; + if !marker_line(outside_line, '<', size, true) { + index += 1; + continue; + } + let start_line = index; + let mut base_line = None; + let mut separator = None; + let mut end_line = None; + index += 1; + while index < lines.len() { + let line = trimmed_line(text, lines[index]); + if marker_size(line, '<').is_some() { + return Err(AiError::new("malformedConflict")); + } + if marker_line(line, '|', size, true) { + if separator.is_some() || base_line.replace(index).is_some() { + return Err(AiError::new("malformedConflict")); + } + } else if marker_line(line, '=', size, false) { + if separator.replace(index).is_some() { + return Err(AiError::new("malformedConflict")); + } + } else if marker_line(line, '>', size, true) { + end_line = Some(index); + break; + } + index += 1; + } + if separator.is_none() || end_line.is_none() { + return Err(AiError::new("malformedConflict")); + } + let separator = separator.unwrap(); + let end_line = end_line.unwrap(); + if base_line.is_some_and(|base| base >= separator) { + return Err(AiError::new("malformedConflict")); + } + let context_start = start_line.saturating_sub(12); + let context_end = (end_line + 13).min(lines.len()); + let start = lines[start_line].0; + let end = lines[end_line].1; + let ours_end = base_line.unwrap_or(separator); + let ours = text[lines[start_line].1..lines[ours_end].0].to_string(); + let ancestor = base_line.map(|base| text[lines[base].1..lines[separator].0].to_string()); + let theirs = text[lines[separator].1..lines[end_line].0].to_string(); + let original = text[start..end].to_string(); + let id = format!("{:x}", Sha256::digest(format!("{start}:{end}:{original}"))); + regions.push(ConflictRegion { + id, + start, + end, + prompt: text[lines[context_start].0..lines[context_end - 1].1].to_string(), + original, + ours, + theirs, + ancestor, + }); + index = end_line + 1; + } + if regions.is_empty() { + return Err(AiError::new("noConflictMarkers")); + } + Ok(regions) +} + +fn build_conflict_prompt(file_path: &str, operation: &str, regions: &[ConflictRegion]) -> String { + let mut prompt = format!("Path: {file_path}\nGit operation: {operation}\n\n"); + for region in regions { + prompt.push_str(&format!( + "Region ID: {}\nOriginal with context:\n{}\n\nOurs:\n{}\n\nAncestor:\n{}\n\nTheirs:\n{}\n\n", + region.id, + region.prompt, + region.ours, + region.ancestor.as_deref().unwrap_or("Not available."), + region.theirs, + )); + } + prompt.push_str( + "Return only JSON matching this shape: {\"regions\":[{\"id\":\"the exact region ID\",\"replacement\":\"resolved text\",\"explanation\":\"brief rationale\"}]}. Include every region exactly once.", + ); + prompt +} + +fn parse_conflict_replacements( + response: &str, + regions: &[ConflictRegion], + mode: AiStructuredOutputMode, +) -> Result, AiError> { + let response = response.trim(); + let response = if mode == AiStructuredOutputMode::PromptOnly { + if let Some(fenced) = response + .strip_prefix("```json") + .and_then(|response| response.strip_suffix("```")) + { + if fenced.contains("```") { + return Err(AiError::new("malformedStructuredOutput")); + } + fenced.trim() + } else { + response + } + } else { + response + }; + let parsed: ModelConflictResponse = + serde_json::from_str(response).map_err(|_| AiError::new("malformedStructuredOutput"))?; + let expected = regions + .iter() + .map(|region| region.id.as_str()) + .collect::>(); + let mut replacements_by_id = HashMap::with_capacity(parsed.regions.len()); + for replacement in parsed.regions { + if !expected.contains(replacement.id.as_str()) { + return Err(AiError::new("unknownConflictRegion")); + } + if replacement.replacement.contains("<<<<<<<") + || replacement.replacement.contains("=======") + || replacement.replacement.contains(">>>>>>>") + { + return Err(AiError::new("unresolvedConflictMarkers")); + } + let id = replacement.id.clone(); + if replacements_by_id.insert(id, replacement).is_some() { + return Err(AiError::new("duplicateConflictRegion")); + } + } + if replacements_by_id.len() != regions.len() { + return Err(AiError::new("missingConflictRegions")); + } + let mut replacements = Vec::with_capacity(regions.len()); + for region in regions { + replacements.push( + replacements_by_id + .remove(®ion.id) + .ok_or_else(|| AiError::new("missingConflictRegions"))?, + ); + } + Ok(replacements) +} + +fn normalise_replacement_line_endings(replacement: &str, original: &str) -> String { + if original.contains("\r\n") { + replacement.replace("\r\n", "\n").replace('\n', "\r\n") + } else { + replacement.replace("\r\n", "\n") + } +} + +fn write_atomically(path: &Path, contents: &[u8]) -> Result<(), AiError> { + let parent = path + .parent() + .ok_or_else(|| AiError::new("fileWriteFailed"))?; + let permissions = path + .metadata() + .map_err(|_| AiError::new("fileWriteFailed"))? + .permissions(); + let mut temporary = + tempfile::NamedTempFile::new_in(parent).map_err(|_| AiError::new("fileWriteFailed"))?; + temporary + .write_all(contents) + .map_err(|_| AiError::new("fileWriteFailed"))?; + temporary + .flush() + .map_err(|_| AiError::new("fileWriteFailed"))?; + temporary + .as_file() + .set_permissions(permissions) + .map_err(|_| AiError::new("fileWriteFailed"))?; + temporary + .persist(path) + .map_err(|_| AiError::new("fileWriteFailed"))?; + Ok(()) +} + +fn read_bounded_file(path: &Path, maximum_bytes: usize) -> Result, AiError> { + let file = std::fs::File::open(path).map_err(|_| AiError::new("fileUnavailable"))?; + let mut bytes = Vec::new(); + file.take(maximum_bytes as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| AiError::new("fileUnavailable"))?; + if bytes.len() > maximum_bytes { + return Err(AiError::new("contextTooLarge")); + } + Ok(bytes) +} + +fn unmerged_index_entries(repo_path: &str, file_path: &str) -> Result, AiError> { + let output = git_output( + repo_path, + &["ls-files", "-u", "-z", "--", file_path], + MAX_GIT_METADATA_OUTPUT_BYTES, + )?; + if output.is_empty() { + return Err(AiError::new("noUnmergedIndex")); + } + Ok(output) +} + +fn file_index_entries(repo_path: &str, file_path: &str) -> Result, AiError> { + git_output( + repo_path, + &["ls-files", "--stage", "-z", "--", file_path], + MAX_GIT_METADATA_OUTPUT_BYTES, + ) +} + +fn stage_conflict_file(repo_path: &str, file_path: &str) -> Result, AiError> { + let status = crate::git_command() + .arg("-C") + .arg(repo_path) + .args(["add", "--", file_path]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map_err(|_| AiError::new("gitUnavailable"))?; + if !status.success() { + return Err(AiError::new("conflictStageFailed")); + } + let entries = file_index_entries(repo_path, file_path)?; + if entries.is_empty() { + return Err(AiError::new("conflictStageFailed")); + } + Ok(entries) +} + +fn restore_unmerged_index( + repo_path: &str, + file_path: &str, + unmerged_index: &[u8], +) -> Result<(), AiError> { + let first_entry = unmerged_index + .split(|byte| *byte == 0) + .next() + .ok_or_else(|| AiError::new("conflictUndoFailed"))?; + let mut fields = first_entry.split(|byte| *byte == b' '); + fields + .next() + .ok_or_else(|| AiError::new("conflictUndoFailed"))?; + let object_id = fields + .next() + .ok_or_else(|| AiError::new("conflictUndoFailed"))?; + if object_id.is_empty() || !object_id.iter().all(u8::is_ascii_hexdigit) { + return Err(AiError::new("conflictUndoFailed")); + } + + let mut input = + Vec::with_capacity(unmerged_index.len() + file_path.len() + object_id.len() + 4); + input.extend_from_slice(b"0 "); + input.extend(std::iter::repeat_n(b'0', object_id.len())); + input.push(b'\t'); + input.extend_from_slice(file_path.as_bytes()); + input.push(0); + input.extend_from_slice(unmerged_index); + + let mut child = crate::git_command() + .arg("-C") + .arg(repo_path) + .args(["update-index", "-z", "--index-info"]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| AiError::new("gitUnavailable"))?; + child + .stdin + .take() + .ok_or_else(|| AiError::new("conflictUndoFailed"))? + .write_all(&input) + .map_err(|_| AiError::new("conflictUndoFailed"))?; + let status = child + .wait() + .map_err(|_| AiError::new("conflictUndoFailed"))?; + if !status.success() { + return Err(AiError::new("conflictUndoFailed")); + } + Ok(()) +} + +fn prepare_conflict( + repo_path: &str, + file_path: &str, + maximum_bytes: usize, +) -> Result { + let repository = Path::new(repo_path) + .canonicalize() + .map_err(|_| AiError::new("invalidRepository"))?; + let unmerged_index = unmerged_index_entries(repo_path, file_path)?; + let operation = repository_operation(repo_path)?.unwrap_or("unmerged index"); + let path = safe_repository_file(repo_path, file_path)?; + let original_bytes = read_bounded_file(&path, maximum_bytes)?; + if original_bytes.contains(&0) { + return Err(AiError::new("unsupportedFile")); + } + let original = + String::from_utf8(original_bytes.clone()).map_err(|_| AiError::new("unsupportedFile"))?; + let regions = parse_conflict_regions(&original)?; + Ok(PreparedConflict { + repository, + original_bytes, + operation, + unmerged_index, + regions, + }) +} + +fn proposal_id(original: &[u8]) -> String { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + format!( + "{:x}", + Sha256::digest(format!( + "{}:{timestamp}:{:x}", + std::process::id(), + Sha256::digest(original) + )) + ) +} + +#[tauri::command] +pub async fn get_ai_conflict_eligibility( + request: ResolveConflictWithAiRequest, + state: tauri::State<'_, AppState>, +) -> Result { + let settings = state.git_service.get_settings(); + let configuration = match state.ai_extension.environment.resolve(&settings) { + Ok(configuration) => configuration, + Err(error) => { + return Ok(AiConflictEligibility { + eligible: false, + reason: Some(error.code), + }); + } + }; + let (_, exclusions) = repository_context_options(&state, &request.repo_path, &configuration); + if excluded_path(&request.file_path, &exclusions) { + return Ok(AiConflictEligibility { + eligible: false, + reason: Some("sensitivePath"), + }); + } + let result = tauri::async_runtime::spawn_blocking(move || { + prepare_conflict( + &request.repo_path, + &request.file_path, + MAX_COMMIT_TOTAL_CONTEXT_BYTES, + ) + .map(|_| ()) + }) + .await + .map_err(|_| AiError::new("fileUnavailable")) + .and_then(|result| result); + Ok(match result { + Ok(()) => AiConflictEligibility { + eligible: true, + reason: None, + }, + Err(error) => AiConflictEligibility { + eligible: false, + reason: Some(error.code), + }, + }) +} + +async fn resolve_conflict_with_ai_inner( + request: ResolveConflictWithAiRequest, + state: &AppState, + app: &tauri::AppHandle, + cancellation: Option, +) -> Result { + resolve_conflict_with_ai_inner_filtered(request, state, app, cancellation, None).await +} + +async fn resolve_conflict_with_ai_inner_filtered( + request: ResolveConflictWithAiRequest, + state: &AppState, + app: &tauri::AppHandle, + cancellation: Option, + regen_ids: Option>, +) -> Result { + emit_ai_progress( + app, + &request.operation_id, + "conflictResolution", + "collectingContext", + ); + let (mut configuration, api_key) = configured_settings(state, true).await?; + let (_, exclusions) = repository_context_options(state, &request.repo_path, &configuration); + if excluded_path(&request.file_path, &exclusions) { + return Err(AiError::new("sensitivePath")); + } + apply_repository_prompts( + state, + &request.repo_path, + &mut configuration, + AiTask::ConflictResolution, + ) + .await?; + let runtime = state + .ai_extension + .runtime + .as_ref() + .ok_or_else(|| AiError::new("network"))?; + require_consent(state, &configuration)?; + let maximum_bytes = configuration.conflict_context_limit_kib as usize * 1024; + let repo_path = request.repo_path.clone(); + let file_path = request.file_path.clone(); + let prepared = tauri::async_runtime::spawn_blocking(move || { + prepare_conflict(&repo_path, &file_path, maximum_bytes) + }) + .await + .map_err(|_| AiError::new("fileUnavailable"))??; + let regions: Vec<_> = if let Some(regen_ids) = ®en_ids { + prepared + .regions + .into_iter() + .filter(|region| regen_ids.contains(®ion.id)) + .collect() + } else { + prepared.regions + }; + let prompt = build_conflict_prompt(&request.file_path, prepared.operation, ®ions); + validate_context_size( + prompt + .len() + .saturating_add(configuration.conflict_resolution_prompt.len()), + configuration.conflict_context_limit_kib, + )?; + emit_ai_progress( + app, + &request.operation_id, + "conflictResolution", + "contactingProvider", + ); + let mut budget = AiRequestBudget::new(); + let output_contract = AiOutputContract::JsonSchema { + name: "gitmun_conflict_resolution", + schema: serde_json::json!({ + "type": "object", + "properties": { + "regions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "replacement": {"type": "string"}, + "explanation": {"type": "string"} + }, + "required": ["id", "replacement", "explanation"], + "additionalProperties": false + } + } + }, + "required": ["regions"], + "additionalProperties": false + }), + }; + let (result, _, structured_output_mode) = run_provider_with_output( + runtime, + &configuration, + &api_key, + &configuration.conflict_resolution_prompt, + &prompt, + configuration.conflict_resolution_max_tokens, + AiTask::ConflictResolution, + &mut budget, + &output_contract, + cancellation, + ) + .await?; + let _ = state + .git_service + .update_structured_output_modes(runtime.structured_output_modes()); + if result.output_truncated { + return Err(AiError::new("outputTruncated").with_provider_response(result.metadata())); + } + let replacements = parse_conflict_replacements( + &result.text, + ®ions, + structured_output_mode.unwrap_or(AiStructuredOutputMode::PromptOnly), + ) + .map_err(|error| error.with_provider_response(result.metadata()))?; + let proposal_id = proposal_id(&prepared.original_bytes); + let proposals = regions + .iter() + .zip(&replacements) + .map(|(region, replacement)| AiConflictRegionProposal { + id: region.id.clone(), + original: region.original.clone(), + ours: region.ours.clone(), + theirs: region.theirs.clone(), + ancestor: region.ancestor.clone(), + proposed: replacement.replacement.clone(), + explanation: Some(replacement.explanation.clone()), + }) + .collect::>(); + let session_replacements = regions + .iter() + .zip(replacements) + .map(|(region, replacement)| crate::ai::ConflictReplacement { + id: region.id.clone(), + start: region.start, + end: region.end, + replacement: replacement.replacement, + }) + .collect(); + state.ai_extension.conflict_sessions.insert( + proposal_id.clone(), + crate::ai::ConflictSession::new( + prepared.repository, + request.file_path.clone(), + prepared.original_bytes, + prepared.unmerged_index, + session_replacements, + ), + )?; + emit_ai_progress(app, &request.operation_id, "conflictResolution", "complete"); + Ok(AiConflictProposalResult { + proposal_id, + file_path: request.file_path, + regions: proposals, + usage: result.usage, + request_id: result.request_id, + generation_id: result.generation_id, + routed_provider: result.routed_provider, + routed_model: result.routed_model, + }) +} + +#[tauri::command] +pub async fn resolve_conflict_with_ai( + mut request: ResolveConflictWithAiRequest, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let started_at = Instant::now(); + if request.operation_id.is_empty() { + request.operation_id = format!("conflict-{}", operation_nonce()); + } + let operation_id = request.operation_id.clone(); + let cancellation = state.ai_extension.operations.begin(&operation_id)?; + let result = tokio::select! { + _ = cancellation.cancelled() => Err(AiError::new("operationCancelled")), + result = tokio::time::timeout(AI_OPERATION_TIMEOUT, resolve_conflict_with_ai_inner(request, &state, &app, Some(cancellation.clone()))) => { + result.map_err(|_| AiError::new("timeout")).and_then(|result| result) + }, + }; + state.ai_extension.operations.finish(&operation_id); + record_conflict_usage(&state, "conflictResolution", started_at, &result); + result +} + +#[tauri::command] +pub async fn regenerate_ai_conflict_regions( + mut request: RegenerateAiConflictRegionsRequest, + state: tauri::State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + if request.region_ids.is_empty() { + return Err(AiError::new("noConflictRegionsSelected")); + } + let (session_clone, proposal_id) = + state + .ai_extension + .conflict_sessions + .mutate(&request.proposal_id, |s| { + if !s.applied_ids.is_empty() { + return Err(AiError::new("fileChanged")); + } + if request.region_ids.iter().any(|id| { + !s.replacements + .iter() + .any(|replacement| &replacement.id == id) + }) { + return Err(AiError::new("conflictRegionUnknown")); + } + Ok((s.clone(), request.proposal_id.clone())) + })?; + if request.operation_id.is_empty() { + request.operation_id = format!("conflict-{}", operation_nonce()); + } + let operation_id = request.operation_id.clone(); + let conflict_request = ResolveConflictWithAiRequest { + repo_path: session_clone.repository.to_string_lossy().to_string(), + file_path: session_clone.file_path.clone(), + operation_id: operation_id.clone(), + }; + let started_at = Instant::now(); + let cancellation = state.ai_extension.operations.begin(&operation_id)?; + let region_ids: HashSet = request.region_ids.iter().cloned().collect(); + let result = tokio::select! { + _ = cancellation.cancelled() => Err(AiError::new("operationCancelled")), + result = tokio::time::timeout(AI_OPERATION_TIMEOUT, resolve_conflict_with_ai_inner_filtered(conflict_request, &state, &app, Some(cancellation.clone()), Some(region_ids))) => { + result.map_err(|_| AiError::new("timeout")).and_then(|result| result) + }, + }; + state.ai_extension.operations.finish(&operation_id); + record_conflict_usage(&state, "conflictRegeneration", started_at, &result); + let mut result = result?; + let generated_session = state + .ai_extension + .conflict_sessions + .get(&result.proposal_id)?; + state + .ai_extension + .conflict_sessions + .mutate(&proposal_id, |s| { + for replacement in &mut s.replacements { + if request.region_ids.contains(&replacement.id) { + let generated = generated_session + .replacements + .iter() + .find(|generated| generated.id == replacement.id) + .ok_or_else(|| AiError::new("conflictRegionUnknown"))?; + replacement.replacement = generated.replacement.clone(); + } + } + Ok(()) + })?; + state + .ai_extension + .conflict_sessions + .remove(&result.proposal_id)?; + result.proposal_id = request.proposal_id; + result + .regions + .retain(|region| request.region_ids.contains(®ion.id)); + Ok(result) +} + +#[tauri::command] +pub fn get_ai_usage_history(state: tauri::State<'_, AppState>) -> Vec { + state.git_service.get_settings().extensions.ai.usage_history +} + +#[tauri::command] +pub fn clear_ai_usage_history(state: tauri::State<'_, AppState>) -> Result<(), AiError> { + state + .git_service + .clear_ai_usage_history() + .map(|_| ()) + .map_err(|_| AiError::new("configurationWriteFailed")) +} + +#[tauri::command] +pub async fn get_ai_conflict_context_preview( + request: ResolveConflictWithAiRequest, + state: tauri::State<'_, AppState>, +) -> Result { + let settings = state.git_service.get_settings(); + let mut configuration = state.ai_extension.environment.resolve(&settings)?; + let (_, exclusions) = repository_context_options(&state, &request.repo_path, &configuration); + if excluded_path(&request.file_path, &exclusions) { + return Err(AiError::new("sensitivePath")); + } + apply_repository_prompts( + &state, + &request.repo_path, + &mut configuration, + AiTask::ConflictResolution, + ) + .await?; + validate_effective_configuration(&configuration, true)?; + let maximum_bytes = configuration.conflict_context_limit_kib as usize * 1024; + let file_path = request.file_path.clone(); + let prepared = tauri::async_runtime::spawn_blocking(move || { + prepare_conflict(&request.repo_path, &request.file_path, maximum_bytes) + }) + .await + .map_err(|_| AiError::new("fileUnavailable"))??; + let context_bytes = build_conflict_prompt(&file_path, prepared.operation, &prepared.regions) + .len() + .saturating_add(configuration.conflict_resolution_prompt.len()); + Ok(AiContextPreview { + provider: configuration.provider, + destination_authority: configuration.destination_authority()?, + task: "conflictResolution", + files: vec![file_path], + context_size_kib: context_bytes.div_ceil(1024), + context_limit_kib: configuration.conflict_context_limit_kib, + includes_commit_history: false, + }) +} + +fn apply_conflict_session( + mut session: crate::ai::ConflictSession, +) -> Result<(crate::ai::ConflictSession, bool), AiError> { + let repository = session.repository.to_string_lossy().to_string(); + let path = safe_repository_file(&repository, &session.file_path)?; + let current = read_bounded_file(&path, MAX_COMMIT_TOTAL_CONTEXT_BYTES)?; + if md5::compute(¤t) != session.current_hash { + return Err(AiError::new("fileChanged")); + } + if unmerged_index_entries(&repository, &session.file_path)? != session.unmerged_index { + return Err(AiError::new("indexChanged")); + } + + let original = + String::from_utf8(session.original.clone()).map_err(|_| AiError::new("unsupportedFile"))?; + let mut resolved = original.clone(); + for replacement in session.replacements.iter().rev() { + if session.applied_ids.contains(&replacement.id) { + let replacement_text = + normalise_replacement_line_endings(&replacement.replacement, &original); + resolved.replace_range(replacement.start..replacement.end, &replacement_text); + } + } + write_atomically(&path, resolved.as_bytes())?; + + let marked_resolved = session.applied_ids.len() == session.replacements.len(); + if marked_resolved { + match stage_conflict_file(&repository, &session.file_path) { + Ok(resolved_index) => session.resolved_index = Some(resolved_index), + Err(_) => { + drop(restore_unmerged_index( + &repository, + &session.file_path, + &session.unmerged_index, + )); + drop(write_atomically(&path, ¤t)); + return Err(AiError::new("conflictStageFailed")); + } + } + } + session.current_hash = md5::compute(resolved.as_bytes()); + Ok((session, marked_resolved)) +} + +fn undo_conflict_session(session: &crate::ai::ConflictSession) -> Result<(), AiError> { + let repository = session.repository.to_string_lossy().to_string(); + let path = safe_repository_file(&repository, &session.file_path)?; + let current = read_bounded_file(&path, MAX_COMMIT_TOTAL_CONTEXT_BYTES)?; + if md5::compute(¤t) != session.current_hash { + return Err(AiError::new("fileChanged")); + } + + if let Some(resolved_index) = &session.resolved_index { + if file_index_entries(&repository, &session.file_path)? != *resolved_index { + return Err(AiError::new("indexChanged")); + } + write_atomically(&path, &session.original)?; + if restore_unmerged_index(&repository, &session.file_path, &session.unmerged_index).is_err() + { + drop(write_atomically(&path, ¤t)); + return Err(AiError::new("conflictUndoFailed")); + } + } else { + if unmerged_index_entries(&repository, &session.file_path)? != session.unmerged_index { + return Err(AiError::new("indexChanged")); + } + write_atomically(&path, &session.original)?; + } + Ok(()) +} + +#[tauri::command] +pub async fn apply_ai_conflict_proposal( + request: ApplyAiConflictProposalRequest, + state: tauri::State<'_, AppState>, +) -> Result { + let session = state + .ai_extension + .conflict_sessions + .mutate(&request.proposal_id, |s| { + if request.region_ids.iter().any(|id| { + !s.replacements + .iter() + .any(|replacement| &replacement.id == id) + }) { + return Err(AiError::new("conflictRegionUnknown")); + } + if request.region_ids.is_empty() { + return Err(AiError::new("noConflictRegionsSelected")); + } + let mut session = s.clone(); + session + .applied_ids + .extend(request.region_ids.iter().cloned()); + Ok(session) + })?; + let proposal_id = request.proposal_id; + let result = tauri::async_runtime::spawn_blocking(move || apply_conflict_session(session)) + .await + .map_err(|_| AiError::new("fileWriteFailed"))??; + let resolved_regions = result.0.applied_ids.len(); + let file_path = result.0.file_path.clone(); + state + .ai_extension + .conflict_sessions + .mutate(&proposal_id, |s| { + s.current_hash = result.0.current_hash; + s.resolved_index.clone_from(&result.0.resolved_index); + s.applied_ids = s + .applied_ids + .union(&result.0.applied_ids) + .cloned() + .collect(); + Ok(()) + })?; + Ok(AiConflictResolutionResult { + file_path, + resolved_regions, + marked_resolved: result.1, + }) +} + +#[tauri::command] +pub async fn undo_ai_conflict_proposal( + proposal_id: String, + state: tauri::State<'_, AppState>, +) -> Result { + let session = state.ai_extension.conflict_sessions.get(&proposal_id)?; + let file_path = session.file_path.clone(); + tauri::async_runtime::spawn_blocking(move || undo_conflict_session(&session)) + .await + .map_err(|_| AiError::new("fileWriteFailed"))??; + state.ai_extension.conflict_sessions.remove(&proposal_id)?; + Ok(AiConflictResolutionResult { + file_path, + resolved_regions: 0, + marked_resolved: false, + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiConflictBatchUndoResult { + pub undone: usize, + pub failed: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiConflictBatchUndoFailure { + pub proposal_id: String, + pub reason: String, +} + +#[tauri::command] +pub async fn undo_ai_conflict_batch( + proposal_ids: Vec, + state: tauri::State<'_, AppState>, +) -> Result { + let mut undone = 0; + let mut failed = Vec::new(); + for proposal_id in proposal_ids { + match state.ai_extension.conflict_sessions.get(&proposal_id) { + Ok(session) => { + match tauri::async_runtime::spawn_blocking(move || undo_conflict_session(&session)) + .await + { + Ok(Ok(())) => { + state.ai_extension.conflict_sessions.remove(&proposal_id)?; + undone += 1; + } + Ok(Err(error)) => failed.push(AiConflictBatchUndoFailure { + proposal_id, + reason: error.code.to_string(), + }), + Err(_) => failed.push(AiConflictBatchUndoFailure { + proposal_id, + reason: "fileWriteFailed".to_string(), + }), + } + } + Err(error) => failed.push(AiConflictBatchUndoFailure { + proposal_id, + reason: error.code.to_string(), + }), + } + } + Ok(AiConflictBatchUndoResult { undone, failed }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + + fn provider_configuration(provider: AiProvider, endpoint: String) -> EffectiveAiConfiguration { + EffectiveAiConfiguration { + enabled: true, + profile_id: "test".to_string(), + provider, + endpoint, + model: "test-model".to_string(), + api_style: AiApiStyle::ChatCompletions, + request_path: if provider == AiProvider::Claude { + "/messages".to_string() + } else { + "/chat/completions".to_string() + }, + models_path: "/models".to_string(), + auth_mode: AiAuthMode::Bearer, + auth_header: "Authorization".to_string(), + max_tokens_field: "max_completion_tokens".to_string(), + extra_headers: Default::default(), + azure_deployment: String::new(), + azure_api_version: String::new(), + reasoning_preference: AiReasoningPreference::Automatic, + effort_capability: AiEffortCapability::Unknown, + open_router: OpenRouterSettings::default(), + commit_context_limit_kib: 24, + conflict_context_limit_kib: 48, + commit_message_max_tokens: 512, + conflict_resolution_max_tokens: 4096, + commit_message_prompt: String::new(), + conflict_resolution_prompt: String::new(), + include_commit_history: true, + global_exclusions: Vec::new(), + sources: Default::default(), + environment_fields: Vec::new(), + environment_api_key: false, + } + } + + fn configuration_request(profile_id: Option<&str>) -> SaveAiConfigurationRequest { + SaveAiConfigurationRequest { + enabled: Some(true), + profile_id: profile_id.map(str::to_string), + profile_name: Some("Profile".to_string()), + provider: AiProvider::OpenAi, + endpoint: "https://api.openai.com/v1".to_string(), + model: "test-model".to_string(), + reasoning_preference: AiReasoningPreference::Automatic, + api_style: None, + request_path: None, + models_path: None, + auth_mode: None, + auth_header: None, + max_tokens_field: None, + azure_deployment: None, + azure_api_version: None, + open_router: None, + api_key: None, + } + } + + #[test] + fn commit_command_contract_uses_camel_case_json() { + let request: GenerateAiCommitMessagesRequest = serde_json::from_value(json!({ + "repoPath": "/tmp/repository", + "subjectLimit": 72, + "operationId": "commit-test", + "candidateCount": 2, + "commitType": "feat", + "issueKey": "AI-19", + "existingMessage": "Existing subject" + })) + .unwrap(); + + assert_eq!(request.repo_path, "/tmp/repository"); + assert_eq!(request.subject_limit, 72); + assert_eq!(request.candidate_count, 2); + assert_eq!(request.operation_id, "commit-test"); + assert_eq!(request.issue_key, "AI-19"); + + let response = serde_json::to_value(AiCommitMessageResult { + message: "feat: add AI coverage".to_string(), + usage: AiUsage::default(), + request_id: Some("request-1".to_string()), + generation_id: None, + routed_provider: Some("provider".to_string()), + routed_model: Some("model".to_string()), + }) + .unwrap(); + assert_eq!(response["requestId"], "request-1"); + assert_eq!(response["routedProvider"], "provider"); + assert!(response.get("request_id").is_none()); + } + + #[test] + fn writing_command_contract_uses_camel_case_json() { + let request: GenerateAiWritingRequest = serde_json::from_value(json!({ + "repoPath": "/tmp/repository", + "task": "BranchSummary", + "baseReference": "main", + "additionalInstruction": "Keep it concise", + "operationId": "writing-test" + })) + .unwrap(); + + assert_eq!(request.repo_path, "/tmp/repository"); + assert_eq!(request.base_reference, "main"); + assert_eq!(request.additional_instruction, "Keep it concise"); + assert_eq!(request.operation_id, "writing-test"); + + let response = serde_json::to_value(AiWritingResult { + content: "Summary".to_string(), + usage: AiUsage::default(), + request_id: Some("request-2".to_string()), + generation_id: Some("generation-2".to_string()), + routed_provider: None, + routed_model: Some("model".to_string()), + }) + .unwrap(); + assert_eq!(response["content"], "Summary"); + assert_eq!(response["generationId"], "generation-2"); + assert_eq!(response["routedModel"], "model"); + } + + #[test] + fn conflict_command_contract_uses_camel_case_json() { + let request: ResolveConflictWithAiRequest = serde_json::from_value(json!({ + "repoPath": "/tmp/repository", + "filePath": "src/conflicted.ts", + "operationId": "conflict-test" + })) + .unwrap(); + + assert_eq!(request.repo_path, "/tmp/repository"); + assert_eq!(request.file_path, "src/conflicted.ts"); + assert_eq!(request.operation_id, "conflict-test"); + + let response = serde_json::to_value(AiConflictProposalResult { + proposal_id: "proposal-1".to_string(), + file_path: request.file_path, + regions: Vec::new(), + usage: AiUsage::default(), + request_id: Some("request-3".to_string()), + generation_id: None, + routed_provider: None, + routed_model: Some("model".to_string()), + }) + .unwrap(); + assert_eq!(response["proposalId"], "proposal-1"); + assert_eq!(response["filePath"], "src/conflicted.ts"); + assert_eq!(response["routedModel"], "model"); + assert!(response.get("proposal_id").is_none()); + } + + #[test] + fn a_request_without_a_profile_id_creates_a_new_profile() { + let mut settings = AiExtensionSettings::default(); + settings.selected_profile_id = "existing".to_string(); + settings.profiles.push(AiProfile { + id: "existing".to_string(), + name: "Existing".to_string(), + ..AiProfile::default() + }); + + let request = configuration_request(None); + let created = profile_from_request(&request, requested_profile(&request, &settings)); + assert_ne!(created.id, "existing"); + settings.profiles.push(created); + assert_eq!(settings.profiles.len(), 2); + assert_eq!(settings.profiles[0].name, "Existing"); + assert_eq!( + requested_profile(&configuration_request(Some("existing")), &settings) + .map(|profile| profile.id.as_str()), + Some("existing") + ); + } + + #[test] + fn allows_openrouter_oauth_only_for_the_official_https_origin() { + assert!(openrouter_oauth_endpoint_allowed( + &Url::parse("https://openrouter.ai/api/v1").unwrap() + )); + assert!(!openrouter_oauth_endpoint_allowed( + &Url::parse("http://openrouter.ai/api/v1").unwrap() + )); + assert!(!openrouter_oauth_endpoint_allowed( + &Url::parse("https://openrouter.ai.example/api/v1").unwrap() + )); + assert!(!openrouter_oauth_endpoint_allowed( + &Url::parse("https://example.com/api/v1").unwrap() + )); + } + + #[test] + fn repository_commit_defaults_are_trimmed_and_mirrored() { + let mut policy = AiRepositoryPolicy { + commit_message_mode: Some(AiCommitMessageMode::ConventionalCommits), + default_commit_type: " docs ".to_string(), + default_commit_scope: " ai ".to_string(), + default_language: " British English ".to_string(), + ..AiRepositoryPolicy::default() + }; + + normalise_repository_policy(&mut policy).unwrap(); + + assert!(policy.conventional_commits); + assert_eq!(policy.default_commit_type, "docs"); + assert_eq!(policy.default_commit_scope, "ai"); + assert_eq!(policy.default_language, "British English"); + } + + #[test] + fn repository_commit_defaults_reject_invalid_controls() { + let mut long_type = AiRepositoryPolicy { + default_commit_type: "x".repeat(33), + ..AiRepositoryPolicy::default() + }; + assert_eq!( + normalise_repository_policy(&mut long_type) + .unwrap_err() + .code, + "invalidRepositoryPolicy" + ); + + let mut control_scope = AiRepositoryPolicy { + default_commit_scope: "ai\nsettings".to_string(), + ..AiRepositoryPolicy::default() + }; + assert_eq!( + normalise_repository_policy(&mut control_scope) + .unwrap_err() + .code, + "invalidRepositoryPolicy" + ); + } + + fn run_git(repo: &Path, arguments: &[&str]) { + let status = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(arguments) + .status() + .unwrap(); + assert!(status.success()); + } + + fn read_http_request(stream: &mut TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(str::trim) + .map(str::to_string) + }) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + } + String::from_utf8(request).unwrap() + } + + fn write_http_response(stream: &mut TcpStream, status: &str, body: &str) { + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nX-Request-Id: request-1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + } + + fn mock_responses(responses: Vec<(String, String)>) -> (String, mpsc::Receiver>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}/v1", listener.local_addr().unwrap()); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut stream, _) = listener.accept().unwrap(); + requests.push(read_http_request(&mut stream)); + write_http_response(&mut stream, &status, &body); + } + drop(sender.send(requests)); + }); + (endpoint, receiver) + } + + #[test] + fn blocks_sensitive_paths() { + for path in [ + ".env", + "config/.env.local", + ".ssh/config", + "keys/id_ed25519.pub", + "certs/client.pem", + ".dockercfg", + ".docker/config.json", + ".git-credentials", + "git-credentials", + ".kube/config", + "terraform/prod.tfvars", + "secrets/prod.tfvars.json", + "terraform/prod.tfstate", + "terraform/prod.tfstate.backup", + ".terraform.lock.hcl", + "credentials", + "credentials.toml", + "credentials.json", + "secrets.yml", + "secrets.yaml", + "secrets.json", + ".kubeconfig", + "kubeconfig", + "keys/service.keytab", + "keys/secret.age", + "keys/signing.pgp", + "client.gpg", + ".htpasswd", + ".k5login", + "store/app.jks", + "store/app.keystore", + "store/app.truststore", + ".s3cfg", + ".pgpass", + "pg_service.conf", + ".my.cnf", + ".azurerc", + ".boto", + ".token", + "api.token", + "config/connectionStrings.config", + ] { + assert!(is_sensitive_path(path), "{path}"); + } + assert!(!is_sensitive_path("src/environment.ts")); + assert!(!is_sensitive_path("src/credentials.ts")); + assert!(!is_sensitive_path(".htpasswd.txt")); + assert!(!is_sensitive_path("terraform/modules/main.tf")); + } + + #[test] + fn excluded_path_matches_user_globs() { + let exclusions = vec!["vendor/**".to_string(), "secrets/*".to_string()]; + assert!(excluded_path("vendor/lib/a.rs", &exclusions)); + assert!(excluded_path("secrets/token.txt", &exclusions)); + assert!(!excluded_path("src/main.rs", &exclusions)); + } + + #[test] + fn parses_multiple_conflict_regions() { + let text = "before\n<<<<<<< HEAD\none\n=======\ntwo\n>>>>>>> branch\nmiddle\n<<<<<<< HEAD\nthree\n||||||| base\nbase\n=======\nfour\n>>>>>>> branch\nafter\n"; + let regions = parse_conflict_regions(text).unwrap(); + assert_eq!(regions.len(), 2); + assert!(regions[0].prompt.contains("before")); + assert!(regions[1].prompt.contains("after")); + } + + #[test] + fn stages_a_complete_proposal_and_undo_restores_the_unmerged_index() { + let repository = tempfile::tempdir().unwrap(); + run_git(repository.path(), &["init", "-b", "main"]); + run_git( + repository.path(), + &["config", "user.email", "test@example.com"], + ); + run_git(repository.path(), &["config", "user.name", "Test"]); + run_git(repository.path(), &["config", "commit.gpgsign", "false"]); + + let middle = (0..20) + .map(|index| format!("unchanged {index}\n")) + .collect::(); + std::fs::write( + repository.path().join("conflicted.txt"), + format!("first base\n{middle}second base\n"), + ) + .unwrap(); + run_git(repository.path(), &["add", "conflicted.txt"]); + run_git(repository.path(), &["commit", "-m", "base"]); + run_git(repository.path(), &["checkout", "-b", "incoming"]); + std::fs::write( + repository.path().join("conflicted.txt"), + format!("first incoming\n{middle}second incoming\n"), + ) + .unwrap(); + run_git(repository.path(), &["commit", "-am", "incoming"]); + run_git(repository.path(), &["checkout", "main"]); + std::fs::write( + repository.path().join("conflicted.txt"), + format!("first current\n{middle}second current\n"), + ) + .unwrap(); + run_git(repository.path(), &["commit", "-am", "current"]); + let merge_status = std::process::Command::new("git") + .arg("-C") + .arg(repository.path()) + .args(["merge", "incoming"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(!merge_status.success()); + + let repository_path = repository.path().to_string_lossy().to_string(); + let prepared = prepare_conflict( + &repository_path, + "conflicted.txt", + MAX_COMMIT_TOTAL_CONTEXT_BYTES, + ) + .unwrap(); + assert_eq!(prepared.regions.len(), 2); + let replacements = prepared + .regions + .iter() + .enumerate() + .map(|(index, region)| crate::ai::ConflictReplacement { + id: region.id.clone(), + start: region.start, + end: region.end, + replacement: format!("resolved {index}\n"), + }) + .collect::>(); + let original = prepared.original_bytes.clone(); + let original_index = prepared.unmerged_index.clone(); + let mut session = crate::ai::ConflictSession::new( + prepared.repository, + "conflicted.txt".to_string(), + prepared.original_bytes, + prepared.unmerged_index, + replacements, + ); + session + .applied_ids + .insert(session.replacements[0].id.clone()); + + let (mut session, marked_resolved) = apply_conflict_session(session).unwrap(); + assert!(!marked_resolved); + assert_eq!( + unmerged_index_entries(&repository_path, "conflicted.txt").unwrap(), + original_index + ); + + session + .applied_ids + .insert(session.replacements[1].id.clone()); + let (session, marked_resolved) = apply_conflict_session(session).unwrap(); + assert!(marked_resolved); + assert_eq!( + unmerged_index_entries(&repository_path, "conflicted.txt") + .unwrap_err() + .code, + "noUnmergedIndex" + ); + + undo_conflict_session(&session).unwrap(); + assert_eq!( + std::fs::read(repository.path().join("conflicted.txt")).unwrap(), + original + ); + assert_eq!( + unmerged_index_entries(&repository_path, "conflicted.txt").unwrap(), + original_index + ); + } + + #[test] + fn rejects_malformed_conflict_regions() { + let error = parse_conflict_regions("<<<<<<< HEAD\none\n>>>>>>> branch\n").unwrap_err(); + assert_eq!(error.code, "malformedConflict"); + } + + #[test] + fn parses_only_complete_structured_conflict_replacements() { + let regions = + parse_conflict_regions("<<<<<<< HEAD\none\n=======\ntwo\n>>>>>>> branch\n").unwrap(); + let response = json!({ + "regions": [{ + "id": regions[0].id, + "replacement": "resolved\n", + "explanation": "Kept both changes." + }] + }) + .to_string(); + let replacements = + parse_conflict_replacements(&response, ®ions, AiStructuredOutputMode::JsonSchema) + .unwrap(); + + assert_eq!(replacements[0].replacement, "resolved\n"); + assert_eq!(replacements[0].explanation, "Kept both changes."); + assert_eq!( + parse_conflict_replacements( + r#"{"regions":[]}"#, + ®ions, + AiStructuredOutputMode::JsonSchema, + ) + .unwrap_err() + .code, + "missingConflictRegions" + ); + } + + #[test] + fn prompt_only_conflict_output_accepts_one_outer_json_fence() { + let regions = + parse_conflict_regions("<<<<<<< HEAD\none\n=======\ntwo\n>>>>>>> branch\n").unwrap(); + let response = format!( + "```json\n{{\"regions\":[{{\"id\":\"{}\",\"replacement\":\"resolved\",\"explanation\":\"Kept both.\"}}]}}\n```", + regions[0].id + ); + + assert!( + parse_conflict_replacements(&response, ®ions, AiStructuredOutputMode::PromptOnly,) + .is_ok() + ); + assert_eq!( + parse_conflict_replacements(&response, ®ions, AiStructuredOutputMode::JsonSchema,) + .unwrap_err() + .code, + "malformedStructuredOutput" + ); + } + + #[test] + fn conflict_output_reports_structural_validation_failures() { + let regions = + parse_conflict_regions("<<<<<<< HEAD\none\n=======\ntwo\n>>>>>>> branch\n").unwrap(); + let id = ®ions[0].id; + for (response, expected_code) in [ + ( + format!( + r#"{{"regions":[{{"id":"{id}","replacement":"one","explanation":"first"}},{{"id":"{id}","replacement":"two","explanation":"second"}}]}}"# + ), + "duplicateConflictRegion", + ), + ( + r#"{"regions":[{"id":"unknown","replacement":"resolved","explanation":"reason"}]}"# + .to_string(), + "unknownConflictRegion", + ), + ( + format!( + r#"{{"regions":[{{"id":"{id}","replacement":"<<<<<<< HEAD\nstill conflicted","explanation":"reason"}}]}}"# + ), + "unresolvedConflictMarkers", + ), + ( + format!(r#"{{"regions":[{{"id":"{id}","replacement":"resolved"}}]}}"#), + "malformedStructuredOutput", + ), + ( + format!( + r#"Commentary before JSON {{"regions":[{{"id":"{id}","replacement":"resolved","explanation":"reason"}}]}}"# + ), + "malformedStructuredOutput", + ), + ] { + assert_eq!( + parse_conflict_replacements( + &response, + ®ions, + AiStructuredOutputMode::PromptOnly, + ) + .unwrap_err() + .code, + expected_code + ); + } + } + + #[test] + fn commit_messages_reject_markdown_fences() { + assert!(validate_commit_message("```text\nmessage\n```".to_string(), 72).is_err()); + assert_eq!( + validate_commit_message("subject\n\nbody".to_string(), 72).unwrap(), + "subject\n\nbody" + ); + assert!(validate_commit_message("x".repeat(73), 72).is_err()); + } + + #[test] + fn commit_context_contains_only_the_staged_version() { + let repository = tempfile::tempdir().unwrap(); + run_git(repository.path(), &["init", "-q"]); + let path = repository.path().join("notes.txt"); + std::fs::write(&path, "staged line\n").unwrap(); + run_git(repository.path(), &["add", "notes.txt"]); + std::fs::write(&path, "staged line\nunstaged secret\n").unwrap(); + + let context = render_commit_context( + &build_commit_context( + repository.path().to_str().unwrap(), + 72, + false, + &[], + AiCommitWorkflow::Normal, + "", + ) + .unwrap(), + ); + + assert!(context.contains("staged line")); + assert!(!context.contains("unstaged secret")); + } + + #[test] + fn commit_context_includes_recent_commit_messages_for_style() { + let repository = tempfile::tempdir().unwrap(); + run_git(repository.path(), &["init", "-q"]); + std::fs::write(repository.path().join("notes.txt"), "first\n").unwrap(); + run_git(repository.path(), &["add", "notes.txt"]); + run_git( + repository.path(), + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "feat: record baseline", + ], + ); + std::fs::write(repository.path().join("notes.txt"), "first\nsecond\n").unwrap(); + run_git(repository.path(), &["add", "notes.txt"]); + + let context = render_commit_context( + &build_commit_context( + repository.path().to_str().unwrap(), + 72, + true, + &[], + AiCommitWorkflow::Normal, + "", + ) + .unwrap(), + ); + + assert!(context.contains("Recent commit messages for style:")); + assert!(context.contains("feat: record baseline")); + } + + #[test] + fn commit_context_requires_and_describes_the_active_git_workflow() { + let repository = tempfile::tempdir().unwrap(); + run_git(repository.path(), &["init", "-q"]); + std::fs::write(repository.path().join("notes.txt"), "first\n").unwrap(); + run_git(repository.path(), &["add", "notes.txt"]); + run_git( + repository.path(), + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "baseline", + ], + ); + std::fs::write(repository.path().join("notes.txt"), "first\nmerged\n").unwrap(); + run_git(repository.path(), &["add", "notes.txt"]); + let git_directory = String::from_utf8( + git_output( + repository.path().to_str().unwrap(), + &["rev-parse", "--absolute-git-dir"], + MAX_GIT_METADATA_OUTPUT_BYTES, + ) + .unwrap(), + ) + .unwrap(); + std::fs::write(Path::new(git_directory.trim()).join("MERGE_HEAD"), "test\n").unwrap(); + + let normal_error = build_commit_context( + repository.path().to_str().unwrap(), + 72, + false, + &[], + AiCommitWorkflow::Normal, + "", + ) + .unwrap_err(); + assert_eq!(normal_error.code, "operationInProgress"); + + let context = build_commit_context( + repository.path().to_str().unwrap(), + 72, + false, + &[], + AiCommitWorkflow::Merge, + "Merge feature branch", + ) + .unwrap(); + let rendered = render_commit_context(&context); + assert!(rendered.contains("Workflow: merge commit")); + assert!(rendered.contains("Merge feature branch")); + } + + #[test] + fn writing_contexts_cover_staged_and_branch_changes_without_writing() { + let repository = tempfile::tempdir().unwrap(); + run_git(repository.path(), &["init", "-q"]); + std::fs::write(repository.path().join("notes.txt"), "baseline\n").unwrap(); + run_git(repository.path(), &["add", "notes.txt"]); + + let staged = writing_context( + repository.path().to_str().unwrap(), + AiWritingTask::StagedReview, + "", + false, + &[], + ) + .unwrap(); + assert_eq!(staged.files, vec!["notes.txt"]); + assert!(staged.content.contains("baseline")); + + run_git( + repository.path(), + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "baseline", + ], + ); + let base = String::from_utf8( + git_output( + repository.path().to_str().unwrap(), + &["rev-parse", "HEAD"], + MAX_GIT_METADATA_OUTPUT_BYTES, + ) + .unwrap(), + ) + .unwrap(); + std::fs::write( + repository.path().join("notes.txt"), + "baseline\nbranch change\n", + ) + .unwrap(); + run_git(repository.path(), &["add", "notes.txt"]); + run_git( + repository.path(), + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + "record branch change", + ], + ); + + let branch = writing_context( + repository.path().to_str().unwrap(), + AiWritingTask::BranchSummary, + base.trim(), + true, + &[], + ) + .unwrap(); + assert_eq!(branch.files, vec!["notes.txt"]); + assert!(branch.content.contains("record branch change")); + assert!(branch.content.contains("branch change")); + } + + #[test] + fn commit_context_refuses_sensitive_and_oversized_changes() { + let sensitive_repository = tempfile::tempdir().unwrap(); + run_git(sensitive_repository.path(), &["init", "-q"]); + std::fs::write(sensitive_repository.path().join(".env"), "TOKEN=value\n").unwrap(); + run_git(sensitive_repository.path(), &["add", ".env"]); + let error = build_commit_context( + sensitive_repository.path().to_str().unwrap(), + 72, + false, + &[], + AiCommitWorkflow::Normal, + "", + ) + .unwrap_err(); + assert_eq!(error.code, "sensitivePath"); + + let large_repository = tempfile::tempdir().unwrap(); + run_git(large_repository.path(), &["init", "-q"]); + std::fs::write( + large_repository.path().join("large.txt"), + "x".repeat(MAX_COMMIT_TOTAL_CONTEXT_BYTES + 1), + ) + .unwrap(); + run_git(large_repository.path(), &["add", "large.txt"]); + let error = build_commit_context( + large_repository.path().to_str().unwrap(), + 72, + false, + &[], + AiCommitWorkflow::Normal, + "", + ) + .unwrap_err(); + assert_eq!(error.code, "contextTooLarge"); + assert_eq!(error.context_size_kib, None); + } + + #[test] + fn context_size_limit_is_inclusive_and_reports_rounded_size() { + assert!(validate_context_size(8 * 1024, 8).is_ok()); + + let error = validate_context_size(8 * 1024 + 1, 8).unwrap_err(); + assert_eq!(error.code, "contextTooLarge"); + assert_eq!(error.context_size_kib, Some(9)); + assert_eq!(error.context_limit_kib, Some(8)); + } + + #[test] + fn splits_commit_context_without_losing_unicode_text() { + let maximum_bytes = 5 * 1024; + let text = format!("{}\n{}", "a".repeat(maximum_bytes), "£".repeat(20)); + let chunks = split_text(&text, maximum_bytes); + + assert!(chunks.len() > 1); + assert!(chunks.iter().all(|chunk| chunk.len() <= maximum_bytes)); + assert_eq!(chunks.concat(), text); + } + + #[test] + fn repeats_commit_requirements_after_the_supplied_context() { + let context = CommitContext { + branch: "main".to_string(), + workflow: AiCommitWorkflow::Normal, + existing_message: String::new(), + subject_limit: 72, + path_list: "M\tsrc/main.rs".to_string(), + recent_messages: "feat: existing style".to_string(), + diff: "diff --git a/src/main.rs b/src/main.rs".to_string(), + staged_snapshot: md5::compute("snapshot"), + }; + + let prompt = render_commit_context(&context); + + assert!(prompt.ends_with( + "Now return only the commit message. Keep its subject to no more than 72 characters. Do not review or explain the changes." + )); + } + + #[test] + fn generates_commit_messages_from_bounded_diff_summaries() { + let summary_response = |text: &str, finish_reason: &str| { + json!({ + "choices": [{"message": {"content": text}, "finish_reason": finish_reason}], + "usage": {"prompt_tokens": 10, "completion_tokens": 3} + }) + .to_string() + }; + let final_response = json!({ + "choices": [{ + "message": {"content": "feat: summarise staged changes"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 20, "completion_tokens": 5} + }) + .to_string(); + let (endpoint, requests) = mock_responses(vec![ + ( + "200 OK".to_string(), + summary_response("Changed the first part.", "stop"), + ), + ( + "200 OK".to_string(), + summary_response("Changed the second part.", "stop"), + ), + ("200 OK".to_string(), final_response), + ]); + let mut configuration = provider_configuration(AiProvider::OpenAiCompatible, endpoint); + configuration.commit_context_limit_kib = 8; + let request_context_bytes = commit_request_context_bytes(&configuration); + let summary_max_tokens = commit_summary_max_tokens(&configuration); + let context = CommitContext { + branch: "main".to_string(), + workflow: AiCommitWorkflow::Normal, + existing_message: String::new(), + subject_limit: 72, + path_list: "M\tsrc/main.rs".to_string(), + recent_messages: "feat: existing style".to_string(), + diff: "x".repeat(request_context_bytes + 1), + staged_snapshot: md5::compute("snapshot"), + }; + let runtime = crate::ai::AiRuntime::new().unwrap(); + let mut budget = AiRequestBudget::new(); + + let result = tauri::async_runtime::block_on(generate_commit_message_from_context( + &runtime, + configuration, + "secret", + context, + "Custom commit prompt", + &mut budget, + None, + )) + .unwrap(); + + assert_eq!(result.message, "feat: summarise staged changes"); + assert_eq!(result.usage.input_tokens, Some(40)); + assert_eq!(result.usage.output_tokens, Some(11)); + let requests = requests.recv().unwrap(); + assert_eq!(requests.len(), 3); + let bodies = requests + .iter() + .map(|request| { + serde_json::from_str::(request.split("\r\n\r\n").nth(1).unwrap()).unwrap() + }) + .collect::>(); + assert_eq!( + bodies[0] + .pointer("/messages/0/content") + .and_then(Value::as_str), + Some(COMMIT_SUMMARY_PROMPT) + ); + assert_eq!(bodies[0]["max_completion_tokens"], summary_max_tokens); + assert_eq!(bodies[1]["max_completion_tokens"], summary_max_tokens); + assert_eq!( + bodies[2] + .pointer("/messages/0/content") + .and_then(Value::as_str), + Some("Custom commit prompt") + ); + let final_context = bodies[2] + .pointer("/messages/1/content") + .and_then(Value::as_str) + .unwrap(); + assert!(final_context.contains("Changed the first part.")); + assert!(final_context.contains("Changed the second part.")); + assert!(final_context.ends_with( + "Now return only the commit message. Keep its subject to no more than 72 characters. Do not review or explain the changes." + )); + assert!(final_context.len() <= request_context_bytes); + } + + #[test] + fn sends_the_full_diff_when_the_configured_request_limit_allows_it() { + let response = json!({ + "choices": [{ + "message": {"content": "feat: use configured request limit"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 2000, "completion_tokens": 7} + }); + let (endpoint, requests) = + mock_responses(vec![("200 OK".to_string(), response.to_string())]); + let configuration = provider_configuration(AiProvider::OpenAiCompatible, endpoint); + let diff = "x".repeat(9 * 1024); + let context = CommitContext { + branch: "main".to_string(), + workflow: AiCommitWorkflow::Normal, + existing_message: String::new(), + subject_limit: 72, + path_list: "M\tsrc/main.rs".to_string(), + recent_messages: "feat: existing style".to_string(), + diff: diff.clone(), + staged_snapshot: md5::compute("snapshot"), + }; + let runtime = crate::ai::AiRuntime::new().unwrap(); + let mut budget = AiRequestBudget::new(); + + let result = tauri::async_runtime::block_on(generate_commit_message_from_context( + &runtime, + configuration, + "secret", + context, + "Custom commit prompt", + &mut budget, + None, + )) + .unwrap(); + + assert_eq!(result.message, "feat: use configured request limit"); + let requests = requests.recv().unwrap(); + assert_eq!(requests.len(), 1); + let body: Value = + serde_json::from_str(requests[0].split("\r\n\r\n").nth(1).unwrap()).unwrap(); + let prompt = body + .pointer("/messages/1/content") + .and_then(Value::as_str) + .unwrap(); + assert!(prompt.contains(&diff)); + assert!(!prompt.contains("Staged change summaries:")); + } +} diff --git a/src-tauri/src/ai/configuration.rs b/src-tauri/src/ai/configuration.rs new file mode 100644 index 0000000..9a6d47a --- /dev/null +++ b/src-tauri/src/ai/configuration.rs @@ -0,0 +1,1095 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::Path; + +use reqwest::header::{HeaderName, HeaderValue}; +use serde::Serialize; +use serde_json::Value; +use url::{Host, Url}; + +use crate::git::types::Settings; + +use super::AiError; +use super::providers::ProviderRegistry; +use super::types::{ + AiApiStyle, AiAuthMode, AiEffortCapability, AiProfile, AiProvider, AiReasoningPreference, + OpenRouterPrivacy, +}; + +const MAX_PROMPT_FILE_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub enum AiConfigurationSource { + Environment, + StoredProfile, + ProviderDefault, +} + +#[derive(Debug, Clone)] +pub(crate) struct EffectiveAiConfiguration { + pub enabled: bool, + pub profile_id: String, + pub provider: AiProvider, + pub endpoint: String, + pub model: String, + pub api_style: AiApiStyle, + pub request_path: String, + pub models_path: String, + pub auth_mode: AiAuthMode, + pub auth_header: String, + pub max_tokens_field: String, + pub extra_headers: BTreeMap, + pub azure_deployment: String, + pub azure_api_version: String, + pub reasoning_preference: AiReasoningPreference, + pub effort_capability: AiEffortCapability, + pub open_router: super::types::OpenRouterSettings, + pub commit_context_limit_kib: u32, + pub conflict_context_limit_kib: u32, + pub commit_message_max_tokens: u32, + pub conflict_resolution_max_tokens: u32, + pub commit_message_prompt: String, + pub conflict_resolution_prompt: String, + pub include_commit_history: bool, + pub global_exclusions: Vec, + pub sources: BTreeMap, + pub environment_fields: Vec, + pub environment_api_key: bool, +} + +impl EffectiveAiConfiguration { + pub fn endpoint_url(&self) -> Result { + validate_endpoint(&self.endpoint) + } + + pub fn endpoint_is_loopback(&self) -> bool { + self.endpoint_url().is_ok_and(|url| is_loopback(&url)) + } + + pub fn credential_scope(&self) -> Result { + let url = self.endpoint_url()?; + let authority = url + .host_str() + .map(|host| match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }) + .ok_or_else(|| AiError::new("endpointInvalid"))?; + Ok(format!( + "{}:{:?}:{authority}", + self.profile_id, self.provider + )) + } + + pub fn destination_authority(&self) -> Result { + let url = self.endpoint_url()?; + let host = url + .host_str() + .ok_or_else(|| AiError::new("endpointInvalid"))?; + Ok(match url.port() { + Some(port) => format!("{}://{host}:{port}", url.scheme()), + None => format!("{}://{host}", url.scheme()), + }) + } + + pub fn consent_key(&self) -> Result { + Ok(format!( + "{:?}:{}", + self.provider, + self.destination_authority()? + )) + } +} + +#[derive(Default)] +struct EnvironmentValues { + enabled: Option, + provider: Option, + endpoint: Option, + model: Option, + api_key: Option, + reasoning: Option, + api_style: Option, + request_path: Option, + models_path: Option, + auth_mode: Option, + auth_header: Option, + max_tokens_field: Option, + extra_headers: Option>, + azure_deployment: Option, + azure_api_version: Option, + openrouter_privacy: Option, + openrouter_allow_fallbacks: Option, + openrouter_require_parameters: Option, + openrouter_max_prompt_price: Option, + openrouter_max_completion_price: Option, + commit_context_limit_kib: Option, + conflict_context_limit_kib: Option, + commit_max_tokens: Option, + conflict_max_tokens: Option, + commit_prompt: Option, + conflict_prompt: Option, + include_commit_history: Option, + standard_keys: BTreeMap, + bedrock_iam_credentials: Option, + standard_endpoints: BTreeMap, +} + +pub(crate) struct AiLaunchOverrides { + values: EnvironmentValues, + invalid_variable: Option, +} + +impl AiLaunchOverrides { + pub fn from_process() -> Self { + Self::from_values(std::env::vars_os()) + } + + fn from_values(values: impl IntoIterator) -> Self { + let environment = values + .into_iter() + .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) + .collect::>(); + let mut overrides = Self { + values: EnvironmentValues::default(), + invalid_variable: None, + }; + overrides.parse(&environment); + overrides + } + + fn parse(&mut self, environment: &BTreeMap) { + self.values.enabled = self.parse_value(environment, "GITMUN_AI_ENABLED", parse_bool); + self.values.provider = self.parse_value(environment, "GITMUN_AI_PROVIDER", parse_provider); + self.values.endpoint = self.string_value(environment, "GITMUN_AI_ENDPOINT"); + self.values.model = self.string_value(environment, "GITMUN_AI_MODEL"); + self.values.api_key = self.secret_value(environment, "GITMUN_AI_API_KEY"); + self.values.reasoning = + self.parse_value(environment, "GITMUN_AI_REASONING", parse_reasoning); + self.values.api_style = + self.parse_value(environment, "GITMUN_AI_API_STYLE", parse_api_style); + self.values.request_path = self.path_value(environment, "GITMUN_AI_REQUEST_PATH"); + self.values.models_path = self.path_value(environment, "GITMUN_AI_MODELS_PATH"); + self.values.auth_mode = + self.parse_value(environment, "GITMUN_AI_AUTH_MODE", parse_auth_mode); + self.values.auth_header = self.header_name_value(environment, "GITMUN_AI_AUTH_HEADER"); + self.values.max_tokens_field = + self.json_field_value(environment, "GITMUN_AI_MAX_TOKENS_FIELD"); + self.values.extra_headers = + self.extra_headers_value(environment, "GITMUN_AI_EXTRA_HEADERS_JSON"); + self.values.azure_deployment = self.string_value(environment, "GITMUN_AI_AZURE_DEPLOYMENT"); + self.values.azure_api_version = + self.string_value(environment, "GITMUN_AI_AZURE_API_VERSION"); + self.values.openrouter_privacy = self.parse_value( + environment, + "GITMUN_AI_OPENROUTER_PRIVACY", + parse_openrouter_privacy, + ); + self.values.openrouter_allow_fallbacks = self.parse_value( + environment, + "GITMUN_AI_OPENROUTER_ALLOW_FALLBACKS", + parse_bool, + ); + self.values.openrouter_require_parameters = self.parse_value( + environment, + "GITMUN_AI_OPENROUTER_REQUIRE_PARAMETERS", + parse_bool, + ); + self.values.openrouter_max_prompt_price = + self.price_value(environment, "GITMUN_AI_OPENROUTER_MAX_PROMPT_PRICE"); + self.values.openrouter_max_completion_price = + self.price_value(environment, "GITMUN_AI_OPENROUTER_MAX_COMPLETION_PRICE"); + self.values.commit_context_limit_kib = self.parse_value( + environment, + "GITMUN_AI_COMMIT_CONTEXT_LIMIT_KIB", + parse_context_limit, + ); + self.values.conflict_context_limit_kib = self.parse_value( + environment, + "GITMUN_AI_CONFLICT_CONTEXT_LIMIT_KIB", + parse_context_limit, + ); + self.values.commit_max_tokens = self.parse_value( + environment, + "GITMUN_AI_COMMIT_MAX_TOKENS", + parse_output_tokens, + ); + self.values.conflict_max_tokens = self.parse_value( + environment, + "GITMUN_AI_CONFLICT_MAX_TOKENS", + parse_output_tokens, + ); + self.values.commit_prompt = + self.prompt_file_value(environment, "GITMUN_AI_COMMIT_PROMPT_FILE"); + self.values.conflict_prompt = + self.prompt_file_value(environment, "GITMUN_AI_CONFLICT_PROMPT_FILE"); + self.values.include_commit_history = + self.parse_value(environment, "GITMUN_AI_INCLUDE_COMMIT_HISTORY", parse_bool); + + for (provider, variable) in [ + (AiProvider::OpenAi, "OPENAI_API_KEY"), + (AiProvider::Claude, "ANTHROPIC_API_KEY"), + (AiProvider::Bedrock, "AWS_BEARER_TOKEN_BEDROCK"), + (AiProvider::Mistral, "MISTRAL_API_KEY"), + (AiProvider::OpenRouter, "OPENROUTER_API_KEY"), + (AiProvider::AzureOpenAi, "AZURE_OPENAI_API_KEY"), + ] { + if let Some(value) = environment.get(variable) { + self.values + .standard_keys + .insert(provider, (variable.to_string(), value.trim().to_string())); + } + } + let gemini_key = environment + .get("GEMINI_API_KEY") + .map(|value| ("GEMINI_API_KEY", value)) + .or_else(|| { + environment + .get("GOOGLE_API_KEY") + .map(|value| ("GOOGLE_API_KEY", value)) + }); + if let Some((variable, value)) = gemini_key { + self.values.standard_keys.insert( + AiProvider::GoogleGemini, + (variable.to_string(), value.trim().to_string()), + ); + } + let access_key_id = environment + .get("AWS_ACCESS_KEY_ID") + .map(|value| value.trim()); + let secret_access_key = environment + .get("AWS_SECRET_ACCESS_KEY") + .map(|value| value.trim()); + if let (Some(access_key_id), Some(secret_access_key)) = (access_key_id, secret_access_key) { + if !access_key_id.is_empty() && !secret_access_key.is_empty() { + self.values.bedrock_iam_credentials = Some( + serde_json::json!({ + "accessKeyId": access_key_id, + "secretAccessKey": secret_access_key, + "sessionToken": environment.get("AWS_SESSION_TOKEN").map(|value| value.trim()), + }) + .to_string(), + ); + } + } + for (provider, variable) in [ + (AiProvider::OpenAi, "OPENAI_BASE_URL"), + (AiProvider::Claude, "ANTHROPIC_BASE_URL"), + (AiProvider::Bedrock, "AWS_BEDROCK_RUNTIME_ENDPOINT"), + (AiProvider::Mistral, "MISTRAL_BASE_URL"), + (AiProvider::OpenRouter, "OPENROUTER_BASE_URL"), + (AiProvider::AzureOpenAi, "AZURE_OPENAI_ENDPOINT"), + ] { + if let Some(value) = environment.get(variable) { + self.values + .standard_endpoints + .insert(provider, (variable.to_string(), value.trim().to_string())); + } + } + } + + fn record_invalid(&mut self, variable: &str) { + if self.invalid_variable.is_none() { + self.invalid_variable = Some(variable.to_string()); + } + } + + fn parse_value( + &mut self, + environment: &BTreeMap, + variable: &str, + parse: impl FnOnce(&str) -> Option, + ) -> Option { + let value = environment.get(variable)?; + match parse(value.trim()) { + Some(value) => Some(value), + None => { + self.record_invalid(variable); + None + } + } + } + + fn string_value( + &mut self, + environment: &BTreeMap, + variable: &str, + ) -> Option { + let value = environment.get(variable)?; + let value = value.trim(); + if value.is_empty() { + self.record_invalid(variable); + None + } else { + Some(value.to_string()) + } + } + + fn secret_value( + &mut self, + environment: &BTreeMap, + variable: &str, + ) -> Option { + self.string_value(environment, variable) + } + + fn path_value( + &mut self, + environment: &BTreeMap, + variable: &str, + ) -> Option { + let value = self.string_value(environment, variable)?; + if value.starts_with('/') && !value.contains(['?', '#']) { + Some(value) + } else { + self.record_invalid(variable); + None + } + } + + fn header_name_value( + &mut self, + environment: &BTreeMap, + variable: &str, + ) -> Option { + let value = self.string_value(environment, variable)?; + if HeaderName::from_bytes(value.as_bytes()).is_ok() { + Some(value) + } else { + self.record_invalid(variable); + None + } + } + + fn json_field_value( + &mut self, + environment: &BTreeMap, + variable: &str, + ) -> Option { + let value = self.string_value(environment, variable)?; + if value + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric()) + { + Some(value) + } else { + self.record_invalid(variable); + None + } + } + + fn price_value( + &mut self, + environment: &BTreeMap, + variable: &str, + ) -> Option { + let value = self.string_value(environment, variable)?; + if value.parse::().is_ok_and(|price| price >= 0.0) { + Some(value) + } else { + self.record_invalid(variable); + None + } + } + + fn extra_headers_value( + &mut self, + environment: &BTreeMap, + variable: &str, + ) -> Option> { + let raw = environment.get(variable)?; + let parsed = serde_json::from_str::>(raw); + let Ok(values) = parsed else { + self.record_invalid(variable); + return None; + }; + let mut headers = BTreeMap::new(); + for (name, value) in values { + let lower = name.to_ascii_lowercase(); + let Some(value) = value.as_str() else { + self.record_invalid(variable); + return None; + }; + if matches!( + lower.as_str(), + "authorization" + | "proxy-authorization" + | "cookie" + | "host" + | "content-length" + | "transfer-encoding" + ) || HeaderName::from_bytes(name.as_bytes()).is_err() + || HeaderValue::from_str(value).is_err() + { + self.record_invalid(variable); + return None; + } + headers.insert(name, value.to_string()); + } + Some(headers) + } + + fn prompt_file_value( + &mut self, + environment: &BTreeMap, + variable: &str, + ) -> Option { + let path = self.string_value(environment, variable)?; + let metadata = std::fs::metadata(Path::new(&path)); + let value = metadata + .ok() + .filter(|metadata| metadata.is_file() && metadata.len() <= MAX_PROMPT_FILE_BYTES) + .and_then(|_| std::fs::read_to_string(&path).ok()) + .filter(|prompt| !prompt.trim().is_empty()); + if value.is_none() { + self.record_invalid(variable); + } + value + } + + pub fn resolve(&self, settings: &Settings) -> Result { + if let Some(variable) = &self.invalid_variable { + return Err(AiError::with_detail("invalidEnvironment", variable.clone())); + } + let stored_ai = &settings.extensions.ai; + let stored_profile = stored_ai.selected_profile(); + let stored_provider = stored_profile + .map(|profile| profile.provider) + .unwrap_or(AiProvider::Disabled); + let provider = self.values.provider.unwrap_or(stored_provider); + for values in [ + self.values.standard_keys.get(&provider), + self.values.standard_endpoints.get(&provider), + ] + .into_iter() + .flatten() + { + if values.1.is_empty() { + return Err(AiError::with_detail("invalidEnvironment", values.0.clone())); + } + } + let provider_matches_profile = + stored_profile.is_some_and(|profile| profile.provider == provider); + let preset = ProviderRegistry::preset(provider); + let mut sources = BTreeMap::new(); + let mut environment_fields = Vec::new(); + + let enabled = choose( + "enabled", + self.values.enabled, + stored_ai.enabled, + &mut sources, + &mut environment_fields, + ); + sources.insert( + "provider".to_string(), + if self.values.provider.is_some() { + environment_fields.push("provider".to_string()); + AiConfigurationSource::Environment + } else if stored_profile.is_some() { + AiConfigurationSource::StoredProfile + } else { + AiConfigurationSource::ProviderDefault + }, + ); + + let standard_endpoint = self + .values + .standard_endpoints + .get(&provider) + .map(|(_, value)| value.clone()); + let endpoint_environment = self.values.endpoint.clone().or(standard_endpoint); + let stored_endpoint = provider_matches_profile + .then(|| stored_profile.map(|profile| profile.endpoint.trim().to_string())) + .flatten() + .filter(|endpoint| !endpoint.is_empty()); + let endpoint = choose_string( + "endpoint", + endpoint_environment, + stored_endpoint, + preset.endpoint, + &mut sources, + &mut environment_fields, + ); + let stored_model = provider_matches_profile + .then(|| stored_profile.map(|profile| profile.model.trim().to_string())) + .flatten() + .filter(|model| !model.is_empty()); + let model = choose_string( + "model", + self.values.model.clone(), + stored_model, + "", + &mut sources, + &mut environment_fields, + ); + let fallback_profile = AiProfile::default(); + let profile = stored_profile.unwrap_or(&fallback_profile); + let api_style = choose_profile_value( + "apiStyle", + self.values.api_style, + provider_matches_profile.then_some(profile.api_style), + preset.api_style, + &mut sources, + &mut environment_fields, + ); + let request_path = choose_string( + "requestPath", + self.values.request_path.clone(), + profile_string(provider_matches_profile, &profile.request_path), + preset.request_path, + &mut sources, + &mut environment_fields, + ); + let models_path = choose_string( + "modelsPath", + self.values.models_path.clone(), + profile_string(provider_matches_profile, &profile.models_path), + preset.models_path, + &mut sources, + &mut environment_fields, + ); + let auth_mode = choose_profile_value( + "authMode", + self.values.auth_mode, + provider_matches_profile.then_some(profile.auth_mode), + preset.auth_mode, + &mut sources, + &mut environment_fields, + ); + let auth_header = choose_string( + "authHeader", + self.values.auth_header.clone(), + profile_string(provider_matches_profile, &profile.auth_header), + preset.auth_header, + &mut sources, + &mut environment_fields, + ); + let max_tokens_field = choose_string( + "maxTokensField", + self.values.max_tokens_field.clone(), + profile_string(provider_matches_profile, &profile.max_tokens_field), + preset.max_tokens_field, + &mut sources, + &mut environment_fields, + ); + let extra_headers = choose_profile_value( + "extraHeaders", + self.values.extra_headers.clone(), + provider_matches_profile.then(|| profile.extra_headers.clone()), + BTreeMap::new(), + &mut sources, + &mut environment_fields, + ); + let azure_deployment = choose_string( + "azureDeployment", + self.values.azure_deployment.clone(), + profile_string(provider_matches_profile, &profile.azure_deployment), + "", + &mut sources, + &mut environment_fields, + ); + let azure_api_version = choose_string( + "azureApiVersion", + self.values.azure_api_version.clone(), + profile_string(provider_matches_profile, &profile.azure_api_version), + "2024-10-21", + &mut sources, + &mut environment_fields, + ); + let reasoning_preference = choose_profile_value( + "reasoningPreference", + self.values.reasoning, + provider_matches_profile.then_some(profile.reasoning_preference), + AiReasoningPreference::Automatic, + &mut sources, + &mut environment_fields, + ); + + let mut open_router = if provider_matches_profile { + profile.open_router.clone() + } else { + super::types::OpenRouterSettings::default() + }; + apply_environment_value( + "openRouterPrivacy", + self.values.openrouter_privacy, + &mut open_router.privacy, + &mut sources, + &mut environment_fields, + ); + apply_environment_value( + "openRouterAllowFallbacks", + self.values.openrouter_allow_fallbacks, + &mut open_router.allow_fallbacks, + &mut sources, + &mut environment_fields, + ); + apply_environment_value( + "openRouterRequireParameters", + self.values.openrouter_require_parameters, + &mut open_router.require_parameters, + &mut sources, + &mut environment_fields, + ); + apply_environment_value( + "openRouterMaxPromptPrice", + self.values.openrouter_max_prompt_price.clone(), + &mut open_router.max_prompt_price, + &mut sources, + &mut environment_fields, + ); + apply_environment_value( + "openRouterMaxCompletionPrice", + self.values.openrouter_max_completion_price.clone(), + &mut open_router.max_completion_price, + &mut sources, + &mut environment_fields, + ); + + Ok(EffectiveAiConfiguration { + enabled, + profile_id: stored_profile + .map(|profile| profile.id.clone()) + .unwrap_or_else(|| "environment".to_string()), + provider, + endpoint, + model, + api_style, + request_path, + models_path, + auth_mode, + auth_header, + max_tokens_field, + extra_headers, + azure_deployment, + azure_api_version, + reasoning_preference, + effort_capability: if provider_matches_profile { + profile.effort_capability.clone() + } else { + AiEffortCapability::Unknown + }, + open_router, + commit_context_limit_kib: choose( + "commitContextLimitKib", + self.values.commit_context_limit_kib, + stored_ai.commit_context_limit_kib, + &mut sources, + &mut environment_fields, + ), + conflict_context_limit_kib: choose( + "conflictContextLimitKib", + self.values.conflict_context_limit_kib, + stored_ai.conflict_context_limit_kib, + &mut sources, + &mut environment_fields, + ), + commit_message_max_tokens: choose( + "commitMessageMaxTokens", + self.values.commit_max_tokens, + stored_ai.commit_message_max_tokens, + &mut sources, + &mut environment_fields, + ), + conflict_resolution_max_tokens: choose( + "conflictResolutionMaxTokens", + self.values.conflict_max_tokens, + stored_ai.conflict_resolution_max_tokens, + &mut sources, + &mut environment_fields, + ), + commit_message_prompt: choose( + "commitMessagePrompt", + self.values.commit_prompt.clone(), + stored_ai.commit_message_prompt.clone(), + &mut sources, + &mut environment_fields, + ), + conflict_resolution_prompt: choose( + "conflictResolutionPrompt", + self.values.conflict_prompt.clone(), + stored_ai.conflict_resolution_prompt.clone(), + &mut sources, + &mut environment_fields, + ), + include_commit_history: choose( + "includeCommitHistory", + self.values.include_commit_history, + stored_ai.include_commit_history, + &mut sources, + &mut environment_fields, + ), + global_exclusions: stored_ai.global_exclusions.clone(), + sources, + environment_fields, + environment_api_key: self.api_key(provider, auth_mode).is_some(), + }) + } + + pub fn api_key(&self, provider: AiProvider, auth_mode: AiAuthMode) -> Option { + if provider == AiProvider::Bedrock { + return match auth_mode { + AiAuthMode::AwsSigV4 => self + .values + .api_key + .clone() + .filter(|value| super::api::aws_sigv4::is_iam_credentials_json(value)) + .or_else(|| self.values.bedrock_iam_credentials.clone()), + AiAuthMode::Bearer => self + .values + .api_key + .clone() + .filter(|value| !super::api::aws_sigv4::is_iam_credentials_json(value)) + .or_else(|| { + self.values + .standard_keys + .get(&provider) + .map(|(_, value)| value.clone()) + .filter(|value| !super::api::aws_sigv4::is_iam_credentials_json(value)) + }), + AiAuthMode::Header | AiAuthMode::None => { + self.values.api_key.clone().or_else(|| { + self.values + .standard_keys + .get(&provider) + .map(|(_, value)| value.clone()) + }) + } + }; + } + self.values.api_key.clone().or_else(|| { + self.values + .standard_keys + .get(&provider) + .map(|(_, value)| value.clone()) + }) + } +} + +fn profile_string(matches: bool, value: &str) -> Option { + matches + .then(|| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn choose( + field: &str, + environment: Option, + stored: T, + sources: &mut BTreeMap, + environment_fields: &mut Vec, +) -> T { + if let Some(value) = environment { + sources.insert(field.to_string(), AiConfigurationSource::Environment); + environment_fields.push(field.to_string()); + value + } else { + sources.insert(field.to_string(), AiConfigurationSource::StoredProfile); + stored + } +} + +fn choose_profile_value( + field: &str, + environment: Option, + stored: Option, + default: T, + sources: &mut BTreeMap, + environment_fields: &mut Vec, +) -> T { + if let Some(value) = environment { + sources.insert(field.to_string(), AiConfigurationSource::Environment); + environment_fields.push(field.to_string()); + value + } else if let Some(value) = stored { + sources.insert(field.to_string(), AiConfigurationSource::StoredProfile); + value + } else { + sources.insert(field.to_string(), AiConfigurationSource::ProviderDefault); + default + } +} + +fn choose_string( + field: &str, + environment: Option, + stored: Option, + default: &str, + sources: &mut BTreeMap, + environment_fields: &mut Vec, +) -> String { + choose_profile_value( + field, + environment, + stored, + default.to_string(), + sources, + environment_fields, + ) +} + +fn apply_environment_value( + field: &str, + environment: Option, + target: &mut T, + sources: &mut BTreeMap, + environment_fields: &mut Vec, +) { + if let Some(value) = environment { + *target = value; + sources.insert(field.to_string(), AiConfigurationSource::Environment); + environment_fields.push(field.to_string()); + } +} + +pub(crate) fn validate_endpoint(endpoint: &str) -> Result { + let url = Url::parse(endpoint.trim()).map_err(|_| AiError::new("endpointInvalid"))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(AiError::new("endpointSchemeInvalid")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(AiError::new("endpointCredentialsForbidden")); + } + if url.query().is_some() || url.fragment().is_some() || url.host().is_none() { + return Err(AiError::new("endpointInvalid")); + } + if url.scheme() == "http" && !is_loopback(&url) { + return Err(AiError::new("insecureRemoteEndpoint")); + } + Ok(url) +} + +pub(crate) fn is_loopback(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + None => false, + } +} + +fn parse_bool(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" => Some(true), + "false" | "0" | "no" | "off" => Some(false), + _ => None, + } +} + +fn compact(value: &str) -> String { + value + .chars() + .filter(|character| !matches!(character, '-' | '_' | ' ')) + .flat_map(char::to_lowercase) + .collect() +} + +fn parse_provider(value: &str) -> Option { + match compact(value).as_str() { + "disabled" => Some(AiProvider::Disabled), + "openai" => Some(AiProvider::OpenAi), + "claude" | "anthropic" => Some(AiProvider::Claude), + "bedrock" | "amazonbedrock" => Some(AiProvider::Bedrock), + "mistral" => Some(AiProvider::Mistral), + "googlegemini" | "gemini" => Some(AiProvider::GoogleGemini), + "openrouter" => Some(AiProvider::OpenRouter), + "azureopenai" | "azure" => Some(AiProvider::AzureOpenAi), + "ollama" => Some(AiProvider::Ollama), + "lmstudio" => Some(AiProvider::LmStudio), + "openaicompatible" | "custom" => Some(AiProvider::OpenAiCompatible), + _ => None, + } +} + +fn parse_reasoning(value: &str) -> Option { + match compact(value).as_str() { + "automatic" | "auto" => Some(AiReasoningPreference::Automatic), + "providerdefault" | "default" => Some(AiReasoningPreference::ProviderDefault), + "low" => Some(AiReasoningPreference::Low), + "medium" => Some(AiReasoningPreference::Medium), + "high" => Some(AiReasoningPreference::High), + _ => None, + } +} + +fn parse_api_style(value: &str) -> Option { + match compact(value).as_str() { + "chatcompletions" | "chat" => Some(AiApiStyle::ChatCompletions), + "responses" => Some(AiApiStyle::Responses), + _ => None, + } +} + +fn parse_auth_mode(value: &str) -> Option { + match compact(value).as_str() { + "bearer" => Some(AiAuthMode::Bearer), + "header" | "apikey" => Some(AiAuthMode::Header), + "awssigv4" | "sigv4" => Some(AiAuthMode::AwsSigV4), + "none" => Some(AiAuthMode::None), + _ => None, + } +} + +fn parse_openrouter_privacy(value: &str) -> Option { + match compact(value).as_str() { + "nodatacollection" | "deny" => Some(OpenRouterPrivacy::NoDataCollection), + "strictzdr" | "zdr" => Some(OpenRouterPrivacy::StrictZdr), + "accountdefault" | "default" => Some(OpenRouterPrivacy::AccountDefault), + _ => None, + } +} + +fn parse_context_limit(value: &str) -> Option { + let parsed = value.parse::().ok()?; + (super::types::normalise_context_limit(parsed) == parsed).then_some(parsed) +} + +fn parse_output_tokens(value: &str) -> Option { + let parsed = value.parse::().ok()?; + (super::types::normalise_output_tokens(parsed) == parsed).then_some(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn overrides(values: &[(&str, &str)]) -> AiLaunchOverrides { + AiLaunchOverrides::from_values( + values + .iter() + .map(|(key, value)| (OsString::from(key), OsString::from(value))), + ) + } + + #[test] + fn explicit_values_override_standard_and_stored_values() { + let mut settings = Settings::default(); + settings.extensions.ai.enabled = true; + let profile = settings.extensions.ai.ensure_profile(); + profile.provider = AiProvider::OpenAi; + profile.endpoint = "https://stored.example/v1".to_string(); + profile.model = "stored-model".to_string(); + let environment = overrides(&[ + ("GITMUN_AI_PROVIDER", "mistral"), + ("GITMUN_AI_ENDPOINT", "https://override.example/v1"), + ("GITMUN_AI_MODEL", "override-model"), + ("MISTRAL_API_KEY", "standard-secret"), + ("GITMUN_AI_API_KEY", "explicit-secret"), + ]); + + let effective = environment.resolve(&settings).unwrap(); + + assert_eq!(effective.provider, AiProvider::Mistral); + assert_eq!(effective.endpoint, "https://override.example/v1"); + assert_eq!(effective.model, "override-model"); + assert_eq!( + environment + .api_key(effective.provider, effective.auth_mode) + .as_deref(), + Some("explicit-secret") + ); + } + + #[test] + fn standard_secret_is_used_only_for_selected_provider() { + let mut settings = Settings::default(); + settings.extensions.ai.enabled = true; + let profile = settings.extensions.ai.ensure_profile(); + profile.provider = AiProvider::Claude; + let environment = overrides(&[("OPENAI_API_KEY", "openai-secret")]); + + let effective = environment.resolve(&settings).unwrap(); + + assert_eq!(effective.provider, AiProvider::Claude); + assert_eq!( + environment.api_key(effective.provider, effective.auth_mode), + None + ); + } + + #[test] + fn bedrock_iam_credentials_are_selected_for_sigv4_authentication() { + let mut settings = Settings::default(); + settings.extensions.ai.enabled = true; + let profile = settings.extensions.ai.ensure_profile(); + profile.provider = AiProvider::Bedrock; + profile.auth_mode = AiAuthMode::AwsSigV4; + let environment = overrides(&[ + ("AWS_ACCESS_KEY_ID", "access-key"), + ("AWS_SECRET_ACCESS_KEY", "secret-key"), + ("AWS_SESSION_TOKEN", "session-token"), + ]); + + let effective = environment.resolve(&settings).unwrap(); + + assert!(effective.environment_api_key); + assert_eq!( + environment + .api_key(effective.provider, effective.auth_mode) + .as_deref() + .and_then(|credentials| serde_json::from_str::(credentials).ok()) + .and_then(|credentials| credentials + .get("accessKeyId") + .and_then(Value::as_str) + .map(str::to_string)), + Some("access-key".to_string()) + ); + } + + #[test] + fn bedrock_does_not_cross_wire_bearer_and_iam_environment_credentials() { + let mut settings = Settings::default(); + settings.extensions.ai.enabled = true; + let profile = settings.extensions.ai.ensure_profile(); + profile.provider = AiProvider::Bedrock; + profile.auth_mode = AiAuthMode::Bearer; + let environment = overrides(&[ + ( + "GITMUN_AI_API_KEY", + r#"{"accessKeyId":"access-key","secretAccessKey":"secret-key"}"#, + ), + ("AWS_BEARER_TOKEN_BEDROCK", "bedrock-bearer"), + ("AWS_ACCESS_KEY_ID", "access-key"), + ("AWS_SECRET_ACCESS_KEY", "secret-key"), + ]); + + let bearer = environment.resolve(&settings).unwrap(); + assert_eq!( + environment + .api_key(bearer.provider, AiAuthMode::Bearer) + .as_deref(), + Some("bedrock-bearer") + ); + + settings.extensions.ai.ensure_profile().auth_mode = AiAuthMode::AwsSigV4; + let sigv4 = environment.resolve(&settings).unwrap(); + let credentials = environment + .api_key(sigv4.provider, AiAuthMode::AwsSigV4) + .unwrap(); + assert!(super::super::api::aws_sigv4::is_iam_credentials_json( + &credentials + )); + assert!(!credentials.contains("bedrock-bearer")); + } + + #[test] + fn invalid_value_reports_only_its_variable_name() { + let environment = overrides(&[("GITMUN_AI_ENABLED", "perhaps")]); + let error = environment.resolve(&Settings::default()).unwrap_err(); + + assert_eq!(error.code, "invalidEnvironment"); + assert_eq!(error.detail.as_deref(), Some("GITMUN_AI_ENABLED")); + } + + #[test] + fn remote_http_is_rejected_but_loopback_is_allowed() { + assert!(validate_endpoint("http://127.0.0.1:11434/v1").is_ok()); + assert_eq!( + validate_endpoint("http://example.test/v1") + .unwrap_err() + .code, + "insecureRemoteEndpoint" + ); + } +} diff --git a/src-tauri/src/ai/conflicts.rs b/src-tauri/src/ai/conflicts.rs new file mode 100644 index 0000000..8da60db --- /dev/null +++ b/src-tauri/src/ai/conflicts.rs @@ -0,0 +1,162 @@ +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use super::AiError; + +const SESSION_LIFETIME: Duration = Duration::from_secs(60 * 60); + +#[derive(Clone)] +pub(crate) struct ConflictReplacement { + pub id: String, + pub start: usize, + pub end: usize, + pub replacement: String, +} + +#[derive(Clone)] +pub(crate) struct ConflictSession { + pub repository: PathBuf, + pub file_path: String, + pub original: Vec, + pub current_hash: md5::Digest, + pub unmerged_index: Vec, + pub resolved_index: Option>, + pub replacements: Vec, + pub applied_ids: HashSet, + created_at: Instant, +} + +impl ConflictSession { + pub fn new( + repository: PathBuf, + file_path: String, + original: Vec, + unmerged_index: Vec, + replacements: Vec, + ) -> Self { + Self { + repository, + file_path, + current_hash: md5::compute(&original), + original, + unmerged_index, + resolved_index: None, + replacements, + applied_ids: HashSet::new(), + created_at: Instant::now(), + } + } +} + +#[derive(Default)] +pub(crate) struct ConflictSessionStore { + sessions: Mutex>, +} + +impl ConflictSessionStore { + pub fn insert(&self, id: String, session: ConflictSession) -> Result<(), AiError> { + let mut sessions = self + .sessions + .lock() + .map_err(|_| AiError::new("conflictSessionUnavailable"))?; + sessions.retain(|_, session| session.created_at.elapsed() <= SESSION_LIFETIME); + sessions.insert(id, session); + Ok(()) + } + + pub fn get(&self, id: &str) -> Result { + let sessions = self + .sessions + .lock() + .map_err(|_| AiError::new("conflictSessionUnavailable"))?; + sessions + .get(id) + .filter(|session| session.created_at.elapsed() <= SESSION_LIFETIME) + .cloned() + .ok_or_else(|| AiError::new("conflictProposalExpired")) + } + + pub fn mutate( + &self, + id: &str, + f: impl FnOnce(&mut ConflictSession) -> Result, + ) -> Result { + let mut sessions = self + .sessions + .lock() + .map_err(|_| AiError::new("conflictSessionUnavailable"))?; + let session = sessions + .get_mut(id) + .filter(|session| session.created_at.elapsed() <= SESSION_LIFETIME) + .ok_or_else(|| AiError::new("conflictProposalExpired"))?; + f(session) + } + + pub fn remove(&self, id: &str) -> Result<(), AiError> { + self.sessions + .lock() + .map_err(|_| AiError::new("conflictSessionUnavailable"))? + .remove(id); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::thread; + + #[test] + fn mutate_serialises_concurrent_updates() { + let store = Arc::new(ConflictSessionStore::default()); + store + .insert( + "proposal".to_string(), + ConflictSession::new( + PathBuf::from("/tmp/repo"), + "conflicted.txt".to_string(), + b"original".to_vec(), + b"index".to_vec(), + vec![ + ConflictReplacement { + id: "a".to_string(), + start: 0, + end: 1, + replacement: "one".to_string(), + }, + ConflictReplacement { + id: "b".to_string(), + start: 2, + end: 3, + replacement: "two".to_string(), + }, + ], + ), + ) + .unwrap(); + + let left = Arc::clone(&store); + let right = Arc::clone(&store); + let first = thread::spawn(move || { + left.mutate("proposal", |session| { + session.applied_ids.insert("a".to_string()); + Ok(()) + }) + }); + let second = thread::spawn(move || { + right.mutate("proposal", |session| { + session.applied_ids.insert("b".to_string()); + Ok(()) + }) + }); + first.join().unwrap().unwrap(); + second.join().unwrap().unwrap(); + + let session = store.get("proposal").unwrap(); + assert!(session.applied_ids.contains("a")); + assert!(session.applied_ids.contains("b")); + } +} diff --git a/src-tauri/src/ai/credentials.rs b/src-tauri/src/ai/credentials.rs new file mode 100644 index 0000000..c128659 --- /dev/null +++ b/src-tauri/src/ai/credentials.rs @@ -0,0 +1,69 @@ +use keyring::{Entry, Error as KeyringError}; + +use super::AiError; + +const CREDENTIAL_SERVICE: &str = "com.cst8t.gitmun.ai"; +const LEGACY_CREDENTIAL_USER: &str = "api-key"; + +pub(crate) trait AiCredentialStore: Send + Sync { + fn read_api_key(&self, scope: &str) -> Result, AiError>; + fn set_api_key(&self, scope: &str, api_key: &str) -> Result<(), AiError>; + fn clear_api_key(&self, scope: &str) -> Result<(), AiError>; + fn read_legacy_api_key(&self) -> Result, AiError>; + fn clear_legacy_api_key(&self) -> Result<(), AiError>; +} + +#[derive(Debug, Default)] +pub(crate) struct KeyringAiCredentialStore; + +impl KeyringAiCredentialStore { + fn entry(&self, scope: &str) -> Result { + let credential_user = format!("api-key-{:x}", md5::compute(scope)); + Entry::new(CREDENTIAL_SERVICE, &credential_user) + .map_err(|_| AiError::new("credentialStoreUnavailable")) + } + + fn legacy_entry(&self) -> Result { + Entry::new(CREDENTIAL_SERVICE, LEGACY_CREDENTIAL_USER) + .map_err(|_| AiError::new("credentialStoreUnavailable")) + } + + fn read_entry(entry: Entry) -> Result, AiError> { + match entry.get_password() { + Ok(key) if !key.trim().is_empty() => Ok(Some(key)), + Ok(_) | Err(KeyringError::NoEntry) => Ok(None), + Err(_) => Err(AiError::new("credentialStoreUnavailable")), + } + } + + fn clear_entry(entry: Entry) -> Result<(), AiError> { + match entry.delete_credential() { + Ok(()) | Err(KeyringError::NoEntry) => Ok(()), + Err(_) => Err(AiError::new("credentialStoreUnavailable")), + } + } +} + +impl AiCredentialStore for KeyringAiCredentialStore { + fn read_api_key(&self, scope: &str) -> Result, AiError> { + Self::read_entry(self.entry(scope)?) + } + + fn set_api_key(&self, scope: &str, api_key: &str) -> Result<(), AiError> { + self.entry(scope)? + .set_password(api_key) + .map_err(|_| AiError::new("credentialStoreUnavailable")) + } + + fn clear_api_key(&self, scope: &str) -> Result<(), AiError> { + Self::clear_entry(self.entry(scope)?) + } + + fn read_legacy_api_key(&self) -> Result, AiError> { + Self::read_entry(self.legacy_entry()?) + } + + fn clear_legacy_api_key(&self) -> Result<(), AiError> { + Self::clear_entry(self.legacy_entry()?) + } +} diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs new file mode 100644 index 0000000..5f99e62 --- /dev/null +++ b/src-tauri/src/ai/mod.rs @@ -0,0 +1,148 @@ +mod api; +pub mod commands; +mod configuration; +mod conflicts; +mod credentials; +mod openrouter_oauth; +mod operations; +mod providers; +pub mod types; + +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +pub(crate) use conflicts::{ConflictReplacement, ConflictSession, ConflictSessionStore}; + +pub(crate) use api::{ + AiModelInfo, AiModelPage, AiModelQuery, AiOutputContract, AiRequestBudget, AiRuntime, + AiStructuredOutputMode, AiTask, ProviderResult, api_key_optional, +}; +pub use configuration::AiConfigurationSource; +pub(crate) use configuration::{AiLaunchOverrides, EffectiveAiConfiguration, validate_endpoint}; +pub(crate) use credentials::{AiCredentialStore, KeyringAiCredentialStore}; +pub(crate) use operations::AiOperationRegistry; +pub(crate) use providers::{ + discover_effort, discover_models, discover_openrouter_model_details, run_provider, + run_provider_with_output, +}; +pub use types::{ + AiApiStyle, AiAuthMode, AiCommitMessageMode, AiEffortCapability, AiExtensionSettings, + AiProfile, AiProvider, AiReasoningPreference, AiRepositoryPolicy, AiUsageRecord, + ExtensionSettings, OpenRouterPrivacy, OpenRouterRoutingStrategy, OpenRouterSettings, +}; + +pub(crate) struct AiExtensionState { + pub environment: AiLaunchOverrides, + pub runtime: Option, + pub credentials: Arc, + pub conflict_sessions: ConflictSessionStore, + pub operations: AiOperationRegistry, + pub openrouter_oauth_active: AtomicBool, +} + +#[derive(Debug, Clone)] +pub(crate) struct AiProviderResponseMetadata { + pub usage: AiUsage, + pub request_id: Option, + pub generation_id: Option, + pub routed_provider: Option, + pub routed_model: Option, + pub finish_reason: Option, + pub response_bytes: usize, +} + +impl AiExtensionState { + pub fn new() -> Self { + Self { + environment: AiLaunchOverrides::from_process(), + runtime: AiRuntime::new().ok(), + credentials: Arc::new(KeyringAiCredentialStore), + conflict_sessions: ConflictSessionStore::default(), + operations: AiOperationRegistry::default(), + openrouter_oauth_active: AtomicBool::new(false), + } + } + + pub fn load_structured_output_modes(&self, modes: &HashMap) { + if let Some(runtime) = &self.runtime { + runtime.load_structured_output_modes(modes); + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AiError { + pub code: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_size_kib: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_limit_kib: Option, + #[serde(skip)] + pub(crate) provider_response: Option>, +} + +impl AiError { + pub(crate) fn new(code: &'static str) -> Self { + Self { + code, + detail: None, + context_size_kib: None, + context_limit_kib: None, + provider_response: None, + } + } + + pub(crate) fn with_detail(code: &'static str, detail: impl Into) -> Self { + Self { + code, + detail: Some(detail.into()), + context_size_kib: None, + context_limit_kib: None, + provider_response: None, + } + } + + pub(crate) fn context_too_large(context_bytes: usize, context_limit_kib: u32) -> Self { + Self { + code: "contextTooLarge", + detail: None, + context_size_kib: Some(context_bytes.div_ceil(1024)), + context_limit_kib: Some(context_limit_kib), + provider_response: None, + } + } + + pub(crate) fn with_provider_response( + mut self, + provider_response: AiProviderResponseMetadata, + ) -> Self { + self.provider_response = Some(Box::new(provider_response)); + self + } +} + +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AiUsage { + pub input_tokens: Option, + pub output_tokens: Option, + pub reasoning_tokens: Option, + pub cached_tokens: Option, + pub cost: Option, + pub byok: Option, +} + +#[cfg(test)] +mod tests { + use super::AiError; + + #[test] + fn provider_metadata_does_not_inflate_ai_errors() { + assert!(std::mem::size_of::() <= 80); + } +} diff --git a/src-tauri/src/ai/openrouter_oauth.rs b/src-tauri/src/ai/openrouter_oauth.rs new file mode 100644 index 0000000..ca7526c --- /dev/null +++ b/src-tauri/src/ai/openrouter_oauth.rs @@ -0,0 +1,264 @@ +use std::io::{ErrorKind, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::time::{Duration, Instant}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use sha2::{Digest, Sha256}; +use tauri_plugin_opener::OpenerExt; +use url::Url; + +use super::AiError; +use super::api::AiRuntime; +use super::providers::openrouter::exchange_openrouter_oauth_code; + +const OPENROUTER_AUTH_URL: &str = "https://openrouter.ai/auth"; +const CALLBACK_PATH: &str = "/callback"; +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); +const CALLBACK_POLL_INTERVAL: Duration = Duration::from_millis(25); +const CALLBACK_READ_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_CALLBACK_REQUEST_BYTES: usize = 16 * 1024; +const MAX_AUTH_CODE_BYTES: usize = 4096; +const MAX_CALLBACK_MESSAGE_BYTES: usize = 512; + +pub(crate) async fn authorise( + runtime: &AiRuntime, + app: &tauri::AppHandle, + callback_message: String, +) -> Result { + validate_callback_message(&callback_message)?; + let (code_verifier, code_challenge) = create_pkce_pair()?; + let listener = TcpListener::bind(("127.0.0.1", 0)) + .map_err(|_| AiError::new("openRouterOAuthUnavailable"))?; + listener + .set_nonblocking(true) + .map_err(|_| AiError::new("openRouterOAuthUnavailable"))?; + let port = listener + .local_addr() + .map_err(|_| AiError::new("openRouterOAuthUnavailable"))? + .port(); + let callback_url = format!("http://127.0.0.1:{port}{CALLBACK_PATH}"); + let authorisation_url = authorisation_url(&callback_url, &code_challenge)?; + app.opener() + .open_url(authorisation_url.to_string(), None::<&str>) + .map_err(|_| AiError::new("openRouterOAuthUnavailable"))?; + + let code = tauri::async_runtime::spawn_blocking(move || { + wait_for_callback(listener, &callback_message) + }) + .await + .map_err(|_| AiError::new("openRouterOAuthUnavailable"))??; + + exchange_openrouter_oauth_code(runtime, &code, &code_verifier).await +} + +fn validate_callback_message(message: &str) -> Result<(), AiError> { + if message.trim().is_empty() + || message.len() > MAX_CALLBACK_MESSAGE_BYTES + || message + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(AiError::new("openRouterOAuthUnavailable")); + } + Ok(()) +} + +fn create_pkce_pair() -> Result<(String, String), AiError> { + let mut random_bytes = [0_u8; 32]; + getrandom::fill(&mut random_bytes).map_err(|_| AiError::new("openRouterOAuthUnavailable"))?; + let code_verifier = URL_SAFE_NO_PAD.encode(random_bytes); + let code_challenge = pkce_challenge(&code_verifier); + Ok((code_verifier, code_challenge)) +} + +fn pkce_challenge(code_verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())) +} + +fn authorisation_url(callback_url: &str, code_challenge: &str) -> Result { + let mut url = + Url::parse(OPENROUTER_AUTH_URL).map_err(|_| AiError::new("openRouterOAuthUnavailable"))?; + url.query_pairs_mut() + .append_pair("callback_url", callback_url) + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url) +} + +fn wait_for_callback(listener: TcpListener, callback_message: &str) -> Result { + let deadline = Instant::now() + CALLBACK_TIMEOUT; + while Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(CALLBACK_READ_TIMEOUT)) + .map_err(|_| AiError::new("openRouterOAuthInvalidCallback"))?; + let request = read_callback_request(&mut stream)?; + match parse_callback_code(&request) { + Ok(Some(code)) => { + write_callback_page(&mut stream, callback_message); + return Ok(code); + } + Ok(None) => write_empty_response(&mut stream, "404 Not Found"), + Err(error) => { + write_callback_page(&mut stream, callback_message); + return Err(error); + } + } + } + Err(error) if error.kind() == ErrorKind::WouldBlock => { + std::thread::sleep(CALLBACK_POLL_INTERVAL); + } + Err(_) => return Err(AiError::new("openRouterOAuthUnavailable")), + } + } + Err(AiError::new("openRouterOAuthTimedOut")) +} + +fn read_callback_request(stream: &mut TcpStream) -> Result, AiError> { + let mut request = Vec::with_capacity(1024); + let mut buffer = [0_u8; 1024]; + loop { + let bytes_read = stream + .read(&mut buffer) + .map_err(|_| AiError::new("openRouterOAuthInvalidCallback"))?; + if bytes_read == 0 { + break; + } + if request.len().saturating_add(bytes_read) > MAX_CALLBACK_REQUEST_BYTES { + return Err(AiError::new("openRouterOAuthInvalidCallback")); + } + request.extend_from_slice(&buffer[..bytes_read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + Ok(request) +} + +fn parse_callback_code(request: &[u8]) -> Result, AiError> { + let request = + std::str::from_utf8(request).map_err(|_| AiError::new("openRouterOAuthInvalidCallback"))?; + let Some(request_line) = request.lines().next() else { + return Ok(None); + }; + let mut parts = request_line.split_whitespace(); + if parts.next() != Some("GET") { + return Ok(None); + } + let Some(target) = parts.next() else { + return Ok(None); + }; + if !matches!(parts.next(), Some("HTTP/1.0" | "HTTP/1.1")) || parts.next().is_some() { + return Ok(None); + } + let callback = Url::parse(&format!("http://127.0.0.1{target}")) + .map_err(|_| AiError::new("openRouterOAuthInvalidCallback"))?; + if callback.path() != CALLBACK_PATH { + return Ok(None); + } + if callback.query_pairs().any(|(name, _)| name == "error") { + return Err(AiError::new("openRouterOAuthDenied")); + } + let codes: Vec = callback + .query_pairs() + .filter(|(name, _)| name == "code") + .map(|(_, value)| value.into_owned()) + .collect(); + if codes.len() != 1 + || codes[0].is_empty() + || codes[0].len() > MAX_AUTH_CODE_BYTES + || codes[0].chars().any(char::is_control) + { + return Err(AiError::new("openRouterOAuthInvalidCallback")); + } + Ok(codes.into_iter().next()) +} + +fn write_callback_page(stream: &mut TcpStream, message: &str) { + let message = escape_html(message); + let body = format!( + "Gitmun

Gitmun

{message}

" + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ); + drop(stream.write_all(response.as_bytes())); +} + +fn write_empty_response(stream: &mut TcpStream, status: &str) { + let response = format!( + "HTTP/1.1 {status}\r\nCache-Control: no-store\r\nConnection: close\r\nContent-Length: 0\r\n\r\n" + ); + drop(stream.write_all(response.as_bytes())); +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_the_rfc_7636_s256_challenge() { + assert_eq!( + pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn builds_an_openrouter_authorisation_url_for_the_loopback_callback() { + let url = authorisation_url("http://127.0.0.1:51423/callback", "challenge").unwrap(); + let parameters: std::collections::HashMap<_, _> = url.query_pairs().collect(); + + assert_eq!(url.origin().ascii_serialization(), "https://openrouter.ai"); + assert_eq!( + parameters.get("callback_url").unwrap(), + "http://127.0.0.1:51423/callback" + ); + assert_eq!(parameters.get("code_challenge").unwrap(), "challenge"); + assert_eq!(parameters.get("code_challenge_method").unwrap(), "S256"); + } + + #[test] + fn accepts_only_one_code_on_the_expected_callback_path() { + let code = parse_callback_code( + b"GET /callback?code=auth_code_123 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ) + .unwrap(); + assert_eq!(code.as_deref(), Some("auth_code_123")); + + let error = parse_callback_code( + b"GET /callback?code=one&code=two HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ) + .unwrap_err(); + assert_eq!(error.code, "openRouterOAuthInvalidCallback"); + assert!( + parse_callback_code(b"GET /favicon.ico HTTP/1.1\r\n\r\n") + .unwrap() + .is_none() + ); + } + + #[test] + fn recognises_denied_authorisation_and_escapes_callback_copy() { + let error = parse_callback_code( + b"GET /callback?error=access_denied HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ) + .unwrap_err(); + assert_eq!(error.code, "openRouterOAuthDenied"); + assert_eq!( + escape_html("Return to & close"), + "Return to <Gitmun> & close" + ); + } +} diff --git a/src-tauri/src/ai/operations.rs b/src-tauri/src/ai/operations.rs new file mode 100644 index 0000000..7ca3cd2 --- /dev/null +++ b/src-tauri/src/ai/operations.rs @@ -0,0 +1,52 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use tokio_util::sync::CancellationToken; + +use super::AiError; + +#[derive(Default)] +pub(crate) struct AiOperationRegistry { + operations: Mutex>, +} + +impl AiOperationRegistry { + pub fn begin(&self, operation_id: &str) -> Result { + if operation_id.is_empty() + || operation_id.len() > 128 + || !operation_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + { + return Err(AiError::new("invalidOperationId")); + } + let mut operations = self + .operations + .lock() + .map_err(|_| AiError::new("operationUnavailable"))?; + if operations.contains_key(operation_id) { + return Err(AiError::new("operationAlreadyActive")); + } + let cancellation = CancellationToken::new(); + operations.insert(operation_id.to_string(), cancellation.clone()); + Ok(cancellation) + } + + pub fn cancel(&self, operation_id: &str) -> Result<(), AiError> { + let operations = self + .operations + .lock() + .map_err(|_| AiError::new("operationUnavailable"))?; + operations + .get(operation_id) + .ok_or_else(|| AiError::new("operationNotFound"))? + .cancel(); + Ok(()) + } + + pub fn finish(&self, operation_id: &str) { + if let Ok(mut operations) = self.operations.lock() { + operations.remove(operation_id); + } + } +} diff --git a/src-tauri/src/ai/providers/mod.rs b/src-tauri/src/ai/providers/mod.rs new file mode 100644 index 0000000..14a1620 --- /dev/null +++ b/src-tauri/src/ai/providers/mod.rs @@ -0,0 +1,1091 @@ +//! ProviderRegistry, ProviderPreset, and public facades (run, discover). + +use std::cmp::Ordering; + +use serde_json::Value; +use tokio_util::sync::CancellationToken; + +use super::AiError; +use super::api::bedrock::BedrockAdapter; +pub(crate) use super::api::claude::discover_effort; +use super::api::claude::{ClaudeAdapter, normalise_claude_model}; +use super::api::openai::{OpenAiAdapter, normalise_openai_compatible_model}; +use super::api::{ + self, AiModelInfo, AiModelPage, AiModelQuery, AiModelSort, AiOutputContract, AiRequestBudget, + AiRuntime, AiStructuredOutputMode, AiTask, MAX_MODELS_RESPONSE_BYTES, MODEL_DISCOVERY_ATTEMPTS, + OpenAiCompatibleExtension, ProtocolAdapter, ProviderResult, REQUEST_TIMEOUT, authenticate, + endpoint_with_path, network_error, read_response, response_error, +}; +use super::configuration::EffectiveAiConfiguration; +use super::types::{AiApiStyle, AiAuthMode, AiEffortCapability, AiProvider}; + +use self::openrouter::OpenRouterProvider; + +pub(crate) mod openrouter; + +const OPENROUTER_CATALOGUE_PAGE_SIZE: usize = 1000; +const MAX_OPENROUTER_CATALOGUE_PAGES: usize = 16; +const MAX_BEDROCK_PROFILE_PAGES: usize = 16; + +// --------------------------------------------------------------------------- +// Public facades (re-exported via ai/mod.rs) +// --------------------------------------------------------------------------- + +pub(crate) async fn run_provider( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + task: AiTask, + budget: &mut AiRequestBudget, + cancellation: Option, +) -> Result<(ProviderResult, AiEffortCapability), AiError> { + let registry = ProviderRegistry::require(configuration)?; + api::run_provider( + runtime, + configuration, + api_key, + system_prompt, + user_prompt, + max_tokens, + task, + budget, + cancellation, + registry.adapter, + registry.extension, + ) + .await +} + +pub(crate) async fn run_provider_with_output( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + system_prompt: &str, + user_prompt: &str, + max_tokens: u32, + task: AiTask, + budget: &mut AiRequestBudget, + output_contract: &AiOutputContract, + cancellation: Option, +) -> Result< + ( + ProviderResult, + AiEffortCapability, + Option, + ), + AiError, +> { + let registry = ProviderRegistry::require(configuration)?; + api::run_provider_with_output( + runtime, + configuration, + api_key, + system_prompt, + user_prompt, + max_tokens, + task, + budget, + output_contract, + cancellation, + registry.adapter, + registry.extension, + ) + .await +} + +pub(crate) async fn discover_models( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + query: &AiModelQuery, +) -> Result { + if configuration.provider == AiProvider::Bedrock { + return discover_bedrock_models(runtime, configuration, api_key, query).await; + } + if configuration.models_path.is_empty() { + return Err(AiError::new("modelDiscoveryUnavailable")); + } + let endpoint = endpoint_with_path(configuration, &configuration.models_path)?; + let page_size = query.page_size.clamp(1, 100); + let page = query.page.max(1); + let mut catalogue = Vec::new(); + let mut offset = 0; + let mut after_id: Option = None; + let maximum_pages = match configuration.provider { + AiProvider::OpenRouter | AiProvider::Claude => MAX_OPENROUTER_CATALOGUE_PAGES, + _ => 1, + }; + for _ in 0..maximum_pages { + let value = fetch_model_page( + runtime, + configuration, + api_key, + &endpoint, + offset, + after_id.as_deref(), + ) + .await?; + let values = value + .get("data") + .or_else(|| value.get("models")) + .and_then(Value::as_array) + .ok_or_else(|| AiError::new("invalidResponse"))?; + let value_count = values.len(); + catalogue.extend( + values + .iter() + .filter_map(|value| normalise_model(configuration.provider, value)), + ); + if configuration.provider == AiProvider::Claude { + let has_more = value + .get("has_more") + .and_then(Value::as_bool) + .unwrap_or(false); + if !has_more { + break; + } + after_id = value + .get("last_id") + .and_then(Value::as_str) + .map(str::to_string); + if after_id.is_none() { + break; + } + } else if configuration.provider != AiProvider::OpenRouter + || value_count < OPENROUTER_CATALOGUE_PAGE_SIZE + { + break; + } else { + offset += value_count; + if offset >= OPENROUTER_CATALOGUE_PAGE_SIZE * MAX_OPENROUTER_CATALOGUE_PAGES { + return Err(AiError::new("modelCatalogueTooLarge")); + } + } + } + + if configuration.provider == AiProvider::OpenRouter { + let zdr_models = + OpenRouterProvider::fetch_zdr_models(runtime, configuration, api_key).await?; + for model in &mut catalogue { + model.zero_data_retention = Some(zdr_models.contains(&model.id)); + } + } + + let mut models = catalogue + .into_iter() + .filter(|model| model_matches(model, query)) + .collect::>(); + sort_models(&mut models, query.sort); + let start = ((page - 1) * page_size) as usize; + let end = (start + page_size as usize).min(models.len()); + let has_more = end < models.len(); + let models = if start < models.len() { + models.drain(start..end).collect() + } else { + Vec::new() + }; + Ok(AiModelPage { + models, + page, + page_size, + has_more, + }) +} + +pub(crate) async fn discover_openrouter_model_details( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + model_id: &str, +) -> Result { + OpenRouterProvider::discover_details(runtime, configuration, api_key, model_id).await +} + +// --------------------------------------------------------------------------- +// ProviderPreset +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy)] +pub(crate) struct ProviderPreset { + pub endpoint: &'static str, + pub api_style: AiApiStyle, + pub request_path: &'static str, + pub models_path: &'static str, + pub auth_mode: AiAuthMode, + pub auth_header: &'static str, + pub max_tokens_field: &'static str, +} + +// --------------------------------------------------------------------------- +// ProviderRegistry +// --------------------------------------------------------------------------- + +pub(crate) struct ProviderRegistry; + +pub(crate) struct ResolvedProvider<'a> { + pub adapter: &'a dyn ProtocolAdapter, + pub extension: Option<&'a dyn OpenAiCompatibleExtension>, +} + +static OPEN_AI_ADAPTER: OpenAiAdapter = OpenAiAdapter; +static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter; +static BEDROCK_ADAPTER: BedrockAdapter = BedrockAdapter; + +impl ProviderRegistry { + pub fn preset(provider: AiProvider) -> ProviderPreset { + let endpoint = provider.default_endpoint(); + match provider { + AiProvider::Claude => ProviderPreset { + endpoint, + api_style: AiApiStyle::ChatCompletions, + request_path: "/messages", + models_path: "/models", + auth_mode: AiAuthMode::Header, + auth_header: "x-api-key", + max_tokens_field: "max_tokens", + }, + AiProvider::Bedrock => ProviderPreset { + endpoint, + api_style: AiApiStyle::ChatCompletions, + request_path: "/model/{model}/converse", + models_path: "", + auth_mode: AiAuthMode::Bearer, + auth_header: "Authorization", + max_tokens_field: "maxTokens", + }, + AiProvider::AzureOpenAi => ProviderPreset { + endpoint, + api_style: AiApiStyle::ChatCompletions, + request_path: "/openai/deployments/{deployment}/chat/completions", + models_path: "", + auth_mode: AiAuthMode::Header, + auth_header: "api-key", + max_tokens_field: "max_completion_tokens", + }, + AiProvider::OpenAi => ProviderPreset { + endpoint, + api_style: AiApiStyle::ChatCompletions, + request_path: "/chat/completions", + models_path: "/models", + auth_mode: AiAuthMode::Bearer, + auth_header: "Authorization", + max_tokens_field: "max_completion_tokens", + }, + AiProvider::Mistral + | AiProvider::GoogleGemini + | AiProvider::OpenRouter + | AiProvider::Ollama + | AiProvider::LmStudio + | AiProvider::OpenAiCompatible + | AiProvider::Disabled => ProviderPreset { + endpoint, + api_style: AiApiStyle::ChatCompletions, + request_path: "/chat/completions", + models_path: if provider == AiProvider::OpenRouter { + "/models/user" + } else { + "/models" + }, + auth_mode: AiAuthMode::Bearer, + auth_header: "Authorization", + max_tokens_field: "max_tokens", + }, + } + } + + pub fn require( + configuration: &EffectiveAiConfiguration, + ) -> Result, AiError> { + let provider = configuration.provider; + if provider == AiProvider::Disabled { + return Err(AiError::new("notConfigured")); + } + let adapter: &dyn ProtocolAdapter = if provider == AiProvider::Claude { + &CLAUDE_ADAPTER + } else if provider == AiProvider::Bedrock { + &BEDROCK_ADAPTER + } else if provider.is_openai_compatible() { + &OPEN_AI_ADAPTER + } else { + return Err(AiError::new("notConfigured")); + }; + let extension: Option<&dyn OpenAiCompatibleExtension> = (provider + == AiProvider::OpenRouter) + .then_some(OpenRouterProvider::extension() as &dyn OpenAiCompatibleExtension); + Ok(ResolvedProvider { adapter, extension }) + } +} + +// --------------------------------------------------------------------------- +// Shared model discovery helpers +// --------------------------------------------------------------------------- + +fn normalise_model(provider: AiProvider, value: &Value) -> Option { + if provider == AiProvider::OpenRouter { + return OpenRouterProvider::normalise_model(value); + } + if provider == AiProvider::Claude { + return normalise_claude_model(value); + } + if provider == AiProvider::Bedrock { + return normalise_bedrock_model(value); + } + normalise_openai_compatible_model(value) +} + +async fn fetch_model_page( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + endpoint: &url::Url, + offset: usize, + after_id: Option<&str>, +) -> Result { + let mut last_error = AiError::new("providerUnavailable"); + for _ in 0..MODEL_DISCOVERY_ATTEMPTS { + let mut request = runtime + .client + .get(endpoint.clone()) + .timeout(REQUEST_TIMEOUT); + if configuration.provider == AiProvider::OpenRouter { + request = request.query(&[ + ("limit", OPENROUTER_CATALOGUE_PAGE_SIZE), + ("offset", offset), + ]); + request = OpenRouterProvider::add_openrouter_headers(request); + } else if configuration.provider == AiProvider::Claude { + if let Some(cursor) = after_id { + request = request.query(&[("after_id", cursor)]); + } + request = CLAUDE_ADAPTER.add_protocol_headers(request); + } + let response = if configuration.auth_mode == AiAuthMode::AwsSigV4 { + request = configuration + .extra_headers + .iter() + .fold(request, |request, (name, value)| { + request.header(name, value) + }); + let mut request = request.build().map_err(network_error)?; + super::api::aws_sigv4::sign_bedrock_request(&mut request, api_key, b"")?; + runtime.client.execute(request).await + } else { + authenticate(request, configuration, api_key)?.send().await + }; + let response = match response { + Ok(response) => response, + Err(error) => { + last_error = network_error(error); + continue; + } + }; + let (status, _, bytes) = read_response(response, MAX_MODELS_RESPONSE_BYTES).await?; + if status.is_success() { + return serde_json::from_slice(&bytes).map_err(|_| AiError::new("invalidResponse")); + } + last_error = response_error(status); + if status.is_client_error() && status != reqwest::StatusCode::TOO_MANY_REQUESTS { + return Err(last_error); + } + } + Err(last_error) +} + +async fn discover_bedrock_models( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + query: &AiModelQuery, +) -> Result { + let mut foundation_models = bedrock_control_plane_endpoint(configuration)?; + foundation_models.set_path("/foundation-models"); + foundation_models + .query_pairs_mut() + .append_pair("byOutputModality", "TEXT"); + let foundation = + fetch_model_page(runtime, configuration, api_key, &foundation_models, 0, None).await?; + let mut catalogue = foundation + .get("modelSummaries") + .and_then(Value::as_array) + .ok_or_else(|| AiError::new("invalidResponse"))? + .iter() + .filter_map(normalise_bedrock_model) + .collect::>(); + + let mut next_token: Option = None; + for _ in 0..MAX_BEDROCK_PROFILE_PAGES { + let mut profiles = bedrock_control_plane_endpoint(configuration)?; + profiles.set_path("/inference-profiles"); + { + let mut query = profiles.query_pairs_mut(); + query + .append_pair("typeEquals", "SYSTEM_DEFINED") + .append_pair("maxResults", "1000"); + if let Some(next_token) = &next_token { + query.append_pair("nextToken", next_token); + } + } + let profiles = + fetch_model_page(runtime, configuration, api_key, &profiles, 0, None).await?; + let profile_models = profiles + .get("inferenceProfileSummaries") + .and_then(Value::as_array) + .ok_or_else(|| AiError::new("invalidResponse"))? + .iter() + .filter_map(normalise_bedrock_inference_profile); + catalogue.extend(profile_models); + next_token = profiles + .get("nextToken") + .and_then(Value::as_str) + .map(str::to_string); + if next_token.is_none() { + break; + } + } + catalogue.sort_by(|left, right| left.id.cmp(&right.id)); + catalogue.dedup_by(|left, right| left.id == right.id); + + let mut models = catalogue + .into_iter() + .filter(|model| model_matches(model, query)) + .collect::>(); + sort_models(&mut models, query.sort); + let page_size = query.page_size.clamp(1, 100); + let page = query.page.max(1); + let start = ((page - 1) * page_size) as usize; + let end = (start + page_size as usize).min(models.len()); + let has_more = end < models.len(); + let models = if start < models.len() { + models.drain(start..end).collect() + } else { + Vec::new() + }; + Ok(AiModelPage { + models, + page, + page_size, + has_more, + }) +} + +fn bedrock_control_plane_endpoint( + configuration: &EffectiveAiConfiguration, +) -> Result { + let mut endpoint = configuration.endpoint_url()?; + let host = endpoint + .host_str() + .ok_or_else(|| AiError::new("endpointInvalid"))?; + let control_host = host + .strip_prefix("bedrock-runtime.") + .map(|region| format!("bedrock.{region}")) + .ok_or_else(|| AiError::new("endpointInvalid"))?; + endpoint + .set_host(Some(&control_host)) + .map_err(|_| AiError::new("endpointInvalid"))?; + endpoint.set_query(None); + Ok(endpoint) +} + +fn normalise_bedrock_model(value: &Value) -> Option { + let id = value.get("modelId")?.as_str()?.to_string(); + let input_modalities = value + .get("inputModalities") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(|value| value.to_ascii_lowercase()) + .collect(); + let output_modalities = value + .get("outputModalities") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(|value| value.to_ascii_lowercase()) + .collect(); + Some(AiModelInfo { + name: value + .get("modelName") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(), + id, + description: value + .get("providerName") + .and_then(Value::as_str) + .map(|provider| format!("Amazon Bedrock - {provider}")), + input_modalities, + output_modalities, + available_providers: vec!["Amazon Bedrock".to_string()], + ..AiModelInfo::default() + }) +} + +fn normalise_bedrock_inference_profile(value: &Value) -> Option { + let id = value.get("inferenceProfileId")?.as_str()?.to_string(); + Some(AiModelInfo { + name: value + .get("inferenceProfileName") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(), + id, + description: value + .get("description") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| Some("Amazon Bedrock inference profile".to_string())), + input_modalities: vec!["text".to_string()], + output_modalities: vec!["text".to_string()], + available_providers: vec!["Amazon Bedrock".to_string()], + ..AiModelInfo::default() + }) +} + +#[cfg(test)] +mod bedrock_tests { + use serde_json::json; + + use super::{ + bedrock_control_plane_endpoint, normalise_bedrock_inference_profile, + normalise_bedrock_model, + }; + use crate::ai::types::{AiAuthMode, AiProvider}; + + #[test] + fn normalises_foundation_models_and_inference_profiles() { + let model = normalise_bedrock_model(&json!({ + "modelId": "amazon.nova-pro-v1:0", + "modelName": "Nova Pro", + "providerName": "Amazon", + "inputModalities": ["TEXT", "IMAGE"], + "outputModalities": ["TEXT"] + })) + .unwrap(); + assert_eq!(model.id, "amazon.nova-pro-v1:0"); + assert_eq!(model.input_modalities, ["text", "image"]); + + let profile = normalise_bedrock_inference_profile(&json!({ + "inferenceProfileId": "us.anthropic.claude-sonnet-4-6", + "inferenceProfileName": "US Claude Sonnet 4.6" + })) + .unwrap(); + assert_eq!(profile.id, "us.anthropic.claude-sonnet-4-6"); + assert_eq!(profile.output_modalities, ["text"]); + } + + #[test] + fn control_plane_endpoint_and_profile_query_are_derived_from_runtime_host() { + let mut configuration = super::test_helpers::configuration(AiProvider::Bedrock); + configuration.endpoint = "https://bedrock-runtime.eu-west-2.amazonaws.com".to_string(); + configuration.auth_mode = AiAuthMode::AwsSigV4; + + let mut profiles = bedrock_control_plane_endpoint(&configuration).unwrap(); + assert_eq!(profiles.host_str(), Some("bedrock.eu-west-2.amazonaws.com")); + profiles.set_path("/inference-profiles"); + { + let mut query = profiles.query_pairs_mut(); + query + .append_pair("typeEquals", "SYSTEM_DEFINED") + .append_pair("maxResults", "1000") + .append_pair("nextToken", "page-2"); + } + assert!(profiles.as_str().contains("/inference-profiles?")); + assert!(profiles.as_str().contains("typeEquals=SYSTEM_DEFINED")); + assert!(profiles.as_str().contains("nextToken=page-2")); + } +} + +fn model_matches(model: &AiModelInfo, query: &AiModelQuery) -> bool { + let search = query.search.trim().to_ascii_lowercase(); + (search.is_empty() + || model.id.to_ascii_lowercase().contains(&search) + || model.name.to_ascii_lowercase().contains(&search) + || model + .description + .as_deref() + .is_some_and(|description| description.to_ascii_lowercase().contains(&search))) + && (!query.programming_only + || model + .description + .as_deref() + .is_some_and(|description| description.to_ascii_lowercase().contains("code")) + || model.coding_score.is_some()) + && (query.author.trim().is_empty() + || model + .id + .split('/') + .next() + .is_some_and(|author| author.eq_ignore_ascii_case(query.author.trim()))) + && (query.hosting_provider.trim().is_empty() + || model + .available_providers + .iter() + .any(|provider| provider.eq_ignore_ascii_case(query.hosting_provider.trim()))) + && query + .minimum_context_length + .is_none_or(|minimum| model.context_length.is_some_and(|length| length >= minimum)) + && price_within(model.prompt_price.as_deref(), query.maximum_prompt_price) + && price_within( + model.completion_price.as_deref(), + query.maximum_completion_price, + ) + && (!query.zdr_only || model.zero_data_retention == Some(true)) + && (model.output_modalities.is_empty() + || model + .output_modalities + .iter() + .any(|modality| modality == "text")) +} + +fn price_within(price: Option<&str>, maximum: Option) -> bool { + maximum.is_none_or(|maximum| { + price + .and_then(|price| price.parse::().ok()) + .is_some_and(|price| price <= maximum) + }) +} + +fn sort_models(models: &mut [AiModelInfo], sort: AiModelSort) { + models.sort_by(|left, right| match sort { + AiModelSort::Popularity => Ordering::Equal, + AiModelSort::PromptPrice => compare_price(&left.prompt_price, &right.prompt_price), + AiModelSort::CompletionPrice => { + compare_price(&left.completion_price, &right.completion_price) + } + AiModelSort::Context => right.context_length.cmp(&left.context_length), + AiModelSort::Latency => compare_optional(left.latency, right.latency), + AiModelSort::Throughput => compare_optional(right.throughput, left.throughput), + AiModelSort::CodingScore => compare_optional(right.coding_score, left.coding_score), + AiModelSort::Newest => right.created.cmp(&left.created), + }); +} + +fn compare_price(left: &Option, right: &Option) -> Ordering { + compare_optional( + left.as_deref().and_then(|value| value.parse().ok()), + right.as_deref().and_then(|value| value.parse().ok()), + ) +} + +fn compare_optional(left: Option, right: Option) -> Ordering { + match (left, right) { + (Some(left), Some(right)) => left.partial_cmp(&right).unwrap_or(Ordering::Equal), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +#[cfg(test)] +pub(crate) mod test_helpers { + use super::*; + use crate::ai::types::OpenRouterSettings; + use crate::git::types::AiReasoningPreference; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + + pub(crate) fn configuration(provider: AiProvider) -> EffectiveAiConfiguration { + EffectiveAiConfiguration { + enabled: true, + profile_id: "test".to_string(), + provider, + endpoint: provider.default_endpoint().to_string(), + model: "test-model".to_string(), + api_style: AiApiStyle::ChatCompletions, + request_path: "/chat/completions".to_string(), + models_path: "/models".to_string(), + auth_mode: AiAuthMode::Bearer, + auth_header: "Authorization".to_string(), + max_tokens_field: "max_tokens".to_string(), + extra_headers: Default::default(), + azure_deployment: String::new(), + azure_api_version: String::new(), + reasoning_preference: AiReasoningPreference::Automatic, + effort_capability: AiEffortCapability::Unknown, + open_router: OpenRouterSettings::default(), + commit_context_limit_kib: 24, + conflict_context_limit_kib: 48, + commit_message_max_tokens: 512, + conflict_resolution_max_tokens: 4096, + commit_message_prompt: String::new(), + conflict_resolution_prompt: String::new(), + include_commit_history: true, + global_exclusions: Vec::new(), + sources: Default::default(), + environment_fields: Vec::new(), + environment_api_key: false, + } + } + + pub(crate) fn conflict_contract() -> AiOutputContract { + AiOutputContract::JsonSchema { + name: "gitmun_conflict_resolution", + schema: serde_json::json!({ + "type": "object", + "properties": {"regions": {"type": "array"}}, + "required": ["regions"], + "additionalProperties": false + }), + } + } + + pub(crate) fn read_http_request(stream: &mut TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(str::trim) + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or_default(); + if request.len() >= header_end + 4 + content_length { + break; + } + } + } + String::from_utf8(request).unwrap() + } + + pub(crate) fn mock_provider( + responses: Vec<(&'static str, String)>, + ) -> (String, mpsc::Receiver>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}/v1", listener.local_addr().unwrap()); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let mut requests = Vec::new(); + for (status, body) in responses { + let (mut stream, _) = listener.accept().unwrap(); + requests.push(read_http_request(&mut stream)); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + } + drop(sender.send(requests)); + }); + (endpoint, receiver) + } +} + +#[cfg(test)] +mod fallback_tests { + use super::super::api::{AiRequestBudget, AiRuntime, AiStructuredOutputMode, AiTask}; + use super::test_helpers; + use crate::ai::types::{AiAuthMode, AiProvider, AiReasoningPreference}; + use serde_json::{Value, json}; + use std::time::Duration; + + #[tokio::test] + async fn structured_output_falls_back_once_and_caches_the_supported_mode() { + let completed = json!({ + "id": "generation-1", + "choices": [{ + "message": {"content": "{\"regions\":[]}"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 4, "completion_tokens": 2} + }) + .to_string(); + let (endpoint, requests) = test_helpers::mock_provider(vec![ + ( + "400 Bad Request", + r#"{"error":{"message":"response_format json_schema is not supported"}}"# + .to_string(), + ), + ("200 OK", completed.clone()), + ("200 OK", completed), + ]); + let runtime = AiRuntime::new().unwrap(); + let mut configuration = test_helpers::configuration(AiProvider::OpenAiCompatible); + configuration.endpoint = endpoint; + configuration.auth_mode = AiAuthMode::None; + configuration.reasoning_preference = AiReasoningPreference::ProviderDefault; + let contract = test_helpers::conflict_contract(); + + let mut first_budget = AiRequestBudget::new(); + let (_, _, first_mode) = super::run_provider_with_output( + &runtime, + &configuration, + "", + "system", + "user", + 128, + AiTask::ConflictResolution, + &mut first_budget, + &contract, + None, + ) + .await + .unwrap(); + let mut second_budget = AiRequestBudget::new(); + let (_, _, second_mode) = super::run_provider_with_output( + &runtime, + &configuration, + "", + "system", + "user", + 128, + AiTask::ConflictResolution, + &mut second_budget, + &contract, + None, + ) + .await + .unwrap(); + let requests = requests.recv_timeout(Duration::from_secs(2)).unwrap(); + let bodies = requests + .iter() + .map(|request| { + serde_json::from_str::(request.split("\r\n\r\n").nth(1).unwrap()).unwrap() + }) + .collect::>(); + + assert_eq!(first_mode, Some(AiStructuredOutputMode::JsonObject)); + assert_eq!(second_mode, Some(AiStructuredOutputMode::JsonObject)); + assert_eq!(first_budget.requests, 2); + assert_eq!(second_budget.requests, 1); + assert_eq!( + bodies[0].pointer("/response_format/type"), + Some(&json!("json_schema")) + ); + assert_eq!( + bodies[1].pointer("/response_format/type"), + Some(&json!("json_object")) + ); + assert_eq!( + bodies[2].pointer("/response_format/type"), + Some(&json!("json_object")) + ); + } + + #[tokio::test] + async fn automatic_effort_is_removed_only_once() { + let rejection = r#"{"error":{"message":"reasoning effort is not supported"}}"#; + let (endpoint, requests) = test_helpers::mock_provider(vec![ + ("400 Bad Request", rejection.to_string()), + ("400 Bad Request", rejection.to_string()), + ]); + let runtime = AiRuntime::new().unwrap(); + let mut configuration = test_helpers::configuration(AiProvider::OpenAiCompatible); + configuration.endpoint = endpoint; + configuration.auth_mode = AiAuthMode::None; + let mut budget = AiRequestBudget::new(); + + let error = match super::run_provider( + &runtime, + &configuration, + "", + "system", + "user", + 128, + AiTask::CommitMessage, + &mut budget, + None, + ) + .await + { + Ok(_) => panic!("a second effort rejection must fail"), + Err(error) => error, + }; + let requests = requests.recv_timeout(Duration::from_secs(2)).unwrap(); + let bodies = requests + .iter() + .map(|request| { + serde_json::from_str::(request.split("\r\n\r\n").nth(1).unwrap()).unwrap() + }) + .collect::>(); + + assert_eq!(error.code, "requestRejected"); + assert_eq!(budget.requests, 2); + assert_eq!(bodies[0].get("reasoning_effort"), Some(&json!("low"))); + assert!(bodies[1].get("reasoning_effort").is_none()); + } + + #[tokio::test] + async fn unsupported_schema_and_json_mode_fall_back_to_prompt_only() { + let completed = json!({ + "choices": [{"message": {"content": "{\"regions\":[]}"}, "finish_reason": "stop"}] + }) + .to_string(); + let (endpoint, requests) = test_helpers::mock_provider(vec![ + ( + "400 Bad Request", + r#"{"error":{"message":"response_format json_schema is unsupported"}}"#.to_string(), + ), + ( + "422 Unprocessable Entity", + r#"{"error":{"message":"invalid value for response_format"}}"#.to_string(), + ), + ("200 OK", completed), + ]); + let runtime = AiRuntime::new().unwrap(); + let mut configuration = test_helpers::configuration(AiProvider::OpenAiCompatible); + configuration.endpoint = endpoint; + configuration.auth_mode = AiAuthMode::None; + configuration.reasoning_preference = AiReasoningPreference::ProviderDefault; + let mut budget = AiRequestBudget::new(); + + let (_, _, mode) = super::run_provider_with_output( + &runtime, + &configuration, + "", + "system", + "user", + 128, + AiTask::ConflictResolution, + &mut budget, + &test_helpers::conflict_contract(), + None, + ) + .await + .unwrap(); + let requests = requests.recv_timeout(Duration::from_secs(2)).unwrap(); + let final_body: Value = + serde_json::from_str(requests[2].split("\r\n\r\n").nth(1).unwrap()).unwrap(); + + assert_eq!(mode, Some(AiStructuredOutputMode::PromptOnly)); + assert_eq!(budget.requests, 3); + assert!(final_body.get("response_format").is_none()); + } + + #[tokio::test] + async fn completed_generation_is_not_retried_for_invalid_content() { + let completed = json!({ + "choices": [{"message": {"content": "not json"}, "finish_reason": "stop"}] + }) + .to_string(); + let (endpoint, requests) = test_helpers::mock_provider(vec![("200 OK", completed)]); + let runtime = AiRuntime::new().unwrap(); + let mut configuration = test_helpers::configuration(AiProvider::OpenAiCompatible); + configuration.endpoint = endpoint; + configuration.auth_mode = AiAuthMode::None; + configuration.reasoning_preference = AiReasoningPreference::ProviderDefault; + let mut budget = AiRequestBudget::new(); + + let (result, _, mode) = super::run_provider_with_output( + &runtime, + &configuration, + "", + "system", + "user", + 128, + AiTask::ConflictResolution, + &mut budget, + &test_helpers::conflict_contract(), + None, + ) + .await + .unwrap(); + let requests = requests.recv_timeout(Duration::from_secs(2)).unwrap(); + + assert_eq!(result.text, "not json"); + assert_eq!(mode, Some(AiStructuredOutputMode::JsonSchema)); + assert_eq!(budget.requests, 1); + assert_eq!(requests.len(), 1); + } + + #[test] + fn unrelated_client_errors_do_not_trigger_structured_output_fallback() { + use super::super::api::AiStructuredOutputMode; + use super::super::api::rejected_structured_output; + assert!(!rejected_structured_output( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":{"message":"model is unavailable"}}"#, + Some(AiStructuredOutputMode::JsonSchema), + )); + assert!(!rejected_structured_output( + reqwest::StatusCode::TOO_MANY_REQUESTS, + br#"{"error":{"message":"response_format is temporarily unavailable"}}"#, + Some(AiStructuredOutputMode::JsonSchema), + )); + } + + #[test] + fn deprecation_notice_does_not_trigger_structured_output_fallback() { + use super::super::api::AiStructuredOutputMode; + use super::super::api::rejected_structured_output; + assert!(!rejected_structured_output( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":{"message":"response_format 'json_schema' is deprecated, use 'json_object' instead"}}"#, + Some(AiStructuredOutputMode::JsonSchema), + )); + assert!(!rejected_structured_output( + reqwest::StatusCode::BAD_REQUEST, + r#"response_format is deprecated"#.as_bytes(), + Some(AiStructuredOutputMode::JsonSchema), + )); + } + + #[test] + fn non_english_error_does_not_trigger_structured_output_fallback() { + use super::super::api::AiStructuredOutputMode; + use super::super::api::rejected_structured_output; + assert!(!rejected_structured_output( + reqwest::StatusCode::BAD_REQUEST, + r#"{"error":{"message":"Das Format 'json_schema' wird nicht unterstützt"}}"#.as_bytes(), + Some(AiStructuredOutputMode::JsonSchema), + )); + assert!(!rejected_structured_output( + reqwest::StatusCode::BAD_REQUEST, + r#"{"error":{"message":"格式 json_schema 不受支持"}}"#.as_bytes(), + Some(AiStructuredOutputMode::JsonSchema), + )); + } + + #[test] + fn structured_json_error_param_triggers_fallback() { + use super::super::api::AiStructuredOutputMode; + use super::super::api::rejected_structured_output; + assert!(rejected_structured_output( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":{"message":"Invalid response_format: 'json_schema' is not supported with this model.","type":"invalid_request_error","param":"response_format","code":null}}"#, + Some(AiStructuredOutputMode::JsonSchema), + )); + } + + #[test] + fn output_config_param_triggers_fallback() { + use super::super::api::AiStructuredOutputMode; + use super::super::api::rejected_structured_output; + assert!(rejected_structured_output( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":{"message":"output_config.format: unsupported value: json_schema","type":"invalid_request_error"}}"#, + Some(AiStructuredOutputMode::JsonSchema), + )); + } + + #[test] + fn structured_error_type_with_format_and_rejection_keywords_triggers_fallback() { + use super::super::api::AiStructuredOutputMode; + use super::super::api::rejected_structured_output; + assert!(rejected_structured_output( + reqwest::StatusCode::BAD_REQUEST, + br#"{"error":{"message":"response_format is not valid for this model","type":"validation_error","param":null}}"#, + Some(AiStructuredOutputMode::JsonSchema), + )); + assert!(rejected_structured_output( + reqwest::StatusCode::UNPROCESSABLE_ENTITY, + br#"{"error":{"message":"invalid value for response_format","type":"invalid_request_error"}}"#, + Some(AiStructuredOutputMode::JsonObject), + )); + } +} diff --git a/src-tauri/src/ai/providers/openrouter.rs b/src-tauri/src/ai/providers/openrouter.rs new file mode 100644 index 0000000..b5367d0 --- /dev/null +++ b/src-tauri/src/ai/providers/openrouter.rs @@ -0,0 +1,693 @@ +//! OpenRouter provider: extension, catalogue, details, ZDR, OAuth exchange, diagnostics. + +use std::collections::HashSet; + +use reqwest::{RequestBuilder, StatusCode}; +use serde_json::{Map, Value, json}; +use url::Url; + +use super::super::AiError; +use super::super::api::{ + AiModelInfo, AiRuntime, AiStructuredOutputMode, MAX_MODELS_RESPONSE_BYTES, + MAX_OAUTH_RESPONSE_BYTES, MODEL_DISCOVERY_ATTEMPTS, OpenAiCompatibleExtension, ProviderResult, + REQUEST_TIMEOUT, authenticate, endpoint_with_path, first_f64, network_error, read_response, + response_error, string_array, string_number, +}; +use super::super::configuration::EffectiveAiConfiguration; +use super::super::types::{OpenRouterPrivacy, OpenRouterRoutingStrategy}; + +const OPENROUTER_APP_URL: &str = "https://gitmun.org"; +const OPENROUTER_APP_TITLE: &str = "Gitmun"; +const OPENROUTER_APP_CATEGORIES: &str = "programming-app"; + +// --------------------------------------------------------------------------- +// OpenRouterProvider +// --------------------------------------------------------------------------- + +pub(crate) struct OpenRouterProvider; + +static OPENROUTER_EXTENSION: OpenRouterExtension = OpenRouterExtension; + +impl OpenRouterProvider { + pub(crate) fn extension() -> &'static OpenRouterExtension { + &OPENROUTER_EXTENSION + } + + /// Add OpenRouter attribution headers to any request builder. + pub(crate) fn add_openrouter_headers(request: RequestBuilder) -> RequestBuilder { + OPENROUTER_EXTENSION.add_headers(request) + } + + pub(crate) fn normalise_model(value: &Value) -> Option { + OPENROUTER_EXTENSION.normalise_model(value) + } + + pub(crate) async fn discover_details( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + model_id: &str, + ) -> Result { + if configuration.provider != super::super::types::AiProvider::OpenRouter + || model_id.is_empty() + || model_id.len() > 256 + || model_id.split('/').any(|segment| { + segment.is_empty() + || matches!(segment, "." | "..") + || segment.chars().any(char::is_control) + }) + { + return Err(AiError::new("modelDiscoveryUnavailable")); + } + let endpoint = endpoint_with_path(configuration, &format!("/models/{model_id}/endpoints"))?; + let value = fetch_openrouter_metadata(runtime, configuration, api_key, endpoint).await?; + let model = normalise_openrouter_endpoint_details(&value, model_id)?; + if configuration.model == model_id { + if let Some(mode) = discovered_structured_output_mode(&model.supported_parameters) { + runtime.remember_structured_output_mode(configuration, mode); + } + } + Ok(model) + } + + pub(crate) async fn fetch_zdr_models( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + ) -> Result, AiError> { + let endpoint = endpoint_with_path(configuration, "/endpoints/zdr")?; + let mut last_error = AiError::new("providerUnavailable"); + for _ in 0..MODEL_DISCOVERY_ATTEMPTS { + let request = OPENROUTER_EXTENSION.add_headers( + runtime + .client + .get(endpoint.clone()) + .timeout(REQUEST_TIMEOUT), + ); + let response = match authenticate(request, configuration, api_key)?.send().await { + Ok(response) => response, + Err(error) => { + last_error = network_error(error); + continue; + } + }; + let (status, _, bytes) = read_response(response, MAX_MODELS_RESPONSE_BYTES).await?; + if status.is_success() { + let value: Value = + serde_json::from_slice(&bytes).map_err(|_| AiError::new("invalidResponse"))?; + return parse_zdr_model_ids(&value); + } + last_error = response_error(status); + if status.is_client_error() && status != StatusCode::TOO_MANY_REQUESTS { + return Err(last_error); + } + } + Err(last_error) + } +} + +// --------------------------------------------------------------------------- +// OpenRouterExtension +// --------------------------------------------------------------------------- + +pub(crate) struct OpenRouterExtension; + +impl OpenAiCompatibleExtension for OpenRouterExtension { + fn add_headers(&self, request: RequestBuilder) -> RequestBuilder { + request + .header("HTTP-Referer", OPENROUTER_APP_URL) + .header("X-OpenRouter-Title", OPENROUTER_APP_TITLE) + .header("X-OpenRouter-Categories", OPENROUTER_APP_CATEGORIES) + } + + fn extend_request( + &self, + configuration: &EffectiveAiConfiguration, + body: &mut Map, + ) { + let settings = &configuration.open_router; + let mut provider = Map::new(); + provider.insert( + "allow_fallbacks".to_string(), + json!(settings.allow_fallbacks), + ); + provider.insert( + "require_parameters".to_string(), + json!(settings.require_parameters), + ); + match settings.privacy { + OpenRouterPrivacy::NoDataCollection => { + provider.insert("data_collection".to_string(), json!("deny")); + } + OpenRouterPrivacy::StrictZdr => { + provider.insert("data_collection".to_string(), json!("deny")); + provider.insert("zdr".to_string(), json!(true)); + } + OpenRouterPrivacy::AccountDefault => {} + } + let sort = match settings.routing_strategy { + OpenRouterRoutingStrategy::Default => None, + OpenRouterRoutingStrategy::Price => Some("price"), + OpenRouterRoutingStrategy::Latency => Some("latency"), + OpenRouterRoutingStrategy::Throughput => Some("throughput"), + }; + if let Some(sort) = sort { + provider.insert("sort".to_string(), json!(sort)); + } + let mut maximum_price = Map::new(); + if let Ok(price) = settings.max_prompt_price.parse::() { + maximum_price.insert("prompt".to_string(), json!(price)); + } + if let Ok(price) = settings.max_completion_price.parse::() { + maximum_price.insert("completion".to_string(), json!(price)); + } + if !maximum_price.is_empty() { + provider.insert("max_price".to_string(), Value::Object(maximum_price)); + } + if !settings.preferred_providers.is_empty() { + provider.insert("order".to_string(), json!(settings.preferred_providers)); + } + if !settings.allowed_providers.is_empty() { + provider.insert("only".to_string(), json!(settings.allowed_providers)); + } + if !settings.ignored_providers.is_empty() { + provider.insert("ignore".to_string(), json!(settings.ignored_providers)); + } + if let Ok(latency) = settings.preferred_max_latency.parse::() { + provider.insert("preferred_max_latency".to_string(), json!(latency)); + } + if let Ok(throughput) = settings.preferred_min_throughput.parse::() { + provider.insert("preferred_min_throughput".to_string(), json!(throughput)); + } + body.insert("provider".to_string(), Value::Object(provider)); + } + + fn extend_result(&self, value: &Value, result: &mut ProviderResult) { + result.usage.cost = value.pointer("/usage/cost").and_then(Value::as_f64); + result.usage.byok = value + .pointer("/usage/is_byok") + .or_else(|| value.pointer("/usage/byok")) + .and_then(Value::as_bool); + result.routed_provider = value + .get("provider") + .or_else(|| value.pointer("/usage/provider")) + .and_then(Value::as_str) + .map(str::to_string); + result.routed_model = value + .get("model") + .and_then(Value::as_str) + .map(str::to_string); + } + + fn normalise_model(&self, value: &Value) -> Option { + let id = value.get("id")?.as_str()?.to_string(); + let parameters = string_array(value.get("supported_parameters")); + let available_providers = string_array(value.get("available_providers")) + .into_iter() + .chain( + value + .get("endpoints") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|endpoint| endpoint.get("provider_name").and_then(Value::as_str)) + .map(str::to_string), + ) + .collect::>(); + let quantisations = string_array(value.get("quantizations")) + .into_iter() + .chain(string_array(value.get("quantisations"))) + .collect(); + Some(AiModelInfo { + name: value + .get("name") + .and_then(Value::as_str) + .unwrap_or(&id) + .to_string(), + id, + canonical_slug: value + .get("canonical_slug") + .and_then(Value::as_str) + .map(str::to_string), + description: value + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + context_length: value.get("context_length").and_then(Value::as_u64), + maximum_completion_tokens: value + .pointer("/top_provider/max_completion_tokens") + .or_else(|| value.get("max_completion_tokens")) + .and_then(Value::as_u64), + input_modalities: string_array(value.pointer("/architecture/input_modalities")), + output_modalities: string_array(value.pointer("/architecture/output_modalities")), + reasoning: parameters.iter().any(|parameter| parameter == "reasoning"), + structured_output: parameters.iter().any(|parameter| { + matches!(parameter.as_str(), "response_format" | "structured_outputs") + }), + supported_parameters: parameters, + prompt_price: string_number(value.pointer("/pricing/prompt")), + completion_price: string_number(value.pointer("/pricing/completion")), + request_price: string_number(value.pointer("/pricing/request")), + cache_read_price: string_number(value.pointer("/pricing/input_cache_read")), + cache_write_price: string_number(value.pointer("/pricing/input_cache_write")), + available_providers, + quantisations, + latency: first_f64(value, &["/performance/latency", "/latency"]) + .map(|milliseconds| milliseconds / 1000.0), + throughput: first_f64(value, &["/performance/throughput", "/throughput"]), + uptime: first_f64(value, &["/performance/uptime", "/uptime"]), + coding_score: first_f64(value, &["/performance/coding_score", "/coding_score"]), + zero_data_retention: value + .get("zdr") + .or_else(|| value.get("zero_data_retention")) + .and_then(Value::as_bool), + created: value.get("created").and_then(Value::as_u64), + }) + } +} + +// --------------------------------------------------------------------------- +// OAuth exchange +// --------------------------------------------------------------------------- + +pub(crate) async fn exchange_openrouter_oauth_code( + runtime: &AiRuntime, + code: &str, + code_verifier: &str, +) -> Result { + let response = OPENROUTER_EXTENSION + .add_headers( + runtime + .client + .post("https://openrouter.ai/api/v1/auth/keys"), + ) + .json(&json!({ + "code": code, + "code_verifier": code_verifier, + "code_challenge_method": "S256", + })) + .send() + .await + .map_err(network_error)?; + let (status, _, body) = read_response(response, MAX_OAUTH_RESPONSE_BYTES).await?; + if !status.is_success() { + return Err(AiError::with_detail( + "openRouterOAuthFailed", + status.as_u16().to_string(), + )); + } + parse_openrouter_oauth_exchange(&body) +} + +pub(crate) fn parse_openrouter_oauth_exchange(body: &[u8]) -> Result { + serde_json::from_slice::(body) + .ok() + .and_then(|value| value.get("key").and_then(Value::as_str).map(str::to_string)) + .filter(|key| { + !key.trim().is_empty() && key.len() <= 1024 && !key.chars().any(char::is_control) + }) + .ok_or_else(|| AiError::new("invalidResponse")) +} + +// --------------------------------------------------------------------------- +// Detailed endpoint metadata aggregation +// --------------------------------------------------------------------------- + +async fn fetch_openrouter_metadata( + runtime: &AiRuntime, + configuration: &EffectiveAiConfiguration, + api_key: &str, + endpoint: Url, +) -> Result { + let mut last_error = AiError::new("providerUnavailable"); + for _ in 0..MODEL_DISCOVERY_ATTEMPTS { + let request = OPENROUTER_EXTENSION.add_headers( + runtime + .client + .get(endpoint.clone()) + .timeout(REQUEST_TIMEOUT), + ); + let response = match authenticate(request, configuration, api_key)?.send().await { + Ok(response) => response, + Err(error) => { + last_error = network_error(error); + continue; + } + }; + let (status, _, bytes) = read_response(response, MAX_MODELS_RESPONSE_BYTES).await?; + if status.is_success() { + return serde_json::from_slice(&bytes).map_err(|_| AiError::new("invalidResponse")); + } + last_error = response_error(status); + if status.is_client_error() && status != StatusCode::TOO_MANY_REQUESTS { + return Err(last_error); + } + } + Err(last_error) +} + +fn normalise_openrouter_endpoint_details( + value: &Value, + model_id: &str, +) -> Result { + let data = value.get("data").unwrap_or(value); + let endpoints = data + .get("endpoints") + .and_then(Value::as_array) + .ok_or_else(|| AiError::new("invalidResponse"))?; + let mut model = OPENROUTER_EXTENSION + .normalise_model(data) + .unwrap_or_else(|| AiModelInfo { + id: model_id.to_string(), + name: data + .get("name") + .and_then(Value::as_str) + .unwrap_or(model_id) + .to_string(), + ..AiModelInfo::default() + }); + for endpoint in endpoints { + push_unique( + &mut model.available_providers, + endpoint + .get("provider_name") + .or_else(|| endpoint.get("provider")) + .and_then(Value::as_str), + ); + push_unique( + &mut model.quantisations, + endpoint + .get("quantization") + .or_else(|| endpoint.get("quantisation")) + .and_then(Value::as_str), + ); + for parameter in string_array(endpoint.get("supported_parameters")) { + push_unique(&mut model.supported_parameters, Some(¶meter)); + } + model.context_length = maximum_u64( + model.context_length, + endpoint.get("context_length").and_then(Value::as_u64), + ); + model.maximum_completion_tokens = maximum_u64( + model.maximum_completion_tokens, + endpoint + .get("max_completion_tokens") + .and_then(Value::as_u64), + ); + model.prompt_price = minimum_price( + model.prompt_price.clone(), + string_number(endpoint.pointer("/pricing/prompt")), + ); + model.completion_price = minimum_price( + model.completion_price.clone(), + string_number(endpoint.pointer("/pricing/completion")), + ); + let endpoint_latency = first_f64(endpoint, &["/latency_last_30m/p50"]).or_else(|| { + first_f64(endpoint, &["/performance/latency", "/latency"]) + .map(|milliseconds| milliseconds / 1000.0) + }); + model.latency = match (model.latency, endpoint_latency) { + (Some(current), Some(candidate)) => Some(current.min(candidate)), + (current, candidate) => current.or(candidate), + }; + model.throughput = maximum_f64( + model.throughput, + first_f64( + endpoint, + &[ + "/throughput_last_30m/p50", + "/performance/throughput", + "/throughput", + ], + ), + ); + model.uptime = maximum_f64( + model.uptime, + first_f64( + endpoint, + &["/uptime_last_30m", "/performance/uptime", "/uptime"], + ), + ); + } + model.reasoning = model + .supported_parameters + .iter() + .any(|parameter| parameter == "reasoning"); + model.structured_output = model + .supported_parameters + .iter() + .any(|parameter| matches!(parameter.as_str(), "response_format" | "structured_outputs")); + Ok(model) +} + +fn discovered_structured_output_mode( + supported_parameters: &[String], +) -> Option { + if supported_parameters + .iter() + .any(|parameter| parameter == "structured_outputs") + { + Some(AiStructuredOutputMode::JsonSchema) + } else if supported_parameters + .iter() + .any(|parameter| parameter == "response_format") + { + Some(AiStructuredOutputMode::JsonObject) + } else if supported_parameters.is_empty() { + None + } else { + Some(AiStructuredOutputMode::PromptOnly) + } +} + +fn parse_zdr_model_ids(value: &Value) -> Result, AiError> { + value + .get("data") + .and_then(Value::as_array) + .ok_or_else(|| AiError::new("invalidResponse")) + .map(|endpoints| { + endpoints + .iter() + .filter_map(|endpoint| endpoint.get("model_id").and_then(Value::as_str)) + .map(str::to_string) + .collect() + }) +} + +fn push_unique(values: &mut Vec, value: Option<&str>) { + if let Some(value) = value.filter(|value| !value.is_empty()) + && !values.iter().any(|existing| existing == value) + { + values.push(value.to_string()); + } +} + +fn maximum_u64(left: Option, right: Option) -> Option { + match (left, right) { + (Some(left), Some(right)) => Some(left.max(right)), + (left, right) => left.or(right), + } +} + +fn maximum_f64(left: Option, right: Option) -> Option { + match (left, right) { + (Some(left), Some(right)) => Some(left.max(right)), + (left, right) => left.or(right), + } +} + +fn minimum_price(left: Option, right: Option) -> Option { + match ( + left.as_deref().and_then(|value| value.parse::().ok()), + right.as_deref().and_then(|value| value.parse::().ok()), + ) { + (Some(left_price), Some(right_price)) if right_price < left_price => right, + (Some(_), _) => left, + (None, Some(_)) => right, + (None, None) => left.or(right), + } +} + +#[cfg(test)] +mod tests { + use super::super::test_helpers; + use super::*; + use crate::ai::api::ProtocolAdapter; + use crate::ai::api::openai::OpenAiAdapter; + use crate::ai::providers::openrouter::OpenRouterProvider; + use crate::ai::types::OpenRouterPrivacy; + use reqwest::{Client, header::HeaderMap}; + use serde_json::json; + + #[test] + fn openrouter_extension_adds_privacy_and_routing_fields() { + let mut configuration = + test_helpers::configuration(super::super::super::types::AiProvider::OpenRouter); + configuration.open_router.privacy = OpenRouterPrivacy::StrictZdr; + configuration.open_router.max_prompt_price = "2.5".to_string(); + let body = OpenAiAdapter.request_body( + &configuration, + "system", + "user", + 100, + None, + &crate::ai::api::AiOutputContract::Text, + None, + Some(&OpenRouterExtension), + ); + + assert_eq!( + body.pointer("/provider/data_collection"), + Some(&json!("deny")) + ); + assert_eq!(body.pointer("/provider/zdr"), Some(&json!(true))); + assert_eq!( + body.pointer("/provider/require_parameters"), + Some(&json!(true)) + ); + assert_eq!( + body.pointer("/provider/max_price/prompt"), + Some(&json!(2.5)) + ); + } + + #[test] + fn openrouter_extension_adds_app_attribution_headers() { + let request = OpenRouterExtension + .add_headers(Client::new().get("https://openrouter.ai/api/v1/models/user")) + .build() + .unwrap(); + + assert_eq!( + request.headers().get("HTTP-Referer").unwrap(), + "https://gitmun.org" + ); + assert_eq!( + request.headers().get("X-OpenRouter-Title").unwrap(), + "Gitmun" + ); + assert_eq!( + request.headers().get("X-OpenRouter-Categories").unwrap(), + "programming-app" + ); + } + + #[test] + fn parses_only_a_bounded_openrouter_oauth_key() { + assert_eq!( + parse_openrouter_oauth_exchange(br#"{"key":"sk-or-test"}"#).unwrap(), + "sk-or-test" + ); + assert_eq!( + parse_openrouter_oauth_exchange(br#"{"user_id":"user"}"#) + .unwrap_err() + .code, + "invalidResponse" + ); + assert_eq!( + parse_openrouter_oauth_exchange(b"{\"key\":\"line\\nbreak\"}") + .unwrap_err() + .code, + "invalidResponse" + ); + } + + #[test] + fn openrouter_zdr_catalogue_is_normalised_without_adding_models() { + let models = parse_zdr_model_ids(&json!({ + "data": [ + {"model_id": "author/private-model", "provider_name": "Example"}, + {"model_id": "author/second-model", "provider_name": "Other"} + ] + })) + .unwrap(); + + assert_eq!(models.len(), 2); + assert!(models.contains("author/private-model")); + } + + #[test] + fn openrouter_catalogue_latency_is_converted_from_milliseconds() { + let model = OpenRouterProvider::normalise_model(&json!({ + "id": "author/model", + "name": "Model", + "latency": 1912, + "throughput": 74.25, + "uptime": 99.93280607445571 + })) + .unwrap(); + + assert_eq!(model.latency, Some(1.912)); + assert_eq!(model.throughput, Some(74.25)); + assert_eq!(model.uptime, Some(99.93280607445571)); + } + + #[test] + fn openrouter_endpoint_metadata_is_aggregated() { + let model = normalise_openrouter_endpoint_details( + &json!({ + "data": { + "id": "author/model", + "name": "Model", + "endpoints": [{ + "provider_name": "Example", + "quantization": "fp8", + "context_length": 128000, + "max_completion_tokens": 16000, + "supported_parameters": ["reasoning", "response_format"], + "pricing": {"prompt": "0.000001", "completion": "0.000002"}, + "latency_last_30m": {"p50": 0.4}, + "throughput_last_30m": {"p50": 90.0}, + "uptime_last_30m": 99.9 + }] + } + }), + "author/model", + ) + .unwrap(); + + assert_eq!(model.available_providers, vec!["Example"]); + assert_eq!(model.quantisations, vec!["fp8"]); + assert_eq!(model.context_length, Some(128000)); + assert!(model.reasoning); + assert!(model.structured_output); + assert_eq!(model.latency, Some(0.4)); + assert_eq!(model.uptime, Some(99.9)); + } + + #[test] + fn openrouter_endpoint_parameters_select_the_best_structured_output_mode() { + assert_eq!( + super::discovered_structured_output_mode(&["structured_outputs".to_string()]), + Some(crate::ai::api::AiStructuredOutputMode::JsonSchema) + ); + assert_eq!( + super::discovered_structured_output_mode(&["response_format".to_string()]), + Some(crate::ai::api::AiStructuredOutputMode::JsonObject) + ); + assert_eq!( + super::discovered_structured_output_mode(&["temperature".to_string()]), + Some(crate::ai::api::AiStructuredOutputMode::PromptOnly) + ); + assert_eq!(super::discovered_structured_output_mode(&[]), None); + } + + #[test] + fn openrouter_error_diagnostics_exclude_messages_and_unsafe_values() { + let mut configuration = + test_helpers::configuration(super::super::super::types::AiProvider::OpenRouter); + configuration.open_router.diagnostics = true; + use crate::ai::api::provider_response_error; + let error = provider_response_error( + &configuration, + reqwest::StatusCode::BAD_REQUEST, + &HeaderMap::new(), + br#"{"error":{"message":"secret prompt text","metadata":{"provider_name":"safe-provider","generation_id":"unsafe value"}}}"#, + ); + + assert_eq!( + error.detail.as_deref(), + Some("status=400; provider=safe-provider") + ); + } +} diff --git a/src-tauri/src/ai/types.rs b/src-tauri/src/ai/types.rs new file mode 100644 index 0000000..d6f9fbc --- /dev/null +++ b/src-tauri/src/ai/types.rs @@ -0,0 +1,541 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Deserializer, Serialize}; + +pub const DEFAULT_COMMIT_CONTEXT_LIMIT_KIB: u32 = 24; +pub const DEFAULT_CONFLICT_CONTEXT_LIMIT_KIB: u32 = 48; +const MIN_CONTEXT_LIMIT_KIB: u32 = 8; +const MAX_CONTEXT_LIMIT_KIB: u32 = 1024; +pub const DEFAULT_COMMIT_MESSAGE_MAX_TOKENS: u32 = 512; +pub const DEFAULT_CONFLICT_RESOLUTION_MAX_TOKENS: u32 = 4096; +const MIN_OUTPUT_TOKENS: u32 = 1; +const MAX_OUTPUT_TOKENS: u32 = 65_536; +pub const DEFAULT_COMMIT_MESSAGE_PROMPT: &str = "Write a concise Git commit message in the style of the supplied recent commits. Return only the commit message as plain text. Put the subject first, then an optional blank line and body. Summarise the staged changes accurately. Do not use Markdown headings, lists, fences, emoji, or commentary."; +pub const DEFAULT_CONFLICT_RESOLUTION_PROMPT: &str = "Resolve the supplied Git conflict regions. Preserve intended behaviour and surrounding style. Return only the requested structured JSON with one replacement for every supplied region ID."; + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub enum AiProvider { + #[default] + Disabled, + OpenAi, + Claude, + Bedrock, + Mistral, + GoogleGemini, + OpenRouter, + AzureOpenAi, + Ollama, + LmStudio, + OpenAiCompatible, +} + +impl AiProvider { + pub fn default_endpoint(self) -> &'static str { + match self { + Self::OpenAi => "https://api.openai.com/v1", + Self::Claude => "https://api.anthropic.com/v1", + Self::Bedrock => "https://bedrock-runtime.eu-west-2.amazonaws.com", + Self::Mistral => "https://api.mistral.ai/v1", + Self::GoogleGemini => "https://generativelanguage.googleapis.com/v1beta/openai", + Self::OpenRouter => "https://openrouter.ai/api/v1", + Self::Ollama => "http://127.0.0.1:11434/v1", + Self::LmStudio => "http://127.0.0.1:1234/v1", + Self::AzureOpenAi | Self::OpenAiCompatible | Self::Disabled => "", + } + } + + pub fn is_openai_compatible(self) -> bool { + !matches!(self, Self::Disabled | Self::Claude | Self::Bedrock) + } + + pub fn api_key_optional(self, endpoint_is_loopback: bool) -> bool { + endpoint_is_loopback + && matches!(self, Self::Ollama | Self::LmStudio | Self::OpenAiCompatible) + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum AiApiStyle { + #[default] + ChatCompletions, + Responses, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum AiCommitMessageMode { + #[default] + RepositoryStyle, + ConventionalCommits, + FreeForm, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum AiAuthMode { + #[default] + Bearer, + Header, + AwsSigV4, + None, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum AiReasoningPreference { + #[default] + Automatic, + ProviderDefault, + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", content = "levels", rename_all = "camelCase")] +pub enum AiEffortCapability { + #[default] + Unknown, + Accepted, + Unsupported, + Supported(Vec), +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum OpenRouterPrivacy { + #[default] + NoDataCollection, + StrictZdr, + AccountDefault, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +pub enum OpenRouterRoutingStrategy { + #[default] + Default, + Price, + Latency, + Throughput, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct OpenRouterSettings { + pub privacy: OpenRouterPrivacy, + pub allow_fallbacks: bool, + pub require_parameters: bool, + pub routing_strategy: OpenRouterRoutingStrategy, + pub max_prompt_price: String, + pub max_completion_price: String, + pub preferred_providers: Vec, + pub allowed_providers: Vec, + pub ignored_providers: Vec, + pub preferred_max_latency: String, + pub preferred_min_throughput: String, + pub diagnostics: bool, +} + +impl Default for OpenRouterSettings { + fn default() -> Self { + Self { + privacy: OpenRouterPrivacy::NoDataCollection, + allow_fallbacks: true, + require_parameters: true, + routing_strategy: OpenRouterRoutingStrategy::Default, + max_prompt_price: String::new(), + max_completion_price: String::new(), + preferred_providers: Vec::new(), + allowed_providers: Vec::new(), + ignored_providers: Vec::new(), + preferred_max_latency: String::new(), + preferred_min_throughput: String::new(), + diagnostics: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct AiProfile { + pub id: String, + pub name: String, + pub provider: AiProvider, + pub endpoint: String, + pub model: String, + pub api_style: AiApiStyle, + pub request_path: String, + pub models_path: String, + pub auth_mode: AiAuthMode, + pub auth_header: String, + pub max_tokens_field: String, + pub extra_headers: BTreeMap, + pub azure_deployment: String, + pub azure_api_version: String, + pub reasoning_preference: AiReasoningPreference, + pub effort_capability: AiEffortCapability, + pub open_router: OpenRouterSettings, +} + +impl Default for AiProfile { + fn default() -> Self { + Self { + id: "default".to_string(), + name: String::new(), + provider: AiProvider::OpenRouter, + endpoint: String::new(), + model: String::new(), + api_style: AiApiStyle::ChatCompletions, + request_path: String::new(), + models_path: String::new(), + auth_mode: AiAuthMode::Bearer, + auth_header: String::new(), + max_tokens_field: String::new(), + extra_headers: BTreeMap::new(), + azure_deployment: String::new(), + azure_api_version: String::new(), + reasoning_preference: AiReasoningPreference::Automatic, + effort_capability: AiEffortCapability::Unknown, + open_router: OpenRouterSettings::default(), + } + } +} + +fn default_commit_context_limit_kib() -> u32 { + DEFAULT_COMMIT_CONTEXT_LIMIT_KIB +} + +fn default_conflict_context_limit_kib() -> u32 { + DEFAULT_CONFLICT_CONTEXT_LIMIT_KIB +} + +fn default_commit_message_max_tokens() -> u32 { + DEFAULT_COMMIT_MESSAGE_MAX_TOKENS +} + +fn default_conflict_resolution_max_tokens() -> u32 { + DEFAULT_CONFLICT_RESOLUTION_MAX_TOKENS +} + +fn default_commit_message_prompt() -> String { + DEFAULT_COMMIT_MESSAGE_PROMPT.to_string() +} + +fn default_conflict_resolution_prompt() -> String { + DEFAULT_CONFLICT_RESOLUTION_PROMPT.to_string() +} + +fn deserialise_context_limit<'de, D>(deserialiser: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(normalise_context_limit(u32::deserialize(deserialiser)?)) +} + +fn deserialise_output_tokens<'de, D>(deserialiser: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(normalise_output_tokens(u32::deserialize(deserialiser)?)) +} + +fn deserialise_commit_prompt<'de, D>(deserialiser: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(normalise_prompt( + String::deserialize(deserialiser)?, + DEFAULT_COMMIT_MESSAGE_PROMPT, + )) +} + +fn deserialise_conflict_prompt<'de, D>(deserialiser: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(normalise_prompt( + String::deserialize(deserialiser)?, + DEFAULT_CONFLICT_RESOLUTION_PROMPT, + )) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct AiExtensionSettings { + pub enabled: bool, + pub selected_profile_id: String, + pub profiles: Vec, + #[serde( + default = "default_commit_context_limit_kib", + deserialize_with = "deserialise_context_limit" + )] + pub commit_context_limit_kib: u32, + #[serde( + default = "default_conflict_context_limit_kib", + deserialize_with = "deserialise_context_limit" + )] + pub conflict_context_limit_kib: u32, + #[serde( + default = "default_commit_message_max_tokens", + deserialize_with = "deserialise_output_tokens" + )] + pub commit_message_max_tokens: u32, + #[serde( + default = "default_conflict_resolution_max_tokens", + deserialize_with = "deserialise_output_tokens" + )] + pub conflict_resolution_max_tokens: u32, + #[serde( + default = "default_commit_message_prompt", + deserialize_with = "deserialise_commit_prompt" + )] + pub commit_message_prompt: String, + #[serde( + default = "default_conflict_resolution_prompt", + deserialize_with = "deserialise_conflict_prompt" + )] + pub conflict_resolution_prompt: String, + pub include_commit_history: bool, + pub global_exclusions: Vec, + pub consented_destinations: Vec, + pub repository_policies: BTreeMap, + pub structured_output_modes: HashMap, + pub usage_history: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct AiUsageRecord { + pub timestamp: u64, + pub provider: AiProvider, + pub profile_id: String, + pub model: String, + pub task: String, + pub duration_ms: u64, + pub input_tokens: Option, + pub output_tokens: Option, + pub reasoning_tokens: Option, + pub cached_tokens: Option, + pub cost: Option, + pub byok: Option, + pub request_id: Option, + pub generation_id: Option, + pub routed_provider: Option, + pub routed_model: Option, + pub diagnostic: Option, + pub status: String, +} + +impl Default for AiUsageRecord { + fn default() -> Self { + Self { + timestamp: 0, + provider: AiProvider::Disabled, + profile_id: String::new(), + model: String::new(), + task: String::new(), + duration_ms: 0, + input_tokens: None, + output_tokens: None, + reasoning_tokens: None, + cached_tokens: None, + cost: None, + byok: None, + request_id: None, + generation_id: None, + routed_provider: None, + routed_model: None, + diagnostic: None, + status: String::new(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct AiRepositoryPolicy { + pub exclusions: Vec, + pub include_commit_history: Option, + pub conventional_commits: bool, + pub commit_message_mode: Option, + pub default_commit_type: String, + pub default_commit_scope: String, + pub default_language: String, + pub commit_prompt_file: String, + pub conflict_prompt_file: String, +} + +impl AiRepositoryPolicy { + pub fn effective_commit_message_mode(&self) -> AiCommitMessageMode { + self.commit_message_mode + .unwrap_or(if self.conventional_commits { + AiCommitMessageMode::ConventionalCommits + } else { + AiCommitMessageMode::RepositoryStyle + }) + } +} + +impl Default for AiExtensionSettings { + fn default() -> Self { + Self { + enabled: false, + selected_profile_id: String::new(), + profiles: Vec::new(), + commit_context_limit_kib: DEFAULT_COMMIT_CONTEXT_LIMIT_KIB, + conflict_context_limit_kib: DEFAULT_CONFLICT_CONTEXT_LIMIT_KIB, + commit_message_max_tokens: DEFAULT_COMMIT_MESSAGE_MAX_TOKENS, + conflict_resolution_max_tokens: DEFAULT_CONFLICT_RESOLUTION_MAX_TOKENS, + commit_message_prompt: default_commit_message_prompt(), + conflict_resolution_prompt: default_conflict_resolution_prompt(), + include_commit_history: true, + global_exclusions: Vec::new(), + consented_destinations: Vec::new(), + repository_policies: BTreeMap::new(), + structured_output_modes: HashMap::new(), + usage_history: Vec::new(), + } + } +} + +impl AiExtensionSettings { + pub fn selected_profile(&self) -> Option<&AiProfile> { + self.profiles + .iter() + .find(|profile| profile.id == self.selected_profile_id) + .or_else(|| self.profiles.first()) + } + + pub fn selected_profile_mut(&mut self) -> Option<&mut AiProfile> { + let selected = self.selected_profile_id.clone(); + let index = self + .profiles + .iter() + .position(|profile| profile.id == selected) + .or_else(|| (!self.profiles.is_empty()).then_some(0))?; + self.profiles.get_mut(index) + } + + pub fn ensure_profile(&mut self) -> &mut AiProfile { + if self.selected_profile().is_none() { + self.profiles.push(AiProfile::default()); + self.selected_profile_id = "default".to_string(); + } + let index = self + .profiles + .iter() + .position(|profile| profile.id == self.selected_profile_id) + .unwrap_or(0); + &mut self.profiles[index] + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExtensionSettings { + pub ai: AiExtensionSettings, +} + +pub fn normalise_context_limit(value: u32) -> u32 { + value.clamp(MIN_CONTEXT_LIMIT_KIB, MAX_CONTEXT_LIMIT_KIB) +} + +pub fn normalise_output_tokens(value: u32) -> u32 { + value.clamp(MIN_OUTPUT_TOKENS, MAX_OUTPUT_TOKENS) +} + +pub fn normalise_prompt(value: String, default: &str) -> String { + let value = value.trim(); + if value.is_empty() { + default.to_string() + } else { + value.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_defaults_are_centralised() { + assert_eq!( + AiProvider::Mistral.default_endpoint(), + "https://api.mistral.ai/v1" + ); + assert_eq!( + AiProvider::GoogleGemini.default_endpoint(), + "https://generativelanguage.googleapis.com/v1beta/openai" + ); + assert_eq!( + AiProvider::OpenRouter.default_endpoint(), + "https://openrouter.ai/api/v1" + ); + assert_eq!( + AiProvider::Bedrock.default_endpoint(), + "https://bedrock-runtime.eu-west-2.amazonaws.com" + ); + assert!(!AiProvider::Bedrock.is_openai_compatible()); + } + + #[test] + fn extension_is_opt_in() { + let settings = AiExtensionSettings::default(); + assert!(!settings.enabled); + assert!(settings.profiles.is_empty()); + assert_eq!(AiProfile::default().provider, AiProvider::OpenRouter); + } + + #[test] + fn openrouter_defaults_prevent_data_collection() { + let settings = OpenRouterSettings::default(); + assert_eq!(settings.privacy, OpenRouterPrivacy::NoDataCollection); + assert!(settings.allow_fallbacks); + assert!(settings.require_parameters); + } + + #[test] + fn legacy_repository_policy_uses_conventional_commit_mode() { + let policy: AiRepositoryPolicy = serde_json::from_value(serde_json::json!({ + "conventionalCommits": true, + "defaultLanguage": "English" + })) + .unwrap(); + + assert_eq!(policy.commit_message_mode, None); + assert_eq!( + policy.effective_commit_message_mode(), + AiCommitMessageMode::ConventionalCommits + ); + assert_eq!(policy.default_language, "English"); + } + + #[test] + fn repository_commit_defaults_round_trip() { + let policy = AiRepositoryPolicy { + commit_message_mode: Some(AiCommitMessageMode::FreeForm), + default_commit_type: "docs".to_string(), + default_commit_scope: "ai".to_string(), + default_language: "British English".to_string(), + ..AiRepositoryPolicy::default() + }; + + let restored: AiRepositoryPolicy = + serde_json::from_value(serde_json::to_value(&policy).unwrap()).unwrap(); + + assert_eq!(restored, policy); + assert_eq!( + restored.effective_commit_message_mode(), + AiCommitMessageMode::FreeForm + ); + } + + #[test] + fn custom_conflict_prompt_is_preserved() { + let custom: AiExtensionSettings = serde_json::from_value(serde_json::json!({ + "conflictResolutionPrompt": "Resolve conflicts using the repository conventions." + })) + .unwrap(); + + assert_eq!( + custom.conflict_resolution_prompt, + "Resolve conflicts using the repository conventions." + ); + } +} diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 8046f30..8db6969 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -307,12 +307,15 @@ fn parse_verification_output(stdout: &str, requested_hashes: &[String]) -> Vec, ) -> Result { self.verified_hashes.borrow_mut().push(hashes.to_vec()); - let stdout = self.verification_outputs + let stdout = self + .verification_outputs .borrow_mut() .pop() .ok_or_else(|| "missing fake verification output".to_string())?; @@ -484,7 +488,12 @@ mod tests { .join("\n"); let results = parse_verification_output( &output, - &["a".to_string(), "b".to_string(), "c".to_string(), "d".to_string()], + &[ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + ], ); assert_eq!(results[0].status, SignatureStatus::Verified); diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index b8c84d9..b4b812d 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -2,19 +2,23 @@ use crate::git::types::{ CloneRequest, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, CommitMarkers, CommitMessageRecovery, CommitRequest, DiffRequest, ExportCommitPatchRequest, ExportPatchRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, - HunkStageRequest, IdentityRequest, ImportPatchRequest, NumstatRequest, NumstatResult, - OperationResult, PullAnalysis, PullStrategyRequest, PushRequest, PushResult, RepoRequest, - RepoStatus, SetIdentityRequest, SshAllowedSignerStatus, StageFilesRequest, StashEntry, - StashPushRequest, StashRequest, SubmoduleActionRequest, + HunkStageRequest, IdentityRequest, ImportPatchRequest, LocalCopyDestinationMode, + LocalCopyError, LocalCopyMode, LocalCopyProgress, LocalCopyProgressPhase, LocalCopyRequest, + LocalCopyResult, LocalCopyWarning, NumstatRequest, NumstatResult, OperationResult, + PullAnalysis, PullStrategyRequest, PushRequest, PushResult, RepoRequest, RepoStatus, + SetIdentityRequest, SshAllowedSignerStatus, StageFilesRequest, StashEntry, StashPushRequest, + StashRequest, SubmoduleActionRequest, }; #[cfg(target_os = "linux")] use crate::git::types::{LINUX_TERMINAL_AUTO_ID, LINUX_TERMINAL_CUSTOM_ID}; use crate::{AppState, CloneCancelFlag, configure_command}; use serde::{Deserialize, Serialize}; -use std::io::Read; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; use tauri::Manager; use tauri_plugin_opener::OpenerExt; @@ -90,6 +94,38 @@ mod tests { use super::*; use tempfile::TempDir; + fn apply_staged_working_tree( + source: &Path, + destination: &Path, + destination_mode: LocalCopyDestinationMode, + ) -> Result<(), LocalCopyError> { + let cancel = AtomicBool::new(false); + let workspace = create_copy_workspace(destination)?; + let staged_result = workspace.join("result"); + std::fs::create_dir(&staged_result).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(&staged_result), + Some(error.to_string()), + ) + })?; + if destination_mode == LocalCopyDestinationMode::DropOnTop { + copy_working_tree(destination, &staged_result, &cancel)?; + } + copy_working_tree(source, &staged_result, &cancel)?; + commit_staged_result( + destination, + &staged_result, + &workspace, + destination.join(".git").exists(), + None, + &cancel, + &None, + )?; + drop(std::fs::remove_dir_all(workspace)); + Ok(()) + } + fn repo_with_git_dir() -> TempDir { let dir = TempDir::new().expect("create temp dir"); std::fs::create_dir(dir.path().join(".git")).expect("create git dir"); @@ -149,6 +185,317 @@ mod tests { Some("Linked Repo") ); } + + #[test] + fn working_tree_copy_skips_git_metadata() { + let source = TempDir::new().expect("create source dir"); + std::fs::create_dir(source.path().join(".git")).expect("create source git dir"); + std::fs::write(source.path().join(".git").join("config"), "source") + .expect("write source git config"); + std::fs::write(source.path().join("README.md"), "source readme") + .expect("write source file"); + + let destination = TempDir::new().expect("create destination dir"); + copy_working_tree(source.path(), destination.path(), &AtomicBool::new(false)) + .expect("copy working tree"); + + assert_eq!( + std::fs::read_to_string(destination.path().join("README.md")) + .expect("read copied file"), + "source readme" + ); + assert!(!destination.path().join(".git").exists()); + } + + #[test] + fn delete_existing_preserves_destination_git_metadata() { + let source = TempDir::new().expect("create source dir"); + std::fs::write(source.path().join("README.md"), "new").expect("write source file"); + + let destination = repo_with_git_dir(); + std::fs::write( + destination.path().join(".git").join("config"), + "destination", + ) + .expect("write destination git config"); + std::fs::write(destination.path().join("stale.txt"), "stale").expect("write stale file"); + + apply_staged_working_tree( + source.path(), + destination.path(), + LocalCopyDestinationMode::DeleteExisting, + ) + .expect("apply source"); + + assert!(!destination.path().join("stale.txt").exists()); + assert_eq!( + std::fs::read_to_string(destination.path().join("README.md")).expect("read new file"), + "new" + ); + assert_eq!( + std::fs::read_to_string(destination.path().join(".git").join("config")) + .expect("read destination git config"), + "destination" + ); + } + + #[test] + fn drop_on_top_overwrites_matching_files_and_keeps_unrelated_files() { + let source = TempDir::new().expect("create source dir"); + std::fs::write(source.path().join("README.md"), "new").expect("write source file"); + + let destination = TempDir::new().expect("create destination dir"); + std::fs::write(destination.path().join("README.md"), "old") + .expect("write old destination file"); + std::fs::write(destination.path().join("notes.txt"), "keep") + .expect("write unrelated destination file"); + + apply_staged_working_tree( + source.path(), + destination.path(), + LocalCopyDestinationMode::DropOnTop, + ) + .expect("apply source"); + + assert_eq!( + std::fs::read_to_string(destination.path().join("README.md")) + .expect("read overwritten file"), + "new" + ); + assert_eq!( + std::fs::read_to_string(destination.path().join("notes.txt")) + .expect("read unrelated file"), + "keep" + ); + } + + #[test] + fn drop_on_top_handles_file_directory_collisions_and_spaces() { + let root = TempDir::new().expect("create root dir"); + let source = root.path().join("source with spaces"); + let destination = root.path().join("destination with spaces"); + std::fs::create_dir(&source).expect("create source"); + std::fs::create_dir(&destination).expect("create destination"); + std::fs::write(source.join("file-replaces-directory"), "file").expect("write source file"); + std::fs::create_dir(source.join("directory-replaces-file")) + .expect("create source directory"); + std::fs::write( + source.join("directory-replaces-file").join("nested.txt"), + "nested", + ) + .expect("write nested source file"); + std::fs::create_dir(destination.join("file-replaces-directory")) + .expect("create destination directory"); + std::fs::write(destination.join("directory-replaces-file"), "old file") + .expect("write destination file"); + + apply_staged_working_tree(&source, &destination, LocalCopyDestinationMode::DropOnTop) + .expect("apply source with collisions"); + + assert_eq!( + std::fs::read_to_string(destination.join("file-replaces-directory")) + .expect("read replacement file"), + "file" + ); + assert_eq!( + std::fs::read_to_string( + destination + .join("directory-replaces-file") + .join("nested.txt") + ) + .expect("read nested replacement file"), + "nested" + ); + } + + #[test] + fn complete_repository_copy_rejects_existing_destination() { + let source = TempDir::new().expect("create source dir"); + let destination = TempDir::new().expect("create destination dir"); + + let error = validate_complete_repository_copy_request( + source.path().to_str().expect("source path"), + destination.path(), + ) + .expect_err("reject existing destination"); + + assert_eq!(error.code, "destinationExists"); + } + + #[test] + fn files_only_copy_rejects_nested_destination() { + let source = TempDir::new().expect("create source dir"); + let destination = source.path().join("nested"); + + let error = validate_files_only_copy_request( + source.path().to_str().expect("source path"), + &destination, + ) + .expect_err("reject nested destination"); + + assert_eq!(error.code, "overlappingPaths"); + } + + #[test] + fn local_copy_is_disabled_unless_experiment_is_enabled() { + assert_eq!( + require_local_copy_enabled(false) + .expect_err("reject disabled Local Copy") + .code, + "featureDisabled" + ); + require_local_copy_enabled(true).expect("allow enabled Local Copy"); + } + + #[test] + fn working_tree_copy_includes_hidden_files_and_excludes_nested_git_metadata() { + let source = TempDir::new().expect("create source dir"); + std::fs::write(source.path().join(".env"), "secret").expect("write hidden file"); + let nested = source.path().join("nested"); + std::fs::create_dir(&nested).expect("create nested dir"); + std::fs::create_dir(nested.join(".git")).expect("create nested git dir"); + std::fs::write(nested.join(".git").join("config"), "metadata") + .expect("write nested git metadata"); + std::fs::write(nested.join("ignored.log"), "present").expect("write ignored file"); + + let destination = TempDir::new().expect("create destination dir"); + copy_working_tree(source.path(), destination.path(), &AtomicBool::new(false)) + .expect("copy working tree"); + + assert_eq!( + std::fs::read_to_string(destination.path().join(".env")).expect("read hidden file"), + "secret" + ); + assert_eq!( + std::fs::read_to_string(destination.path().join("nested").join("ignored.log")) + .expect("read ignored file"), + "present" + ); + assert!(!destination.path().join("nested").join(".git").exists()); + } + + #[cfg(unix)] + #[test] + fn working_tree_copy_preserves_symbolic_links_without_following_them() { + let source = TempDir::new().expect("create source dir"); + std::fs::write(source.path().join("target.txt"), "target").expect("write target"); + std::os::unix::fs::symlink("target.txt", source.path().join("link.txt")) + .expect("create symbolic link"); + std::os::unix::fs::symlink("missing.txt", source.path().join("broken.txt")) + .expect("create broken symbolic link"); + let destination = TempDir::new().expect("create destination dir"); + + preflight_working_tree(source.path(), &AtomicBool::new(false)) + .expect("preflight symbolic links"); + copy_working_tree(source.path(), destination.path(), &AtomicBool::new(false)) + .expect("copy symbolic links"); + + assert_eq!( + std::fs::read_link(destination.path().join("link.txt")).expect("read symbolic link"), + PathBuf::from("target.txt") + ); + assert_eq!( + std::fs::read_link(destination.path().join("broken.txt")) + .expect("read broken symbolic link"), + PathBuf::from("missing.txt") + ); + } + + #[cfg(unix)] + #[test] + fn preflight_rejects_special_files_before_copying() { + let source = TempDir::new().expect("create source dir"); + let fifo_path = source.path().join("events.fifo"); + let status = std::process::Command::new("mkfifo") + .arg(&fifo_path) + .status() + .expect("launch mkfifo"); + assert!(status.success()); + + let error = preflight_working_tree(source.path(), &AtomicBool::new(false)) + .expect_err("reject FIFO"); + assert_eq!(error.code, "unsupportedFileType"); + assert_eq!(error.path.as_deref(), fifo_path.to_str()); + } + + #[test] + fn preflight_honours_cancellation() { + let source = TempDir::new().expect("create source dir"); + let cancel = AtomicBool::new(true); + + let error = preflight_working_tree(source.path(), &cancel).expect_err("cancel preflight"); + assert_eq!(error.code, "cancelled"); + } + + #[test] + fn local_source_rejects_an_unavailable_declared_submodule() { + let source = TempDir::new().expect("create source dir"); + std::fs::write( + source.path().join(".gitmodules"), + "[submodule \"missing\"]\n\tpath = dependencies/missing\n\turl = ../missing\n", + ) + .expect("write gitmodules"); + + let error = validate_local_submodules(source.path(), &AtomicBool::new(false)) + .expect_err("reject unavailable submodule"); + assert_eq!(error.code, "submoduleUnavailable"); + assert!( + error + .path + .as_deref() + .is_some_and(|path| path.ends_with("dependencies/missing")) + ); + } + + #[test] + fn destination_git_metadata_must_be_a_usable_repository() { + let destination = repo_with_git_dir(); + + let error = validate_destination_repository(destination.path()) + .expect_err("reject unusable git metadata"); + assert_eq!(error.code, "invalidDestination"); + } + + #[test] + fn fresh_destination_repository_is_initialised() { + let destination = TempDir::new().expect("create destination dir"); + + run_git_init(destination.path()).expect("initialise repository"); + + assert!(validate_destination_repository(destination.path()).expect("validate repository")); + } + + #[test] + fn rollback_restores_backed_up_entries_and_removes_installed_entries() { + let root = TempDir::new().expect("create root dir"); + let destination = root.path().join("destination"); + let staged_result = root.path().join("staged"); + let backup = root.path().join("backup"); + std::fs::create_dir(&destination).expect("create destination"); + std::fs::create_dir(&staged_result).expect("create staged result"); + std::fs::create_dir(&backup).expect("create backup"); + std::fs::write(destination.join("new.txt"), "new").expect("write installed file"); + std::fs::write(backup.join("old.txt"), "old").expect("write backup file"); + + rollback_staged_result( + &destination, + &staged_result, + &backup, + &[std::ffi::OsString::from("new.txt")], + ) + .expect("rollback staged result"); + + assert_eq!( + std::fs::read_to_string(destination.join("old.txt")).expect("read restored file"), + "old" + ); + assert!(!destination.join("new.txt").exists()); + assert_eq!( + std::fs::read_to_string(staged_result.join("new.txt")) + .expect("read removed installed file"), + "new" + ); + } } #[tauri::command] @@ -517,27 +864,1401 @@ pub fn init_repo(repo_path: String) -> Result { } #[tauri::command] -pub async fn clone_repo( - request: CloneRequest, - on_progress: tauri::ipc::Channel, +pub fn path_is_nonempty_dir(path: String) -> bool { + let path = PathBuf::from(path.trim()); + if !path.is_dir() { + return false; + } + path.read_dir() + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false) +} + +#[tauri::command] +pub async fn local_copy_repo( + request: LocalCopyRequest, + on_progress: tauri::ipc::Channel, cancel_flag: tauri::State<'_, CloneCancelFlag>, -) -> Result { - use crate::git::cli::CliGitHandler; + operation: tauri::State<'_, crate::LocalCopyOperation>, + state: tauri::State<'_, AppState>, +) -> Result { + require_local_copy_enabled(state.git_service.get_settings().enable_local_copy)?; - let repo_url = request.repo_url.trim().to_string(); - let destination = request.destination.trim().to_string(); + let source = request.source.trim().to_string(); + let destination = PathBuf::from(request.destination.trim()); - CliGitHandler::validate_clone_repo_url(&repo_url).map_err(|e| e.to_string())?; + // Single-flight guard. Reset the shared cancel flag after acquire so + // a stale cancellation does not poison this operation. + let _guard = acquire_single_flight(&operation.0)?; + cancel_flag.0.store(false, Ordering::Relaxed); - let final_dest = CliGitHandler::resolve_clone_destination(&repo_url, &destination) - .map_err(|e| e.to_string())?; - let final_dest_str = final_dest.to_string_lossy().to_string(); - let dest_existed = final_dest.exists(); - let cleanup_path = final_dest_str.clone(); + let result = run_local_copy_operation( + &source, + &destination, + request.copy_mode, + request.destination_mode, + on_progress, + cancel_flag.0.clone(), + ) + .await; + + result +} + +/// RAII guard that releases the single-flight lock on drop. +struct SingleFlightGuard<'a>(&'a Mutex>>); + +impl<'a> Drop for SingleFlightGuard<'a> { + fn drop(&mut self) { + if let Ok(mut guard) = self.0.lock() { + *guard = None; + } + } +} + +fn acquire_single_flight<'a>( + lock: &'a Mutex>>, +) -> Result, LocalCopyError> { + let mut guard = lock.lock().map_err(|_| { + local_copy_error("filesystemFailure", None, Some("Internal lock poisoned".to_string())) + })?; + if guard.is_some() { + return Err(local_copy_error("busy", None, None)); + } + *guard = Some(Arc::new(AtomicBool::new(false))); + Ok(SingleFlightGuard(lock)) +} + +/// Helper so the compiler knows we never hold the mutex across awaits. +async fn run_local_copy_operation( + source: &str, + destination: &Path, + copy_mode: LocalCopyMode, + destination_mode: LocalCopyDestinationMode, + on_progress: tauri::ipc::Channel, + shared_cancel: Arc, +) -> Result { + send_local_copy_phase(&on_progress, LocalCopyProgressPhase::Preparing); + + let cancel = Arc::new(AtomicBool::new(false)); + + // Propagate shared cancel requests to the operation-scoped flag. + let cancellation_watch = CancelWatch::new(cancel.clone(), shared_cancel); + + let warning = match copy_mode { + LocalCopyMode::CompleteRepository => { + validate_complete_repository_copy_request(source, destination)?; + run_complete_repository_copy(source, destination, on_progress, cancel.clone()).await? + } + LocalCopyMode::FilesOnly => { + validate_files_only_copy_request(source, destination)?; + run_files_only_copy( + source, + destination, + destination_mode, + on_progress, + cancel.clone(), + ) + .await? + } + }; + + drop(cancellation_watch); + + Ok(LocalCopyResult { + destination_path: destination.to_string_lossy().to_string(), + backend: "git-cli".to_string(), + warning, + }) +} + +struct CancelWatch(Option>); + +impl CancelWatch { + fn new(cancel: Arc, shared_cancel: Arc) -> Self { + let handle = tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + if shared_cancel.load(Ordering::Relaxed) { + cancel.store(true, Ordering::Relaxed); + break; + } + } + }); + Self(Some(handle)) + } +} + +impl Drop for CancelWatch { + fn drop(&mut self) { + if let Some(handle) = self.0.take() { + handle.abort(); + } + } +} + +fn require_local_copy_enabled(enabled: bool) -> Result<(), LocalCopyError> { + if enabled { + Ok(()) + } else { + Err(local_copy_error("featureDisabled", None, None)) + } +} + +fn validate_complete_repository_copy_request( + source: &str, + destination: &Path, +) -> Result<(), LocalCopyError> { + validate_local_copy_source(source)?; + validate_destination_path(destination)?; + validate_source_destination_overlap(source, destination)?; + + if destination.exists() { + return Err(local_copy_error( + "destinationExists", + Some(destination), + None, + )); + } + + Ok(()) +} + +fn validate_files_only_copy_request( + source: &str, + destination: &Path, +) -> Result<(), LocalCopyError> { + validate_local_copy_source(source)?; + validate_destination_path(destination)?; + validate_source_destination_overlap(source, destination) +} + +fn validate_local_copy_source(source: &str) -> Result<(), LocalCopyError> { + if source.is_empty() { + return Err(local_copy_error("invalidSource", None, None)); + } + if source.starts_with('-') { + return Err(local_copy_error("invalidSource", None, None)); + } + if source.chars().any(char::is_control) { + return Err(local_copy_error("invalidSource", None, None)); + } + + let source_path = PathBuf::from(source); + if source_path.exists() && !source_path.is_dir() { + return Err(local_copy_error("invalidSource", Some(&source_path), None)); + } + + Ok(()) +} + +fn validate_destination_path(destination: &Path) -> Result<(), LocalCopyError> { + if destination.as_os_str().is_empty() { + return Err(local_copy_error("invalidDestination", None, None)); + } + if destination.exists() && !destination.is_dir() { + return Err(local_copy_error( + "invalidDestination", + Some(destination), + None, + )); + } + canonical_destination_path(destination)?; + Ok(()) +} + +fn validate_source_destination_overlap( + source: &str, + destination: &Path, +) -> Result<(), LocalCopyError> { + let source_local = resolve_source_path(source); + let Some(source_path) = source_local else { + return Ok(()); + }; + if !source_path.exists() { + return Ok(()); + } + + let source_canonical = source_path.canonicalize().map_err(|error| { + local_copy_error("invalidSource", Some(&source_path), Some(error.to_string())) + })?; + let destination_canonical = canonical_destination_path(destination)?; + + if paths_are_same_or_ancestor(&source_canonical, &destination_canonical) { + return Err(local_copy_error( + "overlappingPaths", + Some(destination), + None, + )); + } + + Ok(()) +} + +/// Resolve a source string that may be a `file://` URL to a local `PathBuf`. +/// Returns `None` for remote sources. +fn resolve_source_path(source: &str) -> Option { + if let Some(local_path) = source.strip_prefix("file://") { + #[cfg(windows)] + let local_path = local_path.strip_prefix('/').unwrap_or(local_path); + return Some(PathBuf::from(local_path)); + } + let path = PathBuf::from(source); + if path.exists() { + return Some(path); + } + None +} + +/// Check whether two canonical paths are identical or one is an ancestor of +/// the other. Handles case-insensitive filesystems where the canonical form +/// may not detect collisions on its own. +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn paths_are_same_or_ancestor(a: &Path, b: &Path) -> bool { + a == b || b.starts_with(a) || a.starts_with(b) +} + +#[cfg(any(target_os = "windows", target_os = "macos"))] +fn paths_are_same_or_ancestor(a: &Path, b: &Path) -> bool { + fn lower(path: &Path) -> String { + path.to_string_lossy().to_lowercase() + } + let a_lower = lower(a); + let b_lower = lower(b); + if a_lower == b_lower { + return true; + } + // Only treat b as a descendant of a when b's canonicalised path starts + // with a followed by a separator (handles both / and \ on Windows). + fn is_boundary(prefix: &str, candidate: &str) -> bool { + candidate + .as_bytes() + .get(prefix.len()) + .is_some_and(|&b| std::path::is_separator(char::from(b))) + } + (b_lower.starts_with(&a_lower) && is_boundary(&a_lower, &b_lower)) + || (a_lower.starts_with(&b_lower) && is_boundary(&b_lower, &a_lower)) +} + +fn canonical_destination_path(destination: &Path) -> Result { + if destination.exists() { + return destination.canonicalize().map_err(|error| { + local_copy_error( + "invalidDestination", + Some(destination), + Some(error.to_string()), + ) + }); + } + + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let parent = parent.canonicalize().map_err(|error| { + local_copy_error("invalidDestination", Some(parent), Some(error.to_string())) + })?; + let name = destination + .file_name() + .ok_or_else(|| local_copy_error("invalidDestination", Some(destination), None))?; + Ok(parent.join(name)) +} + +async fn run_complete_repository_copy( + source: &str, + destination: &Path, + on_progress: tauri::ipc::Channel, + cancel: Arc, +) -> Result, LocalCopyError> { + let workspace = create_copy_workspace(destination)?; + let staged_repository = workspace.join("repository"); + send_local_copy_phase(&on_progress, LocalCopyProgressPhase::Cloning); + + let clone_result = run_local_copy_git_clone( + source, + &staged_repository, + true, + on_progress.clone(), + cancel.clone(), + ) + .await; + if let Err(error) = clone_result { + drop(std::fs::remove_dir_all(&workspace)); + return Err(error); + } + + if let Err(error) = check_local_copy_cancelled(&cancel) { + drop(std::fs::remove_dir_all(&workspace)); + return Err(error); + } + send_local_copy_phase(&on_progress, LocalCopyProgressPhase::Finalising); + std::fs::rename(&staged_repository, destination).map_err(|error| { + drop(std::fs::remove_dir_all(&workspace)); + local_copy_error( + "filesystemFailure", + Some(destination), + Some(error.to_string()), + ) + })?; + + Ok(cleanup_workspace_warning(&workspace)) +} + +async fn run_files_only_copy( + source: &str, + destination: &Path, + destination_mode: LocalCopyDestinationMode, + on_progress: tauri::ipc::Channel, + cancel: Arc, +) -> Result, LocalCopyError> { + send_local_copy_phase(&on_progress, LocalCopyProgressPhase::Scanning); + let local_source = resolve_source_path(source); + let source_is_local = local_source.is_some(); + let local_source = local_source.unwrap_or_else(|| PathBuf::from(source)); + + // Perform preflight and validation inside spawn_blocking to avoid + // blocking the async runtime during recursive tree scans and Git + // subprocess calls. + let (preserve_destination_git, destination_identity) = { + let destination = destination.to_path_buf(); + let local_source = if source_is_local { + Some(local_source.clone()) + } else { + None + }; + let cancel = cancel.clone(); + tauri::async_runtime::spawn_blocking(move || { + if let Some(ref src) = local_source { + preflight_working_tree(src, &cancel)?; + validate_local_submodules(src, &cancel)?; + } + let preserve = validate_destination_repository(&destination)?; + preflight_destination(&destination, &cancel)?; + let identity = record_destination_identity(&destination)?; + Ok::<_, LocalCopyError>((preserve, identity)) + }) + .await + .map_err(|error| { + local_copy_error("filesystemFailure", None, Some(error.to_string())) + })?? + }; + + let workspace = create_copy_workspace(destination)?; + let staged_source = workspace.join("source"); + let copy_source = if source_is_local { + local_source + } else { + send_local_copy_phase(&on_progress, LocalCopyProgressPhase::Cloning); + if let Err(error) = run_local_copy_git_clone( + source, + &staged_source, + true, + on_progress.clone(), + cancel.clone(), + ) + .await + { + drop(std::fs::remove_dir_all(&workspace)); + return Err(error); + } + if let Err(error) = preflight_working_tree(&staged_source, &cancel) { + drop(std::fs::remove_dir_all(&workspace)); + return Err(error); + } + staged_source + }; + + // Move the heavy copy, git init, and commit operations into a blocking + // thread so the async runtime stays responsive to cancellation and + // other commands. + let destination = destination.to_path_buf(); + let staged_result = workspace.join("result"); + let workspace_path = workspace; + let workspace_path_for_result = workspace_path.clone(); + let on_progress_clone = on_progress; + let cancel_clone = cancel; + let identity = destination_identity; + + let staged_result_operation = tauri::async_runtime::spawn_blocking(move || { + std::fs::create_dir(&staged_result).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(&staged_result), + Some(error.to_string()), + ) + })?; + send_local_copy_phase(&on_progress_clone, LocalCopyProgressPhase::Copying); + if destination.exists() && destination_mode == LocalCopyDestinationMode::DropOnTop { + copy_working_tree_preserving_nested_git(&destination, &staged_result, &cancel_clone)?; + } + copy_working_tree(©_source, &staged_result, &cancel_clone)?; + + if !preserve_destination_git { + send_local_copy_phase(&on_progress_clone, LocalCopyProgressPhase::Initialising); + run_git_init(&staged_result)?; + } + + check_local_copy_cancelled(&cancel_clone)?; + send_local_copy_phase(&on_progress_clone, LocalCopyProgressPhase::Finalising); + commit_staged_result( + &destination, + &staged_result, + &workspace_path, + preserve_destination_git, + Some(&on_progress_clone), + &cancel_clone, + &identity, + ) + }) + .await + .map_err(|error| { + local_copy_error("filesystemFailure", None, Some(error.to_string())) + })?; + + let warning = match staged_result_operation { + Ok(warning) => warning, + Err(error) => { + if error.code != "rollbackFailure" { + drop(std::fs::remove_dir_all(&workspace_path_for_result)); + } + return Err(error); + } + }; + Ok(warning.or_else(|| cleanup_workspace_warning(&workspace_path_for_result))) +} + +fn run_git_init(path: &Path) -> Result<(), LocalCopyError> { + let mut command = crate::git_command(); + configure_command(&mut command); + command.arg("init").arg("-b").arg("main").current_dir(path); + let output = command + .output() + .map_err(|error| local_copy_error("gitFailure", Some(path), Some(error.to_string())))?; + + if output.status.success() { + return Ok(()); + } + + let mut fallback = crate::git_command(); + configure_command(&mut fallback); + fallback.arg("init").current_dir(path); + let fallback_output = fallback + .output() + .map_err(|error| local_copy_error("gitFailure", Some(path), Some(error.to_string())))?; + if fallback_output.status.success() { + return Ok(()); + } + + Err(local_copy_error( + "gitFailure", + Some(path), + Some( + String::from_utf8_lossy(&fallback_output.stderr) + .trim() + .to_string(), + ), + )) +} + +fn local_copy_error(code: &str, path: Option<&Path>, detail: Option) -> LocalCopyError { + LocalCopyError { + code: code.to_string(), + path: path.map(|value| value.to_string_lossy().to_string()), + detail, + } +} + +fn send_local_copy_phase( + on_progress: &tauri::ipc::Channel, + phase: LocalCopyProgressPhase, +) { + drop(on_progress.send(LocalCopyProgress::Phase { phase })); +} + +fn check_local_copy_cancelled(cancel: &AtomicBool) -> Result<(), LocalCopyError> { + if cancel.load(Ordering::Relaxed) { + Err(local_copy_error("cancelled", None, None)) + } else { + Ok(()) + } +} + +fn create_copy_workspace(destination: &Path) -> Result { + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| { + local_copy_error("filesystemFailure", Some(parent), Some(error.to_string())) + })? + .as_nanos(); + + for attempt in 0..100_u8 { + let workspace = parent.join(format!( + ".gitmun-local-copy-{}-{timestamp}-{attempt}", + std::process::id() + )); + match std::fs::create_dir(&workspace) { + Ok(()) => return Ok(workspace), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(local_copy_error( + "filesystemFailure", + Some(&workspace), + Some(error.to_string()), + )); + } + } + } + + Err(local_copy_error( + "filesystemFailure", + Some(parent), + Some("Unable to allocate a unique staging directory".to_string()), + )) +} + +fn cleanup_workspace_warning(workspace: &Path) -> Option { + if !workspace.exists() { + return None; + } + // Normalise directory permissions so read-only files can be removed. + let _ = make_tree_writable(workspace); + std::fs::remove_dir_all(workspace) + .err() + .map(|error| LocalCopyWarning { + code: "backupCleanupFailed".to_string(), + path: Some(workspace.to_string_lossy().to_string()), + detail: Some(error.to_string()), + }) +} + +/// Recursively make every entry in `root` writable so `remove_dir_all` can succeed. +fn make_tree_writable(root: &Path) -> Result<(), std::io::Error> { + if !root.is_dir() { + return Ok(()); + } + for entry in std::fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let metadata = entry.metadata()?; + if metadata.is_dir() { + make_tree_writable(&path)?; + } + let mut permissions = metadata.permissions(); + if permissions.readonly() { + permissions.set_readonly(false); + let _ = std::fs::set_permissions(&path, permissions); + } + } + Ok(()) +} + +fn record_destination_identity(destination: &Path) -> Result, LocalCopyError> { + if !destination.exists() { + return Ok(Some(DestinationIdentity::Absent)); + } + // Record the set of top-level entries at the destination. If the + // destination appears or changes during staging we will detect it + // before committing. + let entries = std::fs::read_dir(destination).map_err(|error| { + local_copy_error("filesystemFailure", Some(destination), Some(error.to_string())) + })?; + let mut names: Vec = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + local_copy_error("filesystemFailure", Some(destination), Some(error.to_string())) + })?; + if !is_git_metadata_name(&entry.file_name()) { + names.push(entry.file_name().to_string_lossy().to_string()); + } + } + let canonical = canonical_destination_path(destination)?; + Ok(Some(DestinationIdentity::Present { + canonical_path: canonical, + top_level_names: names, + })) +} + +fn check_destination_unchanged(destination: &Path, identity: &Option) -> Result<(), LocalCopyError> { + let Some(id) = identity else { return Ok(()); }; + match id { + DestinationIdentity::Absent => { + if destination.exists() { + return Err(local_copy_error("destinationChanged", Some(destination), None)); + } + } + DestinationIdentity::Present { canonical_path, top_level_names } => { + if !destination.exists() { + return Err(local_copy_error("destinationChanged", Some(destination), None)); + } + // Canonical path should remain the same (detects replacement with symlink etc.) + let current_canonical = canonical_destination_path(destination)?; + if ¤t_canonical != canonical_path { + return Err(local_copy_error("destinationChanged", Some(destination), None)); + } + // Top-level entries should match exactly (neither added nor removed). + // This is required because commit_staged_result moves *all* current + // top-level entries to backup and deletes the backup on success. + let current_entries = std::fs::read_dir(destination).map_err(|error| { + local_copy_error("filesystemFailure", Some(destination), Some(error.to_string())) + })?; + let current_names: std::collections::HashSet = current_entries + .filter_map(|entry| { + let entry = entry.ok()?; + if is_git_metadata_name(&entry.file_name()) { + None + } else { + Some(entry.file_name().to_string_lossy().to_string()) + } + }) + .collect(); + if current_names != top_level_names.iter().cloned().collect::>() { + return Err(local_copy_error("destinationChanged", Some(destination), None)); + } + } + } + Ok(()) +} + +enum DestinationIdentity { + Absent, + Present { + canonical_path: PathBuf, + top_level_names: Vec, + }, +} + +/// Check whether a filename is a Git metadata entry (`.git`, `.GIT`, etc.). +fn is_git_metadata_name(name: &std::ffi::OsStr) -> bool { + name.to_str() + .is_some_and(|s| s.eq_ignore_ascii_case(".git")) +} + +fn preflight_destination(destination: &Path, cancel: &AtomicBool) -> Result<(), LocalCopyError> { + if destination.exists() { + preflight_working_tree(destination, cancel)?; + } + Ok(()) +} + +fn preflight_working_tree(source: &Path, cancel: &AtomicBool) -> Result<(), LocalCopyError> { + check_local_copy_cancelled(cancel)?; + let entries = std::fs::read_dir(source).map_err(|error| { + local_copy_error("filesystemFailure", Some(source), Some(error.to_string())) + })?; + + for entry_result in entries { + check_local_copy_cancelled(cancel)?; + let entry = entry_result.map_err(|error| { + local_copy_error("filesystemFailure", Some(source), Some(error.to_string())) + })?; + if is_git_metadata_name(&entry.file_name()) { + continue; + } + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| { + local_copy_error("filesystemFailure", Some(&path), Some(error.to_string())) + })?; + + if file_type.is_dir() { + preflight_working_tree(&path, cancel)?; + } else if file_type.is_symlink() { + preflight_symbolic_link(&path)?; + } else if !file_type.is_file() { + return Err(local_copy_error("unsupportedFileType", Some(&path), None)); + } + } + + Ok(()) +} + +#[cfg(unix)] +fn preflight_symbolic_link(path: &Path) -> Result<(), LocalCopyError> { + std::fs::read_link(path) + .map(|_| ()) + .map_err(|error| local_copy_error("symlinkFailure", Some(path), Some(error.to_string()))) +} + +#[cfg(windows)] +fn preflight_symbolic_link(path: &Path) -> Result<(), LocalCopyError> { + std::fs::read_link(path) + .map_err(|error| local_copy_error("symlinkFailure", Some(path), Some(error.to_string())))?; + std::fs::metadata(path) + .map(|_| ()) + .map_err(|error| local_copy_error("symlinkFailure", Some(path), Some(error.to_string()))) +} + +fn validate_local_submodules(source: &Path, cancel: &AtomicBool) -> Result<(), LocalCopyError> { + check_local_copy_cancelled(cancel)?; + if !source.join(".gitmodules").is_file() { + return Ok(()); + } + + if source.join(".git").exists() { + let mut command = crate::git_command(); + configure_command(&mut command); + command + .arg("-C") + .arg(source) + .args(["submodule", "status", "--recursive"]); + let output = command.output().map_err(|error| { + local_copy_error("gitFailure", Some(source), Some(error.to_string())) + })?; + if !output.status.success() { + return Err(local_copy_error( + "gitFailure", + Some(source), + Some(String::from_utf8_lossy(&output.stderr).trim().to_string()), + )); + } + for line in String::from_utf8_lossy(&output.stdout).lines() { + check_local_copy_cancelled(cancel)?; + if line.starts_with('-') || line.starts_with('U') { + let submodule_path = line.split_whitespace().nth(1).map(PathBuf::from); + return Err(local_copy_error( + "submoduleUnavailable", + submodule_path + .as_deref() + .map(|path| source.join(path)) + .as_deref(), + None, + )); + } + } + return Ok(()); + } + + validate_declared_submodule_paths(source, cancel) +} + +fn validate_declared_submodule_paths( + source: &Path, + cancel: &AtomicBool, +) -> Result<(), LocalCopyError> { + let mut command = crate::git_command(); + configure_command(&mut command); + command + .arg("config") + .args(["--file", ".gitmodules", "--null", "--get-regexp", "path"]) + .current_dir(source); + let output = command + .output() + .map_err(|error| local_copy_error("gitFailure", Some(source), Some(error.to_string())))?; + if !output.status.success() && output.status.code() != Some(1) { + return Err(local_copy_error( + "gitFailure", + Some(source), + Some(String::from_utf8_lossy(&output.stderr).trim().to_string()), + )); + } + + // git config --null --get-regexp produces NUL-delimited output: + // key\nvalue\0key\nvalue\0... + for chunk in output.stdout.split(|&b| b == 0) { + if chunk.is_empty() { + continue; + } + check_local_copy_cancelled(cancel)?; + // Split on the first newline to separate key from value. + let Some(newline_pos) = chunk.iter().position(|&b| b == b'\n') else { + continue; + }; + let relative_path = String::from_utf8_lossy(&chunk[newline_pos + 1..]).trim().to_string(); + if relative_path.is_empty() { + continue; + } + let submodule = source.join(&relative_path); + let available = submodule.is_dir() + && std::fs::read_dir(&submodule) + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false); + if !available { + return Err(local_copy_error( + "submoduleUnavailable", + Some(&submodule), + None, + )); + } + validate_local_submodules(&submodule, cancel)?; + } + Ok(()) +} + +fn validate_destination_repository(destination: &Path) -> Result { + if !destination.exists() { + return Ok(false); + } + let dot_git = destination.join(".git"); + if std::fs::symlink_metadata(&dot_git).is_err() { + return Ok(false); + } + + let mut command = crate::git_command(); + configure_command(&mut command); + command + .arg("-C") + .arg(destination) + .args(["rev-parse", "--git-dir"]); + let output = command.output().map_err(|error| { + local_copy_error( + "invalidDestination", + Some(&dot_git), + Some(error.to_string()), + ) + })?; + if !output.status.success() { + return Err(local_copy_error( + "invalidDestination", + Some(&dot_git), + Some(String::from_utf8_lossy(&output.stderr).trim().to_string()), + )); + } + let _git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Reject bare repositories. + let mut is_bare = crate::git_command(); + configure_command(&mut is_bare); + is_bare + .arg("-C") + .arg(destination) + .args(["rev-parse", "--is-bare-repository"]); + let bare_output = is_bare.output().map_err(|error| { + local_copy_error("invalidDestination", Some(destination), Some(error.to_string())) + })?; + if bare_output.status.success() + && String::from_utf8_lossy(&bare_output.stdout).trim() == "true" + { + return Err(local_copy_error( + "bareRepository", + Some(destination), + None, + )); + } + + // Validate that the working tree's toplevel matches the destination. + // This correctly handles linked worktrees (whose .git file points to a + // git-dir inside the main repository's worktrees/ directory) while + // rejecting bare repos and external core.worktree destinations. + let mut toplevel_cmd = crate::git_command(); + configure_command(&mut toplevel_cmd); + toplevel_cmd + .arg("-C") + .arg(destination) + .args(["rev-parse", "--show-toplevel"]); + let toplevel_output = toplevel_cmd.output().map_err(|error| { + local_copy_error("invalidDestination", Some(destination), Some(error.to_string())) + })?; + if !toplevel_output.status.success() { + return Err(local_copy_error( + "invalidDestination", + Some(destination), + Some(String::from_utf8_lossy(&toplevel_output.stderr).trim().to_string()), + )); + } + let toplevel = String::from_utf8_lossy(&toplevel_output.stdout).trim().to_string(); + let canonical_toplevel = std::fs::canonicalize(&toplevel).unwrap_or(PathBuf::from(&toplevel)); + let canonical_destination = destination.canonicalize().unwrap_or(destination.to_path_buf()); + if canonical_toplevel != canonical_destination { + return Err(local_copy_error( + "externalWorktree", + Some(destination), + None, + )); + } + + // Reject destinations with core.worktree pointing elsewhere + // (belt-and-suspenders in case --show-toplevel did not catch it). + let mut worktree_cmd = crate::git_command(); + configure_command(&mut worktree_cmd); + worktree_cmd + .arg("-C") + .arg(destination) + .args(["config", "core.worktree"]); + if let Ok(worktree_output) = worktree_cmd.output() { + if worktree_output.status.success() { + let configured = String::from_utf8_lossy(&worktree_output.stdout).trim().to_string(); + if !configured.is_empty() { + let configured_path = if std::path::Path::new(&configured).is_absolute() { + PathBuf::from(&configured) + } else { + destination.join(&configured) + }; + let canonical_configured = configured_path.canonicalize().unwrap_or(configured_path); + let canonical_destination = destination.canonicalize().unwrap_or(destination.to_path_buf()); + if canonical_configured != canonical_destination { + return Err(local_copy_error( + "externalWorktree", + Some(destination), + None, + )); + } + } + } + } + + Ok(true) +} + +fn copy_working_tree( + source: &Path, + destination: &Path, + cancel: &AtomicBool, +) -> Result<(), LocalCopyError> { + copy_working_tree_inner(source, destination, cancel, false, 0) +} + +fn copy_working_tree_preserving_nested_git( + source: &Path, + destination: &Path, + cancel: &AtomicBool, +) -> Result<(), LocalCopyError> { + copy_working_tree_inner(source, destination, cancel, true, 0) +} + +fn copy_working_tree_inner( + source: &Path, + destination: &Path, + cancel: &AtomicBool, + preserve_nested_git: bool, + depth: usize, +) -> Result<(), LocalCopyError> { + check_local_copy_cancelled(cancel)?; + let entries = std::fs::read_dir(source).map_err(|error| { + local_copy_error("filesystemFailure", Some(source), Some(error.to_string())) + })?; + for entry_result in entries { + check_local_copy_cancelled(cancel)?; + let entry = entry_result.map_err(|error| { + local_copy_error("filesystemFailure", Some(source), Some(error.to_string())) + })?; + // At the root (depth 0) always skip the root .git entry (it is handled + // separately by commit_staged_result). At deeper levels, skip .git + // entries only when not preserving nested metadata (source copy). + let is_git_entry = is_git_metadata_name(&entry.file_name()); + if is_git_entry && (depth == 0 || !preserve_nested_git) { + continue; + } + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + let file_type = entry.file_type().map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(&source_path), + Some(error.to_string()), + ) + })?; + + if file_type.is_dir() { + if let Ok(destination_metadata) = std::fs::symlink_metadata(&destination_path) { + if !destination_metadata.file_type().is_dir() { + remove_path(&destination_path)?; + } + } + std::fs::create_dir_all(&destination_path).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(&destination_path), + Some(error.to_string()), + ) + })?; + copy_working_tree_inner(&source_path, &destination_path, cancel, preserve_nested_git, depth + 1)?; + let permissions = std::fs::metadata(&source_path) + .map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(&source_path), + Some(error.to_string()), + ) + })? + .permissions(); + std::fs::set_permissions(&destination_path, permissions).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(&destination_path), + Some(error.to_string()), + ) + })?; + } else if file_type.is_file() { + if std::fs::symlink_metadata(&destination_path).is_ok() { + remove_path(&destination_path)?; + } + copy_regular_file(&source_path, &destination_path, cancel)?; + } else if file_type.is_symlink() { + if std::fs::symlink_metadata(&destination_path).is_ok() { + remove_path(&destination_path)?; + } + copy_symbolic_link(&source_path, &destination_path)?; + } else { + return Err(local_copy_error( + "unsupportedFileType", + Some(&source_path), + None, + )); + } + } + Ok(()) +} + +fn copy_regular_file( + source: &Path, + destination: &Path, + cancel: &AtomicBool, +) -> Result<(), LocalCopyError> { + let mut source_file = std::fs::File::open(source).map_err(|error| { + local_copy_error("filesystemFailure", Some(source), Some(error.to_string())) + })?; + let mut destination_file = std::fs::File::create(destination).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(destination), + Some(error.to_string()), + ) + })?; + let mut buffer = vec![0_u8; 1024 * 1024]; + loop { + check_local_copy_cancelled(cancel)?; + let bytes_read = source_file.read(&mut buffer).map_err(|error| { + local_copy_error("filesystemFailure", Some(source), Some(error.to_string())) + })?; + if bytes_read == 0 { + break; + } + destination_file + .write_all(&buffer[..bytes_read]) + .map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(destination), + Some(error.to_string()), + ) + })?; + } + let permissions = std::fs::metadata(source) + .map_err(|error| { + local_copy_error("filesystemFailure", Some(source), Some(error.to_string())) + })? + .permissions(); + std::fs::set_permissions(destination, permissions).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(destination), + Some(error.to_string()), + ) + }) +} + +fn remove_path(path: &Path) -> Result<(), LocalCopyError> { + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + local_copy_error("filesystemFailure", Some(path), Some(error.to_string())) + })?; + let result = if metadata.file_type().is_symlink() || metadata.is_file() { + std::fs::remove_file(path) + } else { + std::fs::remove_dir_all(path) + }; + result + .map_err(|error| local_copy_error("filesystemFailure", Some(path), Some(error.to_string()))) +} + +#[cfg(unix)] +fn copy_symbolic_link(source: &Path, destination: &Path) -> Result<(), LocalCopyError> { + let target = std::fs::read_link(source).map_err(|error| { + local_copy_error("symlinkFailure", Some(source), Some(error.to_string())) + })?; + std::os::unix::fs::symlink(target, destination).map_err(|error| { + local_copy_error("symlinkFailure", Some(destination), Some(error.to_string())) + }) +} + +#[cfg(windows)] +fn copy_symbolic_link(source: &Path, destination: &Path) -> Result<(), LocalCopyError> { + let target = std::fs::read_link(source).map_err(|error| { + local_copy_error("symlinkFailure", Some(source), Some(error.to_string())) + })?; + let metadata = std::fs::metadata(source).map_err(|error| { + local_copy_error("symlinkFailure", Some(source), Some(error.to_string())) + })?; + let result = if metadata.is_dir() { + std::os::windows::fs::symlink_dir(target, destination) + } else { + std::os::windows::fs::symlink_file(target, destination) + }; + result.map_err(|error| { + local_copy_error("symlinkFailure", Some(destination), Some(error.to_string())) + }) +} + +fn commit_staged_result( + destination: &Path, + staged_result: &Path, + workspace: &Path, + preserve_destination_git: bool, + on_progress: Option<&tauri::ipc::Channel>, + cancel: &AtomicBool, + destination_identity: &Option, +) -> Result, LocalCopyError> { + check_local_copy_cancelled(cancel)?; + check_destination_unchanged(destination, destination_identity)?; + + if !destination.exists() { + std::fs::rename(staged_result, destination).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(destination), + Some(error.to_string()), + ) + })?; + return Ok(None); + } + + let backup = workspace.join("backup"); + std::fs::create_dir(&backup).map_err(|error| { + local_copy_error("filesystemFailure", Some(&backup), Some(error.to_string())) + })?; + let mut installed_names = Vec::new(); + let finalisation_result = (|| { + for entry_result in std::fs::read_dir(destination).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(destination), + Some(error.to_string()), + ) + })? { + check_local_copy_cancelled(cancel)?; + let entry = entry_result.map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(destination), + Some(error.to_string()), + ) + })?; + if preserve_destination_git && is_git_metadata_name(&entry.file_name()) { + continue; + } + std::fs::rename(entry.path(), backup.join(entry.file_name())).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(&entry.path()), + Some(error.to_string()), + ) + })?; + } + + for entry_result in std::fs::read_dir(staged_result).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(staged_result), + Some(error.to_string()), + ) + })? { + check_local_copy_cancelled(cancel)?; + let entry = entry_result.map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(staged_result), + Some(error.to_string()), + ) + })?; + if preserve_destination_git && is_git_metadata_name(&entry.file_name()) { + return Err(local_copy_error( + "filesystemFailure", + Some(&entry.path()), + Some("Staged result unexpectedly contains Git metadata".to_string()), + )); + } + let name = entry.file_name(); + std::fs::rename(entry.path(), destination.join(&name)).map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(&entry.path()), + Some(error.to_string()), + ) + })?; + installed_names.push(name); + } + Ok(()) + })(); + + if let Err(finalisation_error) = finalisation_result { + if let Some(progress_channel) = on_progress { + send_local_copy_phase(progress_channel, LocalCopyProgressPhase::RollingBack); + } + if let Err(rollback_error) = + rollback_staged_result(destination, staged_result, &backup, &installed_names) + { + return Err(local_copy_error( + "rollbackFailure", + Some(&backup), + Some(format!( + "Finalisation failed: {}; rollback failed: {}", + finalisation_error.detail.unwrap_or_default(), + rollback_error.detail.unwrap_or_default() + )), + )); + } + return Err(finalisation_error); + } + + let _ = make_tree_writable(&backup); + if let Err(error) = std::fs::remove_dir_all(&backup) { + return Ok(Some(LocalCopyWarning { + code: "backupCleanupFailed".to_string(), + path: Some(backup.to_string_lossy().to_string()), + detail: Some(error.to_string()), + })); + } + Ok(None) +} + +fn rollback_staged_result( + destination: &Path, + staged_result: &Path, + backup: &Path, + installed_names: &[std::ffi::OsString], +) -> Result<(), LocalCopyError> { + for name in installed_names.iter().rev() { + let installed_path = destination.join(name); + if std::fs::symlink_metadata(&installed_path).is_ok() { + std::fs::rename(&installed_path, staged_result.join(name)).map_err(|error| { + local_copy_error("rollbackFailure", Some(backup), Some(error.to_string())) + })?; + } + } + for entry_result in std::fs::read_dir(backup).map_err(|error| { + local_copy_error("rollbackFailure", Some(backup), Some(error.to_string())) + })? { + let entry = entry_result.map_err(|error| { + local_copy_error("rollbackFailure", Some(backup), Some(error.to_string())) + })?; + std::fs::rename(entry.path(), destination.join(entry.file_name())).map_err(|error| { + local_copy_error("rollbackFailure", Some(backup), Some(error.to_string())) + })?; + } + Ok(()) +} +async fn run_local_copy_git_clone( + source: &str, + destination: &Path, + recursive_submodules: bool, + on_progress: tauri::ipc::Channel, + cancel: Arc, +) -> Result<(), LocalCopyError> { + let destination_path = destination.to_path_buf(); + let mut command = crate::git_command(); + configure_command(&mut command); + command.args(["clone", "--progress"]); + if recursive_submodules { + command.arg("--recurse-submodules"); + } + command + .arg(source) + .arg(destination) + .stderr(Stdio::piped()) + .stdout(Stdio::null()); + + let mut child = command.spawn().map_err(|error| { + local_copy_error("gitFailure", Some(destination), Some(error.to_string())) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + local_copy_error( + "gitFailure", + Some(destination), + Some("Git clone stderr was unavailable".to_string()), + ) + })?; + let progress_thread = std::thread::spawn(move || -> String { + let mut reader = std::io::BufReader::new(stderr); + let mut buffer = [0_u8; 4096]; + let mut partial = String::new(); + let mut collected = String::new(); + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(bytes_read) => { + partial.push_str(&String::from_utf8_lossy(&buffer[..bytes_read])); + let lines: Vec<&str> = partial.split(['\r', '\n']).collect(); + for part in &lines[..lines.len() - 1] { + let line = part.trim(); + if !line.is_empty() { + collected.push_str(line); + collected.push('\n'); + drop(on_progress.send(LocalCopyProgress::ExternalOutput { + line: line.to_string(), + })); + } + } + partial = lines.last().unwrap_or(&"").to_string(); + } + Err(_) => break, + } + } + let remaining = partial.trim(); + if !remaining.is_empty() { + collected.push_str(remaining); + collected.push('\n'); + drop(on_progress.send(LocalCopyProgress::ExternalOutput { + line: remaining.to_string(), + })); + } + collected + }); + + tauri::async_runtime::spawn_blocking(move || -> Result<(), LocalCopyError> { + loop { + match child.try_wait().map_err(|error| { + local_copy_error( + "gitFailure", + Some(&destination_path), + Some(error.to_string()), + ) + })? { + Some(status) => { + let output = progress_thread.join().unwrap_or_default(); + return if status.success() { + Ok(()) + } else { + Err(local_copy_error( + "gitFailure", + Some(&destination_path), + Some(output.trim_end().to_string()), + )) + }; + } + None if cancel.load(Ordering::Relaxed) => { + drop(child.kill()); + drop(child.wait()); + drop(progress_thread.join()); + return Err(local_copy_error("cancelled", None, None)); + } + None => std::thread::sleep(std::time::Duration::from_millis(100)), + } + } + }) + .await + .map_err(|error| { + local_copy_error( + "filesystemFailure", + Some(destination), + Some(error.to_string()), + ) + })? +} + +async fn run_git_clone_with_progress( + repo_url: &str, + final_dest_str: &str, + on_progress: tauri::ipc::Channel, + cancel: Arc, + dest_existed: bool, +) -> Result<(), String> { + let cleanup_path = final_dest_str.to_string(); let mut cmd = crate::git_command(); configure_command(&mut cmd); - cmd.args(["clone", "--progress", &repo_url, &final_dest_str]) + cmd.args(["clone", "--progress", repo_url, final_dest_str]) .stderr(Stdio::piped()) .stdout(Stdio::null()); @@ -550,12 +2271,6 @@ pub async fn clone_repo( .take() .ok_or_else(|| "Failed to capture git clone stderr".to_string())?; - // Reset cancel flag and grab a clone of the Arc for use in spawn_blocking. - cancel_flag.0.store(false, Ordering::Relaxed); - let cancel = cancel_flag.0.clone(); - - // Read git's stderr in a background thread, forwarding each progress line - // to the frontend via the Channel and collecting output for error reporting. let reader_thread = std::thread::spawn(move || -> String { let mut reader = std::io::BufReader::new(stderr); let mut buf = [0u8; 4096]; @@ -582,7 +2297,7 @@ pub async fn clone_repo( Err(_) => break, } } - // Flush any remaining partial line. + let remaining = partial.trim().to_string(); if !remaining.is_empty() { collected.push_str(&remaining); @@ -592,8 +2307,6 @@ pub async fn clone_repo( collected }); - // Poll for git exit every 100 ms so we can honour cancel requests without - // blocking the async runtime (which would freeze the frontend stuff) tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { loop { match child.try_wait().map_err(|e| format!("Clone error: {e}"))? { @@ -621,7 +2334,41 @@ pub async fn clone_repo( } }) .await - .map_err(|e| format!("Internal error: {e}"))??; + .map_err(|e| format!("Internal error: {e}"))? +} + +#[tauri::command] +pub async fn clone_repo( + request: CloneRequest, + on_progress: tauri::ipc::Channel, + cancel_flag: tauri::State<'_, CloneCancelFlag>, + operation: tauri::State<'_, crate::LocalCopyOperation>, +) -> Result { + use crate::git::cli::CliGitHandler; + + let repo_url = request.repo_url.trim().to_string(); + let destination = request.destination.trim().to_string(); + + CliGitHandler::validate_clone_repo_url(&repo_url).map_err(|e| e.to_string())?; + + let final_dest = CliGitHandler::resolve_clone_destination(&repo_url, &destination) + .map_err(|e| e.to_string())?; + let final_dest_str = final_dest.to_string_lossy().to_string(); + let dest_existed = final_dest.exists(); + + // Single-flight: reject if a copy is in progress. Reset shared cancel + // after acquire so a stale cancellation does not poison this clone. + let _guard = acquire_single_flight(&operation.0).map_err(|e| e.code)?; + cancel_flag.0.store(false, Ordering::Relaxed); + + run_git_clone_with_progress( + &repo_url, + &final_dest_str, + on_progress, + cancel_flag.0.clone(), + dest_existed, + ) + .await?; Ok(OperationResult { message: format!("Cloned repository to {}", final_dest.display()), diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 34d779a..8b1a747 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -242,6 +242,16 @@ pub fn set_show_commit_graph_button( .set_show_commit_graph_button(show_commit_graph_button) } +#[tauri::command] +pub fn set_enable_local_copy( + enable_local_copy: bool, + state: tauri::State<'_, AppState>, +) -> Settings { + let settings = state.git_service.set_enable_local_copy(enable_local_copy); + crate::instance_coordinator::broadcast_settings_updated(); + settings +} + #[tauri::command] pub fn set_persistent_error_toasts( persistent_error_toasts: bool, @@ -262,6 +272,66 @@ pub fn set_error_toast_clear_delay_ms( .set_error_toast_clear_delay_ms(error_toast_clear_delay_ms) } +#[tauri::command] +pub fn set_ai_commit_context_limit_kib( + ai_commit_context_limit_kib: u32, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_ai_commit_context_limit_kib(ai_commit_context_limit_kib) +} + +#[tauri::command] +pub fn set_ai_conflict_context_limit_kib( + ai_conflict_context_limit_kib: u32, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_ai_conflict_context_limit_kib(ai_conflict_context_limit_kib) +} + +#[tauri::command] +pub fn set_ai_commit_message_max_tokens( + ai_commit_message_max_tokens: u32, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_ai_commit_message_max_tokens(ai_commit_message_max_tokens) +} + +#[tauri::command] +pub fn set_ai_conflict_resolution_max_tokens( + ai_conflict_resolution_max_tokens: u32, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_ai_conflict_resolution_max_tokens(ai_conflict_resolution_max_tokens) +} + +#[tauri::command] +pub fn set_ai_commit_message_prompt( + ai_commit_message_prompt: String, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_ai_commit_message_prompt(ai_commit_message_prompt) +} + +#[tauri::command] +pub fn set_ai_conflict_resolution_prompt( + ai_conflict_resolution_prompt: String, + state: tauri::State<'_, AppState>, +) -> Settings { + state + .git_service + .set_ai_conflict_resolution_prompt(ai_conflict_resolution_prompt) +} + #[tauri::command] pub fn set_panel_layout( left_pane_width: u32, diff --git a/src-tauri/src/commands/store_update.rs b/src-tauri/src/commands/store_update.rs index 586b2bf..9ef9257 100644 --- a/src-tauri/src/commands/store_update.rs +++ b/src-tauri/src/commands/store_update.rs @@ -95,10 +95,7 @@ mod platform { ApplicationModel::{Package, PackageVersion}, Services::Store::StoreContext, Win32::Foundation::HWND, - Win32::UI::{ - Shell::ShellExecuteW, - WindowsAndMessaging::SW_SHOWNORMAL, - }, + Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}, core::w, }; diff --git a/src-tauri/src/config_file.rs b/src-tauri/src/config_file.rs index d83984a..8ff2efa 100644 --- a/src-tauri/src/config_file.rs +++ b/src-tauri/src/config_file.rs @@ -12,9 +12,10 @@ pub fn load_or_migrate(toml_path: &Path, json_path: &Path) -> (Settings, bool) { if toml_path.exists() { match std::fs::read_to_string(toml_path) { Ok(text) => match toml::from_str::(&text) { - Ok(settings) => { + Ok(mut settings) => { + let migrated = settings.migrate_legacy_ai(contains_legacy_ai_keys(&text)); archive_migrated_json_config(json_path); - return (settings, false); + return (settings, migrated); } Err(_) => { // Malformed TOML - use defaults but don't overwrite the file. @@ -26,10 +27,9 @@ pub fn load_or_migrate(toml_path: &Path, json_path: &Path) -> (Settings, bool) { } if json_path.exists() { - let settings = std::fs::read_to_string(json_path) - .ok() - .and_then(|text| serde_json::from_str::(&text).ok()) - .unwrap_or_default(); + let text = std::fs::read_to_string(json_path).unwrap_or_default(); + let mut settings = serde_json::from_str::(&text).unwrap_or_default(); + settings.migrate_legacy_ai(contains_legacy_ai_keys(&text)); let created = create_from_template(toml_path, &settings).is_ok(); if created { @@ -43,6 +43,24 @@ pub fn load_or_migrate(toml_path: &Path, json_path: &Path) -> (Settings, bool) { (settings, !created) } +fn contains_legacy_ai_keys(text: &str) -> bool { + [ + "aiProvider", + "aiEndpoint", + "aiModel", + "aiReasoningPreference", + "aiEffortCapability", + "aiCommitContextLimitKib", + "aiConflictContextLimitKib", + "aiCommitMessageMaxTokens", + "aiConflictResolutionMaxTokens", + "aiCommitMessagePrompt", + "aiConflictResolutionPrompt", + ] + .iter() + .any(|key| text.contains(key)) +} + fn archive_migrated_json_config(json_path: &Path) { if !json_path.exists() { return; @@ -101,33 +119,79 @@ fn apply_settings_to_doc(doc: &mut toml_edit::DocumentMut, settings: &Settings) return; }; - let table = doc.as_table_mut(); - let fresh_table = fresh_doc.as_table(); - let template_table = template_doc.as_table(); + merge_table( + doc.as_table_mut(), + fresh_doc.as_table(), + template_doc.as_table(), + ); + + for key in [ + "aiProvider", + "aiEndpoint", + "aiModel", + "aiReasoningPreference", + "aiEffortCapability", + "aiCommitContextLimitKib", + "aiConflictContextLimitKib", + "aiCommitMessageMaxTokens", + "aiConflictResolutionMaxTokens", + "aiCommitMessagePrompt", + "aiConflictResolutionPrompt", + ] { + doc.as_table_mut().remove(key); + } +} - for (key, fresh_item) in fresh_table.iter() { - let Some(new_val) = fresh_item.as_value() else { +fn merge_table( + target: &mut toml_edit::Table, + fresh: &toml_edit::Table, + template: &toml_edit::Table, +) { + for (key, fresh_item) in fresh.iter() { + if let Some(fresh_table) = fresh_item.as_table() { + if target + .get(key) + .and_then(toml_edit::Item::as_table) + .is_none() + { + let item = template + .get(key) + .cloned() + .unwrap_or_else(|| toml_edit::Item::Table(toml_edit::Table::new())); + target.insert(key, item); + } + let Some(target_table) = target.get_mut(key).and_then(toml_edit::Item::as_table_mut) + else { + continue; + }; + let empty_template = toml_edit::Table::new(); + let template_table = template + .get(key) + .and_then(toml_edit::Item::as_table) + .unwrap_or(&empty_template); + merge_table(target_table, fresh_table, template_table); continue; - }; + } - if let Some((_keymut, item)) = table.get_key_value_mut(key) { - if let Some(v) = item.as_value_mut() { - *v = new_val.clone(); + if let Some(fresh_value) = fresh_item.as_value() { + if let Some(target_value) = target.get_mut(key).and_then(toml_edit::Item::as_value_mut) + { + let decor = target_value.decor().clone(); + *target_value = fresh_value.clone(); + *target_value.decor_mut() = decor; + } else if let Some((template_key, template_item)) = template.get_key_value(key) { + let mut item = template_item.clone(); + if let Some(value) = item.as_value_mut() { + *value = fresh_value.clone(); + } + target.insert_formatted(template_key, item); } else { - *item = toml_edit::value(new_val.clone()); + target.insert(key, toml_edit::value(fresh_value.clone())); } continue; } - if let Some((template_key, template_item)) = template_table.get_key_value(key) { - let mut new_item = template_item.clone(); - if let Some(v) = new_item.as_value_mut() { - *v = new_val.clone(); - } - table.insert_formatted(template_key, new_item); - } else { - table.insert(key, toml_edit::value(new_val.clone())); - } + target.insert(key, fresh_item.clone()); } } @@ -194,6 +258,116 @@ mod tests { assert_eq!(settings.error_toast_clear_delay_ms, 8000); assert_eq!(settings.commit_message_recommended_length, 72); assert_eq!(settings.ui_text_scale, 1.0); + assert_eq!( + settings.ai_provider, + crate::git::types::AiProvider::Disabled + ); + assert_eq!(settings.ai_commit_context_limit_kib, 24); + assert_eq!(settings.ai_conflict_context_limit_kib, 48); + assert_eq!(settings.ai_commit_message_max_tokens, 512); + assert_eq!(settings.ai_conflict_resolution_max_tokens, 4096); + assert!( + settings + .ai_commit_message_prompt + .starts_with("Write a concise") + ); + assert!( + settings + .ai_conflict_resolution_prompt + .starts_with("Resolve the supplied") + ); + } + + #[test] + fn load_toml_normalises_ai_context_limits() { + let dir = TempDir::new().unwrap(); + let toml_path = dir.path().join("config.toml"); + let json_path = dir.path().join("config.json"); + + write_file( + &toml_path, + "aiCommitContextLimitKib = 1\naiConflictContextLimitKib = 2048\naiCommitMessageMaxTokens = 0\naiConflictResolutionMaxTokens = 100000\naiCommitMessagePrompt = \"\"\naiConflictResolutionPrompt = \" \"\n", + ); + + let (settings, should_persist) = load_or_migrate(&toml_path, &json_path); + assert!(should_persist); + assert_eq!(settings.extensions.ai.commit_context_limit_kib, 8); + assert_eq!(settings.extensions.ai.conflict_context_limit_kib, 1024); + assert_eq!(settings.extensions.ai.commit_message_max_tokens, 1); + assert_eq!( + settings.extensions.ai.conflict_resolution_max_tokens, + 65_536 + ); + assert!( + settings + .extensions + .ai + .commit_message_prompt + .starts_with("Write a concise") + ); + assert!( + settings + .extensions + .ai + .conflict_resolution_prompt + .starts_with("Resolve the supplied") + ); + } + + #[test] + fn persists_ai_configuration_without_a_secret() { + let dir = TempDir::new().unwrap(); + let toml_path = dir.path().join("config.toml"); + let mut settings = Settings::default(); + settings.extensions.ai.enabled = true; + let profile = crate::ai::AiProfile { + provider: crate::git::types::AiProvider::OpenAiCompatible, + endpoint: "https://example.test/v1".to_string(), + model: "example-model".to_string(), + effort_capability: crate::git::types::AiEffortCapability::Supported(vec![ + crate::git::types::AiReasoningPreference::Low, + crate::git::types::AiReasoningPreference::High, + ]), + ..crate::ai::AiProfile::default() + }; + settings.extensions.ai.selected_profile_id = profile.id.clone(); + settings.extensions.ai.profiles.push(profile.clone()); + + create_from_template(&toml_path, &settings).unwrap(); + + let contents = std::fs::read_to_string(&toml_path).unwrap(); + assert!(contents.contains("provider = \"OpenAiCompatible\"")); + assert!(contents.contains("model = \"example-model\"")); + assert!(!contents.to_ascii_lowercase().contains("api key")); + let loaded: Settings = toml::from_str(&contents).unwrap(); + assert_eq!( + loaded.extensions.ai.profiles[0].provider, + crate::git::types::AiProvider::OpenAiCompatible + ); + assert_eq!(loaded.extensions.ai.profiles[0].model, "example-model"); + assert_eq!( + loaded.extensions.ai.profiles[0].effort_capability, + profile.effort_capability + ); + settings + .extensions + .ai + .structured_output_modes + .insert("cache-key".to_string(), "jsonObject".to_string()); + create_from_template(&toml_path, &settings).unwrap(); + let loaded: Settings = + toml::from_str(&std::fs::read_to_string(&toml_path).unwrap()).unwrap(); + assert_eq!( + loaded + .extensions + .ai + .structured_output_modes + .get("cache-key"), + Some(&"jsonObject".to_string()) + ); + + let legacy: Settings = toml::from_str("[extensions.ai]\nenabled = false\n").unwrap(); + assert!(legacy.extensions.ai.structured_output_modes.is_empty()); } #[test] @@ -443,6 +617,19 @@ mod tests { "missing key gained its template comment" ); assert!(updated.contains("showCommitGraphButton = false")); + assert!(updated.contains("enableLocalCopy = false")); + assert!(updated.contains("# Maximum context sent in each AI commit-message request")); + assert!(updated.contains("commitContextLimitKib = 24")); + assert!(updated.contains("# Maximum conflict context sent")); + assert!(updated.contains("conflictContextLimitKib = 48")); + assert!(updated.contains("# Maximum provider output for AI commit messages")); + assert!(updated.contains("commitMessageMaxTokens = 512")); + assert!(updated.contains("# Maximum provider output for AI conflict resolution")); + assert!(updated.contains("conflictResolutionMaxTokens = 4096")); + assert!(updated.contains("# Instructions used to generate commit messages.")); + assert!(updated.contains("commitMessagePrompt = ")); + assert!(updated.contains("# Instructions used to resolve conflicts.")); + assert!(updated.contains("conflictResolutionPrompt = ")); assert!(!updated.contains("enableUpdateWithMSStoreFlow")); } @@ -453,6 +640,27 @@ mod tests { assert!(!settings.show_commit_graph_button); } + #[test] + fn missing_local_copy_setting_defaults_to_false() { + let settings: Settings = toml::from_str("backendMode = \"Default\"\n").unwrap(); + + assert!(!settings.enable_local_copy); + } + + #[test] + fn persist_writes_local_copy_setting() { + let dir = TempDir::new().unwrap(); + let toml_path = dir.path().join("config.toml"); + + let mut settings = Settings::default(); + settings.enable_local_copy = true; + + persist(&toml_path, &settings).unwrap(); + + let updated = std::fs::read_to_string(&toml_path).unwrap(); + assert!(updated.contains("enableLocalCopy = true")); + } + #[test] fn persist_writes_commit_graph_button() { let dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/git/cli.rs b/src-tauri/src/git/cli.rs index 5cfa17c..6318021 100644 --- a/src-tauri/src/git/cli.rs +++ b/src-tauri/src/git/cli.rs @@ -176,9 +176,7 @@ impl CliGitHandler { let home = std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .ok_or_else(|| { - GitError::InvalidInput( - Self::SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE.to_string(), - ) + GitError::InvalidInput(Self::SSH_ALLOWED_SIGNERS_HOME_UNAVAILABLE.to_string()) })?; Ok(PathBuf::from(home) .join(".config") @@ -822,7 +820,7 @@ impl CliGitHandler { args.push("--binary"); args.push("--"); args.extend(paths.iter().map(String::as_str)); - Self::run_git_allow_exit_codes(&args, Some(repo_path), &[1]) + Self::run_git_allow_exit_codes_without_optional_locks(&args, Some(repo_path), &[1]) } fn git_diff_for_all(repo_path: &Path, staged: bool) -> GitResult { @@ -833,7 +831,7 @@ impl CliGitHandler { args.push("--full-index"); args.push("--binary"); args.push("--"); - Self::run_git_allow_exit_codes(&args, Some(repo_path), &[1]) + Self::run_git_allow_exit_codes_without_optional_locks(&args, Some(repo_path), &[1]) } fn git_untracked_patch(repo_path: &Path, path: &str) -> GitResult { @@ -883,8 +881,11 @@ impl CliGitHandler { } fn changed_unstaged_paths(repo_path: &Path) -> GitResult> { - let output = - Self::run_git_allow_exit_codes(&["diff", "--name-only", "--"], Some(repo_path), &[1])?; + let output = Self::run_git_allow_exit_codes_without_optional_locks( + &["diff", "--name-only", "--"], + Some(repo_path), + &[1], + )?; Ok(output .lines() .map(str::trim) @@ -2369,12 +2370,15 @@ impl GitOperationHandler for CliGitHandler { } let output = if request.staged { - Self::run_git( + Self::run_git_without_optional_locks( &["diff", "--cached", "--numstat", "--", file_path], Some(&repo_path), )? } else { - Self::run_git(&["diff", "--numstat", "--", file_path], Some(&repo_path))? + Self::run_git_without_optional_locks( + &["diff-files", "--numstat", "--", file_path], + Some(&repo_path), + )? }; let (additions, deletions) = Self::parse_numstat_totals(&output); @@ -2490,16 +2494,13 @@ impl GitOperationHandler for CliGitHandler { &[1], ) .ok() - .map(|value| value.trim().to_ascii_lowercase()); - let has_signing_key = - Self::run_git(&["config", "--get", "user.signingkey"], Some(&repo_path)) - .map(|value| !value.trim().is_empty()) - .unwrap_or(false); + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()); let should_sign = match commit_gpgsign.as_deref() { Some("false") | Some("0") | Some("no") | Some("off") => false, Some("true") | Some("1") | Some("yes") | Some("on") => true, Some(_) => true, - None => has_signing_key, + None => false, }; if should_sign { #[cfg(windows)] @@ -2918,6 +2919,7 @@ impl GitOperationHandler for CliGitHandler { let mut args = vec![ "log", + "--abbrev=7", "-n", limit.as_str(), skip.as_str(), @@ -3158,7 +3160,7 @@ impl GitOperationHandler for CliGitHandler { args.push("--"); args.push(file_path); - let output = match Self::run_git(&args, Some(&repo_path)) { + let output = match Self::run_git_without_optional_locks(&args, Some(&repo_path)) { Ok(stdout) => stdout, Err(GitError::CommandFailed { command: _, stderr, .. @@ -3363,7 +3365,8 @@ impl GitOperationHandler for CliGitHandler { let file_path = request.file_path.trim(); // Get the full diff for the file - let diff_output = Self::run_git(&["diff", "--", file_path], Some(&repo_path))?; + let diff_output = + Self::run_git_without_optional_locks(&["diff", "--", file_path], Some(&repo_path))?; if diff_output.is_empty() { return Err(GitError::InvalidInput( @@ -3404,7 +3407,10 @@ impl GitOperationHandler for CliGitHandler { let file_path = request.file_path.trim(); // Get the staged diff for the file. - let diff_output = Self::run_git(&["diff", "--cached", "--", file_path], Some(&repo_path))?; + let diff_output = Self::run_git_without_optional_locks( + &["diff", "--cached", "--", file_path], + Some(&repo_path), + )?; if diff_output.is_empty() { return Err(GitError::InvalidInput( @@ -3666,8 +3672,10 @@ impl GitOperationHandler for CliGitHandler { fn stash_list(&self, request: &RepoRequest) -> GitResult> { let repo_path = Self::normalise_repo_path(&request.repo_path)?; - let output = match Self::run_git(&["stash", "list", "--format=%gd|%h|%s"], Some(&repo_path)) - { + let output = match Self::run_git( + &["stash", "list", "--abbrev=7", "--format=%gd|%h|%s"], + Some(&repo_path), + ) { Ok(o) => o, Err(GitError::CommandFailed { stderr, .. }) if stderr.is_empty() => { return Ok(Vec::new()); @@ -3984,11 +3992,7 @@ impl GitOperationHandler for CliGitHandler { } }; let email = Self::scoped_config_get(&repo_path, &request.scope, "user.email")?.ok_or_else( - || { - GitError::InvalidInput( - Self::SSH_ALLOWED_SIGNERS_MISSING_EMAIL.to_string(), - ) - }, + || GitError::InvalidInput(Self::SSH_ALLOWED_SIGNERS_MISSING_EMAIL.to_string()), )?; let public_key = Self::resolve_ssh_signing_public_key(&signing_key)?; let target_path = PathBuf::from(target_path); @@ -5611,8 +5615,11 @@ impl CliGitHandler { } fn get_conflicted_files(repo_path: &Path) -> Vec { - Self::run_git(&["diff", "--name-only", "--diff-filter=U"], Some(repo_path)) - .unwrap_or_default() + Self::run_git_without_optional_locks( + &["diff", "--name-only", "--diff-filter=U"], + Some(repo_path), + ) + .unwrap_or_default() .lines() .filter(|l| !l.trim().is_empty()) .map(|l| l.trim().to_string()) @@ -5645,7 +5652,7 @@ impl CliGitHandler { } // Fallback: resolve MERGE_HEAD to a short hash - Self::run_git(&["rev-parse", "--short", "MERGE_HEAD"], Some(repo_path)) + Self::run_git(&["rev-parse", "--short=7", "MERGE_HEAD"], Some(repo_path)) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -5669,7 +5676,7 @@ impl CliGitHandler { .filter(|value| !value.is_empty()) })?; - Self::run_git(&["rev-parse", "--short", onto.as_str()], Some(repo_path)) + Self::run_git(&["rev-parse", "--short=7", onto.as_str()], Some(repo_path)) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) @@ -5686,7 +5693,7 @@ impl CliGitHandler { .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty())?; - Self::run_git(&["rev-parse", "--short", head.as_str()], Some(repo_path)) + Self::run_git(&["rev-parse", "--short=7", head.as_str()], Some(repo_path)) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) @@ -5703,7 +5710,7 @@ impl CliGitHandler { .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty())?; - Self::run_git(&["rev-parse", "--short", head.as_str()], Some(repo_path)) + Self::run_git(&["rev-parse", "--short=7", head.as_str()], Some(repo_path)) .ok() .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) @@ -5718,7 +5725,7 @@ impl CliGitHandler { } let detached_head = - Self::run_git(&["rev-parse", "--short", "HEAD"], Some(repo_path)).ok()?; + Self::run_git(&["rev-parse", "--short=7", "HEAD"], Some(repo_path)).ok()?; let trimmed_detached_head = detached_head.trim(); if trimmed_detached_head.is_empty() { None @@ -6324,4 +6331,140 @@ UD deleted_by_them.rs Some("repo".to_string()) ); } + + #[test] + fn get_numstat_unstaged_does_not_rewrite_index() { + let (repo, index_path, index_before) = repo_with_stale_index_entry(); + fs::write(repo.path().join("inspection-record.txt"), "modified") + .expect("write modified file"); + + let handler = CliGitHandler; + let request = NumstatRequest { + repo_path: repo.path().to_string_lossy().into_owned(), + file_path: "inspection-record.txt".to_string(), + staged: false, + }; + let result = handler.get_numstat(&request).expect("get_numstat unstaged"); + assert_eq!(result.file_path, "inspection-record.txt"); + assert_eq!(result.additions, 1); + assert_eq!(result.deletions, 1); + + assert_eq!( + fs::read(&index_path).expect("read index after numstat"), + index_before + ); + assert!(!index_path.with_file_name("index.lock").exists()); + } + + #[test] + fn get_numstat_staged_does_not_rewrite_index() { + let (repo, index_path, _index_before) = repo_with_stale_index_entry(); + fs::write(repo.path().join("inspection-record.txt"), "modified") + .expect("write modified file"); + let run_git = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(repo.path()) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + }; + run_git(&["add", "inspection-record.txt"]); + + let index_after_stage = fs::read(&index_path).expect("read index after stage"); + let tracked_file = fs::OpenOptions::new() + .write(true) + .open(repo.path().join("inspection-record.txt")) + .expect("open tracked file"); + tracked_file + .set_times( + fs::FileTimes::new() + .set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)), + ) + .expect("set tracked file timestamp"); + + let handler = CliGitHandler; + let request = NumstatRequest { + repo_path: repo.path().to_string_lossy().into_owned(), + file_path: "inspection-record.txt".to_string(), + staged: true, + }; + let result = handler.get_numstat(&request).expect("get_numstat staged"); + assert_eq!(result.file_path, "inspection-record.txt"); + assert_eq!(result.additions, 1); + assert_eq!(result.deletions, 1); + + assert_eq!( + fs::read(&index_path).expect("read index after numstat"), + index_after_stage + ); + assert!(!index_path.with_file_name("index.lock").exists()); + } + + #[test] + fn get_diff_unstaged_does_not_rewrite_index() { + let (repo, index_path, index_before) = repo_with_stale_index_entry(); + fs::write(repo.path().join("inspection-record.txt"), "modified") + .expect("write modified file"); + + let handler = CliGitHandler; + let request = DiffRequest { + repo_path: repo.path().to_string_lossy().into_owned(), + file_path: "inspection-record.txt".to_string(), + staged: false, + }; + let diff = handler.get_diff(&request).expect("get_diff unstaged"); + assert_eq!(diff.file_path, "inspection-record.txt"); + assert!(!diff.hunks.is_empty(), "expected diff hunks for modified file"); + + assert_eq!( + fs::read(&index_path).expect("read index after diff"), + index_before + ); + assert!(!index_path.with_file_name("index.lock").exists()); + } + + #[test] + fn get_diff_staged_does_not_rewrite_index() { + let (repo, index_path, _index_before) = repo_with_stale_index_entry(); + fs::write(repo.path().join("inspection-record.txt"), "modified") + .expect("write modified file"); + let run_git = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(repo.path()) + .status() + .expect("run git"); + assert!(status.success(), "git {args:?} failed"); + }; + run_git(&["add", "inspection-record.txt"]); + + let index_after_stage = fs::read(&index_path).expect("read index after stage"); + let tracked_file = fs::OpenOptions::new() + .write(true) + .open(repo.path().join("inspection-record.txt")) + .expect("open tracked file"); + tracked_file + .set_times( + fs::FileTimes::new() + .set_modified(UNIX_EPOCH + std::time::Duration::from_secs(1)), + ) + .expect("set tracked file timestamp"); + + let handler = CliGitHandler; + let request = DiffRequest { + repo_path: repo.path().to_string_lossy().into_owned(), + file_path: "inspection-record.txt".to_string(), + staged: true, + }; + let diff = handler.get_diff(&request).expect("get_diff staged"); + assert_eq!(diff.file_path, "inspection-record.txt"); + assert!(!diff.hunks.is_empty(), "expected diff hunks for staged file"); + + assert_eq!( + fs::read(&index_path).expect("read index after diff"), + index_after_stage + ); + assert!(!index_path.with_file_name("index.lock").exists()); + } } diff --git a/src-tauri/src/git/gix_handler.rs b/src-tauri/src/git/gix_handler.rs index 82bfe6e..f1b200f 100644 --- a/src-tauri/src/git/gix_handler.rs +++ b/src-tauri/src/git/gix_handler.rs @@ -81,9 +81,7 @@ impl GixGitHandler { .map(|rest| rest.starts_with('/')) .unwrap_or(false) }); - if repo_path.join(&candidate).is_dir() - && !has_tracked_descendant - { + if repo_path.join(&candidate).is_dir() && !has_tracked_descendant { return format!("{candidate}/"); } } diff --git a/src-tauri/src/git/handler.rs b/src-tauri/src/git/handler.rs index 5e30744..0946014 100644 --- a/src-tauri/src/git/handler.rs +++ b/src-tauri/src/git/handler.rs @@ -11,14 +11,14 @@ use super::types::{ CommitMessageRecovery, CommitPrimaryAction, CommitRequest, CreateBranchRequest, CreateTagRequest, DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, DiffRequest, ExportCommitPatchRequest, ExportPatchRequest, - ExternalDiffRequest, FetchRequest, FileDiff, - FileRequest, GitIdentity, HunkStageRequest, IdentityRequest, ImportPatchRequest, MergeRequest, - MergeResult, NumstatRequest, NumstatResult, OperationResult, PruneRemoteRequest, PullAnalysis, - PullStrategyRequest, PushRequest, PushResult, PushTagRequest, RebaseRequest, RebaseResult, - RemoteInfo, RemoveRemoteRequest, RenameBranchRequest, RenameRemoteRequest, RepoRequest, - RepoStatus, ResetRequest, RevertCommitRequest, SetBranchUpstreamRequest, SetIdentityRequest, - SetRemoteUrlRequest, Settings, SshAllowedSignerStatus, StageFilesRequest, StashEntry, - StashPushRequest, StashRequest, SubmoduleActionRequest, TagInfo, ThemeMode, + ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, HunkStageRequest, + IdentityRequest, ImportPatchRequest, MergeRequest, MergeResult, NumstatRequest, NumstatResult, + OperationResult, PruneRemoteRequest, PullAnalysis, PullStrategyRequest, PushRequest, + PushResult, PushTagRequest, RebaseRequest, RebaseResult, RemoteInfo, RemoveRemoteRequest, + RenameBranchRequest, RenameRemoteRequest, RepoRequest, RepoStatus, ResetRequest, + RevertCommitRequest, SetBranchUpstreamRequest, SetIdentityRequest, SetRemoteUrlRequest, + Settings, SshAllowedSignerStatus, StageFilesRequest, StashEntry, StashPushRequest, + StashRequest, SubmoduleActionRequest, TagInfo, ThemeMode, }; pub trait GitOperationHandler: Send + Sync { @@ -213,6 +213,27 @@ impl GitService { Settings::default() } + fn update_settings_persisted( + &self, + update: impl FnOnce(&mut Settings), + ) -> Result { + let path = self + .config_path + .read() + .map_err(|_| "Failed to acquire config path lock".to_string())? + .clone() + .ok_or_else(|| "Config path is not initialised".to_string())?; + let mut settings = self + .settings + .write() + .map_err(|_| "Failed to acquire settings lock".to_string())?; + let mut next = settings.clone(); + update(&mut next); + crate::config_file::persist(&path, &next)?; + *settings = next.clone(); + Ok(next) + } + pub fn set_backend_mode(&self, mode: BackendMode) -> Settings { self.update_settings(|settings| { settings.backend_mode = mode; @@ -255,6 +276,12 @@ impl GitService { }) } + pub fn set_enable_local_copy(&self, enable_local_copy: bool) -> Settings { + self.update_settings(|settings| { + settings.enable_local_copy = enable_local_copy; + }) + } + pub fn set_persistent_error_toasts(&self, persistent_error_toasts: bool) -> Settings { self.update_settings(|settings| { settings.persistent_error_toasts = persistent_error_toasts; @@ -377,6 +404,190 @@ impl GitService { }) } + pub fn set_ai_configuration( + &self, + provider: super::types::AiProvider, + endpoint: String, + model: String, + reasoning_preference: super::types::AiReasoningPreference, + effort_capability: super::types::AiEffortCapability, + ) -> Settings { + self.update_settings(|settings| { + let ai = &mut settings.extensions.ai; + ai.enabled = provider != super::types::AiProvider::Disabled; + let profile = ai.ensure_profile(); + profile.provider = provider; + profile.endpoint = endpoint; + profile.model = model; + profile.reasoning_preference = reasoning_preference; + profile.effort_capability = effort_capability; + }) + } + + pub fn save_ai_profile( + &self, + enabled: bool, + profile: crate::ai::AiProfile, + ) -> Result { + self.update_settings_persisted(|settings| { + let ai = &mut settings.extensions.ai; + ai.enabled = enabled; + ai.selected_profile_id = profile.id.clone(); + if let Some(existing) = ai + .profiles + .iter_mut() + .find(|existing| existing.id == profile.id) + { + *existing = profile; + } else { + ai.profiles.push(profile); + } + }) + } + + pub fn delete_ai_profile(&self, profile_id: &str) -> Result { + self.update_settings_persisted(|settings| { + let ai = &mut settings.extensions.ai; + ai.profiles.retain(|profile| profile.id != profile_id); + if ai.selected_profile_id == profile_id { + ai.selected_profile_id = ai + .profiles + .first() + .map(|profile| profile.id.clone()) + .unwrap_or_default(); + } + if ai.profiles.is_empty() { + ai.enabled = false; + } + }) + } + + pub fn update_structured_output_modes( + &self, + modes: std::collections::HashMap, + ) -> Result { + self.update_settings_persisted(|settings| { + settings.extensions.ai.structured_output_modes = modes; + }) + } + + pub fn remove_structured_output_mode(&self, key: &str) -> Result { + self.update_settings_persisted(|settings| { + settings.extensions.ai.structured_output_modes.remove(key); + }) + } + + pub fn set_ai_effort_capability( + &self, + profile_id: &str, + capability: super::types::AiEffortCapability, + ) -> Settings { + self.update_settings(|settings| { + if let Some(profile) = settings + .extensions + .ai + .profiles + .iter_mut() + .find(|profile| profile.id == profile_id) + { + profile.effort_capability = capability; + } + }) + } + + pub fn grant_ai_destination_consent(&self, destination: String) -> Result { + self.update_settings_persisted(|settings| { + let destinations = &mut settings.extensions.ai.consented_destinations; + if !destinations.contains(&destination) { + destinations.push(destination); + } + }) + } + + pub fn set_ai_repository_policy( + &self, + repository: String, + policy: crate::ai::AiRepositoryPolicy, + ) -> Result { + self.update_settings_persisted(|settings| { + settings + .extensions + .ai + .repository_policies + .insert(repository, policy); + }) + } + + pub fn set_ai_privacy_settings( + &self, + include_commit_history: bool, + global_exclusions: Vec, + ) -> Result { + self.update_settings_persisted(|settings| { + settings.extensions.ai.include_commit_history = include_commit_history; + settings.extensions.ai.global_exclusions = global_exclusions; + }) + } + + pub fn record_ai_usage(&self, record: crate::ai::AiUsageRecord) { + drop(self.update_settings_persisted(|settings| { + const THIRTY_DAYS_SECONDS: u64 = 30 * 24 * 60 * 60; + let oldest = record.timestamp.saturating_sub(THIRTY_DAYS_SECONDS); + let history = &mut settings.extensions.ai.usage_history; + history.retain(|existing| existing.timestamp >= oldest); + history.push(record); + if history.len() > 1000 { + history.drain(..history.len() - 1000); + } + })); + } + + pub fn clear_ai_usage_history(&self) -> Result { + self.update_settings_persisted(|settings| settings.extensions.ai.usage_history.clear()) + } + + pub fn set_ai_commit_context_limit_kib(&self, limit_kib: u32) -> Settings { + self.update_settings(|settings| { + settings.extensions.ai.commit_context_limit_kib = + Settings::normalised_ai_context_limit_kib(limit_kib); + }) + } + + pub fn set_ai_conflict_context_limit_kib(&self, limit_kib: u32) -> Settings { + self.update_settings(|settings| { + settings.extensions.ai.conflict_context_limit_kib = + Settings::normalised_ai_context_limit_kib(limit_kib); + }) + } + + pub fn set_ai_commit_message_max_tokens(&self, max_tokens: u32) -> Settings { + self.update_settings(|settings| { + settings.extensions.ai.commit_message_max_tokens = + Settings::normalised_ai_output_tokens(max_tokens); + }) + } + + pub fn set_ai_conflict_resolution_max_tokens(&self, max_tokens: u32) -> Settings { + self.update_settings(|settings| { + settings.extensions.ai.conflict_resolution_max_tokens = + Settings::normalised_ai_output_tokens(max_tokens); + }) + } + + pub fn set_ai_commit_message_prompt(&self, prompt: String) -> Settings { + self.update_settings(|settings| { + settings.extensions.ai.commit_message_prompt = + Settings::normalised_ai_commit_message_prompt(prompt); + }) + } + + pub fn set_ai_conflict_resolution_prompt(&self, prompt: String) -> Settings { + self.update_settings(|settings| { + settings.extensions.ai.conflict_resolution_prompt = + Settings::normalised_ai_conflict_resolution_prompt(prompt); + }) + } + pub fn set_gpg_keyserver_verification_enabled(&self, enabled: bool) -> Settings { self.update_settings(|settings| { settings.gpg_keyserver_verification_enabled = enabled; @@ -541,4 +752,62 @@ mod tests { assert!(settings.show_commit_graph_button); assert!(service.get_settings().show_commit_graph_button); } + + #[test] + fn set_enable_local_copy_updates_settings() { + let service = GitService::new(); + + let settings = service.set_enable_local_copy(true); + + assert!(settings.enable_local_copy); + assert!(service.get_settings().enable_local_copy); + } + + #[test] + fn ai_context_limit_setters_normalise_values() { + let service = GitService::new(); + + let settings = service.set_ai_commit_context_limit_kib(1); + assert_eq!(settings.extensions.ai.commit_context_limit_kib, 8); + + let settings = service.set_ai_conflict_context_limit_kib(2048); + assert_eq!(settings.extensions.ai.conflict_context_limit_kib, 1024); + } + + #[test] + fn ai_output_token_setters_normalise_values() { + let service = GitService::new(); + + let settings = service.set_ai_commit_message_max_tokens(0); + assert_eq!(settings.extensions.ai.commit_message_max_tokens, 1); + + let settings = service.set_ai_conflict_resolution_max_tokens(100_000); + assert_eq!( + settings.extensions.ai.conflict_resolution_max_tokens, + 65_536 + ); + } + + #[test] + fn empty_ai_prompts_restore_defaults() { + let service = GitService::new(); + + let settings = service.set_ai_commit_message_prompt(" ".to_string()); + assert!( + settings + .extensions + .ai + .commit_message_prompt + .starts_with("Write a concise") + ); + + let settings = service.set_ai_conflict_resolution_prompt("\n".to_string()); + assert!( + settings + .extensions + .ai + .conflict_resolution_prompt + .starts_with("Resolve the supplied") + ); + } } diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index f3953c3..085b699 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -1,6 +1,12 @@ use serde::{Deserialize, Deserializer, Serialize}; use super::error_interpretation::InterpretedGitError; +pub use crate::ai::types::{AiEffortCapability, AiProvider, AiReasoningPreference}; +use crate::ai::types::{ + AiProfile, DEFAULT_COMMIT_CONTEXT_LIMIT_KIB, DEFAULT_COMMIT_MESSAGE_MAX_TOKENS, + DEFAULT_COMMIT_MESSAGE_PROMPT, DEFAULT_CONFLICT_CONTEXT_LIMIT_KIB, + DEFAULT_CONFLICT_RESOLUTION_MAX_TOKENS, DEFAULT_CONFLICT_RESOLUTION_PROMPT, +}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum CommitDateMode { @@ -176,6 +182,30 @@ fn normalise_error_toast_clear_delay_ms(value: u32) -> u32 { value.max(MIN_ERROR_TOAST_CLEAR_DELAY_MS) } +fn legacy_commit_context_limit() -> u32 { + DEFAULT_COMMIT_CONTEXT_LIMIT_KIB +} + +fn legacy_conflict_context_limit() -> u32 { + DEFAULT_CONFLICT_CONTEXT_LIMIT_KIB +} + +fn legacy_commit_max_tokens() -> u32 { + DEFAULT_COMMIT_MESSAGE_MAX_TOKENS +} + +fn legacy_conflict_max_tokens() -> u32 { + DEFAULT_CONFLICT_RESOLUTION_MAX_TOKENS +} + +fn legacy_commit_prompt() -> String { + DEFAULT_COMMIT_MESSAGE_PROMPT.to_string() +} + +fn legacy_conflict_prompt() -> String { + DEFAULT_CONFLICT_RESOLUTION_PROMPT.to_string() +} + fn deserialise_error_toast_clear_delay_ms<'de, D>(deserialiser: D) -> Result where D: Deserializer<'de>, @@ -258,6 +288,8 @@ pub struct Settings { #[serde(default)] pub show_commit_graph_button: bool, #[serde(default)] + pub enable_local_copy: bool, + #[serde(default)] pub persistent_error_toasts: bool, #[serde( default = "default_error_toast_clear_delay_ms", @@ -305,6 +337,30 @@ pub struct Settings { pub git_executable_path: String, #[serde(default)] pub gpg_keyserver_verification_enabled: bool, + #[serde(default)] + pub extensions: crate::ai::types::ExtensionSettings, + #[serde(default, skip_serializing)] + pub ai_provider: AiProvider, + #[serde(default, skip_serializing)] + pub ai_endpoint: String, + #[serde(default, skip_serializing)] + pub ai_model: String, + #[serde(default, skip_serializing)] + pub ai_reasoning_preference: AiReasoningPreference, + #[serde(default, skip_serializing)] + pub ai_effort_capability: AiEffortCapability, + #[serde(default = "legacy_commit_context_limit", skip_serializing)] + pub ai_commit_context_limit_kib: u32, + #[serde(default = "legacy_conflict_context_limit", skip_serializing)] + pub ai_conflict_context_limit_kib: u32, + #[serde(default = "legacy_commit_max_tokens", skip_serializing)] + pub ai_commit_message_max_tokens: u32, + #[serde(default = "legacy_conflict_max_tokens", skip_serializing)] + pub ai_conflict_resolution_max_tokens: u32, + #[serde(default = "legacy_commit_prompt", skip_serializing)] + pub ai_commit_message_prompt: String, + #[serde(default = "legacy_conflict_prompt", skip_serializing)] + pub ai_conflict_resolution_prompt: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -352,6 +408,7 @@ impl Default for Settings { wrap_diff_lines: false, row_striping: RowStriping::Off, show_commit_graph_button: false, + enable_local_copy: false, persistent_error_toasts: false, error_toast_clear_delay_ms: DEFAULT_ERROR_TOAST_CLEAR_DELAY_MS, left_pane_width: 300, @@ -373,6 +430,18 @@ impl Default for Settings { repo_open_behaviour: RepoOpenBehaviour::Ask, git_executable_path: String::new(), gpg_keyserver_verification_enabled: false, + extensions: crate::ai::types::ExtensionSettings::default(), + ai_provider: AiProvider::Disabled, + ai_endpoint: String::new(), + ai_model: String::new(), + ai_reasoning_preference: AiReasoningPreference::Automatic, + ai_effort_capability: AiEffortCapability::Unknown, + ai_commit_context_limit_kib: legacy_commit_context_limit(), + ai_conflict_context_limit_kib: legacy_conflict_context_limit(), + ai_commit_message_max_tokens: legacy_commit_max_tokens(), + ai_conflict_resolution_max_tokens: legacy_conflict_max_tokens(), + ai_commit_message_prompt: legacy_commit_prompt(), + ai_conflict_resolution_prompt: legacy_conflict_prompt(), } } } @@ -386,6 +455,67 @@ impl Settings { normalise_error_toast_clear_delay_ms(value) } + pub fn normalised_ai_context_limit_kib(value: u32) -> u32 { + crate::ai::types::normalise_context_limit(value) + } + + pub fn normalised_ai_output_tokens(value: u32) -> u32 { + crate::ai::types::normalise_output_tokens(value) + } + + pub fn normalised_ai_commit_message_prompt(value: String) -> String { + crate::ai::types::normalise_prompt(value, DEFAULT_COMMIT_MESSAGE_PROMPT) + } + + pub fn normalised_ai_conflict_resolution_prompt(value: String) -> String { + crate::ai::types::normalise_prompt(value, DEFAULT_CONFLICT_RESOLUTION_PROMPT) + } + + pub fn migrate_legacy_ai(&mut self, legacy_configuration_present: bool) -> bool { + if !legacy_configuration_present { + return false; + } + + let ai = &mut self.extensions.ai; + ai.commit_context_limit_kib = + crate::ai::types::normalise_context_limit(self.ai_commit_context_limit_kib); + ai.conflict_context_limit_kib = + crate::ai::types::normalise_context_limit(self.ai_conflict_context_limit_kib); + ai.commit_message_max_tokens = + crate::ai::types::normalise_output_tokens(self.ai_commit_message_max_tokens); + ai.conflict_resolution_max_tokens = + crate::ai::types::normalise_output_tokens(self.ai_conflict_resolution_max_tokens); + ai.commit_message_prompt = crate::ai::types::normalise_prompt( + self.ai_commit_message_prompt.clone(), + DEFAULT_COMMIT_MESSAGE_PROMPT, + ); + ai.conflict_resolution_prompt = crate::ai::types::normalise_prompt( + self.ai_conflict_resolution_prompt.clone(), + DEFAULT_CONFLICT_RESOLUTION_PROMPT, + ); + + if ai.profiles.is_empty() + && (self.ai_provider != AiProvider::Disabled + || !self.ai_endpoint.trim().is_empty() + || !self.ai_model.trim().is_empty()) + { + let profile = AiProfile { + id: "migrated-default".to_string(), + provider: self.ai_provider, + endpoint: self.ai_endpoint.trim().to_string(), + model: self.ai_model.trim().to_string(), + reasoning_preference: self.ai_reasoning_preference, + effort_capability: self.ai_effort_capability.clone(), + ..AiProfile::default() + }; + ai.enabled = profile.provider != AiProvider::Disabled; + ai.selected_profile_id = profile.id.clone(); + ai.profiles.push(profile); + } + + true + } + fn default_confirm_revert() -> bool { true } @@ -410,6 +540,72 @@ pub struct CloneRequest { pub destination: String, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LocalCopyMode { + CompleteRepository, + FilesOnly, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LocalCopyDestinationMode { + DeleteExisting, + DropOnTop, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalCopyRequest { + pub source: String, + pub destination: String, + pub copy_mode: LocalCopyMode, + pub destination_mode: LocalCopyDestinationMode, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalCopyResult { + pub destination_path: String, + pub backend: String, + pub warning: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalCopyWarning { + pub code: String, + pub path: Option, + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalCopyError { + pub code: String, + pub path: Option, + pub detail: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum LocalCopyProgressPhase { + Preparing, + Scanning, + Cloning, + Copying, + Initialising, + Finalising, + RollingBack, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum LocalCopyProgress { + Phase { phase: LocalCopyProgressPhase }, + ExternalOutput { line: String }, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RepoRequest { diff --git a/src-tauri/src/instance_coordinator.rs b/src-tauri/src/instance_coordinator.rs index efce24c..ceb4202 100644 --- a/src-tauri/src/instance_coordinator.rs +++ b/src-tauri/src/instance_coordinator.rs @@ -59,7 +59,7 @@ pub enum CoordinatorCommand { path: String, }, OpenCloneWindow { - options: crate::shell::cli::CloneStartupOptions, + options: crate::shell::cli::CloneWindowStartupOptions, }, FocusWindow { label: String, @@ -284,41 +284,48 @@ fn handle_connection(mut stream: TcpStream, app: tauri::AppHandle) { fn process_command(cmd: CoordinatorCommand, app: &tauri::AppHandle) -> (bool, String) { match cmd { CoordinatorCommand::OpenRepo { path } => { - let _ = app.emit("instance-open-repo", path.clone()); + drop(app.emit("instance-open-repo", path.clone())); (true, path) } CoordinatorCommand::InitialiseRepo { path } => { - let _ = app.emit("instance-initialise-repo", path.clone()); + drop(app.emit("instance-initialise-repo", path.clone())); (true, path) } CoordinatorCommand::OpenCloneWindow { options } => { - if options.repo_url.is_some() || options.destination.is_some() || options.start_clone { - if let Some(state) = app.try_state::() { + if matches!( + options, + crate::shell::cli::CloneWindowStartupOptions::Copy(_) + ) && app + .try_state::() + .is_none_or(|state| !state.git_service.get_settings().enable_local_copy) + { + return (false, "localCopy.featureDisabled".into()); + } + if let Some(clone_window) = app.get_webview_window("clone-repository") { + if let Some(state) = app.try_state::() { if let Ok(mut guard) = state.0.lock() { - *guard = Some(options.clone()); + *guard = Some(options); } } - let _ = app.emit("clone-options-updated", options); - } - if let Some(w) = app.get_webview_window("clone-repository") { - let _ = w.show(); - let _ = w.set_focus(); + drop(app.emit("clone-window-options-updated", ())); + drop(clone_window.show()); + drop(clone_window.set_focus()); (true, "focused".into()) } else { (false, "clone window not found".into()) } } CoordinatorCommand::FocusWindow { label } => { - if let Some(w) = app.get_webview_window(&label) { - let _ = w.show(); - let _ = w.set_focus(); + if let Some(target_window) = app.get_webview_window(&label) { + drop(target_window.show()); + drop(target_window.set_focus()); (true, "focused".into()) } else { (false, "window not found".into()) } } CoordinatorCommand::SettingsUpdated => { - let _ = app.emit("instance-settings-updated", ()); + drop(app.emit("instance-settings-updated", ())); (true, "ok".into()) } CoordinatorCommand::Ping => (true, "pong".into()), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a072775..f3d1fb0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +pub mod ai; mod avatar; pub mod commands; mod config_file; @@ -10,7 +11,7 @@ mod window_manager; use git::handler::GitService; use git::types::AvatarProviderMode; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use shell::cli::{CliOutcome, CloneStartupOptions, ShellStartupAction}; +use shell::cli::{CliOutcome, CloneWindowStartupOptions, ShellStartupAction}; use shell::{ContextAction, WindowRouting}; use std::path::{Component, Path, PathBuf}; #[cfg(windows)] @@ -21,15 +22,18 @@ use tauri::{Emitter, Manager}; pub struct AppState { pub git_service: GitService, pub avatar_service: Arc, + pub(crate) ai_extension: ai::AiExtensionState, } +pub struct LocalCopyOperation(pub Mutex>>); + pub struct CloneCancelFlag(pub Arc); struct FsWatcherState(Mutex>); struct StartupState(Mutex>); -pub(crate) struct PendingCloneOptions(Mutex>); +pub(crate) struct PendingCloneWindowOptions(Mutex>); #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x08000000; @@ -146,16 +150,12 @@ fn forward_reuse_window_action(action: &ShellStartupAction) -> bool { ContextAction::OpenRepo => instance_coordinator::CoordinatorCommand::OpenRepo { path: action.path.clone(), }, - ContextAction::CloneRepo => instance_coordinator::CoordinatorCommand::OpenCloneWindow { - options: CloneStartupOptions { - repo_url: action.repo_url.clone(), - destination: action - .destination - .clone() - .or_else(|| Some(action.path.clone())), - start_clone: action.start_clone, - }, - }, + ContextAction::CloneRepo | ContextAction::LocalCopyRepo => { + let Some(options) = action.window_options.clone() else { + return false; + }; + instance_coordinator::CoordinatorCommand::OpenCloneWindow { options } + } ContextAction::InitialiseRepo => instance_coordinator::CoordinatorCommand::InitialiseRepo { path: action.path.clone(), }, @@ -1316,9 +1316,9 @@ fn get_startup_action(state: tauri::State<'_, StartupState>) -> Option, -) -> Option { +fn take_pending_clone_window_options( + state: tauri::State<'_, PendingCloneWindowOptions>, +) -> Option { state.0.lock().ok().and_then(|mut g| g.take()) } @@ -1329,28 +1329,26 @@ fn open_repo_in_new_window(path: String) -> Result<(), String> { #[tauri::command] async fn open_clone_window( - repo_url: Option, - destination: Option, - start_clone: Option, + options: CloneWindowStartupOptions, app: tauri::AppHandle, state: tauri::State<'_, AppState>, - pending: tauri::State<'_, PendingCloneOptions>, + pending: tauri::State<'_, PendingCloneWindowOptions>, ) -> Result<(), String> { - let options = CloneStartupOptions { - repo_url, - destination, - start_clone: start_clone.unwrap_or(false), - }; + if matches!(options, CloneWindowStartupOptions::Copy(_)) + && !state.git_service.get_settings().enable_local_copy + { + return Err("localCopy.featureDisabled".to_string()); + } if let Some(existing) = app.get_webview_window("clone-repository") { - if options.repo_url.is_some() || options.destination.is_some() || options.start_clone { + { let mut guard = pending .0 .lock() .map_err(|_| "Internal clone options state error".to_string())?; *guard = Some(options.clone()); - let _ = app.emit("clone-options-updated", options); } + drop(app.emit("clone-window-options-updated", ())); let _ = existing.show(); let _ = existing.set_focus(); return Ok(()); @@ -1369,7 +1367,7 @@ async fn open_clone_window( } } - if options.repo_url.is_some() || options.destination.is_some() || options.start_clone { + { let mut guard = pending .0 .lock() @@ -1383,7 +1381,7 @@ async fn open_clone_window( "Clone Repository".to_string(), "clone.html".to_string(), 520.0, - 460.0, + 560.0, false, false, state, @@ -1422,11 +1420,13 @@ pub fn run() { AvatarProviderMode::default(), true, )), + ai_extension: ai::AiExtensionState::new(), }) .manage(CloneCancelFlag(Arc::new(AtomicBool::new(false)))) + .manage(LocalCopyOperation(Mutex::new(None))) .manage(FsWatcherState(Mutex::new(None))) .manage(StartupState(Mutex::new(startup_action))) - .manage(PendingCloneOptions(Mutex::new(None))) + .manage(PendingCloneWindowOptions(Mutex::new(None))) .setup(move |app| { register_msix_application_restart(); @@ -1445,6 +1445,9 @@ pub fn run() { // Sync avatar service with the loaded settings let settings = state.git_service.get_settings(); + state + .ai_extension + .load_structured_output_modes(&settings.extensions.ai.structured_output_modes); if let Some(main_window) = app.get_webview_window("main") { let background_colour = window_manager::background_colour_for_theme_mode( &app.handle(), @@ -1493,6 +1496,36 @@ pub fn run() { builder .invoke_handler(tauri::generate_handler![ + ai::commands::get_ai_configuration, + ai::commands::save_ai_configuration, + ai::commands::set_ai_api_key, + ai::commands::clear_ai_api_key, + ai::commands::connect_openrouter, + ai::commands::grant_ai_consent, + ai::commands::set_ai_repository_policy, + ai::commands::get_ai_repository_policy, + ai::commands::set_ai_privacy_settings, + ai::commands::test_ai_connection, + ai::commands::test_ai_connection_draft, + ai::commands::discover_ai_models, + ai::commands::discover_ai_models_draft, + ai::commands::discover_ai_model_details_draft, + ai::commands::delete_ai_profile, + ai::commands::generate_ai_commit_message, + ai::commands::generate_ai_commit_messages, + ai::commands::cancel_ai_operation, + ai::commands::get_ai_usage_history, + ai::commands::clear_ai_usage_history, + ai::commands::get_ai_commit_context_preview, + ai::commands::get_ai_writing_context_preview, + ai::commands::generate_ai_writing, + ai::commands::get_ai_conflict_eligibility, + ai::commands::resolve_conflict_with_ai, + ai::commands::regenerate_ai_conflict_regions, + ai::commands::get_ai_conflict_context_preview, + ai::commands::apply_ai_conflict_proposal, + ai::commands::undo_ai_conflict_proposal, + ai::commands::undo_ai_conflict_batch, commands::settings::get_settings, commands::settings::set_backend_mode, commands::settings::set_show_result_log, @@ -1502,8 +1535,15 @@ pub fn run() { commands::settings::set_wrap_diff_lines, commands::settings::set_row_striping, commands::settings::set_show_commit_graph_button, + commands::settings::set_enable_local_copy, commands::settings::set_persistent_error_toasts, commands::settings::set_error_toast_clear_delay_ms, + commands::settings::set_ai_commit_context_limit_kib, + commands::settings::set_ai_conflict_context_limit_kib, + commands::settings::set_ai_commit_message_max_tokens, + commands::settings::set_ai_conflict_resolution_max_tokens, + commands::settings::set_ai_commit_message_prompt, + commands::settings::set_ai_conflict_resolution_prompt, commands::settings::set_panel_layout, commands::settings::set_confirm_revert, commands::settings::get_config_file_path, @@ -1589,6 +1629,8 @@ pub fn run() { commands::repo::get_repo_open_locations, commands::repo::open_repo_location, commands::repo::clone_repo, + commands::repo::local_copy_repo, + commands::repo::path_is_nonempty_dir, commands::repo::cancel_clone, commands::repo::get_default_clone_dir, commands::repo::open_external_diff, @@ -1657,7 +1699,7 @@ pub fn run() { get_startup_action, open_clone_window, open_repo_in_new_window, - take_pending_clone_options, + take_pending_clone_window_options, ]) .run(tauri::generate_context!()) .expect("failed to run tauri application"); diff --git a/src-tauri/src/shell/cli.rs b/src-tauri/src/shell/cli.rs index a09b2c4..44d7535 100644 --- a/src-tauri/src/shell/cli.rs +++ b/src-tauri/src/shell/cli.rs @@ -1,5 +1,6 @@ +use crate::git::types::{LocalCopyDestinationMode, LocalCopyMode}; use crate::shell::{ContextAction, WindowRouting}; -use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; +use clap::{Command as ClapCommand, CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; use serde::{Deserialize, Serialize}; use std::ffi::OsString; @@ -12,6 +13,13 @@ pub struct ShellStartupAction { pub path: String, #[serde(skip_serializing_if = "Option::is_none")] pub routing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub window_options: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct CloneStartupOptions { #[serde(skip_serializing_if = "Option::is_none")] pub repo_url: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -20,15 +28,25 @@ pub struct ShellStartupAction { pub start_clone: bool, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct CloneStartupOptions { +pub struct LocalCopyStartupOptions { #[serde(skip_serializing_if = "Option::is_none")] - pub repo_url: Option, + pub source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub destination: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub copy_mode: Option, + pub destination_mode: LocalCopyDestinationMode, #[serde(default, skip_serializing_if = "is_false")] - pub start_clone: bool, + pub start_copy: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "operationMode", content = "options", rename_all = "camelCase")] +pub enum CloneWindowStartupOptions { + Clone(CloneStartupOptions), + Copy(LocalCopyStartupOptions), } fn is_false(value: &bool) -> bool { @@ -76,6 +94,21 @@ enum Command { #[arg(long)] start: bool, }, + #[command(hide = true)] + Copy { + #[arg(value_name = "SOURCE")] + source: Option, + #[arg(value_name = "DESTINATION")] + destination: Option, + #[arg(long, value_name = "DESTINATION", conflicts_with = "destination")] + to: Option, + #[arg(long, value_enum, value_name = "MODE")] + mode: Option, + #[arg(long)] + delete_existing: bool, + #[arg(long)] + start: bool, + }, Init { #[arg(value_name = "PATH")] path: Option, @@ -98,6 +131,21 @@ enum CompletionShell { Powershell, } +#[derive(Clone, Copy, Debug, ValueEnum)] +enum CliLocalCopyMode { + FilesOnly, + CompleteRepository, +} + +impl From for LocalCopyMode { + fn from(value: CliLocalCopyMode) -> Self { + match value { + CliLocalCopyMode::FilesOnly => LocalCopyMode::FilesOnly, + CliLocalCopyMode::CompleteRepository => LocalCopyMode::CompleteRepository, + } + } +} + impl From for Shell { fn from(value: CompletionShell) -> Self { match value { @@ -132,6 +180,21 @@ pub fn parse_cli(args: impl IntoIterator) -> CliOutcome { to, start, }) => clone_action(repo, destination.or(to), start, routing), + Some(Command::Copy { + source, + destination, + to, + mode, + delete_existing, + start, + }) => copy_action( + source, + destination.or(to), + mode, + delete_existing, + start, + routing, + ), Some(Command::Init { path }) | Some(Command::Initialise { path }) => launch_action( ContextAction::InitialiseRepo, path.unwrap_or_else(current_dir_path), @@ -159,7 +222,17 @@ fn routing_for(cli: &Cli) -> Option { } fn completion_script(shell: CompletionShell) -> CliOutcome { - let mut command = Cli::command(); + let full_command = Cli::command(); + let arguments = full_command.get_arguments().cloned().collect::>(); + let subcommands = full_command + .get_subcommands() + .filter(|subcommand| subcommand.get_name() != "copy") + .cloned() + .collect::>(); + let mut command = ClapCommand::new("gitmun") + .version(env!("CARGO_PKG_VERSION")) + .args(arguments) + .subcommands(subcommands); let mut output = Vec::new(); generate(Shell::from(shell), &mut command, "gitmun", &mut output); CliOutcome::Print(String::from_utf8_lossy(&output).into_owned()) @@ -174,9 +247,7 @@ fn launch_action( action, path: normalise_cli_path(&path), routing, - repo_url: None, - destination: None, - start_clone: false, + window_options: None, })) } @@ -191,16 +262,79 @@ fn clone_action( .clone() .unwrap_or_else(|| current_dir_path().to_string_lossy().into_owned()); + let options = CloneStartupOptions { + repo_url, + destination: Some(path.clone()), + start_clone, + }; + CliOutcome::Launch(Some(ShellStartupAction { action: ContextAction::CloneRepo, path, routing, - repo_url, + window_options: Some(CloneWindowStartupOptions::Clone(options)), + })) +} + +fn copy_action( + source: Option, + destination: Option, + mode: Option, + delete_existing: bool, + start_copy: bool, + routing: Option, +) -> CliOutcome { + if start_copy && source.is_none() { + return cli_error("SOURCE is required when --start is used"); + } + if start_copy && destination.is_none() { + return cli_error("DESTINATION is required when --start is used"); + } + if start_copy && mode.is_none() { + return cli_error("--mode is required when --start is used"); + } + if delete_existing && !matches!(mode, Some(CliLocalCopyMode::FilesOnly)) { + return cli_error("--delete-existing requires --mode files-only"); + } + + let source = source.map(normalise_copy_source); + let destination = destination.map(|path| normalise_cli_path(&path)); + let path = destination + .clone() + .unwrap_or_else(|| current_dir_path().to_string_lossy().into_owned()); + let options = LocalCopyStartupOptions { + source, destination, - start_clone, + copy_mode: mode.map(LocalCopyMode::from), + destination_mode: if delete_existing { + LocalCopyDestinationMode::DeleteExisting + } else { + LocalCopyDestinationMode::DropOnTop + }, + start_copy, + }; + + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::LocalCopyRepo, + path, + routing, + window_options: Some(CloneWindowStartupOptions::Copy(options)), })) } +fn cli_error(message: &str) -> CliOutcome { + CliOutcome::Error(format!("error: {message}\n")) +} + +fn normalise_copy_source(source: String) -> String { + let path = Path::new(&source); + if path.exists() { + normalise_cli_path(path) + } else { + source + } +} + fn current_dir_path() -> PathBuf { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } @@ -239,9 +373,7 @@ mod tests { action: ContextAction::OpenRepo, path: cwd_path("."), routing: None, - repo_url: None, - destination: None, - start_clone: false, + window_options: None, })) ); } @@ -256,9 +388,7 @@ mod tests { action: ContextAction::OpenRepo, path, routing: None, - repo_url: None, - destination: None, - start_clone: false, + window_options: None, })) ); } @@ -271,9 +401,11 @@ mod tests { action: ContextAction::CloneRepo, path: current_dir_path().to_string_lossy().into_owned(), routing: None, - repo_url: None, - destination: None, - start_clone: false, + window_options: Some(CloneWindowStartupOptions::Clone(CloneStartupOptions { + repo_url: None, + destination: Some(current_dir_path().to_string_lossy().into_owned()), + start_clone: false, + })), })) ); } @@ -293,9 +425,11 @@ mod tests { action: ContextAction::CloneRepo, path: destination.clone(), routing: None, - repo_url: Some("git@github.com:owner/repo.git".to_string()), - destination: Some(destination), - start_clone: false, + window_options: Some(CloneWindowStartupOptions::Clone(CloneStartupOptions { + repo_url: Some("git@github.com:owner/repo.git".to_string()), + destination: Some(destination), + start_clone: false, + })), })) ); } @@ -310,9 +444,11 @@ mod tests { action: ContextAction::CloneRepo, path: destination.clone(), routing: None, - repo_url: None, - destination: Some(destination), - start_clone: false, + window_options: Some(CloneWindowStartupOptions::Clone(CloneStartupOptions { + repo_url: None, + destination: Some(destination), + start_clone: false, + })), })) ); } @@ -330,9 +466,11 @@ mod tests { action: ContextAction::CloneRepo, path: current_dir_path().to_string_lossy().into_owned(), routing: None, - repo_url: Some("https://example.test/repo.git".to_string()), - destination: None, - start_clone: true, + window_options: Some(CloneWindowStartupOptions::Clone(CloneStartupOptions { + repo_url: Some("https://example.test/repo.git".to_string()), + destination: Some(current_dir_path().to_string_lossy().into_owned()), + start_clone: true, + })), })) ); } @@ -345,9 +483,7 @@ mod tests { action: ContextAction::InitialiseRepo, path: current_dir_path().to_string_lossy().into_owned(), routing: None, - repo_url: None, - destination: None, - start_clone: false, + window_options: None, })) ); } @@ -360,17 +496,217 @@ mod tests { action: ContextAction::OpenRepo, path: cwd_path("."), routing: Some(WindowRouting::ReuseWindow), - repo_url: None, - destination: None, - start_clone: false, + window_options: None, + })) + ); + } + + #[test] + fn parses_bare_copy() { + assert_eq!( + parse(&["gitmun", "copy"]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::LocalCopyRepo, + path: current_dir_path().to_string_lossy().into_owned(), + routing: None, + window_options: Some(CloneWindowStartupOptions::Copy(LocalCopyStartupOptions { + source: None, + destination: None, + copy_mode: None, + destination_mode: LocalCopyDestinationMode::DropOnTop, + start_copy: false, + },)), + })) + ); + } + + #[test] + fn parses_copy_source_and_destination() { + let destination = cwd_path("copies/repo"); + + assert_eq!( + parse(&[ + "gitmun", + "copy", + "https://example.test/repo.git", + &destination, + "--mode", + "complete-repository", + ]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::LocalCopyRepo, + path: destination.clone(), + routing: None, + window_options: Some(CloneWindowStartupOptions::Copy(LocalCopyStartupOptions { + source: Some("https://example.test/repo.git".to_string()), + destination: Some(destination), + copy_mode: Some(LocalCopyMode::CompleteRepository), + destination_mode: LocalCopyDestinationMode::DropOnTop, + start_copy: false, + },)), + })) + ); + } + + #[test] + fn normalises_existing_copy_source_and_destination() { + let destination = cwd_path("copies/repo"); + + assert_eq!( + parse(&[ + "gitmun", + "copy", + ".", + "--to", + "copies/repo", + "--mode", + "files-only", + ]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::LocalCopyRepo, + path: destination.clone(), + routing: None, + window_options: Some(CloneWindowStartupOptions::Copy(LocalCopyStartupOptions { + source: Some(cwd_path(".")), + destination: Some(destination), + copy_mode: Some(LocalCopyMode::FilesOnly), + destination_mode: LocalCopyDestinationMode::DropOnTop, + start_copy: false, + },)), + })) + ); + } + + #[test] + fn parses_started_delete_existing_copy() { + let destination = cwd_path("copies/repo"); + + assert_eq!( + parse(&[ + "gitmun", + "--reuse-window", + "copy", + "https://example.test/repo.git", + &destination, + "--mode", + "files-only", + "--delete-existing", + "--start", + ]), + CliOutcome::Launch(Some(ShellStartupAction { + action: ContextAction::LocalCopyRepo, + path: destination.clone(), + routing: Some(WindowRouting::ReuseWindow), + window_options: Some(CloneWindowStartupOptions::Copy(LocalCopyStartupOptions { + source: Some("https://example.test/repo.git".to_string()), + destination: Some(destination), + copy_mode: Some(LocalCopyMode::FilesOnly), + destination_mode: LocalCopyDestinationMode::DeleteExisting, + start_copy: true, + },)), })) ); } + #[test] + fn copy_start_requires_source_destination_and_mode() { + for (args, expected) in [ + (vec!["gitmun", "copy", "--start"], "SOURCE is required"), + ( + vec!["gitmun", "copy", "source", "--start"], + "DESTINATION is required", + ), + ( + vec!["gitmun", "copy", "source", "destination", "--start"], + "--mode is required", + ), + ] { + match parse(&args) { + CliOutcome::Error(text) => assert!(text.contains(expected)), + other => panic!("expected error outcome, got {other:?}"), + } + } + } + + #[test] + fn delete_existing_requires_files_only_mode() { + for args in [ + vec!["gitmun", "copy", "--delete-existing"], + vec![ + "gitmun", + "copy", + "--mode", + "complete-repository", + "--delete-existing", + ], + ] { + match parse(&args) { + CliOutcome::Error(text) => { + assert!(text.contains("--delete-existing requires --mode files-only")); + } + other => panic!("expected error outcome, got {other:?}"), + } + } + } + + #[test] + fn copy_rejects_two_destinations() { + match parse(&[ + "gitmun", + "copy", + "source", + "destination", + "--to", + "other-destination", + ]) { + CliOutcome::Error(text) => assert!(text.contains("cannot be used with")), + other => panic!("expected error outcome, got {other:?}"), + } + } + + #[test] + fn parses_copy_in_new_window() { + match parse(&["gitmun", "--new-window", "copy"]) { + CliOutcome::Launch(Some(action)) => { + assert_eq!(action.routing, Some(WindowRouting::NewWindow)); + assert_eq!(action.action, ContextAction::LocalCopyRepo); + } + other => panic!("expected launch outcome, got {other:?}"), + } + } + + #[test] + fn serialises_copy_window_options_for_the_frontend() { + let options = CloneWindowStartupOptions::Copy(LocalCopyStartupOptions { + source: Some("/source".to_string()), + destination: Some("/destination".to_string()), + copy_mode: Some(LocalCopyMode::FilesOnly), + destination_mode: LocalCopyDestinationMode::DropOnTop, + start_copy: true, + }); + + assert_eq!( + serde_json::to_value(options).expect("serialise startup options"), + serde_json::json!({ + "operationMode": "copy", + "options": { + "source": "/source", + "destination": "/destination", + "copyMode": "filesOnly", + "destinationMode": "dropOnTop", + "startCopy": true + } + }) + ); + } + #[test] fn help_prints_without_launching() { match parse(&["gitmun", "--help"]) { - CliOutcome::Print(text) => assert!(text.contains("Usage:")), + CliOutcome::Print(text) => { + assert!(text.contains("Usage:")); + assert!(!text.contains("\n copy")); + } other => panic!("expected print outcome, got {other:?}"), } } @@ -378,7 +714,10 @@ mod tests { #[test] fn completions_print_without_launching() { match parse(&["gitmun", "completions", "bash"]) { - CliOutcome::Print(text) => assert!(text.contains("gitmun")), + CliOutcome::Print(text) => { + assert!(text.contains("gitmun")); + assert!(!text.contains("_copy")); + } other => panic!("expected print outcome, got {other:?}"), } } diff --git a/src-tauri/src/shell/mod.rs b/src-tauri/src/shell/mod.rs index 2b70873..04195dd 100644 --- a/src-tauri/src/shell/mod.rs +++ b/src-tauri/src/shell/mod.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; pub enum ContextAction { OpenRepo, CloneRepo, + LocalCopyRepo, InitialiseRepo, } diff --git a/src-tauri/src/window_manager.rs b/src-tauri/src/window_manager.rs index 51f298e..bdc5d77 100644 --- a/src-tauri/src/window_manager.rs +++ b/src-tauri/src/window_manager.rs @@ -290,12 +290,12 @@ pub fn show_window( #[cfg(target_os = "linux")] { - // Tao's set_visible(true) only calls show_all(), which doesn't - // signal the compositor to fully manage the window. KWin needs - // gtk_window_present() to properly attach interactive server-side - // decorations (close / minimise / maximise buttons). + // Hidden Tao windows reject focus until their first draw, so enable + // it before presenting. KWin also needs present() to attach its + // interactive server-side decorations. if let Ok(gtk_win) = window.gtk_window() { use gtk::prelude::GtkWindowExt; + gtk_win.set_accept_focus(true); gtk_win.present(); } } diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs index 7b9324d..842bbd0 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -392,11 +392,18 @@ fn status_reports_untracked_item_kinds() { let cli_status = handler() .get_repo_status(&repo_request(&dir)) .expect("cli get_repo_status"); - assert!(cli_status.unversioned_files.iter().any(|path| path == "new.txt")); - assert!(cli_status - .unversioned_files - .iter() - .any(|path| path == "notes/")); + assert!( + cli_status + .unversioned_files + .iter() + .any(|path| path == "new.txt") + ); + assert!( + cli_status + .unversioned_files + .iter() + .any(|path| path == "notes/") + ); assert_unversioned_item(&cli_status, "new.txt", UnversionedItemKind::File); assert_unversioned_item(&cli_status, "tracked/new.txt", UnversionedItemKind::File); assert_unversioned_item(&cli_status, "notes/", UnversionedItemKind::Directory); @@ -404,11 +411,18 @@ fn status_reports_untracked_item_kinds() { let gix_status = gix_handler() .get_repo_status(&repo_request(&dir)) .expect("gix get_repo_status"); - assert!(gix_status.unversioned_files.iter().any(|path| path == "new.txt")); - assert!(gix_status - .unversioned_files - .iter() - .any(|path| path == "notes/")); + assert!( + gix_status + .unversioned_files + .iter() + .any(|path| path == "new.txt") + ); + assert!( + gix_status + .unversioned_files + .iter() + .any(|path| path == "notes/") + ); assert_unversioned_item(&gix_status, "new.txt", UnversionedItemKind::File); assert_unversioned_item(&gix_status, "tracked/new.txt", UnversionedItemKind::File); assert_unversioned_item(&gix_status, "notes/", UnversionedItemKind::Directory); @@ -510,7 +524,11 @@ fn import_patch_three_way_returns_conflict_result_for_drifted_full_index_patch() git(dir.path(), &["commit", "-m", "add calibration baseline"]); let base_hash = head_hash(dir.path()); - write_file(dir.path(), "calibration-report.txt", "incoming calibration\n"); + write_file( + dir.path(), + "calibration-report.txt", + "incoming calibration\n", + ); git(dir.path(), &["add", "calibration-report.txt"]); git(dir.path(), &["commit", "-m", "record incoming calibration"]); let patch = dir.path().join("sonar-calibration.patch"); @@ -521,7 +539,11 @@ fn import_patch_three_way_returns_conflict_result_for_drifted_full_index_patch() fs::write(&patch, format!("{patch_content}\n")).expect("write patch"); git(dir.path(), &["reset", "--hard", &base_hash]); - write_file(dir.path(), "calibration-report.txt", "operator correction\n"); + write_file( + dir.path(), + "calibration-report.txt", + "operator correction\n", + ); git(dir.path(), &["add", "calibration-report.txt"]); git(dir.path(), &["commit", "-m", "record operator correction"]); @@ -581,7 +603,11 @@ fn import_patch_three_way_blocks_dirty_tracked_files() { git(dir.path(), &["commit", "-m", "add calibration baseline"]); let base_hash = head_hash(dir.path()); - write_file(dir.path(), "calibration-report.txt", "incoming calibration\n"); + write_file( + dir.path(), + "calibration-report.txt", + "incoming calibration\n", + ); git(dir.path(), &["add", "calibration-report.txt"]); git(dir.path(), &["commit", "-m", "record incoming calibration"]); let patch = dir.path().join("sonar-calibration.patch"); @@ -1313,6 +1339,27 @@ fn commit_creates_entry_in_log() { assert!(commits.iter().any(|c| c.message == "add b.txt")); } +#[test] +fn commit_does_not_sign_when_gpgsign_is_unset() { + let dir = init_repo(); + git(dir.path(), &["config", "--unset", "commit.gpgsign"]); + write_file(dir.path(), "unsigned.txt", "data"); + git(dir.path(), &["add", "unsigned.txt"]); + + handler() + .commit_changes(&CommitRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + message: "commit without signing".to_string(), + amend: None, + }) + .expect("commit_changes should not sign when commit.gpgsign is unset"); + + assert_eq!( + git_stdout(dir.path(), &["log", "-1", "--format=%s"]), + "commit without signing" + ); +} + #[test] fn commit_preserves_description_and_trailer_like_lines() { let dir = init_repo(); @@ -1368,7 +1415,8 @@ fn commit_message_recovery_ignores_missing_or_comment_only_file() { .is_none() ); - fs::write(commit_editmsg_path, "# comment only\n\n# still comment\n").expect("write COMMIT_EDITMSG"); + fs::write(commit_editmsg_path, "# comment only\n\n# still comment\n") + .expect("write COMMIT_EDITMSG"); assert!( handler() diff --git a/src/api/commands.ts b/src/api/commands.ts index 8b4d0e5..756f9cf 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -55,6 +55,9 @@ import type { BackendMode, LinuxTerminalOption, LinuxTerminalId, + LocalCopyProgress, + LocalCopyRequest, + LocalCopyResult, ThemeMode, ThemeBundle, UiTextScale, @@ -70,10 +73,43 @@ import type { SetRemoteUrlRequest, PruneRemoteRequest, StashEntry, - CloneStartupOptions, + CloneWindowStartupOptions, ShellStartupAction, } from "../types"; +export { + applyAiConflictProposal, + cancelAiOperation, + clearAiUsageHistory, + clearAiApiKey, + connectOpenRouter, + deleteAiProfile, + discoverAiModels, + discoverAiModelsDraft, + discoverAiModelDetailsDraft, + generateAiCommitMessage, + generateAiCommitMessages, + generateAiWriting, + getAiCommitContextPreview, + getAiWritingContextPreview, + getAiConfiguration, + getAiRepositoryPolicy, + getAiUsageHistory, + getAiConflictContextPreview, + getAiConflictEligibility, + grantAiConsent, + regenerateAiConflictRegions, + resolveConflictWithAi, + saveAiConfiguration, + setAiApiKey, + setAiRepositoryPolicy, + setAiPrivacySettings, + testAiConnection, + testAiConnectionDraft, + undoAiConflictBatch, + undoAiConflictProposal, +} from "../features/ai"; + export function getRepoStatus(repoPath: string): Promise { return invoke("get_repo_status", {request: {repoPath}}); } @@ -591,6 +627,10 @@ export function initRepo(repoPath: string): Promise { return invoke("init_repo", {repoPath}); } +export function localCopyRepo(request: LocalCopyRequest, onProgress: Channel): Promise { + return invoke("local_copy_repo", {request, onProgress}); +} + export function detectDesktopEnvironment(): Promise { return invoke("detect_desktop_environment"); } @@ -672,14 +712,12 @@ export function openRepoInNewWindow(path: string): Promise { return invoke("open_repo_in_new_window", {path}); } -export function openCloneWindowWithOptions(options: CloneStartupOptions = {}): Promise { - return invoke("open_clone_window", { - repoUrl: options.repoUrl ?? null, - destination: options.destination ?? null, - startClone: options.startClone ?? false, - }); +export function openCloneWindowWithOptions( + options: CloneWindowStartupOptions = {operationMode: "clone", options: {}}, +): Promise { + return invoke("open_clone_window", {options}); } -export function takePendingCloneOptions(): Promise { - return invoke("take_pending_clone_options"); +export function takePendingCloneWindowOptions(): Promise { + return invoke("take_pending_clone_window_options"); } diff --git a/src/components/App.tsx b/src/components/App.tsx index ae7c456..50908a7 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -275,14 +275,32 @@ export function App() { } }); } else if (action.action === "cloneRepo") { - api.openCloneWindowWithOptions({ - repoUrl: action.repoUrl, - destination: action.destination ?? action.path, - startClone: action.startClone, - }).catch(e => { + const options = action.windowOptions ?? { + operationMode: "clone" as const, + options: {destination: action.path}, + }; + api.openCloneWindowWithOptions(options).catch(e => { showToast(String(e), "error"); appendResultLog("error", t("log.cloneWindowFailed", {message: String(e)}), "unknown"); }); + } else if (action.action === "localCopyRepo") { + const options = action.windowOptions ?? { + operationMode: "copy" as const, + options: {destinationMode: "dropOnTop" as const}, + }; + api.getSettings().then(settings => { + if (!settings.enableLocalCopy) { + showToast(t("errors.featureDisabled", {ns: "clone"}), "error"); + return; + } + return api.openCloneWindowWithOptions(options); + }).catch(e => { + const message = String(e).includes("localCopy.featureDisabled") + ? t("errors.featureDisabled", {ns: "clone"}) + : String(e); + showToast(message, "error"); + appendResultLog("error", t("log.cloneWindowFailed", {message}), "unknown"); + }); } else if (action.action === "initialiseRepo") { api.initRepo(action.path).then(async (result) => { await api.validateRepoPath(action.path); diff --git a/src/components/ProjectView.test.ts b/src/components/ProjectView.test.ts index 5a54ee3..7da6dfd 100644 --- a/src/components/ProjectView.test.ts +++ b/src/components/ProjectView.test.ts @@ -7,6 +7,7 @@ import { getEffectiveCommitAction, importPatchWithRecovery, isPatchConflictResult, + localiseAiError, shouldForceWithLeaseAfterRebase, } from "./ProjectView"; @@ -34,6 +35,21 @@ describe("getEffectiveCommitAction", () => { }); }); +describe("localiseAiError", () => { + it("reports measured and configured conflict context sizes", () => { + expect(localiseAiError({ + code: "contextTooLarge", + contextSizeKib: 57, + contextLimitKib: 48, + }, t)).toBe("Conflict context is 57 KiB; the configured limit is 48 KiB."); + }); + + it("keeps the generic context error when size metadata is absent", () => { + expect(localiseAiError({code: "contextTooLarge"}, t)) + .toBe("This conflict exceeds the safe AI request limit."); + }); +}); + describe("shouldForceWithLeaseAfterRebase", () => { it("forces the next push on the rebased branch", () => { expect(shouldForceWithLeaseAfterRebase("main", "main")).toBe(true); diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index 0da9850..b2d93cc 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -12,6 +12,7 @@ import { ask, open, save } from "@tauri-apps/plugin-dialog"; import { listen } from "@tauri-apps/api/event"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; +import { createRefreshCoalescer } from "../hooks/useRefreshCoalescer"; import { Titlebar } from "./Titlebar"; import { Sidebar } from "./sidebar/Sidebar"; import { CentrePanel, type CentreTab } from "./centre/CentrePanel"; @@ -42,6 +43,7 @@ import { useGitStashes } from "../hooks/useGitStashes"; import * as api from "../api/commands"; import type { ResetMode } from "../api/commands"; import type { + AiError, BranchInfo, CommitLogScope, CommitMarkers, @@ -72,6 +74,8 @@ import type { ToastType } from "../hooks/useToast"; import { buildPushFailureDisplay } from "../utils/gitErrorDisplay"; import { getRemoteActionState, splitUpstreamRef } from "../utils/remoteActionState"; import { displayNameForRepoPath } from "../utils/repoDisplayName"; +import {AiConflictProposalDialog, AiWritingDialog} from "../features/ai"; +import type {AiConflictOperation, AiConflictProposalResult, AiConflictReviewItem, AiContextPreview} from "../features/ai"; // Tracks whether the no-diff-tool warning has already been shown this session // (lives outside the component so repo switches don't reset it). @@ -162,6 +166,30 @@ function localisePatchImportMessage(message: string, t: TFunction<"projectView"> return code ? t(`patch.import.${code}`) : message; } +export function localiseAiError(error: unknown, t: TFunction<"projectView">): string { + const aiError = typeof error === "object" && error !== null && "code" in error + ? error as AiError + : null; + if ( + aiError?.code === "contextTooLarge" + && Number.isFinite(aiError.contextSizeKib) + && Number.isFinite(aiError.contextLimitKib) + ) { + return t("aiErrors.contextTooLargeWithSize", { + actual: aiError.contextSizeKib, + limit: aiError.contextLimitKib, + }); + } + return t(`aiErrors.${aiError?.code ?? "unknown"}`); +} + +function isAiOperationCancelled(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && error.code === "operationCancelled"; +} + export function buildStashDropPrompt( stash: Pick, t: TFunction<"projectView">, @@ -340,6 +368,7 @@ export function ProjectView({ }: ProjectViewProps) { const { t } = useTranslation("projectView"); const { t: tGitAdvice } = useTranslation("gitAdvice"); + const { t: tAi } = useTranslation("ai"); const emptyStateRecentRepos = !repoPath ? recentRepos.slice(0, 5) : []; const collapsedRightPaneBonus = leftPaneCollapsed ? Math.max(0, leftPaneWidth + 6 - 22) @@ -404,12 +433,38 @@ export function ProjectView({ const [rowStriping, setRowStriping] = useState("Off"); const [showCommitGraphButton, setShowCommitGraphButton] = useState(false); const [showCommitGraph, setShowCommitGraph] = useState(readShowCommitGraphPreference); + const [aiEnabled, setAiEnabled] = useState(false); + const [aiConfigured, setAiConfigured] = useState(false); + const [aiResolvingPath, setAiResolvingPath] = useState(null); + const [aiConflictReviewItems, setAiConflictReviewItems] = useState([]); + const [aiConflictBatchProgress, setAiConflictBatchProgress] = useState<{ + current: number; + total: number; + preparing: boolean; + } | null>(null); + const [aiConflictBatchFailure, setAiConflictBatchFailure] = useState<{ + filePath: string; + message: string; + } | null>(null); + const [aiConflictOperation, setAiConflictOperation] = useState(null); + const [showAiWriting, setShowAiWriting] = useState(false); + const aiConflictOperationIdRef = useRef(""); + const aiConflictBatchCancelledRef = useRef(false); + const aiConflictBatchDecisionRef = useRef<((continueBatch: boolean) => void) | null>(null); const [searchQuery, setSearchQuery] = useState(""); const [windowFocused, setWindowFocused] = useState(() => ( typeof document === "undefined" ? true : document.hasFocus() )); const searchInputRef = useRef(null); + useEffect(() => () => { + aiConflictBatchCancelledRef.current = true; + aiConflictBatchDecisionRef.current?.(false); + aiConflictBatchDecisionRef.current = null; + const operationId = aiConflictOperationIdRef.current; + if (operationId) void api.cancelAiOperation(operationId).catch(() => {}); + }, []); + const { identity: localIdentity, saving: localIdentitySaving, saveIdentity: saveLocalIdentity, refreshIdentity: refreshLocalIdentity } = useGitIdentity(repoPath, "Local"); const { identity: globalIdentity, saving: globalIdentitySaving, saveIdentity: saveGlobalIdentity, refreshIdentity: refreshGlobalIdentity } = @@ -635,6 +690,34 @@ export function ProjectView({ }; }, [settingsRevision]); + useEffect(() => { + let cancelled = false; + const refreshAiConfiguration = () => { + api.getAiConfiguration() + .then(configuration => { + if (!cancelled) { + setAiEnabled(configuration.enabled); + setAiConfigured(configuration.configured); + } + }) + .catch(() => { + if (!cancelled) { + setAiEnabled(false); + setAiConfigured(false); + } + }); + }; + refreshAiConfiguration(); + let unlisten: (() => void) | null = null; + listen("ai-configuration-updated", refreshAiConfiguration).then(remove => { + if (cancelled) remove(); else unlisten = remove; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [settingsRevision]); + useEffect(() => { // Respect the user's scope choice; fall back to global email when local // scope is selected but has no email configured (very common). @@ -663,19 +746,21 @@ export function ProjectView({ await refreshAll(); }, [saveGlobalIdentity, refreshAll]); + const refreshAllRef = useRef(refreshAll); + refreshAllRef.current = refreshAll; + const refreshCoalescerRef = useRef(createRefreshCoalescer(() => refreshAllRef.current())); + // Refresh all data when settings change (e.g. avatar provider toggle). const isFirstSettingsRevision = useRef(true); useEffect(() => { if (isFirstSettingsRevision.current) { isFirstSettingsRevision.current = false; return; } - refreshAll(); + refreshCoalescerRef.current.trigger(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [settingsRevision]); // Watch the .git directory for external changes (other git clients, CLI). // Uses a ref so the listener always calls the latest refreshAll without // needing to re-subscribe when its dependencies change. - const refreshAllRef = useRef(refreshAll); - refreshAllRef.current = refreshAll; const windowFocusedRef = useRef(windowFocused); windowFocusedRef.current = windowFocused; const pendingFocusRefreshRef = useRef(false); @@ -685,7 +770,7 @@ export function ProjectView({ windowFocusedRef.current = focused; if (focused && pendingFocusRefreshRef.current) { pendingFocusRefreshRef.current = false; - refreshAllRef.current(); + refreshCoalescerRef.current.trigger(); } }; const handleFocus = () => setFocused(true); @@ -726,11 +811,12 @@ export function ProjectView({ pendingFocusRefreshRef.current = true; return; } - refreshAllRef.current(); + refreshCoalescerRef.current.trigger(); }).then(fn => { if (cancelled) fn(); else unlisten = fn; }); return () => { cancelled = true; + refreshCoalescerRef.current.reset(); api.unwatchRepo().catch(() => {}); unlisten?.(); }; @@ -1580,7 +1666,7 @@ export function ProjectView({ } const confirmed = await ask( - t("ask.cherryPickCommit.message", { commit: commitHash.slice(0, 12), branch: currentBranch ?? t("labels.currentBranch") }), + t("ask.cherryPickCommit.message", { commit: commitHash.slice(0, 7), branch: currentBranch ?? t("labels.currentBranch") }), { title: t("ask.cherryPickCommit.title"), kind: "warning", okLabel: t("actions.cherryPick"), cancelLabel: t("actions.cancel") }, ); if (!confirmed) return; @@ -2117,7 +2203,7 @@ export function ProjectView({ } const confirmed = await ask( - t("ask.revertCommit.message", { commit: commitHash.slice(0, 12), branch: currentBranch ?? t("labels.currentBranch") }), + t("ask.revertCommit.message", { commit: commitHash.slice(0, 7), branch: currentBranch ?? t("labels.currentBranch") }), { title: t("ask.revertCommit.title"), kind: "warning", okLabel: t("actions.revert"), cancelLabel: t("actions.cancel") }, ); if (!confirmed) return; @@ -2193,7 +2279,7 @@ export function ProjectView({ ? t("ask.resetToCommit.softDescription") : t("ask.resetToCommit.mixedDescription"); const confirmed = await ask( - t("ask.resetToCommit.message", { mode: modeLabel, commit: commitHash.slice(0, 12), description: modeDesc }), + t("ask.resetToCommit.message", { mode: modeLabel, commit: commitHash.slice(0, 7), description: modeDesc }), { title: t("ask.resetToCommit.title", { mode: modeLabel }), kind: "warning", okLabel: t("actions.reset"), cancelLabel: t("actions.cancel") }, ); if (!confirmed) return; @@ -2299,6 +2385,331 @@ export function ProjectView({ } }, [repoPath, refreshStatus, showToast, t]); + const handleConflictResolveWithAi = useCallback(async (path: string) => { + if (!repoPath) return; + if (aiResolvingPath || aiConflictOperationIdRef.current) return; + const operationId = `conflict-${Date.now()}-${Math.random().toString(16).slice(2)}`; + aiConflictBatchCancelledRef.current = false; + aiConflictOperationIdRef.current = operationId; + setAiConflictBatchProgress(null); + setAiResolvingPath(path); + try { + const configuration = await api.getAiConfiguration(); + if (configuration.consentRequired) { + const preview = await api.getAiConflictContextPreview(repoPath, path); + const confirmed = await ask([ + tAi("context.destination", { + provider: preview.provider, + authority: preview.destinationAuthority, + }), + tAi("context.files", {count: preview.files.length}), + tAi("context.size", { + size: preview.contextSizeKib, + limit: preview.contextLimitKib, + }), + "", + tAi("context.consent", {authority: preview.destinationAuthority}), + ].join("\n"), { + title: tAi("context.title"), + kind: "warning", + }); + if (!confirmed) return; + await api.grantAiConsent(); + } + const result = await api.resolveConflictWithAi(repoPath, path, operationId); + if (aiConflictBatchCancelledRef.current) return; + setAiConflictReviewItems([{status: "ready", filePath: result.filePath, proposal: result}]); + } catch (error) { + if (isAiOperationCancelled(error)) return; + const message = localiseAiError(error, t); + showToast(message, "error"); + } finally { + aiConflictOperationIdRef.current = ""; + setAiResolvingPath(null); + } + }, [aiResolvingPath, repoPath, showToast, t, tAi]); + + const handleCancelAiConflict = useCallback(async () => { + const operationId = aiConflictOperationIdRef.current; + if (!operationId) return; + aiConflictBatchCancelledRef.current = true; + aiConflictBatchDecisionRef.current?.(false); + aiConflictBatchDecisionRef.current = null; + await api.cancelAiOperation(operationId).catch(() => {}); + }, []); + + const handleSkipAiConflictBatchFailure = useCallback(() => { + setAiConflictBatchFailure(null); + aiConflictBatchDecisionRef.current?.(true); + aiConflictBatchDecisionRef.current = null; + }, []); + + const handleStopAiConflictBatchFailure = useCallback(() => { + setAiConflictBatchFailure(null); + aiConflictBatchCancelledRef.current = true; + aiConflictBatchDecisionRef.current?.(false); + aiConflictBatchDecisionRef.current = null; + }, []); + + const handleConflictResolveAllWithAi = useCallback(async (paths: string[]) => { + if (!repoPath || paths.length === 0 || aiResolvingPath || aiConflictOperationIdRef.current) return; + const batchId = `conflict-batch-${Date.now()}-${Math.random().toString(16).slice(2)}`; + aiConflictBatchCancelledRef.current = false; + setAiConflictBatchFailure(null); + aiConflictOperationIdRef.current = batchId; + setAiConflictBatchProgress({current: 1, total: paths.length, preparing: true}); + setAiResolvingPath(paths[0]); + + try { + const configuration = await api.getAiConfiguration(); + const previews = new Map(); + const failures = new Map(); + for (const path of paths) { + if (aiConflictBatchCancelledRef.current) return; + try { + previews.set(path, await api.getAiConflictContextPreview(repoPath, path)); + } catch (error) { + const message = localiseAiError(error, t); + failures.set(path, message); + setAiConflictBatchFailure({filePath: path, message}); + const continueBatch = await new Promise(resolve => { + aiConflictBatchDecisionRef.current = resolve; + }); + if (!continueBatch) break; + } + } + const preparedPaths = paths.filter(path => previews.has(path)); + if (preparedPaths.length === 0) { + setAiConflictReviewItems(paths.map(path => ({ + status: "failed", + filePath: path, + message: failures.get(path) ?? t("aiErrors.unknown"), + }))); + return; + } + const firstPreview = previews.get(preparedPaths[0])!; + const totalContextSizeKib = preparedPaths.reduce( + (total, path) => total + (previews.get(path)?.contextSizeKib ?? 0), + 0, + ); + const warning = [ + tAi("conflict.batchRequestWarning", { + count: preparedPaths.length, + provider: firstPreview.provider, + authority: firstPreview.destinationAuthority, + }), + tAi("conflict.batchContext", { + count: preparedPaths.length, + size: totalContextSizeKib, + limit: firstPreview.contextLimitKib, + }), + failures.size > 0 ? tAi("conflict.batchExcluded", {count: failures.size}) : "", + failures.size > 0 ? tAi("conflict.batchExcludedDetails", { + files: [...failures].map(([path, message]) => `${path}: ${message}`).join("\n"), + }) : "", + tAi("conflict.batchSequential"), + configuration.consentRequired + ? tAi("context.consent", {authority: firstPreview.destinationAuthority}) + : "", + ].filter(Boolean).join("\n\n"); + const confirmed = await ask(warning, { + title: tAi("conflict.batchTitle"), + kind: "warning", + okLabel: tAi("actions.generate"), + cancelLabel: tAi("actions.cancel"), + }); + if (!confirmed || aiConflictBatchCancelledRef.current) return; + if (configuration.consentRequired) await api.grantAiConsent(); + + setAiConflictReviewItems([]); + const proposals = new Map(); + for (const [index, path] of preparedPaths.entries()) { + if (aiConflictBatchCancelledRef.current) break; + const operationId = `${batchId}-${index + 1}`; + aiConflictOperationIdRef.current = operationId; + setAiConflictBatchProgress({current: index + 1, total: preparedPaths.length, preparing: false}); + setAiResolvingPath(path); + try { + const proposal = await api.resolveConflictWithAi(repoPath, path, operationId); + if (aiConflictBatchCancelledRef.current) break; + proposals.set(path, proposal); + } catch (error) { + if (isAiOperationCancelled(error) || aiConflictBatchCancelledRef.current) { + aiConflictBatchCancelledRef.current = true; + break; + } + const message = localiseAiError(error, t); + failures.set(path, message); + setAiConflictBatchFailure({filePath: path, message}); + const continueBatch = await new Promise(resolve => { + aiConflictBatchDecisionRef.current = resolve; + }); + if (!continueBatch) break; + } + } + + const reviewItems = paths.flatMap((path): AiConflictReviewItem[] => { + const proposal = proposals.get(path); + if (proposal) return [{status: "ready", filePath: path, proposal}]; + const message = failures.get(path); + return message ? [{status: "failed", filePath: path, message}] : []; + }); + if (reviewItems.length > 0) setAiConflictReviewItems(reviewItems); + if (aiConflictBatchCancelledRef.current) { + showToast(tAi("conflict.batchCancelled", { + completed: proposals.size, + total: preparedPaths.length, + }), "info"); + } else if (failures.size > 0) { + const [firstFailedFile, firstFailure] = failures.entries().next().value!; + showToast(tAi("conflict.batchFailed", { + count: failures.size, + completed: proposals.size, + failed: failures.size, + total: paths.length, + file: firstFailedFile, + message: firstFailure, + }), "error"); + } + } catch (error) { + if (!isAiOperationCancelled(error) && !aiConflictBatchCancelledRef.current) { + showToast(localiseAiError(error, t), "error"); + } + } finally { + aiConflictBatchDecisionRef.current = null; + setAiConflictBatchFailure(null); + aiConflictOperationIdRef.current = ""; + setAiResolvingPath(null); + setAiConflictBatchProgress(null); + } + }, [aiResolvingPath, repoPath, showToast, t, tAi]); + + const handleApplyAiConflictProposal = useCallback(async (proposalId: string, regionIds: string[]) => { + if (aiConflictOperation) throw {code: "operationInProgress"} satisfies AiError; + setAiConflictOperation("apply"); + try { + const result = await api.applyAiConflictProposal(proposalId, regionIds); + showToast(t(result.markedResolved ? "toast.aiConflictFileResolved" : "toast.aiConflictRegionsApplied", { + file: getFileName(result.filePath), + count: result.resolvedRegions, + }), "success"); + await refreshStatus(); + return result; + } catch (error) { + showToast(localiseAiError(error, t), "error"); + throw error; + } finally { + setAiConflictOperation(null); + } + }, [aiConflictOperation, refreshStatus, showToast, t]); + + const handleRegenerateAiConflictProposal = useCallback(async (proposalId: string, regionIds?: string[]) => { + if (!repoPath || aiConflictOperation) return; + const reviewItem = aiConflictReviewItems.find(item => ( + item.status === "ready" && item.proposal.proposalId === proposalId + )); + if (!reviewItem || reviewItem.status !== "ready") return; + const proposal = reviewItem.proposal; + setAiConflictOperation("regenerate"); + try { + if (!regionIds?.length) { + const regenerated = await api.resolveConflictWithAi(repoPath, proposal.filePath); + setAiConflictReviewItems(current => current.map(item => ( + item.status === "ready" && item.proposal.proposalId === proposalId + ? {status: "ready", filePath: regenerated.filePath, proposal: regenerated} + : item + ))); + return; + } + const refreshed = await api.regenerateAiConflictRegions(proposalId, regionIds); + const replacements = new Map(refreshed.regions.map(region => [region.id, region])); + setAiConflictReviewItems(current => current.map(item => ( + item.status === "ready" && item.proposal.proposalId === refreshed.proposalId + ? { + ...item, + proposal: { + ...item.proposal, + usage: refreshed.usage, + requestId: refreshed.requestId, + generationId: refreshed.generationId, + routedProvider: refreshed.routedProvider, + routedModel: refreshed.routedModel, + regions: item.proposal.regions.map(region => replacements.get(region.id) ?? region), + }, + } + : item + ))); + } catch (error) { + showToast(localiseAiError(error, t), "error"); + throw error; + } finally { + setAiConflictOperation(null); + } + }, [aiConflictOperation, aiConflictReviewItems, repoPath, showToast, t]); + + const handleRetryAiConflictFile = useCallback(async (filePath: string) => { + if (!repoPath || aiConflictOperation) return; + setAiConflictOperation("regenerate"); + try { + const proposal = await api.resolveConflictWithAi(repoPath, filePath); + setAiConflictReviewItems(current => current.map(item => ( + item.filePath === filePath + ? {status: "ready", filePath: proposal.filePath, proposal} + : item + ))); + } catch (error) { + const message = localiseAiError(error, t); + setAiConflictReviewItems(current => current.map(item => ( + item.filePath === filePath ? {status: "failed", filePath, message} : item + ))); + showToast(message, "error"); + throw error; + } finally { + setAiConflictOperation(null); + } + }, [aiConflictOperation, repoPath, showToast, t]); + + const handleUndoAiConflictProposal = useCallback(async (proposalId: string) => { + if (aiConflictOperation) return; + setAiConflictOperation("undo"); + try { + await api.undoAiConflictProposal(proposalId); + await refreshStatus(); + setAiConflictReviewItems(current => current.filter(item => ( + item.status !== "ready" || item.proposal.proposalId !== proposalId + ))); + } catch (error) { + showToast(localiseAiError(error, t), "error"); + throw error; + } finally { + setAiConflictOperation(null); + } + }, [aiConflictOperation, refreshStatus, showToast, t]); + + const handleBatchUndoAiConflictProposal = useCallback(async (proposalIds: string[]) => { + if (aiConflictOperation) return; + setAiConflictOperation("undo"); + try { + const result = await api.undoAiConflictBatch(proposalIds); + await refreshStatus(); + const failedIds = new Set(result.failed.map(failure => failure.proposalId)); + const undoneIds = new Set(proposalIds.filter(id => !failedIds.has(id))); + setAiConflictReviewItems(current => current.filter(item => ( + item.status !== "ready" || !undoneIds.has(item.proposal.proposalId) + ))); + if (result.failed.length > 0) { + showToast( + tAi("conflict.batchUndoFailed", {count: result.failed.length}) as string, + "error" + ); + } + } catch (error) { + showToast(localiseAiError(error, t), "error"); + } finally { + setAiConflictOperation(null); + } + }, [aiConflictOperation, refreshStatus, showToast, t, tAi]); + const handleOpenMergeTool = useCallback(async (path: string) => { if (!repoPath) return; try { @@ -2519,6 +2930,9 @@ export function ProjectView({ selectedPatchExportEnabled={selectedPatchFiles.length > 0} remoteOp={remoteOp} identityOpen={identityOpen} + aiEnabled={aiEnabled} + aiConfigured={aiConfigured} + onAiWriting={() => setShowAiWriting(true)} />
@@ -2693,6 +3107,9 @@ export function ProjectView({ onRevertAbort={handleRevertAbort} onConflictAcceptTheirs={handleConflictAcceptTheirs} onConflictAcceptOurs={handleConflictAcceptOurs} + onConflictResolveWithAi={handleConflictResolveWithAi} + onConflictResolveAllWithAi={handleConflictResolveAllWithAi} + onCancelAiConflict={handleCancelAiConflict} onOpenMergeTool={handleOpenMergeTool} stagingOperation={stagingOperation} operationLock={operationLock} @@ -2701,7 +3118,15 @@ export function ProjectView({ isCherryPickActionRunning={isCherryPickActionRunning} isRevertActionRunning={isRevertActionRunning} lastCommitMessage={lastCommitMessage} - /> + aiEnabled={aiEnabled} + aiConfigured={aiConfigured} + aiResolvingPath={aiResolvingPath} + aiConflictOperationId={aiResolvingPath ? aiConflictOperationIdRef.current : null} + aiConflictBatchProgress={aiConflictBatchProgress} + aiConflictBatchFailure={aiConflictBatchFailure} + onSkipAiConflictBatchFailure={handleSkipAiConflictBatchFailure} + onStopAiConflictBatchFailure={handleStopAiConflictBatchFailure} + />
+ {aiEnabled && aiConflictReviewItems.length > 0 && ( + setAiConflictReviewItems([])} + /> + )} + {aiEnabled && showAiWriting && repoPath && ( + setShowAiWriting(false)} /> + )} ); } diff --git a/src/components/Titlebar.test.tsx b/src/components/Titlebar.test.tsx index 48e254a..e6d7033 100644 --- a/src/components/Titlebar.test.tsx +++ b/src/components/Titlebar.test.tsx @@ -43,6 +43,10 @@ function renderTitlebar( onReset?: (mode: "mixed" | "hard") => void; currentBranch?: string; repoDisplayName?: string | null; + aiEnabled?: boolean; + aiConfigured?: boolean; + onAiWriting?: () => void; + onSettingsClick?: () => void; } = {}, ) { const onImportPatch = patchHandlers.onImportPatch ?? vi.fn(); @@ -62,7 +66,7 @@ function renderTitlebar( searchInputRef={{ current: null }} onSearchChange={vi.fn()} onAboutClick={vi.fn()} - onSettingsClick={vi.fn()} + onSettingsClick={patchHandlers.onSettingsClick ?? vi.fn()} onIdentityClick={vi.fn()} onCloneClick={vi.fn()} onInitRepoClick={vi.fn()} @@ -80,6 +84,9 @@ function renderTitlebar( selectedPatchExportEnabled={patchHandlers.selectedPatchExportEnabled ?? false} remoteOp={null} identityOpen={false} + aiEnabled={patchHandlers.aiEnabled} + aiConfigured={patchHandlers.aiConfigured} + onAiWriting={patchHandlers.onAiWriting} />, ); } @@ -318,6 +325,49 @@ describe("Titlebar", () => { expect(screen.getByText("Discard tracked changes...")).toBeInTheDocument(); }); + it("opens preview-only AI writing tools from the more menu", () => { + const onAiWriting = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { + aiEnabled: true, + aiConfigured: true, + onAiWriting, + }); + + fireEvent.click(screen.getByText("More")); + fireEvent.click(screen.getByText("Writing tools...")); + + expect(onAiWriting).toHaveBeenCalledOnce(); + }); + + it("links the AI writing entry to Settings when AI is unavailable", () => { + const onSettingsClick = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { + aiEnabled: true, + aiConfigured: false, + onAiWriting: vi.fn(), + onSettingsClick, + }); + + fireEvent.click(screen.getByText("More")); + fireEvent.click(screen.getByText("Configure AI...")); + + expect(onSettingsClick).toHaveBeenCalledOnce(); + }); + + it("hides AI writing actions when the AI extension is disabled", () => { + renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { + aiEnabled: false, + aiConfigured: false, + onAiWriting: vi.fn(), + }); + + fireEvent.click(screen.getByText("More")); + + expect(screen.queryByText("AI")).not.toBeInTheDocument(); + expect(screen.queryByText("Configure AI...")).not.toBeInTheDocument(); + expect(screen.queryByText("Writing tools...")).not.toBeInTheDocument(); + }); + it("calls reset with mixed mode from the more menu", () => { const onReset = vi.fn(); renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { onReset }); diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx index a26f0e4..fecf8b1 100644 --- a/src/components/Titlebar.tsx +++ b/src/components/Titlebar.tsx @@ -28,6 +28,9 @@ type TitlebarProps = { onSearchChange: (query: string) => void; onAboutClick: () => void; onSettingsClick: () => void; + aiEnabled?: boolean; + aiConfigured?: boolean; + onAiWriting?: () => void; onIdentityClick: () => void; onCloneClick: () => void; onInitRepoClick: () => void; @@ -56,7 +59,7 @@ export function Titlebar({ onSearchChange, onAboutClick, onSettingsClick, onIdentityClick, onCloneClick, onInitRepoClick, onOpenExistingClick, onRepoSelect, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, onReset, onImportPatch, onExportPatch, selectedPatchExportEnabled, - identityOpen, remoteOp, + identityOpen, remoteOp, aiEnabled = false, aiConfigured = false, onAiWriting, }: TitlebarProps) { const { t } = useTranslation("titlebar"); const [searchFocused, setSearchFocused] = useState(false); @@ -209,6 +212,10 @@ export function Titlebar({ onImportPatch={onImportPatch} onExportPatch={onExportPatch} selectedPatchExportEnabled={selectedPatchExportEnabled} + aiEnabled={aiEnabled} + aiConfigured={aiConfigured} + onAiWriting={onAiWriting} + onConfigureAi={onSettingsClick} />
@@ -305,12 +312,16 @@ function DisclosureRow({ label, value }: { label: string; value: string }) { ); } -function MoreDropdown({ repoPath, onReset, onImportPatch, onExportPatch, selectedPatchExportEnabled }: { +function MoreDropdown({ repoPath, onReset, onImportPatch, onExportPatch, selectedPatchExportEnabled, aiEnabled, aiConfigured, onAiWriting, onConfigureAi }: { repoPath: string | null; onReset: (mode: Extract) => void; onImportPatch: () => void; onExportPatch: (scope: "staged" | "unstaged" | "all" | "selected") => void; selectedPatchExportEnabled: boolean; + aiEnabled: boolean; + aiConfigured: boolean; + onAiWriting?: () => void; + onConfigureAi: () => void; }) { const { t } = useTranslation("titlebar"); const [open, setOpen] = useState(false); @@ -379,6 +390,14 @@ function MoreDropdown({ repoPath, onReset, onImportPatch, onExportPatch, selecte
+ {aiEnabled && onAiWriting && ( + <> +
{t("ai.heading")}
+
run(aiConfigured ? onAiWriting : onConfigureAi)}> + {t(aiConfigured ? "ai.writingTools" : "ai.configure")} +
+ + )}
{t("reset.heading")}
run(() => onReset("mixed"))}> {t("reset.mixed")} diff --git a/src/components/centre/CentrePanel.css b/src/components/centre/CentrePanel.css index fe32aa8..0faa75c 100644 --- a/src/components/centre/CentrePanel.css +++ b/src/components/centre/CentrePanel.css @@ -141,11 +141,34 @@ .staging__operation-copy { display: flex; + flex: 1; flex-direction: column; gap: 2px; min-width: 0; } +.staging__operation-cancel { + flex-shrink: 0; + padding: 5px 9px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + color: var(--text-secondary); + background: var(--bg-elevated); + font: inherit; + font-size: var(--font-size-xs); + cursor: pointer; +} + +.staging__operation-cancel:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.staging__operation-cancel:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} + .staging__operation-title { color: var(--text-primary); font-size: var(--font-size-xs); @@ -515,6 +538,17 @@ min-width: 0; } +.commit-box__amend--disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.commit-box__meta-actions { + display: flex; + align-items: center; + gap: 10px; +} + .commit-box__checkbox { width: 14px; height: 14px; @@ -1178,14 +1212,16 @@ position: absolute; top: 8px; right: 10px; + display: grid; + place-items: center; + width: 24px; + height: 24px; background: none; border: none; color: var(--text-muted); cursor: pointer; - font-size: var(--font-size-xs); - padding: 2px 4px; + padding: 0; border-radius: var(--radius-sm); - line-height: 1; } .sig-popover__close:hover { color: var(--text-primary); } @@ -1376,6 +1412,12 @@ .staging__conflict-row--striped-subtle.staging__conflict-row--selected, .staging__conflict-row--striped-strong.staging__conflict-row--selected { background: var(--selection-bg); } +.staging__conflict-row--ai-resolving, +.staging__conflict-row--ai-resolving:hover { + background: var(--accent-dim); + box-shadow: inset 2px 0 var(--accent); +} + .staging__conflict-badge { flex-shrink: 0; font-size: var(--font-size-xxs); @@ -1451,6 +1493,21 @@ background: rgba(251, 191, 36, 0.22); } +.staging__conflict-btn--ai { + background: var(--accent-dim); + border-color: var(--selection-border); + color: var(--accent); +} + +.staging__conflict-btn--ai:hover:not(:disabled) { + background: var(--selection-bg); +} + +.staging__conflict-btn--ai:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .staging__conflict-btn--resolve { background: var(--green-dim); border-color: var(--diff-add-border); diff --git a/src/components/centre/CentrePanel.test.tsx b/src/components/centre/CentrePanel.test.tsx index 0aa1f8c..184f866 100644 --- a/src/components/centre/CentrePanel.test.tsx +++ b/src/components/centre/CentrePanel.test.tsx @@ -133,6 +133,9 @@ function renderCentrePanel(overrides: Partial { expect(screen.getByRole("status")).toHaveTextContent("This operation is still running."); }); }); + +describe("CentrePanel AI conflict lock", () => { + it("disables merge workflow actions while AI conflict resolution is active", () => { + renderCentrePanel({ + activeTab: "changes", + mergeInProgress: true, + conflictedFiles: [{path: "src/payment.ts", conflictType: "both_modified"}], + aiResolvingPath: "src/payment.ts", + }); + + expect(screen.getByRole("button", {name: "Abort Merge"})).toBeDisabled(); + expect(screen.getByRole("button", {name: "Commit Merge"})).toBeDisabled(); + }); +}); diff --git a/src/components/centre/CentrePanel.tsx b/src/components/centre/CentrePanel.tsx index 356632e..8e66d58 100644 --- a/src/components/centre/CentrePanel.tsx +++ b/src/components/centre/CentrePanel.tsx @@ -118,6 +118,9 @@ type CentrePanelProps = { onRevertAbort: () => void; onConflictAcceptTheirs: (path: string) => void; onConflictAcceptOurs: (path: string) => void; + onConflictResolveWithAi: (path: string) => void; + onConflictResolveAllWithAi: (paths: string[]) => void; + onCancelAiConflict: () => void; onOpenMergeTool: (path: string) => void; stagingOperation: StagingOperation | null; operationLock: LongRunningOperation | null; @@ -126,6 +129,14 @@ type CentrePanelProps = { isCherryPickActionRunning: boolean; isRevertActionRunning: boolean; lastCommitMessage: string; + aiEnabled: boolean; + aiConfigured: boolean; + aiResolvingPath: string | null; + aiConflictOperationId: string | null; + aiConflictBatchProgress: {current: number; total: number; preparing: boolean} | null; + aiConflictBatchFailure?: {filePath: string; message: string} | null; + onSkipAiConflictBatchFailure?: () => void; + onStopAiConflictBatchFailure?: () => void; }; function useDelayedOperationFeedback(operation: LongRunningOperation | null) { @@ -254,6 +265,7 @@ export function CentrePanel(props: CentrePanelProps) { onMergeAbort={props.onMergeAbort} onCommitMerge={handleCommitMerge} isCommitting={props.isCommitting} + interactionLocked={props.aiResolvingPath !== null} /> )} {!props.mergeInProgress && props.rebaseInProgress && ( @@ -264,6 +276,7 @@ export function CentrePanel(props: CentrePanelProps) { onRebaseContinue={props.onRebaseContinue} onRebaseAbort={props.onRebaseAbort} isRunning={props.isRebaseActionRunning} + interactionLocked={props.aiResolvingPath !== null} /> )} {!props.mergeInProgress && !props.rebaseInProgress && props.cherryPickInProgress && ( @@ -274,6 +287,7 @@ export function CentrePanel(props: CentrePanelProps) { onCherryPickContinue={props.onCherryPickContinue} onCherryPickAbort={props.onCherryPickAbort} isRunning={props.isCherryPickActionRunning} + interactionLocked={props.aiResolvingPath !== null} /> )} {!props.mergeInProgress && !props.rebaseInProgress && !props.cherryPickInProgress && props.revertInProgress && ( @@ -283,6 +297,7 @@ export function CentrePanel(props: CentrePanelProps) { onRevertContinue={props.onRevertContinue} onRevertAbort={props.onRevertAbort} isRunning={props.isRevertActionRunning} + interactionLocked={props.aiResolvingPath !== null} /> )}
@@ -351,6 +366,7 @@ export function CentrePanel(props: CentrePanelProps) { mergeMessage={props.mergeMessage} rebaseInProgress={props.rebaseInProgress} cherryPickInProgress={props.cherryPickInProgress} + revertInProgress={props.revertInProgress} selectedFile={props.selectedFile} selectedSubmodulePath={props.selectedSubmodulePath} selectedStaged={props.selectedStagedFiles} @@ -382,13 +398,24 @@ export function CentrePanel(props: CentrePanelProps) { onCommit={props.onCommit} onConflictAcceptTheirs={props.onConflictAcceptTheirs} onConflictAcceptOurs={props.onConflictAcceptOurs} + onConflictResolveWithAi={props.onConflictResolveWithAi} + onConflictResolveAllWithAi={props.onConflictResolveAllWithAi} + onCancelAiConflict={props.onCancelAiConflict} onOpenMergeTool={props.onOpenMergeTool} stagingOperation={props.stagingOperation} inlineOperation={inlineOperationContent} isCommitting={props.isCommitting} lastCommitMessage={props.lastCommitMessage} rowStriping={props.rowStriping} - /> + aiEnabled={props.aiEnabled} + aiConfigured={props.aiConfigured} + aiResolvingPath={props.aiResolvingPath} + aiConflictOperationId={props.aiConflictOperationId} + aiConflictBatchProgress={props.aiConflictBatchProgress} + aiConflictBatchFailure={props.aiConflictBatchFailure ?? null} + onSkipAiConflictBatchFailure={props.onSkipAiConflictBatchFailure ?? (() => {})} + onStopAiConflictBatchFailure={props.onStopAiConflictBatchFailure ?? (() => {})} + />
void; onCherryPickAbort: () => void; isRunning: boolean; + interactionLocked: boolean; }; export function CherryPickBanner({ @@ -18,6 +19,7 @@ export function CherryPickBanner({ onCherryPickContinue, onCherryPickAbort, isRunning, + interactionLocked, }: CherryPickBannerProps) { const { t } = useTranslation("centre"); const hasConflicts = conflictedFiles.length > 0; @@ -42,14 +44,14 @@ export function CherryPickBanner({ diff --git a/src/components/centre/CommitBox.test.tsx b/src/components/centre/CommitBox.test.tsx index 6c560c0..91f0322 100644 --- a/src/components/centre/CommitBox.test.tsx +++ b/src/components/centre/CommitBox.test.tsx @@ -8,12 +8,42 @@ import "../../i18n"; const COMMIT_BOX_RATIO_KEY = "gitmun.commitBoxRatio"; const getCommitMessageRecovery = vi.fn(); +const generateAiCommitMessages = vi.fn(); +const getAiCommitContextPreview = vi.fn(); +const getAiConfiguration = vi.fn(); const repoPath = "C:\\marine-lab\\reports"; vi.mock("../../api/commands", () => ({ getCommitMessageRecovery: (...args: unknown[]) => getCommitMessageRecovery(...args), })); +vi.mock("../../features/ai/commands", () => ({ + cancelAiOperation: vi.fn(async () => {}), + generateAiCommitMessages: (...args: unknown[]) => generateAiCommitMessages(...args), + getAiCommitContextPreview: (...args: unknown[]) => getAiCommitContextPreview(...args), + getAiConfiguration: (...args: unknown[]) => getAiConfiguration(...args), + getAiRepositoryPolicy: vi.fn(async () => ({ + exclusions: [], + includeCommitHistory: null, + conventionalCommits: false, + commitMessageMode: null, + defaultCommitType: "", + defaultCommitScope: "", + defaultLanguage: "", + commitPromptFile: "", + conflictPromptFile: "", + })), + grantAiConsent: vi.fn(async () => {}), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async () => () => {}), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + ask: vi.fn(async () => true), +})); + function commitMessageDraftKey(repoPath: string) { return `gitmun.commitMessageDraft.v1:${encodeURIComponent(repoPath)}`; } @@ -39,6 +69,8 @@ type RenderCommitBoxOptions = { lastCommitMessage?: string; mergeMessage?: string | null; mergeInProgress?: boolean; + aiEnabled?: boolean; + aiConfigured?: boolean; }; function renderCommitBox({ @@ -49,6 +81,8 @@ function renderCommitBox({ lastCommitMessage = "", mergeMessage, mergeInProgress, + aiEnabled = false, + aiConfigured = false, }: RenderCommitBoxOptions = {}) { const onCommit = vi.fn(() => false); const onSelectAction = vi.fn(); @@ -67,6 +101,8 @@ function renderCommitBox({ lastCommitMessage={lastCommitMessage} mergeMessage={mergeMessage} mergeInProgress={mergeInProgress} + aiEnabled={aiEnabled} + aiConfigured={aiConfigured} />
, ); @@ -80,6 +116,21 @@ describe("CommitBox", () => { localStorage.removeItem(COMMIT_BOX_RATIO_KEY); getCommitMessageRecovery.mockClear(); getCommitMessageRecovery.mockReturnValue(new Promise(() => {})); + generateAiCommitMessages.mockReset(); + generateAiCommitMessages.mockResolvedValue({ + candidates: [{message: "Generated subject\n\nGenerated body"}], + }); + getAiConfiguration.mockReset(); + getAiConfiguration.mockResolvedValue({consentRequired: false}); + getAiCommitContextPreview.mockReset(); + getAiCommitContextPreview.mockResolvedValue({ + provider: "OpenAi", + destinationAuthority: "api.openai.com", + files: [], + contextSizeKib: 1, + contextLimitKib: 24, + includesCommitHistory: false, + }); }); afterEach(() => { @@ -87,6 +138,71 @@ describe("CommitBox", () => { vi.unstubAllGlobals(); }); + it("hides the AI action when the AI extension is disabled", () => { + renderCommitBox(); + + expect(screen.queryByText("Generate")).not.toBeInTheDocument(); + expect(screen.queryByText("Configure AI")).not.toBeInTheDocument(); + }); + + it("shows the appropriate AI action when the AI extension is enabled", () => { + const unconfigured = renderCommitBox({aiEnabled: true}); + expect(screen.getByText("Configure AI")).toBeInTheDocument(); + unconfigured.unmount(); + + renderCommitBox({aiEnabled: true, aiConfigured: true}); + expect(screen.getByText("Generate")).toBeInTheDocument(); + }); + + it("reports the measured and configured staged context sizes", async () => { + getAiCommitContextPreview.mockRejectedValue({ + code: "contextTooLarge", + contextSizeKib: 31, + contextLimitKib: 24, + }); + renderCommitBox({aiEnabled: true, aiConfigured: true}); + + fireEvent.click(screen.getByText("Generate")); + + expect(await screen.findByText( + "Outbound context is 31 KiB; the configured limit is 24 KiB.", + )).toBeInTheDocument(); + }); + + it("inserts a quick message and restores the exact previous editor contents", async () => { + renderCommitBox({aiEnabled: true, aiConfigured: true}); + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: {value: "Existing subject"}, + }); + fireEvent.change(screen.getByPlaceholderText("Commit body..."), { + target: {value: "Existing body"}, + }); + + fireEvent.click(screen.getByRole("button", {name: "Generate"})); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Commit subject...")).toHaveValue("Generated subject"); + }); + expect(screen.getByPlaceholderText("Commit body...")).toHaveValue("Generated body"); + + fireEvent.click(screen.getByRole("button", {name: "Undo AI message"})); + + expect(screen.getByPlaceholderText("Commit subject...")).toHaveValue("Existing subject"); + expect(screen.getByPlaceholderText("Commit body...")).toHaveValue("Existing body"); + }); + + it("removes quick-generation undo after the user edits the generated message", async () => { + renderCommitBox({aiEnabled: true, aiConfigured: true}); + fireEvent.click(screen.getByRole("button", {name: "Generate"})); + expect(await screen.findByRole("button", {name: "Undo AI message"})).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("Commit subject..."), { + target: {value: "Edited generated subject"}, + }); + + expect(screen.queryByRole("button", {name: "Undo AI message"})).not.toBeInTheDocument(); + }); + it("shows Commit as the default primary action", () => { renderCommitBox({selectedAction: "commit"}); expect(screen.getByRole("button", { name: "Commit (2)" })).toBeInTheDocument(); @@ -374,6 +490,30 @@ describe("CommitBox", () => { expect(onCommit).toHaveBeenCalledWith("Existing subject\n\nExisting body", true, "commit"); }); + it("passes the amend workflow and existing message to the AI preview", async () => { + renderCommitBox({ + lastCommitMessage: "Existing subject\n\nExisting body", + aiEnabled: true, + aiConfigured: true, + }); + + fireEvent.click(screen.getByText("Amend latest commit")); + fireEvent.click(screen.getByText("Generate")); + + await waitFor(() => { + expect(getAiCommitContextPreview).toHaveBeenCalledWith( + repoPath, + 72, + "Amend", + "Existing subject\n\nExisting body", + ); + expect(generateAiCommitMessages).toHaveBeenCalledWith(expect.objectContaining({ + workflow: "Amend", + existingMessage: "Existing subject\n\nExisting body", + })); + }); + }); + it("prefills merge message without comment lines", () => { renderCommitBox({ mergeInProgress: true, diff --git a/src/components/centre/CommitBox.tsx b/src/components/centre/CommitBox.tsx index d1873b1..5478cf3 100644 --- a/src/components/centre/CommitBox.tsx +++ b/src/components/centre/CommitBox.tsx @@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import type { CommitPrimaryAction } from "../../types"; -import { getCommitMessageRecovery } from "../../api/commands"; +import { getCommitMessageRecovery, openSettingsWindow } from "../../api/commands"; +import {AiCommitControls, type AiCommitWorkflow} from "../../features/ai"; import { clearCommitMessageDraft, loadCommitMessageDraft, @@ -24,6 +25,9 @@ type CommitBoxProps = { mergeInProgress?: boolean; rebaseInProgress?: boolean; cherryPickInProgress?: boolean; + revertInProgress?: boolean; + aiEnabled?: boolean; + aiConfigured?: boolean; }; type CommitBoxDragState = { @@ -117,6 +121,8 @@ export function CommitBox({ mergeInProgress, rebaseInProgress, cherryPickInProgress, + revertInProgress, + aiEnabled = false, aiConfigured = false, }: CommitBoxProps) { const { t } = useTranslation("centre"); const [subject, setSubject] = useState(""); @@ -124,6 +130,8 @@ export function CommitBox({ const [amend, setAmend] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [recoveryMessage, setRecoveryMessage] = useState(null); + const [aiBusy, setAiBusy] = useState(false); + const [aiUndoMessage, setAiUndoMessage] = useState<{subject: string; body: string} | null>(null); const [draftReady, setDraftReady] = useState(false); const [commitBoxHeight, setCommitBoxHeight] = useState(null); const [dragState, setDragState] = useState(null); @@ -132,10 +140,23 @@ export function CommitBox({ const commitBoxHeightRef = useRef(MIN_COMMIT_BOX_HEIGHT); const commitBoxRatioRef = useRef(parseCommitBoxRatio(getCommitBoxStorage()?.getItem(COMMIT_BOX_RATIO_KEY) ?? null)); const activeAction = allowCommitAndPush ? selectedAction : "commit"; + const aiWorkflow: AiCommitWorkflow = amend + ? "Amend" + : mergeInProgress + ? "Merge" + : rebaseInProgress + ? "Rebase" + : cherryPickInProgress + ? "CherryPick" + : revertInProgress + ? "Revert" + : "Normal"; + const currentMessage = body === "" ? subject : `${subject}\n\n${body}`; useEffect(() => { setDraftReady(false); setRecoveryMessage(null); + setAiUndoMessage(null); if (mergeInProgress && mergeMessage) { const cleaned = mergeMessage.split("\n").filter(l => !l.startsWith("#")).join("\n").trim(); @@ -209,6 +230,10 @@ export function CommitBox({ } }, [allowCommitAndPush]); + useEffect(() => { + if (aiBusy) setMenuOpen(false); + }, [aiBusy]); + useEffect(() => { const root = commitBoxRef.current?.parentElement; if (!root) return; @@ -274,10 +299,12 @@ export function CommitBox({ const hasRecommendedLength = commitMessageRecommendedLength > 0; const subjectOverflow = hasRecommendedLength && subjectLength > commitMessageRecommendedLength; const actionDisabled = - stagedCount === 0 || trimmedSubject === "" || isCommitting || rebaseInProgress || cherryPickInProgress; + stagedCount === 0 || trimmedSubject === "" || isCommitting || aiBusy || rebaseInProgress || cherryPickInProgress; const handleAmendToggle = () => { + if (aiBusy) return; const next = !amend; + setAiUndoMessage(null); setAmend(next); if (next && lastCommitMessage) { const nextMessage = splitCommitMessage(lastCommitMessage); @@ -291,11 +318,26 @@ export function CommitBox({ const handleRestoreRecovery = () => { if (!recoveryMessage) return; const nextMessage = splitCommitMessage(recoveryMessage); + setAiUndoMessage(null); setSubject(nextMessage.subject); setBody(nextMessage.body); setRecoveryMessage(null); }; + const handleApplyAiMessage = (message: string) => { + setAiUndoMessage({subject, body}); + const nextMessage = splitCommitMessage(message); + setSubject(nextMessage.subject); + setBody(nextMessage.body); + }; + + const handleUndoAiMessage = () => { + if (!aiUndoMessage) return; + setSubject(aiUndoMessage.subject); + setBody(aiUndoMessage.body); + setAiUndoMessage(null); + }; + const handleCommit = async () => { if (actionDisabled) return; const message = trimmedBody === "" ? trimmedSubject : `${trimmedSubject}\n\n${trimmedBody}`; @@ -304,6 +346,7 @@ export function CommitBox({ setSubject(""); setBody(""); setAmend(false); + setAiUndoMessage(null); setMenuOpen(false); if (repoPath) { clearCommitMessageDraft(repoPath); @@ -352,7 +395,10 @@ export function CommitBox({ 0 ? "commit-box__subject--warn" : ""}`} value={subject} - onChange={e => setSubject(e.target.value)} + onChange={e => { + setAiUndoMessage(null); + setSubject(e.target.value); + }} onKeyDown={handleCommitKeyDown} placeholder={amend ? t("commitBox.amendSubject") : t("commitBox.commitSubject")} spellCheck="true" @@ -362,7 +408,10 @@ export function CommitBox({