diff --git a/.agents/skills/unity-csharp-reference b/.agents/skills/unity-csharp-reference new file mode 120000 index 0000000..12a8529 --- /dev/null +++ b/.agents/skills/unity-csharp-reference @@ -0,0 +1 @@ +../../.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference \ No newline at end of file diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-edit/SKILL.md b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-edit/SKILL.md index e34fc44..b32d858 100644 --- a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-edit/SKILL.md +++ b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-edit/SKILL.md @@ -15,6 +15,7 @@ metadata: - setting siblings: - unity-csharp-navigate + - unity-csharp-reference - unity-editor-tools - unity-cli-usage - unity-gameobject-edit diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-navigate/SKILL.md b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-navigate/SKILL.md index 079121d..15ab792 100644 --- a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-navigate/SKILL.md +++ b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-navigate/SKILL.md @@ -14,6 +14,7 @@ metadata: - symbol siblings: - unity-csharp-edit + - unity-csharp-reference - unity-scene-inspect --- diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/SKILL.md b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/SKILL.md new file mode 100644 index 0000000..51dbfab --- /dev/null +++ b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/SKILL.md @@ -0,0 +1,64 @@ +--- +name: unity-csharp-reference +description: Browse Unity Technologies' official UnityCsReference C# source as a read-only local cache. Use when the user asks about the exact signature, behavior, or internal implementation of a Unity API, when comparing API differences between Unity versions, or when validating LLM-suggested Unity code against the canonical source. Do not use for editing project code (use `unity-csharp-edit`). Do not use for project-local script reading (use `unity-csharp-navigate`). +allowed-tools: Bash(unity-cli:*), Read, Grep, Glob +metadata: + author: akiojin + version: 0.1.0 + category: code + triggers: + - reference + - unity-api + - signature + - unity-source + - version-diff + siblings: + - unity-csharp-navigate + - unity-csharp-edit + - unity-scene-inspect +--- + +# Unity C# Reference + +Browse Unity Technologies' official UnityCsReference C# source as a read-only local cache. This skill is the sibling of `unity-csharp-navigate` (project sources) and `unity-csharp-edit` (writes). Hand off as soon as the request implies project-level reading or a write. + +## Use When + +- The user asks about the exact signature, attributes, or behavior of a Unity API such as `UnityEngine.Animator.Play` or `UnityEditor.AssetDatabase.Refresh`. +- The user wants to read the internal implementation of a Unity type to predict performance characteristics or side effects. +- The user compares API differences between Unity versions (LTS vs Tech release branches). +- The user wants to validate an LLM-suggested Unity script against the canonical Unity source. + +## Do Not Use When + +- The user wants to read project-local scripts; use `unity-csharp-navigate`. +- The user wants to write or refactor C# code; use `unity-csharp-edit`. +- The user wants Unity scene state, packages, or assets; use the matching scene, asset, or editor skill family. + +## Preferred Flow + +1. Run the runtime checklist in [runtime-checklist.md](references/runtime-checklist.md) before the first fetch in a new environment. +2. Populate the cache for the project's Unity version with `unity-cli reference fetch --accept-license` (one-time per version). +3. Use `unity-cli reference grep` for line-level pattern lookups with optional context, or `unity-cli reference search` for filtered file hits. +4. Open the candidate file with `unity-cli reference view --start-line N --max-lines M` to read the relevant span. +5. Confirm the signature or behavior, then hand off to `unity-csharp-navigate` for project sources or `unity-csharp-edit` for writes. + +```bash +unity-cli reference fetch --accept-license +unity-cli reference status --output json +unity-cli reference grep "class Animator " --context 3 +unity-cli reference view Runtime/Export/Animation/Animator.bindings.cs --start-line 100 --max-lines 60 +unity-cli reference clean --keep 1 --dry-run +``` + +## Examples + +- "Show me how Unity implements `Animator.Play` so I can predict whether it allocates." +- "Compare `UnityWebRequest.Get` between Unity 2022.3 and Unity 6 staging." +- "Confirm the exact signature of `EditorApplication.delayCall` before I wire it into the build pipeline." + +## References + +- [runtime-checklist.md](references/runtime-checklist.md): runtime prerequisites and license acceptance before the first fetch. +- [fetch-and-cache.md](references/fetch-and-cache.md): `reference fetch`, `status`, and `clean` command details and branch mapping per Unity version. +- [symbol-lookup-playbook.md](references/symbol-lookup-playbook.md): the canonical reference → navigate → edit workflow when validating LLM-suggested Unity code. diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/fetch-and-cache.md b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/fetch-and-cache.md new file mode 100644 index 0000000..994a6fe --- /dev/null +++ b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/fetch-and-cache.md @@ -0,0 +1,75 @@ +# Fetch and Cache + +Operational guide for the `unity-cli reference` cache commands. + +## Cache Layout + +``` +~/.unity/cache/UnityCsReference/ + / + .git/ + Runtime/ + Editor/ + Modules/ + .unity-cli-meta.json # { version, branch, commit_sha, fetched_at, source_url } +``` + +Override the base path with `UNITY_CLI_CACHE_ROOT` (defaults to `~/.unity/cache`). Each Unity version lives in its own directory and is independent of `~/.unity/tools/` (which stores managed binaries). + +## Branch Mapping + +The CLI maps the active Unity version (read from `ProjectSettings/ProjectVersion.txt`) to a UnityCsReference branch using a static table: + +| Unity major.minor | Branch | +|-------------------|--------------| +| `2020.3` | `2020.3/staging` | +| `2021.3` | `2021.3/staging` | +| `2022.3` | `2022.3/staging` | +| `2023.1` | `2023.1/staging` | +| `2023.2` | `2023.2/staging` | +| `6000.0` | `6000.0/staging` | + +Unmapped versions require `--branch ` so the CLI can fetch without guessing. + +## Commands + +### Fetch + +```bash +# Auto-detect Unity version from the current project +unity-cli reference fetch --accept-license + +# Explicit version + branch +unity-cli reference fetch --version 2023.2.20f1 --branch 2023.2/staging --accept-license + +# Refetch an existing snapshot +unity-cli reference fetch --version 2023.2.20f1 --force --accept-license +``` + +Fetch uses `git clone --depth 1 --single-branch --branch ` so the on-disk footprint stays close to the source tree size. The `GITHUB_TOKEN` / `GH_TOKEN` environment variable is injected as `http.extraHeader=Authorization: token ...` to relieve rate limits when set. + +### Status + +```bash +unity-cli reference status --output json +``` + +Returns `{ ok: true, versions: [ { version, branch, fetchedAt, sizeBytes, path } ... ] }`. Use this to confirm a fetch completed and to monitor disk usage. + +### Clean + +```bash +# Show what would be removed (LRU by mtime) +unity-cli reference clean --keep 1 --dry-run + +# Actually remove old snapshots +unity-cli reference clean --keep 1 +``` + +`clean` retains the newest `--keep` snapshots (mtime descending) and removes the rest. The CLI prints the removed paths so they can be re-cached on demand. + +## Troubleshooting + +- `git binary not found`: install `git` or point `PATH` at a working installation. +- `Unity Companion License`: pass `--accept-license` or export `UNITY_CLI_ACCEPT_LICENSE=1`. +- `Unity version ... not in the static branch map`: pass `--branch ` explicitly; consider opening an issue to extend the static table. diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/runtime-checklist.md b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/runtime-checklist.md new file mode 100644 index 0000000..c542778 --- /dev/null +++ b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/runtime-checklist.md @@ -0,0 +1,34 @@ +# Runtime Checklist + +Run these checks before the first `reference fetch` in a new environment or worktree. + +## Prerequisites + +- `git` is on `PATH`. Verify with `git --version`. +- The target project has `ProjectSettings/ProjectVersion.txt` so Unity version detection can run automatically. Without a project, pass `--version ` and `--branch ` explicitly. +- Network connectivity to `github.com` is available. For rate-limited environments, export `GITHUB_TOKEN` or `GH_TOKEN` before fetching. +- Disk budget: budget roughly 400-600 MB per cached Unity version. Use `unity-cli reference status --output json` to inspect current usage. + +## License Acceptance + +UnityCsReference is distributed under the Unity Companion License. The CLI refuses to fetch without explicit consent: + +- Pass `--accept-license` to `reference fetch`, or +- Export `UNITY_CLI_ACCEPT_LICENSE=1` for non-interactive sessions. + +Local caches are for personal reference only. Do not redistribute the cached source. + +## First-Run Verification + +```bash +unity-cli reference fetch --accept-license +unity-cli reference status --output json +``` + +Expected: `status` reports a single cached entry with `version`, `branch`, `fetchedAt`, `sizeBytes`, and `path`. Re-running `fetch` without `--force` skips with a notice. + +## Recovery + +- Fetch failed half-way: rerun with `--force` to wipe the version directory and clone again. +- Cache directory unreadable: remove `~/.unity/cache/UnityCsReference/` and re-fetch. +- Disk pressure: `unity-cli reference clean --keep 1` keeps the newest snapshot and removes the rest. diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/symbol-lookup-playbook.md b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/symbol-lookup-playbook.md new file mode 100644 index 0000000..dceeffc --- /dev/null +++ b/.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/references/symbol-lookup-playbook.md @@ -0,0 +1,56 @@ +# Symbol Lookup Playbook + +The canonical reference → navigate → edit workflow when validating LLM-suggested Unity code. + +## When to Reach Here + +- The user (or an LLM) proposed a Unity API call and you need to confirm the exact signature or behavior. +- An LLM suggested code that compiles but you suspect it relies on a different overload, attribute, or version-specific behavior. +- The user is comparing two Unity versions and needs API-level evidence. + +## Workflow + +### 1. Locate the symbol in the official source + +```bash +unity-cli reference grep "class Animator " --context 0 +unity-cli reference grep "void Play\(" --file-glob "Animator*.cs" --context 2 +unity-cli reference search "AssetDatabase.Refresh" --max-results 10 +``` + +`grep` emits `{ path, line, text, context_before, context_after }` so the surrounding context survives a follow-up `view`. + +### 2. Read the relevant span + +```bash +unity-cli reference view Runtime/Export/Animation/Animator.bindings.cs --start-line 100 --max-lines 60 +``` + +Use the line number from `grep` to anchor a tight `--start-line` / `--max-lines` window. Avoid dumping entire files: the LLM context budget is finite and the surrounding code is rarely needed. + +### 3. Cross-check against the project + +Switch to [unity-csharp-navigate](../../unity-csharp-navigate/SKILL.md) for project sources: + +```bash +unity-cli raw find_refs --json '{"name":"Animator.Play","scope":"assets"}' +``` + +This catches callers that depend on the exact overload you just confirmed. + +### 4. Apply the change + +Hand the validated signature to [unity-csharp-edit](../../unity-csharp-edit/SKILL.md) for the write step: + +```bash +unity-cli raw apply_csharp_edits --json '{ ... }' +``` + +Keep the original `reference view` excerpt in the conversation context so the editor can quote the canonical implementation when justifying the change. + +## Anti-Patterns + +- Skipping the reference lookup and trusting the LLM-suggested API name verbatim. +- Reading the entire `Runtime/Export` tree instead of using `grep --file-glob` to narrow the scope. +- Caching multiple versions you do not actively compare; rely on `unity-cli reference clean --keep 1` to control disk usage. +- Mixing project sources and reference sources in the same write tool call; the reference cache is read-only on purpose. diff --git a/.claude-plugin/plugins/unity-cli/skills/unity-scene-inspect/SKILL.md b/.claude-plugin/plugins/unity-cli/skills/unity-scene-inspect/SKILL.md index 39825ef..f7be3af 100644 --- a/.claude-plugin/plugins/unity-cli/skills/unity-scene-inspect/SKILL.md +++ b/.claude-plugin/plugins/unity-cli/skills/unity-scene-inspect/SKILL.md @@ -16,6 +16,7 @@ metadata: - unity-scene-create - unity-gameobject-edit - unity-csharp-navigate + - unity-csharp-reference --- # Scene Inspect diff --git a/.claude/skills/unity-csharp-reference b/.claude/skills/unity-csharp-reference new file mode 120000 index 0000000..12a8529 --- /dev/null +++ b/.claude/skills/unity-csharp-reference @@ -0,0 +1 @@ +../../.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference \ No newline at end of file diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8ec3a14..bf611e6 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -143,3 +143,34 @@ MIT License 4. **ドキュメント**: アプリケーションのユーザーマニュアルやオンラインドキュメントに表記します。 `unity-cli-bridge` は Editor 専用パッケージ(Unity Editor 内でのみ動作し、ビルドには含まれない)であるため、出荷ビルドでの帰属表示は厳密には不要な場合があります。ただし、ソースコードを再配布または改変する場合は帰属表示が必要です。 + +--- + +## Third-Party: UnityCsReference (Unity Companion License) + +`unity-cli reference fetch` clones the official Unity C# reference source from +[`Unity-Technologies/UnityCsReference`](https://github.com/Unity-Technologies/UnityCsReference) +into a local read-only cache (`~/.unity/cache/UnityCsReference//`). +The cached source is © Unity Technologies and is distributed under the +[Unity Companion License](https://unity.com/legal/licenses/unity-companion-license). + +- Purpose: local read-only reference for LLM-assisted Unity C# implementation. +- Acceptance: required via the `--accept-license` flag or the + `UNITY_CLI_ACCEPT_LICENSE=1` environment variable before any fetch runs. +- Restriction: do not redistribute the cached source. The cache is for + personal local use only. + +### Unity Companion License(日本語要約) + +`unity-cli reference fetch` は Unity 公式の C# リファレンス +(`Unity-Technologies/UnityCsReference` リポジトリ)を +`~/.unity/cache/UnityCsReference//` 配下にローカル読み取り専用で +キャッシュします。キャッシュされたソースは Unity Technologies の著作物で、 +[Unity Companion License](https://unity.com/legal/licenses/unity-companion-license) +に従って利用してください。 + +- 用途: LLM 支援による Unity C# 実装の参照用ローカルキャッシュ。 +- 同意: `--accept-license` フラグ、または `UNITY_CLI_ACCEPT_LICENSE=1` + 環境変数で同意を明示してから fetch を実行してください。 +- 制限: キャッシュ済みソースの再配布は禁止です。利用は個人のローカル参照に + 限定してください。 diff --git a/README.ja.md b/README.ja.md index eb98181..df4e641 100644 --- a/README.ja.md +++ b/README.ja.md @@ -9,6 +9,7 @@ - Claude Code から、用途別スキルと typed コマンドで Unity を操作できます。 - シーン、アセット、コード、テスト、UI、Editor を含む `101` 個の Unity Tool API を利用できます。 +- Unity 公式 C# リファレンス([UnityCsReference](https://github.com/Unity-Technologies/UnityCsReference))を `unity-cli reference fetch` でローカルキャッシュし、`unity-csharp-reference` スキルから読み取り専用で参照できます。 - 単一バイナリで高速起動、低オーバーヘッドです。 ## 仕組み diff --git a/docs/tools.md b/docs/tools.md index 8b75e8f..cc0de32 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -249,6 +249,38 @@ unity-cli tool schema input_keyboard --output json unity-cli tool schema package_manager --output json ``` +## Reference Cache (6 tools) + +The `unity-cli reference *` family provides a local read-only mirror of the +official [UnityCsReference](https://github.com/Unity-Technologies/UnityCsReference) +source. Use it when you need the canonical signature or internal +implementation of a Unity API. The cache lives under +`~/.unity/cache/UnityCsReference//` (override with +`UNITY_CLI_CACHE_ROOT`). License acceptance is mandatory before the first +fetch via `--accept-license` or `UNITY_CLI_ACCEPT_LICENSE=1`. + +| Tool | Description | +| --- | --- | +| `reference_fetch` | Shallow-clone UnityCsReference for the active Unity version into the local cache. | +| `reference_status` | List cached UnityCsReference versions, branches, fetched-at, and disk usage. | +| `reference_search` | Search the cached reference source for a pattern with optional path and result limits. | +| `reference_grep` | Grep the cached reference source line-by-line with optional file glob and context lines. | +| `reference_view` | Display a slice of a file in the cached reference source by line range. | +| `reference_clean` | Remove old UnityCsReference snapshots, keeping the newest entries. | + +Typed CLI equivalents: + +```bash +unity-cli reference fetch --accept-license +unity-cli reference status --output json +unity-cli reference grep "class Animator " --context 3 +unity-cli reference view Runtime/Export/Animation/Animator.bindings.cs --start-line 100 --max-lines 60 +unity-cli reference clean --keep 1 --dry-run +``` + +See `.claude-plugin/plugins/unity-cli/skills/unity-csharp-reference/` for the +companion skill and the `reference -> navigate -> edit` workflow. + ## Regenerate This Catalog ```bash diff --git a/src/app/runner.rs b/src/app/runner.rs index 2e5a658..4c51047 100644 --- a/src/app/runner.rs +++ b/src/app/runner.rs @@ -7,8 +7,8 @@ use tracing_subscriber::EnvFilter; use crate::cli::{ Cli, CliCommand, Command, InstancesCommand, LspCommand, LspdCommand, OutputFormat, RawArgs, - SceneCommand, SkillFormat, SkillSeverity, SkillsCommand, SystemCommand, ToolCommand, - UnitydCommand, + ReferenceCommand, SceneCommand, SkillFormat, SkillSeverity, SkillsCommand, SystemCommand, + ToolCommand, UnitydCommand, }; use crate::config::RuntimeConfig; use crate::core::command_stats::{self, CliCommandTiming}; @@ -218,6 +218,11 @@ pub async fn run_with_cli(cli: Cli) -> Result<()> { run_skills_lint(root.as_deref(), *format, *severity)?; } }, + Command::Reference { command } => { + let (tool, params) = build_reference_call(command); + let value = execute_tool(&cli, tool, params).await?; + print_value(&value, cli.output)?; + } Command::Batch { json, stdin } => { let value = execute_batch(&cli, json.as_deref(), *stdin).await?; print_value(&value, cli.output)?; @@ -234,6 +239,110 @@ pub async fn run_with_cli(cli: Cli) -> Result<()> { Ok(()) } +fn build_reference_call(command: &ReferenceCommand) -> (&'static str, Value) { + let mut params = serde_json::Map::new(); + let tool: &'static str = match command { + ReferenceCommand::Fetch { + version, + branch, + force, + accept_license, + } => { + if let Some(v) = version { + params.insert("version".to_string(), Value::String(v.clone())); + } + if let Some(b) = branch { + params.insert("branch".to_string(), Value::String(b.clone())); + } + params.insert("force".to_string(), Value::Bool(*force)); + params.insert("acceptLicense".to_string(), Value::Bool(*accept_license)); + "reference_fetch" + } + ReferenceCommand::Status { version } => { + if let Some(v) = version { + params.insert("version".to_string(), Value::String(v.clone())); + } + "reference_status" + } + ReferenceCommand::Search { + pattern, + version, + path, + max_results, + regex, + } => { + params.insert("pattern".to_string(), Value::String(pattern.clone())); + if let Some(v) = version { + params.insert("version".to_string(), Value::String(v.clone())); + } + if let Some(p) = path { + params.insert("path".to_string(), Value::String(p.clone())); + } + if let Some(n) = max_results { + params.insert("maxResults".to_string(), Value::Number((*n).into())); + } + params.insert("regex".to_string(), Value::Bool(*regex)); + "reference_search" + } + ReferenceCommand::Grep { + pattern, + version, + file_glob, + context, + } => { + params.insert("pattern".to_string(), Value::String(pattern.clone())); + if let Some(v) = version { + params.insert("version".to_string(), Value::String(v.clone())); + } + if let Some(g) = file_glob { + params.insert("fileGlob".to_string(), Value::String(g.clone())); + } + params.insert( + "context".to_string(), + Value::Number((u64::from(*context)).into()), + ); + "reference_grep" + } + ReferenceCommand::View { + path, + version, + start_line, + max_lines, + } => { + params.insert("path".to_string(), Value::String(path.clone())); + if let Some(v) = version { + params.insert("version".to_string(), Value::String(v.clone())); + } + if let Some(n) = start_line { + params.insert( + "startLine".to_string(), + Value::Number((u64::from(*n)).into()), + ); + } + if let Some(n) = max_lines { + params.insert( + "maxLines".to_string(), + Value::Number((u64::from(*n)).into()), + ); + } + "reference_view" + } + ReferenceCommand::Clean { + keep, + version, + dry_run, + } => { + params.insert("keep".to_string(), Value::Number((*keep).into())); + if let Some(v) = version { + params.insert("version".to_string(), Value::String(v.clone())); + } + params.insert("dryRun".to_string(), Value::Bool(*dry_run)); + "reference_clean" + } + }; + (tool, Value::Object(params)) +} + async fn execute_raw(cli: &Cli, args: &RawArgs) -> Result { let params = load_params(args)?; execute_tool(cli, &args.tool_name, params).await @@ -450,15 +559,11 @@ fn validate_value_against_schema(value: &Value, schema: &Value, path: &str) -> R match expected_type { "object" => validate_object(value, schema, path)?, "array" => validate_array(value, schema, path)?, - "string" => { - if !value.is_string() { - return Err(anyhow!("{path} must be a string")); - } + "string" if !value.is_string() => { + return Err(anyhow!("{path} must be a string")); } - "boolean" => { - if !value.is_boolean() { - return Err(anyhow!("{path} must be a boolean")); - } + "boolean" if !value.is_boolean() => { + return Err(anyhow!("{path} must be a boolean")); } "integer" => { let is_integer = value.as_i64().is_some() @@ -468,10 +573,8 @@ fn validate_value_against_schema(value: &Value, schema: &Value, path: &str) -> R return Err(anyhow!("{path} must be an integer")); } } - "number" => { - if value.as_f64().is_none() { - return Err(anyhow!("{path} must be a number")); - } + "number" if value.as_f64().is_none() => { + return Err(anyhow!("{path} must be a number")); } _ => {} } @@ -809,12 +912,12 @@ fn init_tracing(verbose: u8) -> Result<()> { #[cfg(test)] mod tests { use super::{ - execute_tool, init_tracing, load_params, parse_external_tool_command, parse_json_object, - parse_ports, print_value, run_with_cli, validate_tool_params, + build_reference_call, execute_tool, init_tracing, load_params, parse_external_tool_command, + parse_json_object, parse_ports, print_value, run_with_cli, validate_tool_params, }; use crate::cli::{ - Cli, Command, InstancesCommand, LspdCommand, OutputFormat, RawArgs, SceneCommand, - SystemCommand, ToolCommand, UnitydCommand, + Cli, Command, InstancesCommand, LspdCommand, OutputFormat, RawArgs, ReferenceCommand, + SceneCommand, SystemCommand, ToolCommand, UnitydCommand, }; use serde_json::json; use tempfile::tempdir; @@ -1879,6 +1982,179 @@ mod tests { assert!(format!("{err:#}").contains("Provide --json or --stdin")); } + #[test] + fn build_reference_call_status_includes_explicit_version() { + let cmd = ReferenceCommand::Status { + version: Some("2023.2.20f1".to_string()), + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_status"); + assert_eq!(params["version"], "2023.2.20f1"); + } + + #[test] + fn build_reference_call_status_omits_optional_version() { + let cmd = ReferenceCommand::Status { version: None }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_status"); + assert!(params.get("version").is_none()); + } + + #[test] + fn build_reference_call_fetch_carries_all_flags() { + let cmd = ReferenceCommand::Fetch { + version: Some("2023.2.20f1".to_string()), + branch: Some("2023.2/staging".to_string()), + force: true, + accept_license: true, + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_fetch"); + assert_eq!(params["version"], "2023.2.20f1"); + assert_eq!(params["branch"], "2023.2/staging"); + assert_eq!(params["force"], true); + assert_eq!(params["acceptLicense"], true); + } + + #[test] + fn build_reference_call_fetch_defaults_without_overrides() { + let cmd = ReferenceCommand::Fetch { + version: None, + branch: None, + force: false, + accept_license: false, + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_fetch"); + assert!(params.get("version").is_none()); + assert!(params.get("branch").is_none()); + assert_eq!(params["force"], false); + assert_eq!(params["acceptLicense"], false); + } + + #[test] + fn build_reference_call_search_with_all_options() { + let cmd = ReferenceCommand::Search { + pattern: "Animator".to_string(), + version: Some("2023.2.20f1".to_string()), + path: Some("Runtime/Animator*.cs".to_string()), + max_results: Some(5), + regex: true, + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_search"); + assert_eq!(params["pattern"], "Animator"); + assert_eq!(params["version"], "2023.2.20f1"); + assert_eq!(params["path"], "Runtime/Animator*.cs"); + assert_eq!(params["maxResults"], 5); + assert_eq!(params["regex"], true); + } + + #[test] + fn build_reference_call_search_minimal_pattern() { + let cmd = ReferenceCommand::Search { + pattern: "Foo".to_string(), + version: None, + path: None, + max_results: None, + regex: false, + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_search"); + assert_eq!(params["pattern"], "Foo"); + assert_eq!(params["regex"], false); + assert!(params.get("version").is_none()); + assert!(params.get("maxResults").is_none()); + } + + #[test] + fn build_reference_call_grep_with_file_glob_and_context() { + let cmd = ReferenceCommand::Grep { + pattern: "class".to_string(), + version: Some("2023.2.20f1".to_string()), + file_glob: Some("*.cs".to_string()), + context: 3, + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_grep"); + assert_eq!(params["pattern"], "class"); + assert_eq!(params["fileGlob"], "*.cs"); + assert_eq!(params["context"], 3); + assert_eq!(params["version"], "2023.2.20f1"); + } + + #[test] + fn build_reference_call_grep_defaults_context_to_zero() { + let cmd = ReferenceCommand::Grep { + pattern: "class".to_string(), + version: None, + file_glob: None, + context: 0, + }; + let (_tool, params) = build_reference_call(&cmd); + assert_eq!(params["context"], 0); + assert!(params.get("fileGlob").is_none()); + } + + #[test] + fn build_reference_call_view_with_range() { + let cmd = ReferenceCommand::View { + path: "Runtime/Export/Animation/Animator.bindings.cs".to_string(), + version: Some("2023.2.20f1".to_string()), + start_line: Some(100), + max_lines: Some(60), + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_view"); + assert_eq!( + params["path"], + "Runtime/Export/Animation/Animator.bindings.cs" + ); + assert_eq!(params["startLine"], 100); + assert_eq!(params["maxLines"], 60); + } + + #[test] + fn build_reference_call_view_omits_optional_lines() { + let cmd = ReferenceCommand::View { + path: "Editor/Foo.cs".to_string(), + version: None, + start_line: None, + max_lines: None, + }; + let (_tool, params) = build_reference_call(&cmd); + assert_eq!(params["path"], "Editor/Foo.cs"); + assert!(params.get("startLine").is_none()); + assert!(params.get("maxLines").is_none()); + } + + #[test] + fn build_reference_call_clean_with_version() { + let cmd = ReferenceCommand::Clean { + keep: 2, + version: Some("legacy".to_string()), + dry_run: true, + }; + let (tool, params) = build_reference_call(&cmd); + assert_eq!(tool, "reference_clean"); + assert_eq!(params["keep"], 2); + assert_eq!(params["version"], "legacy"); + assert_eq!(params["dryRun"], true); + } + + #[test] + fn build_reference_call_clean_defaults() { + let cmd = ReferenceCommand::Clean { + keep: 1, + version: None, + dry_run: false, + }; + let (_tool, params) = build_reference_call(&cmd); + assert_eq!(params["keep"], 1); + assert_eq!(params["dryRun"], false); + assert!(params.get("version").is_none()); + } + #[allow(clippy::await_holding_lock)] #[tokio::test(flavor = "current_thread")] async fn run_with_cli_set_active_text_output_when_reachable() { diff --git a/src/cli.rs b/src/cli.rs index 06220d7..cdba1a2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -78,6 +78,10 @@ pub enum Command { #[command(subcommand)] command: SkillsCommand, }, + Reference { + #[command(subcommand)] + command: ReferenceCommand, + }, Batch { #[arg(long, value_name = "JSON")] json: Option, @@ -216,3 +220,64 @@ pub enum SkillsCommand { severity: SkillSeverity, }, } + +#[derive(Debug, Subcommand)] +pub enum ReferenceCommand { + /// Fetch UnityCsReference for the active Unity version into the local cache. + Fetch { + #[arg(long)] + version: Option, + #[arg(long)] + branch: Option, + #[arg(long, default_value_t = false)] + force: bool, + #[arg(long, default_value_t = false)] + accept_license: bool, + }, + /// Show cached UnityCsReference versions and disk usage. + Status { + #[arg(long)] + version: Option, + }, + /// Search the cached reference source for a pattern (file-level hits). + Search { + pattern: String, + #[arg(long)] + version: Option, + #[arg(long)] + path: Option, + #[arg(long)] + max_results: Option, + #[arg(long, default_value_t = false)] + regex: bool, + }, + /// Grep the cached reference source line-by-line with optional context. + Grep { + pattern: String, + #[arg(long)] + version: Option, + #[arg(long)] + file_glob: Option, + #[arg(long, default_value_t = 0)] + context: u32, + }, + /// View a file from the cached reference source with an optional line range. + View { + path: String, + #[arg(long)] + version: Option, + #[arg(long)] + start_line: Option, + #[arg(long)] + max_lines: Option, + }, + /// Remove old UnityCsReference snapshots, keeping the newest entries. + Clean { + #[arg(long, default_value_t = 1)] + keep: u64, + #[arg(long)] + version: Option, + #[arg(long, default_value_t = false)] + dry_run: bool, + }, +} diff --git a/src/core/managed_binaries.rs b/src/core/managed_binaries.rs index ed4c480..a3b2566 100644 --- a/src/core/managed_binaries.rs +++ b/src/core/managed_binaries.rs @@ -166,6 +166,14 @@ pub fn tools_root() -> Result { .ok_or_else(|| anyhow!("Unable to resolve tools root")) } +pub fn cache_root() -> Result { + env::var("UNITY_CLI_CACHE_ROOT") + .ok() + .map(|root| PathBuf::from(root.trim())) + .or_else(|| dirs::home_dir().map(|home| home.join(".unity/cache"))) + .ok_or_else(|| anyhow!("Unable to resolve cache root")) +} + pub fn install_dir() -> Result { install_dir_for(ManagedBinary::CSharpLsp) } @@ -753,6 +761,59 @@ mod tests { assert_eq!(resolved, root); } + #[test] + fn cache_root_prefers_unity_cli_cache_root_env() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let root = unique_temp_path("cache-root"); + let root_with_spaces = format!(" {} ", root.display()); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", &root_with_spaces); + let resolved = cache_root().expect("cache root should resolve"); + assert_eq!(resolved, root); + } + + #[test] + fn cache_root_falls_back_to_home_unity_cache() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let previous = env::var("UNITY_CLI_CACHE_ROOT").ok(); + env::remove_var("UNITY_CLI_CACHE_ROOT"); + let resolved = cache_root().expect("cache root should resolve"); + if let Some(home) = dirs::home_dir() { + assert_eq!(resolved, home.join(".unity/cache")); + } + if let Some(value) = previous { + env::set_var("UNITY_CLI_CACHE_ROOT", value); + } + } + + #[test] + fn tools_root_falls_back_to_home_unity_tools_when_env_unset() { + let _guard = env_lock() + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let previous = env::var("UNITY_CLI_TOOLS_ROOT").ok(); + env::remove_var("UNITY_CLI_TOOLS_ROOT"); + let resolved = tools_root().expect("tools root should resolve"); + if let Some(home) = dirs::home_dir() { + assert_eq!(resolved, home.join(".unity/tools")); + } + if let Some(value) = previous { + env::set_var("UNITY_CLI_TOOLS_ROOT", value); + } + } + + #[test] + fn detect_rid_returns_known_target_triple() { + let rid = detect_rid(); + assert!(matches!( + rid, + "win-x64" | "win-arm64" | "osx-x64" | "osx-arm64" | "linux-x64" | "linux-arm64" + )); + } + #[test] fn write_and_read_local_version_round_trip() { let _guard = env_lock() diff --git a/src/core/self_update.rs b/src/core/self_update.rs index c757dd0..2397cdb 100644 --- a/src/core/self_update.rs +++ b/src/core/self_update.rs @@ -97,3 +97,99 @@ fn run_update(marker: &Path) -> anyhow::Result<()> { tracing::debug!("self-update: successfully installed {}", latest.version); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn env_lock() -> &'static std::sync::Mutex<()> { + crate::test_env::env_lock() + } + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + std::env::set_var(key, value); + Self { key, previous } + } + fn unset(key: &'static str) -> Self { + let previous = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } + } + + #[test] + fn maybe_self_update_skipped_when_opt_out_env_set() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _env = EnvVarGuard::set("UNITY_CLI_NO_AUTO_UPDATE", "1"); + assert!(maybe_self_update().is_none()); + } + + #[test] + fn warn_cargo_conflict_runs_without_panic() { + warn_cargo_conflict(); + } + + #[test] + fn touch_creates_parent_and_writes_marker() { + let tmp = tempfile::TempDir::new().unwrap(); + let marker = tmp.path().join("nested/dir/LAST_CHECK"); + touch(&marker); + assert!(marker.exists()); + assert!(marker.parent().unwrap().is_dir()); + } + + #[test] + fn is_recent_returns_false_for_missing_path() { + let tmp = tempfile::TempDir::new().unwrap(); + assert!(!is_recent(&tmp.path().join("nonexistent"))); + } + + #[test] + fn is_recent_returns_true_for_freshly_touched_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("recent"); + touch(&path); + assert!(is_recent(&path)); + } + + #[test] + fn last_check_path_uses_unity_cli_tools_root_env() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let tmp = tempfile::TempDir::new().unwrap(); + let _env = EnvVarGuard::set("UNITY_CLI_TOOLS_ROOT", tmp.path().to_str().unwrap()); + let path = last_check_path().unwrap(); + assert!(path.starts_with(tmp.path())); + assert!(path.ends_with("LAST_UPDATE_CHECK")); + } + + #[test] + fn maybe_self_update_skips_when_recent_check_exists() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _no_auto = EnvVarGuard::unset("UNITY_CLI_NO_AUTO_UPDATE"); + let tmp = tempfile::TempDir::new().unwrap(); + let _tools_env = EnvVarGuard::set("UNITY_CLI_TOOLS_ROOT", tmp.path().to_str().unwrap()); + let marker = last_check_path().unwrap(); + if let Some(parent) = marker.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&marker, b"").unwrap(); + assert!(maybe_self_update().is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 16bea3f..33050e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod core; mod daemon; mod lsp; +mod reference; mod skills; #[cfg(test)] mod test_env; diff --git a/src/reference/cache.rs b/src/reference/cache.rs new file mode 100644 index 0000000..ac321c1 --- /dev/null +++ b/src/reference/cache.rs @@ -0,0 +1,320 @@ +use std::fs; +use std::path::PathBuf; +use std::time::SystemTime; + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; + +const REFERENCE_SUBDIR: &str = "UnityCsReference"; +const META_FILE_NAME: &str = ".unity-cli-meta.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CacheMeta { + pub version: String, + pub branch: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub commit_sha: Option, + pub fetched_at: String, + pub source_url: String, +} + +pub fn reference_root() -> Result { + Ok(crate::core::managed_binaries::cache_root()?.join(REFERENCE_SUBDIR)) +} + +pub fn version_dir(version: &str) -> Result { + if version.trim().is_empty() { + return Err(anyhow!("version must be non-empty")); + } + Ok(reference_root()?.join(version)) +} + +pub fn read_meta(version: &str) -> Result { + let path = version_dir(version)?.join(META_FILE_NAME); + let contents = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let meta: CacheMeta = serde_json::from_str(&contents) + .with_context(|| format!("failed to parse {}", path.display()))?; + Ok(meta) +} + +pub fn write_meta(meta: &CacheMeta) -> Result<()> { + let dir = version_dir(&meta.version)?; + fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?; + let path = dir.join(META_FILE_NAME); + let contents = serde_json::to_string_pretty(meta) + .with_context(|| format!("failed to serialize meta for {}", meta.version))?; + fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +pub fn list_versions() -> Result> { + let root = reference_root()?; + if !root.exists() { + return Ok(Vec::new()); + } + let mut versions = Vec::new(); + for entry in + fs::read_dir(&root).with_context(|| format!("failed to read {}", root.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + if let Some(name) = entry.file_name().to_str() { + versions.push(name.to_string()); + } + } + versions.sort(); + Ok(versions) +} + +pub fn gc(keep: usize, dry_run: bool) -> Result> { + let root = reference_root()?; + if !root.exists() { + return Ok(Vec::new()); + } + let mut entries: Vec<(PathBuf, SystemTime)> = Vec::new(); + for entry in + fs::read_dir(&root).with_context(|| format!("failed to read {}", root.display()))? + { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let meta = entry.metadata()?; + let mtime = meta.modified()?; + entries.push((entry.path(), mtime)); + } + entries.sort_by_key(|entry| std::cmp::Reverse(entry.1)); + let removed: Vec = entries.into_iter().skip(keep).map(|(p, _)| p).collect(); + if !dry_run { + for path in &removed { + fs::remove_dir_all(path) + .with_context(|| format!("failed to remove {}", path.display()))?; + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var(key).ok(); + env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + env::set_var(self.key, value); + } else { + env::remove_var(self.key); + } + } + } + + fn unique_temp_path(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::from_secs(0)) + .as_nanos(); + env::temp_dir().join(format!("unity-cli-reference-{label}-{nanos}")) + } + + #[test] + fn version_dir_under_cache_root() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("vdir"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let dir = version_dir("2023.2.20f1").expect("version_dir resolves"); + assert_eq!(dir, root.join("UnityCsReference").join("2023.2.20f1")); + } + + #[test] + fn version_dir_rejects_empty() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("vdir-empty"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let err = version_dir("").unwrap_err(); + assert!(format!("{err:#}").contains("version")); + } + + #[test] + fn meta_roundtrip() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("meta"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let meta = CacheMeta { + version: "2023.2.20f1".to_string(), + branch: "2023.2/staging".to_string(), + commit_sha: Some("abcdef".to_string()), + fetched_at: "2026-05-11T11:00:00Z".to_string(), + source_url: "https://github.com/Unity-Technologies/UnityCsReference.git".to_string(), + }; + write_meta(&meta).expect("write meta"); + let loaded = read_meta(&meta.version).expect("read meta"); + assert_eq!(loaded, meta); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn list_versions_returns_existing_dirs() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("list"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let base = root.join("UnityCsReference"); + for v in &["2022.3.0f1", "2023.2.20f1", "2024.0.0f1"] { + fs::create_dir_all(base.join(v)).unwrap(); + } + let listed = list_versions().expect("list_versions resolves"); + assert_eq!( + listed, + vec![ + "2022.3.0f1".to_string(), + "2023.2.20f1".to_string(), + "2024.0.0f1".to_string(), + ] + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn gc_keeps_n_newest_and_returns_removed() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("gc"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let base = root.join("UnityCsReference"); + for v in &["a", "b", "c"] { + fs::create_dir_all(base.join(v)).unwrap(); + } + let removed_dry = gc(1, true).expect("gc dry_run resolves"); + assert_eq!(removed_dry.len(), 2); + assert!(base.join("a").exists() && base.join("b").exists() && base.join("c").exists()); + let removed = gc(1, false).expect("gc actual resolves"); + assert_eq!(removed.len(), 2); + let remaining: Vec = fs::read_dir(&base) + .unwrap() + .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string())) + .collect(); + assert_eq!(remaining.len(), 1); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn list_versions_empty_when_root_missing() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("list-missing"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + assert!(list_versions().unwrap().is_empty()); + } + + #[test] + fn gc_keep_zero_removes_all_versions() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("gc-zero"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let base = root.join("UnityCsReference"); + for v in &["x", "y"] { + fs::create_dir_all(base.join(v)).unwrap(); + } + let removed = gc(0, false).unwrap(); + assert_eq!(removed.len(), 2); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn gc_skips_non_directory_entries() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("gc-mixed"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let base = root.join("UnityCsReference"); + fs::create_dir_all(&base).unwrap(); + fs::create_dir_all(base.join("v1")).unwrap(); + fs::write(base.join("stray.txt"), b"ignore").unwrap(); + let removed = gc(1, true).unwrap(); + assert!(removed.is_empty()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn gc_empty_root_returns_empty() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("gc-empty"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let removed = gc(1, true).unwrap(); + assert!(removed.is_empty()); + } + + #[test] + fn read_meta_returns_error_when_missing() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("read-meta-miss"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let err = read_meta("not-fetched").unwrap_err(); + assert!(format!("{err:#}").contains("failed to read")); + } + + #[test] + fn read_meta_returns_error_for_invalid_json() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("read-meta-bad"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let dir = version_dir("bad-version").unwrap(); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(".unity-cli-meta.json"), "not json {").unwrap(); + let err = read_meta("bad-version").unwrap_err(); + assert!(format!("{err:#}").contains("failed to parse")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn list_versions_skips_files_in_root() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("list-files"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let base = root.join("UnityCsReference"); + fs::create_dir_all(&base).unwrap(); + fs::create_dir_all(base.join("v1")).unwrap(); + fs::write(base.join("stray.txt"), b"x").unwrap(); + let listed = list_versions().unwrap(); + assert_eq!(listed, vec!["v1".to_string()]); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/reference/fetcher.rs b/src/reference/fetcher.rs new file mode 100644 index 0000000..4a8371f --- /dev/null +++ b/src/reference/fetcher.rs @@ -0,0 +1,202 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; + +pub const UNITY_CS_REFERENCE_URL: &str = + "https://github.com/Unity-Technologies/UnityCsReference.git"; +const LICENSE_ENV_VAR: &str = "UNITY_CLI_ACCEPT_LICENSE"; +const GITHUB_TOKEN_ENV_VARS: &[&str] = &["GITHUB_TOKEN", "GH_TOKEN"]; + +pub fn build_clone_args(url: &str, branch: &str, dest: &Path, depth: u32) -> Vec { + vec![ + "--depth".to_string(), + depth.to_string(), + "--single-branch".to_string(), + "--branch".to_string(), + branch.to_string(), + url.to_string(), + dest.display().to_string(), + ] +} + +pub fn require_license_accepted(flag: bool) -> Result<()> { + if flag { + return Ok(()); + } + if let Ok(value) = std::env::var(LICENSE_ENV_VAR) { + if !value.trim().is_empty() && value != "0" { + return Ok(()); + } + } + Err(anyhow!( + "UnityCsReference is distributed under the Unity Companion License. Pass --accept-license or set {}=1 to confirm consent before fetching.", + LICENSE_ENV_VAR + )) +} + +pub fn ensure_git_available() -> Result<()> { + Command::new("git") + .arg("--version") + .output() + .context("git binary not found in PATH; install git or use a future zip fallback")?; + Ok(()) +} + +fn github_token() -> Option { + for key in GITHUB_TOKEN_ENV_VARS { + if let Ok(v) = std::env::var(key) { + if !v.trim().is_empty() { + return Some(v); + } + } + } + None +} + +pub fn run_clone( + url: &str, + branch: &str, + dest: &Path, + depth: u32, + accept_license: bool, +) -> Result<()> { + require_license_accepted(accept_license)?; + ensure_git_available()?; + let mut cmd = Command::new("git"); + if let Some(token) = github_token() { + cmd.arg("-c") + .arg(format!("http.extraHeader=Authorization: token {token}")); + } + cmd.arg("clone"); + for arg in build_clone_args(url, branch, dest, depth) { + cmd.arg(arg); + } + let status = cmd + .status() + .with_context(|| format!("failed to spawn git clone for {url}"))?; + if !status.success() { + return Err(anyhow!("git clone exited with status {status}")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::path::PathBuf; + use std::sync::Mutex; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var(key).ok(); + env::set_var(key, value); + Self { key, previous } + } + fn unset(key: &'static str) -> Self { + let previous = env::var(key).ok(); + env::remove_var(key); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + env::set_var(self.key, value); + } else { + env::remove_var(self.key); + } + } + } + + fn env_lock() -> &'static Mutex<()> { + crate::test_env::env_lock() + } + + #[test] + fn build_clone_args_emits_shallow_single_branch() { + let dest = PathBuf::from("/tmp/unity-cs-reference/2023.2.20f1"); + let args = build_clone_args(UNITY_CS_REFERENCE_URL, "2023.2/staging", &dest, 1); + assert_eq!( + args, + vec![ + "--depth".to_string(), + "1".to_string(), + "--single-branch".to_string(), + "--branch".to_string(), + "2023.2/staging".to_string(), + UNITY_CS_REFERENCE_URL.to_string(), + dest.display().to_string(), + ] + ); + } + + #[test] + fn license_required_when_flag_false_and_env_unset() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _env = EnvVarGuard::unset("UNITY_CLI_ACCEPT_LICENSE"); + let err = require_license_accepted(false).unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("Unity Companion License")); + assert!(msg.contains("--accept-license")); + } + + #[test] + fn license_ok_when_flag_true() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _env = EnvVarGuard::unset("UNITY_CLI_ACCEPT_LICENSE"); + require_license_accepted(true).expect("license OK when flag set"); + } + + #[test] + fn license_ok_when_env_set() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _env = EnvVarGuard::set("UNITY_CLI_ACCEPT_LICENSE", "1"); + require_license_accepted(false).expect("license OK when env set"); + } + + #[test] + fn license_rejects_zero_value() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _env = EnvVarGuard::set("UNITY_CLI_ACCEPT_LICENSE", "0"); + let err = require_license_accepted(false).unwrap_err(); + assert!(format!("{err:#}").contains("Unity Companion License")); + } + + #[test] + fn github_token_returns_none_when_env_unset() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _g1 = EnvVarGuard::unset("GITHUB_TOKEN"); + let _g2 = EnvVarGuard::unset("GH_TOKEN"); + assert!(github_token().is_none()); + } + + #[test] + fn github_token_skips_empty_and_picks_first_non_empty() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _g1 = EnvVarGuard::set("GITHUB_TOKEN", ""); + let _g2 = EnvVarGuard::set("GH_TOKEN", "ghp_test_value"); + assert_eq!(github_token().as_deref(), Some("ghp_test_value")); + } + + #[test] + fn ensure_git_available_succeeds_in_test_env() { + ensure_git_available().expect("git is expected on dev/CI environment"); + } + + #[test] + fn run_clone_rejects_when_license_not_accepted() { + let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner()); + let _env = EnvVarGuard::unset("UNITY_CLI_ACCEPT_LICENSE"); + let dest = PathBuf::from("/tmp/unity-cli-reference-clone-license-guard"); + let err = run_clone(UNITY_CS_REFERENCE_URL, "2023.2/staging", &dest, 1, false).unwrap_err(); + assert!(format!("{err:#}").contains("Unity Companion License")); + } +} diff --git a/src/reference/mod.rs b/src/reference/mod.rs new file mode 100644 index 0000000..82812a5 --- /dev/null +++ b/src/reference/mod.rs @@ -0,0 +1,673 @@ +#![allow(dead_code)] + +pub mod cache; +pub mod fetcher; +pub mod search; +pub mod version; + +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use serde_json::{json, Value}; +use walkdir::WalkDir; + +pub fn maybe_execute_reference_tool(tool_name: &str, params: &Value) -> Option> { + match tool_name { + "reference_fetch" => Some(execute_fetch(params)), + "reference_status" => Some(execute_status(params)), + "reference_search" => Some(execute_search(params)), + "reference_grep" => Some(execute_grep(params)), + "reference_view" => Some(execute_view(params)), + "reference_clean" => Some(execute_clean(params)), + _ => None, + } +} + +fn resolve_version(params: &Value) -> Result { + if let Some(v) = params.get("version").and_then(Value::as_str) { + if !v.is_empty() { + return Ok(v.to_string()); + } + } + let project_root = params + .get("projectRoot") + .and_then(Value::as_str) + .map(Path::new) + .unwrap_or_else(|| Path::new(".")); + Ok(version::detect_from_project(project_root)?.version) +} + +fn resolve_version_and_branch(params: &Value) -> Result<(String, String)> { + let explicit_version = params + .get("version") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let explicit_branch = params + .get("branch") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string); + if let (Some(v), Some(b)) = (explicit_version.clone(), explicit_branch.clone()) { + return Ok((v, b)); + } + let project_root = params + .get("projectRoot") + .and_then(Value::as_str) + .map(Path::new) + .unwrap_or_else(|| Path::new(".")); + let detected = version::detect_from_project(project_root)?; + Ok(( + explicit_version.unwrap_or(detected.version), + explicit_branch.unwrap_or(detected.branch), + )) +} + +fn execute_fetch(params: &Value) -> Result { + let (version, branch) = resolve_version_and_branch(params)?; + let accept_license = params + .get("acceptLicense") + .and_then(Value::as_bool) + .unwrap_or(false); + let force = params + .get("force") + .and_then(Value::as_bool) + .unwrap_or(false); + let dest = cache::version_dir(&version)?; + if dest.exists() && !force { + return Ok(json!({ + "ok": true, + "skipped": true, + "reason": "destination already exists; pass force=true to refetch", + "version": version, + "branch": branch, + "path": dest.display().to_string(), + })); + } + if dest.exists() && force { + std::fs::remove_dir_all(&dest) + .with_context(|| format!("failed to remove {}", dest.display()))?; + } + fetcher::run_clone( + fetcher::UNITY_CS_REFERENCE_URL, + &branch, + &dest, + 1, + accept_license, + )?; + let meta = cache::CacheMeta { + version: version.clone(), + branch: branch.clone(), + commit_sha: None, + fetched_at: now_unix_seconds_string(), + source_url: fetcher::UNITY_CS_REFERENCE_URL.to_string(), + }; + cache::write_meta(&meta)?; + Ok(json!({ + "ok": true, + "version": version, + "branch": branch, + "path": dest.display().to_string(), + "fetchedAt": meta.fetched_at, + })) +} + +fn execute_status(_params: &Value) -> Result { + let versions = cache::list_versions()?; + let mut entries = Vec::new(); + for v in versions { + let dir = cache::version_dir(&v)?; + let size_bytes = dir_size(&dir).unwrap_or(0); + let meta = cache::read_meta(&v).ok(); + entries.push(json!({ + "version": v, + "branch": meta.as_ref().map(|m| m.branch.clone()).unwrap_or_default(), + "fetchedAt": meta.as_ref().map(|m| m.fetched_at.clone()).unwrap_or_default(), + "sizeBytes": size_bytes, + "path": dir.display().to_string(), + })); + } + Ok(json!({ + "ok": true, + "versions": entries, + })) +} + +fn execute_search(params: &Value) -> Result { + let pattern = params + .get("pattern") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("search requires `pattern`"))?; + let version = resolve_version(params)?; + let dir = cache::version_dir(&version)?; + let file_glob = params.get("path").and_then(Value::as_str); + let context = params.get("context").and_then(Value::as_u64).unwrap_or(0) as u32; + let max_results = params + .get("maxResults") + .and_then(Value::as_u64) + .map(|n| n as usize); + let mut hits = search::run_grep(&dir, pattern, file_glob, context)?; + if let Some(n) = max_results { + hits.truncate(n); + } + Ok(json!({ + "ok": true, + "version": version, + "hits": hits, + })) +} + +fn execute_grep(params: &Value) -> Result { + let pattern = params + .get("pattern") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("grep requires `pattern`"))?; + let version = resolve_version(params)?; + let dir = cache::version_dir(&version)?; + let file_glob = params.get("fileGlob").and_then(Value::as_str); + let context = params.get("context").and_then(Value::as_u64).unwrap_or(0) as u32; + let hits = search::run_grep(&dir, pattern, file_glob, context)?; + Ok(json!({ + "ok": true, + "version": version, + "hits": hits, + })) +} + +fn execute_view(params: &Value) -> Result { + let path = params + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("view requires `path`"))?; + let version = resolve_version(params)?; + let dir = cache::version_dir(&version)?; + let start_line = params + .get("startLine") + .and_then(Value::as_u64) + .map(|n| n as u32); + let max_lines = params + .get("maxLines") + .and_then(Value::as_u64) + .map(|n| n as u32); + let out = search::run_view(&dir, path, start_line, max_lines)?; + Ok(json!({ + "ok": true, + "version": version, + "view": out, + })) +} + +fn execute_clean(params: &Value) -> Result { + let keep = params.get("keep").and_then(Value::as_u64).unwrap_or(1) as usize; + let dry_run = params + .get("dryRun") + .and_then(Value::as_bool) + .unwrap_or(false); + let removed = cache::gc(keep, dry_run)?; + Ok(json!({ + "ok": true, + "keep": keep, + "dryRun": dry_run, + "removed": removed + .iter() + .map(|p| p.display().to_string()) + .collect::>(), + })) +} + +fn now_unix_seconds_string() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!("{secs}") +} + +fn dir_size(path: &Path) -> Result { + let mut total = 0u64; + for entry in WalkDir::new(path) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + if entry.file_type().is_file() { + if let Ok(meta) = entry.metadata() { + total = total.saturating_add(meta.len()); + } + } + } + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::path::PathBuf; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var(key).ok(); + env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.previous { + env::set_var(self.key, value); + } else { + env::remove_var(self.key); + } + } + } + + fn unique_temp_path(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::from_secs(0)) + .as_nanos(); + env::temp_dir().join(format!("unity-cli-reference-mod-{label}-{nanos}")) + } + + #[test] + fn maybe_execute_returns_none_for_unknown_tool() { + assert!(maybe_execute_reference_tool("nonexistent", &json!({})).is_none()); + } + + #[test] + fn execute_clean_dry_run_on_empty_cache_returns_empty_removed() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("clean"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = + maybe_execute_reference_tool("reference_clean", &json!({"dryRun": true, "keep": 1})) + .unwrap() + .unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["dryRun"], true); + assert!(value["removed"].is_array()); + assert_eq!(value["removed"].as_array().unwrap().len(), 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_status_returns_versions_list() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("status"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let base = root.join("UnityCsReference"); + std::fs::create_dir_all(base.join("2023.2.20f1")).unwrap(); + let value = maybe_execute_reference_tool("reference_status", &json!({})) + .unwrap() + .unwrap(); + assert_eq!(value["ok"], true); + let versions = value["versions"].as_array().unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0]["version"], "2023.2.20f1"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn resolve_version_uses_explicit_param() { + let v = resolve_version(&json!({"version": "2023.2.20f1"})).unwrap(); + assert_eq!(v, "2023.2.20f1"); + } + + #[test] + fn resolve_version_falls_back_to_project_detection() { + let tmp = unique_temp_path("resolve-version"); + let settings = tmp.join("ProjectSettings"); + std::fs::create_dir_all(&settings).unwrap(); + std::fs::write( + settings.join("ProjectVersion.txt"), + "m_EditorVersion: 2023.2.20f1\n", + ) + .unwrap(); + let v = resolve_version(&json!({"projectRoot": tmp.to_str().unwrap()})).unwrap(); + assert_eq!(v, "2023.2.20f1"); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn resolve_version_and_branch_uses_explicit_overrides() { + let (v, b) = resolve_version_and_branch( + &json!({"version": "2025.1.0f1", "branch": "custom/branch"}), + ) + .unwrap(); + assert_eq!(v, "2025.1.0f1"); + assert_eq!(b, "custom/branch"); + } + + #[test] + fn resolve_version_and_branch_detects_from_project_when_missing() { + let tmp = unique_temp_path("resolve-vb"); + let settings = tmp.join("ProjectSettings"); + std::fs::create_dir_all(&settings).unwrap(); + std::fs::write( + settings.join("ProjectVersion.txt"), + "m_EditorVersion: 2023.2.20f1\n", + ) + .unwrap(); + let (v, b) = + resolve_version_and_branch(&json!({"projectRoot": tmp.to_str().unwrap()})).unwrap(); + assert_eq!(v, "2023.2.20f1"); + assert_eq!(b, "2023.2/staging"); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn now_unix_seconds_string_is_decimal() { + let s = now_unix_seconds_string(); + assert!(!s.is_empty()); + assert!(s.chars().all(|c| c.is_ascii_digit())); + } + + #[test] + fn dir_size_empty_returns_zero() { + let tmp = unique_temp_path("dirsize-empty"); + std::fs::create_dir_all(&tmp).unwrap(); + assert_eq!(dir_size(&tmp).unwrap(), 0); + let _ = std::fs::remove_dir_all(&tmp); + } + + #[test] + fn dir_size_sums_file_lengths() { + let tmp = unique_temp_path("dirsize-files"); + let sub = tmp.join("a"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(tmp.join("a.txt"), b"hello").unwrap(); + std::fs::write(sub.join("b.txt"), b"world!!").unwrap(); + assert_eq!(dir_size(&tmp).unwrap(), 5 + 7); + let _ = std::fs::remove_dir_all(&tmp); + } + + fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else if file_type.is_file() { + std::fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) + } + + fn setup_cache_with_fixture(label: &str) -> (PathBuf, &'static str) { + let root = unique_temp_path(label); + let version = "fixture-version"; + let dest = root.join("UnityCsReference").join(version); + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/reference-cache"); + copy_dir_recursive(&fixture, &dest).unwrap(); + (root, version) + } + + #[test] + fn execute_grep_via_dispatcher_returns_hits() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("grep-disp"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_grep", + &json!({"pattern": "class Animator", "version": version}), + ) + .unwrap() + .unwrap(); + assert_eq!(value["ok"], true); + let hits = value["hits"].as_array().unwrap(); + assert!(!hits.is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_view_via_dispatcher_returns_lines() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("view-disp"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_view", + &json!({ + "path": "Runtime/Export/Animation/Animator.bindings.cs", + "version": version, + "startLine": 1, + "maxLines": 3, + }), + ) + .unwrap() + .unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["view"]["lines"].as_array().unwrap().len(), 3); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_search_via_dispatcher_truncates_max_results() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("search-disp"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_search", + &json!({ + "pattern": "class", + "version": version, + "maxResults": 1, + }), + ) + .unwrap() + .unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["hits"].as_array().unwrap().len(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_status_includes_size_bytes() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("status-size"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool("reference_status", &json!({})) + .unwrap() + .unwrap(); + let versions = value["versions"].as_array().unwrap(); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0]["version"], version); + assert!(versions[0]["sizeBytes"].as_u64().unwrap() > 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_fetch_skips_when_destination_exists_without_force() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("fetch-skip"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_fetch", + &json!({ + "version": version, + "branch": "fixture/branch", + "acceptLicense": true, + }), + ) + .unwrap() + .unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["skipped"], true); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_view_rejects_parent_traversal_via_dispatcher() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("view-trav"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let err = maybe_execute_reference_tool( + "reference_view", + &json!({"path": "../escape.cs", "version": version}), + ) + .unwrap() + .unwrap_err(); + assert!(format!("{err:#}").contains("..")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_view_requires_path_param() { + let err = maybe_execute_reference_tool("reference_view", &json!({"version": "any"})) + .unwrap() + .unwrap_err(); + assert!(format!("{err:#}").contains("path")); + } + + #[test] + fn execute_search_requires_pattern() { + let err = maybe_execute_reference_tool("reference_search", &json!({"version": "any"})) + .unwrap() + .unwrap_err(); + assert!(format!("{err:#}").contains("pattern")); + } + + #[test] + fn execute_grep_requires_pattern() { + let err = maybe_execute_reference_tool("reference_grep", &json!({"version": "any"})) + .unwrap() + .unwrap_err(); + assert!(format!("{err:#}").contains("pattern")); + } + + #[test] + fn execute_grep_invalid_regex_returns_error() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("grep-bad"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let err = maybe_execute_reference_tool( + "reference_grep", + &json!({"pattern": "(unclosed", "version": version}), + ) + .unwrap() + .unwrap_err(); + assert!(format!("{err:#}").contains("regex")); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_search_without_max_results_returns_all_hits() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("search-full"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_search", + &json!({ + "pattern": "class", + "version": version, + "context": 1, + }), + ) + .unwrap() + .unwrap(); + assert!(value["hits"].as_array().unwrap().len() >= 2); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_grep_supports_file_glob_filter() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("grep-glob"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let value = maybe_execute_reference_tool( + "reference_grep", + &json!({ + "pattern": "class", + "version": version, + "fileGlob": "*.cs", + }), + ) + .unwrap() + .unwrap(); + assert!(!value["hits"].as_array().unwrap().is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_clean_dry_run_false_actually_removes() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let root = unique_temp_path("clean-actual"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let base = root.join("UnityCsReference"); + for v in &["v1", "v2"] { + std::fs::create_dir_all(base.join(v)).unwrap(); + } + let value = + maybe_execute_reference_tool("reference_clean", &json!({"keep": 1, "dryRun": false})) + .unwrap() + .unwrap(); + assert_eq!(value["dryRun"], false); + assert_eq!(value["removed"].as_array().unwrap().len(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn execute_fetch_force_clears_existing_then_fails_clone() { + let _guard = crate::test_env::env_lock() + .lock() + .unwrap_or_else(|p| p.into_inner()); + let (root, version) = setup_cache_with_fixture("fetch-force"); + let _env = EnvVarGuard::set("UNITY_CLI_CACHE_ROOT", root.to_str().unwrap()); + let dest = root.join("UnityCsReference").join(version); + assert!(dest.exists()); + // Force should remove dest, then attempt clone with a bogus branch that fails. + let result = maybe_execute_reference_tool( + "reference_fetch", + &json!({ + "version": version, + "branch": "definitely-not-a-real-branch-xyz", + "force": true, + "acceptLicense": true, + }), + ) + .unwrap(); + assert!(result.is_err(), "clone should fail for bogus branch"); + assert!( + !dest.exists(), + "force should have removed dest before clone" + ); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src/reference/search.rs b/src/reference/search.rs new file mode 100644 index 0000000..4aa573b --- /dev/null +++ b/src/reference/search.rs @@ -0,0 +1,207 @@ +use std::fs; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; +use regex::Regex; +use serde::Serialize; +use walkdir::WalkDir; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct GrepHit { + pub path: String, + pub line: u32, + pub text: String, + pub context_before: Vec, + pub context_after: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ViewOutput { + pub path: String, + pub start_line: u32, + pub end_line: u32, + pub lines: Vec, +} + +pub fn run_grep( + root: &Path, + pattern: &str, + file_glob: Option<&str>, + context: u32, +) -> Result> { + let regex = Regex::new(pattern).with_context(|| format!("invalid regex: {pattern}"))?; + let glob_re = file_glob.map(glob_to_regex).transpose()?; + let mut hits = Vec::new(); + for entry in WalkDir::new(root) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if let Some(re) = &glob_re { + if !re.is_match(&file_name) { + continue; + } + } + let rel = path.strip_prefix(root).unwrap_or(path); + let rel_str = rel.display().to_string(); + let contents = match fs::read_to_string(path) { + Ok(s) => s, + Err(_) => continue, + }; + let lines: Vec<&str> = contents.lines().collect(); + for (idx, line) in lines.iter().enumerate() { + if regex.is_match(line) { + let line_no = (idx + 1) as u32; + let ctx = context as usize; + let before_start = idx.saturating_sub(ctx); + let after_end = (idx + 1 + ctx).min(lines.len()); + let context_before: Vec = lines[before_start..idx] + .iter() + .map(|s| s.to_string()) + .collect(); + let context_after: Vec = lines[idx + 1..after_end] + .iter() + .map(|s| s.to_string()) + .collect(); + hits.push(GrepHit { + path: rel_str.clone(), + line: line_no, + text: (*line).to_string(), + context_before, + context_after, + }); + } + } + } + hits.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line))); + Ok(hits) +} + +pub fn run_view( + root: &Path, + rel_path: &str, + start_line: Option, + max_lines: Option, +) -> Result { + if rel_path + .split(['/', std::path::MAIN_SEPARATOR]) + .any(|seg| seg == "..") + { + return Err(anyhow!("path must not contain '..' segments: {rel_path}")); + } + let path = root.join(rel_path); + let contents = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let all_lines: Vec<&str> = contents.lines().collect(); + let start = start_line.unwrap_or(1).max(1) as usize; + if start > all_lines.len() && !all_lines.is_empty() { + return Err(anyhow!( + "start_line {start} exceeds file length {}", + all_lines.len() + )); + } + let max = max_lines.map(|m| m as usize).unwrap_or(usize::MAX); + let begin_idx = start.saturating_sub(1); + let end_idx = begin_idx.saturating_add(max).min(all_lines.len()); + let slice: Vec = all_lines[begin_idx..end_idx] + .iter() + .map(|s| (*s).to_string()) + .collect(); + let end_line = if slice.is_empty() { + start as u32 + } else { + (begin_idx + slice.len()) as u32 + }; + Ok(ViewOutput { + path: rel_path.to_string(), + start_line: start as u32, + end_line, + lines: slice, + }) +} + +fn glob_to_regex(glob: &str) -> Result { + let mut out = String::from("^"); + for ch in glob.chars() { + match ch { + '*' => out.push_str("[^/]*"), + '?' => out.push('.'), + '.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '[' | ']' | '\\' => { + out.push('\\'); + out.push(ch); + } + _ => out.push(ch), + } + } + out.push('$'); + Regex::new(&out).with_context(|| format!("invalid file_glob: {glob}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/reference-cache") + } + + #[test] + fn grep_finds_class_animator_with_line_numbers() { + let hits = run_grep(&fixture_root(), "class Animator", None, 0).unwrap(); + assert!(!hits.is_empty()); + for hit in &hits { + assert!(hit.text.contains("class Animator")); + assert!(hit.line >= 1); + } + assert!(hits.iter().any(|h| h.path.contains("Animator.bindings.cs"))); + } + + #[test] + fn grep_filters_by_filename_glob() { + let hits = run_grep(&fixture_root(), "class", Some("*.cs"), 0).unwrap(); + assert!(!hits.is_empty()); + for hit in &hits { + assert!(hit.path.ends_with(".cs")); + } + } + + #[test] + fn grep_returns_context_lines() { + let hits = run_grep(&fixture_root(), "Play\\(string stateName\\)$", None, 1).unwrap(); + let hit = hits + .into_iter() + .find(|h| h.text.contains("Play(string stateName)")) + .expect("should find Play binding"); + assert_eq!(hit.context_before.len(), 1); + assert_eq!(hit.context_after.len(), 1); + } + + #[test] + fn view_returns_requested_line_range() { + let out = run_view( + &fixture_root(), + "Runtime/Export/Animation/Animator.bindings.cs", + Some(3), + Some(2), + ) + .unwrap(); + assert_eq!(out.start_line, 3); + assert_eq!(out.lines.len(), 2); + assert_eq!(out.end_line, 4); + } + + #[test] + fn view_rejects_parent_traversal() { + let err = run_view(&fixture_root(), "../escape.cs", None, None).unwrap_err(); + assert!(format!("{err:#}").contains("..")); + } +} diff --git a/src/reference/version.rs b/src/reference/version.rs new file mode 100644 index 0000000..1f98aff --- /dev/null +++ b/src/reference/version.rs @@ -0,0 +1,178 @@ +use std::fs; +use std::path::Path; + +use anyhow::{anyhow, Context, Result}; + +const PROJECT_VERSION_REL_PATH: &str = "ProjectSettings/ProjectVersion.txt"; +const EDITOR_VERSION_KEY: &str = "m_EditorVersion:"; + +const VERSION_BRANCH_MAP: &[(&str, &str)] = &[ + ("2020.3", "2020.3/staging"), + ("2021.3", "2021.3/staging"), + ("2022.3", "2022.3/staging"), + ("2023.1", "2023.1/staging"), + ("2023.2", "2023.2/staging"), + ("6000.0", "6000.0/staging"), +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedVersion { + pub version: String, + pub branch: String, +} + +pub fn detect_from_project(project_root: &Path) -> Result { + let path = project_root.join(PROJECT_VERSION_REL_PATH); + let contents = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let version = parse_editor_version(&contents).ok_or_else(|| { + anyhow!( + "{} does not contain a '{}' line", + path.display(), + EDITOR_VERSION_KEY + ) + })?; + let branch = resolve_branch(&version)?; + Ok(DetectedVersion { version, branch }) +} + +fn parse_editor_version(contents: &str) -> Option { + for line in contents.lines() { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix(EDITOR_VERSION_KEY) { + let value = rest.trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + None +} + +fn resolve_branch(version: &str) -> Result { + let minor_key = minor_version_key(version)?; + for (prefix, branch) in VERSION_BRANCH_MAP { + if minor_key == *prefix { + return Ok((*branch).to_string()); + } + } + Err(anyhow!( + "Unity version '{}' is not in the static branch map. Pass --branch explicitly to fetch.", + version + )) +} + +fn minor_version_key(version: &str) -> Result { + let mut iter = version.splitn(3, '.'); + let major = iter + .next() + .ok_or_else(|| anyhow!("invalid Unity version '{}': missing major segment", version))?; + let minor = iter + .next() + .ok_or_else(|| anyhow!("invalid Unity version '{}': missing minor segment", version))?; + if major.is_empty() || minor.is_empty() { + return Err(anyhow!("invalid Unity version '{}'", version)); + } + Ok(format!("{major}.{minor}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write_project_version(dir: &Path, contents: &str) { + let settings_dir = dir.join("ProjectSettings"); + fs::create_dir_all(&settings_dir).unwrap(); + fs::write(settings_dir.join("ProjectVersion.txt"), contents).unwrap(); + } + + #[test] + fn detects_known_2023_2_lts() { + let tmp = TempDir::new().unwrap(); + write_project_version( + tmp.path(), + "m_EditorVersion: 2023.2.20f1\nm_EditorVersionWithRevision: 2023.2.20f1 (foo)\n", + ); + let detected = detect_from_project(tmp.path()).unwrap(); + assert_eq!(detected.version, "2023.2.20f1"); + assert_eq!(detected.branch, "2023.2/staging"); + } + + #[test] + fn rejects_unknown_minor_with_branch_hint() { + let tmp = TempDir::new().unwrap(); + write_project_version(tmp.path(), "m_EditorVersion: 9999.9.0f1\n"); + let err = detect_from_project(tmp.path()).unwrap_err(); + let message = format!("{err:#}"); + assert!( + message.contains("9999.9"), + "expected version in error: {message}" + ); + assert!( + message.contains("--branch"), + "expected --branch hint in error: {message}" + ); + } + + #[test] + fn detect_returns_error_when_project_version_missing() { + let tmp = TempDir::new().unwrap(); + let err = detect_from_project(tmp.path()).unwrap_err(); + assert!(format!("{err:#}").contains("failed to read")); + } + + #[test] + fn detect_returns_error_when_editor_version_line_absent() { + let tmp = TempDir::new().unwrap(); + write_project_version(tmp.path(), "m_OtherKey: value\n"); + let err = detect_from_project(tmp.path()).unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("m_EditorVersion")); + } + + #[test] + fn detect_returns_error_when_editor_version_value_empty() { + let tmp = TempDir::new().unwrap(); + write_project_version(tmp.path(), "m_EditorVersion: \n"); + let err = detect_from_project(tmp.path()).unwrap_err(); + let message = format!("{err:#}"); + assert!(message.contains("m_EditorVersion")); + } + + #[test] + fn parse_editor_version_returns_value_directly() { + assert_eq!( + parse_editor_version("m_EditorVersion: 2022.3.10f1\n"), + Some("2022.3.10f1".to_string()) + ); + assert_eq!( + parse_editor_version("m_OtherKey: foo\nm_EditorVersion: 2022.3.10f1\n"), + Some("2022.3.10f1".to_string()) + ); + assert_eq!(parse_editor_version(""), None); + assert_eq!(parse_editor_version("m_EditorVersion: "), None); + } + + #[test] + fn resolve_branch_handles_supported_minors() { + for (input, branch) in &[ + ("2020.3.0f1", "2020.3/staging"), + ("2021.3.0f1", "2021.3/staging"), + ("2022.3.5f1", "2022.3/staging"), + ("2023.1.0f1", "2023.1/staging"), + ("6000.0.0f1", "6000.0/staging"), + ] { + assert_eq!(resolve_branch(input).unwrap(), branch.to_string()); + } + } + + #[test] + fn minor_version_key_rejects_invalid_inputs() { + assert!(minor_version_key("").is_err()); + assert!(minor_version_key("2022").is_err()); + assert!(minor_version_key(".3.0f1").is_err()); + assert!(minor_version_key("2022.").is_err()); + } +} diff --git a/src/tooling/local_tools.rs b/src/tooling/local_tools.rs index c157da6..200011f 100644 --- a/src/tooling/local_tools.rs +++ b/src/tooling/local_tools.rs @@ -65,6 +65,9 @@ struct ScopedType { } pub fn maybe_execute_local_tool(tool_name: &str, params: &Value) -> Option> { + if let Some(result) = crate::reference::maybe_execute_reference_tool(tool_name, params) { + return Some(result); + } match tool_name { "read" => Some(local_read(params)), "search" => Some(local_search(params)), diff --git a/src/tooling/tool_catalog.rs b/src/tooling/tool_catalog.rs index b2bff04..b4e55bb 100644 --- a/src/tooling/tool_catalog.rs +++ b/src/tooling/tool_catalog.rs @@ -120,6 +120,12 @@ pub const TOOL_NAMES: &[&str] = &[ "capture_video_start", "capture_video_status", "capture_video_stop", + "reference_fetch", + "reference_status", + "reference_search", + "reference_grep", + "reference_view", + "reference_clean", ]; #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] @@ -216,7 +222,13 @@ fn tool_executor(name: &str) -> ToolExecutor { | "write_csharp_file" | "create_csharp_file" | "apply_csharp_edits" - | "create_class" => ToolExecutor::Local, + | "create_class" + | "reference_fetch" + | "reference_status" + | "reference_search" + | "reference_grep" + | "reference_view" + | "reference_clean" => ToolExecutor::Local, _ => ToolExecutor::Remote, } } @@ -264,6 +276,10 @@ fn is_read_only_tool(name: &str) -> bool { | "find_ui_elements" | "get_ui_element_state" | "capture_video_status" + | "reference_status" + | "reference_search" + | "reference_grep" + | "reference_view" ) } @@ -2220,6 +2236,62 @@ fn tool_params_schema(name: &str) -> Value { &[], false, ), + "reference_fetch" => object_schema( + &[ + ("version", string_schema()), + ("branch", string_schema()), + ("force", boolean_schema()), + ("acceptLicense", boolean_schema()), + ("projectRoot", string_schema()), + ], + &[], + false, + ), + "reference_status" => object_schema(&[("version", string_schema())], &[], false), + "reference_search" => object_schema( + &[ + ("pattern", string_schema()), + ("version", string_schema()), + ("path", string_schema()), + ("maxResults", integer_schema()), + ("regex", boolean_schema()), + ("context", integer_schema()), + ("projectRoot", string_schema()), + ], + &["pattern"], + false, + ), + "reference_grep" => object_schema( + &[ + ("pattern", string_schema()), + ("version", string_schema()), + ("fileGlob", string_schema()), + ("context", integer_schema()), + ("projectRoot", string_schema()), + ], + &["pattern"], + false, + ), + "reference_view" => object_schema( + &[ + ("path", string_schema()), + ("version", string_schema()), + ("startLine", integer_schema()), + ("maxLines", integer_schema()), + ("projectRoot", string_schema()), + ], + &["path"], + false, + ), + "reference_clean" => object_schema( + &[ + ("keep", integer_schema()), + ("version", string_schema()), + ("dryRun", boolean_schema()), + ], + &[], + false, + ), _ => default_params_schema(), } } @@ -2334,7 +2406,7 @@ mod tests { #[test] fn tool_catalog_keeps_manifest_parity_count() { - assert_eq!(TOOL_NAMES.len(), 118); + assert_eq!(TOOL_NAMES.len(), 124); } #[test] diff --git a/tests/fixtures/reference-cache/Editor/AnimatorInspector.cs b/tests/fixtures/reference-cache/Editor/AnimatorInspector.cs new file mode 100644 index 0000000..e9a6c69 --- /dev/null +++ b/tests/fixtures/reference-cache/Editor/AnimatorInspector.cs @@ -0,0 +1,9 @@ +namespace UnityEditor +{ + public class AnimatorInspector + { + public void OnInspectorGUI() + { + } + } +} diff --git a/tests/fixtures/reference-cache/Runtime/Export/Animation/Animator.bindings.cs b/tests/fixtures/reference-cache/Runtime/Export/Animation/Animator.bindings.cs new file mode 100644 index 0000000..002ddf1 --- /dev/null +++ b/tests/fixtures/reference-cache/Runtime/Export/Animation/Animator.bindings.cs @@ -0,0 +1,13 @@ +namespace UnityEngine +{ + public class Animator : Behaviour + { + public void Play(string stateName) + { + } + + public void Play(string stateName, int layer) + { + } + } +}