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:
Joe 2026-03-01 13:16:00 -06:00
parent b5583c4c1b
commit 3fd50df0b2
29 changed files with 1463 additions and 79 deletions

View 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).

View 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

View file

@ -27,6 +27,7 @@ alwaysApply: true
| `syncbackRules` | SyncbackRules | No | Syncback configuration |
| `syncScriptsOnly` | bool | No | Only sync script instances (default: false) |
| `ignoreHiddenServices` | bool | No | Ignore internal services (default: true) |
| `fileLogLevel` | string | No | File logging level: "trace" (default), "debug", "info", "warn", "error", "none" |
```json5
{
@ -43,7 +44,8 @@ alwaysApply: true
"syncRules": [/* SyncRule */],
"syncbackRules": { /* SyncbackRules */ },
"syncScriptsOnly": false,
"ignoreHiddenServices": true
"ignoreHiddenServices": true,
"fileLogLevel": "trace"
}
```

3
.gitignore vendored
View file

@ -29,3 +29,6 @@
# Environment secrets
.env
# Atlas log directory
/.atlas/

32
Cargo.lock generated
View file

@ -201,7 +201,8 @@ dependencies = [
"csv",
"data-encoding",
"embed-resource",
"env_logger 0.11.9",
"filetime",
"flate2",
"fs-err",
"futures",
"gix",
@ -252,6 +253,10 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"toml",
"tracing",
"tracing-appender",
"tracing-log",
"tracing-subscriber",
"uuid",
"walkdir",
"winreg 0.55.0",
@ -4869,9 +4874,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"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]]
name = "tracing-core"
version = "0.1.36"
@ -4906,6 +4935,7 @@ dependencies = [
"sharded-slab",
"smallvec",
"thread_local",
"time 0.3.47",
"tracing",
"tracing-core",
"tracing-log",

View file

@ -60,7 +60,7 @@ backtrace = "0.3.76"
bincode = { version = "2", features = ["serde"] }
crossbeam-channel = "0.5.15"
csv = "1.4.0"
env_logger = "0.11"
flate2 = "1"
fs-err = "3"
futures = "0.3.32"
globset = "0.4.18"
@ -99,6 +99,10 @@ toml = "0.9"
termcolor = "1.4.1"
thiserror = "2"
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"] }
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
@ -128,6 +132,7 @@ maplit = "1.0.2"
semver = "1.0.27"
[dev-dependencies]
filetime = "0.2"
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
criterion = "0.8"

View file

@ -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?
[mcp]
* sync
* DONE: sync
- add modes:
- standard (default): what we currently have where if there are changes it waits for the user to act
- 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
- 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
* get_script PATH [from draft?]
* DONE: get_script PATH [from draft?]
- 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)
- 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
- 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.
* 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
- 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 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.
* 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
- 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.
@ -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
[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
* Will the existing logging infra take care of this?
* 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.
- 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.
* It might be worth actually having a log entry per-sync?
[sync]
right now sync is running REALLY slow

View file

@ -3,3 +3,6 @@
/*.rbxl.lock
sourcemap.json
# atlas
/.atlas/

View file

@ -17,3 +17,4 @@
# atlas
/atlas.exe
/.atlas/

View file

@ -3,3 +3,6 @@
/{project_name}.rbxm
sourcemap.json
# atlas
/.atlas/

View file

@ -6,6 +6,7 @@ local Packages = Rojo.Packages
local Log = require(Packages.Log)
local Config = require(script.Parent.Config)
local ConsoleOutput = require(script.Parent.McpTools.Utils.ConsoleOutput)
local strict = require(script.Parent.strict)
local RECONNECT_INTERVAL = 5
@ -62,6 +63,7 @@ function McpStream:stop()
end
function McpStream:_disconnect()
ConsoleOutput.onLogMessage = nil
if self._wsClient then
pcall(function()
self._wsClient:Destroy()
@ -95,6 +97,19 @@ function McpStream:_tryConnect()
wsClient:Send(greeting)
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 closed, errored, received
@ -195,6 +210,7 @@ function McpStream:_tryConnect()
closed = wsClient.Closed:Connect(function()
done = true
ConsoleOutput.onLogMessage = nil
closed:Disconnect()
errored:Disconnect()
received:Disconnect()
@ -203,6 +219,7 @@ function McpStream:_tryConnect()
errored = wsClient.Error:Connect(function(_code, _msg)
done = true
ConsoleOutput.onLogMessage = nil
closed:Disconnect()
errored:Disconnect()
received:Disconnect()

View file

@ -6,10 +6,11 @@ local MAX_OUTPUT = 10000
local ConsoleOutput = {
outputMessage = "",
onLogMessage = nil :: ((message: string, messageType: Enum.MessageType) -> ())?,
}
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 == 0 then
ConsoleOutput.outputMessage = message
@ -17,6 +18,11 @@ function ConsoleOutput.startListener(): RBXScriptConnection
ConsoleOutput.outputMessage = ConsoleOutput.outputMessage .. "\n" .. message
end
end
local callback = ConsoleOutput.onLogMessage
if callback then
callback(message, messageType)
end
end)
return conn
end

View file

@ -14,7 +14,12 @@ mod studio;
mod syncback;
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 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)]
#[error("Invalid color choice '{attempted}'. Valid values are: auto, always, never")]
pub struct ColorChoiceParseError {
@ -146,10 +141,62 @@ pub enum Subcommand {
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() {
Cow::Borrowed(path)
} else {
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()
}
}

View file

@ -11,6 +11,7 @@ mod change_processor;
pub mod git;
mod glob;
mod json;
pub mod logging;
mod lua_ast;
mod message_queue;
mod multimap;

521
src/logging.rs Normal file
View 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");
}
}

View file

@ -3,15 +3,14 @@ use std::{env, panic, process};
use backtrace::Backtrace;
use clap::Parser;
use librojo::cli::Options;
use librojo::cli::{resolve_project_dir, Options};
use librojo::logging;
fn main() {
#[cfg(feature = "profile-with-tracy")]
profiling::tracy_client::Client::start();
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>() {
Some(&message) => message.to_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());
}
// 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")
.map(|var| var == "1")
.unwrap_or(false);
@ -57,22 +53,22 @@ fn main() {
let options = Options::parse();
let log_filter = match options.global.verbosity {
0 => "info",
1 => "info,librojo=debug",
2 => "info,librojo=trace",
_ => "trace",
};
let project_dir = options.subcommand.project_path().map(resolve_project_dir);
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)
.format_module_path(false)
.format_timestamp(None)
// Indent following lines equal to the log level label, like `[ERROR] `
.format_indent(Some(8))
.write_style(options.global.color.into())
.init();
let command_name = format!("atlas-{}", options.subcommand.command_name());
let _log_guard = logging::init_logging(
options.global.verbosity,
options.global.color,
project_dir.as_deref(),
file_log_level,
&command_name,
);
if let Err(err) = options.run() {
log::error!("{:?}", err);

View file

@ -142,6 +142,12 @@ pub struct Project {
#[serde(skip_serializing_if = "Option::is_none")]
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
/// project should be considered relative to the parent of this field, also
/// given by `Project::folder_location`.

View file

@ -383,7 +383,7 @@ mod test {
#[test]
fn add_from_empty() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut tree = RojoTree::new(InstanceSnapshot::new());
@ -421,7 +421,7 @@ mod test {
#[test]
fn update_existing() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut tree = RojoTree::new(
InstanceSnapshot::new()

View file

@ -985,7 +985,7 @@ mod test {
#[ignore = "Functionality moved to root snapshot middleware"]
#[test]
fn project_from_folder() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1022,7 +1022,7 @@ mod test {
#[test]
fn project_from_direct_file() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1059,7 +1059,7 @@ mod test {
#[test]
fn project_with_resolved_properties() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1098,7 +1098,7 @@ mod test {
#[test]
fn project_with_unresolved_properties() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1135,7 +1135,7 @@ mod test {
#[test]
fn project_with_children() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1173,7 +1173,7 @@ mod test {
#[test]
fn project_with_path_to_txt() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1213,7 +1213,7 @@ mod test {
#[test]
fn project_with_path_to_project() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1265,7 +1265,7 @@ mod test {
#[test]
fn project_with_path_to_project_with_children() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1324,7 +1324,7 @@ mod test {
/// precedence.
#[test]
fn project_path_property_overrides() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
@ -1382,7 +1382,7 @@ mod test {
#[test]
fn no_name_project() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(

View file

@ -3878,7 +3878,15 @@ async fn handle_mcp_stream_connection(
match msg {
Some(Ok(Message::Text(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() {
enrich_get_script_result(&mut result_value, &ctx);
} else if let Some(changes) = result_value.get_mut("changes") {

View file

@ -33,7 +33,7 @@ use crate::rojo_test::io_util::{
/// The passed in callback is where the actual test body should go. Setup and
/// cleanup happens automatically.
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();

View file

@ -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)) {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
// let working_dir = get_working_dir_path();

View file

@ -11,7 +11,7 @@ macro_rules! gen_build_tests {
paste::item! {
#[test]
fn [<build_ $test_name>]() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
run_build_test(stringify!($test_name));
}
@ -132,7 +132,7 @@ fn snapshot_debug(snap: &librojo::InstanceSnapshot) -> String {
fn parallel_snapshot_determinism() {
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 vfs = memofs::Vfs::new_default();
@ -158,7 +158,7 @@ fn parallel_snapshot_determinism() {
#[test]
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 root = dir.path();
@ -206,7 +206,7 @@ fn parallel_snapshot_determinism_stress() {
#[test]
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 root = dir.path();
@ -274,7 +274,7 @@ fn parallel_snapshot_with_prefetch_cache() {
#[test]
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 root = dir.path();
@ -305,7 +305,7 @@ fn parallel_snapshot_error_propagation() {
#[test]
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 root = dir.path();

View file

@ -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.
#[test]
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
// 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]
fn clean_mode_preserves_written_files() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
// Use the child_but_not test case
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]
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
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]
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
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]
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)
.join("ignore_trees_adding")
@ -287,7 +287,7 @@ fn clean_mode_preserves_project_file() {
/// Test that running clean mode twice produces identical results
#[test]
fn clean_mode_is_idempotent() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let source_path = Path::new(SYNCBACK_TESTS_PATH)
.join("child_but_not")
@ -334,7 +334,7 @@ fn clean_mode_is_idempotent() {
/// .cursor/ should be left untouched by the orphan scanner.
#[test]
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)
.join("sync_rules")

View file

@ -27,7 +27,7 @@ use crate::rojo_test::roundtrip_util::{
/// 5. Syncback to DIR_B (completely fresh)
/// 6. Assert DIR_A == DIR_B
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);

View file

@ -164,7 +164,7 @@ fn face_distribution(dom: &WeakDom, refs: &[Ref]) -> HashMap<Option<u32>, usize>
#[test]
fn syncback_matching_ufowave_textures() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let input_dom = load_input_rbxm();
let expected_dom = load_expected_dom();
@ -245,7 +245,7 @@ fn syncback_matching_ufowave_textures() {
#[test]
fn forward_matching_ufowave_textures() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
// For forward sync: snapshots come from filesystem (sparse),
// tree comes from Studio (full properties, simulated by rbxm).
@ -531,7 +531,7 @@ fn parity_forward_vs_syncback() {
#[test]
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)
let input_dom = load_input_rbxm();

View file

@ -976,7 +976,7 @@ fn ref_path_nonexistent_target_no_crash() {
/// unrelated directories with potentially malformed files).
#[test]
fn non_default_project_file_tree_validation() {
let _ = env_logger::try_init();
let _ = tracing_subscriber::fmt::try_init();
let mut session =
TestServeSession::new_with_project_file("non_default_project", "named.project.json5");

View file

@ -37,7 +37,7 @@ macro_rules! roundtrip_tests {
}
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);
@ -360,7 +360,7 @@ fn rbxmx_ref() {
/// This validates that syncback output is stable and self-consistent.
#[test]
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
let project_path = Path::new(BUILD_TESTS_PATH).join("dedup_suffix_with_meta");

@ -1 +1 @@
Subproject commit 08656cd5f7ca50bc3c93ceb9fbd604e3d7f29c6d
Subproject commit 29d23b25f07c73814d7bdeb6f65eef5fafd02b82