mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-22 21:41:37 +00:00
Update logging and enhance .gitignore for Atlas directory
- Introduced a logging system using `tracing` and related crates to improve log management and output formatting. - Updated `.gitignore` to include the `.atlas/` directory, ensuring that log files and other generated content are not tracked by Git. - Enhanced project templates to include the new `.gitignore` settings for Atlas in various project types. These changes aim to improve logging capabilities and maintain a cleaner repository by ignoring unnecessary files.
This commit is contained in:
parent
b5583c4c1b
commit
3fd50df0b2
29 changed files with 1463 additions and 79 deletions
458
.cursor/plans/port_mcp_studio_tools_5135a181.plan.md
Normal file
458
.cursor/plans/port_mcp_studio_tools_5135a181.plan.md
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
---
|
||||||
|
name: Port MCP Studio Tools
|
||||||
|
overview: Port 6 MCP tools from studio-rust-mcp-server into Atlas's existing MCP infrastructure, add agent/command logging, and migrate all tool descriptions to .md files with comprehensive documentation.
|
||||||
|
todos:
|
||||||
|
- id: mcp-docs
|
||||||
|
content: Create src/web/mcp_docs/ with .md files for all 8 tools (atlas_sync, get_script, run_code, insert_model, get_console_output, get_studio_mode, start_stop_play, run_script_in_play_mode). Include comprehensive workflow guidance in atlas_sync.md and get_script.md.
|
||||||
|
status: completed
|
||||||
|
- id: rust-dispatch
|
||||||
|
content: Add PluginToolResult struct and dispatch_to_plugin() generic helper in mcp.rs. Update handle_tools_list to use include_str! for all tool descriptions. Add 6 new tools to handle_tools_call routing via dispatch_to_plugin.
|
||||||
|
status: completed
|
||||||
|
- id: rust-api-passthrough
|
||||||
|
content: Add passthrough else-clause in api.rs handle_mcp_stream_connection command routing for non-sync/non-getScript commands.
|
||||||
|
status: completed
|
||||||
|
- id: rust-logging
|
||||||
|
content: Add log::info for agent initialize, tool call (name + compact JSON args), and tool result (name + compact summary). One line per entry.
|
||||||
|
status: completed
|
||||||
|
- id: plugin-mcptools
|
||||||
|
content: Create plugin/src/McpTools/ with init.luau dispatcher, 6 tool handler files (ported from studio-rust-mcp-server), and Utils/ (ConsoleOutput, StudioModeState, GameStopUtil, DeepCopy).
|
||||||
|
status: completed
|
||||||
|
- id: plugin-mcpstream
|
||||||
|
content: Add onToolCommand callback to McpStream.lua, with tools_set lookup and generic dispatch pattern matching existing sync/getScript handlers.
|
||||||
|
status: completed
|
||||||
|
- id: plugin-lifecycle
|
||||||
|
content: Wire onToolCommand in App/init.lua McpStream constructor. Start ConsoleOutput listener on plugin init. Start GameStop monitor in Server DataModel (init.server.lua).
|
||||||
|
status: completed
|
||||||
|
- id: tests
|
||||||
|
content: "Rust: PluginToolResult serde, dispatch_to_plugin guards (command_in_progress, plugin_stream_connected), tool routing. Plugin: tool dispatch, ConsoleOutput accumulation/reset, StudioModeState tracking."
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Port MCP Studio Tools, Add Logging, Migrate to .md Docs
|
||||||
|
|
||||||
|
## Scope (from TODO lines 344-354)
|
||||||
|
|
||||||
|
1. Port 6 tools from local `D:\UserGenerated\studio-rust-mcp-server`: `run_code`, `insert_model`, `get_console_output`, `get_studio_mode`, `start_stop_play`, `run_script_in_play_mode`
|
||||||
|
2. Skip multi-studio tools (`list_studios`, `set_studio`, `get_studio`) -- not needed, Atlas has a single plugin connection
|
||||||
|
3. Add MCP logging (agent connect/disconnect, command JSON, result JSON -- compact, one line)
|
||||||
|
4. Migrate ALL tool descriptions (new + existing) to `.md` files with comprehensive workflow documentation
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
All 6 new tools are "passthrough" -- Rust forwards a JSON command to the plugin, plugin executes locally, returns a text result. No tree resolution or fsPath enrichment needed (unlike `atlas_sync` and `get_script`).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Agent
|
||||||
|
participant mcpRs as mcp.rs
|
||||||
|
participant apiRs as api.rs MCP stream
|
||||||
|
participant Plugin as Plugin McpStream
|
||||||
|
participant Handler as McpTools handler
|
||||||
|
|
||||||
|
Agent->>mcpRs: tools/call run_code {command: "..."}
|
||||||
|
Note over mcpRs: Guards: command_in_progress, plugin_stream_connected
|
||||||
|
Note over mcpRs: Log: tool call + compact args
|
||||||
|
mcpRs->>apiRs: command_tx {type: "runCode", ...}
|
||||||
|
Note over apiRs: Passthrough (no enrichment needed)
|
||||||
|
apiRs->>Plugin: WebSocket JSON
|
||||||
|
Plugin->>Handler: dispatch(type, args)
|
||||||
|
Handler->>Plugin: {requestId, status, response}
|
||||||
|
Plugin->>apiRs: WebSocket JSON
|
||||||
|
apiRs->>mcpRs: result_tx
|
||||||
|
Note over mcpRs: Log: tool result compact
|
||||||
|
mcpRs->>Agent: Tool response
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 1. Rust: Generic Passthrough Dispatch (`src/web/mcp.rs`)
|
||||||
|
|
||||||
|
Create `dispatch_to_plugin()` helper to avoid repeating the guard/send/receive/format boilerplate for each tool. Existing `handle_atlas_sync` and `handle_get_script` have tool-specific logic and stay as-is.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn dispatch_to_plugin(
|
||||||
|
id: Option<Value>,
|
||||||
|
tool_type: &str,
|
||||||
|
arguments: Value,
|
||||||
|
mcp_state: Arc<McpState>,
|
||||||
|
) -> Response<Full<Bytes>> {
|
||||||
|
// 1. command_in_progress CAS
|
||||||
|
// 2. plugin_stream_connected check
|
||||||
|
// 3. NO active_api_connections guard (passthrough tools are safe during live sync)
|
||||||
|
// 4. Build { type, requestId, args: arguments }, send via command_tx
|
||||||
|
// 5. Wait on result_rx
|
||||||
|
// 6. Parse PluginToolResult { request_id, status, response }
|
||||||
|
// 7. tool_response(id, status != "success", response)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `PluginToolResult` struct:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginToolResult {
|
||||||
|
pub request_id: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub response: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Registration (`handle_tools_list`)
|
||||||
|
|
||||||
|
Add 6 tool entries. Each tool's `description` is loaded via `include_str!("mcp_docs/<tool>.md")`. Input schemas from the studio-rust-mcp-server structs:
|
||||||
|
|
||||||
|
|
||||||
|
| Tool | inputSchema properties |
|
||||||
|
| ------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `run_code` | `command: string` (required) |
|
||||||
|
| `insert_model` | `query: string` (required) |
|
||||||
|
| `get_console_output` | none |
|
||||||
|
| `get_studio_mode` | none |
|
||||||
|
| `start_stop_play` | `mode: enum["start_play", "run_server", "stop"]` (required) |
|
||||||
|
| `run_script_in_play_mode` | `code: string` (required), `timeout: integer` (optional), `mode: enum["start_play", "run_server"]` (required) |
|
||||||
|
|
||||||
|
|
||||||
|
### Tool Routing (`handle_tools_call`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match tool_name {
|
||||||
|
"atlas_sync" => handle_atlas_sync(...).await,
|
||||||
|
"get_script" => handle_get_script(...).await,
|
||||||
|
"run_code" | "insert_model" | "get_console_output" | "get_studio_mode"
|
||||||
|
| "start_stop_play" | "run_script_in_play_mode" => {
|
||||||
|
dispatch_to_plugin(id, tool_name, arguments, mcp_state).await
|
||||||
|
}
|
||||||
|
_ => { /* unknown tool error */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `tool_name` is sent as `type` in the command JSON so the plugin knows which handler to dispatch to.
|
||||||
|
|
||||||
|
## 2. Rust: API-Side Passthrough (`src/web/api.rs`)
|
||||||
|
|
||||||
|
In `handle_mcp_stream_connection`, the command routing loop currently handles `sync` and `getScript` specially. All other command types become passthrough:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if cmd_type == "sync" && active_api_connections.load(...) > 0 {
|
||||||
|
// existing: return already_connected
|
||||||
|
} else if cmd_type == "getScript" {
|
||||||
|
// existing: resolve fsPath -> id, forward
|
||||||
|
} else {
|
||||||
|
// NEW: passthrough -- forward command directly to plugin
|
||||||
|
let json_cmd = serde_json::to_string(&cmd_value)?;
|
||||||
|
websocket.send(Message::Text(json_cmd.into())).await...;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response side: already works. When no `pending_get_script_context` exists and no `changes` array is present, the result passes through to `result_tx` unmodified.
|
||||||
|
|
||||||
|
## 3. Rust: .md Documentation Files
|
||||||
|
|
||||||
|
Create `src/web/mcp_docs/` directory with 8 `.md` files.
|
||||||
|
|
||||||
|
### `atlas_sync.md`
|
||||||
|
|
||||||
|
Comprehensive description covering:
|
||||||
|
|
||||||
|
- What it does (sync filesystem changes to Studio)
|
||||||
|
- Mode descriptions (standard/manual/fastfail/dryrun)
|
||||||
|
- Override system (id, direction, studioHash, expectedProperties)
|
||||||
|
- Sync workflow guidance:
|
||||||
|
1. Basic: `atlas_sync()` -- auto-accepts if all git-resolved, else shows Studio UI
|
||||||
|
2. Exploration: `atlas_sync(mode: "dryrun")` -- see changes without applying
|
||||||
|
3. Fast: `atlas_sync(mode: "fastfail")` -- get changes, fail immediately if unresolved
|
||||||
|
4. Conflict resolution: fastfail -> get_script -> merge -> sync with overrides
|
||||||
|
- Response format: human-readable summary + `<json>` block with structured data
|
||||||
|
- Change entry fields: path, id, direction, patchType, className, studioHash, defaultSelection, fsPath, properties
|
||||||
|
|
||||||
|
### `get_script.md`
|
||||||
|
|
||||||
|
Already has good content in the current inline description. Extract and expand:
|
||||||
|
|
||||||
|
- Purpose: read script source from Studio for conflict resolution
|
||||||
|
- Dryrun-first pattern: if no prior sync, call `atlas_sync(mode: "dryrun")` first
|
||||||
|
- Full conflict resolution workflow (numbered steps)
|
||||||
|
- Parameters: id vs fsPath, fromDraft
|
||||||
|
- Response: source, studioHash, className, instancePath
|
||||||
|
- studioHash format: SHA1 of git blob (`blob <len>\0<content>`)
|
||||||
|
|
||||||
|
### `run_code.md` through `run_script_in_play_mode.md`
|
||||||
|
|
||||||
|
Pull from the studio-rust-mcp-server `.md` files (already read above). These are concise (2-6 lines each). Adapt to Atlas context.
|
||||||
|
|
||||||
|
**Key addition to `run_code.md`**: Note that this runs in the Studio command bar context. Output from `print()`, `warn()`, `error()` is captured. Return values are included. Code has full Studio API access.
|
||||||
|
|
||||||
|
**Key addition to `start_stop_play.md`**: Note that starting play mode will temporarily disconnect the Atlas plugin connection. The plugin will reconnect automatically when play mode stops.
|
||||||
|
|
||||||
|
**Key addition to `run_script_in_play_mode.md`**: Note that this automatically starts play, runs the script, then stops play. Returns structured JSON result with success, value, error, logs, errors, duration, isTimeout.
|
||||||
|
|
||||||
|
### Use in `handle_tools_list`
|
||||||
|
|
||||||
|
Replace inline description strings with `include_str!("mcp_docs/atlas_sync.md")` etc.
|
||||||
|
|
||||||
|
## 4. Rust: MCP Logging
|
||||||
|
|
||||||
|
Add structured logging at key points in [src/web/mcp.rs](src/web/mcp.rs):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In the top-level `call()` function, after parsing the JSON-RPC request:
|
||||||
|
// 1. Log initialize (agent connect)
|
||||||
|
if method == "initialize" {
|
||||||
|
log::info!("MCP agent connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Log tool calls (compact JSON, one line)
|
||||||
|
// In handle_tools_call, before dispatching:
|
||||||
|
log::info!("MCP call: {} {}", tool_name, serde_json::to_string(&arguments).unwrap_or_default());
|
||||||
|
|
||||||
|
// 3. Log tool results (compact, one line)
|
||||||
|
// In each handler, after receiving result:
|
||||||
|
log::info!("MCP result: {} {}", tool_name, /* compact result summary */);
|
||||||
|
```
|
||||||
|
|
||||||
|
For results, keep it concise:
|
||||||
|
|
||||||
|
- `atlas_sync`: log status + change count
|
||||||
|
- `get_script`: log status + className
|
||||||
|
- Passthrough tools: log status + first ~200 chars of response
|
||||||
|
|
||||||
|
Also upgrade existing plugin stream logs:
|
||||||
|
|
||||||
|
- `"Plugin MCP stream connected"` -> keep at `info` (already there)
|
||||||
|
- `"Plugin MCP stream disconnected"` -> keep at `info` (already there)
|
||||||
|
- Add `log::info!("MCP agent disconnected")` -- but MCP is HTTP (stateless), so this maps to: no persistent connection to track. The `initialize` log suffices for connect; we don't have a reliable disconnect event. Note: if we wanted disconnect tracking, we'd need session tracking, which is out of scope.
|
||||||
|
|
||||||
|
## 5. Plugin: McpTools Module
|
||||||
|
|
||||||
|
Create `plugin/src/McpTools/` directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugin/src/McpTools/
|
||||||
|
init.luau -- Dispatcher: maps tool type -> handler function
|
||||||
|
RunCode.luau -- Ported from studio-rust-mcp-server (with fixes)
|
||||||
|
InsertModel.luau -- Ported
|
||||||
|
GetConsoleOutput.luau -- Ported
|
||||||
|
GetStudioMode.luau -- Ported
|
||||||
|
StartStopPlay.luau -- Ported
|
||||||
|
RunScriptInPlayMode.luau -- Ported
|
||||||
|
Utils/
|
||||||
|
ConsoleOutput.luau -- Ported (log accumulator)
|
||||||
|
StudioModeState.luau -- Ported (play/stop state tracking)
|
||||||
|
GameStopUtil.luau -- Ported (cross-DataModel stop via plugin settings)
|
||||||
|
DeepCopy.luau -- Ported (for run_code output serialization)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dispatcher (`McpTools/init.luau`)
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local tools = {
|
||||||
|
runCode = require(script.RunCode),
|
||||||
|
insertModel = require(script.InsertModel),
|
||||||
|
getConsoleOutput = require(script.GetConsoleOutput),
|
||||||
|
getStudioMode = require(script.GetStudioMode),
|
||||||
|
startStopPlay = require(script.StartStopPlay),
|
||||||
|
runScriptInPlayMode = require(script.RunScriptInPlayMode),
|
||||||
|
}
|
||||||
|
|
||||||
|
function McpTools.dispatch(toolType, args)
|
||||||
|
local handler = tools[toolType]
|
||||||
|
if not handler then
|
||||||
|
error("Unknown MCP tool: " .. tostring(toolType))
|
||||||
|
end
|
||||||
|
return handler(args)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Handler Adaptations
|
||||||
|
|
||||||
|
Each handler is ported from the studio-rust-mcp-server with these changes:
|
||||||
|
|
||||||
|
- Replace `require("../Utils/X")` with `require(script.Parent.Utils.X)` (Roblox instance paths)
|
||||||
|
- Remove msgpack encoding (Atlas uses JSON over WebSocket)
|
||||||
|
- `run_code.luau`: uses `loadstring`/`getfenv` (necessary for command bar emulation, add selene exception)
|
||||||
|
- `insert_model.luau`: uses `InsertService:GetFreeModels` and `game:GetObjects` (deprecated but functional)
|
||||||
|
- `GameStopUtil.luau`: needs `plugin` reference. In Atlas, pass via module-level setter or require from App context.
|
||||||
|
|
||||||
|
### GameStopUtil Plugin Reference
|
||||||
|
|
||||||
|
The studio-rust-mcp-server uses `PluginUtils.plugin = plugin` at startup. Atlas needs the same. Two approaches:
|
||||||
|
|
||||||
|
**Option A (simpler)**: In `init.server.lua`, after plugin is available, set it on the McpTools utility:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local McpToolsGameStop = require(script.McpTools.Utils.GameStopUtil)
|
||||||
|
McpToolsGameStop.setPlugin(plugin)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B**: Pass plugin as argument to handlers that need it.
|
||||||
|
|
||||||
|
Option A is simpler and matches the studio-rust-mcp-server pattern.
|
||||||
|
|
||||||
|
## 6. Plugin: McpStream Integration
|
||||||
|
|
||||||
|
In [plugin/src/McpStream.lua](plugin/src/McpStream.lua), add a generic tool handler callback:
|
||||||
|
|
||||||
|
### Constructor change
|
||||||
|
|
||||||
|
```lua
|
||||||
|
self._onToolCommand = options.onToolCommand
|
||||||
|
```
|
||||||
|
|
||||||
|
### MessageReceived handler addition
|
||||||
|
|
||||||
|
After the existing `getScript` handler (line ~129), add:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
elseif data.requestId and tools_set[data.type] then
|
||||||
|
Log.info("MCP stream: received {} command (requestId={})", data.type, data.requestId)
|
||||||
|
local resultPromise = self._onToolCommand(data.requestId, data.type, data.args or {})
|
||||||
|
resultPromise
|
||||||
|
:andThen(function(result)
|
||||||
|
if self._wsClient then
|
||||||
|
local json = HttpService:JSONEncode(result)
|
||||||
|
pcall(function() self._wsClient:Send(json) end)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
:catch(function(err)
|
||||||
|
-- send error result
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `tools_set` is a set of known passthrough tool names:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local tools_set = {
|
||||||
|
runCode = true, insertModel = true, getConsoleOutput = true,
|
||||||
|
getStudioMode = true, startStopPlay = true, runScriptInPlayMode = true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Plugin: Lifecycle Integration (`init.server.lua` + `App/init.lua`)
|
||||||
|
|
||||||
|
### ConsoleOutput Listener
|
||||||
|
|
||||||
|
In [plugin/src/init.server.lua](plugin/src/init.server.lua) (or `App/init.lua`), start the ConsoleOutput listener on plugin init:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local ConsoleOutput = require(script.McpTools.Utils.ConsoleOutput)
|
||||||
|
local connConsoleOutput = ConsoleOutput.startListener()
|
||||||
|
plugin.Unloading:Connect(function()
|
||||||
|
connConsoleOutput:Disconnect()
|
||||||
|
end)
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameStop Monitor
|
||||||
|
|
||||||
|
In `init.server.lua`, if running in Server DataModel (play mode), start the stop monitor:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local RunService = game:GetService("RunService")
|
||||||
|
if RunService:IsRunning() and RunService:IsServer() then
|
||||||
|
local GameStopUtil = require(script.McpTools.Utils.GameStopUtil)
|
||||||
|
task.spawn(GameStopUtil.monitorForStopPlay)
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### App/init.lua: Wire onToolCommand
|
||||||
|
|
||||||
|
In the McpStream constructor call, add:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
onToolCommand = function(requestId, toolType, args)
|
||||||
|
return Promise.new(function(resolve, reject)
|
||||||
|
local McpTools = require(script.McpTools)
|
||||||
|
local ok, result = pcall(McpTools.dispatch, toolType, args)
|
||||||
|
if ok then
|
||||||
|
resolve({ requestId = requestId, status = "success", response = result or "" })
|
||||||
|
else
|
||||||
|
resolve({ requestId = requestId, status = "error", response = tostring(result) })
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end,
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. PlaceId Filtering
|
||||||
|
|
||||||
|
Already handled on the MCP stream WebSocket connection (`api.rs` lines 3737-3790). Since all tool commands flow through the same stream, placeId filtering applies to all tools automatically. No additional work needed.
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **Passthrough pattern**: New tools don't need tree resolution or fsPath enrichment. They bypass `active_api_connections` guard (safe during live sync).
|
||||||
|
2. **No `active_api_connections` guard**: Unlike `atlas_sync`, the passthrough tools don't interfere with live WebSocket sync sessions. `get_script` already established this precedent.
|
||||||
|
3. `**command_in_progress` serialization**: All MCP commands (including new tools) are serialized -- only one at a time. This is a channel limitation (single command_tx + oneshot result_tx). Acceptable since MCP calls are agent-initiated and inherently sequential.
|
||||||
|
4. **Play mode timing for `start_stop_play`**: `StudioTestService:ExecutePlayModeAsync({})` returns quickly (before play fully activates). The response is sent back before the MCP stream disconnects due to plugin reload. If it was already in play mode, the call returns even faster. The 0.1s timeout detection pattern from studio-rust-mcp-server handles this correctly.
|
||||||
|
5. `**run_code` uses `loadstring`/`getfenv**`: These are technically deprecated but necessary for command bar context emulation. This is a known and accepted pattern copied from Roblox's own MCP server. Add `--!nolint DeprecatedApi` to the file.
|
||||||
|
6. **ConsoleOutput resets**: Console output accumulates between `run_code` / `run_script_in_play_mode` calls. Each call clears prior output. The `get_console_output` tool reads whatever has accumulated since the last reset.
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `src/web/mcp_docs/atlas_sync.md`
|
||||||
|
- `src/web/mcp_docs/get_script.md`
|
||||||
|
- `src/web/mcp_docs/run_code.md`
|
||||||
|
- `src/web/mcp_docs/insert_model.md`
|
||||||
|
- `src/web/mcp_docs/get_console_output.md`
|
||||||
|
- `src/web/mcp_docs/get_studio_mode.md`
|
||||||
|
- `src/web/mcp_docs/start_stop_play.md`
|
||||||
|
- `src/web/mcp_docs/run_script_in_play_mode.md`
|
||||||
|
- `plugin/src/McpTools/init.luau`
|
||||||
|
- `plugin/src/McpTools/RunCode.luau`
|
||||||
|
- `plugin/src/McpTools/InsertModel.luau`
|
||||||
|
- `plugin/src/McpTools/GetConsoleOutput.luau`
|
||||||
|
- `plugin/src/McpTools/GetStudioMode.luau`
|
||||||
|
- `plugin/src/McpTools/StartStopPlay.luau`
|
||||||
|
- `plugin/src/McpTools/RunScriptInPlayMode.luau`
|
||||||
|
- `plugin/src/McpTools/Utils/ConsoleOutput.luau`
|
||||||
|
- `plugin/src/McpTools/Utils/StudioModeState.luau`
|
||||||
|
- `plugin/src/McpTools/Utils/GameStopUtil.luau`
|
||||||
|
- `plugin/src/McpTools/Utils/DeepCopy.luau`
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- `src/web/mcp.rs` -- `dispatch_to_plugin`, `PluginToolResult`, 6 new tools in list/call, logging, `include_str!` for all docs
|
||||||
|
- `src/web/api.rs` -- passthrough else-clause in MCP stream command routing
|
||||||
|
- `plugin/src/McpStream.lua` -- `onToolCommand` callback, generic tool dispatch
|
||||||
|
- `plugin/src/App/init.lua` -- wire `onToolCommand`, ConsoleOutput listener, GameStopUtil setup
|
||||||
|
- `plugin/src/init.server.lua` -- GameStop monitor in Server DataModel, ConsoleOutput listener
|
||||||
|
|
||||||
|
## Audit Notes
|
||||||
|
|
||||||
|
### Against original TODO (lines 344-354)
|
||||||
|
|
||||||
|
- Port all basic endpoints: 6 tools ported (run_code, insert_model, get_console_output, get_studio_mode, start_stop_play, run_script_in_play_mode). Multi-studio tools excluded per "we don't need that whole studio selection system."
|
||||||
|
- Integrate into Atlas MCP: Uses same JSON-RPC/MCP stream architecture. No rmcp dependency.
|
||||||
|
- PlaceId filtering: Handled at stream level, covers all tools. No additional work.
|
||||||
|
- Pull fixed versions: Porting from local `D:\UserGenerated\studio-rust-mcp-server` which contains WebSocket, msgpack, and typed codec fixes.
|
||||||
|
- .md doc setup: All 8 tools get .md files with `include_str!`.
|
||||||
|
- Logging: Agent initialize logged, every tool call logged with compact JSON args, every result logged compact. One line per entry.
|
||||||
|
- Documentation quality: Comprehensive .md files covering workflows, parameters, response formats.
|
||||||
|
- Sync flow with get_script merging: Documented in atlas_sync.md and get_script.md.
|
||||||
|
- Dryrun for get_script: Documented in get_script.md ("call atlas_sync(mode: 'dryrun') first").
|
||||||
|
|
||||||
|
### Against atlas.mdc
|
||||||
|
|
||||||
|
- Round-trip identity: Not affected. These tools don't touch filesystem sync.
|
||||||
|
- Code quality: `dispatch_to_plugin` helper prevents code duplication. Plugin dispatcher follows DRY pattern.
|
||||||
|
- CLI syncback parity: Not affected.
|
||||||
|
- Two-way sync: New tools bypass `active_api_connections` guard (safe during live sync), same as `get_script`.
|
||||||
|
|
||||||
|
### Against first principles
|
||||||
|
|
||||||
|
- Single responsibility: Each tool handler does one thing. Generic dispatch helper handles common plumbing.
|
||||||
|
- Error handling: Guards for command_in_progress, plugin_stream_connected. pcall wraps all plugin-side execution.
|
||||||
|
- Concurrency: Serialized via `command_in_progress` atomic. No concurrent command support needed (HTTP is request-response).
|
||||||
|
- Security: PlaceId filtering inherited from stream connection.
|
||||||
|
- Separation of concerns: Rust side only routes. Plugin side executes. No cross-cutting.
|
||||||
|
|
||||||
|
### Whole-system soundness
|
||||||
|
|
||||||
|
- **MCP stream lifecycle**: Tools work only when plugin is connected (Edit mode). During play mode, stream disconnects and tools return "no plugin connected." This is correct behavior.
|
||||||
|
- **ConsoleOutput accumulation**: Starts on plugin init, persists across tool calls, reset by run_code/run_script_in_play_mode. Same lifecycle as studio-rust-mcp-server.
|
||||||
|
- **GameStop cross-DataModel**: Uses plugin settings (the only reliable cross-DataModel communication in Studio). The Server DataModel copy polls every 1s. Matches the proven studio-rust-mcp-server pattern.
|
||||||
|
- **Play mode transition**: `ExecutePlayModeAsync` returns before play activates. Response gets back to agent before stream disconnects. Agent sees "Started play" then loses plugin connection. On stop, plugin reconnects automatically.
|
||||||
|
- **No channel conflicts**: All tools use the same command_tx/result_tx channel. The `command_in_progress` lock ensures only one in-flight at a time. No deadlock risk since the lock is released on every exit path (including errors and plugin disconnection).
|
||||||
|
|
||||||
277
.cursor/plans/schema_struct_refactor_6dddb54b.plan.md
Normal file
277
.cursor/plans/schema_struct_refactor_6dddb54b.plan.md
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
---
|
||||||
|
name: Schema struct refactor
|
||||||
|
overview: Replace the ~150-line hand-written JSON Schema blob in `handle_tools_list` with `schemars::JsonSchema`-derived arg structs and a `tool_def<T>()` helper, matching the studio-rust-mcp-server pattern.
|
||||||
|
todos:
|
||||||
|
- id: add-schemars
|
||||||
|
content: Add schemars = "1" to Cargo.toml dependencies
|
||||||
|
status: completed
|
||||||
|
- id: define-arg-structs
|
||||||
|
content: Define JsonSchema arg structs/enums for all 8 tools in mcp.rs, add JsonSchema to SyncOverride
|
||||||
|
status: completed
|
||||||
|
- id: tool-def-helper
|
||||||
|
content: "Create tool_def<T: JsonSchema>() helper function"
|
||||||
|
status: completed
|
||||||
|
- id: replace-tools-list
|
||||||
|
content: Replace handle_tools_list JSON blob with tool_def calls
|
||||||
|
status: completed
|
||||||
|
- id: update-tests
|
||||||
|
content: Update tool_schema_tests and handler_tests for schemars-generated schemas
|
||||||
|
status: completed
|
||||||
|
isProject: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor `handle_tools_list` to Use `schemars` Struct-Derived Schemas
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`handle_tools_list` in [src/web/mcp.rs](src/web/mcp.rs) (lines 297-448) is a ~150-line hand-written `serde_json::json!` blob with manually maintained `inputSchema` objects for all 8 tools. Adding or modifying tool parameters requires editing nested JSON, which is error-prone and hard to review.
|
||||||
|
|
||||||
|
The studio-rust-mcp-server solves this with `#[derive(schemars::JsonSchema)]` on arg structs -- the struct definition IS the schema. That's what we want here.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Add `schemars` (v1, matching studio-rust-mcp-server) and define arg structs per tool. A `tool_def<T: JsonSchema>()` helper generates the full tool entry from (name, description, schema). The existing `SyncOverride` struct gets `JsonSchema` added to its derives.
|
||||||
|
|
||||||
|
### Before (current)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn handle_tools_list(id: Option<Value>) -> Response<Full<Bytes>> {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "run_code",
|
||||||
|
"description": include_str!("mcp_docs/run_code.md"),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Luau code to execute..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// ... 7 more tools, ~20 lines each ...
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (target)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
struct RunCodeArgs {
|
||||||
|
/// Luau code to execute in Roblox Studio.
|
||||||
|
command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_tools_list(id: Option<Value>) -> Response<Full<Bytes>> {
|
||||||
|
let tools = vec![
|
||||||
|
tool_def::<RunCodeArgs>("run_code", include_str!("mcp_docs/run_code.md")),
|
||||||
|
// ... 7 more, one line each ...
|
||||||
|
];
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Add `schemars` dependency
|
||||||
|
|
||||||
|
In [Cargo.toml](Cargo.toml), add after the `serde_json` line (line 93):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
schemars = "1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Define arg structs and enums
|
||||||
|
|
||||||
|
In [src/web/mcp.rs](src/web/mcp.rs), add after the existing `PluginToolResult` struct. These are schema-only structs -- they define the shape of each tool's arguments. `schemars` reads `#[doc = "..."]` comments as JSON Schema `description` fields.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
#[schemars(rename_all = "camelCase")]
|
||||||
|
struct AtlasSyncArgs {
|
||||||
|
/// Sync mode. 'standard' (default): auto-accept if all changes are
|
||||||
|
/// git-resolved, otherwise show Studio UI. 'manual': always show
|
||||||
|
/// Studio UI. 'fastfail': fail immediately if any changes are
|
||||||
|
/// unresolved. 'dryrun': return what would change without applying.
|
||||||
|
mode: Option<SyncMode>,
|
||||||
|
/// Auto-accept directives for specific instance changes, matched
|
||||||
|
/// by id from a previous sync response.
|
||||||
|
overrides: Option<Vec<SyncOverride>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
#[schemars(rename_all = "snake_case")]
|
||||||
|
enum SyncMode {
|
||||||
|
Standard,
|
||||||
|
Manual,
|
||||||
|
Fastfail,
|
||||||
|
Dryrun,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
#[schemars(rename_all = "camelCase")]
|
||||||
|
struct GetScriptArgs {
|
||||||
|
/// Server Ref (32-char hex) from a previous atlas_sync response.
|
||||||
|
/// Preferred over fsPath.
|
||||||
|
id: Option<String>,
|
||||||
|
/// Atlas filesystem path relative to project root
|
||||||
|
/// (e.g. 'src/server/MyScript.server.luau'). Resolved server-side
|
||||||
|
/// to an instance id.
|
||||||
|
fs_path: Option<String>,
|
||||||
|
/// If true, read unsaved editor content instead of the saved Source
|
||||||
|
/// property. Default: false.
|
||||||
|
from_draft: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
struct RunCodeArgs {
|
||||||
|
/// Luau code to execute in Roblox Studio.
|
||||||
|
command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
struct InsertModelArgs {
|
||||||
|
/// Search query for the model on the Roblox Creator Store.
|
||||||
|
query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
struct NoArgs {}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
#[schemars(rename_all = "snake_case")]
|
||||||
|
enum StartStopPlayMode {
|
||||||
|
StartPlay,
|
||||||
|
RunServer,
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
struct StartStopPlayArgs {
|
||||||
|
/// Play mode action.
|
||||||
|
mode: StartStopPlayMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
#[schemars(rename_all = "snake_case")]
|
||||||
|
enum PlayTestMode {
|
||||||
|
StartPlay,
|
||||||
|
RunServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(JsonSchema)]
|
||||||
|
struct RunScriptInPlayModeArgs {
|
||||||
|
/// Luau code to run inside a play session.
|
||||||
|
code: String,
|
||||||
|
/// Timeout in seconds. Defaults to 100.
|
||||||
|
timeout: Option<u32>,
|
||||||
|
/// Play mode to start.
|
||||||
|
mode: PlayTestMode,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `JsonSchema` to the existing `SyncOverride` struct (line ~29). It already has `Serialize, Deserialize` with `#[serde(rename_all = "camelCase")]` which schemars will pick up:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SyncOverride { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create `tool_def` helper
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn tool_def<T: JsonSchema>(name: &str, description: &str) -> Value {
|
||||||
|
let schema = schemars::schema_for!(T);
|
||||||
|
let mut schema_value = serde_json::to_value(schema).unwrap_or_default();
|
||||||
|
if let Some(obj) = schema_value.as_object_mut() {
|
||||||
|
obj.remove("$schema");
|
||||||
|
obj.remove("title");
|
||||||
|
}
|
||||||
|
serde_json::json!({
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"inputSchema": schema_value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `schema_for!` is a macro so it works with concrete types. We call `tool_def::<RunCodeArgs>(...)` etc. If schemars 1.x doesn't support `schema_for!` in this context, use `SchemaGenerator::default().into_root_schema_for::<T>()` instead.
|
||||||
|
|
||||||
|
### 4. Replace `handle_tools_list` body
|
||||||
|
|
||||||
|
The 150-line JSON blob becomes:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn handle_tools_list(id: Option<Value>) -> Response<Full<Bytes>> {
|
||||||
|
let tools = vec![
|
||||||
|
tool_def::<AtlasSyncArgs>("atlas_sync", include_str!("mcp_docs/atlas_sync.md")),
|
||||||
|
tool_def::<GetScriptArgs>("get_script", include_str!("mcp_docs/get_script.md")),
|
||||||
|
tool_def::<RunCodeArgs>("run_code", include_str!("mcp_docs/run_code.md")),
|
||||||
|
tool_def::<InsertModelArgs>("insert_model", include_str!("mcp_docs/insert_model.md")),
|
||||||
|
tool_def::<NoArgs>("get_console_output", include_str!("mcp_docs/get_console_output.md")),
|
||||||
|
tool_def::<NoArgs>("get_studio_mode", include_str!("mcp_docs/get_studio_mode.md")),
|
||||||
|
tool_def::<StartStopPlayArgs>("start_stop_play", include_str!("mcp_docs/start_stop_play.md")),
|
||||||
|
tool_def::<RunScriptInPlayModeArgs>("run_script_in_play_mode", include_str!("mcp_docs/run_script_in_play_mode.md")),
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = serde_json::json!({ "tools": tools });
|
||||||
|
let resp = JsonRpcResponse::success(id, result);
|
||||||
|
json_response(&resp, StatusCode::OK)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Update tests
|
||||||
|
|
||||||
|
The `tool_schema_tests` module tests check for specific schema structures (e.g. `["inputSchema"]["required"]`, `["inputSchema"]["properties"]["mode"]["enum"]`). The schemars-generated schemas should produce the same logical structure:
|
||||||
|
|
||||||
|
- Required fields (non-`Option`) -> appears in `required` array
|
||||||
|
- `Option<T>` fields -> absent from `required`
|
||||||
|
- Enums with `rename_all = "snake_case"` -> `{"enum": ["start_play", ...]}`
|
||||||
|
- String fields -> `{"type": "string"}`
|
||||||
|
- `u32` -> `{"type": "integer"}` (or `{"type": "integer", "minimum": 0}`)
|
||||||
|
|
||||||
|
The tests may need minor adjustments depending on exact schemars output:
|
||||||
|
|
||||||
|
- schemars may add `"format": "uint32"` to the `timeout` field
|
||||||
|
- schemars may produce `"required"` as absent (not `null`) for empty-args tools -- current test checks `is_null()`, may need to check `is_null() || required.is_empty()`
|
||||||
|
- Enum schemas might be nested under `$defs` with `$ref` -- if so, configure `schemars` to inline subschemas
|
||||||
|
|
||||||
|
The `handler_tests::tools_list_returns_all_tools` and `get_script_tests::tools_list_includes_get_script` tests check tool count and presence -- those should pass unchanged.
|
||||||
|
|
||||||
|
## What stays the same
|
||||||
|
|
||||||
|
### `.md` tool descriptions (our improvement)
|
||||||
|
|
||||||
|
The studio-rust-mcp-server uses inline `#[doc = include_str!("run_code.md")]` on the tool method. We use `include_str!("mcp_docs/<tool>.md")` in the `tool_def` call. Same `include_str!` pattern, but our `.md` files are more comprehensive (workflow guidance, response formats, conflict resolution steps). This is our improvement over their version and stays as-is.
|
||||||
|
|
||||||
|
### Error handling (already equivalent)
|
||||||
|
|
||||||
|
Our error handling already mirrors the studio-rust-mcp-server's rmcp pattern:
|
||||||
|
|
||||||
|
|
||||||
|
| Our pattern | Their rmcp equivalent | When used |
|
||||||
|
| ----------------------------------------- | ---------------------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| `tool_response(id, false, text)` | `CallToolResult::success(vec![Content::text(text)])` | Tool completed successfully |
|
||||||
|
| `tool_response(id, true, msg)` | `CallToolResult::error(vec![Content::text(msg)])` | Recoverable tool error (tells the agent what went wrong) |
|
||||||
|
| `JsonRpcResponse::error(id, -32602, msg)` | `ErrorData::invalid_params(msg, None)` | Protocol-level error (bad params, unknown tool) |
|
||||||
|
|
||||||
|
|
||||||
|
No changes needed here. Their `error.rs` is a `color_eyre`/axum wrapper that doesn't apply to our hyper-based architecture.
|
||||||
|
|
||||||
|
### No file separation
|
||||||
|
|
||||||
|
All tool definitions, arg structs, and the `tool_def` helper stay in [src/web/mcp.rs](src/web/mcp.rs). We do not split into one-file-per-tool like the studio-rust-mcp-server does -- we only have 8 tools and the overhead of separate files isn't justified.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- [Cargo.toml](Cargo.toml) -- add `schemars = "1"`
|
||||||
|
- [src/web/mcp.rs](src/web/mcp.rs) -- add `JsonSchema` import, arg structs, `tool_def` helper, replace `handle_tools_list` body, add `JsonSchema` derive to `SyncOverride`, update tests
|
||||||
|
|
||||||
|
|
@ -27,6 +27,7 @@ alwaysApply: true
|
||||||
| `syncbackRules` | SyncbackRules | No | Syncback configuration |
|
| `syncbackRules` | SyncbackRules | No | Syncback configuration |
|
||||||
| `syncScriptsOnly` | bool | No | Only sync script instances (default: false) |
|
| `syncScriptsOnly` | bool | No | Only sync script instances (default: false) |
|
||||||
| `ignoreHiddenServices` | bool | No | Ignore internal services (default: true) |
|
| `ignoreHiddenServices` | bool | No | Ignore internal services (default: true) |
|
||||||
|
| `fileLogLevel` | string | No | File logging level: "trace" (default), "debug", "info", "warn", "error", "none" |
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
|
@ -43,7 +44,8 @@ alwaysApply: true
|
||||||
"syncRules": [/* SyncRule */],
|
"syncRules": [/* SyncRule */],
|
||||||
"syncbackRules": { /* SyncbackRules */ },
|
"syncbackRules": { /* SyncbackRules */ },
|
||||||
"syncScriptsOnly": false,
|
"syncScriptsOnly": false,
|
||||||
"ignoreHiddenServices": true
|
"ignoreHiddenServices": true,
|
||||||
|
"fileLogLevel": "trace"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -29,3 +29,6 @@
|
||||||
|
|
||||||
# Environment secrets
|
# Environment secrets
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Atlas log directory
|
||||||
|
/.atlas/
|
||||||
|
|
|
||||||
32
Cargo.lock
generated
32
Cargo.lock
generated
|
|
@ -201,7 +201,8 @@ dependencies = [
|
||||||
"csv",
|
"csv",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
"env_logger 0.11.9",
|
"filetime",
|
||||||
|
"flate2",
|
||||||
"fs-err",
|
"fs-err",
|
||||||
"futures",
|
"futures",
|
||||||
"gix",
|
"gix",
|
||||||
|
|
@ -252,6 +253,10 @@ dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"tracing-appender",
|
||||||
|
"tracing-log",
|
||||||
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"winreg 0.55.0",
|
"winreg 0.55.0",
|
||||||
|
|
@ -4869,9 +4874,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-appender"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-channel",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time 0.3.47",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.31"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2 1.0.106",
|
||||||
|
"quote 1.0.44",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.36"
|
version = "0.1.36"
|
||||||
|
|
@ -4906,6 +4935,7 @@ dependencies = [
|
||||||
"sharded-slab",
|
"sharded-slab",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thread_local",
|
"thread_local",
|
||||||
|
"time 0.3.47",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
"tracing-log",
|
"tracing-log",
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ backtrace = "0.3.76"
|
||||||
bincode = { version = "2", features = ["serde"] }
|
bincode = { version = "2", features = ["serde"] }
|
||||||
crossbeam-channel = "0.5.15"
|
crossbeam-channel = "0.5.15"
|
||||||
csv = "1.4.0"
|
csv = "1.4.0"
|
||||||
env_logger = "0.11"
|
flate2 = "1"
|
||||||
fs-err = "3"
|
fs-err = "3"
|
||||||
futures = "0.3.32"
|
futures = "0.3.32"
|
||||||
globset = "0.4.18"
|
globset = "0.4.18"
|
||||||
|
|
@ -99,6 +99,10 @@ toml = "0.9"
|
||||||
termcolor = "1.4.1"
|
termcolor = "1.4.1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros", "sync"] }
|
tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros", "sync"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-appender = "0.2"
|
||||||
|
tracing-log = "0.2"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] }
|
||||||
uuid = { version = "1.21.0", features = ["v4", "serde"] }
|
uuid = { version = "1.21.0", features = ["v4", "serde"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
clap_complete = "4"
|
clap_complete = "4"
|
||||||
|
|
@ -128,6 +132,7 @@ maplit = "1.0.2"
|
||||||
semver = "1.0.27"
|
semver = "1.0.27"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
filetime = "0.2"
|
||||||
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
|
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
|
||||||
|
|
||||||
criterion = "0.8"
|
criterion = "0.8"
|
||||||
|
|
|
||||||
14
TODO.txt
14
TODO.txt
|
|
@ -320,7 +320,7 @@ in all 3 matchers, simply randomize the order of the children - this will break
|
||||||
can we allow the terminal to stay so i can like run some git commands myself without having to make a whole new terminal?
|
can we allow the terminal to stay so i can like run some git commands myself without having to make a whole new terminal?
|
||||||
|
|
||||||
[mcp]
|
[mcp]
|
||||||
* sync
|
* DONE: sync
|
||||||
- add modes:
|
- add modes:
|
||||||
- standard (default): what we currently have where if there are changes it waits for the user to act
|
- standard (default): what we currently have where if there are changes it waits for the user to act
|
||||||
- manual: user must always interact
|
- manual: user must always interact
|
||||||
|
|
@ -330,7 +330,7 @@ can we allow the terminal to stay so i can like run some git commands myself wit
|
||||||
- allow the agent to specify patches it wants to accept automatically
|
- allow the agent to specify patches it wants to accept automatically
|
||||||
- for sciprts, need to include a hash of the file ON roblox studio currently to signify there wasn't MORE new changes in the mid-term
|
- for sciprts, need to include a hash of the file ON roblox studio currently to signify there wasn't MORE new changes in the mid-term
|
||||||
- for properties, just include what we are accepting
|
- for properties, just include what we are accepting
|
||||||
* get_script PATH [from draft?]
|
* DONE: get_script PATH [from draft?]
|
||||||
- allows the agent to read a script currently on studio
|
- allows the agent to read a script currently on studio
|
||||||
- the core purpose of this endpoint is to utilize with sync when failures occur due to other people changing things, and then allow us to grab their changes, compare against the previous git version we have and then integrate their changes and then sending another sync with the expected hash of the file on roblox to allow it to auto-accept (via an mcp override we specify in the json of the request to sync)
|
- the core purpose of this endpoint is to utilize with sync when failures occur due to other people changing things, and then allow us to grab their changes, compare against the previous git version we have and then integrate their changes and then sending another sync with the expected hash of the file on roblox to allow it to auto-accept (via an mcp override we specify in the json of the request to sync)
|
||||||
- by default directly reads the Source property on the script
|
- by default directly reads the Source property on the script
|
||||||
|
|
@ -341,14 +341,14 @@ can we allow the terminal to stay so i can like run some git commands myself wit
|
||||||
- we should steal this system fully: D:\UserGenerated\studio-rust-mcp-server\plugin\Utils\Paths.luau
|
- we should steal this system fully: D:\UserGenerated\studio-rust-mcp-server\plugin\Utils\Paths.luau
|
||||||
- in the sync feedback it gives, we should include Paths.GetRelativePath so the agent is able to find it via the read_script ALONGSIDE the current info we can give so it can also find it on the Atlas filesystem
|
- in the sync feedback it gives, we should include Paths.GetRelativePath so the agent is able to find it via the read_script ALONGSIDE the current info we can give so it can also find it on the Atlas filesystem
|
||||||
- investigate this further, because i just want to give the agent the best possible way to query roblox instances, and also atlas instances on the filesystem. if using this Paths module is an overstep and we can accomplish it with a fully atlas-based approach, that's fine - aslong as it works CONSISTENTLY and is simple.
|
- investigate this further, because i just want to give the agent the best possible way to query roblox instances, and also atlas instances on the filesystem. if using this Paths module is an overstep and we can accomplish it with a fully atlas-based approach, that's fine - aslong as it works CONSISTENTLY and is simple.
|
||||||
* port all of the basic MCP endpoints offered via https://github.com/Roblox/studio-rust-mcp-server
|
* DONE: port all of the basic MCP endpoints offered via https://github.com/Roblox/studio-rust-mcp-server
|
||||||
- integrate them into how we've done our mcp in atlas
|
- integrate them into how we've done our mcp in atlas
|
||||||
- make sure to handle the placeid filtering like we already do when establishing the connection
|
- make sure to handle the placeid filtering like we already do when establishing the connection
|
||||||
- we've done some actual fixes over at D:\UserGenerated\studio-rust-mcp-server on these, we should probably pull those fixed versions! we don't need that whole studio selection system from over there, though.
|
- we've done some actual fixes over at D:\UserGenerated\studio-rust-mcp-server on these, we should probably pull those fixed versions! we don't need that whole studio selection system from over there, though.
|
||||||
- we don't need to restructure ours to be like this, but i like the .md doc setup.
|
- we don't need to restructure ours to be like this, but i like the .md doc setup.
|
||||||
* add logging for all agent connect/disconnects in the terminal
|
* DONE: add logging for all agent connect/disconnects in the terminal
|
||||||
- also add logging for commands (including the json) it runs and the resulting json. try to keep this on one line please, json compact.
|
- also add logging for commands (including the json) it runs and the resulting json. try to keep this on one line please, json compact.
|
||||||
* ensure all mcp route documentation is well formed and gives the agent knowledge on how to use this system to the fullest.
|
* DONE: ensure all mcp route documentation is well formed and gives the agent knowledge on how to use this system to the fullest.
|
||||||
- I did .md based docs over with my D:\UserGenerated\studio-rust-mcp-server codebase, we should probably switch to this as it's cleaner
|
- I did .md based docs over with my D:\UserGenerated\studio-rust-mcp-server codebase, we should probably switch to this as it's cleaner
|
||||||
- Instruct on good sync flow, with get_script merging.
|
- Instruct on good sync flow, with get_script merging.
|
||||||
- If user requests get_script and we havn't sync'd yet, just do a dryrun sync to get the instance id map going.
|
- If user requests get_script and we havn't sync'd yet, just do a dryrun sync to get the instance id map going.
|
||||||
|
|
@ -357,7 +357,8 @@ can we allow the terminal to stay so i can like run some git commands myself wit
|
||||||
- ensure this doesn't interrupt the connection of the mcp, since i think we shutdown the api? we should just not shutdown the api or something
|
- ensure this doesn't interrupt the connection of the mcp, since i think we shutdown the api? we should just not shutdown the api or something
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
* I want to add verbose logging .atlas in the repository
|
* I want to add verbose logging .atlas in the directory being synced from
|
||||||
|
- Need to add to project template .gitignore for .atlas/
|
||||||
* We should do rolling logs, and it should compress them automatically
|
* We should do rolling logs, and it should compress them automatically
|
||||||
* Will the existing logging infra take care of this?
|
* Will the existing logging infra take care of this?
|
||||||
* I want to have it include trace logging
|
* I want to have it include trace logging
|
||||||
|
|
@ -370,7 +371,6 @@ can we allow the terminal to stay so i can like run some git commands myself wit
|
||||||
- Listen to MessageOut to get the log info we are wanting.
|
- Listen to MessageOut to get the log info we are wanting.
|
||||||
- Is this actually the one we want? I might be confused, check the docs, this looks new.
|
- Is this actually the one we want? I might be confused, check the docs, this looks new.
|
||||||
* The goal with these trace logging is so that when others are using this tool, we can actually debug real issues by having them send me their logs.
|
* The goal with these trace logging is so that when others are using this tool, we can actually debug real issues by having them send me their logs.
|
||||||
* It might be worth actually having a log entry per-sync?
|
|
||||||
|
|
||||||
[sync]
|
[sync]
|
||||||
right now sync is running REALLY slow
|
right now sync is running REALLY slow
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,6 @@
|
||||||
/*.rbxl.lock
|
/*.rbxl.lock
|
||||||
|
|
||||||
sourcemap.json
|
sourcemap.json
|
||||||
|
|
||||||
|
# atlas
|
||||||
|
/.atlas/
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,4 @@
|
||||||
|
|
||||||
# atlas
|
# atlas
|
||||||
/atlas.exe
|
/atlas.exe
|
||||||
|
/.atlas/
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,6 @@
|
||||||
/{project_name}.rbxm
|
/{project_name}.rbxm
|
||||||
|
|
||||||
sourcemap.json
|
sourcemap.json
|
||||||
|
|
||||||
|
# atlas
|
||||||
|
/.atlas/
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ local Packages = Rojo.Packages
|
||||||
local Log = require(Packages.Log)
|
local Log = require(Packages.Log)
|
||||||
|
|
||||||
local Config = require(script.Parent.Config)
|
local Config = require(script.Parent.Config)
|
||||||
|
local ConsoleOutput = require(script.Parent.McpTools.Utils.ConsoleOutput)
|
||||||
local strict = require(script.Parent.strict)
|
local strict = require(script.Parent.strict)
|
||||||
|
|
||||||
local RECONNECT_INTERVAL = 5
|
local RECONNECT_INTERVAL = 5
|
||||||
|
|
@ -62,6 +63,7 @@ function McpStream:stop()
|
||||||
end
|
end
|
||||||
|
|
||||||
function McpStream:_disconnect()
|
function McpStream:_disconnect()
|
||||||
|
ConsoleOutput.onLogMessage = nil
|
||||||
if self._wsClient then
|
if self._wsClient then
|
||||||
pcall(function()
|
pcall(function()
|
||||||
self._wsClient:Destroy()
|
self._wsClient:Destroy()
|
||||||
|
|
@ -95,6 +97,19 @@ function McpStream:_tryConnect()
|
||||||
wsClient:Send(greeting)
|
wsClient:Send(greeting)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
ConsoleOutput.onLogMessage = function(message: string, messageType: Enum.MessageType)
|
||||||
|
if not self._wsClient then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
self._wsClient:Send(HttpService:JSONEncode({
|
||||||
|
type = "roblox_log",
|
||||||
|
message = message,
|
||||||
|
level = messageType.Value,
|
||||||
|
}))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
local done = false
|
local done = false
|
||||||
local closed, errored, received
|
local closed, errored, received
|
||||||
|
|
||||||
|
|
@ -195,6 +210,7 @@ function McpStream:_tryConnect()
|
||||||
|
|
||||||
closed = wsClient.Closed:Connect(function()
|
closed = wsClient.Closed:Connect(function()
|
||||||
done = true
|
done = true
|
||||||
|
ConsoleOutput.onLogMessage = nil
|
||||||
closed:Disconnect()
|
closed:Disconnect()
|
||||||
errored:Disconnect()
|
errored:Disconnect()
|
||||||
received:Disconnect()
|
received:Disconnect()
|
||||||
|
|
@ -203,6 +219,7 @@ function McpStream:_tryConnect()
|
||||||
|
|
||||||
errored = wsClient.Error:Connect(function(_code, _msg)
|
errored = wsClient.Error:Connect(function(_code, _msg)
|
||||||
done = true
|
done = true
|
||||||
|
ConsoleOutput.onLogMessage = nil
|
||||||
closed:Disconnect()
|
closed:Disconnect()
|
||||||
errored:Disconnect()
|
errored:Disconnect()
|
||||||
received:Disconnect()
|
received:Disconnect()
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ local MAX_OUTPUT = 10000
|
||||||
|
|
||||||
local ConsoleOutput = {
|
local ConsoleOutput = {
|
||||||
outputMessage = "",
|
outputMessage = "",
|
||||||
|
onLogMessage = nil :: ((message: string, messageType: Enum.MessageType) -> ())?,
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConsoleOutput.startListener(): RBXScriptConnection
|
function ConsoleOutput.startListener(): RBXScriptConnection
|
||||||
local conn = LogService.MessageOut:Connect(function(message: string, _: Enum.MessageType)
|
local conn = LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
|
||||||
if #ConsoleOutput.outputMessage + #message <= MAX_OUTPUT then
|
if #ConsoleOutput.outputMessage + #message <= MAX_OUTPUT then
|
||||||
if #ConsoleOutput.outputMessage == 0 then
|
if #ConsoleOutput.outputMessage == 0 then
|
||||||
ConsoleOutput.outputMessage = message
|
ConsoleOutput.outputMessage = message
|
||||||
|
|
@ -17,6 +18,11 @@ function ConsoleOutput.startListener(): RBXScriptConnection
|
||||||
ConsoleOutput.outputMessage = ConsoleOutput.outputMessage .. "\n" .. message
|
ConsoleOutput.outputMessage = ConsoleOutput.outputMessage .. "\n" .. message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local callback = ConsoleOutput.onLogMessage
|
||||||
|
if callback then
|
||||||
|
callback(message, messageType)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
return conn
|
return conn
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ mod studio;
|
||||||
mod syncback;
|
mod syncback;
|
||||||
mod upload;
|
mod upload;
|
||||||
|
|
||||||
use std::{borrow::Cow, env, path::Path, str::FromStr};
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
str::FromStr,
|
||||||
|
};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
@ -110,16 +115,6 @@ impl From<ColorChoice> for termcolor::ColorChoice {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ColorChoice> for env_logger::WriteStyle {
|
|
||||||
fn from(value: ColorChoice) -> Self {
|
|
||||||
match value {
|
|
||||||
ColorChoice::Auto => env_logger::WriteStyle::Auto,
|
|
||||||
ColorChoice::Always => env_logger::WriteStyle::Always,
|
|
||||||
ColorChoice::Never => env_logger::WriteStyle::Never,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
#[error("Invalid color choice '{attempted}'. Valid values are: auto, always, never")]
|
#[error("Invalid color choice '{attempted}'. Valid values are: auto, always, never")]
|
||||||
pub struct ColorChoiceParseError {
|
pub struct ColorChoiceParseError {
|
||||||
|
|
@ -146,10 +141,62 @@ pub enum Subcommand {
|
||||||
Pull(SyncbackCommand),
|
Pull(SyncbackCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn resolve_path(path: &Path) -> Cow<'_, Path> {
|
impl Subcommand {
|
||||||
|
pub fn project_path(&self) -> Option<&Path> {
|
||||||
|
match self {
|
||||||
|
Subcommand::Clone(cmd) => cmd.path.as_deref(),
|
||||||
|
Subcommand::Serve(cmd) => Some(&cmd.project),
|
||||||
|
Subcommand::Build(cmd) => Some(&cmd.project),
|
||||||
|
Subcommand::Upload(cmd) => Some(&cmd.project),
|
||||||
|
Subcommand::Sourcemap(cmd) => Some(&cmd.project),
|
||||||
|
Subcommand::FmtProject(cmd) => Some(&cmd.project),
|
||||||
|
Subcommand::Studio(cmd) => Some(&cmd.project),
|
||||||
|
Subcommand::Syncback(cmd) | Subcommand::Pull(cmd) => Some(&cmd.project),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Subcommand::Clone(_) => "clone",
|
||||||
|
Subcommand::Completions(_) => "completions",
|
||||||
|
Subcommand::Init(_) => "init",
|
||||||
|
Subcommand::Serve(_) => "serve",
|
||||||
|
Subcommand::Build(_) => "build",
|
||||||
|
Subcommand::Upload(_) => "upload",
|
||||||
|
Subcommand::Sourcemap(_) => "sourcemap",
|
||||||
|
Subcommand::FmtProject(_) => "fmt-project",
|
||||||
|
Subcommand::Cursor(_) => "cursor",
|
||||||
|
Subcommand::Doc(_) => "doc",
|
||||||
|
Subcommand::Plugin(_) => "plugin",
|
||||||
|
Subcommand::Studio(_) => "studio",
|
||||||
|
Subcommand::Syncback(_) => "syncback",
|
||||||
|
Subcommand::Pull(_) => "pull",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_path(path: &Path) -> Cow<'_, Path> {
|
||||||
if path.is_absolute() {
|
if path.is_absolute() {
|
||||||
Cow::Borrowed(path)
|
Cow::Borrowed(path)
|
||||||
} else {
|
} else {
|
||||||
Cow::Owned(env::current_dir().unwrap().join(path))
|
Cow::Owned(env::current_dir().unwrap().join(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves a project path (which may point to a file) to its parent directory.
|
||||||
|
pub fn resolve_project_dir(project_path: &Path) -> PathBuf {
|
||||||
|
let resolved = resolve_path(project_path);
|
||||||
|
let resolved = resolved.as_ref();
|
||||||
|
|
||||||
|
if resolved.is_file() {
|
||||||
|
resolved
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| resolved.to_path_buf())
|
||||||
|
} else if resolved.as_os_str().is_empty() {
|
||||||
|
env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||||
|
} else {
|
||||||
|
resolved.to_path_buf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ mod change_processor;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
mod glob;
|
mod glob;
|
||||||
mod json;
|
mod json;
|
||||||
|
pub mod logging;
|
||||||
mod lua_ast;
|
mod lua_ast;
|
||||||
mod message_queue;
|
mod message_queue;
|
||||||
mod multimap;
|
mod multimap;
|
||||||
|
|
|
||||||
521
src/logging.rs
Normal file
521
src/logging.rs
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
use std::{
|
||||||
|
io::{self, IsTerminal, Write},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
use tracing_subscriber::{
|
||||||
|
fmt::{self, time::UtcTime},
|
||||||
|
layer::SubscriberExt,
|
||||||
|
util::SubscriberInitExt,
|
||||||
|
EnvFilter, Layer,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::cli::ColorChoice;
|
||||||
|
|
||||||
|
pub struct LogGuard {
|
||||||
|
_file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_logging(
|
||||||
|
verbosity: u8,
|
||||||
|
color: ColorChoice,
|
||||||
|
project_dir: Option<&Path>,
|
||||||
|
file_log_level: Option<tracing::level_filters::LevelFilter>,
|
||||||
|
command_name: &str,
|
||||||
|
) -> LogGuard {
|
||||||
|
tracing_log::LogTracer::init().expect("Failed to set log tracer");
|
||||||
|
|
||||||
|
let console_filter = match verbosity {
|
||||||
|
0 => "info",
|
||||||
|
1 => "info,librojo=debug",
|
||||||
|
2 => "info,librojo=trace",
|
||||||
|
_ => "trace",
|
||||||
|
};
|
||||||
|
|
||||||
|
let console_env_filter =
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(console_filter));
|
||||||
|
|
||||||
|
let use_ansi = match color {
|
||||||
|
ColorChoice::Always => true,
|
||||||
|
ColorChoice::Never => false,
|
||||||
|
ColorChoice::Auto => io::stderr().is_terminal(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let console_layer = fmt::layer()
|
||||||
|
.with_writer(io::stderr)
|
||||||
|
.with_ansi(use_ansi)
|
||||||
|
.without_time()
|
||||||
|
.with_target(false)
|
||||||
|
.with_thread_names(false)
|
||||||
|
.with_level(true)
|
||||||
|
.with_filter(console_env_filter);
|
||||||
|
|
||||||
|
let mut file_guard: Option<tracing_appender::non_blocking::WorkerGuard> = None;
|
||||||
|
|
||||||
|
let file_layer = if let (Some(dir), Some(level)) = (project_dir, file_log_level) {
|
||||||
|
let log_dir = dir.join(".atlas").join("logs");
|
||||||
|
|
||||||
|
match std::fs::create_dir_all(&log_dir) {
|
||||||
|
Ok(()) => {
|
||||||
|
compress_old_logs(&log_dir, command_name);
|
||||||
|
|
||||||
|
let file_appender = tracing_appender::rolling::Builder::new()
|
||||||
|
.rotation(tracing_appender::rolling::Rotation::DAILY)
|
||||||
|
.filename_prefix(command_name)
|
||||||
|
.filename_suffix("log")
|
||||||
|
.build(&log_dir)
|
||||||
|
.expect("Failed to create rolling file appender");
|
||||||
|
|
||||||
|
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
file_guard = Some(guard);
|
||||||
|
|
||||||
|
let file_filter = EnvFilter::new(level.to_string());
|
||||||
|
|
||||||
|
let layer = fmt::layer()
|
||||||
|
.with_writer(non_blocking)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_timer(UtcTime::rfc_3339())
|
||||||
|
.with_target(true)
|
||||||
|
.with_thread_names(true)
|
||||||
|
.with_level(true)
|
||||||
|
.with_filter(file_filter);
|
||||||
|
|
||||||
|
Some(layer)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: could not create log directory {}: {e}",
|
||||||
|
log_dir.display()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(console_layer)
|
||||||
|
.with(file_layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
LogGuard {
|
||||||
|
_file_guard: file_guard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress_old_logs(log_dir: &Path, command_name: &str) {
|
||||||
|
let today = {
|
||||||
|
let now = std::time::SystemTime::now();
|
||||||
|
let since_epoch = now
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let days = since_epoch.as_secs() / 86400;
|
||||||
|
days
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(log_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let file_name = match path.file_name().and_then(|n| n.to_str()) {
|
||||||
|
Some(n) => n.to_owned(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if file_name.ends_with(".log.gz") {
|
||||||
|
if let Ok(meta) = entry.metadata() {
|
||||||
|
if let Ok(modified) = meta.modified() {
|
||||||
|
let age_days = modified
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| today.saturating_sub(d.as_secs() / 86400))
|
||||||
|
.unwrap_or(0);
|
||||||
|
if age_days > 7 {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !file_name.ends_with(".log") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !file_name.starts_with(command_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(meta) = entry.metadata() {
|
||||||
|
if let Ok(modified) = meta.modified() {
|
||||||
|
let file_days = modified
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() / 86400)
|
||||||
|
.unwrap_or(today);
|
||||||
|
if file_days >= today {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let gz_path = path.with_extension("log.gz");
|
||||||
|
if let Ok(input) = std::fs::read(&path) {
|
||||||
|
let gz_file = match std::fs::File::create(&gz_path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let mut encoder =
|
||||||
|
flate2::write::GzEncoder::new(gz_file, flate2::Compression::default());
|
||||||
|
if encoder.write_all(&input).is_ok() && encoder.finish().is_ok() {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
} else {
|
||||||
|
let _ = std::fs::remove_file(&gz_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a Roblox log message forwarded from the plugin, extracting the real
|
||||||
|
/// log level and a cleaned message. Rojo plugin logs have `[Rojo-Trace] ` etc.
|
||||||
|
/// prefixes that encode the level; these are stripped and replaced with `[Rojo]`.
|
||||||
|
/// Game script output without a Rojo prefix falls back to the `MessageType` enum.
|
||||||
|
///
|
||||||
|
/// Returns `(log::Level, cleaned_message)`.
|
||||||
|
pub fn parse_roblox_log(message: &str, message_type: u64) -> (log::Level, String) {
|
||||||
|
if let Some(rest) = message.strip_prefix("[Rojo-Trace] ") {
|
||||||
|
(log::Level::Trace, format!("[Rojo] {}", rest))
|
||||||
|
} else if let Some(rest) = message.strip_prefix("[Rojo-Debug] ") {
|
||||||
|
(log::Level::Debug, format!("[Rojo] {}", rest))
|
||||||
|
} else if let Some(rest) = message.strip_prefix("[Rojo-Info] ") {
|
||||||
|
(log::Level::Info, format!("[Rojo] {}", rest))
|
||||||
|
} else if let Some(rest) = message.strip_prefix("[Rojo-Warn] ") {
|
||||||
|
(log::Level::Warn, format!("[Rojo] {}", rest))
|
||||||
|
} else {
|
||||||
|
let lvl = match message_type {
|
||||||
|
2 => log::Level::Warn,
|
||||||
|
3 => log::Level::Error,
|
||||||
|
_ => log::Level::Info,
|
||||||
|
};
|
||||||
|
(lvl, message.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight pre-read of a project file to extract the `fileLogLevel` config.
|
||||||
|
/// Accepts either a project file path or a directory (searches for default project).
|
||||||
|
/// Returns `None` if no project found or the field is absent (caller defaults
|
||||||
|
/// to trace). Returns `Some(None)` when `"none"` (disabled).
|
||||||
|
pub fn quick_read_file_log_level(
|
||||||
|
path: &Path,
|
||||||
|
) -> Option<Option<tracing::level_filters::LevelFilter>> {
|
||||||
|
use tracing::level_filters::LevelFilter;
|
||||||
|
|
||||||
|
let project_file = if path.is_file() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
let json5_path = path.join("default.project.json5");
|
||||||
|
if json5_path.is_file() {
|
||||||
|
json5_path
|
||||||
|
} else {
|
||||||
|
let json_path = path.join("default.project.json");
|
||||||
|
if json_path.is_file() {
|
||||||
|
json_path
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let contents = std::fs::read_to_string(&project_file).ok()?;
|
||||||
|
let val: serde_json::Value = json5::from_str(&contents).ok()?;
|
||||||
|
let level_str = val.get("fileLogLevel")?.as_str()?;
|
||||||
|
|
||||||
|
Some(match level_str.to_lowercase().as_str() {
|
||||||
|
"none" | "off" => None,
|
||||||
|
"error" => Some(LevelFilter::ERROR),
|
||||||
|
"warn" => Some(LevelFilter::WARN),
|
||||||
|
"info" => Some(LevelFilter::INFO),
|
||||||
|
"debug" => Some(LevelFilter::DEBUG),
|
||||||
|
"trace" => Some(LevelFilter::TRACE),
|
||||||
|
_ => Some(LevelFilter::TRACE),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Read;
|
||||||
|
use tracing::level_filters::LevelFilter;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_rojo_trace() {
|
||||||
|
let (level, msg) = parse_roblox_log("[Rojo-Trace] snapshot started", 0);
|
||||||
|
assert_eq!(level, log::Level::Trace);
|
||||||
|
assert_eq!(msg, "[Rojo] snapshot started");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_rojo_debug() {
|
||||||
|
let (level, msg) = parse_roblox_log("[Rojo-Debug] loading project", 0);
|
||||||
|
assert_eq!(level, log::Level::Debug);
|
||||||
|
assert_eq!(msg, "[Rojo] loading project");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_rojo_info() {
|
||||||
|
let (level, msg) = parse_roblox_log("[Rojo-Info] connected to server", 0);
|
||||||
|
assert_eq!(level, log::Level::Info);
|
||||||
|
assert_eq!(msg, "[Rojo] connected to server");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_rojo_warn() {
|
||||||
|
let (level, msg) = parse_roblox_log("[Rojo-Warn] something fishy", 0);
|
||||||
|
assert_eq!(level, log::Level::Warn);
|
||||||
|
assert_eq!(msg, "[Rojo] something fishy");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_game_print() {
|
||||||
|
let (level, msg) = parse_roblox_log("hello from game", 0);
|
||||||
|
assert_eq!(level, log::Level::Info);
|
||||||
|
assert_eq!(msg, "hello from game");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_game_warn() {
|
||||||
|
let (level, msg) = parse_roblox_log("something wrong", 2);
|
||||||
|
assert_eq!(level, log::Level::Warn);
|
||||||
|
assert_eq!(msg, "something wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_game_error() {
|
||||||
|
let (level, msg) = parse_roblox_log("crash!", 3);
|
||||||
|
assert_eq!(level, log::Level::Error);
|
||||||
|
assert_eq!(msg, "crash!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_message_info_type() {
|
||||||
|
let (level, msg) = parse_roblox_log("info message", 1);
|
||||||
|
assert_eq!(level, log::Level::Info);
|
||||||
|
assert_eq!(msg, "info message");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_unknown_message_type() {
|
||||||
|
let (level, msg) = parse_roblox_log("weird type", 99);
|
||||||
|
assert_eq!(level, log::Level::Info);
|
||||||
|
assert_eq!(msg, "weird type");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_partial_prefix_not_stripped() {
|
||||||
|
let (level, msg) = parse_roblox_log("[Rojo-Trace]no space", 0);
|
||||||
|
assert_eq!(level, log::Level::Info);
|
||||||
|
assert_eq!(msg, "[Rojo-Trace]no space");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_roblox_log_empty_message_after_prefix() {
|
||||||
|
let (level, msg) = parse_roblox_log("[Rojo-Info] ", 0);
|
||||||
|
assert_eq!(level, log::Level::Info);
|
||||||
|
assert_eq!(msg, "[Rojo] ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_trace() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json5"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "trace" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, Some(Some(LevelFilter::TRACE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_none() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json5"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "none" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, Some(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_off() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json5"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "off" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, Some(None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_warn() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json5"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "warn" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, Some(Some(LevelFilter::WARN)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_case_insensitive() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json5"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "DEBUG" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, Some(Some(LevelFilter::DEBUG)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_absent() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json5"),
|
||||||
|
r#"{ "name": "Test", "tree": {} }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_no_project() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_legacy_json() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "error" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, Some(Some(LevelFilter::ERROR)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_json5_preferred_over_json() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json5"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "info" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "error" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, Some(Some(LevelFilter::INFO)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_direct_file_path() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = dir.path().join("custom.project.json5");
|
||||||
|
std::fs::write(
|
||||||
|
&file_path,
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "debug" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(&file_path);
|
||||||
|
assert_eq!(result, Some(Some(LevelFilter::DEBUG)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quick_read_file_log_level_unknown_defaults_to_trace() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("default.project.json5"),
|
||||||
|
r#"{ "name": "Test", "tree": {}, "fileLogLevel": "banana" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let result = quick_read_file_log_level(dir.path());
|
||||||
|
assert_eq!(result, Some(Some(LevelFilter::TRACE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compress_old_logs_compresses_old_files() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let log_dir = dir.path();
|
||||||
|
|
||||||
|
let old_file = log_dir.join("atlas-serve.2020-01-01.log");
|
||||||
|
std::fs::write(&old_file, "old log content").unwrap();
|
||||||
|
|
||||||
|
let mtime =
|
||||||
|
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(86400 * 18262);
|
||||||
|
filetime::set_file_mtime(&old_file, filetime::FileTime::from_system_time(mtime))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
compress_old_logs(log_dir, "atlas-serve");
|
||||||
|
|
||||||
|
assert!(!old_file.exists(), "original .log file should be deleted");
|
||||||
|
let gz_file = log_dir.join("atlas-serve.2020-01-01.log.gz");
|
||||||
|
assert!(gz_file.exists(), ".log.gz file should be created");
|
||||||
|
|
||||||
|
let gz_data = std::fs::read(&gz_file).unwrap();
|
||||||
|
let mut decoder = flate2::read::GzDecoder::new(&gz_data[..]);
|
||||||
|
let mut decompressed = String::new();
|
||||||
|
decoder.read_to_string(&mut decompressed).unwrap();
|
||||||
|
assert_eq!(decompressed, "old log content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compress_old_logs_skips_other_commands() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let log_dir = dir.path();
|
||||||
|
|
||||||
|
let other_file = log_dir.join("atlas-build.2020-01-01.log");
|
||||||
|
std::fs::write(&other_file, "build log").unwrap();
|
||||||
|
|
||||||
|
let mtime =
|
||||||
|
std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(86400 * 18262);
|
||||||
|
filetime::set_file_mtime(&other_file, filetime::FileTime::from_system_time(mtime))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
compress_old_logs(log_dir, "atlas-serve");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
other_file.exists(),
|
||||||
|
"other command's log should NOT be compressed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compress_old_logs_skips_today() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let log_dir = dir.path();
|
||||||
|
|
||||||
|
let today_file = log_dir.join("atlas-serve.today.log");
|
||||||
|
std::fs::write(&today_file, "today's log").unwrap();
|
||||||
|
|
||||||
|
compress_old_logs(log_dir, "atlas-serve");
|
||||||
|
|
||||||
|
assert!(today_file.exists(), "today's log should NOT be compressed");
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/main.rs
36
src/main.rs
|
|
@ -3,15 +3,14 @@ use std::{env, panic, process};
|
||||||
use backtrace::Backtrace;
|
use backtrace::Backtrace;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use librojo::cli::Options;
|
use librojo::cli::{resolve_project_dir, Options};
|
||||||
|
use librojo::logging;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
#[cfg(feature = "profile-with-tracy")]
|
#[cfg(feature = "profile-with-tracy")]
|
||||||
profiling::tracy_client::Client::start();
|
profiling::tracy_client::Client::start();
|
||||||
|
|
||||||
panic::set_hook(Box::new(|panic_info| {
|
panic::set_hook(Box::new(|panic_info| {
|
||||||
// PanicInfo's payload is usually a &'static str or String.
|
|
||||||
// See: https://doc.rust-lang.org/beta/std/panic/struct.PanicInfo.html#method.payload
|
|
||||||
let message = match panic_info.payload().downcast_ref::<&str>() {
|
let message = match panic_info.payload().downcast_ref::<&str>() {
|
||||||
Some(&message) => message.to_string(),
|
Some(&message) => message.to_string(),
|
||||||
None => match panic_info.payload().downcast_ref::<String>() {
|
None => match panic_info.payload().downcast_ref::<String>() {
|
||||||
|
|
@ -37,9 +36,6 @@ fn main() {
|
||||||
log::error!("in file {} on line {}", location.file(), location.line());
|
log::error!("in file {} on line {}", location.file(), location.line());
|
||||||
}
|
}
|
||||||
|
|
||||||
// When using the backtrace crate, we need to check the RUST_BACKTRACE
|
|
||||||
// environment variable ourselves. Once we switch to the (currently
|
|
||||||
// unstable) std::backtrace module, we won't need to do this anymore.
|
|
||||||
let should_backtrace = env::var("RUST_BACKTRACE")
|
let should_backtrace = env::var("RUST_BACKTRACE")
|
||||||
.map(|var| var == "1")
|
.map(|var| var == "1")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
@ -57,22 +53,22 @@ fn main() {
|
||||||
|
|
||||||
let options = Options::parse();
|
let options = Options::parse();
|
||||||
|
|
||||||
let log_filter = match options.global.verbosity {
|
let project_dir = options.subcommand.project_path().map(resolve_project_dir);
|
||||||
0 => "info",
|
|
||||||
1 => "info,librojo=debug",
|
|
||||||
2 => "info,librojo=trace",
|
|
||||||
_ => "trace",
|
|
||||||
};
|
|
||||||
|
|
||||||
let log_env = env_logger::Env::default().default_filter_or(log_filter);
|
let file_log_level = project_dir
|
||||||
|
.as_deref()
|
||||||
|
.and_then(logging::quick_read_file_log_level)
|
||||||
|
.unwrap_or(Some(tracing::level_filters::LevelFilter::TRACE));
|
||||||
|
|
||||||
env_logger::Builder::from_env(log_env)
|
let command_name = format!("atlas-{}", options.subcommand.command_name());
|
||||||
.format_module_path(false)
|
|
||||||
.format_timestamp(None)
|
let _log_guard = logging::init_logging(
|
||||||
// Indent following lines equal to the log level label, like `[ERROR] `
|
options.global.verbosity,
|
||||||
.format_indent(Some(8))
|
options.global.color,
|
||||||
.write_style(options.global.color.into())
|
project_dir.as_deref(),
|
||||||
.init();
|
file_log_level,
|
||||||
|
&command_name,
|
||||||
|
);
|
||||||
|
|
||||||
if let Err(err) = options.run() {
|
if let Err(err) = options.run() {
|
||||||
log::error!("{:?}", err);
|
log::error!("{:?}", err);
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,12 @@ pub struct Project {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub ignore_hidden_services: Option<bool>,
|
pub ignore_hidden_services: Option<bool>,
|
||||||
|
|
||||||
|
/// Controls the log level for file-based logging to `.atlas/logs/`.
|
||||||
|
/// Values: "trace" (default), "debug", "info", "warn", "error", "none".
|
||||||
|
/// When "none", file logging is disabled entirely.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub file_log_level: Option<String>,
|
||||||
|
|
||||||
/// The path to the file that this project came from. Relative paths in the
|
/// The path to the file that this project came from. Relative paths in the
|
||||||
/// project should be considered relative to the parent of this field, also
|
/// project should be considered relative to the parent of this field, also
|
||||||
/// given by `Project::folder_location`.
|
/// given by `Project::folder_location`.
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_from_empty() {
|
fn add_from_empty() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut tree = RojoTree::new(InstanceSnapshot::new());
|
let mut tree = RojoTree::new(InstanceSnapshot::new());
|
||||||
|
|
||||||
|
|
@ -421,7 +421,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_existing() {
|
fn update_existing() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut tree = RojoTree::new(
|
let mut tree = RojoTree::new(
|
||||||
InstanceSnapshot::new()
|
InstanceSnapshot::new()
|
||||||
|
|
|
||||||
|
|
@ -985,7 +985,7 @@ mod test {
|
||||||
#[ignore = "Functionality moved to root snapshot middleware"]
|
#[ignore = "Functionality moved to root snapshot middleware"]
|
||||||
#[test]
|
#[test]
|
||||||
fn project_from_folder() {
|
fn project_from_folder() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1022,7 +1022,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_from_direct_file() {
|
fn project_from_direct_file() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1059,7 +1059,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_with_resolved_properties() {
|
fn project_with_resolved_properties() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1098,7 +1098,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_with_unresolved_properties() {
|
fn project_with_unresolved_properties() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1135,7 +1135,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_with_children() {
|
fn project_with_children() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1173,7 +1173,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_with_path_to_txt() {
|
fn project_with_path_to_txt() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1213,7 +1213,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_with_path_to_project() {
|
fn project_with_path_to_project() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1265,7 +1265,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_with_path_to_project_with_children() {
|
fn project_with_path_to_project_with_children() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1324,7 +1324,7 @@ mod test {
|
||||||
/// precedence.
|
/// precedence.
|
||||||
#[test]
|
#[test]
|
||||||
fn project_path_property_overrides() {
|
fn project_path_property_overrides() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
@ -1382,7 +1382,7 @@ mod test {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_name_project() {
|
fn no_name_project() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut imfs = InMemoryFs::new();
|
let mut imfs = InMemoryFs::new();
|
||||||
imfs.load_snapshot(
|
imfs.load_snapshot(
|
||||||
|
|
|
||||||
|
|
@ -3878,7 +3878,15 @@ async fn handle_mcp_stream_connection(
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
if let Ok(mut result_value) = serde_json::from_str::<serde_json::Value>(&text) {
|
if let Ok(mut result_value) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
// Enrich based on the command type that produced this result.
|
if result_value.get("type").and_then(|t| t.as_str()) == Some("roblox_log") {
|
||||||
|
if let Some(message) = result_value.get("message").and_then(|m| m.as_str()) {
|
||||||
|
let message_type = result_value.get("level").and_then(|l| l.as_u64()).unwrap_or(0);
|
||||||
|
let (level, formatted) = crate::logging::parse_roblox_log(message, message_type);
|
||||||
|
log::log!(target: "roblox", level, "{}", formatted);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ctx) = pending_get_script_context.take() {
|
if let Some(ctx) = pending_get_script_context.take() {
|
||||||
enrich_get_script_result(&mut result_value, &ctx);
|
enrich_get_script_result(&mut result_value, &ctx);
|
||||||
} else if let Some(changes) = result_value.get_mut("changes") {
|
} else if let Some(changes) = result_value.get_mut("changes") {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ use crate::rojo_test::io_util::{
|
||||||
/// The passed in callback is where the actual test body should go. Setup and
|
/// The passed in callback is where the actual test body should go. Setup and
|
||||||
/// cleanup happens automatically.
|
/// cleanup happens automatically.
|
||||||
pub fn run_serve_test(test_name: &str, callback: impl FnOnce(TestServeSession, RedactionMap)) {
|
pub fn run_serve_test(test_name: &str, callback: impl FnOnce(TestServeSession, RedactionMap)) {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut redactions = RedactionMap::default();
|
let mut redactions = RedactionMap::default();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ pub fn run_syncback_test_incremental(name: &str, callback: impl FnOnce(&Path)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_syncback_test_impl(name: &str, incremental: bool, callback: impl FnOnce(&Path)) {
|
fn run_syncback_test_impl(name: &str, incremental: bool, callback: impl FnOnce(&Path)) {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
// let working_dir = get_working_dir_path();
|
// let working_dir = get_working_dir_path();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ macro_rules! gen_build_tests {
|
||||||
paste::item! {
|
paste::item! {
|
||||||
#[test]
|
#[test]
|
||||||
fn [<build_ $test_name>]() {
|
fn [<build_ $test_name>]() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
run_build_test(stringify!($test_name));
|
run_build_test(stringify!($test_name));
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +132,7 @@ fn snapshot_debug(snap: &librojo::InstanceSnapshot) -> String {
|
||||||
fn parallel_snapshot_determinism() {
|
fn parallel_snapshot_determinism() {
|
||||||
use crate::rojo_test::io_util::SERVE_TESTS_PATH;
|
use crate::rojo_test::io_util::SERVE_TESTS_PATH;
|
||||||
|
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let fixture_path = Path::new(SERVE_TESTS_PATH).join("connected_scripts");
|
let fixture_path = Path::new(SERVE_TESTS_PATH).join("connected_scripts");
|
||||||
let vfs = memofs::Vfs::new_default();
|
let vfs = memofs::Vfs::new_default();
|
||||||
|
|
@ -158,7 +158,7 @@ fn parallel_snapshot_determinism() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parallel_snapshot_determinism_stress() {
|
fn parallel_snapshot_determinism_stress() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let dir = tempdir().expect("couldn't create temp dir");
|
let dir = tempdir().expect("couldn't create temp dir");
|
||||||
let root = dir.path();
|
let root = dir.path();
|
||||||
|
|
@ -206,7 +206,7 @@ fn parallel_snapshot_determinism_stress() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parallel_snapshot_with_prefetch_cache() {
|
fn parallel_snapshot_with_prefetch_cache() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let dir = tempdir().expect("couldn't create temp dir");
|
let dir = tempdir().expect("couldn't create temp dir");
|
||||||
let root = dir.path();
|
let root = dir.path();
|
||||||
|
|
@ -274,7 +274,7 @@ fn parallel_snapshot_with_prefetch_cache() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parallel_snapshot_error_propagation() {
|
fn parallel_snapshot_error_propagation() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let dir = tempdir().expect("couldn't create temp dir");
|
let dir = tempdir().expect("couldn't create temp dir");
|
||||||
let root = dir.path();
|
let root = dir.path();
|
||||||
|
|
@ -305,7 +305,7 @@ fn parallel_snapshot_error_propagation() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn overlapping_path_roots_no_duplicate_children() {
|
fn overlapping_path_roots_no_duplicate_children() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let dir = tempdir().expect("couldn't create temp dir");
|
let dir = tempdir().expect("couldn't create temp dir");
|
||||||
let root = dir.path();
|
let root = dir.path();
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ fn run_syncback(project_path: &Path, input_path: &Path, incremental: bool) -> bo
|
||||||
/// that get replaced with standard extensions (.luau, .txt) in clean mode.
|
/// that get replaced with standard extensions (.luau, .txt) in clean mode.
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_mode_removes_orphaned_files() {
|
fn clean_mode_removes_orphaned_files() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
// Use the sync_rules test case - it has files with custom extensions
|
// Use the sync_rules test case - it has files with custom extensions
|
||||||
// that become orphaned when replaced with standard extensions
|
// that become orphaned when replaced with standard extensions
|
||||||
|
|
@ -105,7 +105,7 @@ fn clean_mode_removes_orphaned_files() {
|
||||||
/// Test that clean mode doesn't remove files that are being written to
|
/// Test that clean mode doesn't remove files that are being written to
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_mode_preserves_written_files() {
|
fn clean_mode_preserves_written_files() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
// Use the child_but_not test case
|
// Use the child_but_not test case
|
||||||
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
||||||
|
|
@ -156,7 +156,7 @@ fn clean_mode_preserves_written_files() {
|
||||||
/// Test that incremental mode preserves existing file structure
|
/// Test that incremental mode preserves existing file structure
|
||||||
#[test]
|
#[test]
|
||||||
fn incremental_mode_preserves_structure() {
|
fn incremental_mode_preserves_structure() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
// Use the sync_rules test case - it has custom extensions
|
// Use the sync_rules test case - it has custom extensions
|
||||||
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
||||||
|
|
@ -201,7 +201,7 @@ fn incremental_mode_preserves_structure() {
|
||||||
/// Test that clean mode uses fresh file extensions (not preserving old ones)
|
/// Test that clean mode uses fresh file extensions (not preserving old ones)
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_mode_uses_fresh_extensions() {
|
fn clean_mode_uses_fresh_extensions() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
// Use the sync_rules test case - it has custom extensions
|
// Use the sync_rules test case - it has custom extensions
|
||||||
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
||||||
|
|
@ -250,7 +250,7 @@ fn clean_mode_uses_fresh_extensions() {
|
||||||
/// Test that clean mode doesn't remove the project file
|
/// Test that clean mode doesn't remove the project file
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_mode_preserves_project_file() {
|
fn clean_mode_preserves_project_file() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
||||||
.join("ignore_trees_adding")
|
.join("ignore_trees_adding")
|
||||||
|
|
@ -287,7 +287,7 @@ fn clean_mode_preserves_project_file() {
|
||||||
/// Test that running clean mode twice produces identical results
|
/// Test that running clean mode twice produces identical results
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_mode_is_idempotent() {
|
fn clean_mode_is_idempotent() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
||||||
.join("child_but_not")
|
.join("child_but_not")
|
||||||
|
|
@ -334,7 +334,7 @@ fn clean_mode_is_idempotent() {
|
||||||
/// .cursor/ should be left untouched by the orphan scanner.
|
/// .cursor/ should be left untouched by the orphan scanner.
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_mode_preserves_hidden_directories() {
|
fn clean_mode_preserves_hidden_directories() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
let source_path = Path::new(SYNCBACK_TESTS_PATH)
|
||||||
.join("sync_rules")
|
.join("sync_rules")
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ use crate::rojo_test::roundtrip_util::{
|
||||||
/// 5. Syncback to DIR_B (completely fresh)
|
/// 5. Syncback to DIR_B (completely fresh)
|
||||||
/// 6. Assert DIR_A == DIR_B
|
/// 6. Assert DIR_A == DIR_B
|
||||||
fn clean_equals_fresh(test_name: &str, mutations: &[Mutation]) {
|
fn clean_equals_fresh(test_name: &str, mutations: &[Mutation]) {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let project_path = Path::new(BUILD_TESTS_PATH).join(test_name);
|
let project_path = Path::new(BUILD_TESTS_PATH).join(test_name);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ fn face_distribution(dom: &WeakDom, refs: &[Ref]) -> HashMap<Option<u32>, usize>
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn syncback_matching_ufowave_textures() {
|
fn syncback_matching_ufowave_textures() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let input_dom = load_input_rbxm();
|
let input_dom = load_input_rbxm();
|
||||||
let expected_dom = load_expected_dom();
|
let expected_dom = load_expected_dom();
|
||||||
|
|
@ -245,7 +245,7 @@ fn syncback_matching_ufowave_textures() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forward_matching_ufowave_textures() {
|
fn forward_matching_ufowave_textures() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
// For forward sync: snapshots come from filesystem (sparse),
|
// For forward sync: snapshots come from filesystem (sparse),
|
||||||
// tree comes from Studio (full properties, simulated by rbxm).
|
// tree comes from Studio (full properties, simulated by rbxm).
|
||||||
|
|
@ -531,7 +531,7 @@ fn parity_forward_vs_syncback() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trip_matching_identity() {
|
fn round_trip_matching_identity() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
// Load the input rbxm (simulates Studio with full properties)
|
// Load the input rbxm (simulates Studio with full properties)
|
||||||
let input_dom = load_input_rbxm();
|
let input_dom = load_input_rbxm();
|
||||||
|
|
|
||||||
|
|
@ -976,7 +976,7 @@ fn ref_path_nonexistent_target_no_crash() {
|
||||||
/// unrelated directories with potentially malformed files).
|
/// unrelated directories with potentially malformed files).
|
||||||
#[test]
|
#[test]
|
||||||
fn non_default_project_file_tree_validation() {
|
fn non_default_project_file_tree_validation() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let mut session =
|
let mut session =
|
||||||
TestServeSession::new_with_project_file("non_default_project", "named.project.json5");
|
TestServeSession::new_with_project_file("non_default_project", "named.project.json5");
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ macro_rules! roundtrip_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_roundtrip_test(build_test_name: &str) {
|
fn run_roundtrip_test(build_test_name: &str) {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
let project_path = Path::new(BUILD_TESTS_PATH).join(build_test_name);
|
let project_path = Path::new(BUILD_TESTS_PATH).join(build_test_name);
|
||||||
|
|
||||||
|
|
@ -360,7 +360,7 @@ fn rbxmx_ref() {
|
||||||
/// This validates that syncback output is stable and self-consistent.
|
/// This validates that syncback output is stable and self-consistent.
|
||||||
#[test]
|
#[test]
|
||||||
fn syncback_idempotency() {
|
fn syncback_idempotency() {
|
||||||
let _ = env_logger::try_init();
|
let _ = tracing_subscriber::fmt::try_init();
|
||||||
|
|
||||||
// Use dedup_suffix_with_meta as it exercises slugified names + meta
|
// Use dedup_suffix_with_meta as it exercises slugified names + meta
|
||||||
let project_path = Path::new(BUILD_TESTS_PATH).join("dedup_suffix_with_meta");
|
let project_path = Path::new(BUILD_TESTS_PATH).join("dedup_suffix_with_meta");
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 08656cd5f7ca50bc3c93ceb9fbd604e3d7f29c6d
|
Subproject commit 29d23b25f07c73814d7bdeb6f65eef5fafd02b82
|
||||||
Loading…
Add table
Add a link
Reference in a new issue